akm-cli 0.9.0-beta.11 → 0.9.0-beta.26

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 (88) hide show
  1. package/CHANGELOG.md +163 -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 +25 -27
  16. package/dist/cli.js +2 -2
  17. package/dist/commands/agent/contribute-cli.js +16 -3
  18. package/dist/commands/feedback-cli.js +48 -44
  19. package/dist/commands/health/html-report.js +140 -16
  20. package/dist/commands/health.js +277 -1
  21. package/dist/commands/improve/calibration.js +161 -0
  22. package/dist/commands/improve/consolidate.js +595 -105
  23. package/dist/commands/improve/dedup.js +482 -0
  24. package/dist/commands/improve/distill.js +119 -64
  25. package/dist/commands/improve/encoding-salience.js +205 -0
  26. package/dist/commands/improve/extract-cli.js +115 -1
  27. package/dist/commands/improve/extract-prompt.js +32 -1
  28. package/dist/commands/improve/extract-watch.js +140 -0
  29. package/dist/commands/improve/extract.js +210 -30
  30. package/dist/commands/improve/feedback-valence.js +54 -0
  31. package/dist/commands/improve/homeostatic.js +467 -0
  32. package/dist/commands/improve/improve-auto-accept.js +80 -7
  33. package/dist/commands/improve/improve-profiles.js +8 -0
  34. package/dist/commands/improve/improve.js +991 -61
  35. package/dist/commands/improve/memory/memory-contradiction-detect.js +23 -28
  36. package/dist/commands/improve/outcome-loop.js +256 -0
  37. package/dist/commands/improve/proactive-maintenance.js +9 -35
  38. package/dist/commands/improve/procedural.js +409 -0
  39. package/dist/commands/improve/recombine.js +488 -0
  40. package/dist/commands/improve/reflect.js +20 -1
  41. package/dist/commands/improve/related-sessions.js +120 -0
  42. package/dist/commands/improve/salience.js +386 -0
  43. package/dist/commands/improve/triage.js +95 -0
  44. package/dist/commands/lint/agent-linter.js +19 -24
  45. package/dist/commands/lint/base-linter.js +173 -60
  46. package/dist/commands/lint/command-linter.js +19 -24
  47. package/dist/commands/lint/env-key-rules.js +34 -1
  48. package/dist/commands/lint/index.js +30 -13
  49. package/dist/commands/lint/memory-linter.js +1 -1
  50. package/dist/commands/lint/registry.js +5 -2
  51. package/dist/commands/lint/task-linter.js +3 -3
  52. package/dist/commands/lint/workflow-linter.js +26 -1
  53. package/dist/commands/proposal/validators/proposals.js +4 -0
  54. package/dist/commands/read/curate.js +284 -86
  55. package/dist/commands/read/search-cli.js +7 -0
  56. package/dist/commands/read/search.js +1 -0
  57. package/dist/commands/sources/installed-stashes.js +5 -1
  58. package/dist/core/asset/frontmatter.js +166 -167
  59. package/dist/core/asset/markdown.js +8 -0
  60. package/dist/core/config/config-schema.js +211 -3
  61. package/dist/core/config/config.js +2 -2
  62. package/dist/core/logs-db.js +4 -3
  63. package/dist/core/state-db.js +555 -29
  64. package/dist/indexer/db/db.js +250 -27
  65. package/dist/indexer/db/graph-db.js +81 -86
  66. package/dist/indexer/graph/graph-boost.js +51 -41
  67. package/dist/indexer/passes/memory-inference.js +10 -3
  68. package/dist/indexer/passes/staleness-detect.js +2 -5
  69. package/dist/indexer/search/db-search.js +15 -4
  70. package/dist/indexer/search/ranking.js +4 -0
  71. package/dist/integrations/harnesses/claude/session-log.js +10 -0
  72. package/dist/integrations/harnesses/opencode/session-log.js +9 -0
  73. package/dist/integrations/session-logs/index.js +16 -0
  74. package/dist/llm/embedder.js +27 -3
  75. package/dist/llm/embedders/local.js +66 -2
  76. package/dist/llm/graph-extract.js +2 -1
  77. package/dist/llm/memory-infer.js +4 -8
  78. package/dist/llm/metadata-enhance.js +9 -1
  79. package/dist/output/shapes/curate.js +14 -2
  80. package/dist/output/text/helpers.js +9 -0
  81. package/dist/runtime.js +25 -1
  82. package/dist/scripts/migrate-storage.js +1025 -567
  83. package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +435 -269
  84. package/dist/storage/sqlite-pragmas.js +146 -0
  85. package/dist/workflows/db.js +3 -4
  86. package/dist/workflows/validate-summary.js +2 -7
  87. package/docs/data-and-telemetry.md +1 -0
  88. package/package.json +5 -4
@@ -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,15 @@ 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
  );
373
400
  `);
401
+ // #624-P1 migration step 2: copy any renamed-aside legacy graph data into the
402
+ // new-shape tables (just created above), then drop the legacy tables. No-op
403
+ // unless migrateGraphFilesSchema renamed a legacy graph_files this open.
404
+ migrateGraphDataFromLegacy(db);
374
405
  // FTS-dirty queue. Created here (not lazily on first upsert) so the
375
406
  // per-entry write path doesn't issue a CREATE TABLE IF NOT EXISTS on
376
407
  // every call — that DDL would fire thousands of times during a full
@@ -715,6 +746,110 @@ function ensureDerivedFromColumn(db) {
715
746
  db.exec("CREATE INDEX IF NOT EXISTS idx_entries_derived_from ON entries(derived_from)");
716
747
  }, "entries table may not exist on a brand-new DB before CREATE — caller is responsible");
717
748
  }
749
+ /**
750
+ * Returns true when a table exists in the current database.
751
+ */
752
+ function tableExists(db, name) {
753
+ const row = db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name=? LIMIT 1").get(name);
754
+ return row !== undefined && row !== null;
755
+ }
756
+ /**
757
+ * #624-P1 targeted graph-schema migration — STEP 1 of 2 (rename).
758
+ *
759
+ * graph_files was re-keyed from `entry_id INTEGER PRIMARY KEY REFERENCES
760
+ * entries(id)` to a self-contained `(stash_root, file_path, body_hash)` PK.
761
+ * SQLite cannot ALTER a primary key, so an existing DB carrying the OLD shape
762
+ * has its 3 graph tables RENAMED to `*_legacy` here; ensureSchema's CREATE block
763
+ * then builds the new-shape tables, and {@link migrateGraphDataFromLegacy} COPIES
764
+ * the data across before dropping the legacy tables. The graph is preserved —
765
+ * NOT re-extracted (re-extraction is ~19s/file of LLM work).
766
+ *
767
+ * Crucially this is GRAPH-SCOPED: it touches ONLY the graph tables, never the
768
+ * index / embeddings / enrichment cache. So users keep their (expensive)
769
+ * embeddings instead of being forced into a full re-embed by a DB_VERSION bump.
770
+ *
771
+ * Detection: the old schema has an `entry_id` column on graph_files. Fresh DBs
772
+ * (no graph_files yet) and already-migrated DBs (no entry_id column) are no-ops.
773
+ * Idempotent.
774
+ */
775
+ function migrateGraphFilesSchema(db) {
776
+ bestEffort(() => {
777
+ const cols = db.prepare("PRAGMA table_info(graph_files)").all();
778
+ const isLegacyShape = cols.some((c) => c.name === "entry_id");
779
+ if (!isLegacyShape)
780
+ return;
781
+ // A previous interrupted migration may have left *_legacy behind — drop those
782
+ // husks first so the rename below doesn't collide.
783
+ db.exec("DROP TABLE IF EXISTS graph_file_relations_legacy");
784
+ db.exec("DROP TABLE IF EXISTS graph_file_entities_legacy");
785
+ db.exec("DROP TABLE IF EXISTS graph_files_legacy");
786
+ // Rename the 3 entry_id-keyed tables aside. graph_meta is unchanged (stash_root
787
+ // key) so it is left in place. ALTER … RENAME auto-updates child FK refs in
788
+ // SQLite ≥3.25, which is fine — the legacy children are dropped after the copy.
789
+ db.exec("ALTER TABLE graph_files RENAME TO graph_files_legacy");
790
+ if (tableExists(db, "graph_file_entities")) {
791
+ db.exec("ALTER TABLE graph_file_entities RENAME TO graph_file_entities_legacy");
792
+ }
793
+ if (tableExists(db, "graph_file_relations")) {
794
+ db.exec("ALTER TABLE graph_file_relations RENAME TO graph_file_relations_legacy");
795
+ }
796
+ }, "graph_files may not exist on a brand-new DB before CREATE — caller is responsible");
797
+ }
798
+ /**
799
+ * #624-P1 targeted graph-schema migration — STEP 2 of 2 (copy + drop legacy).
800
+ *
801
+ * Runs AFTER the graph CREATE TABLE block, so the new-shape tables exist. Copies
802
+ * every legacy row into the re-keyed tables — the old tables already carry
803
+ * (stash_root, file_path, body_hash) next to entry_id, so the projection is a
804
+ * straight column copy (children JOIN back to graph_files_legacy to resolve the
805
+ * composite key from their entry_id). Then drops the `*_legacy` tables.
806
+ *
807
+ * Best-effort: a copy failure (e.g. a pre-body_hash legacy schema) is tolerated,
808
+ * and the legacy tables are dropped regardless so they never linger. Rows whose
809
+ * body_hash is null/empty can't form the new PK and are skipped (they re-extract).
810
+ */
811
+ function migrateGraphDataFromLegacy(db) {
812
+ if (!tableExists(db, "graph_files_legacy"))
813
+ return;
814
+ let migratedFiles = 0;
815
+ bestEffort(() => {
816
+ db.transaction(() => {
817
+ const res = db
818
+ .prepare(`INSERT OR IGNORE INTO graph_files
819
+ (stash_root, file_path, body_hash, file_order, file_type, confidence, status, reason, extraction_run_id)
820
+ SELECT stash_root, file_path, body_hash, file_order, file_type, confidence, status, reason, extraction_run_id
821
+ FROM graph_files_legacy
822
+ WHERE body_hash IS NOT NULL AND body_hash != ''`)
823
+ .run();
824
+ migratedFiles = Number(res.changes);
825
+ if (tableExists(db, "graph_file_entities_legacy")) {
826
+ db.exec(`INSERT OR IGNORE INTO graph_file_entities
827
+ (stash_root, file_path, body_hash, entity_order, entity_norm, entity)
828
+ SELECT gf.stash_root, gf.file_path, gf.body_hash, e.entity_order, e.entity_norm, e.entity
829
+ FROM graph_file_entities_legacy e
830
+ JOIN graph_files_legacy gf ON gf.entry_id = e.entry_id
831
+ WHERE gf.body_hash IS NOT NULL AND gf.body_hash != ''`);
832
+ }
833
+ if (tableExists(db, "graph_file_relations_legacy")) {
834
+ db.exec(`INSERT OR IGNORE INTO graph_file_relations
835
+ (stash_root, file_path, body_hash, relation_order, from_entity_norm, from_entity, to_entity_norm, to_entity, relation_type, confidence)
836
+ 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
837
+ FROM graph_file_relations_legacy r
838
+ JOIN graph_files_legacy gf ON gf.entry_id = r.entry_id
839
+ WHERE gf.body_hash IS NOT NULL AND gf.body_hash != ''`);
840
+ }
841
+ })();
842
+ }, "graph data migration is best-effort; legacy tables are dropped regardless below");
843
+ // Always drop the legacy tables (children first), migrated or not.
844
+ bestEffort(() => {
845
+ db.exec("DROP TABLE IF EXISTS graph_file_relations_legacy");
846
+ db.exec("DROP TABLE IF EXISTS graph_file_entities_legacy");
847
+ db.exec("DROP TABLE IF EXISTS graph_files_legacy");
848
+ }, "drop legacy graph tables after migration");
849
+ if (migratedFiles > 0) {
850
+ warn(`[akm] graph index re-keyed (#624): migrated ${migratedFiles} extracted file(s) to the new schema — no re-extraction needed. Index + embeddings untouched.`);
851
+ }
852
+ }
718
853
  /**
719
854
  * Phase 5A / Advantage D5: look up the derived-memory child row whose
720
855
  * `derived_from` column matches `parentRef` (e.g. `"memory:claude-prefs"`).
@@ -839,6 +974,38 @@ function deleteRelatedRows(db, ids) {
839
974
  // Clean up usage events before deleting entries
840
975
  bestEffort(() => db.prepare(`DELETE FROM usage_events WHERE entry_id IN (${placeholders})`).run(...chunk), "delete usage_events for entries");
841
976
  }
977
+ // #624-P1: graph_files is NO LONGER keyed on entries.id, so deleting an
978
+ // entries row must NOT wipe the extracted graph (that is the whole point —
979
+ // the graph survives a reindex when body_hash is unchanged). We therefore do
980
+ // NOT delete graph_files here. We DO, however, recompute graph_meta counts
981
+ // for the stash roots touched by the deleted entries so the summary numbers
982
+ // stay consistent with the live child rows (the counts are derived, and the
983
+ // entries delete may have changed which files are considered/indexed).
984
+ //
985
+ // Resolve the affected stash roots from the entries rows BEFORE deletion.
986
+ const affectedStashRoots = new Set();
987
+ for (let i = 0; i < numericIds.length; i += SQLITE_CHUNK_SIZE) {
988
+ const chunk = numericIds.slice(i, i + SQLITE_CHUNK_SIZE);
989
+ const placeholders = chunk.map(() => "?").join(",");
990
+ bestEffort(() => {
991
+ const rows = db
992
+ .prepare(`SELECT DISTINCT stash_dir FROM entries WHERE id IN (${placeholders})`)
993
+ .all(...chunk);
994
+ for (const row of rows) {
995
+ if (row.stash_dir)
996
+ affectedStashRoots.add(row.stash_dir);
997
+ }
998
+ }, "resolve stash roots for graph_meta recompute");
999
+ }
1000
+ for (const stashRoot of affectedStashRoots) {
1001
+ bestEffort(() => db
1002
+ .prepare(`UPDATE graph_meta
1003
+ SET extracted_files = (SELECT COUNT(*) FROM graph_files WHERE stash_root = ?),
1004
+ entity_count = (SELECT COUNT(*) FROM graph_file_entities WHERE stash_root = ?),
1005
+ relation_count = (SELECT COUNT(*) FROM graph_file_relations WHERE stash_root = ?)
1006
+ WHERE stash_root = ?`)
1007
+ .run(stashRoot, stashRoot, stashRoot, stashRoot), "sync graph_meta counts after entries delete");
1008
+ }
842
1009
  }
843
1010
  /**
844
1011
  * Delete entries by their primary key IDs, along with all related rows
@@ -1048,12 +1215,12 @@ function searchBlobVec(db, queryEmbedding, k) {
1048
1215
  }
1049
1216
  }
1050
1217
  // ── FTS5 search ─────────────────────────────────────────────────────────────
1051
- export function searchFts(db, query, limit, entryType) {
1218
+ export function searchFts(db, query, limit, entryType, excludeTypes) {
1052
1219
  const ftsQuery = sanitizeFtsQuery(query);
1053
1220
  if (!ftsQuery)
1054
1221
  return [];
1055
1222
  // Try the exact AND query first
1056
- const exactResults = runFtsQuery(db, ftsQuery, limit, entryType);
1223
+ const exactResults = runFtsQuery(db, ftsQuery, limit, entryType, excludeTypes);
1057
1224
  if (exactResults.length > 0)
1058
1225
  return exactResults;
1059
1226
  // Exact match returned zero results — try prefix fallback.
@@ -1063,7 +1230,7 @@ export function searchFts(db, query, limit, entryType) {
1063
1230
  const prefixQuery = buildPrefixQuery(ftsQuery);
1064
1231
  if (!prefixQuery)
1065
1232
  return [];
1066
- return runFtsQuery(db, prefixQuery, limit, entryType);
1233
+ return runFtsQuery(db, prefixQuery, limit, entryType, excludeTypes);
1067
1234
  }
1068
1235
  /**
1069
1236
  * Build a prefix query from an FTS5 query string by appending `*` to each
@@ -1086,9 +1253,14 @@ function buildPrefixQuery(ftsQuery) {
1086
1253
  return null;
1087
1254
  return prefixTokens.join(" ");
1088
1255
  }
1089
- function runFtsQuery(db, ftsQuery, limit, entryType) {
1256
+ function runFtsQuery(db, ftsQuery, limit, entryType, excludeTypes) {
1090
1257
  let sql;
1091
1258
  let params;
1259
+ // #627 — exclude-type clause. Only applies on the untyped ('any') path; an
1260
+ // explicit include filter (entryType) already narrows to a single type, so
1261
+ // exclusion is redundant there. An empty list skips the clause entirely
1262
+ // (never emit `NOT IN ()`, which is a SQL error / always-false).
1263
+ const excludes = excludeTypes && excludeTypes.length > 0 ? excludeTypes : [];
1092
1264
  // Join on integer entry_id directly (no CAST needed; we store integer)
1093
1265
  // Use bm25() with per-column weights: entry_id(0), name(10), description(5), tags(3), hints(2), content(1)
1094
1266
  if (entryType && entryType !== "any") {
@@ -1105,16 +1277,19 @@ function runFtsQuery(db, ftsQuery, limit, entryType) {
1105
1277
  params = [ftsQuery, entryType, limit];
1106
1278
  }
1107
1279
  else {
1280
+ const excludeClause = excludes.length > 0 ? `AND e.entry_type NOT IN (${excludes.map(() => "?").join(", ")})` : "";
1108
1281
  sql = `
1109
1282
  SELECT e.id, e.file_path AS filePath, e.entry_json, e.search_text AS searchText,
1110
1283
  bm25(entries_fts, 0, 10.0, 5.0, 3.0, 2.0, 1.0) AS bm25Score
1111
1284
  FROM entries_fts f
1112
1285
  JOIN entries e ON e.id = f.entry_id
1113
1286
  WHERE entries_fts MATCH ?
1287
+ ${excludeClause}
1114
1288
  ORDER BY bm25Score, e.id ASC
1115
1289
  LIMIT ?
1116
1290
  `;
1117
- params = [ftsQuery, limit];
1291
+ // Param order: MATCH, then the NOT IN values, then LIMIT.
1292
+ params = [ftsQuery, ...excludes, limit];
1118
1293
  }
1119
1294
  try {
1120
1295
  const rows = db.prepare(sql).all(...params);
@@ -1183,14 +1358,21 @@ function parseEntryRows(rows, context) {
1183
1358
  }
1184
1359
  return entries;
1185
1360
  }
1186
- export function getAllEntries(db, entryType) {
1361
+ export function getAllEntries(db, entryType, excludeTypes) {
1187
1362
  let sql;
1188
1363
  let params;
1364
+ // #627 — exclude-type clause applies only on the untyped ('any') path. Empty
1365
+ // list skips the clause (never `NOT IN ()`).
1366
+ const excludes = excludeTypes && excludeTypes.length > 0 ? excludeTypes : [];
1189
1367
  if (entryType && entryType !== "any") {
1190
1368
  sql =
1191
1369
  "SELECT id, entry_key, dir_path, file_path, stash_dir, entry_json, search_text FROM entries WHERE entry_type = ?";
1192
1370
  params = [entryType];
1193
1371
  }
1372
+ else if (excludes.length > 0) {
1373
+ 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(", ")})`;
1374
+ params = [...excludes];
1375
+ }
1194
1376
  else {
1195
1377
  sql = "SELECT id, entry_key, dir_path, file_path, stash_dir, entry_json, search_text FROM entries";
1196
1378
  params = [];
@@ -1198,6 +1380,47 @@ export function getAllEntries(db, entryType) {
1198
1380
  const rows = db.prepare(sql).all(...params);
1199
1381
  return parseEntryRows(rows, "getAllEntries");
1200
1382
  }
1383
+ /**
1384
+ * #609 — read graph entities (normalized) for a set of entry ids. Used by the
1385
+ * recombine pass to cluster memories by shared graph entity ("graph"
1386
+ * relatedness source). Returns a map of `entry_id -> entity_norm[]`. Entries
1387
+ * with no graph entities (graph extraction has not run, or the file produced
1388
+ * no entities) are simply absent from the map — callers must fail open
1389
+ * (fall back to tag relatedness) when the map is empty.
1390
+ */
1391
+ export function getEntitiesByEntryIds(db, entryIds) {
1392
+ const result = new Map();
1393
+ if (entryIds.length === 0)
1394
+ return result;
1395
+ // #624-P1: graph_file_entities no longer carries entry_id. Re-derive the
1396
+ // entry_id -> entity_norm[] contract by JOINing through entries on
1397
+ // (stash_dir, file_path) -> graph_files. Chunk the IN(?) list because the
1398
+ // recombine pass can pass 10k+ entry ids (well over the SQLite param limit).
1399
+ for (let i = 0; i < entryIds.length; i += SQLITE_CHUNK_SIZE) {
1400
+ const chunk = entryIds.slice(i, i + SQLITE_CHUNK_SIZE);
1401
+ const placeholders = chunk.map(() => "?").join(", ");
1402
+ const rows = db
1403
+ .prepare(`SELECT e.id AS entry_id, gfe.entity_norm AS entity_norm
1404
+ FROM entries e
1405
+ JOIN graph_files gf
1406
+ ON gf.stash_root = e.stash_dir AND gf.file_path = e.file_path
1407
+ JOIN graph_file_entities gfe
1408
+ ON gfe.stash_root = gf.stash_root
1409
+ AND gfe.file_path = gf.file_path
1410
+ AND gfe.body_hash = gf.body_hash
1411
+ WHERE e.id IN (${placeholders})
1412
+ ORDER BY e.id, gfe.entity_order`)
1413
+ .all(...chunk);
1414
+ for (const row of rows) {
1415
+ const list = result.get(row.entry_id);
1416
+ if (list)
1417
+ list.push(row.entity_norm);
1418
+ else
1419
+ result.set(row.entry_id, [row.entity_norm]);
1420
+ }
1421
+ }
1422
+ return result;
1423
+ }
1201
1424
  export function findEntryIdByRef(db, ref) {
1202
1425
  const parsed = parseAssetRef(ref);
1203
1426
  const nameVariants = [parsed.name];