claude-mem-lite 3.96.0 → 3.97.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/adopt-cli.mjs +125 -51
  4. package/bash-utils.mjs +105 -29
  5. package/claudemd.mjs +63 -19
  6. package/cli/activity.mjs +36 -18
  7. package/cli/common.mjs +101 -18
  8. package/cli/doctor.mjs +29 -9
  9. package/cli.mjs +69 -10
  10. package/deep-search.mjs +63 -20
  11. package/format-utils.mjs +15 -7
  12. package/haiku-client.mjs +189 -111
  13. package/hash-utils.mjs +15 -6
  14. package/hook-context.mjs +135 -46
  15. package/hook-episode.mjs +76 -23
  16. package/hook-handoff.mjs +216 -84
  17. package/hook-llm.mjs +476 -216
  18. package/hook-memory.mjs +106 -43
  19. package/hook-optimize.mjs +272 -109
  20. package/hook-precompact.mjs +1 -1
  21. package/hook-semaphore.mjs +46 -12
  22. package/hook-shared.mjs +101 -131
  23. package/hook-update.mjs +286 -88
  24. package/hook.mjs +1207 -617
  25. package/install-metadata.mjs +246 -125
  26. package/install.mjs +1327 -917
  27. package/lib/activity.mjs +52 -29
  28. package/lib/atomic-write.mjs +23 -4
  29. package/lib/binding-probe.mjs +40 -38
  30. package/lib/browse-core.mjs +22 -8
  31. package/lib/citation-tracker.mjs +189 -79
  32. package/lib/cite-back-hint.mjs +37 -19
  33. package/lib/cite-recall-path.mjs +3 -1
  34. package/lib/cli-flags.mjs +4 -2
  35. package/lib/cli-project.mjs +8 -5
  36. package/lib/compress-core.mjs +34 -9
  37. package/lib/cooldown-path.mjs +3 -1
  38. package/lib/db-backup.mjs +33 -11
  39. package/lib/deferred-work.mjs +73 -32
  40. package/lib/delete-core.mjs +25 -9
  41. package/lib/doctor-benchmark.mjs +5 -1
  42. package/lib/doctor-drift.mjs +25 -8
  43. package/lib/edge-attribution.mjs +23 -9
  44. package/lib/env-number.mjs +7 -8
  45. package/lib/err-sampler.mjs +22 -11
  46. package/lib/error-recall-core.mjs +15 -17
  47. package/lib/events-injection.mjs +25 -8
  48. package/lib/export-columns.mjs +49 -10
  49. package/lib/fast-summary.mjs +22 -9
  50. package/lib/file-edge-match.mjs +4 -2
  51. package/lib/file-intel.mjs +38 -13
  52. package/lib/frontmatter.mjs +22 -5
  53. package/lib/get-core.mjs +78 -11
  54. package/lib/handoff-constants.mjs +18 -0
  55. package/lib/hook-stdin.mjs +10 -3
  56. package/lib/hook-stdout.mjs +21 -7
  57. package/lib/hook-telemetry.mjs +44 -17
  58. package/lib/id-routing.mjs +11 -6
  59. package/lib/import-jsonl.mjs +92 -41
  60. package/lib/inject-search-core.mjs +5 -2
  61. package/lib/injected-ids.mjs +19 -12
  62. package/lib/install-shape.mjs +47 -23
  63. package/lib/keyctx-marker.mjs +3 -1
  64. package/lib/lesson-bridge.mjs +8 -5
  65. package/lib/llm-call.mjs +76 -0
  66. package/lib/llm-provider-probe.mjs +24 -12
  67. package/lib/low-signal-patterns.mjs +25 -22
  68. package/lib/maintain-core.mjs +221 -92
  69. package/lib/mem-override.mjs +4 -2
  70. package/lib/metrics.mjs +30 -25
  71. package/lib/native-binding-hint.mjs +29 -8
  72. package/lib/observation-write.mjs +74 -19
  73. package/lib/patha-exclude-meter.mjs +10 -6
  74. package/lib/persist-reminder.mjs +22 -10
  75. package/lib/plan-reader.mjs +2 -2
  76. package/lib/private-strip.mjs +3 -3
  77. package/lib/proc-lock.mjs +15 -3
  78. package/lib/proxy-fetch.mjs +49 -13
  79. package/lib/quiet-scope.mjs +42 -0
  80. package/lib/recall-core.mjs +19 -7
  81. package/lib/recent-core.mjs +18 -5
  82. package/lib/registry-core.mjs +12 -6
  83. package/lib/release-digest.mjs +4 -1
  84. package/lib/relevance-floor.mjs +5 -2
  85. package/lib/reread-guard.mjs +16 -8
  86. package/lib/resolve-data-dir.mjs +1 -1
  87. package/lib/rrf.mjs +4 -1
  88. package/lib/save-enrich.mjs +36 -18
  89. package/lib/save-observation.mjs +78 -35
  90. package/lib/scrub-record.mjs +20 -6
  91. package/lib/search-core.mjs +456 -123
  92. package/lib/shard-gc.mjs +50 -0
  93. package/lib/startup-dashboard.mjs +20 -9
  94. package/lib/stats-core.mjs +101 -35
  95. package/lib/stats-quality.mjs +56 -20
  96. package/lib/summary-extractor.mjs +2 -2
  97. package/lib/task-imperative.mjs +12 -4
  98. package/lib/timeline-core.mjs +88 -28
  99. package/lib/tmp-fixture-sweep.mjs +32 -9
  100. package/lib/transcript-scan.mjs +20 -4
  101. package/lib/upgrade-banner.mjs +11 -7
  102. package/mem-cli.mjs +1314 -506
  103. package/memdir.mjs +53 -21
  104. package/nlp.mjs +68 -38
  105. package/npm-shrinkwrap.json +2 -2
  106. package/package.json +9 -2
  107. package/plugin-cache-guard.mjs +31 -15
  108. package/project-utils.mjs +44 -20
  109. package/registry-enricher.mjs +26 -6
  110. package/registry-github.mjs +4 -2
  111. package/registry-importer.mjs +151 -40
  112. package/registry-recommend.mjs +189 -96
  113. package/registry-retriever.mjs +229 -70
  114. package/registry-scanner.mjs +11 -6
  115. package/registry.mjs +163 -66
  116. package/rerank.mjs +18 -4
  117. package/resource-discovery.mjs +14 -4
  118. package/schema.mjs +258 -99
  119. package/scoring-sql.mjs +21 -10
  120. package/scripts/binding-probe-cli.mjs +17 -12
  121. package/scripts/hook-launcher.mjs +148 -31
  122. package/scripts/launch-preflight.mjs +5 -7
  123. package/scripts/launch.mjs +21 -9
  124. package/scripts/post-tool-recall.js +21 -5
  125. package/scripts/pre-agent-inject.js +51 -13
  126. package/scripts/pre-skill-bridge.js +22 -7
  127. package/scripts/pre-tool-recall.js +157 -77
  128. package/scripts/prompt-search-utils.mjs +70 -25
  129. package/scripts/setup.sh +16 -1
  130. package/scripts/user-prompt-search.js +238 -97
  131. package/search-engine.mjs +329 -96
  132. package/search-scoring.mjs +75 -33
  133. package/secret-scrub.mjs +48 -12
  134. package/server/fts-check.mjs +12 -9
  135. package/server.mjs +674 -243
  136. package/skip-tools.mjs +19 -8
  137. package/source-files.mjs +54 -21
  138. package/stop-words.mjs +152 -19
  139. package/synonyms.mjs +579 -164
  140. package/tfidf.mjs +130 -40
  141. package/tier.mjs +1 -1
  142. package/tool-schemas.mjs +381 -131
  143. package/utils.mjs +184 -36
package/schema.mjs CHANGED
@@ -178,17 +178,15 @@ export const CURRENT_SCHEMA_VERSION = 47;
178
178
  // pragma_table_info on a missing table returns zero rows (it does not throw), so
179
179
  // naming any column of the new table is a table-presence check.
180
180
  const LATEST_MIGRATION_COLUMNS = [
181
- { table: 'observations', column: 'decay_seen_at_first_cite' }, // v46
182
- { table: 'citation_surface_log', column: 'surface' }, // v45
183
- { table: 'observations', column: 'scope' }, // v44
181
+ { table: 'observations', column: 'decay_seen_at_first_cite' }, // v46
182
+ { table: 'citation_surface_log', column: 'surface' }, // v45
183
+ { table: 'observations', column: 'scope' }, // v44
184
184
  { table: 'observation_files', column: 'last_cited_session_id' }, // v43
185
185
  ];
186
186
 
187
187
  function hasLatestMigrationColumn(db) {
188
188
  try {
189
- const stmt = db.prepare(
190
- `SELECT 1 AS present FROM pragma_table_info(?) WHERE name = ?`
191
- );
189
+ const stmt = db.prepare(`SELECT 1 AS present FROM pragma_table_info(?) WHERE name = ?`);
192
190
  return LATEST_MIGRATION_COLUMNS.every(({ table, column }) => Boolean(stmt.get(table, column)));
193
191
  } catch {
194
192
  return false; // table itself missing → caller falls through to CORE_SCHEMA
@@ -322,7 +320,7 @@ const CORE_SCHEMA = `
322
320
  // Column migrations (idempotent — only swallow "duplicate column" errors)
323
321
  const MIGRATIONS = [
324
322
  'ALTER TABLE observations ADD COLUMN importance INTEGER DEFAULT 1',
325
- 'ALTER TABLE observations ADD COLUMN related_ids TEXT DEFAULT \'[]\'',
323
+ "ALTER TABLE observations ADD COLUMN related_ids TEXT DEFAULT '[]'",
326
324
  'ALTER TABLE observations ADD COLUMN minhash_sig TEXT',
327
325
  'ALTER TABLE observations ADD COLUMN access_count INTEGER DEFAULT 0',
328
326
  'ALTER TABLE observations ADD COLUMN compressed_into INTEGER DEFAULT NULL',
@@ -432,7 +430,7 @@ export function initSchema(db) {
432
430
  if (row.version > CURRENT_SCHEMA_VERSION) {
433
431
  throw new Error(
434
432
  `DB schema is v${row.version} but this claude-mem-lite binary supports up to v${CURRENT_SCHEMA_VERSION}. ` +
435
- `A newer version wrote this DB; upgrade claude-mem-lite (npm i -g claude-mem-lite@latest) or point CLAUDE_MEM_DIR to a fresh directory.`
433
+ `A newer version wrote this DB; upgrade claude-mem-lite (npm i -g claude-mem-lite@latest) or point CLAUDE_MEM_DIR to a fresh directory.`,
436
434
  );
437
435
  }
438
436
  }
@@ -459,14 +457,18 @@ export function initSchema(db) {
459
457
  db.pragma('foreign_keys = ON');
460
458
  return db;
461
459
  }
462
- } catch { /* table absent — proceed */ }
460
+ } catch {
461
+ /* table absent — proceed */
462
+ }
463
463
 
464
464
  // Create core tables
465
465
  db.exec(CORE_SCHEMA);
466
466
 
467
467
  // Run column migrations
468
468
  for (const sql of MIGRATIONS) {
469
- try { db.exec(sql); } catch (e) {
469
+ try {
470
+ db.exec(sql);
471
+ } catch (e) {
470
472
  if (!e.message?.includes('duplicate column name')) throw e;
471
473
  }
472
474
  }
@@ -475,7 +477,9 @@ export function initSchema(db) {
475
477
  // Old PK assumed one session per project, causing cross-session handoff overwrite
476
478
  // (see docs/bug.txt). Rebuild table if still on old PK. Idempotent.
477
479
  try {
478
- const handoffDdl = db.prepare(`SELECT sql FROM sqlite_master WHERE type='table' AND name='session_handoffs'`).get();
480
+ const handoffDdl = db
481
+ .prepare(`SELECT sql FROM sqlite_master WHERE type='table' AND name='session_handoffs'`)
482
+ .get();
479
483
  const oldPk = handoffDdl && /PRIMARY KEY\s*\(\s*project\s*,\s*type\s*\)/i.test(handoffDdl.sql);
480
484
  if (oldPk) {
481
485
  const rebuild = db.transaction(() => {
@@ -505,37 +509,54 @@ export function initSchema(db) {
505
509
  });
506
510
  rebuild();
507
511
  }
508
- } catch { /* non-critical — next open retries */ }
512
+ } catch {
513
+ /* non-critical — next open retries */
514
+ }
509
515
 
510
516
  // v25 (T10d): commit-anchored continuation — store HEAD sha at handoff time
511
517
  // so detectContinuationIntent can auto-confirm continuation when the working
512
518
  // tree hasn't moved since /exit or /clear. Runs AFTER the PK-widen rebuild
513
519
  // above so the new column is not clobbered by the DROP+CREATE path.
514
520
  try {
515
- const handoffCols = db.prepare(`PRAGMA table_info(session_handoffs)`).all().map(c => c.name);
521
+ const handoffCols = db
522
+ .prepare(`PRAGMA table_info(session_handoffs)`)
523
+ .all()
524
+ .map((c) => c.name);
516
525
  if (!handoffCols.includes('git_sha_at_handoff')) {
517
526
  db.exec(`ALTER TABLE session_handoffs ADD COLUMN git_sha_at_handoff TEXT DEFAULT NULL`);
518
527
  }
519
- } catch { /* non-critical — migration retries on next open */ }
528
+ } catch {
529
+ /* non-critical — migration retries on next open */
530
+ }
520
531
 
521
532
  // Dedup migration: ensure memory_session_id is unique, then enable FK
522
- const hasIdx = db.prepare(`SELECT 1 FROM sqlite_master WHERE type='index' AND name='idx_sess_memory_sid'`).get();
533
+ const hasIdx = db
534
+ .prepare(`SELECT 1 FROM sqlite_master WHERE type='index' AND name='idx_sess_memory_sid'`)
535
+ .get();
523
536
  if (!hasIdx) {
524
- const dupes = db.prepare(`
537
+ const dupes = db
538
+ .prepare(
539
+ `
525
540
  SELECT memory_session_id, COUNT(*) as cnt
526
541
  FROM sdk_sessions
527
542
  WHERE memory_session_id IS NOT NULL
528
543
  GROUP BY memory_session_id HAVING cnt > 1
529
- `).all();
544
+ `,
545
+ )
546
+ .all();
530
547
 
531
548
  // Atomic: dedup + create unique index in one transaction
532
549
  const dedupAndIndex = db.transaction(() => {
533
550
  for (const { memory_session_id } of dupes) {
534
- const rows = db.prepare(`
551
+ const rows = db
552
+ .prepare(
553
+ `
535
554
  SELECT s.id FROM sdk_sessions s
536
555
  WHERE s.memory_session_id = ?
537
556
  ORDER BY s.id ASC
538
- `).all(memory_session_id);
557
+ `,
558
+ )
559
+ .all(memory_session_id);
539
560
  for (let i = 1; i < rows.length; i++) {
540
561
  db.prepare('DELETE FROM sdk_sessions WHERE id = ?').run(rows[i].id);
541
562
  }
@@ -547,15 +568,27 @@ export function initSchema(db) {
547
568
 
548
569
  // Performance indexes
549
570
  db.exec(`CREATE INDEX IF NOT EXISTS idx_obs_epoch_project ON observations(created_at_epoch DESC, project)`);
550
- db.exec(`CREATE INDEX IF NOT EXISTS idx_sess_sum_epoch ON session_summaries(created_at_epoch DESC, project)`);
551
- db.exec(`CREATE INDEX IF NOT EXISTS idx_obs_project_epoch_minhash ON observations(project, created_at_epoch DESC) WHERE minhash_sig IS NOT NULL`);
571
+ db.exec(
572
+ `CREATE INDEX IF NOT EXISTS idx_sess_sum_epoch ON session_summaries(created_at_epoch DESC, project)`,
573
+ );
574
+ db.exec(
575
+ `CREATE INDEX IF NOT EXISTS idx_obs_project_epoch_minhash ON observations(project, created_at_epoch DESC) WHERE minhash_sig IS NOT NULL`,
576
+ );
552
577
  db.exec(`CREATE INDEX IF NOT EXISTS idx_user_prompts_session ON user_prompts(content_session_id)`);
553
- db.exec(`CREATE INDEX IF NOT EXISTS idx_user_prompts_cc ON user_prompts(cc_session_id) WHERE cc_session_id IS NOT NULL`);
554
- db.exec(`CREATE INDEX IF NOT EXISTS idx_obs_superseded ON observations(superseded_at) WHERE superseded_at IS NOT NULL`);
578
+ db.exec(
579
+ `CREATE INDEX IF NOT EXISTS idx_user_prompts_cc ON user_prompts(cc_session_id) WHERE cc_session_id IS NOT NULL`,
580
+ );
581
+ db.exec(
582
+ `CREATE INDEX IF NOT EXISTS idx_obs_superseded ON observations(superseded_at) WHERE superseded_at IS NOT NULL`,
583
+ );
555
584
  db.exec(`CREATE INDEX IF NOT EXISTS idx_obs_branch ON observations(branch) WHERE branch IS NOT NULL`);
556
585
  db.exec(`CREATE INDEX IF NOT EXISTS idx_sessions_project ON sdk_sessions(project)`);
557
- db.exec(`CREATE INDEX IF NOT EXISTS idx_obs_not_compressed ON observations(created_at_epoch DESC) WHERE COALESCE(compressed_into, 0) = 0`);
558
- db.exec(`CREATE INDEX IF NOT EXISTS idx_handoffs_project_time ON session_handoffs(project, type, created_at_epoch DESC)`);
586
+ db.exec(
587
+ `CREATE INDEX IF NOT EXISTS idx_obs_not_compressed ON observations(created_at_epoch DESC) WHERE COALESCE(compressed_into, 0) = 0`,
588
+ );
589
+ db.exec(
590
+ `CREATE INDEX IF NOT EXISTS idx_handoffs_project_time ON session_handoffs(project, type, created_at_epoch DESC)`,
591
+ );
559
592
  // v47 (audit 2026-09-02 P2-11 + the previous round's ALGO-7), one additive migration for
560
593
  // both. Additive only: new indexes on existing columns, no table rewrite, no data move.
561
594
  //
@@ -567,13 +600,17 @@ export function initSchema(db) {
567
600
  // The second narrows the live-row scan the injection faces run per project. Partial on the
568
601
  // same predicate `liveObsFilterSql` uses, so the index covers exactly the rows those
569
602
  // queries can return.
570
- db.exec(`CREATE INDEX IF NOT EXISTS idx_obs_project_live ON observations(project, created_at_epoch DESC) WHERE superseded_at IS NULL AND COALESCE(compressed_into, 0) = 0`);
603
+ db.exec(
604
+ `CREATE INDEX IF NOT EXISTS idx_obs_project_live ON observations(project, created_at_epoch DESC) WHERE superseded_at IS NULL AND COALESCE(compressed_into, 0) = 0`,
605
+ );
571
606
 
572
607
  // FTS5 migration: recreate observations_fts when columns are missing (one-time)
573
608
  // Detect old FTS5 table missing lesson_learned or search_aliases and recreate with full column set
574
609
  let obsFtsRecreated = false;
575
610
  try {
576
- const ftsDdl = db.prepare(`SELECT sql FROM sqlite_master WHERE type='table' AND name='observations_fts'`).get();
611
+ const ftsDdl = db
612
+ .prepare(`SELECT sql FROM sqlite_master WHERE type='table' AND name='observations_fts'`)
613
+ .get();
577
614
  if (ftsDdl && (!ftsDdl.sql.includes('lesson_learned') || !ftsDdl.sql.includes('search_aliases'))) {
578
615
  db.exec(`DROP TRIGGER IF EXISTS observations_ai`);
579
616
  db.exec(`DROP TRIGGER IF EXISTS observations_ad`);
@@ -581,7 +618,9 @@ export function initSchema(db) {
581
618
  db.exec(`DROP TABLE IF EXISTS observations_fts`);
582
619
  obsFtsRecreated = true;
583
620
  }
584
- } catch { /* non-critical — ensureFTS will create if missing */ }
621
+ } catch {
622
+ /* non-critical — ensureFTS will create if missing */
623
+ }
585
624
 
586
625
  // v27 migration: drop legacy _au triggers that fire on ANY row UPDATE so
587
626
  // ensureFTS reinstates them with `AFTER UPDATE OF <fts_cols>`. Trigger fires
@@ -590,21 +629,31 @@ export function initSchema(db) {
590
629
  // Conditional per #7647: only drop when the stored DDL lacks the scoped
591
630
  // `UPDATE OF` clause (handles re-run + fresh-DB cases).
592
631
  for (const [trg, tbl] of [
593
- ['observations_au', 'observations'],
632
+ ['observations_au', 'observations'],
594
633
  ['session_summaries_au', 'session_summaries'],
595
- ['user_prompts_au', 'user_prompts'],
634
+ ['user_prompts_au', 'user_prompts'],
596
635
  ]) {
597
636
  try {
598
637
  const row = db.prepare(`SELECT sql FROM sqlite_master WHERE type='trigger' AND name=?`).get(trg);
599
638
  if (row && row.sql && !/\bAFTER\s+UPDATE\s+OF\s+/i.test(row.sql)) {
600
639
  db.exec(`DROP TRIGGER IF EXISTS ${tbl}_au`);
601
640
  }
602
- } catch { /* non-critical — ensureFTS will recreate */ }
641
+ } catch {
642
+ /* non-critical — ensureFTS will recreate */
643
+ }
603
644
  }
604
645
 
605
646
  // FTS5 full-text search tables + triggers (idempotent)
606
647
  ensureFTS(db, 'observations_fts', 'observations', OBS_FTS_COLUMNS);
607
- ensureFTS(db, 'session_summaries_fts', 'session_summaries', ['request', 'investigated', 'learned', 'completed', 'next_steps', 'notes', 'remaining_items']);
648
+ ensureFTS(db, 'session_summaries_fts', 'session_summaries', [
649
+ 'request',
650
+ 'investigated',
651
+ 'learned',
652
+ 'completed',
653
+ 'next_steps',
654
+ 'notes',
655
+ 'remaining_items',
656
+ ]);
608
657
  ensureFTS(db, 'user_prompts_fts', 'user_prompts', ['prompt_text']);
609
658
 
610
659
  // Rebuild FTS5 if we just recreated it above (the new index is empty and must be
@@ -618,7 +667,9 @@ export function initSchema(db) {
618
667
  try {
619
668
  const cnt = db.prepare(`SELECT COUNT(*) as cnt FROM observations`).get();
620
669
  if (cnt.cnt > 0) db.exec(`INSERT INTO observations_fts(observations_fts) VALUES('rebuild')`);
621
- } catch { /* non-critical */ }
670
+ } catch {
671
+ /* non-critical */
672
+ }
622
673
  }
623
674
 
624
675
  // v36 migration: narrow events_fts_au like the v27 fix above. The events FTS
@@ -629,11 +680,15 @@ export function initSchema(db) {
629
680
  // the CREATE TRIGGER IF NOT EXISTS below reinstates the scoped form (handles
630
681
  // re-run + fresh-DB: undefined row on a fresh DB is a no-op).
631
682
  try {
632
- const row = db.prepare(`SELECT sql FROM sqlite_master WHERE type='trigger' AND name='events_fts_au'`).get();
683
+ const row = db
684
+ .prepare(`SELECT sql FROM sqlite_master WHERE type='trigger' AND name='events_fts_au'`)
685
+ .get();
633
686
  if (row && row.sql && !/\bAFTER\s+UPDATE\s+OF\s+/i.test(row.sql)) {
634
687
  db.exec(`DROP TRIGGER IF EXISTS events_fts_au`);
635
688
  }
636
- } catch { /* non-critical — recreated below */ }
689
+ } catch {
690
+ /* non-critical — recreated below */
691
+ }
637
692
 
638
693
  // ─── v2.31 T6: events table + FTS5 (activity namespace) ───────────────────
639
694
  // Independent namespace for bugfix/lesson/bug/discovery/refactor/feature/
@@ -718,7 +773,9 @@ export function initSchema(db) {
718
773
  'ALTER TABLE observation_files ADD COLUMN last_resolved_session_id TEXT DEFAULT NULL',
719
774
  'ALTER TABLE observation_files ADD COLUMN last_cited_session_id TEXT DEFAULT NULL',
720
775
  ]) {
721
- try { db.exec(sql); } catch (e) {
776
+ try {
777
+ db.exec(sql);
778
+ } catch (e) {
722
779
  if (!e.message?.includes('duplicate column name')) throw e;
723
780
  }
724
781
  }
@@ -728,12 +785,16 @@ export function initSchema(db) {
728
785
  try {
729
786
  const obsFilesCount = db.prepare('SELECT COUNT(*) as c FROM observation_files').get().c;
730
787
  if (obsFilesCount === 0) {
731
- const obsWithFiles = db.prepare(
732
- `SELECT id, files_modified FROM observations WHERE files_modified IS NOT NULL AND files_modified != '[]'`
733
- ).all();
788
+ const obsWithFiles = db
789
+ .prepare(
790
+ `SELECT id, files_modified FROM observations WHERE files_modified IS NOT NULL AND files_modified != '[]'`,
791
+ )
792
+ .all();
734
793
  if (obsWithFiles.length > 0) {
735
794
  const migrateFiles = db.transaction(() => {
736
- const insertFile = db.prepare('INSERT OR IGNORE INTO observation_files (obs_id, filename) VALUES (?, ?)');
795
+ const insertFile = db.prepare(
796
+ 'INSERT OR IGNORE INTO observation_files (obs_id, filename) VALUES (?, ?)',
797
+ );
737
798
  for (const row of obsWithFiles) {
738
799
  try {
739
800
  const files = JSON.parse(row.files_modified);
@@ -744,13 +805,17 @@ export function initSchema(db) {
744
805
  }
745
806
  }
746
807
  }
747
- } catch { /* skip malformed JSON */ }
808
+ } catch {
809
+ /* skip malformed JSON */
810
+ }
748
811
  }
749
812
  });
750
813
  migrateFiles();
751
814
  }
752
815
  }
753
- } catch { /* non-critical — migration can retry on next open */ }
816
+ } catch {
817
+ /* non-critical — migration can retry on next open */
818
+ }
754
819
 
755
820
  // observation_files orphan cleanup moved to runDeferredCleanups() (audit P1-5):
756
821
  // it now runs retryably outside the version fast-path. See DEFERRED_CLEANUPS.
@@ -911,30 +976,46 @@ export function auditSessionConsistency(db, { graceMinutes = 5 } = {}) {
911
976
  const cutoff = Date.now() - graceMinutes * 60_000;
912
977
  // UUID-shape gate mirrors the v30 trigger — same length=36 + LIKE pattern.
913
978
  const UUID_LIKE = '________-____-____-____-____________';
914
- const idMixUuidShape = db.prepare(`
979
+ const idMixUuidShape = db
980
+ .prepare(
981
+ `
915
982
  SELECT COUNT(*) AS c FROM sdk_sessions
916
983
  WHERE memory_session_id IS NOT NULL
917
984
  AND memory_session_id = content_session_id
918
985
  AND length(memory_session_id) = 36
919
986
  AND memory_session_id LIKE ?
920
- `).get(UUID_LIKE).c;
921
- const idMixOther = db.prepare(`
987
+ `,
988
+ )
989
+ .get(UUID_LIKE).c;
990
+ const idMixOther = db
991
+ .prepare(
992
+ `
922
993
  SELECT COUNT(*) AS c FROM sdk_sessions
923
994
  WHERE memory_session_id IS NOT NULL
924
995
  AND memory_session_id = content_session_id
925
996
  AND NOT (length(memory_session_id) = 36 AND memory_session_id LIKE ?)
926
- `).get(UUID_LIKE).c;
927
- const missingMemId = db.prepare(`
997
+ `,
998
+ )
999
+ .get(UUID_LIKE).c;
1000
+ const missingMemId = db
1001
+ .prepare(
1002
+ `
928
1003
  SELECT COUNT(*) AS c FROM sdk_sessions
929
1004
  WHERE memory_session_id IS NULL
930
1005
  AND started_at_epoch < ?
931
- `).get(cutoff).c;
932
- const orphanObs = db.prepare(`
1006
+ `,
1007
+ )
1008
+ .get(cutoff).c;
1009
+ const orphanObs = db
1010
+ .prepare(
1011
+ `
933
1012
  SELECT COUNT(*) AS c FROM observations o
934
1013
  WHERE NOT EXISTS (
935
1014
  SELECT 1 FROM sdk_sessions s WHERE s.memory_session_id = o.memory_session_id
936
1015
  )
937
- `).get().c;
1016
+ `,
1017
+ )
1018
+ .get().c;
938
1019
  // Audit P3-14 backstop. `observations.importance` is INTEGER DEFAULT 1 but NULLABLE, and
939
1020
  // the two maintenance faces disagree about what NULL means: decayAndMarkIdle reads
940
1021
  // COALESCE(importance,1)=1 and queues the row for purge, runIdleCleanup reads a bare
@@ -943,17 +1024,16 @@ export function auditSessionConsistency(db, { graceMinutes = 5 } = {}) {
943
1024
  // ones that got in another way — an old version, a hand-edited DB, a restored dump.
944
1025
  // Reported here rather than in its own command because `orphan_obs` above establishes
945
1026
  // that this audit already covers observation-level integrity, not only sessions.
946
- const obsImportanceNull = db.prepare(
947
- 'SELECT COUNT(*) AS c FROM observations WHERE importance IS NULL'
948
- ).get().c;
1027
+ const obsImportanceNull = db
1028
+ .prepare('SELECT COUNT(*) AS c FROM observations WHERE importance IS NULL')
1029
+ .get().c;
949
1030
  return {
950
1031
  id_mix_uuid_shape: idMixUuidShape,
951
1032
  id_mix_other: idMixOther,
952
1033
  missing_mem_id: missingMemId,
953
1034
  orphan_obs: orphanObs,
954
1035
  obs_importance_null: obsImportanceNull,
955
- healthy: idMixUuidShape === 0 && missingMemId === 0 && orphanObs === 0
956
- && obsImportanceNull === 0,
1036
+ healthy: idMixUuidShape === 0 && missingMemId === 0 && orphanObs === 0 && obsImportanceNull === 0,
957
1037
  };
958
1038
  }
959
1039
 
@@ -969,16 +1049,16 @@ const DEFERRED_CLEANUPS = [
969
1049
  // while early warm-start handles ran with foreign_keys OFF, so junction rows
970
1050
  // leaked. Idempotent (NOT IN is empty on a clean DB).
971
1051
  name: 'orphan-observation-files',
972
- run: (db) => db.prepare(
973
- `DELETE FROM observation_files WHERE obs_id NOT IN (SELECT id FROM observations)`
974
- ).run(),
1052
+ run: (db) =>
1053
+ db.prepare(`DELETE FROM observation_files WHERE obs_id NOT IN (SELECT id FROM observations)`).run(),
975
1054
  },
976
1055
  {
977
1056
  // v28 (v2.47) P0-1: orphaned observation_vectors — same FK-OFF root cause.
978
1057
  name: 'orphan-observation-vectors',
979
- run: (db) => db.prepare(
980
- `DELETE FROM observation_vectors WHERE observation_id NOT IN (SELECT id FROM observations)`
981
- ).run(),
1058
+ run: (db) =>
1059
+ db
1060
+ .prepare(`DELETE FROM observation_vectors WHERE observation_id NOT IN (SELECT id FROM observations)`)
1061
+ .run(),
982
1062
  },
983
1063
  {
984
1064
  // Project-name normalization: migrate short names ("mem") to canonical
@@ -986,26 +1066,34 @@ const DEFERRED_CLEANUPS = [
986
1066
  // substring. Idempotent: only acts on remaining short-name records.
987
1067
  name: 'normalize-project-names',
988
1068
  run: (db) => {
989
- const shortProjects = db.prepare(`
1069
+ const shortProjects = db
1070
+ .prepare(
1071
+ `
990
1072
  SELECT DISTINCT project FROM observations
991
1073
  WHERE project NOT LIKE '%--_%' AND project != '' AND project IS NOT NULL
992
1074
  UNION
993
1075
  SELECT DISTINCT project FROM sdk_sessions
994
1076
  WHERE project NOT LIKE '%--_%' AND project != '' AND project IS NOT NULL
995
- `).all();
1077
+ `,
1078
+ )
1079
+ .all();
996
1080
  if (shortProjects.length === 0) return;
997
1081
  const normalize = db.transaction(() => {
998
1082
  for (const { project: shortName } of shortProjects) {
999
- let canonical = db.prepare(
1000
- `SELECT project FROM observations WHERE project LIKE ? GROUP BY project ORDER BY COUNT(*) DESC LIMIT 1`
1001
- ).get(`%--${shortName}`);
1083
+ let canonical = db
1084
+ .prepare(
1085
+ `SELECT project FROM observations WHERE project LIKE ? GROUP BY project ORDER BY COUNT(*) DESC LIMIT 1`,
1086
+ )
1087
+ .get(`%--${shortName}`);
1002
1088
  if (!canonical) {
1003
- const tokens = shortName.split(/[-_.]/).filter(t => t.length >= 5);
1089
+ const tokens = shortName.split(/[-_.]/).filter((t) => t.length >= 5);
1004
1090
  for (const token of tokens) {
1005
- canonical = db.prepare(
1006
- `SELECT project FROM observations WHERE project LIKE ? AND project LIKE '%--_%'
1007
- GROUP BY project ORDER BY COUNT(*) DESC LIMIT 1`
1008
- ).get(`%${token}%`);
1091
+ canonical = db
1092
+ .prepare(
1093
+ `SELECT project FROM observations WHERE project LIKE ? AND project LIKE '%--_%'
1094
+ GROUP BY project ORDER BY COUNT(*) DESC LIMIT 1`,
1095
+ )
1096
+ .get(`%${token}%`);
1009
1097
  if (canonical) break;
1010
1098
  }
1011
1099
  }
@@ -1017,10 +1105,20 @@ const DEFERRED_CLEANUPS = [
1017
1105
  // handoffs (session_handoffs) were stranded on the old name — invisible to
1018
1106
  // every project-scoped query after normalization. All eight carry a
1019
1107
  // `project` column (verified).
1020
- for (const table of ['observations', 'sdk_sessions', 'session_summaries',
1021
- 'session_handoffs', 'citation_log', 'citation_surface_log',
1022
- 'events', 'deferred_work']) {
1023
- db.prepare(`UPDATE ${table} SET project = ? WHERE project = ?`).run(canonical.project, shortName);
1108
+ for (const table of [
1109
+ 'observations',
1110
+ 'sdk_sessions',
1111
+ 'session_summaries',
1112
+ 'session_handoffs',
1113
+ 'citation_log',
1114
+ 'citation_surface_log',
1115
+ 'events',
1116
+ 'deferred_work',
1117
+ ]) {
1118
+ db.prepare(`UPDATE ${table} SET project = ? WHERE project = ?`).run(
1119
+ canonical.project,
1120
+ shortName,
1121
+ );
1024
1122
  }
1025
1123
  }
1026
1124
  }
@@ -1039,7 +1137,12 @@ const DEFERRED_CLEANUPS = [
1039
1137
  export function runDeferredCleanups(db) {
1040
1138
  let done;
1041
1139
  try {
1042
- done = new Set(db.prepare('SELECT name FROM migration_cleanups').all().map(r => r.name));
1140
+ done = new Set(
1141
+ db
1142
+ .prepare('SELECT name FROM migration_cleanups')
1143
+ .all()
1144
+ .map((r) => r.name),
1145
+ );
1043
1146
  } catch {
1044
1147
  return; // table not present yet (pre-migration open) — nothing to do
1045
1148
  }
@@ -1069,7 +1172,7 @@ export function ensureDb() {
1069
1172
  // Remove DB_DIR only if it has no user data (no .db files)
1070
1173
  if (existsSync(DB_DIR)) {
1071
1174
  try {
1072
- const hasDbFiles = readdirSync(DB_DIR).some(f => f.endsWith('.db'));
1175
+ const hasDbFiles = readdirSync(DB_DIR).some((f) => f.endsWith('.db'));
1073
1176
  if (!hasDbFiles) rmSync(DB_DIR, { recursive: true, force: true });
1074
1177
  } catch {}
1075
1178
  }
@@ -1083,12 +1186,17 @@ export function ensureDb() {
1083
1186
  if (!existsSync(DB_PATH) && existsSync(oldPath)) {
1084
1187
  renameSync(oldPath, DB_PATH);
1085
1188
  for (const ext of ['-wal', '-shm']) {
1086
- if (existsSync(oldPath + ext)) try { renameSync(oldPath + ext, DB_PATH + ext); } catch {}
1189
+ if (existsSync(oldPath + ext))
1190
+ try {
1191
+ renameSync(oldPath + ext, DB_PATH + ext);
1192
+ } catch {}
1087
1193
  }
1088
1194
  }
1089
1195
 
1090
1196
  const db = new Database(DB_PATH);
1091
- try { chmodSync(DB_PATH, 0o600); } catch {}
1197
+ try {
1198
+ chmodSync(DB_PATH, 0o600);
1199
+ } catch {}
1092
1200
  db.pragma('journal_mode = WAL');
1093
1201
  // 5000ms matches the MCP server (server.mjs) — 3000ms wasn't enough under realistic
1094
1202
  // concurrency (parallel CLI saves + a long-running FTS rebuild can push individual
@@ -1106,7 +1214,9 @@ export function ensureDb() {
1106
1214
  runDeferredCleanups(ready);
1107
1215
  return ready;
1108
1216
  } catch (e) {
1109
- try { db.close(); } catch {}
1217
+ try {
1218
+ db.close();
1219
+ } catch {}
1110
1220
  throw e;
1111
1221
  }
1112
1222
  }
@@ -1118,8 +1228,9 @@ export function ensureDb() {
1118
1228
  * uncheckpointed transactions — silent data loss.
1119
1229
  */
1120
1230
  export function isDbCorruptionError(err) {
1121
- return /SQLITE_CORRUPT|SQLITE_NOTADB|malformed|not a database|disk image/i
1122
- .test(`${err?.code || ''} ${err?.message || ''}`);
1231
+ return /SQLITE_CORRUPT|SQLITE_NOTADB|malformed|not a database|disk image/i.test(
1232
+ `${err?.code || ''} ${err?.message || ''}`,
1233
+ );
1123
1234
  }
1124
1235
 
1125
1236
  /**
@@ -1141,14 +1252,26 @@ export function ensureDbWithWalRecovery({ warn, info } = {}) {
1141
1252
  } catch (firstErr) {
1142
1253
  if (!isDbCorruptionError(firstErr)) throw firstErr;
1143
1254
  warn?.(`DB corruption detected, attempting WAL recovery: ${firstErr.message}`);
1144
- try { rmSync(DB_PATH + '-wal', { force: true }); } catch { /* best-effort */ }
1145
- try { rmSync(DB_PATH + '-shm', { force: true }); } catch { /* best-effort */ }
1255
+ try {
1256
+ rmSync(DB_PATH + '-wal', { force: true });
1257
+ } catch {
1258
+ /* best-effort */
1259
+ }
1260
+ try {
1261
+ rmSync(DB_PATH + '-shm', { force: true });
1262
+ } catch {
1263
+ /* best-effort */
1264
+ }
1146
1265
  try {
1147
1266
  const db = ensureDb();
1148
1267
  info?.('DB recovered after WAL cleanup');
1149
1268
  return db;
1150
1269
  } catch (retryErr) {
1151
- try { retryErr.walRecoveryAttempted = true; } catch { /* frozen error — fine */ }
1270
+ try {
1271
+ retryErr.walRecoveryAttempted = true;
1272
+ } catch {
1273
+ /* frozen error — fine */
1274
+ }
1152
1275
  throw retryErr;
1153
1276
  }
1154
1277
  }
@@ -1171,9 +1294,15 @@ export function rebuildFTS(db) {
1171
1294
  const errors = [];
1172
1295
  for (const fts of FTS_TABLES) {
1173
1296
  try {
1174
- if (!idRe.test(fts)) { errors.push(`${fts}: invalid identifier`); continue; }
1297
+ if (!idRe.test(fts)) {
1298
+ errors.push(`${fts}: invalid identifier`);
1299
+ continue;
1300
+ }
1175
1301
  const exists = db.prepare(`SELECT 1 FROM sqlite_master WHERE type='table' AND name=?`).get(fts);
1176
- if (!exists) { errors.push(`${fts}: not found`); continue; }
1302
+ if (!exists) {
1303
+ errors.push(`${fts}: not found`);
1304
+ continue;
1305
+ }
1177
1306
  db.exec(`INSERT INTO ${fts}(${fts}) VALUES('rebuild')`);
1178
1307
  rebuilt.push(fts);
1179
1308
  } catch (e) {
@@ -1195,9 +1324,17 @@ export function checkFTSIntegrity(db) {
1195
1324
  let healthy = true;
1196
1325
  for (const fts of FTS_TABLES) {
1197
1326
  try {
1198
- if (!idRe.test(fts)) { details.push(`${fts}: invalid identifier`); healthy = false; continue; }
1327
+ if (!idRe.test(fts)) {
1328
+ details.push(`${fts}: invalid identifier`);
1329
+ healthy = false;
1330
+ continue;
1331
+ }
1199
1332
  const exists = db.prepare(`SELECT 1 FROM sqlite_master WHERE type='table' AND name=?`).get(fts);
1200
- if (!exists) { details.push(`${fts}: missing`); healthy = false; continue; }
1333
+ if (!exists) {
1334
+ details.push(`${fts}: missing`);
1335
+ healthy = false;
1336
+ continue;
1337
+ }
1201
1338
  db.exec(`INSERT INTO ${fts}(${fts}) VALUES('integrity-check')`);
1202
1339
  details.push(`${fts}: ok`);
1203
1340
  } catch (e) {
@@ -1212,13 +1349,13 @@ export function ensureFTS(db, ftsName, tableName, columns) {
1212
1349
  // Validate identifiers to prevent SQL injection (done upfront; both
1213
1350
  // branches below use these identifiers in string-interpolated SQL)
1214
1351
  const idRe = /^[a-z][a-z0-9_]*$/;
1215
- if (!idRe.test(ftsName) || !idRe.test(tableName) || !columns.every(c => idRe.test(c))) {
1352
+ if (!idRe.test(ftsName) || !idRe.test(tableName) || !columns.every((c) => idRe.test(c))) {
1216
1353
  throw new Error(`Invalid identifier in ensureFTS: ${ftsName}, ${tableName}`);
1217
1354
  }
1218
1355
 
1219
1356
  const colList = columns.join(', ');
1220
- const newVals = columns.map(c => `new.${c}`).join(', ');
1221
- const oldVals = columns.map(c => `old.${c}`).join(', ');
1357
+ const newVals = columns.map((c) => `new.${c}`).join(', ');
1358
+ const oldVals = columns.map((c) => `old.${c}`).join(', ');
1222
1359
 
1223
1360
  // Column-aware (re)creation. An existing FTS table is never silently reused when its
1224
1361
  // indexed-column set has drifted from `columns`. Root cause of a silent-write bug class:
@@ -1235,8 +1372,15 @@ export function ensureFTS(db, ftsName, tableName, columns) {
1235
1372
  let recreated = false;
1236
1373
  if (ftsRow) {
1237
1374
  let existingCols = [];
1238
- try { existingCols = db.prepare(`PRAGMA table_info(${ftsName})`).all().map(c => c.name); } catch { /* unreadable → treat as drifted, recreate */ }
1239
- const drifted = existingCols.length !== columns.length || columns.some(c => !existingCols.includes(c));
1375
+ try {
1376
+ existingCols = db
1377
+ .prepare(`PRAGMA table_info(${ftsName})`)
1378
+ .all()
1379
+ .map((c) => c.name);
1380
+ } catch {
1381
+ /* unreadable → treat as drifted, recreate */
1382
+ }
1383
+ const drifted = existingCols.length !== columns.length || columns.some((c) => !existingCols.includes(c));
1240
1384
  if (drifted) {
1241
1385
  db.exec(`DROP TRIGGER IF EXISTS ${tableName}_ai`);
1242
1386
  db.exec(`DROP TRIGGER IF EXISTS ${tableName}_ad`);
@@ -1246,7 +1390,9 @@ export function ensureFTS(db, ftsName, tableName, columns) {
1246
1390
  }
1247
1391
  }
1248
1392
  if (!ftsRow || recreated) {
1249
- db.exec(`CREATE VIRTUAL TABLE ${ftsName} USING fts5(${colList}, content='${tableName}', content_rowid='id')`);
1393
+ db.exec(
1394
+ `CREATE VIRTUAL TABLE ${ftsName} USING fts5(${colList}, content='${tableName}', content_rowid='id')`,
1395
+ );
1250
1396
  }
1251
1397
 
1252
1398
  // Triggers created / recreated independently of FTS table existence so that
@@ -1280,7 +1426,9 @@ export function ensureFTS(db, ftsName, tableName, columns) {
1280
1426
  try {
1281
1427
  const cnt = db.prepare(`SELECT COUNT(*) AS c FROM ${tableName}`).get();
1282
1428
  if (cnt.c > 0) db.exec(`INSERT INTO ${ftsName}(${ftsName}) VALUES('rebuild')`);
1283
- } catch { /* non-critical — index repopulates lazily on next write */ }
1429
+ } catch {
1430
+ /* non-critical — index repopulates lazily on next write */
1431
+ }
1284
1432
  }
1285
1433
  }
1286
1434
 
@@ -1300,8 +1448,17 @@ export function ensureEventsFTS(db) {
1300
1448
  let recreated = false;
1301
1449
  if (ftsRow) {
1302
1450
  let existingCols = [];
1303
- try { existingCols = db.prepare(`PRAGMA table_info(events_fts)`).all().map(c => c.name); } catch { /* unreadable → recreate */ }
1304
- const drifted = existingCols.length !== EVENTS_FTS_COLUMNS.length || EVENTS_FTS_COLUMNS.some(c => !existingCols.includes(c));
1451
+ try {
1452
+ existingCols = db
1453
+ .prepare(`PRAGMA table_info(events_fts)`)
1454
+ .all()
1455
+ .map((c) => c.name);
1456
+ } catch {
1457
+ /* unreadable → recreate */
1458
+ }
1459
+ const drifted =
1460
+ existingCols.length !== EVENTS_FTS_COLUMNS.length ||
1461
+ EVENTS_FTS_COLUMNS.some((c) => !existingCols.includes(c));
1305
1462
  if (drifted) {
1306
1463
  db.exec(`DROP TRIGGER IF EXISTS events_fts_ai`);
1307
1464
  db.exec(`DROP TRIGGER IF EXISTS events_fts_ad`);
@@ -1342,6 +1499,8 @@ export function ensureEventsFTS(db) {
1342
1499
  try {
1343
1500
  const cnt = db.prepare(`SELECT COUNT(*) AS c FROM events`).get();
1344
1501
  if (cnt.c > 0) db.exec(`INSERT INTO events_fts(events_fts) VALUES('rebuild')`);
1345
- } catch { /* non-critical — index repopulates lazily on next write */ }
1502
+ } catch {
1503
+ /* non-critical — index repopulates lazily on next write */
1504
+ }
1346
1505
  }
1347
1506
  }