claude-mneme 2.9.1

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.
@@ -0,0 +1,491 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * PreCompact Hook - Context Preservation
4
+ *
5
+ * Fires before Claude Code compacts the conversation. This is an opportunity to:
6
+ * 1. Flush pending log entries
7
+ * 2. Extract important context from the transcript before it's compressed
8
+ * 3. Force immediate summarization
9
+ * 4. Optionally save a transcript snapshot
10
+ *
11
+ * All behavior is configurable via ~/.claude-mneme/config.json under "preCompact"
12
+ */
13
+
14
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, unlinkSync } from 'fs';
15
+ import { join, dirname } from 'path';
16
+ import { spawn } from 'child_process';
17
+ import { fileURLToPath } from 'url';
18
+ import { ensureDeps, ensureMemoryDirs, loadConfig, getProjectName, flushPendingLog, appendLogEntry, logError } from './utils.mjs';
19
+
20
+ const __filename = fileURLToPath(import.meta.url);
21
+ const __dirname = dirname(__filename);
22
+
23
+ // Read hook input from stdin
24
+ let input = '';
25
+ process.stdin.setEncoding('utf8');
26
+ process.stdin.on('data', chunk => input += chunk);
27
+ process.stdin.on('end', async () => {
28
+ try {
29
+ const hookData = JSON.parse(input);
30
+ await processPreCompact(hookData);
31
+ } catch (e) {
32
+ console.error(`[claude-mneme] PreCompact error: ${e.message}`);
33
+ process.exit(0);
34
+ }
35
+ });
36
+
37
+ /**
38
+ * Read and parse transcript from file path
39
+ */
40
+ function readTranscript(transcriptPath) {
41
+ if (!transcriptPath || !existsSync(transcriptPath)) {
42
+ return [];
43
+ }
44
+
45
+ try {
46
+ const content = readFileSync(transcriptPath, 'utf-8').trim();
47
+ if (!content) return [];
48
+
49
+ const lines = content.split('\n').filter(l => l.trim());
50
+ const transcript = [];
51
+
52
+ for (const line of lines) {
53
+ try {
54
+ const entry = JSON.parse(line);
55
+ transcript.push(entry);
56
+ } catch {
57
+ // Skip malformed lines
58
+ }
59
+ }
60
+
61
+ return transcript;
62
+ } catch {
63
+ return [];
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Extract text content from a message
69
+ */
70
+ function extractText(content) {
71
+ if (typeof content === 'string') return content;
72
+ if (Array.isArray(content)) {
73
+ return content
74
+ .filter(block => block.type === 'text')
75
+ .map(block => block.text)
76
+ .join('\n');
77
+ }
78
+ return '';
79
+ }
80
+
81
+ /**
82
+ * Extract decisions/choices from transcript
83
+ */
84
+ function extractDecisions(transcript, maxItems) {
85
+ const decisions = [];
86
+ const decisionPatterns = [
87
+ /(?:decided|choosing|going with|selected|picked|using|will use|opted for)\s+(.{10,100})/gi,
88
+ /(?:the approach|the solution|the plan) (?:is|will be)\s+(.{10,100})/gi,
89
+ /(?:let's|we'll|I'll)\s+(?:go with|use|implement)\s+(.{10,100})/gi,
90
+ ];
91
+
92
+ for (const entry of transcript) {
93
+ if (entry.type !== 'assistant') continue;
94
+ const text = extractText(entry.message?.content);
95
+ if (!text) continue;
96
+
97
+ for (const pattern of decisionPatterns) {
98
+ pattern.lastIndex = 0;
99
+ let match;
100
+ while ((match = pattern.exec(text)) !== null && decisions.length < maxItems) {
101
+ const decision = match[1].trim().replace(/[.,:;]$/, '');
102
+ if (decision.length > 10 && !decisions.includes(decision)) {
103
+ decisions.push(decision);
104
+ }
105
+ }
106
+ }
107
+ }
108
+
109
+ return decisions.slice(0, maxItems);
110
+ }
111
+
112
+ /**
113
+ * Extract file paths mentioned in transcript
114
+ */
115
+ function extractFiles(transcript, maxItems) {
116
+ const files = new Set();
117
+ const filePatterns = [
118
+ /(?:^|[\s"'`])([a-zA-Z0-9_\-./]+\.[a-zA-Z]{1,10})(?:[\s"'`:]|$)/g,
119
+ /(?:file|path|in|from|to|edit|read|write|create)\s+[`"']?([a-zA-Z0-9_\-./]+\.[a-zA-Z]{1,10})[`"']?/gi,
120
+ ];
121
+
122
+ const skipExtensions = ['com', 'org', 'net', 'io', 'dev', 'app'];
123
+
124
+ for (const entry of transcript) {
125
+ const text = extractText(entry.message?.content || entry.content);
126
+ if (!text) continue;
127
+
128
+ for (const pattern of filePatterns) {
129
+ pattern.lastIndex = 0;
130
+ let match;
131
+ while ((match = pattern.exec(text)) !== null) {
132
+ const file = match[1];
133
+ const ext = file.split('.').pop()?.toLowerCase();
134
+ if (ext && !skipExtensions.includes(ext) && file.length < 100) {
135
+ files.add(file);
136
+ }
137
+ }
138
+ }
139
+
140
+ if (files.size >= maxItems * 2) break;
141
+ }
142
+
143
+ return Array.from(files).slice(0, maxItems);
144
+ }
145
+
146
+ /**
147
+ * Extract errors encountered in transcript
148
+ */
149
+ function extractErrors(transcript, maxItems) {
150
+ const errors = [];
151
+ const errorPatterns = [
152
+ /(?:error|exception|failed|failure):\s*(.{10,150})/gi,
153
+ /(?:cannot|can't|couldn't|unable to)\s+(.{10,100})/gi,
154
+ /(?:TypeError|ReferenceError|SyntaxError|Error):\s*(.{10,150})/gi,
155
+ ];
156
+
157
+ for (const entry of transcript) {
158
+ const text = extractText(entry.message?.content || entry.content);
159
+ if (!text) continue;
160
+
161
+ for (const pattern of errorPatterns) {
162
+ pattern.lastIndex = 0;
163
+ let match;
164
+ while ((match = pattern.exec(text)) !== null && errors.length < maxItems) {
165
+ const error = match[1].trim().replace(/[.,:;]$/, '');
166
+ if (error.length > 10 && !errors.some(e => e.includes(error) || error.includes(e))) {
167
+ errors.push(error);
168
+ }
169
+ }
170
+ }
171
+ }
172
+
173
+ return errors.slice(0, maxItems);
174
+ }
175
+
176
+ /**
177
+ * Extract TODOs and action items
178
+ */
179
+ function extractTodos(transcript, maxItems) {
180
+ const todos = [];
181
+ const todoPatterns = [
182
+ /(?:TODO|FIXME|HACK|XXX):\s*(.{10,100})/gi,
183
+ /(?:we |you |I )?(?:need to|should|must|have to)\s+((?:add|fix|update|create|remove|implement|refactor|change|move|rename|delete|migrate|test|check|verify|ensure|resolve|handle|configure|set up|clean up)\b.{5,100})/gi,
184
+ /(?:next step|action item|follow.?up):\s*(.{10,100})/gi,
185
+ ];
186
+
187
+ for (const entry of transcript) {
188
+ if (entry.type !== 'assistant') continue;
189
+ const text = extractText(entry.message?.content);
190
+ if (!text) continue;
191
+
192
+ for (const pattern of todoPatterns) {
193
+ pattern.lastIndex = 0;
194
+ let match;
195
+ while ((match = pattern.exec(text)) !== null && todos.length < maxItems) {
196
+ const todo = match[1].trim().replace(/[.,:;]$/, '');
197
+ if (todo.length > 10 && !todos.includes(todo)) {
198
+ todos.push(todo);
199
+ }
200
+ }
201
+ }
202
+ }
203
+
204
+ return todos.slice(0, maxItems);
205
+ }
206
+
207
+ /**
208
+ * Extract key discussion points using AI
209
+ */
210
+ async function extractKeyPoints(transcript, config, maxItems) {
211
+ // Get the last N messages for context
212
+ const recentMessages = transcript.slice(-20);
213
+ const conversationText = recentMessages
214
+ .filter(e => e.type === 'user' || e.type === 'assistant')
215
+ .map(e => {
216
+ const role = e.type === 'user' ? 'User' : 'Assistant';
217
+ const text = extractText(e.message?.content || e.content);
218
+ return `${role}: ${text.substring(0, 500)}`;
219
+ })
220
+ .join('\n\n');
221
+
222
+ if (!conversationText || conversationText.length < 100) {
223
+ return [];
224
+ }
225
+
226
+ const prompt = `Extract the ${maxItems} most important key points from this conversation that should be remembered. Focus on:
227
+ - Decisions made
228
+ - Problems solved
229
+ - Important discoveries
230
+ - User preferences expressed
231
+
232
+ <conversation>
233
+ ${conversationText.substring(0, 4000)}
234
+ </conversation>
235
+
236
+ Output ONLY a JSON array of strings, each being a concise key point (max 100 chars each).
237
+ Example: ["Decided to use TypeScript for type safety", "Fixed auth bug by adding token refresh"]`;
238
+
239
+ try {
240
+ ensureDeps();
241
+ const { query } = await import('@anthropic-ai/claude-agent-sdk');
242
+
243
+ async function* messageGenerator() {
244
+ yield {
245
+ type: 'user',
246
+ message: { role: 'user', content: prompt },
247
+ session_id: `pre-compact-${Date.now()}`,
248
+ parent_tool_use_id: null,
249
+ isSynthetic: true
250
+ };
251
+ }
252
+
253
+ const queryResult = query({
254
+ prompt: messageGenerator(),
255
+ options: {
256
+ model: config.model,
257
+ disallowedTools: ['Bash', 'Read', 'Write', 'Edit', 'Grep', 'Glob', 'WebFetch', 'WebSearch', 'Task', 'TodoWrite'],
258
+ pathToClaudeCodeExecutable: config.claudePath
259
+ }
260
+ });
261
+
262
+ let response = '';
263
+ try {
264
+ for await (const message of queryResult) {
265
+ if (message.type === 'assistant') {
266
+ const content = message.message.content;
267
+ response = Array.isArray(content)
268
+ ? content.filter(c => c.type === 'text').map(c => c.text).join('\n')
269
+ : typeof content === 'string' ? content : '';
270
+ }
271
+ }
272
+ } catch (iterError) {
273
+ if (!response) throw iterError;
274
+ }
275
+
276
+ if (response) {
277
+ const jsonMatch = response.match(/\[[\s\S]*\]/);
278
+ if (jsonMatch) {
279
+ const points = JSON.parse(jsonMatch[0]);
280
+ return points.slice(0, maxItems);
281
+ }
282
+ }
283
+ } catch (error) {
284
+ console.error(`[claude-mneme] Key points extraction error: ${error.message}`);
285
+ }
286
+
287
+ return [];
288
+ }
289
+
290
+ /**
291
+ * Save transcript snapshot, rotating old snapshots to keep at most maxCount.
292
+ */
293
+ function saveSnapshot(transcript, paths, trigger, maxCount = 10) {
294
+ const snapshotDir = join(paths.project, 'snapshots');
295
+ if (!existsSync(snapshotDir)) {
296
+ mkdirSync(snapshotDir, { recursive: true });
297
+ }
298
+
299
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
300
+ const snapshotPath = join(snapshotDir, `pre-compact-${trigger}-${timestamp}.jsonl`);
301
+
302
+ const content = transcript.map(e => JSON.stringify(e)).join('\n');
303
+ writeFileSync(snapshotPath, content + '\n');
304
+
305
+ // Rotate: keep only the most recent maxCount snapshots
306
+ try {
307
+ const files = readdirSync(snapshotDir)
308
+ .filter(f => f.startsWith('pre-compact-') && f.endsWith('.jsonl'))
309
+ .sort(); // Lexicographic sort works because timestamps are ISO-formatted
310
+ if (files.length > maxCount) {
311
+ for (const old of files.slice(0, files.length - maxCount)) {
312
+ unlinkSync(join(snapshotDir, old));
313
+ }
314
+ }
315
+ } catch (e) {
316
+ logError(e, 'pre-compact:snapshotRotation');
317
+ }
318
+
319
+ console.error(`[claude-mneme] Saved transcript snapshot: ${snapshotPath}`);
320
+ return snapshotPath;
321
+ }
322
+
323
+ /**
324
+ * Force run summarization script
325
+ */
326
+ function forceSummarize(cwd) {
327
+ const summarizeScript = join(__dirname, 'summarize.mjs');
328
+
329
+ return new Promise((resolve) => {
330
+ const child = spawn('node', [summarizeScript, cwd], {
331
+ stdio: 'inherit',
332
+ cwd
333
+ });
334
+
335
+ child.on('close', () => resolve());
336
+ child.on('error', () => resolve());
337
+
338
+ // Timeout after 60 seconds
339
+ setTimeout(() => {
340
+ try { child.kill(); } catch {}
341
+ resolve();
342
+ }, 60000);
343
+ });
344
+ }
345
+
346
+ /**
347
+ * Main processing function
348
+ */
349
+ async function processPreCompact(hookData) {
350
+ const { trigger, transcript_path, cwd, custom_instructions } = hookData;
351
+ const workingDir = cwd || process.cwd();
352
+ const config = loadConfig();
353
+ const pcConfig = config.preCompact || {};
354
+
355
+ // Check if hook is enabled
356
+ if (pcConfig.enabled === false) {
357
+ process.exit(0);
358
+ return;
359
+ }
360
+
361
+ // Check if we should respond to this trigger
362
+ const triggers = pcConfig.triggers || ['auto', 'manual'];
363
+ if (!triggers.includes(trigger)) {
364
+ process.exit(0);
365
+ return;
366
+ }
367
+
368
+ const paths = ensureMemoryDirs(workingDir);
369
+ const projectName = getProjectName(workingDir);
370
+
371
+ console.error(`[claude-mneme] PreCompact triggered (${trigger}) for "${projectName}"`);
372
+
373
+ // 1. Flush pending log entries
374
+ if (pcConfig.flushPending !== false) {
375
+ flushPendingLog(workingDir, 0);
376
+ }
377
+
378
+ // 2. Read transcript
379
+ const transcript = readTranscript(transcript_path);
380
+
381
+ // 3. Save snapshot if enabled
382
+ if (pcConfig.saveSnapshot && transcript.length > 0) {
383
+ saveSnapshot(transcript, paths, trigger);
384
+ }
385
+
386
+ // 4. Extract context if enabled
387
+ if (pcConfig.extractContext !== false && transcript.length > 0) {
388
+ const extraction = pcConfig.extraction || {};
389
+ const categories = extraction.categories || {};
390
+ const maxItems = extraction.maxItems || 10;
391
+
392
+ const extracted = {
393
+ ts: new Date().toISOString(),
394
+ trigger,
395
+ customInstructions: custom_instructions || null
396
+ };
397
+
398
+ let hasContent = false;
399
+
400
+ if (categories.decisions !== false) {
401
+ const decisions = extractDecisions(transcript, maxItems);
402
+ if (decisions.length > 0) {
403
+ extracted.decisions = decisions;
404
+ hasContent = true;
405
+ }
406
+ }
407
+
408
+ if (categories.files !== false) {
409
+ const files = extractFiles(transcript, maxItems);
410
+ if (files.length > 0) {
411
+ extracted.files = files;
412
+ hasContent = true;
413
+ }
414
+ }
415
+
416
+ if (categories.errors !== false) {
417
+ const errors = extractErrors(transcript, maxItems);
418
+ if (errors.length > 0) {
419
+ extracted.errors = errors;
420
+ hasContent = true;
421
+ }
422
+ }
423
+
424
+ if (categories.todos !== false) {
425
+ const todos = extractTodos(transcript, maxItems);
426
+ if (todos.length > 0) {
427
+ extracted.todos = todos;
428
+ hasContent = true;
429
+ }
430
+ }
431
+
432
+ if (categories.keyPoints !== false) {
433
+ const keyPoints = await extractKeyPoints(transcript, config, maxItems);
434
+ if (keyPoints.length > 0) {
435
+ extracted.keyPoints = keyPoints;
436
+ hasContent = true;
437
+ }
438
+ }
439
+
440
+ // Save extracted context
441
+ if (hasContent) {
442
+ const extractedPath = join(paths.project, 'extracted-context.json');
443
+
444
+ // Append to existing extractions (keep last 5)
445
+ let extractions = [];
446
+ if (existsSync(extractedPath)) {
447
+ try {
448
+ extractions = JSON.parse(readFileSync(extractedPath, 'utf-8'));
449
+ } catch (e) {
450
+ logError(e, 'pre-compact:extracted-context.json');
451
+ }
452
+ }
453
+
454
+ extractions.push(extracted);
455
+ if (extractions.length > 5) {
456
+ extractions = extractions.slice(-5);
457
+ }
458
+
459
+ writeFileSync(extractedPath, JSON.stringify(extractions, null, 2) + '\n');
460
+ console.error(`[claude-mneme] Extracted context saved (${Object.keys(extracted).length - 3} categories)`);
461
+
462
+ // Log a summary entry
463
+ const summaryParts = [];
464
+ if (extracted.decisions?.length) summaryParts.push(`${extracted.decisions.length} decisions`);
465
+ if (extracted.files?.length) summaryParts.push(`${extracted.files.length} files`);
466
+ if (extracted.errors?.length) summaryParts.push(`${extracted.errors.length} errors`);
467
+ if (extracted.keyPoints?.length) summaryParts.push(`${extracted.keyPoints.length} key points`);
468
+
469
+ if (summaryParts.length > 0) {
470
+ appendLogEntry({
471
+ ts: new Date().toISOString(),
472
+ type: 'compact',
473
+ trigger,
474
+ content: `Pre-compact extraction: ${summaryParts.join(', ')}`
475
+ }, workingDir);
476
+ }
477
+ }
478
+ }
479
+
480
+ // 5. Force summarization if enabled
481
+ if (pcConfig.forceSummarize !== false) {
482
+ console.error(`[claude-mneme] Forcing summarization before compact...`);
483
+ await forceSummarize(workingDir);
484
+ }
485
+
486
+ console.error(`[claude-mneme] PreCompact processing complete`);
487
+ process.exit(0);
488
+ }
489
+
490
+ // Timeout fallback
491
+ setTimeout(() => process.exit(0), 120000);