@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/src/doctor.mjs CHANGED
@@ -1,4 +1,4 @@
1
- // `cmk doctor` — health checks HC-1..HC-9 (Task 37, T-031).
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',
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-3 distill freshness via cooldown marker mtime is
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-6 cron registration check)
21
- // - lock-discipline.mjs::detectStaleLocks (HC-9)
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: memsearch installed ----------------------------------------
60
- async function hc1Memsearch() {
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-2',
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-2',
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-2',
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-2',
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-3: distill freshness (≤2 days) --------------------------------
189
- function hc3DistillFreshness({ projectRoot, now }) {
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-3',
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-3',
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-3',
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-3',
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-4: transcripts firing (≤3 days) -------------------------------
235
- function hc4Transcripts({ projectRoot, now }) {
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-4',
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-4',
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-4',
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-4',
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-5: INDEX.md matches context/memory/ ---------------------------
291
- function hc5IndexConsistency({ projectRoot }) {
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-5',
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-5',
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-5',
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-5',
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-5',
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-5',
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-6: Cron jobs registered with host scheduler -------------------
387
- function hc6CronRegistered({ projectRoot }) {
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-6',
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-6',
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-7: memsearch backend reachable --------------------------------
409
- function hc7MemsearchReachable(hc1Result) {
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-8',
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-9: Stale lock files -------------------------------------------
518
- function hc9StaleLocks({ projectRoot, userDir }) {
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-9',
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-9',
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 9-check health audit.
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. HC-7 depends on HC-1's verdict.
577
- const c1 = await hc1Memsearch();
578
- const c2 = hc2Hooks({ projectRoot });
579
- const c3 = hc3DistillFreshness({ projectRoot, now: ts });
580
- const c4 = hc4Transcripts({ projectRoot, now: ts });
581
- const c5 = hc5IndexConsistency({ projectRoot });
582
- const c6 = hc6CronRegistered({ projectRoot });
583
- const c7 = hc7MemsearchReachable(c1);
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, c8, c9],
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
@@ -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
- const m = text.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
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
- const bulletLines = proposals.map((p) => `- (${p.id}) ${p.text}\n<!-- write_source: imported, trust: medium, source: anthropic-auto-memory, imported_at: ${ts} -->`).join('\n');
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
  /**
@@ -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
- const lines = content.split('\n');
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
  };