akm-cli 0.9.0-beta.3 → 0.9.0-beta.30

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 (107) hide show
  1. package/CHANGELOG.md +600 -0
  2. package/dist/assets/prompts/consolidate-system.md +23 -0
  3. package/dist/assets/prompts/contradiction-judge.md +33 -0
  4. package/dist/assets/prompts/distill-knowledge-system.md +22 -0
  5. package/dist/assets/prompts/distill-lesson-system.md +36 -0
  6. package/dist/assets/prompts/extract-session.md +5 -1
  7. package/dist/assets/prompts/graph-extract-system.md +1 -0
  8. package/dist/assets/prompts/memory-infer-system.md +1 -0
  9. package/dist/assets/prompts/memory-infer-user.md +5 -0
  10. package/dist/assets/prompts/metadata-enhance-system.md +1 -0
  11. package/dist/assets/prompts/procedural-system.md +44 -0
  12. package/dist/assets/prompts/recombine-system.md +40 -0
  13. package/dist/assets/prompts/staleness-detect-system.md +6 -0
  14. package/dist/assets/prompts/validate-summary-judge.md +1 -0
  15. package/dist/assets/templates/html/health.html +281 -111
  16. package/dist/cli.js +14 -3
  17. package/dist/commands/agent/contribute-cli.js +16 -3
  18. package/dist/commands/feedback-cli.js +15 -6
  19. package/dist/commands/graph/graph.js +75 -71
  20. package/dist/commands/health/checks.js +48 -0
  21. package/dist/commands/health/html-report.js +422 -80
  22. package/dist/commands/health.js +381 -9
  23. package/dist/commands/improve/calibration.js +161 -0
  24. package/dist/commands/improve/consolidate.js +634 -111
  25. package/dist/commands/improve/dedup.js +482 -0
  26. package/dist/commands/improve/distill.js +145 -69
  27. package/dist/commands/improve/encoding-salience.js +205 -0
  28. package/dist/commands/improve/extract-cli.js +115 -1
  29. package/dist/commands/improve/extract-prompt.js +33 -2
  30. package/dist/commands/improve/extract-watch.js +140 -0
  31. package/dist/commands/improve/extract.js +244 -35
  32. package/dist/commands/improve/feedback-valence.js +54 -0
  33. package/dist/commands/improve/homeostatic.js +467 -0
  34. package/dist/commands/improve/improve-auto-accept.js +113 -6
  35. package/dist/commands/improve/improve-profiles.js +12 -0
  36. package/dist/commands/improve/improve.js +1974 -614
  37. package/dist/commands/improve/memory/memory-contradiction-detect.js +23 -28
  38. package/dist/commands/improve/outcome-loop.js +256 -0
  39. package/dist/commands/improve/proactive-maintenance.js +87 -0
  40. package/dist/commands/improve/procedural.js +409 -0
  41. package/dist/commands/improve/recombine.js +528 -0
  42. package/dist/commands/improve/reflect.js +26 -1
  43. package/dist/commands/improve/related-sessions.js +120 -0
  44. package/dist/commands/improve/salience.js +386 -0
  45. package/dist/commands/improve/triage.js +95 -0
  46. package/dist/commands/lint/agent-linter.js +19 -24
  47. package/dist/commands/lint/base-linter.js +173 -60
  48. package/dist/commands/lint/command-linter.js +19 -24
  49. package/dist/commands/lint/env-key-rules.js +34 -1
  50. package/dist/commands/lint/fact-linter.js +39 -0
  51. package/dist/commands/lint/index.js +31 -13
  52. package/dist/commands/lint/memory-linter.js +1 -1
  53. package/dist/commands/lint/registry.js +7 -2
  54. package/dist/commands/lint/task-linter.js +3 -3
  55. package/dist/commands/lint/workflow-linter.js +26 -1
  56. package/dist/commands/proposal/proposal.js +5 -0
  57. package/dist/commands/proposal/validators/proposals.js +71 -54
  58. package/dist/commands/read/curate.js +344 -80
  59. package/dist/commands/read/search-cli.js +7 -0
  60. package/dist/commands/read/search.js +1 -0
  61. package/dist/commands/read/show.js +67 -2
  62. package/dist/commands/sources/installed-stashes.js +5 -1
  63. package/dist/commands/sources/stash-cli.js +10 -2
  64. package/dist/core/asset/asset-registry.js +2 -0
  65. package/dist/core/asset/asset-spec.js +14 -0
  66. package/dist/core/asset/frontmatter.js +166 -167
  67. package/dist/core/asset/markdown.js +8 -0
  68. package/dist/core/config/config-schema.js +259 -2
  69. package/dist/core/config/config.js +2 -2
  70. package/dist/core/logs-db.js +4 -3
  71. package/dist/core/paths.js +3 -0
  72. package/dist/core/state-db.js +649 -30
  73. package/dist/indexer/db/db.js +364 -38
  74. package/dist/indexer/db/graph-db.js +129 -86
  75. package/dist/indexer/ensure-index.js +152 -17
  76. package/dist/indexer/graph/graph-boost.js +51 -41
  77. package/dist/indexer/graph/graph-extraction.js +203 -3
  78. package/dist/indexer/index-writer-lock.js +99 -0
  79. package/dist/indexer/indexer.js +114 -111
  80. package/dist/indexer/passes/memory-inference.js +10 -3
  81. package/dist/indexer/passes/staleness-detect.js +2 -5
  82. package/dist/indexer/search/db-search.js +15 -4
  83. package/dist/indexer/search/ranking-contributors.js +22 -0
  84. package/dist/indexer/search/ranking.js +4 -0
  85. package/dist/indexer/walk/matchers.js +9 -0
  86. package/dist/integrations/agent/prompts.js +1 -0
  87. package/dist/integrations/harnesses/claude/session-log.js +11 -1
  88. package/dist/integrations/harnesses/opencode/session-log.js +9 -0
  89. package/dist/integrations/session-logs/index.js +16 -0
  90. package/dist/llm/client.js +23 -4
  91. package/dist/llm/embedder.js +27 -3
  92. package/dist/llm/embedders/local.js +66 -2
  93. package/dist/llm/graph-extract.js +2 -1
  94. package/dist/llm/memory-infer.js +4 -8
  95. package/dist/llm/metadata-enhance.js +9 -1
  96. package/dist/output/renderers.js +73 -1
  97. package/dist/output/shapes/curate.js +14 -2
  98. package/dist/output/text/helpers.js +9 -0
  99. package/dist/runtime.js +25 -1
  100. package/dist/scripts/migrate-storage.js +1242 -594
  101. package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +473 -270
  102. package/dist/sources/providers/tar-utils.js +16 -8
  103. package/dist/storage/sqlite-pragmas.js +146 -0
  104. package/dist/workflows/db.js +3 -4
  105. package/dist/workflows/validate-summary.js +2 -7
  106. package/docs/data-and-telemetry.md +1 -0
  107. package/package.json +9 -6
@@ -12,13 +12,22 @@ import { warn } from "../../core/warn.js";
12
12
  import { cosineSimilarity } from "../../llm/embedders/types.js";
13
13
  import { sha256Hex } from "../../runtime.js";
14
14
  import { openDatabase as openSqlite } from "../../storage/database.js";
15
+ import { applyStandardPragmas } from "../../storage/sqlite-pragmas.js";
15
16
  import { buildSearchFields } from "../search/search-fields.js";
16
17
  import { ensureUsageEventsSchema } from "../usage/usage-events.js";
17
18
  import { backupDataDir, EMBEDDING_DIM_CHANGE_REASON } from "./db-backup.js";
18
19
  // ── Constants ───────────────────────────────────────────────────────────────
20
+ // NOTE: do NOT bump DB_VERSION for graph-schema changes. A DB_VERSION mismatch
21
+ // triggers handleVersionUpgrade()'s NUCLEAR drop of the ENTIRE index — entries,
22
+ // embeddings, FTS, and the llm_enrichment_cache — forcing every user to re-embed
23
+ // their whole corpus on upgrade. The graph tables are derived and cheap to
24
+ // rebuild, so graph re-keying is migrated in a TARGETED, graph-only path
25
+ // (migrateGraphFilesSchema) that leaves entries + embeddings untouched.
19
26
  export const DB_VERSION = 17;
20
27
  export const EMBEDDING_DIM = 384;
21
- export const GRAPH_SCHEMA_VERSION = 3;
28
+ // #624-P1: graph_files re-keyed to (stash_root, file_path, body_hash). Bumped 3→4
29
+ // as a marker; the actual migration is the targeted drop in migrateGraphFilesSchema.
30
+ export const GRAPH_SCHEMA_VERSION = 4;
22
31
  // ── Database lifecycle ──────────────────────────────────────────────────────
23
32
  export function openDatabase(dbPath, options) {
24
33
  const resolvedPath = dbPath ?? getDbPath();
@@ -27,9 +36,7 @@ export function openDatabase(dbPath, options) {
27
36
  fs.mkdirSync(dir, { recursive: true });
28
37
  }
29
38
  const db = openSqlite(resolvedPath);
30
- db.exec("PRAGMA journal_mode = WAL");
31
- db.exec("PRAGMA busy_timeout = 30000");
32
- db.exec("PRAGMA foreign_keys = ON");
39
+ applyStandardPragmas(db, { dataDir: dir });
33
40
  // Try to load sqlite-vec extension
34
41
  loadVecExtension(db);
35
42
  // Dim resolution: explicit option wins; otherwise consult the on-disk
@@ -67,10 +74,9 @@ function resolveConfiguredEmbeddingDim() {
67
74
  }
68
75
  export function openExistingDatabase(dbPath) {
69
76
  const resolvedPath = dbPath ?? getDbPath();
77
+ const dir = path.dirname(resolvedPath);
70
78
  const db = openSqlite(resolvedPath);
71
- db.exec("PRAGMA journal_mode = WAL");
72
- db.exec("PRAGMA busy_timeout = 30000");
73
- db.exec("PRAGMA foreign_keys = ON");
79
+ applyStandardPragmas(db, { dataDir: dir });
74
80
  // Existing-DB callers must not mutate schema or embedding metadata on open,
75
81
  // but some paths still need write access to usage_events and other tables.
76
82
  loadVecExtension(db);
@@ -299,15 +305,27 @@ function ensureSchema(db, embeddingDim, options) {
299
305
  CREATE INDEX IF NOT EXISTS idx_llm_cache_updated
300
306
  ON llm_enrichment_cache(updated_at);
301
307
  `);
302
- // Graph extraction tables — schema v2 (entry_id PK).
308
+ // Graph extraction tables — schema v4 ((stash_root, file_path, body_hash) PK).
309
+ //
310
+ // graph_files is self-keyed on (stash_root, file_path, body_hash) and is NO
311
+ // LONGER tied to entries.id. This is the #624-P1 win: deleting and
312
+ // re-inserting an entries row during a reindex no longer cascade-wipes the
313
+ // extracted graph — as long as the file's body_hash is unchanged, the graph
314
+ // data survives. body_hash is part of the PK so a content change yields a
315
+ // distinct key; a UNIQUE index on (stash_root, file_path) still enforces
316
+ // exactly one graph_files row per path (delete-then-insert on a hash change).
303
317
  //
304
- // graph_files is keyed on entries.id so child tables cascade-delete cleanly
305
- // when an entry is removed, and so JOINs from graph rows to entries are a
306
- // direct PK lookup. (stash_root, file_path) is retained as UNIQUE so the
307
- // extractor's path-based upsert still works.
318
+ // graph_file_entities and graph_file_relations carry (stash_root, file_path,
319
+ // body_hash) and declare a composite FK -> graph_files ON DELETE CASCADE so
320
+ // child rows are removed when a graph_files row is replaced.
308
321
  //
309
- // graph_file_entities and graph_file_relations no longer duplicate file_path;
310
- // they reference entry_id and inherit stash scoping via graph_files.
322
+ // #624-P1 targeted migration: an existing DB may still hold the OLD graph_files
323
+ // (entry_id PK). SQLite can't ALTER a primary key, so we RENAME the 3 graph
324
+ // tables aside (→ *_legacy) here — ONLY the graph tables, never the index/
325
+ // embeddings — then the CREATE block below builds the new shape, then
326
+ // migrateGraphDataFromLegacy() copies the data across so the graph is PRESERVED
327
+ // (not re-extracted).
328
+ migrateGraphFilesSchema(db);
311
329
  db.exec(`
312
330
  CREATE TABLE IF NOT EXISTS graph_meta (
313
331
  stash_root TEXT PRIMARY KEY,
@@ -331,7 +349,6 @@ function ensureSchema(db, embeddingDim, options) {
331
349
  );
332
350
 
333
351
  CREATE TABLE IF NOT EXISTS graph_files (
334
- entry_id INTEGER PRIMARY KEY REFERENCES entries(id) ON DELETE CASCADE,
335
352
  stash_root TEXT NOT NULL,
336
353
  file_path TEXT NOT NULL,
337
354
  file_order INTEGER NOT NULL,
@@ -341,26 +358,34 @@ function ensureSchema(db, embeddingDim, options) {
341
358
  status TEXT NOT NULL DEFAULT 'extracted',
342
359
  reason TEXT,
343
360
  extraction_run_id TEXT,
344
- UNIQUE(stash_root, file_path)
361
+ PRIMARY KEY (stash_root, file_path, body_hash)
345
362
  );
346
363
 
364
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_graph_files_path
365
+ ON graph_files(stash_root, file_path);
366
+
347
367
  CREATE INDEX IF NOT EXISTS idx_graph_files_stash_order
348
368
  ON graph_files(stash_root, file_order);
349
369
 
350
370
  CREATE TABLE IF NOT EXISTS graph_file_entities (
351
- entry_id INTEGER NOT NULL REFERENCES graph_files(entry_id) ON DELETE CASCADE,
352
- entity_order INTEGER NOT NULL,
353
371
  stash_root TEXT NOT NULL,
372
+ file_path TEXT NOT NULL,
373
+ body_hash TEXT NOT NULL,
374
+ entity_order INTEGER NOT NULL,
354
375
  entity_norm TEXT NOT NULL,
355
376
  entity TEXT NOT NULL,
356
- PRIMARY KEY (entry_id, entity_order)
377
+ PRIMARY KEY (stash_root, file_path, body_hash, entity_order),
378
+ FOREIGN KEY (stash_root, file_path, body_hash)
379
+ REFERENCES graph_files(stash_root, file_path, body_hash) ON DELETE CASCADE
357
380
  );
358
381
 
359
382
  CREATE INDEX IF NOT EXISTS idx_graph_file_entities_entity_norm
360
383
  ON graph_file_entities(stash_root, entity_norm);
361
384
 
362
385
  CREATE TABLE IF NOT EXISTS graph_file_relations (
363
- entry_id INTEGER NOT NULL REFERENCES graph_files(entry_id) ON DELETE CASCADE,
386
+ stash_root TEXT NOT NULL,
387
+ file_path TEXT NOT NULL,
388
+ body_hash TEXT NOT NULL,
364
389
  relation_order INTEGER NOT NULL,
365
390
  from_entity_norm TEXT NOT NULL,
366
391
  from_entity TEXT NOT NULL,
@@ -368,9 +393,31 @@ function ensureSchema(db, embeddingDim, options) {
368
393
  to_entity TEXT NOT NULL,
369
394
  relation_type TEXT,
370
395
  confidence REAL,
371
- PRIMARY KEY (entry_id, relation_order)
396
+ PRIMARY KEY (stash_root, file_path, body_hash, relation_order),
397
+ FOREIGN KEY (stash_root, file_path, body_hash)
398
+ REFERENCES graph_files(stash_root, file_path, body_hash) ON DELETE CASCADE
372
399
  );
400
+
401
+ -- #624-P3: lazy graph-extraction queue. Standalone table (NO FK to
402
+ -- graph_files — a queued file by definition has no graph row yet).
403
+ -- Idempotent on (stash_root, file_path); drained highest-priority-first.
404
+ -- CREATE TABLE IF NOT EXISTS is the forward migration (no DB_VERSION bump).
405
+ CREATE TABLE IF NOT EXISTS graph_extraction_queue (
406
+ stash_root TEXT NOT NULL,
407
+ file_path TEXT NOT NULL,
408
+ body_hash TEXT NOT NULL,
409
+ queued_at TEXT NOT NULL DEFAULT (datetime('now')),
410
+ priority INTEGER NOT NULL DEFAULT 0,
411
+ PRIMARY KEY (stash_root, file_path)
412
+ );
413
+
414
+ CREATE INDEX IF NOT EXISTS idx_graph_extraction_queue_drain
415
+ ON graph_extraction_queue(stash_root, priority DESC, queued_at);
373
416
  `);
417
+ // #624-P1 migration step 2: copy any renamed-aside legacy graph data into the
418
+ // new-shape tables (just created above), then drop the legacy tables. No-op
419
+ // unless migrateGraphFilesSchema renamed a legacy graph_files this open.
420
+ migrateGraphDataFromLegacy(db);
374
421
  // FTS-dirty queue. Created here (not lazily on first upsert) so the
375
422
  // per-entry write path doesn't issue a CREATE TABLE IF NOT EXISTS on
376
423
  // every call — that DDL would fire thousands of times during a full
@@ -485,6 +532,7 @@ function handleVersionUpgrade(db) {
485
532
  db.exec("DROP TABLE IF EXISTS index_dir_state");
486
533
  db.exec("DROP TABLE IF EXISTS llm_enrichment_cache");
487
534
  db.exec("DROP INDEX IF EXISTS idx_llm_cache_updated");
535
+ db.exec("DROP TABLE IF EXISTS graph_extraction_queue");
488
536
  db.exec("DROP TABLE IF EXISTS graph_file_relations");
489
537
  db.exec("DROP TABLE IF EXISTS graph_file_entities");
490
538
  db.exec("DROP TABLE IF EXISTS graph_files");
@@ -715,6 +763,110 @@ function ensureDerivedFromColumn(db) {
715
763
  db.exec("CREATE INDEX IF NOT EXISTS idx_entries_derived_from ON entries(derived_from)");
716
764
  }, "entries table may not exist on a brand-new DB before CREATE — caller is responsible");
717
765
  }
766
+ /**
767
+ * Returns true when a table exists in the current database.
768
+ */
769
+ function tableExists(db, name) {
770
+ const row = db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name=? LIMIT 1").get(name);
771
+ return row !== undefined && row !== null;
772
+ }
773
+ /**
774
+ * #624-P1 targeted graph-schema migration — STEP 1 of 2 (rename).
775
+ *
776
+ * graph_files was re-keyed from `entry_id INTEGER PRIMARY KEY REFERENCES
777
+ * entries(id)` to a self-contained `(stash_root, file_path, body_hash)` PK.
778
+ * SQLite cannot ALTER a primary key, so an existing DB carrying the OLD shape
779
+ * has its 3 graph tables RENAMED to `*_legacy` here; ensureSchema's CREATE block
780
+ * then builds the new-shape tables, and {@link migrateGraphDataFromLegacy} COPIES
781
+ * the data across before dropping the legacy tables. The graph is preserved —
782
+ * NOT re-extracted (re-extraction is ~19s/file of LLM work).
783
+ *
784
+ * Crucially this is GRAPH-SCOPED: it touches ONLY the graph tables, never the
785
+ * index / embeddings / enrichment cache. So users keep their (expensive)
786
+ * embeddings instead of being forced into a full re-embed by a DB_VERSION bump.
787
+ *
788
+ * Detection: the old schema has an `entry_id` column on graph_files. Fresh DBs
789
+ * (no graph_files yet) and already-migrated DBs (no entry_id column) are no-ops.
790
+ * Idempotent.
791
+ */
792
+ function migrateGraphFilesSchema(db) {
793
+ bestEffort(() => {
794
+ const cols = db.prepare("PRAGMA table_info(graph_files)").all();
795
+ const isLegacyShape = cols.some((c) => c.name === "entry_id");
796
+ if (!isLegacyShape)
797
+ return;
798
+ // A previous interrupted migration may have left *_legacy behind — drop those
799
+ // husks first so the rename below doesn't collide.
800
+ db.exec("DROP TABLE IF EXISTS graph_file_relations_legacy");
801
+ db.exec("DROP TABLE IF EXISTS graph_file_entities_legacy");
802
+ db.exec("DROP TABLE IF EXISTS graph_files_legacy");
803
+ // Rename the 3 entry_id-keyed tables aside. graph_meta is unchanged (stash_root
804
+ // key) so it is left in place. ALTER … RENAME auto-updates child FK refs in
805
+ // SQLite ≥3.25, which is fine — the legacy children are dropped after the copy.
806
+ db.exec("ALTER TABLE graph_files RENAME TO graph_files_legacy");
807
+ if (tableExists(db, "graph_file_entities")) {
808
+ db.exec("ALTER TABLE graph_file_entities RENAME TO graph_file_entities_legacy");
809
+ }
810
+ if (tableExists(db, "graph_file_relations")) {
811
+ db.exec("ALTER TABLE graph_file_relations RENAME TO graph_file_relations_legacy");
812
+ }
813
+ }, "graph_files may not exist on a brand-new DB before CREATE — caller is responsible");
814
+ }
815
+ /**
816
+ * #624-P1 targeted graph-schema migration — STEP 2 of 2 (copy + drop legacy).
817
+ *
818
+ * Runs AFTER the graph CREATE TABLE block, so the new-shape tables exist. Copies
819
+ * every legacy row into the re-keyed tables — the old tables already carry
820
+ * (stash_root, file_path, body_hash) next to entry_id, so the projection is a
821
+ * straight column copy (children JOIN back to graph_files_legacy to resolve the
822
+ * composite key from their entry_id). Then drops the `*_legacy` tables.
823
+ *
824
+ * Best-effort: a copy failure (e.g. a pre-body_hash legacy schema) is tolerated,
825
+ * and the legacy tables are dropped regardless so they never linger. Rows whose
826
+ * body_hash is null/empty can't form the new PK and are skipped (they re-extract).
827
+ */
828
+ function migrateGraphDataFromLegacy(db) {
829
+ if (!tableExists(db, "graph_files_legacy"))
830
+ return;
831
+ let migratedFiles = 0;
832
+ bestEffort(() => {
833
+ db.transaction(() => {
834
+ const res = db
835
+ .prepare(`INSERT OR IGNORE INTO graph_files
836
+ (stash_root, file_path, body_hash, file_order, file_type, confidence, status, reason, extraction_run_id)
837
+ SELECT stash_root, file_path, body_hash, file_order, file_type, confidence, status, reason, extraction_run_id
838
+ FROM graph_files_legacy
839
+ WHERE body_hash IS NOT NULL AND body_hash != ''`)
840
+ .run();
841
+ migratedFiles = Number(res.changes);
842
+ if (tableExists(db, "graph_file_entities_legacy")) {
843
+ db.exec(`INSERT OR IGNORE INTO graph_file_entities
844
+ (stash_root, file_path, body_hash, entity_order, entity_norm, entity)
845
+ SELECT gf.stash_root, gf.file_path, gf.body_hash, e.entity_order, e.entity_norm, e.entity
846
+ FROM graph_file_entities_legacy e
847
+ JOIN graph_files_legacy gf ON gf.entry_id = e.entry_id
848
+ WHERE gf.body_hash IS NOT NULL AND gf.body_hash != ''`);
849
+ }
850
+ if (tableExists(db, "graph_file_relations_legacy")) {
851
+ db.exec(`INSERT OR IGNORE INTO graph_file_relations
852
+ (stash_root, file_path, body_hash, relation_order, from_entity_norm, from_entity, to_entity_norm, to_entity, relation_type, confidence)
853
+ SELECT gf.stash_root, gf.file_path, gf.body_hash, r.relation_order, r.from_entity_norm, r.from_entity, r.to_entity_norm, r.to_entity, r.relation_type, r.confidence
854
+ FROM graph_file_relations_legacy r
855
+ JOIN graph_files_legacy gf ON gf.entry_id = r.entry_id
856
+ WHERE gf.body_hash IS NOT NULL AND gf.body_hash != ''`);
857
+ }
858
+ })();
859
+ }, "graph data migration is best-effort; legacy tables are dropped regardless below");
860
+ // Always drop the legacy tables (children first), migrated or not.
861
+ bestEffort(() => {
862
+ db.exec("DROP TABLE IF EXISTS graph_file_relations_legacy");
863
+ db.exec("DROP TABLE IF EXISTS graph_file_entities_legacy");
864
+ db.exec("DROP TABLE IF EXISTS graph_files_legacy");
865
+ }, "drop legacy graph tables after migration");
866
+ if (migratedFiles > 0) {
867
+ warn(`[akm] graph index re-keyed (#624): migrated ${migratedFiles} extracted file(s) to the new schema — no re-extraction needed. Index + embeddings untouched.`);
868
+ }
869
+ }
718
870
  /**
719
871
  * Phase 5A / Advantage D5: look up the derived-memory child row whose
720
872
  * `derived_from` column matches `parentRef` (e.g. `"memory:claude-prefs"`).
@@ -839,6 +991,38 @@ function deleteRelatedRows(db, ids) {
839
991
  // Clean up usage events before deleting entries
840
992
  bestEffort(() => db.prepare(`DELETE FROM usage_events WHERE entry_id IN (${placeholders})`).run(...chunk), "delete usage_events for entries");
841
993
  }
994
+ // #624-P1: graph_files is NO LONGER keyed on entries.id, so deleting an
995
+ // entries row must NOT wipe the extracted graph (that is the whole point —
996
+ // the graph survives a reindex when body_hash is unchanged). We therefore do
997
+ // NOT delete graph_files here. We DO, however, recompute graph_meta counts
998
+ // for the stash roots touched by the deleted entries so the summary numbers
999
+ // stay consistent with the live child rows (the counts are derived, and the
1000
+ // entries delete may have changed which files are considered/indexed).
1001
+ //
1002
+ // Resolve the affected stash roots from the entries rows BEFORE deletion.
1003
+ const affectedStashRoots = new Set();
1004
+ for (let i = 0; i < numericIds.length; i += SQLITE_CHUNK_SIZE) {
1005
+ const chunk = numericIds.slice(i, i + SQLITE_CHUNK_SIZE);
1006
+ const placeholders = chunk.map(() => "?").join(",");
1007
+ bestEffort(() => {
1008
+ const rows = db
1009
+ .prepare(`SELECT DISTINCT stash_dir FROM entries WHERE id IN (${placeholders})`)
1010
+ .all(...chunk);
1011
+ for (const row of rows) {
1012
+ if (row.stash_dir)
1013
+ affectedStashRoots.add(row.stash_dir);
1014
+ }
1015
+ }, "resolve stash roots for graph_meta recompute");
1016
+ }
1017
+ for (const stashRoot of affectedStashRoots) {
1018
+ bestEffort(() => db
1019
+ .prepare(`UPDATE graph_meta
1020
+ SET extracted_files = (SELECT COUNT(*) FROM graph_files WHERE stash_root = ?),
1021
+ entity_count = (SELECT COUNT(*) FROM graph_file_entities WHERE stash_root = ?),
1022
+ relation_count = (SELECT COUNT(*) FROM graph_file_relations WHERE stash_root = ?)
1023
+ WHERE stash_root = ?`)
1024
+ .run(stashRoot, stashRoot, stashRoot, stashRoot), "sync graph_meta counts after entries delete");
1025
+ }
842
1026
  }
843
1027
  /**
844
1028
  * Delete entries by their primary key IDs, along with all related rows
@@ -1048,12 +1232,12 @@ function searchBlobVec(db, queryEmbedding, k) {
1048
1232
  }
1049
1233
  }
1050
1234
  // ── FTS5 search ─────────────────────────────────────────────────────────────
1051
- export function searchFts(db, query, limit, entryType) {
1235
+ export function searchFts(db, query, limit, entryType, excludeTypes) {
1052
1236
  const ftsQuery = sanitizeFtsQuery(query);
1053
1237
  if (!ftsQuery)
1054
1238
  return [];
1055
1239
  // Try the exact AND query first
1056
- const exactResults = runFtsQuery(db, ftsQuery, limit, entryType);
1240
+ const exactResults = runFtsQuery(db, ftsQuery, limit, entryType, excludeTypes);
1057
1241
  if (exactResults.length > 0)
1058
1242
  return exactResults;
1059
1243
  // Exact match returned zero results — try prefix fallback.
@@ -1063,7 +1247,7 @@ export function searchFts(db, query, limit, entryType) {
1063
1247
  const prefixQuery = buildPrefixQuery(ftsQuery);
1064
1248
  if (!prefixQuery)
1065
1249
  return [];
1066
- return runFtsQuery(db, prefixQuery, limit, entryType);
1250
+ return runFtsQuery(db, prefixQuery, limit, entryType, excludeTypes);
1067
1251
  }
1068
1252
  /**
1069
1253
  * Build a prefix query from an FTS5 query string by appending `*` to each
@@ -1086,9 +1270,14 @@ function buildPrefixQuery(ftsQuery) {
1086
1270
  return null;
1087
1271
  return prefixTokens.join(" ");
1088
1272
  }
1089
- function runFtsQuery(db, ftsQuery, limit, entryType) {
1273
+ function runFtsQuery(db, ftsQuery, limit, entryType, excludeTypes) {
1090
1274
  let sql;
1091
1275
  let params;
1276
+ // #627 — exclude-type clause. Only applies on the untyped ('any') path; an
1277
+ // explicit include filter (entryType) already narrows to a single type, so
1278
+ // exclusion is redundant there. An empty list skips the clause entirely
1279
+ // (never emit `NOT IN ()`, which is a SQL error / always-false).
1280
+ const excludes = excludeTypes && excludeTypes.length > 0 ? excludeTypes : [];
1092
1281
  // Join on integer entry_id directly (no CAST needed; we store integer)
1093
1282
  // Use bm25() with per-column weights: entry_id(0), name(10), description(5), tags(3), hints(2), content(1)
1094
1283
  if (entryType && entryType !== "any") {
@@ -1105,16 +1294,19 @@ function runFtsQuery(db, ftsQuery, limit, entryType) {
1105
1294
  params = [ftsQuery, entryType, limit];
1106
1295
  }
1107
1296
  else {
1297
+ const excludeClause = excludes.length > 0 ? `AND e.entry_type NOT IN (${excludes.map(() => "?").join(", ")})` : "";
1108
1298
  sql = `
1109
1299
  SELECT e.id, e.file_path AS filePath, e.entry_json, e.search_text AS searchText,
1110
1300
  bm25(entries_fts, 0, 10.0, 5.0, 3.0, 2.0, 1.0) AS bm25Score
1111
1301
  FROM entries_fts f
1112
1302
  JOIN entries e ON e.id = f.entry_id
1113
1303
  WHERE entries_fts MATCH ?
1304
+ ${excludeClause}
1114
1305
  ORDER BY bm25Score, e.id ASC
1115
1306
  LIMIT ?
1116
1307
  `;
1117
- params = [ftsQuery, limit];
1308
+ // Param order: MATCH, then the NOT IN values, then LIMIT.
1309
+ params = [ftsQuery, ...excludes, limit];
1118
1310
  }
1119
1311
  try {
1120
1312
  const rows = db.prepare(sql).all(...params);
@@ -1183,14 +1375,21 @@ function parseEntryRows(rows, context) {
1183
1375
  }
1184
1376
  return entries;
1185
1377
  }
1186
- export function getAllEntries(db, entryType) {
1378
+ export function getAllEntries(db, entryType, excludeTypes) {
1187
1379
  let sql;
1188
1380
  let params;
1381
+ // #627 — exclude-type clause applies only on the untyped ('any') path. Empty
1382
+ // list skips the clause (never `NOT IN ()`).
1383
+ const excludes = excludeTypes && excludeTypes.length > 0 ? excludeTypes : [];
1189
1384
  if (entryType && entryType !== "any") {
1190
1385
  sql =
1191
1386
  "SELECT id, entry_key, dir_path, file_path, stash_dir, entry_json, search_text FROM entries WHERE entry_type = ?";
1192
1387
  params = [entryType];
1193
1388
  }
1389
+ else if (excludes.length > 0) {
1390
+ sql = `SELECT id, entry_key, dir_path, file_path, stash_dir, entry_json, search_text FROM entries WHERE entry_type NOT IN (${excludes.map(() => "?").join(", ")})`;
1391
+ params = [...excludes];
1392
+ }
1194
1393
  else {
1195
1394
  sql = "SELECT id, entry_key, dir_path, file_path, stash_dir, entry_json, search_text FROM entries";
1196
1395
  params = [];
@@ -1198,6 +1397,47 @@ export function getAllEntries(db, entryType) {
1198
1397
  const rows = db.prepare(sql).all(...params);
1199
1398
  return parseEntryRows(rows, "getAllEntries");
1200
1399
  }
1400
+ /**
1401
+ * #609 — read graph entities (normalized) for a set of entry ids. Used by the
1402
+ * recombine pass to cluster memories by shared graph entity ("graph"
1403
+ * relatedness source). Returns a map of `entry_id -> entity_norm[]`. Entries
1404
+ * with no graph entities (graph extraction has not run, or the file produced
1405
+ * no entities) are simply absent from the map — callers must fail open
1406
+ * (fall back to tag relatedness) when the map is empty.
1407
+ */
1408
+ export function getEntitiesByEntryIds(db, entryIds) {
1409
+ const result = new Map();
1410
+ if (entryIds.length === 0)
1411
+ return result;
1412
+ // #624-P1: graph_file_entities no longer carries entry_id. Re-derive the
1413
+ // entry_id -> entity_norm[] contract by JOINing through entries on
1414
+ // (stash_dir, file_path) -> graph_files. Chunk the IN(?) list because the
1415
+ // recombine pass can pass 10k+ entry ids (well over the SQLite param limit).
1416
+ for (let i = 0; i < entryIds.length; i += SQLITE_CHUNK_SIZE) {
1417
+ const chunk = entryIds.slice(i, i + SQLITE_CHUNK_SIZE);
1418
+ const placeholders = chunk.map(() => "?").join(", ");
1419
+ const rows = db
1420
+ .prepare(`SELECT e.id AS entry_id, gfe.entity_norm AS entity_norm
1421
+ FROM entries e
1422
+ JOIN graph_files gf
1423
+ ON gf.stash_root = e.stash_dir AND gf.file_path = e.file_path
1424
+ JOIN graph_file_entities gfe
1425
+ ON gfe.stash_root = gf.stash_root
1426
+ AND gfe.file_path = gf.file_path
1427
+ AND gfe.body_hash = gf.body_hash
1428
+ WHERE e.id IN (${placeholders})
1429
+ ORDER BY e.id, gfe.entity_order`)
1430
+ .all(...chunk);
1431
+ for (const row of rows) {
1432
+ const list = result.get(row.entry_id);
1433
+ if (list)
1434
+ list.push(row.entity_norm);
1435
+ else
1436
+ result.set(row.entry_id, [row.entity_norm]);
1437
+ }
1438
+ }
1439
+ return result;
1440
+ }
1201
1441
  export function findEntryIdByRef(db, ref) {
1202
1442
  const parsed = parseAssetRef(ref);
1203
1443
  const nameVariants = [parsed.name];
@@ -1262,6 +1502,19 @@ export function getEntryIdByFilePath(db, filePath) {
1262
1502
  const row = db.prepare("SELECT id FROM entries WHERE file_path = ? LIMIT 1").get(filePath);
1263
1503
  return row?.id;
1264
1504
  }
1505
+ /**
1506
+ * Set of every non-empty `entries.file_path` currently indexed (across all
1507
+ * stashes/sources). Used by staleness detection to spot files that exist on
1508
+ * disk but were never indexed — a clock-independent signal for newly-added
1509
+ * assets that an mtime-vs-builtAt comparison can miss when the two clocks
1510
+ * (filesystem vs wall-clock) are skewed within the same millisecond.
1511
+ */
1512
+ export function getIndexedFilePaths(db) {
1513
+ const rows = db
1514
+ .prepare("SELECT DISTINCT file_path FROM entries WHERE file_path IS NOT NULL AND file_path <> ''")
1515
+ .all();
1516
+ return new Set(rows.map((r) => r.file_path));
1517
+ }
1265
1518
  /**
1266
1519
  * Resolve a single `entries.file_path` by primary key, or `undefined` if no
1267
1520
  * row matches.
@@ -1477,25 +1730,98 @@ export function computeBodyHash(body) {
1477
1730
  return sha256Hex(body);
1478
1731
  }
1479
1732
  /**
1480
- * Count search and show events for the given entry refs.
1481
- * Returns a Map<ref, count> with only refs that have at least one event.
1482
- * Used by the improve loop to find high-retrieval assets without feedback.
1733
+ * Reduce a ref to its bare `type:name` form, dropping any `origin//` prefix.
1734
+ *
1735
+ * usage_events store entry_ref inconsistently: search/show writers persist
1736
+ * whatever ref the result carried, which is sometimes stash-prefixed
1737
+ * (`origin//type:name`) and sometimes bare (`type:name`). Retrieval counting
1738
+ * keys on the bare form so both spellings of the same asset collapse together.
1739
+ *
1740
+ * Returns the bare form, or the original string when it cannot be parsed (best
1741
+ * effort — never throws so a malformed stored ref can't break counting).
1742
+ */
1743
+ function bareRef(ref) {
1744
+ try {
1745
+ const parsed = parseAssetRef(ref);
1746
+ return `${parsed.type}:${parsed.name}`;
1747
+ }
1748
+ catch {
1749
+ return ref;
1750
+ }
1751
+ }
1752
+ /**
1753
+ * Count retrieval events for the given entry refs.
1754
+ *
1755
+ * Counts `search`, `show`, and `curate` usage events. Returns a
1756
+ * Map<inputRef, count> keyed by the *input* ref strings (only those with at
1757
+ * least one matching event appear). Used by the improve loop to find
1758
+ * high-retrieval assets without feedback.
1759
+ *
1760
+ * Matching is normalization-aware: each stored `entry_ref` is reduced to its
1761
+ * bare `type:name` form before comparison, so a stash-prefixed stored ref
1762
+ * (`origin//type:name`) still matches a bare input ref (`type:name`) and vice
1763
+ * versa. Previously the raw `entry_ref IN (...)` comparison silently dropped
1764
+ * roughly half the signal whenever the two spellings disagreed.
1765
+ *
1766
+ * `curate` events are included: their per-item rows are written with
1767
+ * entry_ref populated (see logCurateEvent), so curation is a real retrieval
1768
+ * signal here. Legacy summary-only curate rows with a NULL entry_ref simply
1769
+ * contribute nothing.
1483
1770
  */
1484
1771
  export function getRetrievalCounts(db, refs) {
1485
1772
  if (refs.length === 0)
1486
1773
  return new Map();
1487
- const result = new Map();
1774
+ // Map each distinct bare form back to the input ref(s) that produced it so we
1775
+ // can re-key DB results (grouped by bare form) onto the caller's ref strings.
1776
+ const bareToInputs = new Map();
1777
+ for (const ref of refs) {
1778
+ const bare = bareRef(ref);
1779
+ const existing = bareToInputs.get(bare);
1780
+ if (existing)
1781
+ existing.push(ref);
1782
+ else
1783
+ bareToInputs.set(bare, [ref]);
1784
+ }
1785
+ const bareForms = [...bareToInputs.keys()];
1786
+ // Accumulate counts per bare form across chunks before re-keying.
1787
+ const countsByBare = new Map();
1488
1788
  // Chunk to stay within SQLITE_MAX_VARIABLE_NUMBER (same pattern as getUtilityScoresByIds).
1489
- for (let i = 0; i < refs.length; i += SQLITE_CHUNK_SIZE) {
1490
- const chunk = refs.slice(i, i + SQLITE_CHUNK_SIZE);
1789
+ for (let i = 0; i < bareForms.length; i += SQLITE_CHUNK_SIZE) {
1790
+ const chunk = bareForms.slice(i, i + SQLITE_CHUNK_SIZE);
1491
1791
  const placeholders = chunk.map(() => "?").join(", ");
1792
+ // Normalize the stored entry_ref to its bare form inside SQL by stripping
1793
+ // everything up to and including the last `//` separator. SQLite has no
1794
+ // rfind, but stored origins never themselves contain `//`, so a stash ref
1795
+ // has exactly one `//` and `substr(... instr ...)` is exact; bare refs have
1796
+ // no `//` and pass through unchanged.
1492
1797
  const rows = db
1493
- .prepare(`SELECT entry_ref, COUNT(*) AS cnt FROM usage_events
1494
- WHERE event_type IN ('search','show') AND entry_ref IN (${placeholders})
1495
- GROUP BY entry_ref`)
1798
+ .prepare(`SELECT
1799
+ CASE
1800
+ WHEN instr(entry_ref, '//') > 0
1801
+ THEN substr(entry_ref, instr(entry_ref, '//') + 2)
1802
+ ELSE entry_ref
1803
+ END AS bare_ref,
1804
+ COUNT(*) AS cnt
1805
+ FROM usage_events
1806
+ WHERE event_type IN ('search','show','curate')
1807
+ AND entry_ref IS NOT NULL
1808
+ AND CASE
1809
+ WHEN instr(entry_ref, '//') > 0
1810
+ THEN substr(entry_ref, instr(entry_ref, '//') + 2)
1811
+ ELSE entry_ref
1812
+ END IN (${placeholders})
1813
+ GROUP BY bare_ref`)
1496
1814
  .all(...chunk);
1497
- for (const r of rows)
1498
- result.set(r.entry_ref, r.cnt);
1815
+ for (const r of rows) {
1816
+ countsByBare.set(r.bare_ref, (countsByBare.get(r.bare_ref) ?? 0) + r.cnt);
1817
+ }
1818
+ }
1819
+ // Re-key bare-form counts onto every input ref that maps to that bare form.
1820
+ const result = new Map();
1821
+ for (const [bare, count] of countsByBare) {
1822
+ for (const input of bareToInputs.get(bare) ?? []) {
1823
+ result.set(input, count);
1824
+ }
1499
1825
  }
1500
1826
  return result;
1501
1827
  }