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.
Files changed (182) hide show
  1. package/CHANGELOG.md +15 -67
  2. package/dist/app/index.d.ts +28 -5
  3. package/dist/app/index.d.ts.map +1 -1
  4. package/dist/app/index.js +69 -10
  5. package/dist/app/index.js.map +1 -1
  6. package/dist/cache/fingerprint.d.ts +49 -0
  7. package/dist/cache/fingerprint.d.ts.map +1 -0
  8. package/dist/cache/fingerprint.js +100 -0
  9. package/dist/cache/fingerprint.js.map +1 -0
  10. package/dist/cache/index.d.ts +15 -0
  11. package/dist/cache/index.d.ts.map +1 -0
  12. package/dist/cache/index.js +15 -0
  13. package/dist/cache/index.js.map +1 -0
  14. package/dist/cache/smartModel.d.ts +40 -0
  15. package/dist/cache/smartModel.d.ts.map +1 -0
  16. package/dist/cache/smartModel.js +43 -0
  17. package/dist/cache/smartModel.js.map +1 -0
  18. package/dist/cache/stats.d.ts +93 -0
  19. package/dist/cache/stats.d.ts.map +1 -0
  20. package/dist/cache/stats.js +155 -0
  21. package/dist/cache/stats.js.map +1 -0
  22. package/dist/cache/volatile.d.ts +32 -0
  23. package/dist/cache/volatile.d.ts.map +1 -0
  24. package/dist/cache/volatile.js +44 -0
  25. package/dist/cache/volatile.js.map +1 -0
  26. package/dist/cmd/index.js +49 -57
  27. package/dist/cmd/index.js.map +1 -1
  28. package/dist/config/index.d.ts.map +1 -1
  29. package/dist/config/index.js +8 -0
  30. package/dist/config/index.js.map +1 -1
  31. package/dist/config/types.d.ts +14 -0
  32. package/dist/config/types.d.ts.map +1 -1
  33. package/dist/llm/anthropic.d.ts +55 -5
  34. package/dist/llm/anthropic.d.ts.map +1 -1
  35. package/dist/llm/anthropic.js +172 -26
  36. package/dist/llm/anthropic.js.map +1 -1
  37. package/dist/llm/index.d.ts +26 -8
  38. package/dist/llm/index.d.ts.map +1 -1
  39. package/dist/llm/index.js +83 -15
  40. package/dist/llm/index.js.map +1 -1
  41. package/dist/llm/openai.d.ts +28 -8
  42. package/dist/llm/openai.d.ts.map +1 -1
  43. package/dist/llm/openai.js +58 -8
  44. package/dist/llm/openai.js.map +1 -1
  45. package/dist/llm/provider.d.ts +40 -8
  46. package/dist/llm/provider.d.ts.map +1 -1
  47. package/dist/llm/provider.js +8 -1
  48. package/dist/llm/provider.js.map +1 -1
  49. package/dist/prompts/index.d.ts +32 -0
  50. package/dist/prompts/index.d.ts.map +1 -1
  51. package/dist/prompts/index.js +33 -11
  52. package/dist/prompts/index.js.map +1 -1
  53. package/dist/session/index.d.ts +32 -3
  54. package/dist/session/index.d.ts.map +1 -1
  55. package/dist/session/index.js +51 -36
  56. package/dist/session/index.js.map +1 -1
  57. package/dist/skills/brainstorming.js +33 -33
  58. package/dist/skills/code-review.js +35 -35
  59. package/dist/skills/executing-plans.js +18 -18
  60. package/dist/skills/frontend-design.js +25 -25
  61. package/dist/skills/git-workflow.js +26 -26
  62. package/dist/skills/improve-architecture.js +28 -28
  63. package/dist/skills/neuroweave.js +37 -37
  64. package/dist/skills/playwright.js +62 -62
  65. package/dist/skills/prototype.js +20 -20
  66. package/dist/skills/subagent.js +19 -19
  67. package/dist/skills/supabase.js +34 -34
  68. package/dist/skills/systematic-debugging.js +34 -34
  69. package/dist/skills/tdd.js +29 -29
  70. package/dist/skills/triage.js +25 -25
  71. package/dist/skills/verification.js +22 -22
  72. package/dist/skills/writing-plans.js +32 -32
  73. package/dist/tools/nwt.js +16 -16
  74. package/dist/tui/app.d.ts.map +1 -1
  75. package/dist/tui/app.js +9 -2
  76. package/dist/tui/app.js.map +1 -1
  77. package/dist/tui/components/Header.d.ts +8 -0
  78. package/dist/tui/components/Header.d.ts.map +1 -1
  79. package/dist/tui/components/Header.js +17 -4
  80. package/dist/tui/components/Header.js.map +1 -1
  81. package/dist/tui/components/InitWizard.d.ts +12 -1
  82. package/dist/tui/components/InitWizard.d.ts.map +1 -1
  83. package/dist/tui/components/InitWizard.js +58 -3
  84. package/dist/tui/components/InitWizard.js.map +1 -1
  85. package/dist/tui/components/ToolCall.d.ts.map +1 -1
  86. package/dist/tui/components/ToolCall.js +1 -6
  87. package/dist/tui/components/ToolCall.js.map +1 -1
  88. package/dist/tui/hooks/useChat.d.ts +22 -0
  89. package/dist/tui/hooks/useChat.d.ts.map +1 -1
  90. package/dist/tui/hooks/useChat.js +82 -46
  91. package/dist/tui/hooks/useChat.js.map +1 -1
  92. package/dist/tui/hooks/useCommands.d.ts.map +1 -1
  93. package/dist/tui/hooks/useCommands.js +57 -0
  94. package/dist/tui/hooks/useCommands.js.map +1 -1
  95. package/dist/tui/welcome.d.ts.map +1 -1
  96. package/dist/tui/welcome.js +2 -3
  97. package/dist/tui/welcome.js.map +1 -1
  98. package/dist/types.d.ts +79 -6
  99. package/dist/types.d.ts.map +1 -1
  100. package/dist/types.js +7 -0
  101. package/dist/types.js.map +1 -1
  102. package/dist/utils/stableStringify.d.ts +21 -0
  103. package/dist/utils/stableStringify.d.ts.map +1 -0
  104. package/dist/utils/stableStringify.js +53 -0
  105. package/dist/utils/stableStringify.js.map +1 -0
  106. package/dist/utils/thinking.d.ts.map +1 -1
  107. package/dist/utils/thinking.js +0 -3
  108. package/dist/utils/thinking.js.map +1 -1
  109. package/package.json +6 -6
  110. package/src/app/index.ts +180 -124
  111. package/src/cache/fingerprint.ts +101 -0
  112. package/src/cache/index.ts +15 -0
  113. package/src/cache/smartModel.ts +52 -0
  114. package/src/cache/stats.ts +199 -0
  115. package/src/cache/volatile.ts +47 -0
  116. package/src/cmd/index.tsx +288 -292
  117. package/src/config/index.ts +156 -148
  118. package/src/config/providers.ts +234 -234
  119. package/src/config/types.ts +67 -53
  120. package/src/hooks/index.ts +111 -111
  121. package/src/llm/anthropic.ts +388 -243
  122. package/src/llm/gemini.ts +169 -169
  123. package/src/llm/index.ts +265 -200
  124. package/src/llm/openai.ts +243 -196
  125. package/src/llm/provider.ts +66 -34
  126. package/src/prompts/index.ts +260 -220
  127. package/src/session/compactor.ts +103 -103
  128. package/src/session/index.ts +181 -168
  129. package/src/session/message.ts +42 -42
  130. package/src/skills/brainstorming.ts +43 -43
  131. package/src/skills/code-review.ts +45 -45
  132. package/src/skills/executing-plans.ts +27 -27
  133. package/src/skills/frontend-design.ts +35 -35
  134. package/src/skills/git-workflow.ts +36 -36
  135. package/src/skills/improve-architecture.ts +38 -38
  136. package/src/skills/index.ts +136 -136
  137. package/src/skills/neuroweave.ts +47 -47
  138. package/src/skills/playwright.ts +72 -72
  139. package/src/skills/prototype.ts +30 -30
  140. package/src/skills/subagent.ts +28 -28
  141. package/src/skills/supabase.ts +44 -44
  142. package/src/skills/systematic-debugging.ts +44 -44
  143. package/src/skills/tdd.ts +39 -39
  144. package/src/skills/triage.ts +35 -35
  145. package/src/skills/verification.ts +31 -31
  146. package/src/skills/writing-plans.ts +42 -42
  147. package/src/tools/nwt.ts +598 -598
  148. package/src/tools/types.ts +122 -122
  149. package/src/tui/app.tsx +199 -186
  150. package/src/tui/components/ChatList.tsx +41 -41
  151. package/src/tui/components/ChatMessage.tsx +54 -54
  152. package/src/tui/components/Header.tsx +56 -29
  153. package/src/tui/components/InitWizard.tsx +217 -132
  154. package/src/tui/components/Markdown.tsx +35 -35
  155. package/src/tui/components/ModelSelector.tsx +87 -87
  156. package/src/tui/components/StatusBar.tsx +30 -30
  157. package/src/tui/components/Thinking.tsx +17 -17
  158. package/src/tui/components/ToolCall.tsx +102 -107
  159. package/src/tui/components/UserInput.tsx +131 -131
  160. package/src/tui/hooks/useChat.ts +287 -238
  161. package/src/tui/hooks/useCommands.ts +234 -176
  162. package/src/tui/index.ts +6 -6
  163. package/src/tui/welcome.ts +177 -178
  164. package/src/types.ts +104 -42
  165. package/src/utils/diff.ts +71 -71
  166. package/src/utils/project.ts +99 -99
  167. package/src/utils/stableStringify.ts +47 -0
  168. package/src/utils/thinking.ts +118 -121
  169. package/tests/cache/fingerprint.test.ts +75 -0
  170. package/tests/cache/stableStringify.test.ts +43 -0
  171. package/tests/cache/stats.test.ts +146 -0
  172. package/tests/cache/volatile.test.ts +75 -0
  173. package/tests/smoke-pollution.mjs +78 -0
  174. package/tests/smoke-thinking.mjs +110 -0
  175. package/tests/smoke-tool-stream.mjs +69 -0
  176. package/tests/smoke-tool.mjs +117 -0
  177. package/.nwt/meta.json +0 -5
  178. package/dist/version.d.ts +0 -16
  179. package/dist/version.d.ts.map +0 -1
  180. package/dist/version.js +0 -16
  181. package/dist/version.js.map +0 -1
  182. 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
+ }