@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.
- package/bin/cmk-compress-lazy.mjs +59 -0
- package/bin/cmk-daily-distill.mjs +67 -0
- package/bin/cmk-weekly-curate.mjs +56 -0
- package/bin/cmk.mjs +12 -0
- package/package.json +50 -0
- package/src/audit-log.mjs +103 -0
- package/src/auto-extract.mjs +742 -0
- package/src/capture-prompt.mjs +61 -0
- package/src/capture-turn.mjs +273 -0
- package/src/claude-md.mjs +212 -0
- package/src/compress-session.mjs +349 -0
- package/src/compressor.mjs +376 -0
- package/src/conflict-queue.mjs +796 -0
- package/src/cooldown.mjs +61 -0
- package/src/daily-distill.mjs +252 -0
- package/src/doctor.mjs +528 -0
- package/src/forget.mjs +335 -0
- package/src/frontmatter.mjs +73 -0
- package/src/import-anthropic-memory.mjs +266 -0
- package/src/index-db.mjs +154 -0
- package/src/index-rebuild.mjs +597 -0
- package/src/index.mjs +90 -0
- package/src/inject-context.mjs +484 -0
- package/src/install.mjs +327 -0
- package/src/lazy-compress.mjs +326 -0
- package/src/lock-discipline.mjs +166 -0
- package/src/mcp-server.mjs +498 -0
- package/src/memory-write.mjs +565 -0
- package/src/merge-facts.mjs +213 -0
- package/src/observe-edit.mjs +87 -0
- package/src/platform-commands.mjs +138 -0
- package/src/poison-guard.mjs +245 -0
- package/src/privacy.mjs +21 -0
- package/src/provenance.mjs +217 -0
- package/src/register-crons.mjs +354 -0
- package/src/reindex.mjs +134 -0
- package/src/repair.mjs +316 -0
- package/src/result-shapes.mjs +155 -0
- package/src/review-queue.mjs +345 -0
- package/src/roll.mjs +115 -0
- package/src/scratchpad.mjs +335 -0
- package/src/search.mjs +311 -0
- package/src/subcommands.mjs +1252 -0
- package/src/tier-paths.mjs +74 -0
- package/src/transcripts.mjs +234 -0
- package/src/trust.mjs +226 -0
- package/src/weekly-curate.mjs +454 -0
- package/src/write-fact.mjs +205 -0
- package/template/.claude/hooks/pre-tool-memory.js +78 -0
- package/template/.claude/hooks/transcript-capture.js +69 -0
- package/template/.claude/settings.json +27 -0
- package/template/.claude/skills/memory-write/SKILL.md +117 -0
- package/template/.gitignore.fragment +12 -0
- package/template/CLAUDE.md.template +49 -0
- package/template/docs/journey/journey-log.md.template +292 -0
- package/template/local/machine-paths.md.template +37 -0
- package/template/local/overrides.md.template +36 -0
- package/template/project/.index/.gitkeep +0 -0
- package/template/project/MEMORY.md.template +47 -0
- package/template/project/SOUL.md.template +35 -0
- package/template/project/memory/INDEX.md.template +47 -0
- package/template/project/memory/archive/superseded/.gitkeep +0 -0
- package/template/project/memory/archive/tombstones/.gitkeep +0 -0
- package/template/project/queues/.gitkeep +0 -0
- package/template/project/sessions/.gitkeep +0 -0
- package/template/project/transcripts/.gitkeep +0 -0
- package/template/support/cron-jobs/daily-memory-distill.md +15 -0
- package/template/support/cron-jobs/nightly-memsearch-index.md +17 -0
- package/template/support/cron-jobs/weekly-memory-curator.md +15 -0
- package/template/support/milvus-deploy/README.md +57 -0
- package/template/support/milvus-deploy/docker-compose.yml +66 -0
- package/template/support/scripts/auto-extract-memory.sh +102 -0
- package/template/support/scripts/memsearch-index-with-flush.sh +59 -0
- package/template/support/scripts/refresh-distill-timestamp.py +35 -0
- package/template/support/scripts/register-crons.py +242 -0
- package/template/support/scripts/run-daily-distill.sh +67 -0
- package/template/support/scripts/run-weekly-curate.sh +58 -0
- package/template/user/HABITS.md.template +18 -0
- package/template/user/LESSONS.md.template +18 -0
- package/template/user/USER.md.template +18 -0
- 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
|
+
}
|