thatgfsj-code 2.2.9 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -67
- package/dist/app/index.d.ts +28 -5
- package/dist/app/index.d.ts.map +1 -1
- package/dist/app/index.js +69 -10
- package/dist/app/index.js.map +1 -1
- package/dist/cache/fingerprint.d.ts +49 -0
- package/dist/cache/fingerprint.d.ts.map +1 -0
- package/dist/cache/fingerprint.js +100 -0
- package/dist/cache/fingerprint.js.map +1 -0
- package/dist/cache/index.d.ts +15 -0
- package/dist/cache/index.d.ts.map +1 -0
- package/dist/cache/index.js +15 -0
- package/dist/cache/index.js.map +1 -0
- package/dist/cache/smartModel.d.ts +40 -0
- package/dist/cache/smartModel.d.ts.map +1 -0
- package/dist/cache/smartModel.js +43 -0
- package/dist/cache/smartModel.js.map +1 -0
- package/dist/cache/stats.d.ts +93 -0
- package/dist/cache/stats.d.ts.map +1 -0
- package/dist/cache/stats.js +155 -0
- package/dist/cache/stats.js.map +1 -0
- package/dist/cache/volatile.d.ts +32 -0
- package/dist/cache/volatile.d.ts.map +1 -0
- package/dist/cache/volatile.js +44 -0
- package/dist/cache/volatile.js.map +1 -0
- package/dist/cmd/index.js +49 -57
- package/dist/cmd/index.js.map +1 -1
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/index.js +8 -0
- package/dist/config/index.js.map +1 -1
- package/dist/config/types.d.ts +14 -0
- package/dist/config/types.d.ts.map +1 -1
- package/dist/llm/anthropic.d.ts +55 -5
- package/dist/llm/anthropic.d.ts.map +1 -1
- package/dist/llm/anthropic.js +172 -26
- package/dist/llm/anthropic.js.map +1 -1
- package/dist/llm/index.d.ts +26 -8
- package/dist/llm/index.d.ts.map +1 -1
- package/dist/llm/index.js +83 -15
- package/dist/llm/index.js.map +1 -1
- package/dist/llm/openai.d.ts +28 -8
- package/dist/llm/openai.d.ts.map +1 -1
- package/dist/llm/openai.js +58 -8
- package/dist/llm/openai.js.map +1 -1
- package/dist/llm/provider.d.ts +40 -8
- package/dist/llm/provider.d.ts.map +1 -1
- package/dist/llm/provider.js +8 -1
- package/dist/llm/provider.js.map +1 -1
- package/dist/prompts/index.d.ts +32 -0
- package/dist/prompts/index.d.ts.map +1 -1
- package/dist/prompts/index.js +33 -11
- package/dist/prompts/index.js.map +1 -1
- package/dist/session/index.d.ts +32 -3
- package/dist/session/index.d.ts.map +1 -1
- package/dist/session/index.js +51 -36
- package/dist/session/index.js.map +1 -1
- package/dist/skills/brainstorming.js +33 -33
- package/dist/skills/code-review.js +35 -35
- package/dist/skills/executing-plans.js +18 -18
- package/dist/skills/frontend-design.js +25 -25
- package/dist/skills/git-workflow.js +26 -26
- package/dist/skills/improve-architecture.js +28 -28
- package/dist/skills/neuroweave.js +37 -37
- package/dist/skills/playwright.js +62 -62
- package/dist/skills/prototype.js +20 -20
- package/dist/skills/subagent.js +19 -19
- package/dist/skills/supabase.js +34 -34
- package/dist/skills/systematic-debugging.js +34 -34
- package/dist/skills/tdd.js +29 -29
- package/dist/skills/triage.js +25 -25
- package/dist/skills/verification.js +22 -22
- package/dist/skills/writing-plans.js +32 -32
- package/dist/tools/nwt.js +16 -16
- package/dist/tui/app.d.ts.map +1 -1
- package/dist/tui/app.js +9 -2
- package/dist/tui/app.js.map +1 -1
- package/dist/tui/components/Header.d.ts +8 -0
- package/dist/tui/components/Header.d.ts.map +1 -1
- package/dist/tui/components/Header.js +17 -4
- package/dist/tui/components/Header.js.map +1 -1
- package/dist/tui/components/InitWizard.d.ts +12 -1
- package/dist/tui/components/InitWizard.d.ts.map +1 -1
- package/dist/tui/components/InitWizard.js +58 -3
- package/dist/tui/components/InitWizard.js.map +1 -1
- package/dist/tui/components/ToolCall.d.ts.map +1 -1
- package/dist/tui/components/ToolCall.js +1 -6
- package/dist/tui/components/ToolCall.js.map +1 -1
- package/dist/tui/hooks/useChat.d.ts +22 -0
- package/dist/tui/hooks/useChat.d.ts.map +1 -1
- package/dist/tui/hooks/useChat.js +82 -46
- package/dist/tui/hooks/useChat.js.map +1 -1
- package/dist/tui/hooks/useCommands.d.ts.map +1 -1
- package/dist/tui/hooks/useCommands.js +57 -0
- package/dist/tui/hooks/useCommands.js.map +1 -1
- package/dist/tui/welcome.d.ts.map +1 -1
- package/dist/tui/welcome.js +2 -3
- package/dist/tui/welcome.js.map +1 -1
- package/dist/types.d.ts +79 -6
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +7 -0
- package/dist/types.js.map +1 -1
- package/dist/utils/stableStringify.d.ts +21 -0
- package/dist/utils/stableStringify.d.ts.map +1 -0
- package/dist/utils/stableStringify.js +53 -0
- package/dist/utils/stableStringify.js.map +1 -0
- package/dist/utils/thinking.d.ts.map +1 -1
- package/dist/utils/thinking.js +0 -3
- package/dist/utils/thinking.js.map +1 -1
- package/package.json +6 -6
- package/src/app/index.ts +180 -124
- package/src/cache/fingerprint.ts +101 -0
- package/src/cache/index.ts +15 -0
- package/src/cache/smartModel.ts +52 -0
- package/src/cache/stats.ts +199 -0
- package/src/cache/volatile.ts +47 -0
- package/src/cmd/index.tsx +288 -292
- package/src/config/index.ts +156 -148
- package/src/config/providers.ts +234 -234
- package/src/config/types.ts +67 -53
- package/src/hooks/index.ts +111 -111
- package/src/llm/anthropic.ts +388 -243
- package/src/llm/gemini.ts +169 -169
- package/src/llm/index.ts +265 -200
- package/src/llm/openai.ts +243 -196
- package/src/llm/provider.ts +66 -34
- package/src/prompts/index.ts +260 -220
- package/src/session/compactor.ts +103 -103
- package/src/session/index.ts +181 -168
- package/src/session/message.ts +42 -42
- package/src/skills/brainstorming.ts +43 -43
- package/src/skills/code-review.ts +45 -45
- package/src/skills/executing-plans.ts +27 -27
- package/src/skills/frontend-design.ts +35 -35
- package/src/skills/git-workflow.ts +36 -36
- package/src/skills/improve-architecture.ts +38 -38
- package/src/skills/index.ts +136 -136
- package/src/skills/neuroweave.ts +47 -47
- package/src/skills/playwright.ts +72 -72
- package/src/skills/prototype.ts +30 -30
- package/src/skills/subagent.ts +28 -28
- package/src/skills/supabase.ts +44 -44
- package/src/skills/systematic-debugging.ts +44 -44
- package/src/skills/tdd.ts +39 -39
- package/src/skills/triage.ts +35 -35
- package/src/skills/verification.ts +31 -31
- package/src/skills/writing-plans.ts +42 -42
- package/src/tools/nwt.ts +598 -598
- package/src/tools/types.ts +122 -122
- package/src/tui/app.tsx +199 -186
- package/src/tui/components/ChatList.tsx +41 -41
- package/src/tui/components/ChatMessage.tsx +54 -54
- package/src/tui/components/Header.tsx +56 -29
- package/src/tui/components/InitWizard.tsx +217 -132
- package/src/tui/components/Markdown.tsx +35 -35
- package/src/tui/components/ModelSelector.tsx +87 -87
- package/src/tui/components/StatusBar.tsx +30 -30
- package/src/tui/components/Thinking.tsx +17 -17
- package/src/tui/components/ToolCall.tsx +102 -107
- package/src/tui/components/UserInput.tsx +131 -131
- package/src/tui/hooks/useChat.ts +287 -238
- package/src/tui/hooks/useCommands.ts +234 -176
- package/src/tui/index.ts +6 -6
- package/src/tui/welcome.ts +177 -178
- package/src/types.ts +104 -42
- package/src/utils/diff.ts +71 -71
- package/src/utils/project.ts +99 -99
- package/src/utils/stableStringify.ts +47 -0
- package/src/utils/thinking.ts +118 -121
- package/tests/cache/fingerprint.test.ts +75 -0
- package/tests/cache/stableStringify.test.ts +43 -0
- package/tests/cache/stats.test.ts +146 -0
- package/tests/cache/volatile.test.ts +75 -0
- package/tests/smoke-pollution.mjs +78 -0
- package/tests/smoke-thinking.mjs +110 -0
- package/tests/smoke-tool-stream.mjs +69 -0
- package/tests/smoke-tool.mjs +117 -0
- package/.nwt/meta.json +0 -5
- package/dist/version.d.ts +0 -16
- package/dist/version.d.ts.map +0 -1
- package/dist/version.js +0 -16
- package/dist/version.js.map +0 -1
- package/src/version.ts +0 -16
package/src/tools/nwt.ts
CHANGED
|
@@ -1,598 +1,598 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* NeuroWeave Timeline (NWT) Tool - Project evolution memory
|
|
3
|
-
* Built-in port of https://github.com/Thatgfsj/neuroweave-timeline
|
|
4
|
-
*
|
|
5
|
-
* Stores meaningful actions (decisions, refactors, bug fixes) as timeline events
|
|
6
|
-
* in .nwt/ directory. Supports monthly archives (max 1 month per archive).
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import type { Tool, ToolResult } from './types.js';
|
|
10
|
-
import {
|
|
11
|
-
readFileSync, writeFileSync, existsSync, mkdirSync,
|
|
12
|
-
readdirSync, renameSync, unlinkSync
|
|
13
|
-
} from 'fs';
|
|
14
|
-
import { join } from 'path';
|
|
15
|
-
|
|
16
|
-
interface TimelineEvent {
|
|
17
|
-
id: string;
|
|
18
|
-
timestamp: string;
|
|
19
|
-
task: string;
|
|
20
|
-
summary: string;
|
|
21
|
-
reason?: string;
|
|
22
|
-
files: string[];
|
|
23
|
-
tags: string[];
|
|
24
|
-
parent?: string;
|
|
25
|
-
importance: 'low' | 'normal' | 'high' | 'milestone';
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
const NWT_DIR = '.nwt';
|
|
29
|
-
const EVENTS_DIR = 'events';
|
|
30
|
-
const ARCHIVE_DIR = 'archives';
|
|
31
|
-
const MAX_EVENTS = 500;
|
|
32
|
-
const ARCHIVE_AFTER_DAYS = 30;
|
|
33
|
-
|
|
34
|
-
export class NwtTool implements Tool {
|
|
35
|
-
name = 'nwt';
|
|
36
|
-
description = `NeuroWeave Timeline - Project evolution memory.
|
|
37
|
-
Actions: init, log, history, search, story, explain, archive, diff, compact
|
|
38
|
-
|
|
39
|
-
AUTO-LOG TRIGGERS (call nwt log silently when):
|
|
40
|
-
- Created, modified, or deleted 2+ files
|
|
41
|
-
- Made an architectural decision (chose a library, pattern, or approach)
|
|
42
|
-
- Fixed a bug
|
|
43
|
-
- Added a new feature or capability
|
|
44
|
-
- Refactored existing code
|
|
45
|
-
- Set up a new project or module
|
|
46
|
-
- Resolved a security issue
|
|
47
|
-
- Completed a multi-step task
|
|
48
|
-
|
|
49
|
-
DO NOT log:
|
|
50
|
-
- Simple file reads or searches
|
|
51
|
-
- Trivial single-line changes
|
|
52
|
-
- Conversational questions with no code changes`;
|
|
53
|
-
|
|
54
|
-
inputSchema = {
|
|
55
|
-
type: 'object' as const,
|
|
56
|
-
properties: {
|
|
57
|
-
action: {
|
|
58
|
-
type: 'string',
|
|
59
|
-
description: 'Action: init, log, history, search, story, explain, archive, diff, compact',
|
|
60
|
-
},
|
|
61
|
-
task: { type: 'string', description: 'Short imperative title (for log)' },
|
|
62
|
-
summary: { type: 'string', description: 'What was done (for log)' },
|
|
63
|
-
reason: { type: 'string', description: 'Why it was done (for log)' },
|
|
64
|
-
files: { type: 'string', description: 'Comma-separated file paths (for log)' },
|
|
65
|
-
tags: { type: 'string', description: 'Comma-separated tags (for log)' },
|
|
66
|
-
importance: { type: 'string', description: 'Importance: low, normal, high, milestone (for log)' },
|
|
67
|
-
query: { type: 'string', description: 'Search query (for search/explain)' },
|
|
68
|
-
limit: { type: 'number', description: 'Max results (for history/search)' },
|
|
69
|
-
from_id: { type: 'string', description: 'Start event ID (for diff)' },
|
|
70
|
-
to_id: { type: 'string', description: 'End event ID (for diff)' },
|
|
71
|
-
},
|
|
72
|
-
required: ['action'],
|
|
73
|
-
};
|
|
74
|
-
|
|
75
|
-
metadata = {
|
|
76
|
-
permissions: ['read', 'write'] as ('read' | 'write' | 'execute' | 'network')[],
|
|
77
|
-
tags: ['nwt', 'timeline', 'memory', 'history'],
|
|
78
|
-
};
|
|
79
|
-
|
|
80
|
-
parameters = [
|
|
81
|
-
{ name: 'action', type: 'string', description: 'Action to perform', required: true },
|
|
82
|
-
{ name: 'task', type: 'string', description: 'Short title', required: false },
|
|
83
|
-
{ name: 'summary', type: 'string', description: 'What was done', required: false },
|
|
84
|
-
{ name: 'reason', type: 'string', description: 'Why it was done', required: false },
|
|
85
|
-
{ name: 'files', type: 'string', description: 'Comma-separated file paths', required: false },
|
|
86
|
-
{ name: 'tags', type: 'string', description: 'Comma-separated tags', required: false },
|
|
87
|
-
{ name: 'importance', type: 'string', description: 'low/normal/high/milestone', required: false },
|
|
88
|
-
{ name: 'query', type: 'string', description: 'Search query', required: false },
|
|
89
|
-
{ name: 'limit', type: 'number', description: 'Max results', required: false },
|
|
90
|
-
{ name: 'from_id', type: 'string', description: 'Start event ID for diff', required: false },
|
|
91
|
-
{ name: 'to_id', type: 'string', description: 'End event ID for diff', required: false },
|
|
92
|
-
];
|
|
93
|
-
|
|
94
|
-
async execute(params: Record<string, any>, ctx?: any): Promise<ToolResult> {
|
|
95
|
-
const cwd = ctx?.workingDirectory || process.cwd();
|
|
96
|
-
const action = params.action;
|
|
97
|
-
|
|
98
|
-
try {
|
|
99
|
-
switch (action) {
|
|
100
|
-
case 'init':
|
|
101
|
-
return this.init(cwd);
|
|
102
|
-
case 'log':
|
|
103
|
-
return this.log(cwd, params);
|
|
104
|
-
case 'history':
|
|
105
|
-
return this.history(cwd, params.limit || 20);
|
|
106
|
-
case 'search':
|
|
107
|
-
return this.search(cwd, params.query || '', params.limit || 20);
|
|
108
|
-
case 'story':
|
|
109
|
-
return this.story(cwd);
|
|
110
|
-
case 'explain':
|
|
111
|
-
return this.explain(cwd, params.query || '');
|
|
112
|
-
case 'archive':
|
|
113
|
-
return this.archive(cwd);
|
|
114
|
-
case 'diff':
|
|
115
|
-
return this.diff(cwd, params.from_id || '', params.to_id || '');
|
|
116
|
-
case 'compact':
|
|
117
|
-
return this.compact(cwd);
|
|
118
|
-
default:
|
|
119
|
-
return { success: false, error: `Unknown action: ${action}` };
|
|
120
|
-
}
|
|
121
|
-
} catch (error: any) {
|
|
122
|
-
return { success: false, error: error.message };
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
// ── Init ──────────────────────────────────────────────
|
|
127
|
-
|
|
128
|
-
private init(cwd: string): ToolResult {
|
|
129
|
-
const nwtDir = join(cwd, NWT_DIR);
|
|
130
|
-
const eventsDir = join(nwtDir, EVENTS_DIR);
|
|
131
|
-
const archiveDir = join(nwtDir, ARCHIVE_DIR);
|
|
132
|
-
|
|
133
|
-
if (!existsSync(nwtDir)) mkdirSync(nwtDir, { recursive: true });
|
|
134
|
-
if (!existsSync(eventsDir)) mkdirSync(eventsDir, { recursive: true });
|
|
135
|
-
if (!existsSync(archiveDir)) mkdirSync(archiveDir, { recursive: true });
|
|
136
|
-
|
|
137
|
-
// Auto-archive events older than 30 days on every init
|
|
138
|
-
this.autoArchiveIfNeeded(cwd);
|
|
139
|
-
|
|
140
|
-
// Write metadata
|
|
141
|
-
const meta = {
|
|
142
|
-
version: '2.0.0',
|
|
143
|
-
created: new Date().toISOString(),
|
|
144
|
-
description: 'NeuroWeave Timeline - Project evolution memory',
|
|
145
|
-
};
|
|
146
|
-
writeFileSync(join(nwtDir, 'meta.json'), JSON.stringify(meta, null, 2));
|
|
147
|
-
|
|
148
|
-
return { success: true, output: 'NWT initialized in .nwt/' };
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
// ── Log Event ─────────────────────────────────────────
|
|
152
|
-
|
|
153
|
-
private log(cwd: string, params: Record<string, any>): ToolResult {
|
|
154
|
-
const nwtDir = join(cwd, NWT_DIR);
|
|
155
|
-
if (!existsSync(nwtDir)) {
|
|
156
|
-
this.init(cwd);
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
const { task, summary, reason, files, tags, importance } = params;
|
|
160
|
-
if (!task || !summary) {
|
|
161
|
-
return { success: false, error: 'task and summary are required' };
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
// Get next ID
|
|
165
|
-
const eventsDir = join(nwtDir, EVENTS_DIR);
|
|
166
|
-
const existing = existsSync(eventsDir)
|
|
167
|
-
? readdirSync(eventsDir).filter(f => f.endsWith('.json'))
|
|
168
|
-
: [];
|
|
169
|
-
const nextId = (existing.length + 1).toString().padStart(6, '0');
|
|
170
|
-
|
|
171
|
-
// Get parent (last event)
|
|
172
|
-
const parent = existing.length > 0
|
|
173
|
-
? existing.sort().pop()?.replace('.json', '')
|
|
174
|
-
: undefined;
|
|
175
|
-
|
|
176
|
-
// Validate importance
|
|
177
|
-
const validImportance = ['low', 'normal', 'high', 'milestone'];
|
|
178
|
-
const eventImportance = validImportance.includes(importance) ? importance : 'normal';
|
|
179
|
-
|
|
180
|
-
const event: TimelineEvent = {
|
|
181
|
-
id: nextId,
|
|
182
|
-
timestamp: new Date().toISOString(),
|
|
183
|
-
task: task.trim(),
|
|
184
|
-
summary: summary.trim(),
|
|
185
|
-
reason: reason?.trim() || undefined,
|
|
186
|
-
files: files ? files.split(',').map((f: string) => f.trim()).filter(Boolean) : [],
|
|
187
|
-
tags: tags ? tags.split(',').map((t: string) => t.trim().toLowerCase()).filter(Boolean) : [],
|
|
188
|
-
parent: parent || undefined,
|
|
189
|
-
importance: eventImportance as TimelineEvent['importance'],
|
|
190
|
-
};
|
|
191
|
-
|
|
192
|
-
writeFileSync(
|
|
193
|
-
join(eventsDir, `${nextId}.json`),
|
|
194
|
-
JSON.stringify(event, null, 2)
|
|
195
|
-
);
|
|
196
|
-
|
|
197
|
-
// Auto-archive if too many events
|
|
198
|
-
if (existing.length >= MAX_EVENTS) {
|
|
199
|
-
this.archive(cwd);
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
return {
|
|
203
|
-
success: true,
|
|
204
|
-
output: `Event [${nextId}] logged: ${event.task}`,
|
|
205
|
-
};
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
// ── History ───────────────────────────────────────────
|
|
209
|
-
|
|
210
|
-
private history(cwd: string, limit: number): ToolResult {
|
|
211
|
-
const events = this.loadEvents(cwd);
|
|
212
|
-
if (events.length === 0) {
|
|
213
|
-
return { success: true, output: 'No events yet. Use nwt log to start tracking.' };
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
const recent = events.slice(-limit);
|
|
217
|
-
const lines = recent.map(e => {
|
|
218
|
-
const time = e.timestamp.split('T')[0];
|
|
219
|
-
const files = e.files.length > 0 ? ` [${e.files.join(', ')}]` : '';
|
|
220
|
-
const tags = e.tags.length > 0 ? ` {${e.tags.join(', ')}}` : '';
|
|
221
|
-
const imp = e.importance && e.importance !== 'normal' ? ` (${e.importance})` : '';
|
|
222
|
-
return `[${e.id}] ${time} ${e.task}${imp}${files}${tags}`;
|
|
223
|
-
});
|
|
224
|
-
|
|
225
|
-
return { success: true, output: lines.join('\n') };
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
// ── Search ────────────────────────────────────────────
|
|
229
|
-
|
|
230
|
-
private search(cwd: string, query: string, limit: number): ToolResult {
|
|
231
|
-
if (!query) return { success: false, error: 'query is required' };
|
|
232
|
-
|
|
233
|
-
const events = this.loadEvents(cwd);
|
|
234
|
-
const q = query.toLowerCase();
|
|
235
|
-
const matches = events.filter(e =>
|
|
236
|
-
e.task.toLowerCase().includes(q) ||
|
|
237
|
-
e.summary.toLowerCase().includes(q) ||
|
|
238
|
-
(e.reason || '').toLowerCase().includes(q) ||
|
|
239
|
-
e.files.some(f => f.toLowerCase().includes(q)) ||
|
|
240
|
-
e.tags.some(t => t.includes(q))
|
|
241
|
-
).slice(-limit);
|
|
242
|
-
|
|
243
|
-
if (matches.length === 0) {
|
|
244
|
-
return { success: true, output: `No events matching "${query}"` };
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
const lines = matches.map(e => {
|
|
248
|
-
const time = e.timestamp.split('T')[0];
|
|
249
|
-
return `[${e.id}] ${time} ${e.task}\n ${e.summary}`;
|
|
250
|
-
});
|
|
251
|
-
|
|
252
|
-
return { success: true, output: lines.join('\n\n') };
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
// ── Story ─────────────────────────────────────────────
|
|
256
|
-
|
|
257
|
-
private story(cwd: string): ToolResult {
|
|
258
|
-
const events = this.loadEvents(cwd);
|
|
259
|
-
if (events.length === 0) {
|
|
260
|
-
return { success: true, output: 'No events yet.' };
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
// Group by importance
|
|
264
|
-
const milestones = events.filter(e => e.importance === 'milestone' || e.tags.includes('milestone'));
|
|
265
|
-
const highImportance = events.filter(e => e.importance === 'high');
|
|
266
|
-
const decisions = events.filter(e => e.tags.includes('decision'));
|
|
267
|
-
const totalFiles = new Set(events.flatMap(e => e.files)).size;
|
|
268
|
-
|
|
269
|
-
const lines = [
|
|
270
|
-
`Project Story: ${events.length} events, ${totalFiles} files touched`,
|
|
271
|
-
'',
|
|
272
|
-
];
|
|
273
|
-
|
|
274
|
-
if (milestones.length > 0) {
|
|
275
|
-
lines.push('Milestones:');
|
|
276
|
-
for (const m of milestones.slice(-5)) {
|
|
277
|
-
lines.push(` [${m.id}] ${m.task} - ${m.summary}`);
|
|
278
|
-
}
|
|
279
|
-
lines.push('');
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
if (highImportance.length > 0) {
|
|
283
|
-
lines.push('High Importance:');
|
|
284
|
-
for (const h of highImportance.slice(-5)) {
|
|
285
|
-
lines.push(` [${h.id}] ${h.task} - ${h.summary}`);
|
|
286
|
-
}
|
|
287
|
-
lines.push('');
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
if (decisions.length > 0) {
|
|
291
|
-
lines.push('Key Decisions:');
|
|
292
|
-
for (const d of decisions.slice(-5)) {
|
|
293
|
-
lines.push(` [${d.id}] ${d.task}${d.reason ? ` - ${d.reason}` : ''}`);
|
|
294
|
-
}
|
|
295
|
-
lines.push('');
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
lines.push('Recent Activity:');
|
|
299
|
-
for (const e of events.slice(-5)) {
|
|
300
|
-
const time = e.timestamp.split('T')[0];
|
|
301
|
-
const imp = e.importance && e.importance !== 'normal' ? ` (${e.importance})` : '';
|
|
302
|
-
lines.push(` ${time} ${e.task}${imp}`);
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
return { success: true, output: lines.join('\n') };
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
// ── Explain File ──────────────────────────────────────
|
|
309
|
-
|
|
310
|
-
private explain(cwd: string, filePath: string): ToolResult {
|
|
311
|
-
if (!filePath) return { success: false, error: 'file path is required' };
|
|
312
|
-
|
|
313
|
-
const events = this.loadEvents(cwd);
|
|
314
|
-
const matches = events.filter(e =>
|
|
315
|
-
e.files.some(f => f === filePath || f.endsWith(filePath))
|
|
316
|
-
);
|
|
317
|
-
|
|
318
|
-
if (matches.length === 0) {
|
|
319
|
-
return { success: true, output: `No history for "${filePath}"` };
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
const lines = matches.map(e => {
|
|
323
|
-
const time = e.timestamp.split('T')[0];
|
|
324
|
-
return `[${e.id}] ${time} ${e.task}\n ${e.summary}${e.reason ? `\n Reason: ${e.reason}` : ''}`;
|
|
325
|
-
});
|
|
326
|
-
|
|
327
|
-
return { success: true, output: `History of ${filePath}:\n\n${lines.join('\n\n')}` };
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
// ── Diff ──────────────────────────────────────────────
|
|
331
|
-
|
|
332
|
-
private diff(cwd: string, fromId: string, toId: string): ToolResult {
|
|
333
|
-
if (!fromId || !toId) {
|
|
334
|
-
return { success: false, error: 'from_id and to_id are required' };
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
const events = this.loadEvents(cwd);
|
|
338
|
-
const fromEvent = events.find(e => e.id === fromId.padStart(6, '0'));
|
|
339
|
-
const toEvent = events.find(e => e.id === toId.padStart(6, '0'));
|
|
340
|
-
|
|
341
|
-
if (!fromEvent) return { success: false, error: `Event ${fromId} not found` };
|
|
342
|
-
if (!toEvent) return { success: false, error: `Event ${toId} not found` };
|
|
343
|
-
|
|
344
|
-
// Get events between from and to
|
|
345
|
-
const fromIdx = events.indexOf(fromEvent);
|
|
346
|
-
const toIdx = events.indexOf(toEvent);
|
|
347
|
-
const between = events.slice(fromIdx, toIdx + 1);
|
|
348
|
-
|
|
349
|
-
// Collect all files touched
|
|
350
|
-
const allFiles = new Set(between.flatMap(e => e.files));
|
|
351
|
-
const fromFiles = new Set(fromEvent.files);
|
|
352
|
-
const toFiles = new Set(toEvent.files);
|
|
353
|
-
|
|
354
|
-
// Files added/removed/modified
|
|
355
|
-
const added = [...toFiles].filter(f => !fromFiles.has(f));
|
|
356
|
-
const removed = [...fromFiles].filter(f => !toFiles.has(f));
|
|
357
|
-
const modified = [...allFiles].filter(f => fromFiles.has(f) && toFiles.has(f));
|
|
358
|
-
|
|
359
|
-
const lines = [
|
|
360
|
-
`Diff: [${fromEvent.id}] → [${toEvent.id}]`,
|
|
361
|
-
`Events: ${between.length} between these points`,
|
|
362
|
-
'',
|
|
363
|
-
];
|
|
364
|
-
|
|
365
|
-
if (added.length > 0) lines.push(`Added: ${added.join(', ')}`);
|
|
366
|
-
if (removed.length > 0) lines.push(`Removed: ${removed.join(', ')}`);
|
|
367
|
-
if (modified.length > 0) lines.push(`Modified: ${modified.join(', ')}`);
|
|
368
|
-
|
|
369
|
-
lines.push('', 'Events in range:');
|
|
370
|
-
for (const e of between) {
|
|
371
|
-
const time = e.timestamp.split('T')[0];
|
|
372
|
-
lines.push(` [${e.id}] ${time} ${e.task}`);
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
return { success: true, output: lines.join('\n') };
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
// ── Compact ───────────────────────────────────────────
|
|
379
|
-
|
|
380
|
-
private compact(cwd: string): ToolResult {
|
|
381
|
-
const events = this.loadEvents(cwd);
|
|
382
|
-
if (events.length < 10) {
|
|
383
|
-
return { success: true, output: 'Not enough events to compact (need 10+).' };
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
const nwtDir = join(cwd, NWT_DIR);
|
|
387
|
-
const eventsDir = join(nwtDir, EVENTS_DIR);
|
|
388
|
-
|
|
389
|
-
// Group consecutive events with same tags
|
|
390
|
-
const groups: TimelineEvent[][] = [];
|
|
391
|
-
let currentGroup: TimelineEvent[] = [events[0]];
|
|
392
|
-
|
|
393
|
-
for (let i = 1; i < events.length; i++) {
|
|
394
|
-
const prev = events[i - 1];
|
|
395
|
-
const curr = events[i];
|
|
396
|
-
|
|
397
|
-
// Same tags and close in time (within 1 hour)
|
|
398
|
-
const sameTags = curr.tags.join(',') === prev.tags.join(',');
|
|
399
|
-
const timeDiff = new Date(curr.timestamp).getTime() - new Date(prev.timestamp).getTime();
|
|
400
|
-
const closeInTime = timeDiff < 3600000; // 1 hour
|
|
401
|
-
|
|
402
|
-
if (sameTags && closeInTime) {
|
|
403
|
-
currentGroup.push(curr);
|
|
404
|
-
} else {
|
|
405
|
-
groups.push(currentGroup);
|
|
406
|
-
currentGroup = [curr];
|
|
407
|
-
}
|
|
408
|
-
}
|
|
409
|
-
groups.push(currentGroup);
|
|
410
|
-
|
|
411
|
-
// Merge groups with 3+ events
|
|
412
|
-
let merged = 0;
|
|
413
|
-
const newEvents: TimelineEvent[] = [];
|
|
414
|
-
|
|
415
|
-
for (const group of groups) {
|
|
416
|
-
if (group.length >= 3) {
|
|
417
|
-
// Merge into one summary event
|
|
418
|
-
const first = group[0];
|
|
419
|
-
const last = group[group.length - 1];
|
|
420
|
-
const allFiles = [...new Set(group.flatMap(e => e.files))];
|
|
421
|
-
const allTags = [...new Set(group.flatMap(e => e.tags))];
|
|
422
|
-
|
|
423
|
-
newEvents.push({
|
|
424
|
-
id: first.id,
|
|
425
|
-
timestamp: first.timestamp,
|
|
426
|
-
task: `${first.task} ... ${last.task}`,
|
|
427
|
-
summary: `Compacted ${group.length} events: ${group.map(e => e.task).join(', ')}`,
|
|
428
|
-
files: allFiles,
|
|
429
|
-
tags: allTags,
|
|
430
|
-
importance: first.importance,
|
|
431
|
-
});
|
|
432
|
-
merged += group.length - 1;
|
|
433
|
-
} else {
|
|
434
|
-
newEvents.push(...group);
|
|
435
|
-
}
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
if (merged === 0) {
|
|
439
|
-
return { success: true, output: 'No events to compact.' };
|
|
440
|
-
}
|
|
441
|
-
|
|
442
|
-
// Re-write events
|
|
443
|
-
for (const file of readdirSync(eventsDir).filter(f => f.endsWith('.json'))) {
|
|
444
|
-
unlinkSync(join(eventsDir, file));
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
for (let i = 0; i < newEvents.length; i++) {
|
|
448
|
-
const event = newEvents[i];
|
|
449
|
-
event.id = (i + 1).toString().padStart(6, '0');
|
|
450
|
-
writeFileSync(join(eventsDir, `${event.id}.json`), JSON.stringify(event, null, 2));
|
|
451
|
-
}
|
|
452
|
-
|
|
453
|
-
return {
|
|
454
|
-
success: true,
|
|
455
|
-
output: `Compacted: ${events.length} → ${newEvents.length} events (merged ${merged})`,
|
|
456
|
-
};
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
// ── Archive ───────────────────────────────────────────
|
|
460
|
-
|
|
461
|
-
private archive(cwd: string): ToolResult {
|
|
462
|
-
const nwtDir = join(cwd, NWT_DIR);
|
|
463
|
-
const eventsDir = join(nwtDir, EVENTS_DIR);
|
|
464
|
-
const archiveDir = join(nwtDir, ARCHIVE_DIR);
|
|
465
|
-
|
|
466
|
-
if (!existsSync(eventsDir)) {
|
|
467
|
-
return { success: true, output: 'No events to archive.' };
|
|
468
|
-
}
|
|
469
|
-
|
|
470
|
-
const cutoff = new Date();
|
|
471
|
-
cutoff.setDate(cutoff.getDate() - ARCHIVE_AFTER_DAYS);
|
|
472
|
-
|
|
473
|
-
const files = readdirSync(eventsDir).filter(f => f.endsWith('.json')).sort();
|
|
474
|
-
const toArchive: string[] = [];
|
|
475
|
-
const toKeep: string[] = [];
|
|
476
|
-
|
|
477
|
-
for (const file of files) {
|
|
478
|
-
try {
|
|
479
|
-
const event = JSON.parse(readFileSync(join(eventsDir, file), 'utf-8'));
|
|
480
|
-
const eventDate = new Date(event.timestamp);
|
|
481
|
-
if (eventDate < cutoff) {
|
|
482
|
-
toArchive.push(file);
|
|
483
|
-
} else {
|
|
484
|
-
toKeep.push(file);
|
|
485
|
-
}
|
|
486
|
-
} catch {
|
|
487
|
-
toKeep.push(file);
|
|
488
|
-
}
|
|
489
|
-
}
|
|
490
|
-
|
|
491
|
-
if (toArchive.length === 0) {
|
|
492
|
-
return { success: true, output: 'No events old enough to archive.' };
|
|
493
|
-
}
|
|
494
|
-
|
|
495
|
-
// Create archive file
|
|
496
|
-
const archiveName = `archive-${new Date().toISOString().split('T')[0]}.json`;
|
|
497
|
-
const archiveEvents = toArchive.map(f => {
|
|
498
|
-
return JSON.parse(readFileSync(join(eventsDir, f), 'utf-8'));
|
|
499
|
-
});
|
|
500
|
-
|
|
501
|
-
writeFileSync(
|
|
502
|
-
join(archiveDir, archiveName),
|
|
503
|
-
JSON.stringify(archiveEvents, null, 2)
|
|
504
|
-
);
|
|
505
|
-
|
|
506
|
-
// Remove archived events from events dir
|
|
507
|
-
for (const file of toArchive) {
|
|
508
|
-
const src = join(eventsDir, file);
|
|
509
|
-
try { renameSync(src, join(archiveDir, file)); } catch {}
|
|
510
|
-
}
|
|
511
|
-
|
|
512
|
-
return {
|
|
513
|
-
success: true,
|
|
514
|
-
output: `Archived ${toArchive.length} events to archives/${archiveName}. ${toKeep.length} events remain.`,
|
|
515
|
-
};
|
|
516
|
-
}
|
|
517
|
-
|
|
518
|
-
// ── Auto Archive ──────────────────────────────────────
|
|
519
|
-
|
|
520
|
-
private autoArchiveIfNeeded(cwd: string): void {
|
|
521
|
-
try {
|
|
522
|
-
const nwtDir = join(cwd, NWT_DIR);
|
|
523
|
-
const eventsDir = join(nwtDir, EVENTS_DIR);
|
|
524
|
-
const archiveDir = join(nwtDir, ARCHIVE_DIR);
|
|
525
|
-
|
|
526
|
-
if (!existsSync(eventsDir)) return;
|
|
527
|
-
|
|
528
|
-
const files = readdirSync(eventsDir).filter(f => f.endsWith('.json'));
|
|
529
|
-
if (files.length === 0) return;
|
|
530
|
-
|
|
531
|
-
const cutoff = new Date();
|
|
532
|
-
cutoff.setDate(cutoff.getDate() - ARCHIVE_AFTER_DAYS);
|
|
533
|
-
|
|
534
|
-
const toArchive: string[] = [];
|
|
535
|
-
const toKeep: string[] = [];
|
|
536
|
-
|
|
537
|
-
for (const file of files) {
|
|
538
|
-
try {
|
|
539
|
-
const event = JSON.parse(readFileSync(join(eventsDir, file), 'utf-8'));
|
|
540
|
-
const eventDate = new Date(event.timestamp);
|
|
541
|
-
if (eventDate < cutoff) {
|
|
542
|
-
toArchive.push(file);
|
|
543
|
-
} else {
|
|
544
|
-
toKeep.push(file);
|
|
545
|
-
}
|
|
546
|
-
} catch {
|
|
547
|
-
toKeep.push(file);
|
|
548
|
-
}
|
|
549
|
-
}
|
|
550
|
-
|
|
551
|
-
if (toArchive.length === 0) return;
|
|
552
|
-
|
|
553
|
-
// Create monthly archive file
|
|
554
|
-
const archiveName = `archive-${new Date().toISOString().split('T')[0]}.json`;
|
|
555
|
-
const archiveEvents = toArchive.map(f => {
|
|
556
|
-
return JSON.parse(readFileSync(join(eventsDir, f), 'utf-8'));
|
|
557
|
-
});
|
|
558
|
-
|
|
559
|
-
if (!existsSync(archiveDir)) mkdirSync(archiveDir, { recursive: true });
|
|
560
|
-
|
|
561
|
-
// Append to existing archive or create new
|
|
562
|
-
const archivePath = join(archiveDir, archiveName);
|
|
563
|
-
let existing: any[] = [];
|
|
564
|
-
if (existsSync(archivePath)) {
|
|
565
|
-
try { existing = JSON.parse(readFileSync(archivePath, 'utf-8')); } catch {}
|
|
566
|
-
}
|
|
567
|
-
existing.push(...archiveEvents);
|
|
568
|
-
writeFileSync(archivePath, JSON.stringify(existing, null, 2));
|
|
569
|
-
|
|
570
|
-
// Remove archived files from events dir
|
|
571
|
-
for (const file of toArchive) {
|
|
572
|
-
try { renameSync(join(eventsDir, file), join(archiveDir, file)); } catch {}
|
|
573
|
-
}
|
|
574
|
-
} catch {
|
|
575
|
-
// Silent fail - don't break init
|
|
576
|
-
}
|
|
577
|
-
}
|
|
578
|
-
|
|
579
|
-
// ── Helpers ───────────────────────────────────────────
|
|
580
|
-
|
|
581
|
-
private loadEvents(cwd: string): TimelineEvent[] {
|
|
582
|
-
const eventsDir = join(cwd, NWT_DIR, EVENTS_DIR);
|
|
583
|
-
if (!existsSync(eventsDir)) return [];
|
|
584
|
-
|
|
585
|
-
const files = readdirSync(eventsDir).filter(f => f.endsWith('.json')).sort();
|
|
586
|
-
const events: TimelineEvent[] = [];
|
|
587
|
-
|
|
588
|
-
for (const file of files) {
|
|
589
|
-
try {
|
|
590
|
-
events.push(JSON.parse(readFileSync(join(eventsDir, file), 'utf-8')));
|
|
591
|
-
} catch {
|
|
592
|
-
// Skip corrupted files
|
|
593
|
-
}
|
|
594
|
-
}
|
|
595
|
-
|
|
596
|
-
return events;
|
|
597
|
-
}
|
|
598
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* NeuroWeave Timeline (NWT) Tool - Project evolution memory
|
|
3
|
+
* Built-in port of https://github.com/Thatgfsj/neuroweave-timeline
|
|
4
|
+
*
|
|
5
|
+
* Stores meaningful actions (decisions, refactors, bug fixes) as timeline events
|
|
6
|
+
* in .nwt/ directory. Supports monthly archives (max 1 month per archive).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { Tool, ToolResult } from './types.js';
|
|
10
|
+
import {
|
|
11
|
+
readFileSync, writeFileSync, existsSync, mkdirSync,
|
|
12
|
+
readdirSync, renameSync, unlinkSync
|
|
13
|
+
} from 'fs';
|
|
14
|
+
import { join } from 'path';
|
|
15
|
+
|
|
16
|
+
interface TimelineEvent {
|
|
17
|
+
id: string;
|
|
18
|
+
timestamp: string;
|
|
19
|
+
task: string;
|
|
20
|
+
summary: string;
|
|
21
|
+
reason?: string;
|
|
22
|
+
files: string[];
|
|
23
|
+
tags: string[];
|
|
24
|
+
parent?: string;
|
|
25
|
+
importance: 'low' | 'normal' | 'high' | 'milestone';
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const NWT_DIR = '.nwt';
|
|
29
|
+
const EVENTS_DIR = 'events';
|
|
30
|
+
const ARCHIVE_DIR = 'archives';
|
|
31
|
+
const MAX_EVENTS = 500;
|
|
32
|
+
const ARCHIVE_AFTER_DAYS = 30;
|
|
33
|
+
|
|
34
|
+
export class NwtTool implements Tool {
|
|
35
|
+
name = 'nwt';
|
|
36
|
+
description = `NeuroWeave Timeline - Project evolution memory.
|
|
37
|
+
Actions: init, log, history, search, story, explain, archive, diff, compact
|
|
38
|
+
|
|
39
|
+
AUTO-LOG TRIGGERS (call nwt log silently when):
|
|
40
|
+
- Created, modified, or deleted 2+ files
|
|
41
|
+
- Made an architectural decision (chose a library, pattern, or approach)
|
|
42
|
+
- Fixed a bug
|
|
43
|
+
- Added a new feature or capability
|
|
44
|
+
- Refactored existing code
|
|
45
|
+
- Set up a new project or module
|
|
46
|
+
- Resolved a security issue
|
|
47
|
+
- Completed a multi-step task
|
|
48
|
+
|
|
49
|
+
DO NOT log:
|
|
50
|
+
- Simple file reads or searches
|
|
51
|
+
- Trivial single-line changes
|
|
52
|
+
- Conversational questions with no code changes`;
|
|
53
|
+
|
|
54
|
+
inputSchema = {
|
|
55
|
+
type: 'object' as const,
|
|
56
|
+
properties: {
|
|
57
|
+
action: {
|
|
58
|
+
type: 'string',
|
|
59
|
+
description: 'Action: init, log, history, search, story, explain, archive, diff, compact',
|
|
60
|
+
},
|
|
61
|
+
task: { type: 'string', description: 'Short imperative title (for log)' },
|
|
62
|
+
summary: { type: 'string', description: 'What was done (for log)' },
|
|
63
|
+
reason: { type: 'string', description: 'Why it was done (for log)' },
|
|
64
|
+
files: { type: 'string', description: 'Comma-separated file paths (for log)' },
|
|
65
|
+
tags: { type: 'string', description: 'Comma-separated tags (for log)' },
|
|
66
|
+
importance: { type: 'string', description: 'Importance: low, normal, high, milestone (for log)' },
|
|
67
|
+
query: { type: 'string', description: 'Search query (for search/explain)' },
|
|
68
|
+
limit: { type: 'number', description: 'Max results (for history/search)' },
|
|
69
|
+
from_id: { type: 'string', description: 'Start event ID (for diff)' },
|
|
70
|
+
to_id: { type: 'string', description: 'End event ID (for diff)' },
|
|
71
|
+
},
|
|
72
|
+
required: ['action'],
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
metadata = {
|
|
76
|
+
permissions: ['read', 'write'] as ('read' | 'write' | 'execute' | 'network')[],
|
|
77
|
+
tags: ['nwt', 'timeline', 'memory', 'history'],
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
parameters = [
|
|
81
|
+
{ name: 'action', type: 'string', description: 'Action to perform', required: true },
|
|
82
|
+
{ name: 'task', type: 'string', description: 'Short title', required: false },
|
|
83
|
+
{ name: 'summary', type: 'string', description: 'What was done', required: false },
|
|
84
|
+
{ name: 'reason', type: 'string', description: 'Why it was done', required: false },
|
|
85
|
+
{ name: 'files', type: 'string', description: 'Comma-separated file paths', required: false },
|
|
86
|
+
{ name: 'tags', type: 'string', description: 'Comma-separated tags', required: false },
|
|
87
|
+
{ name: 'importance', type: 'string', description: 'low/normal/high/milestone', required: false },
|
|
88
|
+
{ name: 'query', type: 'string', description: 'Search query', required: false },
|
|
89
|
+
{ name: 'limit', type: 'number', description: 'Max results', required: false },
|
|
90
|
+
{ name: 'from_id', type: 'string', description: 'Start event ID for diff', required: false },
|
|
91
|
+
{ name: 'to_id', type: 'string', description: 'End event ID for diff', required: false },
|
|
92
|
+
];
|
|
93
|
+
|
|
94
|
+
async execute(params: Record<string, any>, ctx?: any): Promise<ToolResult> {
|
|
95
|
+
const cwd = ctx?.workingDirectory || process.cwd();
|
|
96
|
+
const action = params.action;
|
|
97
|
+
|
|
98
|
+
try {
|
|
99
|
+
switch (action) {
|
|
100
|
+
case 'init':
|
|
101
|
+
return this.init(cwd);
|
|
102
|
+
case 'log':
|
|
103
|
+
return this.log(cwd, params);
|
|
104
|
+
case 'history':
|
|
105
|
+
return this.history(cwd, params.limit || 20);
|
|
106
|
+
case 'search':
|
|
107
|
+
return this.search(cwd, params.query || '', params.limit || 20);
|
|
108
|
+
case 'story':
|
|
109
|
+
return this.story(cwd);
|
|
110
|
+
case 'explain':
|
|
111
|
+
return this.explain(cwd, params.query || '');
|
|
112
|
+
case 'archive':
|
|
113
|
+
return this.archive(cwd);
|
|
114
|
+
case 'diff':
|
|
115
|
+
return this.diff(cwd, params.from_id || '', params.to_id || '');
|
|
116
|
+
case 'compact':
|
|
117
|
+
return this.compact(cwd);
|
|
118
|
+
default:
|
|
119
|
+
return { success: false, error: `Unknown action: ${action}` };
|
|
120
|
+
}
|
|
121
|
+
} catch (error: any) {
|
|
122
|
+
return { success: false, error: error.message };
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ── Init ──────────────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
private init(cwd: string): ToolResult {
|
|
129
|
+
const nwtDir = join(cwd, NWT_DIR);
|
|
130
|
+
const eventsDir = join(nwtDir, EVENTS_DIR);
|
|
131
|
+
const archiveDir = join(nwtDir, ARCHIVE_DIR);
|
|
132
|
+
|
|
133
|
+
if (!existsSync(nwtDir)) mkdirSync(nwtDir, { recursive: true });
|
|
134
|
+
if (!existsSync(eventsDir)) mkdirSync(eventsDir, { recursive: true });
|
|
135
|
+
if (!existsSync(archiveDir)) mkdirSync(archiveDir, { recursive: true });
|
|
136
|
+
|
|
137
|
+
// Auto-archive events older than 30 days on every init
|
|
138
|
+
this.autoArchiveIfNeeded(cwd);
|
|
139
|
+
|
|
140
|
+
// Write metadata
|
|
141
|
+
const meta = {
|
|
142
|
+
version: '2.0.0',
|
|
143
|
+
created: new Date().toISOString(),
|
|
144
|
+
description: 'NeuroWeave Timeline - Project evolution memory',
|
|
145
|
+
};
|
|
146
|
+
writeFileSync(join(nwtDir, 'meta.json'), JSON.stringify(meta, null, 2));
|
|
147
|
+
|
|
148
|
+
return { success: true, output: 'NWT initialized in .nwt/' };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ── Log Event ─────────────────────────────────────────
|
|
152
|
+
|
|
153
|
+
private log(cwd: string, params: Record<string, any>): ToolResult {
|
|
154
|
+
const nwtDir = join(cwd, NWT_DIR);
|
|
155
|
+
if (!existsSync(nwtDir)) {
|
|
156
|
+
this.init(cwd);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const { task, summary, reason, files, tags, importance } = params;
|
|
160
|
+
if (!task || !summary) {
|
|
161
|
+
return { success: false, error: 'task and summary are required' };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Get next ID
|
|
165
|
+
const eventsDir = join(nwtDir, EVENTS_DIR);
|
|
166
|
+
const existing = existsSync(eventsDir)
|
|
167
|
+
? readdirSync(eventsDir).filter(f => f.endsWith('.json'))
|
|
168
|
+
: [];
|
|
169
|
+
const nextId = (existing.length + 1).toString().padStart(6, '0');
|
|
170
|
+
|
|
171
|
+
// Get parent (last event)
|
|
172
|
+
const parent = existing.length > 0
|
|
173
|
+
? existing.sort().pop()?.replace('.json', '')
|
|
174
|
+
: undefined;
|
|
175
|
+
|
|
176
|
+
// Validate importance
|
|
177
|
+
const validImportance = ['low', 'normal', 'high', 'milestone'];
|
|
178
|
+
const eventImportance = validImportance.includes(importance) ? importance : 'normal';
|
|
179
|
+
|
|
180
|
+
const event: TimelineEvent = {
|
|
181
|
+
id: nextId,
|
|
182
|
+
timestamp: new Date().toISOString(),
|
|
183
|
+
task: task.trim(),
|
|
184
|
+
summary: summary.trim(),
|
|
185
|
+
reason: reason?.trim() || undefined,
|
|
186
|
+
files: files ? files.split(',').map((f: string) => f.trim()).filter(Boolean) : [],
|
|
187
|
+
tags: tags ? tags.split(',').map((t: string) => t.trim().toLowerCase()).filter(Boolean) : [],
|
|
188
|
+
parent: parent || undefined,
|
|
189
|
+
importance: eventImportance as TimelineEvent['importance'],
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
writeFileSync(
|
|
193
|
+
join(eventsDir, `${nextId}.json`),
|
|
194
|
+
JSON.stringify(event, null, 2)
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
// Auto-archive if too many events
|
|
198
|
+
if (existing.length >= MAX_EVENTS) {
|
|
199
|
+
this.archive(cwd);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return {
|
|
203
|
+
success: true,
|
|
204
|
+
output: `Event [${nextId}] logged: ${event.task}`,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ── History ───────────────────────────────────────────
|
|
209
|
+
|
|
210
|
+
private history(cwd: string, limit: number): ToolResult {
|
|
211
|
+
const events = this.loadEvents(cwd);
|
|
212
|
+
if (events.length === 0) {
|
|
213
|
+
return { success: true, output: 'No events yet. Use nwt log to start tracking.' };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const recent = events.slice(-limit);
|
|
217
|
+
const lines = recent.map(e => {
|
|
218
|
+
const time = e.timestamp.split('T')[0];
|
|
219
|
+
const files = e.files.length > 0 ? ` [${e.files.join(', ')}]` : '';
|
|
220
|
+
const tags = e.tags.length > 0 ? ` {${e.tags.join(', ')}}` : '';
|
|
221
|
+
const imp = e.importance && e.importance !== 'normal' ? ` (${e.importance})` : '';
|
|
222
|
+
return `[${e.id}] ${time} ${e.task}${imp}${files}${tags}`;
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
return { success: true, output: lines.join('\n') };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ── Search ────────────────────────────────────────────
|
|
229
|
+
|
|
230
|
+
private search(cwd: string, query: string, limit: number): ToolResult {
|
|
231
|
+
if (!query) return { success: false, error: 'query is required' };
|
|
232
|
+
|
|
233
|
+
const events = this.loadEvents(cwd);
|
|
234
|
+
const q = query.toLowerCase();
|
|
235
|
+
const matches = events.filter(e =>
|
|
236
|
+
e.task.toLowerCase().includes(q) ||
|
|
237
|
+
e.summary.toLowerCase().includes(q) ||
|
|
238
|
+
(e.reason || '').toLowerCase().includes(q) ||
|
|
239
|
+
e.files.some(f => f.toLowerCase().includes(q)) ||
|
|
240
|
+
e.tags.some(t => t.includes(q))
|
|
241
|
+
).slice(-limit);
|
|
242
|
+
|
|
243
|
+
if (matches.length === 0) {
|
|
244
|
+
return { success: true, output: `No events matching "${query}"` };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const lines = matches.map(e => {
|
|
248
|
+
const time = e.timestamp.split('T')[0];
|
|
249
|
+
return `[${e.id}] ${time} ${e.task}\n ${e.summary}`;
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
return { success: true, output: lines.join('\n\n') };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// ── Story ─────────────────────────────────────────────
|
|
256
|
+
|
|
257
|
+
private story(cwd: string): ToolResult {
|
|
258
|
+
const events = this.loadEvents(cwd);
|
|
259
|
+
if (events.length === 0) {
|
|
260
|
+
return { success: true, output: 'No events yet.' };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Group by importance
|
|
264
|
+
const milestones = events.filter(e => e.importance === 'milestone' || e.tags.includes('milestone'));
|
|
265
|
+
const highImportance = events.filter(e => e.importance === 'high');
|
|
266
|
+
const decisions = events.filter(e => e.tags.includes('decision'));
|
|
267
|
+
const totalFiles = new Set(events.flatMap(e => e.files)).size;
|
|
268
|
+
|
|
269
|
+
const lines = [
|
|
270
|
+
`Project Story: ${events.length} events, ${totalFiles} files touched`,
|
|
271
|
+
'',
|
|
272
|
+
];
|
|
273
|
+
|
|
274
|
+
if (milestones.length > 0) {
|
|
275
|
+
lines.push('Milestones:');
|
|
276
|
+
for (const m of milestones.slice(-5)) {
|
|
277
|
+
lines.push(` [${m.id}] ${m.task} - ${m.summary}`);
|
|
278
|
+
}
|
|
279
|
+
lines.push('');
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (highImportance.length > 0) {
|
|
283
|
+
lines.push('High Importance:');
|
|
284
|
+
for (const h of highImportance.slice(-5)) {
|
|
285
|
+
lines.push(` [${h.id}] ${h.task} - ${h.summary}`);
|
|
286
|
+
}
|
|
287
|
+
lines.push('');
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (decisions.length > 0) {
|
|
291
|
+
lines.push('Key Decisions:');
|
|
292
|
+
for (const d of decisions.slice(-5)) {
|
|
293
|
+
lines.push(` [${d.id}] ${d.task}${d.reason ? ` - ${d.reason}` : ''}`);
|
|
294
|
+
}
|
|
295
|
+
lines.push('');
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
lines.push('Recent Activity:');
|
|
299
|
+
for (const e of events.slice(-5)) {
|
|
300
|
+
const time = e.timestamp.split('T')[0];
|
|
301
|
+
const imp = e.importance && e.importance !== 'normal' ? ` (${e.importance})` : '';
|
|
302
|
+
lines.push(` ${time} ${e.task}${imp}`);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
return { success: true, output: lines.join('\n') };
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// ── Explain File ──────────────────────────────────────
|
|
309
|
+
|
|
310
|
+
private explain(cwd: string, filePath: string): ToolResult {
|
|
311
|
+
if (!filePath) return { success: false, error: 'file path is required' };
|
|
312
|
+
|
|
313
|
+
const events = this.loadEvents(cwd);
|
|
314
|
+
const matches = events.filter(e =>
|
|
315
|
+
e.files.some(f => f === filePath || f.endsWith(filePath))
|
|
316
|
+
);
|
|
317
|
+
|
|
318
|
+
if (matches.length === 0) {
|
|
319
|
+
return { success: true, output: `No history for "${filePath}"` };
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const lines = matches.map(e => {
|
|
323
|
+
const time = e.timestamp.split('T')[0];
|
|
324
|
+
return `[${e.id}] ${time} ${e.task}\n ${e.summary}${e.reason ? `\n Reason: ${e.reason}` : ''}`;
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
return { success: true, output: `History of ${filePath}:\n\n${lines.join('\n\n')}` };
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// ── Diff ──────────────────────────────────────────────
|
|
331
|
+
|
|
332
|
+
private diff(cwd: string, fromId: string, toId: string): ToolResult {
|
|
333
|
+
if (!fromId || !toId) {
|
|
334
|
+
return { success: false, error: 'from_id and to_id are required' };
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const events = this.loadEvents(cwd);
|
|
338
|
+
const fromEvent = events.find(e => e.id === fromId.padStart(6, '0'));
|
|
339
|
+
const toEvent = events.find(e => e.id === toId.padStart(6, '0'));
|
|
340
|
+
|
|
341
|
+
if (!fromEvent) return { success: false, error: `Event ${fromId} not found` };
|
|
342
|
+
if (!toEvent) return { success: false, error: `Event ${toId} not found` };
|
|
343
|
+
|
|
344
|
+
// Get events between from and to
|
|
345
|
+
const fromIdx = events.indexOf(fromEvent);
|
|
346
|
+
const toIdx = events.indexOf(toEvent);
|
|
347
|
+
const between = events.slice(fromIdx, toIdx + 1);
|
|
348
|
+
|
|
349
|
+
// Collect all files touched
|
|
350
|
+
const allFiles = new Set(between.flatMap(e => e.files));
|
|
351
|
+
const fromFiles = new Set(fromEvent.files);
|
|
352
|
+
const toFiles = new Set(toEvent.files);
|
|
353
|
+
|
|
354
|
+
// Files added/removed/modified
|
|
355
|
+
const added = [...toFiles].filter(f => !fromFiles.has(f));
|
|
356
|
+
const removed = [...fromFiles].filter(f => !toFiles.has(f));
|
|
357
|
+
const modified = [...allFiles].filter(f => fromFiles.has(f) && toFiles.has(f));
|
|
358
|
+
|
|
359
|
+
const lines = [
|
|
360
|
+
`Diff: [${fromEvent.id}] → [${toEvent.id}]`,
|
|
361
|
+
`Events: ${between.length} between these points`,
|
|
362
|
+
'',
|
|
363
|
+
];
|
|
364
|
+
|
|
365
|
+
if (added.length > 0) lines.push(`Added: ${added.join(', ')}`);
|
|
366
|
+
if (removed.length > 0) lines.push(`Removed: ${removed.join(', ')}`);
|
|
367
|
+
if (modified.length > 0) lines.push(`Modified: ${modified.join(', ')}`);
|
|
368
|
+
|
|
369
|
+
lines.push('', 'Events in range:');
|
|
370
|
+
for (const e of between) {
|
|
371
|
+
const time = e.timestamp.split('T')[0];
|
|
372
|
+
lines.push(` [${e.id}] ${time} ${e.task}`);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
return { success: true, output: lines.join('\n') };
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// ── Compact ───────────────────────────────────────────
|
|
379
|
+
|
|
380
|
+
private compact(cwd: string): ToolResult {
|
|
381
|
+
const events = this.loadEvents(cwd);
|
|
382
|
+
if (events.length < 10) {
|
|
383
|
+
return { success: true, output: 'Not enough events to compact (need 10+).' };
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const nwtDir = join(cwd, NWT_DIR);
|
|
387
|
+
const eventsDir = join(nwtDir, EVENTS_DIR);
|
|
388
|
+
|
|
389
|
+
// Group consecutive events with same tags
|
|
390
|
+
const groups: TimelineEvent[][] = [];
|
|
391
|
+
let currentGroup: TimelineEvent[] = [events[0]];
|
|
392
|
+
|
|
393
|
+
for (let i = 1; i < events.length; i++) {
|
|
394
|
+
const prev = events[i - 1];
|
|
395
|
+
const curr = events[i];
|
|
396
|
+
|
|
397
|
+
// Same tags and close in time (within 1 hour)
|
|
398
|
+
const sameTags = curr.tags.join(',') === prev.tags.join(',');
|
|
399
|
+
const timeDiff = new Date(curr.timestamp).getTime() - new Date(prev.timestamp).getTime();
|
|
400
|
+
const closeInTime = timeDiff < 3600000; // 1 hour
|
|
401
|
+
|
|
402
|
+
if (sameTags && closeInTime) {
|
|
403
|
+
currentGroup.push(curr);
|
|
404
|
+
} else {
|
|
405
|
+
groups.push(currentGroup);
|
|
406
|
+
currentGroup = [curr];
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
groups.push(currentGroup);
|
|
410
|
+
|
|
411
|
+
// Merge groups with 3+ events
|
|
412
|
+
let merged = 0;
|
|
413
|
+
const newEvents: TimelineEvent[] = [];
|
|
414
|
+
|
|
415
|
+
for (const group of groups) {
|
|
416
|
+
if (group.length >= 3) {
|
|
417
|
+
// Merge into one summary event
|
|
418
|
+
const first = group[0];
|
|
419
|
+
const last = group[group.length - 1];
|
|
420
|
+
const allFiles = [...new Set(group.flatMap(e => e.files))];
|
|
421
|
+
const allTags = [...new Set(group.flatMap(e => e.tags))];
|
|
422
|
+
|
|
423
|
+
newEvents.push({
|
|
424
|
+
id: first.id,
|
|
425
|
+
timestamp: first.timestamp,
|
|
426
|
+
task: `${first.task} ... ${last.task}`,
|
|
427
|
+
summary: `Compacted ${group.length} events: ${group.map(e => e.task).join(', ')}`,
|
|
428
|
+
files: allFiles,
|
|
429
|
+
tags: allTags,
|
|
430
|
+
importance: first.importance,
|
|
431
|
+
});
|
|
432
|
+
merged += group.length - 1;
|
|
433
|
+
} else {
|
|
434
|
+
newEvents.push(...group);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
if (merged === 0) {
|
|
439
|
+
return { success: true, output: 'No events to compact.' };
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// Re-write events
|
|
443
|
+
for (const file of readdirSync(eventsDir).filter(f => f.endsWith('.json'))) {
|
|
444
|
+
unlinkSync(join(eventsDir, file));
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
for (let i = 0; i < newEvents.length; i++) {
|
|
448
|
+
const event = newEvents[i];
|
|
449
|
+
event.id = (i + 1).toString().padStart(6, '0');
|
|
450
|
+
writeFileSync(join(eventsDir, `${event.id}.json`), JSON.stringify(event, null, 2));
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
return {
|
|
454
|
+
success: true,
|
|
455
|
+
output: `Compacted: ${events.length} → ${newEvents.length} events (merged ${merged})`,
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// ── Archive ───────────────────────────────────────────
|
|
460
|
+
|
|
461
|
+
private archive(cwd: string): ToolResult {
|
|
462
|
+
const nwtDir = join(cwd, NWT_DIR);
|
|
463
|
+
const eventsDir = join(nwtDir, EVENTS_DIR);
|
|
464
|
+
const archiveDir = join(nwtDir, ARCHIVE_DIR);
|
|
465
|
+
|
|
466
|
+
if (!existsSync(eventsDir)) {
|
|
467
|
+
return { success: true, output: 'No events to archive.' };
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
const cutoff = new Date();
|
|
471
|
+
cutoff.setDate(cutoff.getDate() - ARCHIVE_AFTER_DAYS);
|
|
472
|
+
|
|
473
|
+
const files = readdirSync(eventsDir).filter(f => f.endsWith('.json')).sort();
|
|
474
|
+
const toArchive: string[] = [];
|
|
475
|
+
const toKeep: string[] = [];
|
|
476
|
+
|
|
477
|
+
for (const file of files) {
|
|
478
|
+
try {
|
|
479
|
+
const event = JSON.parse(readFileSync(join(eventsDir, file), 'utf-8'));
|
|
480
|
+
const eventDate = new Date(event.timestamp);
|
|
481
|
+
if (eventDate < cutoff) {
|
|
482
|
+
toArchive.push(file);
|
|
483
|
+
} else {
|
|
484
|
+
toKeep.push(file);
|
|
485
|
+
}
|
|
486
|
+
} catch {
|
|
487
|
+
toKeep.push(file);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
if (toArchive.length === 0) {
|
|
492
|
+
return { success: true, output: 'No events old enough to archive.' };
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// Create archive file
|
|
496
|
+
const archiveName = `archive-${new Date().toISOString().split('T')[0]}.json`;
|
|
497
|
+
const archiveEvents = toArchive.map(f => {
|
|
498
|
+
return JSON.parse(readFileSync(join(eventsDir, f), 'utf-8'));
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
writeFileSync(
|
|
502
|
+
join(archiveDir, archiveName),
|
|
503
|
+
JSON.stringify(archiveEvents, null, 2)
|
|
504
|
+
);
|
|
505
|
+
|
|
506
|
+
// Remove archived events from events dir
|
|
507
|
+
for (const file of toArchive) {
|
|
508
|
+
const src = join(eventsDir, file);
|
|
509
|
+
try { renameSync(src, join(archiveDir, file)); } catch {}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
return {
|
|
513
|
+
success: true,
|
|
514
|
+
output: `Archived ${toArchive.length} events to archives/${archiveName}. ${toKeep.length} events remain.`,
|
|
515
|
+
};
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// ── Auto Archive ──────────────────────────────────────
|
|
519
|
+
|
|
520
|
+
private autoArchiveIfNeeded(cwd: string): void {
|
|
521
|
+
try {
|
|
522
|
+
const nwtDir = join(cwd, NWT_DIR);
|
|
523
|
+
const eventsDir = join(nwtDir, EVENTS_DIR);
|
|
524
|
+
const archiveDir = join(nwtDir, ARCHIVE_DIR);
|
|
525
|
+
|
|
526
|
+
if (!existsSync(eventsDir)) return;
|
|
527
|
+
|
|
528
|
+
const files = readdirSync(eventsDir).filter(f => f.endsWith('.json'));
|
|
529
|
+
if (files.length === 0) return;
|
|
530
|
+
|
|
531
|
+
const cutoff = new Date();
|
|
532
|
+
cutoff.setDate(cutoff.getDate() - ARCHIVE_AFTER_DAYS);
|
|
533
|
+
|
|
534
|
+
const toArchive: string[] = [];
|
|
535
|
+
const toKeep: string[] = [];
|
|
536
|
+
|
|
537
|
+
for (const file of files) {
|
|
538
|
+
try {
|
|
539
|
+
const event = JSON.parse(readFileSync(join(eventsDir, file), 'utf-8'));
|
|
540
|
+
const eventDate = new Date(event.timestamp);
|
|
541
|
+
if (eventDate < cutoff) {
|
|
542
|
+
toArchive.push(file);
|
|
543
|
+
} else {
|
|
544
|
+
toKeep.push(file);
|
|
545
|
+
}
|
|
546
|
+
} catch {
|
|
547
|
+
toKeep.push(file);
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
if (toArchive.length === 0) return;
|
|
552
|
+
|
|
553
|
+
// Create monthly archive file
|
|
554
|
+
const archiveName = `archive-${new Date().toISOString().split('T')[0]}.json`;
|
|
555
|
+
const archiveEvents = toArchive.map(f => {
|
|
556
|
+
return JSON.parse(readFileSync(join(eventsDir, f), 'utf-8'));
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
if (!existsSync(archiveDir)) mkdirSync(archiveDir, { recursive: true });
|
|
560
|
+
|
|
561
|
+
// Append to existing archive or create new
|
|
562
|
+
const archivePath = join(archiveDir, archiveName);
|
|
563
|
+
let existing: any[] = [];
|
|
564
|
+
if (existsSync(archivePath)) {
|
|
565
|
+
try { existing = JSON.parse(readFileSync(archivePath, 'utf-8')); } catch {}
|
|
566
|
+
}
|
|
567
|
+
existing.push(...archiveEvents);
|
|
568
|
+
writeFileSync(archivePath, JSON.stringify(existing, null, 2));
|
|
569
|
+
|
|
570
|
+
// Remove archived files from events dir
|
|
571
|
+
for (const file of toArchive) {
|
|
572
|
+
try { renameSync(join(eventsDir, file), join(archiveDir, file)); } catch {}
|
|
573
|
+
}
|
|
574
|
+
} catch {
|
|
575
|
+
// Silent fail - don't break init
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// ── Helpers ───────────────────────────────────────────
|
|
580
|
+
|
|
581
|
+
private loadEvents(cwd: string): TimelineEvent[] {
|
|
582
|
+
const eventsDir = join(cwd, NWT_DIR, EVENTS_DIR);
|
|
583
|
+
if (!existsSync(eventsDir)) return [];
|
|
584
|
+
|
|
585
|
+
const files = readdirSync(eventsDir).filter(f => f.endsWith('.json')).sort();
|
|
586
|
+
const events: TimelineEvent[] = [];
|
|
587
|
+
|
|
588
|
+
for (const file of files) {
|
|
589
|
+
try {
|
|
590
|
+
events.push(JSON.parse(readFileSync(join(eventsDir, file), 'utf-8')));
|
|
591
|
+
} catch {
|
|
592
|
+
// Skip corrupted files
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
return events;
|
|
597
|
+
}
|
|
598
|
+
}
|