@lh8ppl/claude-memory-kit 0.1.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 (81) hide show
  1. package/bin/cmk-compress-lazy.mjs +59 -0
  2. package/bin/cmk-daily-distill.mjs +67 -0
  3. package/bin/cmk-weekly-curate.mjs +56 -0
  4. package/bin/cmk.mjs +12 -0
  5. package/package.json +50 -0
  6. package/src/audit-log.mjs +103 -0
  7. package/src/auto-extract.mjs +742 -0
  8. package/src/capture-prompt.mjs +61 -0
  9. package/src/capture-turn.mjs +273 -0
  10. package/src/claude-md.mjs +212 -0
  11. package/src/compress-session.mjs +349 -0
  12. package/src/compressor.mjs +376 -0
  13. package/src/conflict-queue.mjs +796 -0
  14. package/src/cooldown.mjs +61 -0
  15. package/src/daily-distill.mjs +252 -0
  16. package/src/doctor.mjs +528 -0
  17. package/src/forget.mjs +335 -0
  18. package/src/frontmatter.mjs +73 -0
  19. package/src/import-anthropic-memory.mjs +266 -0
  20. package/src/index-db.mjs +154 -0
  21. package/src/index-rebuild.mjs +597 -0
  22. package/src/index.mjs +90 -0
  23. package/src/inject-context.mjs +484 -0
  24. package/src/install.mjs +327 -0
  25. package/src/lazy-compress.mjs +326 -0
  26. package/src/lock-discipline.mjs +166 -0
  27. package/src/mcp-server.mjs +498 -0
  28. package/src/memory-write.mjs +565 -0
  29. package/src/merge-facts.mjs +213 -0
  30. package/src/observe-edit.mjs +87 -0
  31. package/src/platform-commands.mjs +138 -0
  32. package/src/poison-guard.mjs +245 -0
  33. package/src/privacy.mjs +21 -0
  34. package/src/provenance.mjs +217 -0
  35. package/src/register-crons.mjs +354 -0
  36. package/src/reindex.mjs +134 -0
  37. package/src/repair.mjs +316 -0
  38. package/src/result-shapes.mjs +155 -0
  39. package/src/review-queue.mjs +345 -0
  40. package/src/roll.mjs +115 -0
  41. package/src/scratchpad.mjs +335 -0
  42. package/src/search.mjs +311 -0
  43. package/src/subcommands.mjs +1252 -0
  44. package/src/tier-paths.mjs +74 -0
  45. package/src/transcripts.mjs +234 -0
  46. package/src/trust.mjs +226 -0
  47. package/src/weekly-curate.mjs +454 -0
  48. package/src/write-fact.mjs +205 -0
  49. package/template/.claude/hooks/pre-tool-memory.js +78 -0
  50. package/template/.claude/hooks/transcript-capture.js +69 -0
  51. package/template/.claude/settings.json +27 -0
  52. package/template/.claude/skills/memory-write/SKILL.md +117 -0
  53. package/template/.gitignore.fragment +12 -0
  54. package/template/CLAUDE.md.template +49 -0
  55. package/template/docs/journey/journey-log.md.template +292 -0
  56. package/template/local/machine-paths.md.template +37 -0
  57. package/template/local/overrides.md.template +36 -0
  58. package/template/project/.index/.gitkeep +0 -0
  59. package/template/project/MEMORY.md.template +47 -0
  60. package/template/project/SOUL.md.template +35 -0
  61. package/template/project/memory/INDEX.md.template +47 -0
  62. package/template/project/memory/archive/superseded/.gitkeep +0 -0
  63. package/template/project/memory/archive/tombstones/.gitkeep +0 -0
  64. package/template/project/queues/.gitkeep +0 -0
  65. package/template/project/sessions/.gitkeep +0 -0
  66. package/template/project/transcripts/.gitkeep +0 -0
  67. package/template/support/cron-jobs/daily-memory-distill.md +15 -0
  68. package/template/support/cron-jobs/nightly-memsearch-index.md +17 -0
  69. package/template/support/cron-jobs/weekly-memory-curator.md +15 -0
  70. package/template/support/milvus-deploy/README.md +57 -0
  71. package/template/support/milvus-deploy/docker-compose.yml +66 -0
  72. package/template/support/scripts/auto-extract-memory.sh +102 -0
  73. package/template/support/scripts/memsearch-index-with-flush.sh +59 -0
  74. package/template/support/scripts/refresh-distill-timestamp.py +35 -0
  75. package/template/support/scripts/register-crons.py +242 -0
  76. package/template/support/scripts/run-daily-distill.sh +67 -0
  77. package/template/support/scripts/run-weekly-curate.sh +58 -0
  78. package/template/user/HABITS.md.template +18 -0
  79. package/template/user/LESSONS.md.template +18 -0
  80. package/template/user/USER.md.template +18 -0
  81. package/template/user/fragments/INDEX.md.template +23 -0
@@ -0,0 +1,742 @@
1
+ // Auto-extract subagent (Task 23, T-020).
2
+ //
3
+ // Pattern inspired by claude-remember (https://github.com/Digital-
4
+ // Process-Tools/claude-remember, Community License) — see
5
+ // docs/research/2026-05-25-claude-remember-code-dive.md and SOURCES.md
6
+ // for the absorbed ideas + license posture. Implementation written
7
+ // from scratch per design.md §6; no code or prompts copied verbatim.
8
+ //
9
+ // Spawned detached by Task 21's Stop hook (cmk-capture-turn). Reads
10
+ // the just-captured turn pair (user prompt + assistant response) from
11
+ // a temp file, asks a sandboxed Haiku to identify durable facts per
12
+ // the six writing triggers from design §6.4, then routes each
13
+ // candidate by trust:
14
+ // high → memoryWrite({action:'add', tier:'P', ...}) — same public
15
+ // boundary the user-explicit memory-write Skill uses. The
16
+ // write goes through Poison_Guard (design §6.7) before
17
+ // touching MEMORY.md (Active Threads). Task 24 closed the
18
+ // documented Poison_Guard bypass that Task 23 left open.
19
+ // medium → appended to context/queues/review.md (user reviews via
20
+ // `cmk queue review`).
21
+ // low → discarded; logged as skipped_reason "nothing_durable".
22
+ //
23
+ // Bi-turn extraction (2026-05-26 amendment, see design §6.4 +
24
+ // docs/journey/2026-05-26-live-test-findings.md): the temp file
25
+ // carries BOTH turns with explicit USER_TURN: / ASSISTANT_TURN:
26
+ // markers. Haiku tags each candidate with origin (user|assistant);
27
+ // assistant-origin candidates demote one trust level (HIGH → MEDIUM,
28
+ // MEDIUM → LOW, LOW → discarded) so assistant inferences land in the
29
+ // review queue for user confirmation rather than auto-applying. The
30
+ // <retain> override beats demotion (force-promotes to HIGH).
31
+ // Within-call dedup by canonical-ID keeps the higher-trust candidate
32
+ // when the user states a fact and the assistant echoes it.
33
+ //
34
+ // Public boundary: runAutoExtract({turnFile, projectRoot, haikuBackend,
35
+ // now, sessionId}) → result. The bin wrapper at
36
+ // plugin/bin/cmk-auto-extract.mjs constructs a real
37
+ // HaikuViaAnthropicApi and calls this function with the turn file
38
+ // path passed in argv[2] from Task 21's spawn.
39
+
40
+ import {
41
+ existsSync,
42
+ mkdirSync,
43
+ openSync,
44
+ closeSync,
45
+ writeSync,
46
+ readFileSync,
47
+ unlinkSync,
48
+ appendFileSync,
49
+ } from 'node:fs';
50
+ import { join, dirname } from 'node:path';
51
+ import { generateId } from '@lh8ppl/cmk-canonicalize';
52
+ import { memoryWrite } from './memory-write.mjs';
53
+ import { HaikuTimeoutError } from './compressor.mjs';
54
+ import { pidIsAlive } from './lock-discipline.mjs';
55
+ import { nowIso } from './audit-log.mjs';
56
+ import { ERROR_CATEGORIES } from './result-shapes.mjs';
57
+ import { touchCooldownMarker } from './cooldown.mjs';
58
+
59
+ const LOCK_FILENAME = 'auto-extract.lock';
60
+ const NOW_MD_RELATIVE = ['context', 'sessions', 'now.md'];
61
+ const REVIEW_QUEUE_RELATIVE = ['context', 'queues', 'review.md'];
62
+ const EXTRACT_LOG_DIR_RELATIVE = ['context', 'sessions'];
63
+
64
+ // Noise tags absorbed from the code-dive note — generic markers Claude
65
+ // Code injects that aren't part of real exchanges and should never
66
+ // reach the extraction prompt.
67
+ const NOISE_TAG_PATTERNS = [
68
+ /<system-reminder>[\s\S]*?<\/system-reminder>/g,
69
+ /<command-name>[\s\S]*?<\/command-name>/g,
70
+ /<local-command-stdout>[\s\S]*?<\/local-command-stdout>/g,
71
+ /<local-command-stderr>[\s\S]*?<\/local-command-stderr>/g,
72
+ /<local-command-output>[\s\S]*?<\/local-command-output>/g,
73
+ ];
74
+
75
+ // Force-save tag from design §6.6 — content wrapped in <retain> is kept
76
+ // at trust:high regardless of what Haiku decided.
77
+ const RETAIN_RE = /<retain>([\s\S]*?)<\/retain>/g;
78
+
79
+ // Trust labels Haiku emits in our prompt's response format. Anything
80
+ // not matching a label is ignored (resilient against minor format drift).
81
+ // Shape: `TRUST_<HIGH|MEDIUM|LOW> <user|assistant>: <text>`
82
+ const CANDIDATE_LINE_RE = /^TRUST_(HIGH|MEDIUM|LOW)\s+(user|assistant):\s*(.+)$/i;
83
+ const SKIP_LINE_RE = /^\s*SKIP\s*$/i;
84
+
85
+ // Demotion map for assistant-origin candidates (design §6.4 amendment).
86
+ // HIGH → MEDIUM → LOW → discarded. Discarded candidates never reach the
87
+ // router; they're dropped immediately and counted toward
88
+ // `skipped_reason: nothing_durable` if everything demotes away.
89
+ const ASSISTANT_DEMOTION = Object.freeze({
90
+ high: 'medium',
91
+ medium: 'low',
92
+ low: 'discarded',
93
+ });
94
+
95
+ // Trust ranking for the within-call dedup tiebreak. Higher = stronger.
96
+ const TRUST_RANK = Object.freeze({
97
+ high: 3,
98
+ medium: 2,
99
+ low: 1,
100
+ discarded: 0,
101
+ });
102
+
103
+ // --- Lock file primitives -------------------------------------------
104
+
105
+ function acquireLock(lockPath) {
106
+ mkdirSync(dirname(lockPath), { recursive: true });
107
+ // O_CREAT | O_EXCL: atomic-create-or-fail. The 'wx' flag in Node
108
+ // maps to that combination — exactly the noclobber semantics from
109
+ // claude-remember's bash pattern.
110
+ try {
111
+ const fd = openSync(lockPath, 'wx');
112
+ writeSync(fd, String(process.pid), 0, 'utf8');
113
+ closeSync(fd);
114
+ return { acquired: true };
115
+ } catch (err) {
116
+ if (err.code !== 'EEXIST') {
117
+ return { acquired: false, reason: 'lock-error', error: err };
118
+ }
119
+ }
120
+ // Lock exists. Check if the holding PID is alive.
121
+ let pid = null;
122
+ try {
123
+ pid = parseInt(readFileSync(lockPath, 'utf8').trim(), 10);
124
+ } catch {
125
+ // Lock file unreadable — treat as stale.
126
+ }
127
+ if (pid && pidIsAlive(pid)) {
128
+ return { acquired: false, reason: 'pid-alive', pid };
129
+ }
130
+ // Stale lock. Remove + retry once. (No infinite loop — at most two
131
+ // attempts so an unrelated concurrent kill races doesn't deadlock.)
132
+ try {
133
+ unlinkSync(lockPath);
134
+ } catch (err) {
135
+ // Another process beat us to the cleanup; treat as contention.
136
+ if (err.code !== 'ENOENT') {
137
+ return { acquired: false, reason: 'lock-cleanup-failed', error: err };
138
+ }
139
+ }
140
+ try {
141
+ const fd = openSync(lockPath, 'wx');
142
+ writeSync(fd, String(process.pid), 0, 'utf8');
143
+ closeSync(fd);
144
+ return { acquired: true, recoveredStale: true };
145
+ } catch (err) {
146
+ return { acquired: false, reason: 'lock-error-after-recovery', error: err };
147
+ }
148
+ }
149
+
150
+ // pidIsAlive is consolidated in lock-discipline.mjs — same probe is
151
+ // used by cmk doctor HC-9 + this module's stale-recovery path.
152
+ // Importing instead of inlining eliminates drift risk (the kit's
153
+ // shared-modules rule, CLAUDE.md §1.3).
154
+
155
+ function releaseLock(lockPath) {
156
+ try {
157
+ unlinkSync(lockPath);
158
+ } catch {
159
+ // Best-effort; if the lock is already gone, fine.
160
+ }
161
+ }
162
+
163
+ // --- Turn-file sanitization -----------------------------------------
164
+
165
+ function stripNoiseTags(text) {
166
+ let out = text;
167
+ for (const re of NOISE_TAG_PATTERNS) {
168
+ out = out.replace(re, '');
169
+ }
170
+ return out;
171
+ }
172
+
173
+ function extractRetainSegments(text) {
174
+ const segments = [];
175
+ let m;
176
+ RETAIN_RE.lastIndex = 0;
177
+ while ((m = RETAIN_RE.exec(text)) !== null) {
178
+ segments.push(m[1].trim());
179
+ }
180
+ return segments;
181
+ }
182
+
183
+ // --- Dedup context --------------------------------------------------
184
+
185
+ function readLastEntryFromNowMd(projectRoot) {
186
+ const nowMd = join(projectRoot, ...NOW_MD_RELATIVE);
187
+ if (!existsSync(nowMd)) return '';
188
+ let body;
189
+ try {
190
+ body = readFileSync(nowMd, 'utf8');
191
+ } catch {
192
+ return '';
193
+ }
194
+ // Find the last `## ` heading and return everything from it to end.
195
+ const lines = body.split('\n');
196
+ let lastHeadingIdx = -1;
197
+ for (let i = lines.length - 1; i >= 0; i--) {
198
+ if (/^##\s/.test(lines[i])) {
199
+ lastHeadingIdx = i;
200
+ break;
201
+ }
202
+ }
203
+ if (lastHeadingIdx === -1) return '';
204
+ return lines.slice(lastHeadingIdx).join('\n').trim();
205
+ }
206
+
207
+ // --- Turn-file parser (bi-turn) -------------------------------------
208
+
209
+ // Parse the temp-file format Task 21's capture-turn writes:
210
+ // USER_TURN:
211
+ // <user body>
212
+ //
213
+ // ASSISTANT_TURN:
214
+ // <assistant body>
215
+ // Either section may be empty. If no USER_TURN: / ASSISTANT_TURN:
216
+ // markers are present, fall back to "the whole file is the assistant
217
+ // turn" so old-format temp files (pre-2026-05-26) still work — useful
218
+ // when running auto-extract against a turn buffer that pre-dates this
219
+ // amendment (unlikely after the rollout, but defensive).
220
+ const USER_TURN_RE = /^[ \t]*USER_TURN:\s*\n([\s\S]*?)(?=^[ \t]*ASSISTANT_TURN:|\Z)/m;
221
+ const ASSISTANT_TURN_RE = /^[ \t]*ASSISTANT_TURN:\s*\n([\s\S]*)$/m;
222
+
223
+ function parseTurnFile(rawTurn) {
224
+ const userMatch = rawTurn.match(USER_TURN_RE);
225
+ const assistantMatch = rawTurn.match(ASSISTANT_TURN_RE);
226
+ if (!userMatch && !assistantMatch) {
227
+ // Old-format / unlabeled — treat whole content as assistant.
228
+ return { userTurn: '', assistantTurn: rawTurn.trim() };
229
+ }
230
+ return {
231
+ userTurn: (userMatch?.[1] ?? '').trim(),
232
+ assistantTurn: (assistantMatch?.[1] ?? '').trim(),
233
+ };
234
+ }
235
+
236
+ // --- Prompt construction --------------------------------------------
237
+
238
+ // Written from scratch per design §6.4 — no text copied from claude-
239
+ // remember's prompts. Output format encodes origin so the routing
240
+ // layer can apply the assistant-demotion rule (§6.4 amendment, 2026-05-26).
241
+ function buildExtractionInstructions() {
242
+ return [
243
+ 'You are a memory-extraction agent for claude-memory-kit.',
244
+ 'You read a captured turn pair (the user prompt + the assistant response) and identify durable facts worth saving.',
245
+ '',
246
+ 'The user is the authority on facts about themselves and their preferences.',
247
+ 'The assistant is inferring or echoing — treat its observations as proposals to confirm later, not as ground truth.',
248
+ '',
249
+ 'Save when EITHER turn reveals any of the six writing triggers:',
250
+ ' 1. User corrections — "don\'t do that again", "use this instead".',
251
+ ' 2. Discovered preferences — patterns across multiple turns.',
252
+ ' 3. Environment facts — tool versions, paths, configurations.',
253
+ ' 4. Project conventions — discovered through code inspection.',
254
+ ' 5. Completed complex workflows — 5+ tool calls; the approach is worth recording.',
255
+ ' 6. Tool quirks and workarounds — non-obvious findings.',
256
+ '',
257
+ 'Skip: conversational chatter, trivial info, raw data dumps, session-specific ephemera.',
258
+ '',
259
+ 'Output format (one candidate per line; tag each with origin = user OR assistant):',
260
+ ' TRUST_HIGH user: <text> — user clearly stated this; high confidence',
261
+ ' TRUST_MEDIUM user: <text> — user mentioned this but ambiguously',
262
+ ' TRUST_LOW user: <text> — barely a signal (rarely emit)',
263
+ ' TRUST_HIGH assistant: <text> — assistant inferred this with high confidence',
264
+ ' TRUST_MEDIUM assistant: <text> — assistant\'s weaker inference',
265
+ ' TRUST_LOW assistant: <text> — barely a signal (rarely emit)',
266
+ ' SKIP — emit alone if nothing in either turn is worth saving',
267
+ '',
268
+ 'Constraints:',
269
+ ' - Each bullet ≤ 200 chars.',
270
+ ' - No prose around the labels.',
271
+ ' - Do not invent facts; only restate what the turns show.',
272
+ ' - If a previous-entry context is included below, do NOT re-emit facts already in it.',
273
+ '',
274
+ 'Note: assistant-origin candidates are auto-demoted one trust level before routing (HIGH → MEDIUM → LOW → discarded). This is intentional — assistant inferences need user review. Emit your honest trust assessment; the routing layer handles demotion.',
275
+ ].join('\n');
276
+ }
277
+
278
+ function buildExtractionPrompt({ userTurn, assistantTurn, dedupContext }) {
279
+ const sections = [];
280
+ if (dedupContext) {
281
+ sections.push('# Previous entry (do not re-emit facts already here)');
282
+ sections.push(dedupContext);
283
+ sections.push('');
284
+ }
285
+ sections.push('# USER_TURN');
286
+ sections.push(userTurn || '(no user turn captured)');
287
+ sections.push('');
288
+ sections.push('# ASSISTANT_TURN');
289
+ sections.push(assistantTurn || '(no assistant turn captured)');
290
+ return sections.join('\n');
291
+ }
292
+
293
+ function parseCandidates(haikuOutput) {
294
+ if (!haikuOutput || typeof haikuOutput !== 'string') return [];
295
+ const lines = haikuOutput.split('\n');
296
+ const candidates = [];
297
+ for (const line of lines) {
298
+ const trimmed = line.trim();
299
+ if (SKIP_LINE_RE.test(trimmed)) continue;
300
+ const m = trimmed.match(CANDIDATE_LINE_RE);
301
+ if (!m) continue;
302
+ const trust = m[1].toLowerCase();
303
+ const origin = m[2].toLowerCase();
304
+ const text = m[3].trim();
305
+ if (text === '') continue;
306
+ candidates.push({ trust, origin, text });
307
+ }
308
+ return candidates;
309
+ }
310
+
311
+ // Demote assistant-origin candidates one trust level. User-origin
312
+ // candidates pass through unchanged — they're authoritative.
313
+ // Order: must run BEFORE applyRetainOverride so the override beats
314
+ // demotion (an assistant-origin candidate inside a <retain> still
315
+ // force-promotes to HIGH).
316
+ function applyOriginDemotion(candidates) {
317
+ return candidates.map((c) => {
318
+ if (c.origin !== 'assistant') return c;
319
+ const demoted = ASSISTANT_DEMOTION[c.trust] ?? c.trust;
320
+ return { ...c, trust: demoted, demotedFrom: c.trust };
321
+ });
322
+ }
323
+
324
+ // Group by canonical id of text; keep the highest-trust candidate per
325
+ // group. Handles the "user states X; assistant echoes X" duplicate
326
+ // problem. Note: canonical-id dedup is LITERAL — semantically-similar
327
+ // phrasings with different canonical forms slip through here and are
328
+ // resolved by Task 25's conflict queue at write time.
329
+ function dedupByCanonicalId(candidates) {
330
+ const byId = new Map();
331
+ for (const c of candidates) {
332
+ const id = generateId('P', c.text);
333
+ const existing = byId.get(id);
334
+ if (!existing || (TRUST_RANK[c.trust] ?? 0) > (TRUST_RANK[existing.trust] ?? 0)) {
335
+ byId.set(id, c);
336
+ }
337
+ }
338
+ return [...byId.values()];
339
+ }
340
+
341
+ // Force-promote any candidate whose text overlaps substantively with a
342
+ // <retain> segment from the original turn. Per design §6.6: <retain>
343
+ // is the force-save signal that overrides Haiku's trust judgment.
344
+ //
345
+ // Match semantics (deliberately conservative, per code-review B1 fix):
346
+ // - Forward-only: the candidate text must CONTAIN the retain segment.
347
+ // The reverse direction (retain contains candidate) was rejected
348
+ // because it lets a small retain segment promote any candidate that
349
+ // happens to contain a tiny common substring (e.g. <retain>x</retain>
350
+ // would promote anything with an "x").
351
+ // - Minimum length: the matching retain segment must be at least
352
+ // MIN_RETAIN_MATCH_CHARS long. Stops trivially-short retain segments
353
+ // from grabbing unrelated candidates.
354
+ const MIN_RETAIN_MATCH_CHARS = 20;
355
+
356
+ function applyRetainOverride(candidates, retainSegments) {
357
+ if (retainSegments.length === 0) return candidates;
358
+ return candidates.map((c) => {
359
+ const matched = retainSegments.some(
360
+ (seg) => seg.length >= MIN_RETAIN_MATCH_CHARS && c.text.includes(seg),
361
+ );
362
+ return matched ? { ...c, trust: 'high', retainOverride: true } : c;
363
+ });
364
+ }
365
+
366
+ // --- Routing --------------------------------------------------------
367
+
368
+ // Classifies a memoryWrite result for observation-counting purposes.
369
+ //
370
+ // Three categories of memoryWrite outcome that auto-extract cares about:
371
+ // - 'memory' — bullet appended to MEMORY.md (or other scratchpad)
372
+ // - 'conflict' — bullet routed to queues/conflicts.md (the queue-route
373
+ // in memory-write.doAdd, when new.trust < existing.trust)
374
+ // - 'rejected' — Poison_Guard / schema / cap-exceeded rejection
375
+ //
376
+ // At trust:high (where auto-extract calls memoryWrite) the queue-route
377
+ // is unreachable today: detectConflicts returns action:'supersede' when
378
+ // new.trust >= existing.trust. The explicit 'conflict' branch is
379
+ // defensive — if a v0.1.x change lowers auto-extract trust or alters
380
+ // supersede semantics, a 'queued' return would otherwise be silently
381
+ // misclassified as 'rejected'. observation_count counts both 'memory'
382
+ // AND 'conflict' since both are successful writes (just to different
383
+ // scratchpads).
384
+ //
385
+ // Exported for direct unit-testing — the queue-route is unreachable
386
+ // from the live auto-extract flow today (see above), so pinning the
387
+ // discriminator's behavior on each possible action value requires
388
+ // calling it with literal inputs.
389
+ export function classifyHighTrustWrite(r) {
390
+ if (r?.action === 'appended') return 'memory';
391
+ if (r?.action === 'queued') return 'conflict';
392
+ return 'rejected';
393
+ }
394
+
395
+ function routeHigh({ candidate, projectRoot, ts, sessionId }) {
396
+ // Auto-extract writes go to the project tier's MEMORY.md, Active
397
+ // Threads section via memoryWrite() — the same public boundary the
398
+ // user-explicit Skill uses. This routes the auto-extract write
399
+ // through Poison_Guard (design §6.7), which was the KNOWN GAP
400
+ // documented in Task 23 (rejected secrets / injection patterns
401
+ // now blocked before they reach disk).
402
+ //
403
+ // memoryWrite() composes:
404
+ // 1. Poison_Guard regex filter (rejects secrets + injections,
405
+ // logs to .locks/poison-guard.log with redacted excerpt).
406
+ // 2. appendScratchpadBullet (cap + dedup + audit + ID derivation).
407
+ //
408
+ // The retain-vs-haiku origin is recorded on the in-memory result
409
+ // struct (candidate.retainOverride), not in provenance — sha1 is a
410
+ // content hash, not an origin marker.
411
+ return memoryWrite({
412
+ action: 'add',
413
+ text: candidate.text,
414
+ tier: 'P',
415
+ scratchpad: 'MEMORY.md',
416
+ section: 'Active Threads',
417
+ source: 'auto-extract',
418
+ sessionId: sessionId ?? 'session',
419
+ trust: 'high',
420
+ projectRoot,
421
+ now: ts,
422
+ });
423
+ }
424
+
425
+ function routeMedium({ candidate, projectRoot, ts }) {
426
+ const reviewPath = join(projectRoot, ...REVIEW_QUEUE_RELATIVE);
427
+ mkdirSync(dirname(reviewPath), { recursive: true });
428
+ const id = generateId('P', candidate.text);
429
+ const block = [
430
+ `## ${ts} — auto-extract (medium-trust, pending review)`,
431
+ `- (${id}) ${candidate.text}`,
432
+ ` <!-- proposed_trust: medium, write: auto-extract, at: ${ts} -->`,
433
+ '',
434
+ ].join('\n');
435
+ appendFileSync(reviewPath, block, 'utf8');
436
+ return { action: 'queued', id, path: reviewPath };
437
+ }
438
+
439
+ // --- NDJSON extract.log ---------------------------------------------
440
+
441
+ function writeExtractLogEntry({ projectRoot, ts, entry }) {
442
+ const date = ts.slice(0, 10);
443
+ const logPath = join(projectRoot, ...EXTRACT_LOG_DIR_RELATIVE, `${date}.extract.log`);
444
+ mkdirSync(dirname(logPath), { recursive: true });
445
+ // `phase: 'extract'` discriminator added 2026-05-27 (PR-D2b) to
446
+ // compose with capture-turn.mjs's `phase: 'spawn'` spawn-failed
447
+ // entries. Both shapes coexist in the same NDJSON file; readers
448
+ // route by `phase`. Pre-D2b entries WITHOUT a `phase` field can be
449
+ // treated as `extract` by convention (the spawn-phase entries only
450
+ // exist post-D2b).
451
+ appendFileSync(logPath, JSON.stringify({ phase: 'extract', ...entry }) + '\n', 'utf8');
452
+ return logPath;
453
+ }
454
+
455
+ // --- Public boundary ------------------------------------------------
456
+
457
+ export async function runAutoExtract({
458
+ turnFile,
459
+ projectRoot,
460
+ haikuBackend,
461
+ now,
462
+ sessionId,
463
+ } = {}) {
464
+ const ts = now ?? nowIso();
465
+ const t0 = Date.now();
466
+ const baseEntry = {
467
+ ts,
468
+ success: false,
469
+ error_category: null,
470
+ observation_count: 0,
471
+ skipped_reason: null,
472
+ duration_ms: 0,
473
+ };
474
+
475
+ if (!projectRoot) {
476
+ return {
477
+ action: 'error',
478
+ error_category: ERROR_CATEGORIES.MISSING_PROJECT_ROOT,
479
+ observation_count: 0,
480
+ duration_ms: Date.now() - t0,
481
+ logPath: null,
482
+ candidates: [],
483
+ };
484
+ }
485
+ if (!haikuBackend || typeof haikuBackend.compress !== 'function') {
486
+ return {
487
+ action: 'error',
488
+ error_category: ERROR_CATEGORIES.MISSING_BACKEND,
489
+ observation_count: 0,
490
+ duration_ms: Date.now() - t0,
491
+ logPath: null,
492
+ candidates: [],
493
+ };
494
+ }
495
+
496
+ const lockPath = join(projectRoot, 'context', '.locks', LOCK_FILENAME);
497
+ const lock = acquireLock(lockPath);
498
+ if (!lock.acquired) {
499
+ // Per code-review I3: set only error_category here, not
500
+ // skipped_reason — concurrent_run is a transient error (retry on
501
+ // the next Stop event), not a "Haiku said nothing durable" skip.
502
+ const entry = {
503
+ ...baseEntry,
504
+ success: false,
505
+ error_category: ERROR_CATEGORIES.CONCURRENT_RUN,
506
+ duration_ms: Date.now() - t0,
507
+ };
508
+ const logPath = writeExtractLogEntry({ projectRoot, ts, entry });
509
+ return {
510
+ action: 'concurrent',
511
+ error_category: ERROR_CATEGORIES.CONCURRENT_RUN,
512
+ observation_count: 0,
513
+ duration_ms: entry.duration_ms,
514
+ logPath,
515
+ candidates: [],
516
+ };
517
+ }
518
+
519
+ try {
520
+ // 1. Read turn file.
521
+ if (!existsSync(turnFile)) {
522
+ const entry = {
523
+ ...baseEntry,
524
+ success: false,
525
+ error_category: ERROR_CATEGORIES.MISSING_TURN,
526
+ duration_ms: Date.now() - t0,
527
+ };
528
+ const logPath = writeExtractLogEntry({ projectRoot, ts, entry });
529
+ return {
530
+ action: 'error',
531
+ error_category: ERROR_CATEGORIES.MISSING_TURN,
532
+ observation_count: 0,
533
+ duration_ms: entry.duration_ms,
534
+ logPath,
535
+ candidates: [],
536
+ };
537
+ }
538
+ const rawTurn = readFileSync(turnFile, 'utf8');
539
+ if (rawTurn.trim() === '') {
540
+ const entry = {
541
+ ...baseEntry,
542
+ success: true,
543
+ skipped_reason: 'empty_turn',
544
+ duration_ms: Date.now() - t0,
545
+ };
546
+ const logPath = writeExtractLogEntry({ projectRoot, ts, entry });
547
+ return {
548
+ action: 'skipped',
549
+ skipped_reason: 'empty_turn',
550
+ observation_count: 0,
551
+ duration_ms: entry.duration_ms,
552
+ logPath,
553
+ candidates: [],
554
+ };
555
+ }
556
+
557
+ // 2. Sanitize: strip noise tags + extract <retain> segments
558
+ // (for the override). Both apply across BOTH turn bodies —
559
+ // <retain> in either user or assistant turn triggers the
560
+ // override.
561
+ const retainSegments = extractRetainSegments(rawTurn);
562
+ const sanitized = stripNoiseTags(rawTurn);
563
+ const { userTurn, assistantTurn } = parseTurnFile(sanitized);
564
+
565
+ // 3. Build prompt with dedup context (last `## ` entry from now.md).
566
+ const dedupContext = readLastEntryFromNowMd(projectRoot);
567
+ const instructions = buildExtractionInstructions();
568
+ const promptBody = buildExtractionPrompt({
569
+ userTurn,
570
+ assistantTurn,
571
+ dedupContext,
572
+ });
573
+
574
+ // 4. Call Haiku.
575
+ //
576
+ // Subprocess timeout: 25_000 ms. Sits comfortably under the 30s
577
+ // Stop hook ceiling (design §5.1) so on timeout the catch +
578
+ // finally + extract.log write all complete BEFORE Claude Code
579
+ // kills the parent. Without this, a hung claude --print call
580
+ // would leak the auto-extract.lock file and skip the NDJSON
581
+ // log entry — see design §8.5 for the composition rationale.
582
+ let haikuResult;
583
+ try {
584
+ haikuResult = await haikuBackend.compress({
585
+ input: promptBody,
586
+ instructions,
587
+ maxOutputBytes: 2000,
588
+ preserveCitationIds: false,
589
+ timeoutMs: 25_000,
590
+ });
591
+ // Touch the cooldown marker IMMEDIATELY after the Haiku call
592
+ // resolves — this is the "we spent the budget" signal that
593
+ // compress-session.mjs reads to skip its own Haiku call within
594
+ // 120s of ours. Touching on success only (not in the catch below)
595
+ // would mean a failing Haiku in the auto-extract path doesn't
596
+ // block compress-session — which would then re-spend the budget
597
+ // on the failure. The catch path below also touches.
598
+ touchCooldownMarker({ projectRoot, now: ts });
599
+ } catch (err) {
600
+ // Spent the Haiku budget (succeeded OR failed); touch the
601
+ // cooldown so compress-session skips within 120s.
602
+ touchCooldownMarker({ projectRoot, now: ts });
603
+ // Route on the error TYPE — distinguishes "took too long"
604
+ // (HAIKU_TIMEOUT) from "subprocess exited non-zero"
605
+ // (HAIKU_FAILED). Using `instanceof HaikuTimeoutError`
606
+ // rather than `err.category === 'haiku_timeout'` because the
607
+ // string-comparison contract is fragile: a future error class
608
+ // that happens to set `.category` to a colliding value, or a
609
+ // rename of the string at one end but not the other, would
610
+ // silently misroute. The instanceof check is type-anchored.
611
+ const category = err instanceof HaikuTimeoutError
612
+ ? ERROR_CATEGORIES.HAIKU_TIMEOUT
613
+ : ERROR_CATEGORIES.HAIKU_FAILED;
614
+ const entry = {
615
+ ...baseEntry,
616
+ success: false,
617
+ error_category: category,
618
+ duration_ms: Date.now() - t0,
619
+ };
620
+ const logPath = writeExtractLogEntry({ projectRoot, ts, entry });
621
+ return {
622
+ action: 'error',
623
+ error_category: category,
624
+ observation_count: 0,
625
+ duration_ms: entry.duration_ms,
626
+ logPath,
627
+ candidates: [],
628
+ errorMessage: err?.message ?? String(err),
629
+ };
630
+ }
631
+
632
+ // 5. Parse → demote assistant-origin → apply <retain> override
633
+ // → dedup-within-call. Order matters: demotion runs BEFORE
634
+ // retain so an assistant-origin candidate inside a <retain>
635
+ // still force-promotes to HIGH; dedup runs last so same-id
636
+ // candidates collapse to the highest-trust survivor.
637
+ let candidates = parseCandidates(haikuResult.outputText);
638
+ candidates = applyOriginDemotion(candidates);
639
+ candidates = applyRetainOverride(candidates, retainSegments);
640
+ candidates = dedupByCanonicalId(candidates);
641
+
642
+ if (candidates.length === 0) {
643
+ const entry = {
644
+ ...baseEntry,
645
+ success: true,
646
+ skipped_reason: 'nothing_durable',
647
+ duration_ms: Date.now() - t0,
648
+ };
649
+ const logPath = writeExtractLogEntry({ projectRoot, ts, entry });
650
+ return {
651
+ action: 'skipped',
652
+ skipped_reason: 'nothing_durable',
653
+ observation_count: 0,
654
+ duration_ms: entry.duration_ms,
655
+ logPath,
656
+ candidates: [],
657
+ };
658
+ }
659
+
660
+ // 6. Route each candidate. Low-trust candidates are discarded.
661
+ // High-trust candidates go through memoryWrite() which may
662
+ // REJECT them at the Poison_Guard / schema / cap_exceeded
663
+ // layer — in that case the candidate is marked
664
+ // written:'rejected' so it doesn't count toward
665
+ // observation_count, and `rejected_category` carries the
666
+ // distinguishing error category so analytics can separate
667
+ // "secret leak averted" from "scratchpad full" from
668
+ // "validation failed".
669
+ const writes = [];
670
+ for (const candidate of candidates) {
671
+ if (candidate.trust === 'high') {
672
+ const r = routeHigh({ candidate, projectRoot, ts, sessionId });
673
+ const written = classifyHighTrustWrite(r);
674
+ const writeRecord = { ...candidate, written, result: r };
675
+ if (written === 'rejected') {
676
+ writeRecord.rejected_category = r?.errorCategory ?? 'unknown';
677
+ }
678
+ writes.push(writeRecord);
679
+ } else if (candidate.trust === 'medium') {
680
+ const r = routeMedium({ candidate, projectRoot, ts });
681
+ writes.push({ ...candidate, written: 'review', result: r });
682
+ } else {
683
+ writes.push({ ...candidate, written: 'discarded' });
684
+ }
685
+ }
686
+
687
+ const observation_count = writes.filter(
688
+ (w) => w.written === 'memory' || w.written === 'review' || w.written === 'conflict',
689
+ ).length;
690
+
691
+ if (observation_count === 0) {
692
+ const entry = {
693
+ ...baseEntry,
694
+ success: true,
695
+ skipped_reason: 'nothing_durable',
696
+ duration_ms: Date.now() - t0,
697
+ };
698
+ const logPath = writeExtractLogEntry({ projectRoot, ts, entry });
699
+ return {
700
+ action: 'skipped',
701
+ skipped_reason: 'nothing_durable',
702
+ observation_count: 0,
703
+ duration_ms: entry.duration_ms,
704
+ logPath,
705
+ candidates: writes,
706
+ };
707
+ }
708
+
709
+ const entry = {
710
+ ...baseEntry,
711
+ success: true,
712
+ observation_count,
713
+ duration_ms: Date.now() - t0,
714
+ };
715
+ const logPath = writeExtractLogEntry({ projectRoot, ts, entry });
716
+ return {
717
+ action: 'extracted',
718
+ observation_count,
719
+ duration_ms: entry.duration_ms,
720
+ logPath,
721
+ candidates: writes,
722
+ };
723
+ } finally {
724
+ // Cleanup order: turn-file FIRST (frees disk), lock LAST (releases
725
+ // the mutex so a subsequent invocation can start). Both swallow
726
+ // errors — the lock release is best-effort because EEXIST on Windows
727
+ // can transiently fire if the watcher hasn't released the handle;
728
+ // the turn-file cleanup is best-effort because (a) the missing_turn
729
+ // path means it's already absent, (b) Windows can refuse unlink if
730
+ // a virus scanner has the file briefly open. The next Stop hook
731
+ // overwrites the path, so a leaked turn-file is harmless beyond
732
+ // disk noise.
733
+ if (existsSync(turnFile)) {
734
+ try {
735
+ unlinkSync(turnFile);
736
+ } catch {
737
+ // ignored — see comment above
738
+ }
739
+ }
740
+ releaseLock(lockPath);
741
+ }
742
+ }