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.
- package/.claude-plugin/plugin.json +17 -0
- package/CLAUDE.md +98 -0
- package/CONFIG_REFERENCE.md +495 -0
- package/README.md +40 -0
- package/commands/entity.md +64 -0
- package/commands/forget.md +69 -0
- package/commands/remember.md +60 -0
- package/commands/status.md +90 -0
- package/commands/summarize.md +69 -0
- package/hooks/hooks.json +123 -0
- package/package.json +12 -0
- package/scripts/mem-add.mjs +59 -0
- package/scripts/mem-entity.mjs +143 -0
- package/scripts/mem-forget.mjs +245 -0
- package/scripts/mem-status.mjs +319 -0
- package/scripts/mem-summarize.mjs +338 -0
- package/scripts/post-compact.mjs +132 -0
- package/scripts/post-tool-use.mjs +353 -0
- package/scripts/pre-compact.mjs +491 -0
- package/scripts/session-start.mjs +283 -0
- package/scripts/session-stop.mjs +31 -0
- package/scripts/stop-capture.mjs +294 -0
- package/scripts/subagent-stop.mjs +203 -0
- package/scripts/summarize.mjs +428 -0
- package/scripts/sync.mjs +609 -0
- package/scripts/user-prompt-submit.mjs +77 -0
- package/scripts/utils.mjs +2142 -0
- package/scripts/utils.test.mjs +1465 -0
|
@@ -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);
|