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,283 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Session Start Hook
4
+ * Reads project-specific memory context and outputs it for injection
5
+ *
6
+ * Uses hierarchical context injection:
7
+ * - HIGH priority: Project context, key decisions, current state, remembered items
8
+ * - MEDIUM priority: Recent work, git changes, active entities
9
+ * - LOW priority: Recent log entries (limited to last few)
10
+ */
11
+
12
+ import { readFileSync, writeFileSync, existsSync } from 'fs';
13
+ import { execFileSync } from 'child_process';
14
+ import { join } from 'path';
15
+ import { ensureMemoryDirs, loadConfig, getProjectName, escapeAttr, formatEntry, renderSummaryToMarkdown, flushPendingLog, scoreEntriesByRelevance, getRelevantEntities, deduplicateEntries, readCachedData, withFileLock, logError, getErrorsSince } from './utils.mjs';
16
+ import { pullIfEnabled, startHeartbeat } from './sync.mjs';
17
+
18
+ async function main() {
19
+ const cwd = process.cwd();
20
+ const paths = ensureMemoryDirs(cwd);
21
+ const config = loadConfig();
22
+ const projectName = getProjectName(cwd);
23
+ const ciConfig = config.contextInjection || {};
24
+ const sections = ciConfig.sections || {};
25
+
26
+ // Flush any pending log entries from previous session
27
+ flushPendingLog(cwd, 0);
28
+
29
+ // Sync: pull files from server if enabled (before reading cached data)
30
+ const syncResult = await pullIfEnabled(cwd, config);
31
+ if (syncResult.lockAcquired) {
32
+ startHeartbeat(cwd, config);
33
+ }
34
+
35
+ // Prune stale tasks (>24h) instead of deleting — avoids wiping another session's tracking
36
+ const taskTrackingPath = join(paths.project, 'active-tasks.json');
37
+ const taskLockPath = taskTrackingPath + '.lock';
38
+ try {
39
+ withFileLock(taskLockPath, () => {
40
+ if (!existsSync(taskTrackingPath)) return;
41
+ const data = JSON.parse(readFileSync(taskTrackingPath, 'utf-8'));
42
+ const tasks = data.tasks || {};
43
+ const cutoff = Date.now() - 24 * 60 * 60 * 1000;
44
+ let pruned = false;
45
+ for (const [id, task] of Object.entries(tasks)) {
46
+ const ts = task.createdAt ? new Date(task.createdAt).getTime() : 0;
47
+ if (ts < cutoff) {
48
+ delete tasks[id];
49
+ pruned = true;
50
+ }
51
+ }
52
+ if (pruned) {
53
+ data.tasks = tasks;
54
+ writeFileSync(taskTrackingPath, JSON.stringify(data));
55
+ }
56
+ }, 5);
57
+ } catch {}
58
+
59
+ // ============================================================================
60
+ // Read all data using cache (avoids redundant file reads/parsing)
61
+ // ============================================================================
62
+ const cachedData = readCachedData(cwd, config);
63
+
64
+ // ============================================================================
65
+ // HIGH PRIORITY - Always inject
66
+ // ============================================================================
67
+
68
+ // Render structured summary
69
+ let summaryParts = { high: '', medium: '', full: '' };
70
+
71
+ if (cachedData.summary) {
72
+ summaryParts = renderSummaryToMarkdown(cachedData.summary, projectName, ciConfig);
73
+ }
74
+
75
+ // Read persistent remembered items (HIGH priority)
76
+ let remembered = [];
77
+ const remConfig = sections.remembered || { enabled: true };
78
+ if (remConfig.enabled !== false) {
79
+ remembered = cachedData.remembered || [];
80
+ }
81
+
82
+ // ============================================================================
83
+ // MEDIUM PRIORITY - Inject if relevant/recent
84
+ // ============================================================================
85
+
86
+ // Git changes since last session
87
+ let gitChanges = '';
88
+ const gcConfig = sections.gitChanges || { enabled: true };
89
+ if (gcConfig.enabled !== false) {
90
+ try {
91
+ let sinceArg = null;
92
+ if (existsSync(paths.lastSession)) {
93
+ sinceArg = readFileSync(paths.lastSession, 'utf-8').trim();
94
+ }
95
+ if (sinceArg) {
96
+ const log = execFileSync('git', ['log', '--oneline', `--since=${sinceArg}`], {
97
+ encoding: 'utf8',
98
+ cwd,
99
+ stdio: ['ignore', 'pipe', 'ignore']
100
+ }).trim();
101
+ if (log) gitChanges = log;
102
+ }
103
+ } catch {
104
+ // Not a git repo or git error — skip silently
105
+ }
106
+ }
107
+
108
+ // Get relevant entities for context (MEDIUM priority)
109
+ let relevantEntities = null;
110
+ const aeConfig = sections.activeEntities || { enabled: true, maxFiles: 5, maxFunctions: 5 };
111
+ const eeConfig = config.entityExtraction || {};
112
+ if (aeConfig.enabled !== false && eeConfig.enabled !== false) {
113
+ try {
114
+ relevantEntities = getRelevantEntities(cwd);
115
+ } catch (e) {
116
+ logError(e, 'session-start:getRelevantEntities');
117
+ }
118
+ }
119
+
120
+ // ============================================================================
121
+ // LOW PRIORITY - Minimal injection
122
+ // ============================================================================
123
+
124
+ // Process log entries by relevance (LOW priority - reduced count)
125
+ let recentEntries = [];
126
+ const reConfig = sections.recentEntries || { enabled: true, maxItems: 4 };
127
+ if (reConfig.enabled !== false && cachedData.logEntries.length > 0) {
128
+ // Filter out response entries (low signal)
129
+ let meaningful = cachedData.logEntries.filter(e => e.type !== 'response');
130
+
131
+ // Apply semantic deduplication - group related entries and keep highest signal
132
+ meaningful = deduplicateEntries(meaningful, config);
133
+
134
+ // Use hierarchical limit (default 4, not 10)
135
+ const maxEntries = reConfig.maxItems || 4;
136
+ const rsConfig = config.relevanceScoring || {};
137
+
138
+ if (rsConfig.enabled !== false && meaningful.length > maxEntries) {
139
+ // Score and rank by relevance
140
+ const ranked = scoreEntriesByRelevance(meaningful, cwd, config);
141
+ recentEntries = ranked.slice(0, maxEntries).map(formatEntry);
142
+ } else {
143
+ // Fall back to simple recency
144
+ recentEntries = meaningful.slice(-maxEntries).map(formatEntry);
145
+ }
146
+ }
147
+
148
+ // Write current timestamp for next session
149
+ try {
150
+ writeFileSync(paths.lastSession, new Date().toISOString(), 'utf-8');
151
+ } catch (e) {
152
+ logError(e, 'session-start:lastSession');
153
+ }
154
+
155
+ // ============================================================================
156
+ // Output - Hierarchical injection
157
+ // ============================================================================
158
+
159
+ // Read handoff from previous session (if recent)
160
+ let handoff = null;
161
+ const lsConfig = sections.lastSession || { enabled: true };
162
+ if (lsConfig.enabled !== false && existsSync(paths.handoff)) {
163
+ try {
164
+ const data = JSON.parse(readFileSync(paths.handoff, 'utf-8'));
165
+ const maxAgeMs = 48 * 60 * 60 * 1000;
166
+ if (data.ts && (Date.now() - new Date(data.ts).getTime()) < maxAgeMs) {
167
+ handoff = data;
168
+ }
169
+ } catch {}
170
+ }
171
+
172
+ const hasContent = summaryParts.high || summaryParts.medium ||
173
+ remembered.length > 0 || gitChanges ||
174
+ recentEntries.length > 0 || relevantEntities || handoff;
175
+
176
+ if (hasContent) {
177
+ console.log(`<claude-mneme project="${escapeAttr(projectName)}">`);
178
+
179
+ // HANDOFF from previous session (highest immediate value)
180
+ if (handoff) {
181
+ console.log('\n## Last Session\n');
182
+ if (handoff.workingOn) console.log(`**Working on:** ${handoff.workingOn}`);
183
+ if (handoff.lastDone) console.log(`**Done:** ${handoff.lastDone}`);
184
+ if (handoff.openItems?.length > 0) {
185
+ console.log(`**Open:** ${handoff.openItems.join(', ')}`);
186
+ }
187
+ }
188
+
189
+ // LESSONS LEARNED - high visibility to avoid repeating mistakes
190
+ const lessons = remembered.filter(r => r.type === 'lesson');
191
+ const otherRemembered = remembered.filter(r => r.type !== 'lesson');
192
+
193
+ if (lessons.length > 0) {
194
+ console.log('\n## Lessons Learned\n');
195
+ for (const item of lessons) {
196
+ console.log(`- ${item.content}`);
197
+ }
198
+ }
199
+
200
+ // HIGH PRIORITY SECTION
201
+ if (summaryParts.high) {
202
+ console.log(summaryParts.high);
203
+ }
204
+
205
+ if (otherRemembered.length > 0) {
206
+ console.log('\n## Remembered\n');
207
+ for (const item of otherRemembered) {
208
+ console.log(`- [${item.type}] ${item.content}`);
209
+ }
210
+ }
211
+
212
+ // MEDIUM PRIORITY SECTION
213
+ if (summaryParts.medium) {
214
+ console.log(summaryParts.medium);
215
+ }
216
+
217
+ if (gitChanges) {
218
+ console.log('\n## Changes Since Last Session\n');
219
+ console.log(gitChanges);
220
+ }
221
+
222
+ if (relevantEntities) {
223
+ const maxFiles = aeConfig.maxFiles || 5;
224
+ const maxFunctions = aeConfig.maxFunctions || 5;
225
+ const hasClusters = relevantEntities.clusters?.length > 0;
226
+ const hasEntities = hasClusters ||
227
+ (relevantEntities.files?.length > 0) ||
228
+ (relevantEntities.functions?.length > 0);
229
+ if (hasEntities) {
230
+ console.log('\n## Recently Active\n');
231
+ const badgeMap = { commit: 'modified', task: 'worked on', prompt: 'discussed', agent: 'worked on', response: 'discussed' };
232
+ const formatEntity = (e) => {
233
+ let line = `\`${e.name}\``;
234
+ const badges = [...new Set((e.contextTypes || []).map(t => badgeMap[t]).filter(Boolean))];
235
+ if (badges.length > 0) line += ` [${badges.join(', ')}]`;
236
+ if (e.velocity) line += ` (${e.velocity})`;
237
+ if (e.recentContext) line += ` — ${e.recentContext}`;
238
+ return `- ${line}`;
239
+ };
240
+ if (hasClusters) {
241
+ for (const cluster of relevantEntities.clusters) {
242
+ const label = cluster.label
243
+ ? cluster.label.charAt(0).toUpperCase() + cluster.label.slice(1)
244
+ : 'Related';
245
+ console.log(`**${label}:**`);
246
+ cluster.entities.forEach(e => console.log(formatEntity(e)));
247
+ }
248
+ }
249
+ if (relevantEntities.files?.length > 0) {
250
+ console.log('**Files:**');
251
+ relevantEntities.files.slice(0, maxFiles).forEach(f => console.log(formatEntity(f)));
252
+ }
253
+ if (relevantEntities.functions?.length > 0) {
254
+ console.log('**Functions:**');
255
+ relevantEntities.functions.slice(0, maxFunctions).forEach(f => console.log(formatEntity(f)));
256
+ }
257
+ }
258
+ }
259
+
260
+ // LOW PRIORITY SECTION
261
+ if (recentEntries.length > 0) {
262
+ console.log('\n## Recent Activity\n');
263
+ recentEntries.forEach(entry => console.log(`- ${entry}`));
264
+ }
265
+
266
+ // Check for recent errors and warn user
267
+ const recentErrors = getErrorsSince(24);
268
+ if (recentErrors.length > 0) {
269
+ console.log(`\n⚠️ **${recentErrors.length} error(s) in the last 24 hours.** Run \`/status\` to diagnose.`);
270
+ }
271
+
272
+ console.log('\nTip: Use /remember to save key decisions, preferences, or project context for future sessions.');
273
+ console.log('</claude-mneme>');
274
+ }
275
+ }
276
+
277
+ main()
278
+ .then(() => process.exit(0))
279
+ .catch(err => {
280
+ logError(err, 'session-start');
281
+ console.error(`[mneme] Error: ${err.message}`);
282
+ process.exit(0); // Exit 0 — memory is non-critical, don't block session startup
283
+ });
@@ -0,0 +1,31 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Session Stop Hook
4
+ * Flushes pending log entries, pushes to sync server, and checks for summarization
5
+ */
6
+
7
+ import { flushPendingLog, loadConfig, logError } from './utils.mjs';
8
+ import { pushIfEnabled, stopHeartbeat } from './sync.mjs';
9
+
10
+ async function main() {
11
+ const cwd = process.cwd();
12
+ const config = loadConfig();
13
+
14
+ // Stop the heartbeat interval
15
+ stopHeartbeat();
16
+
17
+ // Flush all pending entries (throttle=0 forces immediate flush)
18
+ // This also triggers maybeSummarize after merging
19
+ flushPendingLog(cwd, 0);
20
+
21
+ // Sync: push files to server if enabled
22
+ await pushIfEnabled(cwd, config);
23
+ }
24
+
25
+ main()
26
+ .then(() => process.exit(0))
27
+ .catch(err => {
28
+ logError(err, 'session-stop');
29
+ console.error(`[mneme] Error: ${err.message}`);
30
+ process.exit(0); // Exit 0 — memory is non-critical, don't block session lifecycle
31
+ });
@@ -0,0 +1,294 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Stop Hook - Response Capture
4
+ * Captures Claude's final response when a turn completes
5
+ * Supports configurable summarization: none, extractive, or llm
6
+ *
7
+ * Handles markdown formatting:
8
+ * - Splits on paragraph breaks (double newlines)
9
+ * - Treats bullet list items as separate units
10
+ * - Falls back to sentence boundary detection
11
+ *
12
+ * Runs before session-stop.mjs (summarization)
13
+ */
14
+
15
+ import { readFileSync, writeFileSync, existsSync, statSync, openSync, readSync, closeSync } from 'fs';
16
+ import { ensureMemoryDirs, loadConfig, appendLogEntry, extractiveSummarize, stripMarkdown, logError } from './utils.mjs';
17
+
18
+ // Read hook input from stdin
19
+ let input = '';
20
+ process.stdin.setEncoding('utf8');
21
+ process.stdin.on('data', chunk => input += chunk);
22
+ process.stdin.on('end', () => {
23
+ try {
24
+ const hookData = JSON.parse(input);
25
+ processStop(hookData);
26
+ } catch (e) {
27
+ logError(e, 'stop-capture');
28
+ process.exit(0);
29
+ }
30
+ });
31
+
32
+ /**
33
+ * Read and parse transcript from transcript_path
34
+ * Claude Code provides transcript as a JSONL file path, not direct data
35
+ */
36
+ function readTranscript(transcriptPath) {
37
+ if (!transcriptPath || !existsSync(transcriptPath)) {
38
+ return null;
39
+ }
40
+
41
+ try {
42
+ const content = readFileSync(transcriptPath, 'utf-8').trim();
43
+ if (!content) return null;
44
+
45
+ // Parse JSONL - each line is a JSON object
46
+ const lines = content.split('\n').filter(l => l.trim());
47
+ const transcript = [];
48
+
49
+ for (const line of lines) {
50
+ try {
51
+ const entry = JSON.parse(line);
52
+ // Transcript entries have role and message properties
53
+ if (entry.type === 'user' || entry.type === 'assistant') {
54
+ transcript.push({
55
+ role: entry.type,
56
+ content: entry.message?.content || entry.content
57
+ });
58
+ }
59
+ } catch {
60
+ // Skip malformed lines
61
+ }
62
+ }
63
+
64
+ return transcript.length > 0 ? transcript : null;
65
+ } catch {
66
+ return null;
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Extract text content from a message content field (string or content blocks)
72
+ */
73
+ function extractTextContent(content) {
74
+ if (typeof content === 'string') return content;
75
+ if (Array.isArray(content)) {
76
+ return content
77
+ .filter(block => block.type === 'text')
78
+ .map(block => block.text)
79
+ .join('\n');
80
+ }
81
+ return '';
82
+ }
83
+
84
+ const CONFIRMATION_PATTERN = /^(y(es)?|no?|ok(ay)?|sure|go ahead|continue|do it|sounds good|lgtm|looks good|please|yep|yup|nope|correct|right|exactly|agreed|confirmed?)\.?$/i;
85
+
86
+ function isConfirmation(text) {
87
+ return text.trim().length < 20 || CONFIRMATION_PATTERN.test(text.trim());
88
+ }
89
+
90
+ /**
91
+ * Extract open items / next steps from assistant text
92
+ */
93
+ function extractOpenItems(text) {
94
+ if (!text) return [];
95
+ const items = [];
96
+ const patterns = [
97
+ /(?:next steps?|todo|remaining|still need to|should|need to|plan to)[:\s]+(.+)/gi,
98
+ /(?:^|\n)\s*[-*]\s*\[[ ]\]\s*(.+)/gm, // unchecked markdown checkboxes
99
+ ];
100
+ for (const pattern of patterns) {
101
+ let match;
102
+ while ((match = pattern.exec(text)) !== null) {
103
+ const item = match[1].trim().substring(0, 150);
104
+ if (item.length >= 10 && items.length < 5) {
105
+ items.push(item);
106
+ }
107
+ }
108
+ }
109
+ return items;
110
+ }
111
+
112
+ /**
113
+ * Build handoff data from transcript for next session pickup
114
+ */
115
+ function extractHandoff(transcript, responseSummary) {
116
+ // Find last meaningful user prompt (walk backward, skip confirmations)
117
+ let workingOn = null;
118
+ for (let i = transcript.length - 1; i >= 0; i--) {
119
+ if (transcript[i].role === 'user') {
120
+ const text = extractTextContent(transcript[i].content);
121
+ if (text && text.length >= 20 && !isConfirmation(text)) {
122
+ workingOn = text.substring(0, 300);
123
+ break;
124
+ }
125
+ }
126
+ }
127
+
128
+ // Get open items from last assistant response
129
+ let lastAssistantText = '';
130
+ for (let i = transcript.length - 1; i >= 0; i--) {
131
+ if (transcript[i].role === 'assistant') {
132
+ lastAssistantText = extractTextContent(transcript[i].content);
133
+ break;
134
+ }
135
+ }
136
+
137
+ return {
138
+ ts: new Date().toISOString(),
139
+ workingOn,
140
+ lastDone: responseSummary || null,
141
+ openItems: extractOpenItems(lastAssistantText),
142
+ };
143
+ }
144
+
145
+ /**
146
+ * Read last N lines from a file efficiently (tail of file only)
147
+ */
148
+ function readLastLines(filePath, count) {
149
+ try {
150
+ const stat = statSync(filePath);
151
+ if (stat.size === 0) return [];
152
+ const readSize = Math.min(stat.size, 4096);
153
+ const buf = Buffer.alloc(readSize);
154
+ const fd = openSync(filePath, 'r');
155
+ readSync(fd, buf, 0, readSize, stat.size - readSize);
156
+ closeSync(fd);
157
+ const lines = buf.toString('utf-8').trim().split('\n');
158
+ return lines.slice(-count);
159
+ } catch {
160
+ return [];
161
+ }
162
+ }
163
+
164
+ /**
165
+ * Check if the last response entry already has identical content.
166
+ * Prevents duplicate logging when Stop fires but the transcript's last
167
+ * assistant message hasn't changed (e.g. current turn used a subagent).
168
+ */
169
+ function isDuplicateResponse(content, logPath) {
170
+ const pendingPath = logPath.replace('.jsonl', '.pending.jsonl');
171
+ const filesToCheck = [pendingPath, logPath].filter(f => existsSync(f));
172
+ const prefix = content.substring(0, 100).toLowerCase();
173
+
174
+ for (const filePath of filesToCheck) {
175
+ const lastLines = readLastLines(filePath, 5);
176
+ for (const line of lastLines) {
177
+ if (!line) continue;
178
+ try {
179
+ const entry = JSON.parse(line);
180
+ if (entry.type === 'response') {
181
+ const existing = (entry.content || '').substring(0, 100).toLowerCase();
182
+ if (prefix === existing || prefix.startsWith(existing) || existing.startsWith(prefix)) {
183
+ return true;
184
+ }
185
+ }
186
+ } catch {}
187
+ }
188
+ }
189
+ return false;
190
+ }
191
+
192
+ function processStop(hookData) {
193
+ const { transcript_path, cwd } = hookData;
194
+
195
+ // Read transcript from file path (Claude Code passes path, not data)
196
+ const transcript = readTranscript(transcript_path);
197
+
198
+ if (!transcript || transcript.length === 0) {
199
+ process.exit(0);
200
+ return;
201
+ }
202
+
203
+ // Find the last assistant message in the transcript
204
+ let lastAssistantMessage = null;
205
+ for (let i = transcript.length - 1; i >= 0; i--) {
206
+ if (transcript[i].role === 'assistant') {
207
+ lastAssistantMessage = transcript[i];
208
+ break;
209
+ }
210
+ }
211
+
212
+ if (!lastAssistantMessage) {
213
+ process.exit(0);
214
+ return;
215
+ }
216
+
217
+ // Extract text content from the assistant message
218
+ const content = lastAssistantMessage.content;
219
+ let textContent = '';
220
+
221
+ if (typeof content === 'string') {
222
+ textContent = content;
223
+ } else if (Array.isArray(content)) {
224
+ // Content blocks - extract text blocks only
225
+ textContent = content
226
+ .filter(block => block.type === 'text')
227
+ .map(block => block.text)
228
+ .join('\n');
229
+ }
230
+
231
+ if (!textContent || textContent.trim().length === 0) {
232
+ process.exit(0);
233
+ return;
234
+ }
235
+
236
+ // Skip /remember command responses (already persisted in remembered.json)
237
+ const rememberPatterns = [
238
+ /what would you like me to remember/i,
239
+ /remembered\.json/,
240
+ /this will persist across all future sessions/i,
241
+ ];
242
+ if (rememberPatterns.some(p => p.test(textContent))) {
243
+ process.exit(0);
244
+ return;
245
+ }
246
+
247
+ const config = loadConfig();
248
+ let processed = stripMarkdown(textContent);
249
+
250
+ // Apply response summarization based on configured mode
251
+ const mode = config.responseSummarization || 'none';
252
+ if (mode === 'extractive') {
253
+ processed = extractiveSummarize(processed, config);
254
+ }
255
+ // 'llm' mode: reserved for future LLM-based summarization
256
+ // 'none': no summarization, just length cap below
257
+
258
+ // Apply max length truncation as final safeguard
259
+ if (processed.length > config.maxResponseLength) {
260
+ processed = processed.substring(0, config.maxResponseLength) + '...';
261
+ }
262
+
263
+ const workDir = cwd || process.cwd();
264
+ const paths = ensureMemoryDirs(workDir);
265
+
266
+ // Skip if the last response entry already has identical content
267
+ // (happens when Stop fires but transcript's last assistant msg is stale,
268
+ // e.g. current turn used a subagent whose output came via tool result)
269
+ if (isDuplicateResponse(processed, paths.log)) {
270
+ process.exit(0);
271
+ return;
272
+ }
273
+
274
+ const entry = {
275
+ ts: new Date().toISOString(),
276
+ type: 'response',
277
+ content: processed
278
+ };
279
+
280
+ appendLogEntry(entry, workDir);
281
+
282
+ // Write handoff for next session pickup
283
+ try {
284
+ const handoff = extractHandoff(transcript, processed);
285
+ writeFileSync(paths.handoff, JSON.stringify(handoff, null, 2));
286
+ } catch (e) {
287
+ logError(e, 'stop-capture:handoff');
288
+ }
289
+
290
+ process.exit(0);
291
+ }
292
+
293
+ // Timeout fallback
294
+ setTimeout(() => process.exit(0), 10000);