@lh8ppl/claude-memory-kit 0.2.3 → 0.3.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/README.md +13 -10
- package/bin/cmk-capture-prompt.mjs +21 -1
- package/package.json +2 -1
- package/src/auto-extract.mjs +68 -11
- package/src/capture-prompt.mjs +33 -1
- package/src/capture-turn.mjs +64 -6
- package/src/conflict-queue.mjs +20 -3
- package/src/doctor.mjs +52 -125
- package/src/forget.mjs +13 -0
- package/src/frontmatter.mjs +4 -1
- package/src/import-anthropic-memory.mjs +25 -1
- package/src/index-db.mjs +39 -0
- package/src/index-rebuild.mjs +42 -2
- package/src/inject-context.mjs +49 -6
- package/src/install.mjs +107 -1
- package/src/mcp-server.mjs +57 -7
- package/src/merge-facts.mjs +12 -0
- package/src/provenance.mjs +4 -0
- package/src/result-shapes.mjs +2 -2
- package/src/scratchpad.mjs +5 -3
- package/src/search.mjs +100 -12
- package/src/semantic-backend.mjs +485 -0
- package/src/settings-hooks.mjs +4 -1
- package/src/spawn-bin.mjs +7 -2
- package/src/subcommands.mjs +95 -18
- package/src/transcript-index.mjs +162 -0
- package/src/turn-tools.mjs +179 -0
- package/template/.claude/skills/memory-search/SKILL.md +86 -0
- package/template/CLAUDE.md.template +2 -0
- package/template/support/cron-jobs/nightly-memsearch-index.md +0 -17
- package/template/support/milvus-deploy/README.md +0 -57
- package/template/support/milvus-deploy/docker-compose.yml +0 -66
- package/template/support/scripts/memsearch-index-with-flush.sh +0 -59
package/src/doctor.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// `cmk doctor` — health checks HC-1..HC-
|
|
1
|
+
// `cmk doctor` — health checks HC-1..HC-7 (Task 37, T-031; memsearch HC-1/HC-7 removed in Task 120).
|
|
2
2
|
//
|
|
3
3
|
// Public boundary:
|
|
4
4
|
// async runDoctor({projectRoot, userDir, now, promptUser?, ...overrides})
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
//
|
|
7
7
|
// HCResult shape:
|
|
8
8
|
// {
|
|
9
|
-
// id: 'HC-1' | ... | 'HC-
|
|
9
|
+
// id: 'HC-1' | ... | 'HC-7',
|
|
10
10
|
// name: string,
|
|
11
11
|
// status: 'pass' | 'fail' | 'skip',
|
|
12
12
|
// message: string,
|
|
@@ -15,10 +15,10 @@
|
|
|
15
15
|
// }
|
|
16
16
|
//
|
|
17
17
|
// Per design §14. Composes on:
|
|
18
|
-
// - cooldown.mjs (HC-
|
|
18
|
+
// - cooldown.mjs (HC-2 distill freshness via cooldown marker mtime is
|
|
19
19
|
// NOT used — we read recent.md mtime directly, more accurate)
|
|
20
|
-
// - lazy-compress.mjs::cronSentinelPath (HC-
|
|
21
|
-
// - lock-discipline.mjs::detectStaleLocks (HC-
|
|
20
|
+
// - lazy-compress.mjs::cronSentinelPath (HC-5 cron registration check)
|
|
21
|
+
// - lock-discipline.mjs::detectStaleLocks (HC-7)
|
|
22
22
|
// - platform-commands.mjs — cross-platform repair command emission
|
|
23
23
|
//
|
|
24
24
|
// Critical rule per design §14 + tasks.md 37.5: any repair requiring
|
|
@@ -38,7 +38,6 @@ import {
|
|
|
38
38
|
statSync,
|
|
39
39
|
writeFileSync,
|
|
40
40
|
} from 'node:fs';
|
|
41
|
-
import { spawnBinSync } from './spawn-bin.mjs';
|
|
42
41
|
import { homedir } from 'node:os';
|
|
43
42
|
import { basename, join } from 'node:path';
|
|
44
43
|
import { nowIso } from './audit-log.mjs';
|
|
@@ -56,62 +55,15 @@ const MEMORY_DIR_REL = ['context', 'memory'];
|
|
|
56
55
|
const LOCKS_REL = ['context', '.locks'];
|
|
57
56
|
const NATIVE_MEMORY_LOG_REL = ['context', '.locks', 'native-memory-status.log'];
|
|
58
57
|
|
|
59
|
-
// --- HC-1:
|
|
60
|
-
|
|
61
|
-
// Layer 5b (semantic search) is OPTIONAL per ADR-0008. Missing
|
|
62
|
-
// memsearch → skip (not fail). The kit ships keyword-only as v0.1.0;
|
|
63
|
-
// semantic requires a separate `pip install memsearch[onnx]`.
|
|
64
|
-
// `requiresInstall: true` so the CLI prompts before auto-installing.
|
|
65
|
-
try {
|
|
66
|
-
// spawnBinSync resolves the Windows .cmd shim without `shell:true`+args
|
|
67
|
-
// (no DEP0190; #4). memsearch's only arg is `--version` (no spaces), so
|
|
68
|
-
// the quoting is a no-op here — the win is dropping the deprecated combo.
|
|
69
|
-
const r = spawnBinSync('memsearch', ['--version'], {
|
|
70
|
-
encoding: 'utf8',
|
|
71
|
-
// M1 fix (skill-review 2026-05-28): 3.5s tolerates Windows
|
|
72
|
-
// cold-Python startup (AV scan + .pyc generation on first hit
|
|
73
|
-
// can push past 2s for a healthy install). HC-2..9 are file-
|
|
74
|
-
// system ops that complete in ≪100ms total, so HC-1 + the rest
|
|
75
|
-
// still fits comfortably inside the 5s NFR budget. Timeout →
|
|
76
|
-
// 'skip' so cmk doctor completes regardless.
|
|
77
|
-
timeout: 3_500,
|
|
78
|
-
});
|
|
79
|
-
if (r.status === 0) {
|
|
80
|
-
return {
|
|
81
|
-
id: 'HC-1',
|
|
82
|
-
name: 'memsearch installed (semantic search backend)',
|
|
83
|
-
status: 'pass',
|
|
84
|
-
message: `memsearch ${(r.stdout || '').trim() || 'detected'}`,
|
|
85
|
-
};
|
|
86
|
-
}
|
|
87
|
-
} catch {
|
|
88
|
-
// fall through to skip
|
|
89
|
-
}
|
|
90
|
-
// The user (2026-05-28): make the feature impact explicit so users
|
|
91
|
-
// understand WHAT THEY LOSE by skipping the install, not just that
|
|
92
|
-
// a check failed. Matches the user's directive: "ask before we do
|
|
93
|
-
// anything, explain if they dont install they dont get certain
|
|
94
|
-
// features".
|
|
95
|
-
return {
|
|
96
|
-
id: 'HC-1',
|
|
97
|
-
name: 'memsearch installed (semantic search backend)',
|
|
98
|
-
status: 'skip',
|
|
99
|
-
message:
|
|
100
|
-
'memsearch not on PATH — Layer 5b semantic backend disabled. Features unavailable: `cmk search --mode=semantic` (will error), `cmk search --mode=hybrid` (will error). Keyword search (`cmk search --mode=keyword`, default) still works fully.',
|
|
101
|
-
recoveryCommand: 'python -m pip install "memsearch[onnx]"',
|
|
102
|
-
requiresInstall: true,
|
|
103
|
-
};
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
// --- HC-2: Stop + SessionStart hooks registered -----------------------
|
|
107
|
-
function hc2Hooks({ projectRoot }) {
|
|
58
|
+
// --- HC-1: Stop + SessionStart hooks registered -----------------------
|
|
59
|
+
function hc1Hooks({ projectRoot }) {
|
|
108
60
|
// Per design §5 — the kit's hooks live in .claude/settings.json
|
|
109
61
|
// alongside its plugin manifest. Required for auto-extract +
|
|
110
62
|
// session-end compression to fire.
|
|
111
63
|
const settingsPath = join(projectRoot, '.claude', 'settings.json');
|
|
112
64
|
if (!existsSync(settingsPath)) {
|
|
113
65
|
return {
|
|
114
|
-
id: 'HC-
|
|
66
|
+
id: 'HC-1',
|
|
115
67
|
name: 'Stop + SessionStart hooks registered',
|
|
116
68
|
status: 'fail',
|
|
117
69
|
message: '.claude/settings.json missing — hooks not wired',
|
|
@@ -123,7 +75,7 @@ function hc2Hooks({ projectRoot }) {
|
|
|
123
75
|
settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
|
|
124
76
|
} catch (err) {
|
|
125
77
|
return {
|
|
126
|
-
id: 'HC-
|
|
78
|
+
id: 'HC-1',
|
|
127
79
|
name: 'Stop + SessionStart hooks registered',
|
|
128
80
|
status: 'fail',
|
|
129
81
|
message: `.claude/settings.json parse error: ${err?.message ?? err}`,
|
|
@@ -170,7 +122,7 @@ function hc2Hooks({ projectRoot }) {
|
|
|
170
122
|
}
|
|
171
123
|
if (missing.length > 0) {
|
|
172
124
|
return {
|
|
173
|
-
id: 'HC-
|
|
125
|
+
id: 'HC-1',
|
|
174
126
|
name: 'Stop + SessionStart hooks registered',
|
|
175
127
|
status: 'fail',
|
|
176
128
|
message: `missing hook references: ${missing.join(', ')}`,
|
|
@@ -178,15 +130,15 @@ function hc2Hooks({ projectRoot }) {
|
|
|
178
130
|
};
|
|
179
131
|
}
|
|
180
132
|
return {
|
|
181
|
-
id: 'HC-
|
|
133
|
+
id: 'HC-1',
|
|
182
134
|
name: 'Stop + SessionStart hooks registered',
|
|
183
135
|
status: 'pass',
|
|
184
136
|
message: 'all kit hooks wired to their correct event arrays in .claude/settings.json',
|
|
185
137
|
};
|
|
186
138
|
}
|
|
187
139
|
|
|
188
|
-
// --- HC-
|
|
189
|
-
function
|
|
140
|
+
// --- HC-2: distill freshness (≤2 days) --------------------------------
|
|
141
|
+
function hc2DistillFreshness({ projectRoot, now }) {
|
|
190
142
|
const recentPath = join(projectRoot, ...RECENT_MD_REL);
|
|
191
143
|
if (!existsSync(recentPath)) {
|
|
192
144
|
// Not a failure: on a fresh project there's nothing distilled yet, and
|
|
@@ -194,7 +146,7 @@ function hc3DistillFreshness({ projectRoot, now }) {
|
|
|
194
146
|
// (or `cmk daily-distill`). "Stale recent.md" below IS a real fail; a
|
|
195
147
|
// never-built one is just "not yet" — mirror HC-5's skip-on-fresh.
|
|
196
148
|
return {
|
|
197
|
-
id: 'HC-
|
|
149
|
+
id: 'HC-2',
|
|
198
150
|
name: 'Daily distill is fresh (≤2 days)',
|
|
199
151
|
status: 'skip',
|
|
200
152
|
message: 'recent.md not built yet — nothing to distill. Runs automatically (lazy-on-read at SessionStart, or `cmk daily-distill`) once there is session content.',
|
|
@@ -205,7 +157,7 @@ function hc3DistillFreshness({ projectRoot, now }) {
|
|
|
205
157
|
mtimeMs = statSync(recentPath).mtimeMs;
|
|
206
158
|
} catch (err) {
|
|
207
159
|
return {
|
|
208
|
-
id: 'HC-
|
|
160
|
+
id: 'HC-2',
|
|
209
161
|
name: 'Daily distill is fresh (≤2 days)',
|
|
210
162
|
status: 'fail',
|
|
211
163
|
message: `recent.md stat error: ${err?.message ?? err}`,
|
|
@@ -216,7 +168,7 @@ function hc3DistillFreshness({ projectRoot, now }) {
|
|
|
216
168
|
const ageMs = nowMs - mtimeMs;
|
|
217
169
|
if (ageMs > TWO_DAYS_MS) {
|
|
218
170
|
return {
|
|
219
|
-
id: 'HC-
|
|
171
|
+
id: 'HC-2',
|
|
220
172
|
name: 'Daily distill is fresh (≤2 days)',
|
|
221
173
|
status: 'fail',
|
|
222
174
|
message: `recent.md ${Math.round(ageMs / (24 * 60 * 60 * 1000))}d old (cutoff: 2d)`,
|
|
@@ -224,21 +176,21 @@ function hc3DistillFreshness({ projectRoot, now }) {
|
|
|
224
176
|
};
|
|
225
177
|
}
|
|
226
178
|
return {
|
|
227
|
-
id: 'HC-
|
|
179
|
+
id: 'HC-2',
|
|
228
180
|
name: 'Daily distill is fresh (≤2 days)',
|
|
229
181
|
status: 'pass',
|
|
230
182
|
message: `recent.md ${Math.round(ageMs / (60 * 60 * 1000))}h old`,
|
|
231
183
|
};
|
|
232
184
|
}
|
|
233
185
|
|
|
234
|
-
// --- HC-
|
|
235
|
-
function
|
|
186
|
+
// --- HC-3: transcripts firing (≤3 days) -------------------------------
|
|
187
|
+
function hc3Transcripts({ projectRoot, now }) {
|
|
236
188
|
const transcriptsDir = join(projectRoot, ...TRANSCRIPTS_REL);
|
|
237
189
|
if (!existsSync(transcriptsDir)) {
|
|
238
190
|
// Fresh project, never had a Claude Code session here → nothing to fire
|
|
239
191
|
// yet. Skip, don't fail (the dir + first transcript appear on the first turn).
|
|
240
192
|
return {
|
|
241
|
-
id: 'HC-
|
|
193
|
+
id: 'HC-3',
|
|
242
194
|
name: 'Transcripts firing (≤3 days)',
|
|
243
195
|
status: 'skip',
|
|
244
196
|
message: 'no transcripts yet — they appear after your first Claude Code turn in this project.',
|
|
@@ -262,7 +214,7 @@ function hc4Transcripts({ projectRoot, now }) {
|
|
|
262
214
|
// Dir exists (scaffolded) but no transcripts captured yet → still "not
|
|
263
215
|
// yet", not a failure.
|
|
264
216
|
return {
|
|
265
|
-
id: 'HC-
|
|
217
|
+
id: 'HC-3',
|
|
266
218
|
name: 'Transcripts firing (≤3 days)',
|
|
267
219
|
status: 'skip',
|
|
268
220
|
message: 'no transcripts yet — they appear after your first Claude Code turn in this project.',
|
|
@@ -272,7 +224,7 @@ function hc4Transcripts({ projectRoot, now }) {
|
|
|
272
224
|
// Transcripts EXIST but none recent → the kit was capturing and stopped.
|
|
273
225
|
// That IS a real signal (the Stop hook may not be firing here).
|
|
274
226
|
return {
|
|
275
|
-
id: 'HC-
|
|
227
|
+
id: 'HC-3',
|
|
276
228
|
name: 'Transcripts firing (≤3 days)',
|
|
277
229
|
status: 'fail',
|
|
278
230
|
message: 'transcripts exist but none within 3 days — the Stop hook may have stopped firing (is this project Claude Code\'s primary cwd?)',
|
|
@@ -280,20 +232,20 @@ function hc4Transcripts({ projectRoot, now }) {
|
|
|
280
232
|
};
|
|
281
233
|
}
|
|
282
234
|
return {
|
|
283
|
-
id: 'HC-
|
|
235
|
+
id: 'HC-3',
|
|
284
236
|
name: 'Transcripts firing (≤3 days)',
|
|
285
237
|
status: 'pass',
|
|
286
238
|
message: `${recentCount} transcript(s) within 3 days`,
|
|
287
239
|
};
|
|
288
240
|
}
|
|
289
241
|
|
|
290
|
-
// --- HC-
|
|
291
|
-
function
|
|
242
|
+
// --- HC-4: INDEX.md matches context/memory/ ---------------------------
|
|
243
|
+
function hc4IndexConsistency({ projectRoot }) {
|
|
292
244
|
const memoryDir = join(projectRoot, ...MEMORY_DIR_REL);
|
|
293
245
|
const indexPath = join(projectRoot, ...MEMORY_INDEX_REL);
|
|
294
246
|
if (!existsSync(memoryDir)) {
|
|
295
247
|
return {
|
|
296
|
-
id: 'HC-
|
|
248
|
+
id: 'HC-4',
|
|
297
249
|
name: 'INDEX.md matches context/memory/ files',
|
|
298
250
|
status: 'skip',
|
|
299
251
|
message: 'context/memory/ missing — no granular facts to index yet',
|
|
@@ -301,7 +253,7 @@ function hc5IndexConsistency({ projectRoot }) {
|
|
|
301
253
|
}
|
|
302
254
|
if (!existsSync(indexPath)) {
|
|
303
255
|
return {
|
|
304
|
-
id: 'HC-
|
|
256
|
+
id: 'HC-4',
|
|
305
257
|
name: 'INDEX.md matches context/memory/ files',
|
|
306
258
|
status: 'fail',
|
|
307
259
|
message: 'context/memory/INDEX.md missing',
|
|
@@ -316,7 +268,7 @@ function hc5IndexConsistency({ projectRoot }) {
|
|
|
316
268
|
);
|
|
317
269
|
} catch (err) {
|
|
318
270
|
return {
|
|
319
|
-
id: 'HC-
|
|
271
|
+
id: 'HC-4',
|
|
320
272
|
name: 'INDEX.md matches context/memory/ files',
|
|
321
273
|
status: 'fail',
|
|
322
274
|
message: `readdir error: ${err?.message ?? err}`,
|
|
@@ -330,7 +282,7 @@ function hc5IndexConsistency({ projectRoot }) {
|
|
|
330
282
|
indexText = readFileSync(indexPath, 'utf8');
|
|
331
283
|
} catch (err) {
|
|
332
284
|
return {
|
|
333
|
-
id: 'HC-
|
|
285
|
+
id: 'HC-4',
|
|
334
286
|
name: 'INDEX.md matches context/memory/ files',
|
|
335
287
|
status: 'fail',
|
|
336
288
|
message: `INDEX.md read error: ${err?.message ?? err}`,
|
|
@@ -365,7 +317,7 @@ function hc5IndexConsistency({ projectRoot }) {
|
|
|
365
317
|
const inIndexNotFacts = [...indexEntries].filter((f) => !factSet.has(f));
|
|
366
318
|
if (inFactsNotIndex.length === 0 && inIndexNotFacts.length === 0) {
|
|
367
319
|
return {
|
|
368
|
-
id: 'HC-
|
|
320
|
+
id: 'HC-4',
|
|
369
321
|
name: 'INDEX.md matches context/memory/ files',
|
|
370
322
|
status: 'pass',
|
|
371
323
|
message: `${factFiles.length} fact file(s); INDEX in sync`,
|
|
@@ -375,7 +327,7 @@ function hc5IndexConsistency({ projectRoot }) {
|
|
|
375
327
|
if (inFactsNotIndex.length > 0) parts.push(`missing from INDEX: ${inFactsNotIndex.length}`);
|
|
376
328
|
if (inIndexNotFacts.length > 0) parts.push(`stale in INDEX: ${inIndexNotFacts.length}`);
|
|
377
329
|
return {
|
|
378
|
-
id: 'HC-
|
|
330
|
+
id: 'HC-4',
|
|
379
331
|
name: 'INDEX.md matches context/memory/ files',
|
|
380
332
|
status: 'fail',
|
|
381
333
|
message: parts.join('; '),
|
|
@@ -383,11 +335,11 @@ function hc5IndexConsistency({ projectRoot }) {
|
|
|
383
335
|
};
|
|
384
336
|
}
|
|
385
337
|
|
|
386
|
-
// --- HC-
|
|
387
|
-
function
|
|
338
|
+
// --- HC-5: Cron jobs registered with host scheduler -------------------
|
|
339
|
+
function hc5CronRegistered({ projectRoot }) {
|
|
388
340
|
if (existsSync(cronSentinelPath(projectRoot))) {
|
|
389
341
|
return {
|
|
390
|
-
id: 'HC-
|
|
342
|
+
id: 'HC-5',
|
|
391
343
|
name: 'Cron jobs registered with host scheduler',
|
|
392
344
|
status: 'pass',
|
|
393
345
|
message: 'cron-registered sentinel present',
|
|
@@ -398,38 +350,15 @@ function hc6CronRegistered({ projectRoot }) {
|
|
|
398
350
|
// SKIP, not a FAIL — flagging an optional, working-by-fallback feature as a
|
|
399
351
|
// failure made a healthy fresh install read as broken.
|
|
400
352
|
return {
|
|
401
|
-
id: 'HC-
|
|
353
|
+
id: 'HC-5',
|
|
402
354
|
name: 'Cron jobs registered with host scheduler',
|
|
403
355
|
status: 'skip',
|
|
404
356
|
message: 'cron not registered (optional) — using the lazy-on-read fallback (compresses at SessionStart). Run `cmk register-crons` for scheduled background compression.',
|
|
405
357
|
};
|
|
406
358
|
}
|
|
407
359
|
|
|
408
|
-
// --- HC-
|
|
409
|
-
function
|
|
410
|
-
// Only relevant if HC-1 passed. Skip when memsearch isn't installed.
|
|
411
|
-
if (hc1Result.status !== 'pass') {
|
|
412
|
-
return {
|
|
413
|
-
id: 'HC-7',
|
|
414
|
-
name: 'memsearch backend reachable',
|
|
415
|
-
status: 'skip',
|
|
416
|
-
message: 'depends on HC-1 (memsearch installed) — skipped',
|
|
417
|
-
};
|
|
418
|
-
}
|
|
419
|
-
// HC-1 already proves memsearch --version succeeds. For HC-7 the
|
|
420
|
-
// additional check would be milvus reachability — out of scope for
|
|
421
|
-
// v0.1.0's keyword-only ship (Layer 5b is v0.1.x). Treat HC-7 as
|
|
422
|
-
// pass when HC-1 passes.
|
|
423
|
-
return {
|
|
424
|
-
id: 'HC-7',
|
|
425
|
-
name: 'memsearch backend reachable',
|
|
426
|
-
status: 'pass',
|
|
427
|
-
message: 'memsearch responds to --version (milvus reachability is Layer 5b / v0.1.x)',
|
|
428
|
-
};
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
// --- HC-8: Native Anthropic Auto Memory status -----------------------
|
|
432
|
-
function hc8NativeAutoMemory({ projectRoot, now }) {
|
|
360
|
+
// --- HC-6: Native Anthropic Auto Memory status -----------------------
|
|
361
|
+
function hc6NativeAutoMemory({ projectRoot, now }) {
|
|
433
362
|
// Per ADR-0011 — detect whether Anthropic's native Auto Memory is
|
|
434
363
|
// also active for this project. Non-fatal; informational. Log the
|
|
435
364
|
// result to context/.locks/native-memory-status.log so users can
|
|
@@ -507,19 +436,19 @@ function hc8NativeAutoMemory({ projectRoot, now }) {
|
|
|
507
436
|
}
|
|
508
437
|
|
|
509
438
|
return {
|
|
510
|
-
id: 'HC-
|
|
439
|
+
id: 'HC-6',
|
|
511
440
|
name: 'Native Anthropic Auto Memory status detected',
|
|
512
441
|
status: 'pass',
|
|
513
442
|
message,
|
|
514
443
|
};
|
|
515
444
|
}
|
|
516
445
|
|
|
517
|
-
// --- HC-
|
|
518
|
-
function
|
|
446
|
+
// --- HC-7: Stale lock files -------------------------------------------
|
|
447
|
+
function hc7StaleLocks({ projectRoot, userDir }) {
|
|
519
448
|
const stale = detectStaleLocks(projectRoot, { userDir }).filter((r) => r.stale);
|
|
520
449
|
if (stale.length === 0) {
|
|
521
450
|
return {
|
|
522
|
-
id: 'HC-
|
|
451
|
+
id: 'HC-7',
|
|
523
452
|
name: 'No stale lock files',
|
|
524
453
|
status: 'pass',
|
|
525
454
|
message: 'all locks healthy',
|
|
@@ -533,7 +462,7 @@ function hc9StaleLocks({ projectRoot, userDir }) {
|
|
|
533
462
|
? ` (+ ${stale.length - 1} more — re-run after cleaning to surface)`
|
|
534
463
|
: '';
|
|
535
464
|
return {
|
|
536
|
-
id: 'HC-
|
|
465
|
+
id: 'HC-7',
|
|
537
466
|
name: 'No stale lock files',
|
|
538
467
|
status: 'fail',
|
|
539
468
|
message: `${stale.length} stale lock(s); first: ${first.path} (${first.reason})${moreNote}`,
|
|
@@ -542,7 +471,7 @@ function hc9StaleLocks({ projectRoot, userDir }) {
|
|
|
542
471
|
}
|
|
543
472
|
|
|
544
473
|
/**
|
|
545
|
-
* Run the full
|
|
474
|
+
* Run the full 7-check health audit.
|
|
546
475
|
*
|
|
547
476
|
* @param {object} opts
|
|
548
477
|
* @param {string} opts.projectRoot
|
|
@@ -573,20 +502,18 @@ export async function runDoctor({
|
|
|
573
502
|
const ts = now ?? nowIso();
|
|
574
503
|
const resolvedUserDir = userDir ?? join(homedir(), '.claude-memory-kit');
|
|
575
504
|
|
|
576
|
-
// Run in order.
|
|
577
|
-
const c1 =
|
|
578
|
-
const c2 =
|
|
579
|
-
const c3 =
|
|
580
|
-
const c4 =
|
|
581
|
-
const c5 =
|
|
582
|
-
const c6 =
|
|
583
|
-
const c7 =
|
|
584
|
-
const c8 = hc8NativeAutoMemory({ projectRoot, now: ts });
|
|
585
|
-
const c9 = hc9StaleLocks({ projectRoot, userDir: resolvedUserDir });
|
|
505
|
+
// Run all checks in order.
|
|
506
|
+
const c1 = hc1Hooks({ projectRoot });
|
|
507
|
+
const c2 = hc2DistillFreshness({ projectRoot, now: ts });
|
|
508
|
+
const c3 = hc3Transcripts({ projectRoot, now: ts });
|
|
509
|
+
const c4 = hc4IndexConsistency({ projectRoot });
|
|
510
|
+
const c5 = hc5CronRegistered({ projectRoot });
|
|
511
|
+
const c6 = hc6NativeAutoMemory({ projectRoot, now: ts });
|
|
512
|
+
const c7 = hc7StaleLocks({ projectRoot, userDir: resolvedUserDir });
|
|
586
513
|
|
|
587
514
|
return {
|
|
588
515
|
action: 'completed',
|
|
589
|
-
checks: [c1, c2, c3, c4, c5, c6, c7
|
|
516
|
+
checks: [c1, c2, c3, c4, c5, c6, c7],
|
|
590
517
|
duration_ms: Date.now() - t0,
|
|
591
518
|
};
|
|
592
519
|
}
|
package/src/forget.mjs
CHANGED
|
@@ -29,6 +29,7 @@ import { ERROR_CATEGORIES, errorResult, notFoundResult } from './result-shapes.m
|
|
|
29
29
|
import { findBulletScratchpad } from './bullet-lookup.mjs';
|
|
30
30
|
import { openIndexDb } from './index-db.mjs';
|
|
31
31
|
import { reindexBoot } from './index-rebuild.mjs';
|
|
32
|
+
import { reindex } from './reindex.mjs';
|
|
32
33
|
|
|
33
34
|
// Layer-2 review: PR-1 rejected \n / \r / : in the `reason` field as a
|
|
34
35
|
// minimum fix for the naive serializer (finding B2). PR-2's frontmatter.mjs
|
|
@@ -292,6 +293,18 @@ export function forget(opts = {}) {
|
|
|
292
293
|
},
|
|
293
294
|
});
|
|
294
295
|
|
|
296
|
+
// Task 124 (D-112): the writer owns the derived view on the DELETE path
|
|
297
|
+
// too — writeFact refreshes INDEX.md on every create (the Task-85 lesson);
|
|
298
|
+
// without this, the tombstoned fact stayed listed in INDEX.md and doctor
|
|
299
|
+
// HC-4 failed until a manual `cmk reindex` (dogfood-found 2026-06-10).
|
|
300
|
+
// Best-effort, same contract as writeFact's: the tombstone is already
|
|
301
|
+
// durable on disk, so an index hiccup must not fail the forget.
|
|
302
|
+
try {
|
|
303
|
+
reindex({ tier: match.tier, projectRoot, userDir, warn: () => {} });
|
|
304
|
+
} catch {
|
|
305
|
+
// index rebuild is best-effort; the tombstone already succeeded
|
|
306
|
+
}
|
|
307
|
+
|
|
295
308
|
// Task 110 (F-7 / D-84): reindex the project tier IN-BAND so the just-
|
|
296
309
|
// tombstoned fact stops surfacing in `cmk search` immediately — no manual
|
|
297
310
|
// `cmk reindex`, no forgotten fact resurfacing (D-85: the action completes
|
package/src/frontmatter.mjs
CHANGED
|
@@ -43,7 +43,10 @@ const LOAD_OPTIONS = Object.freeze({
|
|
|
43
43
|
|
|
44
44
|
export function parse(text) {
|
|
45
45
|
if (typeof text !== 'string') return { frontmatter: null, body: '' };
|
|
46
|
-
|
|
46
|
+
// Task 139 (D-126): \r? tolerance — a Windows clone with autocrlf=true
|
|
47
|
+
// rewrites committed memory files to CRLF, and a strict-\n boundary made
|
|
48
|
+
// every fact file invisible (cut-gate9 H1: clone reindex found 0 facts).
|
|
49
|
+
const m = text.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
|
|
47
50
|
if (!m) return { frontmatter: null, body: text };
|
|
48
51
|
let frontmatter;
|
|
49
52
|
try {
|
|
@@ -39,6 +39,8 @@ import {
|
|
|
39
39
|
REASON_CODES,
|
|
40
40
|
} from './audit-log.mjs';
|
|
41
41
|
import { ERROR_CATEGORIES, errorResult } from './result-shapes.mjs';
|
|
42
|
+
import { writeBullet } from './provenance.mjs';
|
|
43
|
+
import { createHash } from 'node:crypto';
|
|
42
44
|
|
|
43
45
|
const MEMORY_REL = ['context', 'MEMORY.md'];
|
|
44
46
|
|
|
@@ -227,7 +229,29 @@ export async function importAnthropicMemory({
|
|
|
227
229
|
// deduplication of section headers is a v0.1.x candidate per design §16.
|
|
228
230
|
const today = ts.slice(0, 10);
|
|
229
231
|
const sectionHeader = `\n## Imported (Anthropic auto-memory, ${today})\n`;
|
|
230
|
-
|
|
232
|
+
// Task 138 (D-125): emit the CANONICAL provenance comment via the shared
|
|
233
|
+
// writeBullet builder — the hand-rolled `write_source:`-keyed comment was
|
|
234
|
+
// invisible to the reindex parser (it maps the `write:` key to the
|
|
235
|
+
// NOT-NULL observations.write_source column), so the first reindex after
|
|
236
|
+
// an import failed and search degraded to the stale index (cut-gate9 F-13).
|
|
237
|
+
const bulletLines = proposals
|
|
238
|
+
.map((p) => {
|
|
239
|
+
const sha1 = createHash('sha1').update(p.text, 'utf8').digest('hex');
|
|
240
|
+
const formatted = writeBullet({
|
|
241
|
+
id: p.id,
|
|
242
|
+
text: p.text,
|
|
243
|
+
provenance: {
|
|
244
|
+
source: 'anthropic-auto-memory',
|
|
245
|
+
source_line: 1,
|
|
246
|
+
sha1,
|
|
247
|
+
write: 'imported',
|
|
248
|
+
trust: 'medium',
|
|
249
|
+
at: ts,
|
|
250
|
+
},
|
|
251
|
+
});
|
|
252
|
+
return formatted.lines;
|
|
253
|
+
})
|
|
254
|
+
.join('\n');
|
|
231
255
|
mkdirSync(join(projectRoot, 'context'), { recursive: true });
|
|
232
256
|
appendFileSync(targetPath, sectionHeader + '\n' + bulletLines + '\n', 'utf8');
|
|
233
257
|
|
package/src/index-db.mjs
CHANGED
|
@@ -116,6 +116,45 @@ CREATE TABLE IF NOT EXISTS files (
|
|
|
116
116
|
sha1 TEXT NOT NULL,
|
|
117
117
|
indexed_at INTEGER NOT NULL
|
|
118
118
|
);
|
|
119
|
+
|
|
120
|
+
-- Task 104.2 — the L3 raw tier (D-117). Transcript turn-chunks live in a
|
|
121
|
+
-- SEPARATE table + FTS so the raw tier is searched only when explicitly
|
|
122
|
+
-- asked (search --scope transcripts, the MemPalace last-resort contract)
|
|
123
|
+
-- and never pollutes L1 fact results. Chunks have no id/tier/trust — the
|
|
124
|
+
-- drill-back key is source_file:source_line. IF NOT EXISTS means existing
|
|
125
|
+
-- DBs gain these tables on the first open after upgrade (no migration).
|
|
126
|
+
CREATE TABLE IF NOT EXISTS transcript_chunks (
|
|
127
|
+
source_file TEXT NOT NULL,
|
|
128
|
+
chunk_idx INTEGER NOT NULL,
|
|
129
|
+
source_line INTEGER NOT NULL,
|
|
130
|
+
heading TEXT,
|
|
131
|
+
body TEXT NOT NULL,
|
|
132
|
+
PRIMARY KEY (source_file, chunk_idx)
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS transcript_chunks_fts USING fts5(
|
|
136
|
+
body, heading,
|
|
137
|
+
content='transcript_chunks',
|
|
138
|
+
content_rowid='rowid',
|
|
139
|
+
tokenize='porter unicode61'
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
CREATE TRIGGER IF NOT EXISTS tch_after_insert AFTER INSERT ON transcript_chunks BEGIN
|
|
143
|
+
INSERT INTO transcript_chunks_fts(rowid, body, heading)
|
|
144
|
+
VALUES (new.rowid, new.body, new.heading);
|
|
145
|
+
END;
|
|
146
|
+
|
|
147
|
+
CREATE TRIGGER IF NOT EXISTS tch_after_update AFTER UPDATE ON transcript_chunks BEGIN
|
|
148
|
+
INSERT INTO transcript_chunks_fts(transcript_chunks_fts, rowid, body, heading)
|
|
149
|
+
VALUES ('delete', old.rowid, old.body, old.heading);
|
|
150
|
+
INSERT INTO transcript_chunks_fts(rowid, body, heading)
|
|
151
|
+
VALUES (new.rowid, new.body, new.heading);
|
|
152
|
+
END;
|
|
153
|
+
|
|
154
|
+
CREATE TRIGGER IF NOT EXISTS tch_after_delete AFTER DELETE ON transcript_chunks BEGIN
|
|
155
|
+
INSERT INTO transcript_chunks_fts(transcript_chunks_fts, rowid, body, heading)
|
|
156
|
+
VALUES ('delete', old.rowid, old.body, old.heading);
|
|
157
|
+
END;
|
|
119
158
|
`;
|
|
120
159
|
|
|
121
160
|
/**
|
package/src/index-rebuild.mjs
CHANGED
|
@@ -47,6 +47,7 @@ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
|
47
47
|
import { basename, join, relative } from 'node:path';
|
|
48
48
|
import chokidar from 'chokidar';
|
|
49
49
|
import { INDEX_DB_SCHEMA } from './index-db.mjs';
|
|
50
|
+
import { syncTranscriptChunks } from './transcript-index.mjs';
|
|
50
51
|
import { readBullet, parseBulletProvenance } from './provenance.mjs';
|
|
51
52
|
import { parse as parseFrontmatter } from './frontmatter.mjs';
|
|
52
53
|
import {
|
|
@@ -145,7 +146,10 @@ export function parseObservationsFromScratchpad({
|
|
|
145
146
|
projectRoot,
|
|
146
147
|
userDir,
|
|
147
148
|
}) {
|
|
148
|
-
|
|
149
|
+
// Task 139 (D-126): CRLF-tolerant read — autocrlf clones rewrite the
|
|
150
|
+
// committed memory files; a strict-\n split left \r on every line and
|
|
151
|
+
// the bullet/provenance regexes went blind.
|
|
152
|
+
const lines = content.split(/\r?\n/);
|
|
149
153
|
const sha1 = sha1OfContent(content);
|
|
150
154
|
const source_file = relativeSource(path, { projectRoot, userDir });
|
|
151
155
|
const baseName = basename(path);
|
|
@@ -435,6 +439,12 @@ export function reindexBoot({ projectRoot, userDir, db, now }) {
|
|
|
435
439
|
});
|
|
436
440
|
const knownPaths = db.prepare('SELECT path FROM files').all();
|
|
437
441
|
for (const { path: relPath } of knownPaths) {
|
|
442
|
+
// Task 104.2 composition guard: 'transcript:'-prefixed checkpoints
|
|
443
|
+
// belong to the transcript scope (transcript-index.mjs) — they are
|
|
444
|
+
// never in the observation live-set and pruning them here would
|
|
445
|
+
// defeat that scope's checkpoint on every boot. Its own sync prunes
|
|
446
|
+
// its own orphans.
|
|
447
|
+
if (relPath.startsWith('transcript:')) continue;
|
|
438
448
|
if (liveRelPaths.has(relPath)) continue;
|
|
439
449
|
const obsCount = db
|
|
440
450
|
.prepare('SELECT COUNT(*) AS n FROM observations WHERE source_file = ?')
|
|
@@ -443,12 +453,24 @@ export function reindexBoot({ projectRoot, userDir, db, now }) {
|
|
|
443
453
|
}
|
|
444
454
|
}
|
|
445
455
|
|
|
456
|
+
// Task 104.2 — sync the transcript scope (the L3 raw tier) in the same
|
|
457
|
+
// boot pass. Cheap: per-file sha1 checkpoint; best-effort — a transcript
|
|
458
|
+
// sync hiccup must not fail the observation reindex.
|
|
459
|
+
let transcripts = { files: 0, chunks: 0 };
|
|
460
|
+
try {
|
|
461
|
+
transcripts = syncTranscriptChunks({ db, projectRoot, now: ts });
|
|
462
|
+
} catch {
|
|
463
|
+
// best-effort; the next boot retries
|
|
464
|
+
}
|
|
465
|
+
|
|
446
466
|
return {
|
|
447
467
|
filesScanned,
|
|
448
468
|
filesReindexed,
|
|
449
469
|
observationsAffected,
|
|
450
470
|
filesPruned,
|
|
451
471
|
observationsPruned,
|
|
472
|
+
transcriptFiles: transcripts.files,
|
|
473
|
+
transcriptChunks: transcripts.chunks,
|
|
452
474
|
durationMs: Date.now() - t0,
|
|
453
475
|
skipped,
|
|
454
476
|
};
|
|
@@ -464,13 +486,20 @@ export function reindexBoot({ projectRoot, userDir, db, now }) {
|
|
|
464
486
|
export function reindexFull({ projectRoot, userDir, db, now }) {
|
|
465
487
|
const t0 = Date.now();
|
|
466
488
|
const ts = now ?? t0;
|
|
467
|
-
// Drop + recreate (faster than per-row DELETE).
|
|
489
|
+
// Drop + recreate (faster than per-row DELETE). Task 104.2: the transcript
|
|
490
|
+
// scope drops + rebuilds with everything else — `files` carries its
|
|
491
|
+
// checkpoints, so a full reindex must re-chunk from scratch too.
|
|
468
492
|
db.exec(`
|
|
469
493
|
DROP TABLE IF EXISTS observations_fts;
|
|
470
494
|
DROP TRIGGER IF EXISTS obs_after_insert;
|
|
471
495
|
DROP TRIGGER IF EXISTS obs_after_update;
|
|
472
496
|
DROP TRIGGER IF EXISTS obs_after_delete;
|
|
473
497
|
DROP TABLE IF EXISTS observations;
|
|
498
|
+
DROP TABLE IF EXISTS transcript_chunks_fts;
|
|
499
|
+
DROP TRIGGER IF EXISTS tch_after_insert;
|
|
500
|
+
DROP TRIGGER IF EXISTS tch_after_update;
|
|
501
|
+
DROP TRIGGER IF EXISTS tch_after_delete;
|
|
502
|
+
DROP TABLE IF EXISTS transcript_chunks;
|
|
474
503
|
DROP TABLE IF EXISTS files;
|
|
475
504
|
`);
|
|
476
505
|
db.exec(INDEX_DB_SCHEMA);
|
|
@@ -514,9 +543,20 @@ export function reindexFull({ projectRoot, userDir, db, now }) {
|
|
|
514
543
|
observationsAffected += txn(source, sha1);
|
|
515
544
|
}
|
|
516
545
|
|
|
546
|
+
// Task 104.2 — rebuild the transcript scope from scratch (its tables were
|
|
547
|
+
// dropped above). Best-effort, same contract as the boot-path sync.
|
|
548
|
+
let transcripts = { files: 0, chunks: 0 };
|
|
549
|
+
try {
|
|
550
|
+
transcripts = syncTranscriptChunks({ db, projectRoot, now: ts });
|
|
551
|
+
} catch {
|
|
552
|
+
// best-effort; the next reindex retries
|
|
553
|
+
}
|
|
554
|
+
|
|
517
555
|
return {
|
|
518
556
|
filesScanned,
|
|
519
557
|
observationsAffected,
|
|
558
|
+
transcriptFiles: transcripts.files,
|
|
559
|
+
transcriptChunks: transcripts.chunks,
|
|
520
560
|
durationMs: Date.now() - t0,
|
|
521
561
|
skipped,
|
|
522
562
|
};
|