Skip to content

Commit 68e4273

Browse files
bwlclaude
andcommitted
Add 'forest node recent' command for activity timeline
Implements a new command to view recent node activity with rich filtering options. Features: - Timeline view combining created and updated events - Default limit of 20 activities (configurable via --limit) - Filter by activity type: --created or --updated - Time-based filtering: --since 24h/7d/4w/1m/1y - JSON output mode for programmatic use - Automatic chunk node filtering (shows only root/standalone nodes) - Edge count display for each node - Table format: timestamp, type, short ID, title, tags, body preview Implementation: - Added NodeRecentFlags type definition - Implemented parseDuration() helper for duration parsing (h/d/w/m/y) - Implemented runNodeRecent() with timeline merging logic - Registered command with Clerc framework - Added TLDR metadata for agent discovery Examples: - forest node recent - forest node recent --limit 10 - forest node recent --since 24h --json - forest node recent --created - forest node recent --updated --limit 5 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 94b07ad commit 68e4273

2 files changed

Lines changed: 203 additions & 2 deletions

File tree

src/cli/commands/node.ts

Lines changed: 179 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { NodeRecord, deleteNode, updateNode, listNodes } from '../../lib/db';
1+
import { NodeRecord, deleteNode, updateNode, listNodes, listEdges } from '../../lib/db';
22
import { EdgeRecord, EdgeStatus, insertOrUpdateEdge } from '../../lib/db';
33
import { extractTags, tokenize } from '../../lib/text';
44
import { computeEmbeddingForNode } from '../../lib/embeddings';
@@ -18,7 +18,7 @@ import { COMMAND_TLDR, emitTldrAndExit } from '../tldr';
1818
import { synthesizeNodesCore, SynthesisModel, ReasoningEffort, TextVerbosity } from '../../core/synthesize';
1919
import { createNodeCore } from '../../core/nodes';
2020
import { importDocumentCore } from '../../core/import';
21-
import { reconstructDocument } from '../../lib/reconstruction';
21+
import { reconstructDocument, filterOutChunks } from '../../lib/reconstruction';
2222

2323
import type { HandlerContext } from '@clerc/core';
2424

@@ -79,6 +79,15 @@ type NodeImportFlags = {
7979
tldr?: string;
8080
};
8181

82+
type NodeRecentFlags = {
83+
limit?: number;
84+
json?: boolean;
85+
created?: boolean;
86+
updated?: boolean;
87+
since?: string;
88+
tldr?: string;
89+
};
90+
8291
export function registerNodeCommands(cli: ClercInstance, clerc: ClercModule) {
8392
const readCommand = clerc.defineCommand(
8493
{
@@ -365,6 +374,53 @@ export function registerNodeCommands(cli: ClercInstance, clerc: ClercModule) {
365374
);
366375
cli.command(importCommand);
367376

377+
const recentCommand = clerc.defineCommand(
378+
{
379+
name: 'node recent',
380+
description: 'Show recent node activity (created/updated)',
381+
flags: {
382+
limit: {
383+
type: Number,
384+
description: 'Maximum number of activities to show',
385+
default: 20,
386+
},
387+
json: {
388+
type: Boolean,
389+
description: 'Emit JSON output',
390+
},
391+
created: {
392+
type: Boolean,
393+
description: 'Show only created activities',
394+
},
395+
updated: {
396+
type: Boolean,
397+
description: 'Show only updated activities',
398+
},
399+
since: {
400+
type: String,
401+
description: 'Show activities since duration (e.g., 24h, 7d, 4w, 1m, 1y)',
402+
},
403+
tldr: {
404+
type: String,
405+
description: 'Output command metadata for agent consumption (--tldr or --tldr=json)',
406+
},
407+
},
408+
},
409+
async ({ flags }: { flags: NodeRecentFlags }) => {
410+
try {
411+
// Handle TLDR request first
412+
if (flags.tldr !== undefined) {
413+
const jsonMode = flags.tldr === 'json';
414+
emitTldrAndExit(COMMAND_TLDR['node.recent'], jsonMode);
415+
}
416+
await runNodeRecent(flags);
417+
} catch (error) {
418+
handleError(error);
419+
}
420+
},
421+
);
422+
cli.command(recentCommand);
423+
368424
const baseCommand = clerc.defineCommand(
369425
{
370426
name: 'node',
@@ -376,6 +432,7 @@ export function registerNodeCommands(cli: ClercInstance, clerc: ClercModule) {
376432
' edit Edit an existing note and optionally rescore links',
377433
' delete Delete a note and its edges',
378434
' link Manually create an edge between two notes',
435+
' recent Show recent node activity (created/updated)',
379436
' import Import a large markdown document by chunking it',
380437
' synthesize Use GPT-5 to synthesize a new article from 2+ notes',
381438
'',
@@ -903,6 +960,126 @@ async function runNodeImport(flags: NodeImportFlags) {
903960
console.log('');
904961
}
905962

963+
async function runNodeRecent(flags: NodeRecentFlags) {
964+
const limit =
965+
typeof flags.limit === 'number' && !Number.isNaN(flags.limit) && flags.limit > 0 ? flags.limit : 20;
966+
967+
// Get all nodes and filter out chunks
968+
const allNodes = await listNodes();
969+
const nodes = filterOutChunks(allNodes);
970+
971+
// Get all accepted edges for counting
972+
const acceptedEdges = await listEdges('accepted');
973+
974+
// Build edge count map
975+
const edgeCountMap = new Map<string, number>();
976+
for (const edge of acceptedEdges) {
977+
edgeCountMap.set(edge.sourceId, (edgeCountMap.get(edge.sourceId) || 0) + 1);
978+
edgeCountMap.set(edge.targetId, (edgeCountMap.get(edge.targetId) || 0) + 1);
979+
}
980+
981+
// Build timeline: each node generates activity records
982+
type Activity = {
983+
type: 'created' | 'updated';
984+
timestamp: string;
985+
node: NodeRecord;
986+
};
987+
988+
const activities: Activity[] = [];
989+
for (const node of nodes) {
990+
// Add created activity unless user specified --updated only
991+
if (!flags.updated) {
992+
activities.push({ type: 'created', timestamp: node.createdAt, node });
993+
}
994+
995+
// Add updated activity if it's different from created, unless user specified --created only
996+
if (!flags.created && node.createdAt !== node.updatedAt) {
997+
activities.push({ type: 'updated', timestamp: node.updatedAt, node });
998+
}
999+
}
1000+
1001+
// Apply --since filter if provided
1002+
let filteredActivities = activities;
1003+
if (flags.since) {
1004+
const sinceMs = parseDuration(flags.since);
1005+
filteredActivities = activities.filter((activity) => {
1006+
const activityMs = new Date(activity.timestamp).getTime();
1007+
return activityMs >= sinceMs;
1008+
});
1009+
}
1010+
1011+
// Sort by timestamp descending (most recent first)
1012+
filteredActivities.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
1013+
1014+
// Take limit
1015+
const recentActivities = filteredActivities.slice(0, limit);
1016+
1017+
if (recentActivities.length === 0) {
1018+
console.log('No recent node activity found.');
1019+
return;
1020+
}
1021+
1022+
// Emit JSON or table output
1023+
if (flags.json) {
1024+
console.log(
1025+
JSON.stringify(
1026+
recentActivities.map((activity) => ({
1027+
type: activity.type,
1028+
timestamp: activity.timestamp,
1029+
node: {
1030+
id: activity.node.id,
1031+
title: activity.node.title,
1032+
tags: activity.node.tags,
1033+
bodyPreview: activity.node.body.slice(0, 100),
1034+
edges: edgeCountMap.get(activity.node.id) || 0,
1035+
},
1036+
})),
1037+
null,
1038+
2
1039+
)
1040+
);
1041+
return;
1042+
}
1043+
1044+
// Table output
1045+
console.log(`\nRecent node activity (${recentActivities.length}):\n`);
1046+
1047+
for (const activity of recentActivities) {
1048+
const date = new Date(activity.timestamp).toISOString().replace('T', ' ').slice(0, 19);
1049+
const typeLabel = activity.type.padEnd(7);
1050+
const shortId = formatId(activity.node.id);
1051+
const title = activity.node.title.length > 40 ? activity.node.title.slice(0, 37) + '...' : activity.node.title;
1052+
const tagsStr = activity.node.tags.length > 0 ? `[${activity.node.tags.slice(0, 3).join(', ')}]` : '';
1053+
const bodyPreview = activity.node.body.replace(/\n/g, ' ').slice(0, 100);
1054+
const edgeCount = edgeCountMap.get(activity.node.id) || 0;
1055+
const edgesLabel = `${edgeCount} edge${edgeCount === 1 ? '' : 's'}`;
1056+
1057+
console.log(`${date} ${typeLabel} ${shortId} ${title.padEnd(42)} ${tagsStr}`);
1058+
console.log(`${' '.repeat(42)}${bodyPreview} ${edgesLabel}`);
1059+
console.log('');
1060+
}
1061+
}
1062+
1063+
function parseDuration(duration: string): number {
1064+
const match = duration.match(/^(\d+)([hdwmy])$/);
1065+
if (!match) {
1066+
throw new Error(`Invalid duration format: "${duration}". Use format like: 24h, 7d, 4w, 1m, 1y`);
1067+
}
1068+
1069+
const value = Number(match[1]);
1070+
const unit = match[2];
1071+
1072+
const msPerUnit: Record<string, number> = {
1073+
h: 3600000, // 1 hour
1074+
d: 86400000, // 1 day
1075+
w: 604800000, // 1 week
1076+
m: 2592000000, // 30 days
1077+
y: 31536000000, // 365 days
1078+
};
1079+
1080+
return Date.now() - (value * msPerUnit[unit]);
1081+
}
1082+
9061083
function validateChunkStrategy(strategyFlag: string | undefined): 'headers' | 'size' | 'hybrid' {
9071084
if (!strategyFlag) return 'headers';
9081085
const normalized = strategyFlag.toLowerCase();

src/cli/tldr.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,7 @@ export function getGlobalTldr(version: string): GlobalTldr {
151151
'node.edit',
152152
'node.delete',
153153
'node.link',
154+
'node.recent',
154155
'node.import',
155156
'node.synthesize',
156157
'node',
@@ -437,6 +438,29 @@ export const COMMAND_TLDR: Record<string, CommandTldr> = {
437438
related: ['edges.accept', 'edges.explain', 'node.read'],
438439
},
439440

441+
'node.recent': {
442+
cmd: 'node.recent',
443+
purpose: 'Show recent node activity (created/updated)',
444+
inputs: ['none'],
445+
outputs: ['timeline of node activity with timestamps,tags,body previews,edge counts'],
446+
sideEffects: 'none (read-only)',
447+
flags: [
448+
{ name: 'limit', type: 'INT', default: 20, desc: 'max activities to show' },
449+
{ name: 'json', type: 'BOOL', default: false, desc: 'emit JSON output' },
450+
{ name: 'created', type: 'BOOL', default: false, desc: 'show only created activities' },
451+
{ name: 'updated', type: 'BOOL', default: false, desc: 'show only updated activities' },
452+
{ name: 'since', type: 'STR', desc: 'show activities since duration (e.g. 24h, 7d)' },
453+
],
454+
examples: [
455+
'forest node recent',
456+
'forest node recent --limit 10',
457+
'forest node recent --since 24h',
458+
'forest node recent --created',
459+
'forest node recent --json',
460+
],
461+
related: ['node.read', 'explore', 'capture'],
462+
},
463+
440464
node: {
441465
cmd: 'node',
442466
purpose: 'View node dashboard (total count, recent nodes, quick actions)',

0 commit comments

Comments
 (0)