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,338 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Force Manual Summarization
4
+ *
5
+ * Triggers summarization regardless of entry count.
6
+ * Used by the /summarize slash command.
7
+ *
8
+ * Usage: node mem-summarize.mjs [--dry-run]
9
+ *
10
+ * Options:
11
+ * --dry-run Show what would be summarized without actually doing it
12
+ */
13
+
14
+ import { readFileSync, writeFileSync, existsSync, unlinkSync } from 'fs';
15
+ import {
16
+ ensureDeps,
17
+ ensureMemoryDirs,
18
+ loadConfig,
19
+ getProjectName,
20
+ formatEntriesForSummary,
21
+ emptyStructuredSummary,
22
+ deduplicateEntries,
23
+ flushPendingLog,
24
+ logError
25
+ } from './utils.mjs';
26
+
27
+ const cwd = process.cwd();
28
+ const dryRun = process.argv.includes('--dry-run');
29
+ const paths = ensureMemoryDirs(cwd);
30
+ const config = loadConfig();
31
+ const projectName = getProjectName(cwd);
32
+
33
+ // Flush any pending entries first
34
+ flushPendingLog(cwd, 0);
35
+
36
+ // Check if log exists
37
+ if (!existsSync(paths.log)) {
38
+ console.log(JSON.stringify({
39
+ project: projectName,
40
+ status: 'empty',
41
+ message: 'No log entries to summarize.'
42
+ }));
43
+ process.exit(0);
44
+ }
45
+
46
+ const logContent = readFileSync(paths.log, 'utf-8').trim();
47
+ if (!logContent) {
48
+ console.log(JSON.stringify({
49
+ project: projectName,
50
+ status: 'empty',
51
+ message: 'No log entries to summarize.'
52
+ }));
53
+ process.exit(0);
54
+ }
55
+
56
+ const lines = logContent.split('\n').filter(l => l);
57
+ const entryCount = lines.length;
58
+
59
+ // Check for lock
60
+ const lockFile = paths.log + '.lock';
61
+ if (existsSync(lockFile)) {
62
+ const lockContent = readFileSync(lockFile, 'utf-8').trim();
63
+ const lockTime = parseInt(lockContent, 10);
64
+ if (lockTime && Date.now() - lockTime < 5 * 60 * 1000) {
65
+ console.log(JSON.stringify({
66
+ project: projectName,
67
+ status: 'locked',
68
+ message: 'Summarization already in progress.'
69
+ }));
70
+ process.exit(0);
71
+ }
72
+ }
73
+
74
+ // Dry run - just report what would happen
75
+ if (dryRun) {
76
+ const keepCount = Math.min(config.keepRecentEntries, entryCount);
77
+ const summarizeCount = entryCount - keepCount;
78
+
79
+ // Read existing summary state
80
+ let hasSummary = false;
81
+ if (existsSync(paths.summaryJson)) {
82
+ try {
83
+ const summary = JSON.parse(readFileSync(paths.summaryJson, 'utf-8'));
84
+ hasSummary = !!summary.lastUpdated;
85
+ } catch (e) {
86
+ logError(e, 'mem-summarize:summary.json');
87
+ }
88
+ }
89
+
90
+ console.log(JSON.stringify({
91
+ project: projectName,
92
+ status: 'dry_run',
93
+ logEntries: entryCount,
94
+ wouldSummarize: summarizeCount,
95
+ wouldKeep: keepCount,
96
+ hasSummary,
97
+ summaryPath: paths.summaryJson
98
+ }, null, 2));
99
+ process.exit(0);
100
+ }
101
+
102
+ // Minimum entries to summarize (at least 3 to be meaningful)
103
+ const minEntriesToSummarize = 3;
104
+ if (entryCount < minEntriesToSummarize) {
105
+ console.log(JSON.stringify({
106
+ project: projectName,
107
+ status: 'skipped',
108
+ message: `Only ${entryCount} entries. Need at least ${minEntriesToSummarize} to summarize.`,
109
+ logEntries: entryCount
110
+ }));
111
+ process.exit(0);
112
+ }
113
+
114
+ // Acquire lock
115
+ writeFileSync(lockFile, Date.now().toString());
116
+
117
+ try {
118
+ // Ensure SDK is installed, then import
119
+ ensureDeps();
120
+ const { query } = await import('@anthropic-ai/claude-agent-sdk');
121
+
122
+ // Read existing summary
123
+ let existingSummary = emptyStructuredSummary();
124
+ if (existsSync(paths.summaryJson)) {
125
+ try {
126
+ existingSummary = JSON.parse(readFileSync(paths.summaryJson, 'utf-8'));
127
+ } catch (e) {
128
+ logError(e, 'mem-summarize:existingSummary');
129
+ }
130
+ }
131
+
132
+ // Calculate entries to summarize vs keep
133
+ const summarizeCount = Math.max(0, entryCount - config.keepRecentEntries);
134
+ if (summarizeCount === 0) {
135
+ console.log(JSON.stringify({
136
+ project: projectName,
137
+ status: 'skipped',
138
+ message: 'All entries are within the keep-recent window.',
139
+ logEntries: entryCount,
140
+ keepRecentEntries: config.keepRecentEntries
141
+ }));
142
+ process.exit(0);
143
+ }
144
+
145
+ const entriesToSummarize = lines.slice(0, summarizeCount);
146
+ const entriesToKeep = lines.slice(summarizeCount);
147
+
148
+ // Parse and deduplicate entries
149
+ const parsedEntries = entriesToSummarize.map(line => {
150
+ try { return JSON.parse(line); }
151
+ catch { return null; }
152
+ }).filter(Boolean);
153
+
154
+ const deduped = deduplicateEntries(parsedEntries, config);
155
+ const dedupedLines = deduped.map(e => JSON.stringify(e));
156
+ const entriesText = formatEntriesForSummary(dedupedLines);
157
+
158
+ // Build context from existing summary
159
+ const existingContext = [];
160
+ if (existingSummary.projectContext) {
161
+ existingContext.push(`Project: ${existingSummary.projectContext}`);
162
+ }
163
+ if (existingSummary.keyDecisions?.length > 0) {
164
+ existingContext.push(`Key decisions: ${existingSummary.keyDecisions.map(d => d.decision).join('; ')}`);
165
+ }
166
+ if (existingSummary.currentState?.length > 0) {
167
+ existingContext.push(`Current state: ${existingSummary.currentState.map(s => `${s.topic}: ${s.status}`).join('; ')}`);
168
+ }
169
+
170
+ const prompt = `You are updating a structured memory for project "${projectName}".
171
+
172
+ <existing_context>
173
+ ${existingContext.join('\n') || '(New project, no existing context)'}
174
+ Recent work items: ${existingSummary.recentWork?.length || 0}
175
+ </existing_context>
176
+
177
+ <new_entries>
178
+ ${entriesText}
179
+ </new_entries>
180
+
181
+ Analyze the new entries and output a JSON object with updates:
182
+
183
+ {
184
+ "projectContext": "Updated project description if new info changes it, or null to keep existing",
185
+ "newKeyDecisions": [
186
+ { "date": "YYYY-MM-DD", "decision": "Important architectural/design choice", "reason": "Why" }
187
+ ],
188
+ "updateCurrentState": [
189
+ { "topic": "Feature name", "status": "New or updated status" }
190
+ ],
191
+ "newRecentWork": [
192
+ { "date": "YYYY-MM-DD", "summary": "What was done" }
193
+ ],
194
+ "promoteToCurrentState": [],
195
+ "removeFromRecentWork": []
196
+ }
197
+
198
+ Rules:
199
+ - Only include fields that have updates (use empty arrays for no changes)
200
+ - Key decisions: major architectural choices, technology decisions, design patterns
201
+ - Current state: features implemented, work in progress, known issues
202
+ - Recent work: specific tasks completed in this batch of entries
203
+ - Merge similar entries, avoid duplicates
204
+ - Be concise — each item should be one clear sentence
205
+ - Output ONLY the JSON object`;
206
+
207
+ console.error(`[claude-mneme] Summarizing ${entriesToSummarize.length} entries for "${projectName}"...`);
208
+
209
+ async function* messageGenerator() {
210
+ yield {
211
+ type: 'user',
212
+ message: { role: 'user', content: prompt },
213
+ session_id: `memory-summarize-manual-${Date.now()}`,
214
+ parent_tool_use_id: null,
215
+ isSynthetic: true
216
+ };
217
+ }
218
+
219
+ const queryResult = query({
220
+ prompt: messageGenerator(),
221
+ options: {
222
+ model: config.model,
223
+ disallowedTools: ['Bash', 'Read', 'Write', 'Edit', 'Grep', 'Glob', 'WebFetch', 'WebSearch', 'Task', 'TodoWrite'],
224
+ pathToClaudeCodeExecutable: config.claudePath
225
+ }
226
+ });
227
+
228
+ let response = '';
229
+ try {
230
+ for await (const message of queryResult) {
231
+ if (message.type === 'assistant') {
232
+ const content = message.message.content;
233
+ response = Array.isArray(content)
234
+ ? content.filter(c => c.type === 'text').map(c => c.text).join('\n')
235
+ : typeof content === 'string' ? content : '';
236
+ }
237
+ }
238
+ } catch (iterError) {
239
+ if (!response) throw iterError;
240
+ }
241
+
242
+ if (!response) {
243
+ throw new Error('No response from summarization model');
244
+ }
245
+
246
+ const jsonMatch = response.match(/\{[\s\S]*\}/);
247
+ if (!jsonMatch) {
248
+ throw new Error('Could not parse JSON from response');
249
+ }
250
+
251
+ const updates = JSON.parse(jsonMatch[0]);
252
+
253
+ // Apply updates to summary
254
+ const result = { ...existingSummary };
255
+
256
+ if (updates.projectContext) {
257
+ result.projectContext = updates.projectContext;
258
+ }
259
+
260
+ if (updates.newKeyDecisions?.length > 0) {
261
+ result.keyDecisions = [...(result.keyDecisions || []), ...updates.newKeyDecisions];
262
+ }
263
+
264
+ if (updates.updateCurrentState?.length > 0) {
265
+ const stateMap = new Map((result.currentState || []).map(s => [s.topic, s]));
266
+ for (const update of updates.updateCurrentState) {
267
+ stateMap.set(update.topic, update);
268
+ }
269
+ result.currentState = Array.from(stateMap.values());
270
+ }
271
+
272
+ let recentWork = [...(result.recentWork || [])];
273
+
274
+ // Remove stale items (process in reverse to preserve indices)
275
+ if (updates.removeFromRecentWork?.length > 0) {
276
+ const toRemove = new Set(updates.removeFromRecentWork.map(Number));
277
+ recentWork = recentWork.filter((_, i) => !toRemove.has(i));
278
+ }
279
+
280
+ // Promote items to current state
281
+ if (updates.promoteToCurrentState?.length > 0) {
282
+ const toPromote = new Set(updates.promoteToCurrentState.map(Number));
283
+ for (const idx of toPromote) {
284
+ if (result.recentWork?.[idx]) {
285
+ const item = result.recentWork[idx];
286
+ result.currentState = result.currentState || [];
287
+ result.currentState.push({
288
+ topic: 'Completed',
289
+ status: item.summary
290
+ });
291
+ }
292
+ }
293
+ recentWork = recentWork.filter((_, i) => !toPromote.has(i));
294
+ }
295
+
296
+ if (updates.newRecentWork?.length > 0) {
297
+ recentWork = [...recentWork, ...updates.newRecentWork];
298
+ }
299
+ result.recentWork = recentWork.slice(-10);
300
+
301
+ if (result.currentState?.length > 15) {
302
+ result.currentState = result.currentState.slice(-15);
303
+ }
304
+ if (result.keyDecisions?.length > 10) {
305
+ result.keyDecisions = result.keyDecisions.slice(-10);
306
+ }
307
+
308
+ result.lastUpdated = new Date().toISOString();
309
+
310
+ // Write updated summary
311
+ writeFileSync(paths.summaryJson, JSON.stringify(result, null, 2) + '\n');
312
+
313
+ // Re-read the log to preserve any entries appended during summarization
314
+ const currentLogContent = readFileSync(paths.log, 'utf-8').trim();
315
+ const currentLines = currentLogContent ? currentLogContent.split('\n').filter(l => l) : [];
316
+ const remainingLines = currentLines.slice(summarizeCount);
317
+ writeFileSync(paths.log, remainingLines.join('\n') + (remainingLines.length ? '\n' : ''));
318
+
319
+ console.log(JSON.stringify({
320
+ project: projectName,
321
+ status: 'success',
322
+ summarized: entriesToSummarize.length,
323
+ kept: remainingLines.length,
324
+ summaryUpdated: result.lastUpdated
325
+ }));
326
+
327
+ } catch (error) {
328
+ logError(error, 'summarize');
329
+ console.log(JSON.stringify({
330
+ project: projectName,
331
+ status: 'error',
332
+ message: error.message
333
+ }));
334
+ process.exit(1);
335
+
336
+ } finally {
337
+ try { unlinkSync(lockFile); } catch {}
338
+ }
@@ -0,0 +1,132 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Post-Compact Hook (via SessionStart with "compact" matcher)
4
+ *
5
+ * Fires after Claude Code compacts the conversation.
6
+ * Injects extracted context back into the conversation to restore important information.
7
+ *
8
+ * Configurable via ~/.claude-mneme/config.json under "postCompact"
9
+ */
10
+
11
+ import { readFileSync, existsSync } from 'fs';
12
+ import { join } from 'path';
13
+ import { ensureMemoryDirs, loadConfig, getProjectName, escapeAttr } from './utils.mjs';
14
+
15
+ const cwd = process.cwd();
16
+ const paths = ensureMemoryDirs(cwd);
17
+ const config = loadConfig();
18
+ const projectName = getProjectName(cwd);
19
+ const pcConfig = config.postCompact || {};
20
+
21
+ // Check if hook is enabled
22
+ if (pcConfig.enabled === false) {
23
+ process.exit(0);
24
+ }
25
+
26
+ // Read extracted context from PreCompact
27
+ const extractedPath = join(paths.project, 'extracted-context.json');
28
+ let extractions = [];
29
+
30
+ if (existsSync(extractedPath)) {
31
+ try {
32
+ extractions = JSON.parse(readFileSync(extractedPath, 'utf-8'));
33
+ } catch {
34
+ extractions = [];
35
+ }
36
+ }
37
+
38
+ // Get the most recent extraction
39
+ const latest = extractions[extractions.length - 1];
40
+
41
+ if (!latest) {
42
+ // No extracted context to inject
43
+ process.exit(0);
44
+ }
45
+
46
+ // Check how recent the extraction is (within last 5 minutes)
47
+ const extractionAge = Date.now() - new Date(latest.ts).getTime();
48
+ const maxAge = (pcConfig.maxAgeMinutes || 5) * 60 * 1000;
49
+
50
+ if (extractionAge > maxAge) {
51
+ // Extraction is too old, skip injection
52
+ process.exit(0);
53
+ }
54
+
55
+ // Build injection output
56
+ const sections = [];
57
+
58
+ sections.push(`<claude-mneme-restored project="${escapeAttr(projectName)}">`);
59
+ sections.push('## Context Restored After Compaction\n');
60
+ sections.push('The following context was extracted before compaction and may be relevant:\n');
61
+
62
+ // Inject based on configuration
63
+ const categories = pcConfig.categories || {
64
+ keyPoints: true,
65
+ decisions: true,
66
+ files: true,
67
+ errors: true,
68
+ todos: true
69
+ };
70
+
71
+ if (categories.keyPoints !== false && latest.keyPoints?.length > 0) {
72
+ sections.push('### Key Points');
73
+ for (const point of latest.keyPoints) {
74
+ sections.push(`- ${point}`);
75
+ }
76
+ sections.push('');
77
+ }
78
+
79
+ if (categories.decisions !== false && latest.decisions?.length > 0) {
80
+ sections.push('### Decisions Made');
81
+ for (const decision of latest.decisions) {
82
+ sections.push(`- ${decision}`);
83
+ }
84
+ sections.push('');
85
+ }
86
+
87
+ if (categories.files !== false && latest.files?.length > 0) {
88
+ const maxFiles = pcConfig.maxFiles || 10;
89
+ const files = latest.files.slice(0, maxFiles);
90
+ sections.push('### Files Referenced');
91
+ sections.push(`\`${files.join('`, `')}\``);
92
+ sections.push('');
93
+ }
94
+
95
+ if (categories.errors !== false && latest.errors?.length > 0) {
96
+ sections.push('### Errors Encountered');
97
+ for (const error of latest.errors) {
98
+ sections.push(`- ${error}`);
99
+ }
100
+ sections.push('');
101
+ }
102
+
103
+ if (categories.todos !== false && latest.todos?.length > 0) {
104
+ sections.push('### Pending Items');
105
+ for (const todo of latest.todos) {
106
+ sections.push(`- ${todo}`);
107
+ }
108
+ sections.push('');
109
+ }
110
+
111
+ // Add custom instructions if any were provided during compact
112
+ if (latest.customInstructions) {
113
+ sections.push('### User Instructions for Compaction');
114
+ sections.push(latest.customInstructions);
115
+ sections.push('');
116
+ }
117
+
118
+ sections.push('</claude-mneme-restored>');
119
+
120
+ // Only output if we have meaningful content
121
+ const hasContent = latest.keyPoints?.length > 0 ||
122
+ latest.decisions?.length > 0 ||
123
+ latest.files?.length > 0 ||
124
+ latest.errors?.length > 0 ||
125
+ latest.todos?.length > 0 ||
126
+ latest.customInstructions;
127
+
128
+ if (hasContent) {
129
+ console.log(sections.join('\n'));
130
+ }
131
+
132
+ process.exit(0);