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
@@ -4,7 +4,6 @@
4
4
  import fs from "node:fs";
5
5
  import { rethrowIfTestIsolationError } from "../../core/errors.js";
6
6
  import { getDbPath } from "../../core/paths.js";
7
- import { warn } from "../../core/warn.js";
8
7
  import { closeDatabase, openExistingDatabase } from "./db.js";
9
8
  function withReadableGraphDb(db, fn) {
10
9
  if (db)
@@ -26,38 +25,16 @@ function uniqueSorted(values) {
26
25
  function normalizeEntity(value) {
27
26
  return value.trim().toLowerCase();
28
27
  }
29
- /**
30
- * Resolve a file_path within a stash to its entries.id. Returns null when the
31
- * path has no indexed entry (orphan graph row).
32
- */
33
- export function resolveEntryIdForPath(db, stashRoot, filePath) {
34
- try {
35
- const row = db
36
- .prepare("SELECT id FROM entries WHERE stash_dir = ? AND file_path = ? LIMIT 1")
37
- .get(stashRoot, filePath);
38
- if (row)
39
- return row.id;
40
- // Fall back to file_path-only match (legacy callers may pass a stash root
41
- // that doesn't exactly match entries.stash_dir, e.g. trailing-slash diffs).
42
- const fallback = db.prepare("SELECT id FROM entries WHERE file_path = ? LIMIT 1").get(filePath);
43
- return fallback?.id ?? null;
44
- }
45
- catch {
46
- return null;
47
- }
48
- }
49
28
  /**
50
29
  * Persist (or update) a graph snapshot for a stash root.
51
30
  *
52
- * Implementation: incremental upsert keyed on entries.id. Unchanged files
53
- * (matching body_hash) are skipped; changed files have their child rows
54
- * deleted (CASCADE) and re-inserted; files in DB but absent from the new
55
- * snapshot are deleted. The old behaviour wiped every row for the stash on
56
- * each write, which produced ~22k row writes per re-index even when one
57
- * asset changed.
58
- *
59
- * Orphan files (no entries row resolvable) are skipped and counted in a
60
- * single warn() so the caller sees the magnitude without log spam.
31
+ * #624-P1: keyed on (stash_root, file_path, body_hash) — NOT entries.id. Graph
32
+ * rows are self-keyed by path, so they survive an entries delete + reinsert
33
+ * (a reindex) when body_hash is unchanged. Unchanged files (matching body_hash)
34
+ * only have their file-meta refreshed; files whose body_hash changed have their
35
+ * old row + child rows deleted and the new content inserted; files in DB but
36
+ * absent from the new snapshot are deleted. There is no entry_id resolution and
37
+ * no orphan-skip — a graph file no longer needs a matching entries row.
61
38
  */
62
39
  export function replaceStoredGraph(db, graph) {
63
40
  const upsertMeta = db.prepare(`INSERT INTO graph_meta (
@@ -98,87 +75,98 @@ export function replaceStoredGraph(db, graph) {
98
75
  cache_misses = excluded.cache_misses,
99
76
  truncation_count = excluded.truncation_count,
100
77
  failure_count = excluded.failure_count`);
101
- const selectExisting = db.prepare("SELECT entry_id, file_path, body_hash FROM graph_files WHERE stash_root = ?");
102
- const deleteFile = db.prepare("DELETE FROM graph_files WHERE entry_id = ?");
103
- const deleteEntities = db.prepare("DELETE FROM graph_file_entities WHERE entry_id = ?");
104
- const deleteRelations = db.prepare("DELETE FROM graph_file_relations WHERE entry_id = ?");
78
+ const selectExisting = db.prepare("SELECT file_path, body_hash, file_order FROM graph_files WHERE stash_root = ?");
79
+ const deleteFile = db.prepare("DELETE FROM graph_files WHERE stash_root = ? AND file_path = ? AND body_hash = ?");
80
+ const deleteEntities = db.prepare("DELETE FROM graph_file_entities WHERE stash_root = ? AND file_path = ? AND body_hash = ?");
81
+ const deleteRelations = db.prepare("DELETE FROM graph_file_relations WHERE stash_root = ? AND file_path = ? AND body_hash = ?");
105
82
  const insertFile = db.prepare(`INSERT INTO graph_files (
106
- entry_id, stash_root, file_path, file_order, file_type, body_hash, confidence, status, reason, extraction_run_id
107
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
83
+ stash_root, file_path, file_order, file_type, body_hash, confidence, status, reason, extraction_run_id
84
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`);
108
85
  const updateFileMeta = db.prepare(`UPDATE graph_files
109
86
  SET file_order = ?, file_type = ?, confidence = ?, status = ?, reason = ?, extraction_run_id = ?
110
- WHERE entry_id = ?`);
111
- const insertEntity = db.prepare(`INSERT INTO graph_file_entities (entry_id, entity_order, stash_root, entity_norm, entity)
112
- VALUES (?, ?, ?, ?, ?)`);
87
+ WHERE stash_root = ? AND file_path = ? AND body_hash = ?`);
88
+ const insertEntity = db.prepare(`INSERT INTO graph_file_entities (stash_root, file_path, body_hash, entity_order, entity_norm, entity)
89
+ VALUES (?, ?, ?, ?, ?, ?)`);
113
90
  const insertRelation = db.prepare(`INSERT INTO graph_file_relations (
114
- entry_id, relation_order, from_entity_norm, from_entity, to_entity_norm, to_entity, relation_type, confidence
115
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`);
91
+ stash_root, file_path, body_hash, relation_order, from_entity_norm, from_entity, to_entity_norm, to_entity, relation_type, confidence
92
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
116
93
  const quality = graph.quality;
117
94
  const telemetry = graph.telemetry;
118
95
  db.transaction(() => {
119
96
  upsertMeta.run(graph.stashRoot, graph.schemaVersion, graph.generatedAt, quality?.consideredFiles ?? graph.files.length, quality?.extractedFiles ?? graph.files.length, quality?.entityCount ?? graph.entities?.length ?? 0, quality?.relationCount ?? graph.relations?.length ?? 0, quality?.extractionCoverage ?? 0, quality?.density ?? 0, telemetry?.extractorId ?? null, telemetry?.extractionRunId ?? null, telemetry?.model ?? null, telemetry?.promptVersion ?? null, telemetry?.batchSize ?? null, telemetry?.cacheHits ?? 0, telemetry?.cacheMisses ?? 0, telemetry?.truncationCount ?? 0, telemetry?.failureCount ?? 0);
120
- // Build a snapshot of existing rows for incremental compare.
97
+ // Build a snapshot of existing rows for incremental compare. The unique
98
+ // index idx_graph_files_path guarantees at most one row per file_path.
121
99
  const existingRows = selectExisting.all(graph.stashRoot);
122
100
  const existingByPath = new Map();
123
101
  for (const row of existingRows)
124
102
  existingByPath.set(row.file_path, row);
125
- let orphanCount = 0;
126
- const presentEntryIds = new Set();
103
+ const presentPaths = new Set();
127
104
  for (const [fileOrder, node] of graph.files.entries()) {
128
- // body_hash is NOT NULL in schema v2; default to a sentinel for inputs
129
- // (test fixtures, legacy imports) that don't supply one. The sentinel
130
- // never equals a real hash so subsequent staleness checks always
131
- // re-extract — correct behaviour for "unknown" bodies.
105
+ // body_hash is part of the PK; default to a sentinel for inputs (test
106
+ // fixtures, legacy imports) that don't supply one. The sentinel never
107
+ // equals a real hash so subsequent staleness checks always re-extract —
108
+ // correct behaviour for "unknown" bodies. Distinct files in one stash
109
+ // are still keyed apart by file_path, so the empty sentinel is safe.
132
110
  const bodyHash = node.bodyHash && node.bodyHash.length > 0 ? node.bodyHash : "";
133
- const entryId = resolveEntryIdForPath(db, graph.stashRoot, node.path);
134
- if (entryId == null) {
135
- orphanCount += 1;
136
- continue;
137
- }
138
- presentEntryIds.add(entryId);
111
+ presentPaths.add(node.path);
139
112
  const existing = existingByPath.get(node.path);
140
- if (existing && existing.entry_id === entryId && existing.body_hash === bodyHash) {
113
+ if (existing && existing.body_hash === bodyHash) {
141
114
  // Body unchanged — only fix up file_order/confidence in case they drifted.
142
- updateFileMeta.run(fileOrder, node.type, node.confidence ?? null, node.status ?? (node.entities.length > 0 ? "extracted" : "empty"), node.reason ?? (node.entities.length > 0 ? "none" : "no_graph_content"), node.extractionRunId ?? telemetry?.extractionRunId ?? null, entryId);
115
+ updateFileMeta.run(fileOrder, node.type, node.confidence ?? null, node.status ?? (node.entities.length > 0 ? "extracted" : "empty"), node.reason ?? (node.entities.length > 0 ? "none" : "no_graph_content"), node.extractionRunId ?? telemetry?.extractionRunId ?? null, graph.stashRoot, node.path, bodyHash);
143
116
  continue;
144
117
  }
145
118
  if (existing) {
146
- // Stale row (different body_hash, or entry_id moved to a different
147
- // path under the same file_path). Wipe child rows; CASCADE would do
148
- // it but explicit DELETE keeps the order deterministic.
149
- deleteEntities.run(existing.entry_id);
150
- deleteRelations.run(existing.entry_id);
151
- deleteFile.run(existing.entry_id);
119
+ // Stale row (different body_hash for this path). Delete the old row by
120
+ // its OLD body_hash; child rows cascade, but explicit DELETE keeps the
121
+ // order deterministic and is safe regardless of the FK pragma.
122
+ deleteEntities.run(graph.stashRoot, existing.file_path, existing.body_hash);
123
+ deleteRelations.run(graph.stashRoot, existing.file_path, existing.body_hash);
124
+ deleteFile.run(graph.stashRoot, existing.file_path, existing.body_hash);
152
125
  }
153
- insertFile.run(entryId, graph.stashRoot, node.path, fileOrder, node.type, bodyHash, node.confidence ?? null, node.status ?? (node.entities.length > 0 ? "extracted" : "empty"), node.reason ?? (node.entities.length > 0 ? "none" : "no_graph_content"), node.extractionRunId ?? telemetry?.extractionRunId ?? null);
126
+ insertFile.run(graph.stashRoot, node.path, fileOrder, node.type, bodyHash, node.confidence ?? null, node.status ?? (node.entities.length > 0 ? "extracted" : "empty"), node.reason ?? (node.entities.length > 0 ? "none" : "no_graph_content"), node.extractionRunId ?? telemetry?.extractionRunId ?? null);
154
127
  for (const [entityOrder, entity] of node.entities.entries()) {
155
- insertEntity.run(entryId, entityOrder, graph.stashRoot, normalizeEntity(entity), entity);
128
+ insertEntity.run(graph.stashRoot, node.path, bodyHash, entityOrder, normalizeEntity(entity), entity);
156
129
  }
157
130
  for (const [relationOrder, relation] of node.relations.entries()) {
158
- insertRelation.run(entryId, relationOrder, normalizeEntity(relation.from), relation.from, normalizeEntity(relation.to), relation.to, relation.type ?? null, relation.confidence ?? null);
131
+ insertRelation.run(graph.stashRoot, node.path, bodyHash, relationOrder, normalizeEntity(relation.from), relation.from, normalizeEntity(relation.to), relation.to, relation.type ?? null, relation.confidence ?? null);
159
132
  }
160
133
  }
161
134
  // Delete files present in DB but absent from the new snapshot. Child
162
- // tables CASCADE on entry_id.
135
+ // tables CASCADE on the composite key; explicit DELETE keeps it determinstic.
163
136
  for (const row of existingRows) {
164
- if (!presentEntryIds.has(row.entry_id)) {
165
- deleteEntities.run(row.entry_id);
166
- deleteRelations.run(row.entry_id);
167
- deleteFile.run(row.entry_id);
137
+ if (!presentPaths.has(row.file_path)) {
138
+ deleteEntities.run(graph.stashRoot, row.file_path, row.body_hash);
139
+ deleteRelations.run(graph.stashRoot, row.file_path, row.body_hash);
140
+ deleteFile.run(graph.stashRoot, row.file_path, row.body_hash);
168
141
  }
169
142
  }
170
- if (orphanCount > 0) {
171
- warn(`[graph] replaceStoredGraph: skipped ${orphanCount} file(s) with no resolvable entry under ${graph.stashRoot}.`);
172
- }
173
143
  })();
174
144
  }
175
145
  export function deleteStoredGraph(db, stashPath) {
176
146
  db.transaction(() => {
177
- // Child rows cascade via entry_id; deleting graph_files clears them.
147
+ // Child rows cascade via the composite (stash_root, file_path, body_hash)
148
+ // FK; deleting graph_files clears them. This is the explicit full-clear
149
+ // path for a stash (entries-delete no longer wipes graph data — see #624-P1).
178
150
  db.prepare("DELETE FROM graph_files WHERE stash_root = ?").run(stashPath);
179
151
  db.prepare("DELETE FROM graph_meta WHERE stash_root = ?").run(stashPath);
180
152
  })();
181
153
  }
154
+ /**
155
+ * #624-P1 — does any graph data exist for a file_path under a stash root?
156
+ * Consumed by show/curate flows (P3) but defined here so the schema and its
157
+ * accessors land together.
158
+ */
159
+ export function hasGraphData(db, stashRoot, filePath) {
160
+ try {
161
+ const row = db
162
+ .prepare("SELECT 1 AS present FROM graph_files WHERE stash_root = ? AND file_path = ? LIMIT 1")
163
+ .get(stashRoot, filePath);
164
+ return row !== undefined;
165
+ }
166
+ catch {
167
+ return false;
168
+ }
169
+ }
182
170
  /**
183
171
  * Scoped loader — only the graph_meta row for a stash. Used by callers that
184
172
  * only need summary numbers (e.g. `akm graph summary`).
@@ -195,13 +183,12 @@ export function loadGraphFilesOnly(stashPath, db) {
195
183
  return withReadableGraphDb(db, (readDb) => {
196
184
  try {
197
185
  const rows = readDb
198
- .prepare(`SELECT entry_id, file_path, file_type, body_hash, confidence, status, reason
186
+ .prepare(`SELECT file_path, file_type, body_hash, confidence, status, reason
199
187
  FROM graph_files
200
188
  WHERE stash_root = ?
201
189
  ORDER BY file_order`)
202
190
  .all(stashPath);
203
191
  return rows.map((row) => ({
204
- entryId: row.entry_id,
205
192
  path: row.file_path,
206
193
  type: row.file_type,
207
194
  bodyHash: row.body_hash,
@@ -222,13 +209,16 @@ export function loadGraphFilesOnly(stashPath, db) {
222
209
  }
223
210
  }
224
211
  /**
225
- * Scoped loader — entities for a single entry_id. Used by per-asset lookups.
212
+ * Scoped loader — entities for a single file, keyed on the #624-P1 composite
213
+ * (stash_root, file_path, body_hash). Used by per-asset show/curate lookups.
226
214
  */
227
- export function loadGraphEntitiesByEntry(db, entryId) {
215
+ export function loadGraphEntitiesByPath(db, stashRoot, filePath, bodyHash) {
228
216
  try {
229
217
  const rows = db
230
- .prepare("SELECT entity FROM graph_file_entities WHERE entry_id = ? ORDER BY entity_order")
231
- .all(entryId);
218
+ .prepare(`SELECT entity FROM graph_file_entities
219
+ WHERE stash_root = ? AND file_path = ? AND body_hash = ?
220
+ ORDER BY entity_order`)
221
+ .all(stashRoot, filePath, bodyHash);
232
222
  return rows.map((r) => r.entity);
233
223
  }
234
224
  catch {
@@ -314,27 +304,32 @@ export function loadStoredGraphSnapshot(stashPath, db) {
314
304
  return null;
315
305
  try {
316
306
  const fileRows = readDb
317
- .prepare(`SELECT entry_id, file_path, file_type, body_hash, confidence, status, reason, extraction_run_id
307
+ .prepare(`SELECT file_path, file_type, body_hash, confidence, status, reason, extraction_run_id
318
308
  FROM graph_files
319
309
  WHERE stash_root = ?
320
310
  ORDER BY file_order`)
321
311
  .all(stashPath);
322
312
  const entityRows = readDb
323
- .prepare(`SELECT gfe.entry_id AS entry_id, gf.file_path AS file_path, gfe.entity AS entity
313
+ .prepare(`SELECT gf.file_path AS file_path, gfe.entity AS entity
324
314
  FROM graph_file_entities gfe
325
- JOIN graph_files gf ON gf.entry_id = gfe.entry_id
315
+ JOIN graph_files gf
316
+ ON gf.stash_root = gfe.stash_root
317
+ AND gf.file_path = gfe.file_path
318
+ AND gf.body_hash = gfe.body_hash
326
319
  WHERE gf.stash_root = ?
327
320
  ORDER BY gf.file_order, gfe.entity_order`)
328
321
  .all(stashPath);
329
322
  const relationRows = readDb
330
- .prepare(`SELECT gfr.entry_id AS entry_id,
331
- gf.file_path AS file_path,
323
+ .prepare(`SELECT gf.file_path AS file_path,
332
324
  gfr.from_entity AS from_entity,
333
325
  gfr.to_entity AS to_entity,
334
326
  gfr.relation_type AS relation_type,
335
327
  gfr.confidence AS confidence
336
328
  FROM graph_file_relations gfr
337
- JOIN graph_files gf ON gf.entry_id = gfr.entry_id
329
+ JOIN graph_files gf
330
+ ON gf.stash_root = gfr.stash_root
331
+ AND gf.file_path = gfr.file_path
332
+ AND gf.body_hash = gfr.body_hash
338
333
  WHERE gf.stash_root = ?
339
334
  ORDER BY gf.file_order, gfr.relation_order`)
340
335
  .all(stashPath);
@@ -241,13 +241,18 @@ export function collectGraphRelatedHit(context, filePath) {
241
241
  * Find graph files that share entities with the given file.
242
242
  *
243
243
  * Implementation: SQL self-join on graph_file_entities, scoped by stash_root,
244
- * grouped by entry_id, ordered by shared-entity count desc. Touches ~50-200
244
+ * grouped by file_path, ordered by shared-entity count desc. Touches ~50-200
245
245
  * rows instead of loading the entire snapshot into memory. Cold-call latency
246
246
  * drops from ~30-60ms (full snapshot parse) to ~2-5ms on typical stashes.
247
247
  *
248
+ * #624-P1: the graph tables are keyed on (stash_root, file_path, body_hash) —
249
+ * NOT entries.id — so candidates are identified by file_path (the unique index
250
+ * idx_graph_files_path guarantees one graph_files row per path).
251
+ *
248
252
  * The returned `ref` field carries the canonical asset ref (`type:name`)
249
- * resolved from entries.entry_key when the entry is indexed. Callers should
250
- * fall back to formatting `path` when `ref` is undefined (orphan graph row).
253
+ * resolved from entries.entry_key when the file is indexed. Callers should
254
+ * fall back to formatting `path` when `ref` is undefined (graph row with no
255
+ * matching entries row).
251
256
  */
252
257
  export function listRelatedPathsForFile(stashRoot, filePath, limit = 5, db) {
253
258
  if (!db) {
@@ -255,113 +260,118 @@ export function listRelatedPathsForFile(stashRoot, filePath, limit = 5, db) {
255
260
  // callers pass a handle), so degrade to empty rather than reopening.
256
261
  return [];
257
262
  }
258
- // Resolve target's entry_id from the stash_root + file_path. The graph rows
259
- // are keyed on entry_id; without it we can't run the join.
260
- let targetEntryId;
263
+ // Confirm the target file has a graph row; without it there is nothing to
264
+ // relate. (Identity is file_path within the stash one row per path.)
261
265
  try {
262
266
  const row = db
263
- .prepare("SELECT entry_id FROM graph_files WHERE stash_root = ? AND file_path = ? LIMIT 1")
267
+ .prepare("SELECT 1 AS present FROM graph_files WHERE stash_root = ? AND file_path = ? LIMIT 1")
264
268
  .get(stashRoot, filePath);
265
- targetEntryId = row?.entry_id;
269
+ if (row === undefined)
270
+ return [];
266
271
  }
267
272
  catch {
268
273
  return [];
269
274
  }
270
- if (targetEntryId == null)
271
- return [];
272
275
  const effectiveLimit = Math.max(1, limit);
273
- // Shared-entity count per candidate entry_id.
276
+ // Shared-entity count per candidate file_path. The target's entities are the
277
+ // rows for `filePath`; candidates are any OTHER file_path in the stash that
278
+ // shares a normalized entity.
274
279
  let candidateRows;
275
280
  try {
276
281
  candidateRows = db
277
- .prepare(`SELECT gf.entry_id AS entry_id,
278
- gf.file_path AS file_path,
282
+ .prepare(`SELECT gf.file_path AS file_path,
279
283
  gf.file_type AS file_type,
280
284
  COUNT(*) AS shared
281
285
  FROM graph_file_entities target
282
286
  JOIN graph_file_entities e
283
287
  ON e.stash_root = target.stash_root
284
288
  AND e.entity_norm = target.entity_norm
285
- AND e.entry_id != target.entry_id
289
+ AND e.file_path != target.file_path
286
290
  JOIN graph_files gf
287
- ON gf.entry_id = e.entry_id
288
- WHERE target.entry_id = ?
291
+ ON gf.stash_root = e.stash_root
292
+ AND gf.file_path = e.file_path
293
+ AND gf.body_hash = e.body_hash
294
+ WHERE target.file_path = ?
289
295
  AND target.stash_root = ?
290
- GROUP BY gf.entry_id
296
+ GROUP BY gf.file_path
291
297
  ORDER BY shared DESC, gf.file_path ASC
292
298
  LIMIT ?`)
293
- .all(targetEntryId, stashRoot, effectiveLimit);
299
+ .all(filePath, stashRoot, effectiveLimit);
294
300
  }
295
301
  catch {
296
302
  return [];
297
303
  }
298
304
  if (candidateRows.length === 0)
299
305
  return [];
300
- const candidateIds = candidateRows.map((r) => r.entry_id);
301
- const placeholders = candidateIds.map(() => "?").join(",");
306
+ const candidatePaths = candidateRows.map((r) => r.file_path);
307
+ const placeholders = candidatePaths.map(() => "?").join(",");
302
308
  // Pull the shared entity names (joined by normalized casing) for display.
303
309
  const sharedRows = db
304
- .prepare(`SELECT e.entry_id AS entry_id, e.entity AS entity
310
+ .prepare(`SELECT e.file_path AS file_path, e.entity AS entity
305
311
  FROM graph_file_entities e
306
312
  JOIN graph_file_entities target
307
313
  ON target.stash_root = e.stash_root
308
314
  AND target.entity_norm = e.entity_norm
309
- WHERE e.entry_id IN (${placeholders})
310
- AND target.entry_id = ?
315
+ WHERE e.file_path IN (${placeholders})
316
+ AND e.stash_root = ?
317
+ AND target.file_path = ?
311
318
  AND target.stash_root = ?`)
312
- .all(...candidateIds, targetEntryId, stashRoot);
313
- const sharedByEntry = new Map();
319
+ .all(...candidatePaths, stashRoot, filePath, stashRoot);
320
+ const sharedByPath = new Map();
314
321
  for (const row of sharedRows) {
315
- let bucket = sharedByEntry.get(row.entry_id);
322
+ let bucket = sharedByPath.get(row.file_path);
316
323
  if (!bucket) {
317
324
  bucket = new Set();
318
- sharedByEntry.set(row.entry_id, bucket);
325
+ sharedByPath.set(row.file_path, bucket);
319
326
  }
320
327
  bucket.add(row.entity);
321
328
  }
322
329
  // Relation count for each candidate (relations where either endpoint
323
330
  // matches one of the shared entities).
324
- const relationCountByEntry = new Map();
331
+ const relationCountByPath = new Map();
325
332
  const relationRows = db
326
- .prepare(`SELECT entry_id, from_entity, to_entity
333
+ .prepare(`SELECT file_path, from_entity, to_entity
327
334
  FROM graph_file_relations
328
- WHERE entry_id IN (${placeholders})`)
329
- .all(...candidateIds);
335
+ WHERE file_path IN (${placeholders})
336
+ AND stash_root = ?`)
337
+ .all(...candidatePaths, stashRoot);
330
338
  for (const row of relationRows) {
331
- const shared = sharedByEntry.get(row.entry_id);
339
+ const shared = sharedByPath.get(row.file_path);
332
340
  if (!shared)
333
341
  continue;
334
342
  if (shared.has(row.from_entity) || shared.has(row.to_entity)) {
335
- relationCountByEntry.set(row.entry_id, (relationCountByEntry.get(row.entry_id) ?? 0) + 1);
343
+ relationCountByPath.set(row.file_path, (relationCountByPath.get(row.file_path) ?? 0) + 1);
336
344
  }
337
345
  }
338
346
  // Optional: ref lookup via entries.entry_key. entry_key is stored as
339
347
  // `${stash_dir}:${type}:${name}` — strip the stash-dir prefix to get the
340
- // user-facing `type:name`.
341
- const refByEntryId = new Map();
348
+ // user-facing `type:name`. Resolve by (stash_dir, file_path) now that the
349
+ // graph rows are no longer keyed on entries.id.
350
+ const refByPath = new Map();
342
351
  try {
343
352
  const entryRows = db
344
- .prepare(`SELECT id, entry_key, stash_dir FROM entries WHERE id IN (${placeholders})`)
345
- .all(...candidateIds);
353
+ .prepare(`SELECT entry_key, stash_dir, file_path FROM entries
354
+ WHERE file_path IN (${placeholders}) AND stash_dir = ?`)
355
+ .all(...candidatePaths, stashRoot);
346
356
  for (const row of entryRows) {
347
357
  const ref = stripStashPrefix(row.entry_key, row.stash_dir);
348
358
  if (ref)
349
- refByEntryId.set(row.id, ref);
359
+ refByPath.set(row.file_path, ref);
350
360
  }
351
361
  }
352
362
  catch {
353
363
  /* ignore — refs are best-effort */
354
364
  }
355
365
  return candidateRows.map((row) => {
356
- const sharedSet = sharedByEntry.get(row.entry_id) ?? new Set();
366
+ const sharedSet = sharedByPath.get(row.file_path) ?? new Set();
357
367
  const sharedEntities = [...sharedSet].sort((a, b) => a.localeCompare(b));
358
- const ref = refByEntryId.get(row.entry_id);
368
+ const ref = refByPath.get(row.file_path);
359
369
  return {
360
370
  ...(ref ? { ref } : {}),
361
371
  path: row.file_path,
362
372
  type: row.file_type,
363
373
  sharedEntities,
364
- relationCount: relationCountByEntry.get(row.entry_id) ?? 0,
374
+ relationCount: relationCountByPath.get(row.file_path) ?? 0,
365
375
  };
366
376
  });
367
377
  }
@@ -425,10 +425,17 @@ function markParentProcessed(parent) {
425
425
  warn(`memory inference: failed to re-read parent ${parent.filePath}: ${err instanceof Error ? err.message : String(err)}`);
426
426
  return;
427
427
  }
428
- const updatedFm = { ...parent.data, [FM_INFERENCE_PROCESSED]: true };
429
428
  const block = parseFrontmatterBlock(raw);
430
- const body = block?.content ?? raw;
431
- const next = assembleAsset(updatedFm, body);
429
+ if (!block) {
430
+ // Cannot safely rewrite malformed frontmatter — skip marking so the memory
431
+ // is retried on the next run once the frontmatter is repaired. Writing with
432
+ // `body = raw` would wrap the entire file (including the bad frontmatter)
433
+ // in a new block, producing a duplicate-frontmatter corruption.
434
+ warn(`memory inference: skipping markParentProcessed for ${parent.filePath} — could not parse frontmatter block`);
435
+ return;
436
+ }
437
+ const updatedFm = { ...parent.data, [FM_INFERENCE_PROCESSED]: true };
438
+ const next = assembleAsset(updatedFm, block.content);
432
439
  try {
433
440
  fs.writeFileSync(parent.filePath, next, "utf8");
434
441
  }
@@ -44,6 +44,7 @@
44
44
  import { createHash } from "node:crypto";
45
45
  import fs from "node:fs";
46
46
  import path from "node:path";
47
+ import stalenessDetectSystemPrompt from "../../assets/prompts/staleness-detect-system.md" with { type: "text" };
47
48
  import { assembleAsset } from "../../core/asset/asset-serialize.js";
48
49
  import { parseFrontmatter, parseFrontmatterBlock } from "../../core/asset/frontmatter.js";
49
50
  import { concurrentMap } from "../../core/concurrent.js";
@@ -319,11 +320,7 @@ function pickSimilar(candidate, all) {
319
320
  return scored.slice(0, TOP_K_SIMILAR).map((s) => s.snap);
320
321
  }
321
322
  // ── LLM dispatch ────────────────────────────────────────────────────────────
322
- const SYSTEM_PROMPT = "You are a belief-state classifier for a memory store. Given a candidate memory and a list of more-recent similar memories from the same store, decide whether the candidate is still current or has been superseded.\n\n" +
323
- "Respond on the first line with exactly YES or NO.\n" +
324
- "If YES, the second line MUST be of the form `SUPERSEDED_BY: <ref>` where <ref> is the exact ref of the superseding memory from the list provided. Do NOT invent refs.\n" +
325
- "If NO, do not include any additional lines.\n" +
326
- "No prose, no preamble, no markdown.";
323
+ const SYSTEM_PROMPT = stalenessDetectSystemPrompt;
327
324
  async function askValidator(connection, candidate, allMemories, signal, timeoutMs) {
328
325
  const similar = pickSimilar(candidate, allMemories);
329
326
  if (similar.length === 0) {
@@ -65,6 +65,7 @@ export async function searchLocal(input) {
65
65
  const includeProposed = input.includeProposed === true;
66
66
  const beliefFilter = input.beliefFilter ?? "all";
67
67
  const restrictToSources = input.restrictToSources === true;
68
+ const includeExcludedTypes = input.includeExcludedTypes === true;
68
69
  const rendererRegistry = input.rendererRegistry ?? defaultRendererRegistry;
69
70
  const allSourceDirs = sources.map((s) => s.path);
70
71
  const rawStatus = readSemanticStatus();
@@ -114,7 +115,7 @@ export async function searchLocal(input) {
114
115
  mode: "keyword",
115
116
  };
116
117
  }
117
- const { hits, embedMs, rankMs } = await searchDatabase(db, query, searchType, limit, stashDir, allSourceDirs, config, sources, rendererRegistry, filters, includeProposed, beliefFilter, restrictToSources);
118
+ const { hits, embedMs, rankMs } = await searchDatabase(db, query, searchType, limit, stashDir, allSourceDirs, config, sources, rendererRegistry, filters, includeProposed, beliefFilter, restrictToSources, includeExcludedTypes);
118
119
  return {
119
120
  hits,
120
121
  tip: hits.length === 0
@@ -131,14 +132,19 @@ export async function searchLocal(input) {
131
132
  }
132
133
  }
133
134
  // ── Database search ─────────────────────────────────────────────────────────
134
- async function searchDatabase(db, query, searchType, limit, stashDir, allSourceDirs, config, sources, rendererRegistry = defaultRendererRegistry, filters, includeProposed = false, beliefFilter = "all", restrictToSources = false) {
135
+ async function searchDatabase(db, query, searchType, limit, stashDir, allSourceDirs, config, sources, rendererRegistry = defaultRendererRegistry, filters, includeProposed = false, beliefFilter = "all", restrictToSources = false, includeExcludedTypes = false) {
135
136
  const hasSearchableTokens = query.length > 0 && sanitizeFtsQuery(query).length > 0;
137
+ // #627 — resolve the default type-exclusion policy. It applies ONLY on the
138
+ // untyped ('any') path and only when the caller did not opt back in via
139
+ // `includeExcludedTypes`. When the config key is ABSENT a built-in default of
140
+ // ['session'] is applied; an explicit empty list disables exclusion.
141
+ const defaultExcludes = searchType === "any" && !includeExcludedTypes ? (config.search?.defaultExcludeTypes ?? ["session"]) : [];
136
142
  // Empty queries — including ones that sanitize down to no searchable FTS
137
143
  // tokens such as "." — should enumerate matching entries instead of
138
144
  // returning an empty result set from FTS.
139
145
  if (!hasSearchableTokens) {
140
146
  const typeFilter = searchType === "any" ? undefined : searchType;
141
- const allEntries = getAllEntries(db, typeFilter);
147
+ const allEntries = getAllEntries(db, typeFilter, defaultExcludes);
142
148
  // Deduplicate by file path — multiple entries can share the same file
143
149
  const seenFilePaths = new Set();
144
150
  const uniqueEntries = allEntries.filter((ie) => {
@@ -187,7 +193,7 @@ async function searchDatabase(db, query, searchType, limit, stashDir, allSourceD
187
193
  const typeFilter = searchType === "any" ? undefined : searchType;
188
194
  const tEmbed0 = Date.now();
189
195
  const embeddingPromise = tryVecScores(db, query, limit * 3, config);
190
- const ftsResults = searchFts(db, query, limit * 3, typeFilter);
196
+ const ftsResults = searchFts(db, query, limit * 3, typeFilter, defaultExcludes);
191
197
  const embeddingScores = await embeddingPromise;
192
198
  const embedMs = Date.now() - tEmbed0;
193
199
  const tRank0 = Date.now();
@@ -208,6 +214,11 @@ async function searchDatabase(db, query, searchType, limit, stashDir, allSourceD
208
214
  embedScoreMap,
209
215
  getEntryById: (id) => getEntryById(db, id) ?? undefined,
210
216
  typeFilter,
217
+ // #627 — also exclude default-hidden types from the vector-only branch so a
218
+ // session asset that is a top-k vector neighbor (but not an FTS match) does
219
+ // not leak into default ('any') results. defaultExcludes is already []
220
+ // unless this is the untyped path without includeExcludedTypes.
221
+ excludeTypes: defaultExcludes,
211
222
  });
212
223
  // ── Scoring Phase ──────────────────────────────────────────────────────
213
224
  // Apply boosts as multiplicative factors (all boosts in a single phase
@@ -20,6 +20,7 @@ export function normalizeFtsScores(results) {
20
20
  export function combineSearchScores(options) {
21
21
  const FTS_WEIGHT = 0.7;
22
22
  const VEC_WEIGHT = 0.3;
23
+ const excludeTypeSet = options.excludeTypes && options.excludeTypes.length > 0 ? new Set(options.excludeTypes) : null;
23
24
  const scored = [];
24
25
  const seenIds = new Set();
25
26
  for (const [id, { score: ftsScore, result }] of options.ftsScoreMap) {
@@ -42,6 +43,9 @@ export function combineSearchScores(options) {
42
43
  continue;
43
44
  if (options.typeFilter && found.entry.type !== options.typeFilter)
44
45
  continue;
46
+ // #627 — drop vector-only neighbors whose type is excluded on the default path.
47
+ if (excludeTypeSet?.has(found.entry.type))
48
+ continue;
45
49
  scored.push({
46
50
  id,
47
51
  entry: found.entry,
@@ -107,6 +107,16 @@ export class ClaudeCodeProvider {
107
107
  isAvailable() {
108
108
  return fs.existsSync(claudeProjectsDir());
109
109
  }
110
+ /**
111
+ * Directory holding Claude Code's per-project session JSONL files
112
+ * (`~/.claude/projects`, honoring `AKM_CLAUDE_PROJECTS_DIR`). Returns `[]`
113
+ * when the directory does not exist on this machine. See {@link
114
+ * SessionLogHarness.watchRoots}.
115
+ */
116
+ watchRoots() {
117
+ const dir = claudeProjectsDir();
118
+ return fs.existsSync(dir) ? [dir] : [];
119
+ }
110
120
  *readEvents(input) {
111
121
  try {
112
122
  for (const jsonlPath of this.#walkJsonl(claudeProjectsDir())) {
@@ -26,6 +26,15 @@ export class OpenCodeProvider {
26
26
  isAvailable() {
27
27
  return fs.existsSync(this.#baseDir);
28
28
  }
29
+ /**
30
+ * Directory holding opencode's per-project session metadata files
31
+ * (`<base>/storage/session`). Returns `[]` when it does not exist on this
32
+ * machine. See {@link SessionLogHarness.watchRoots}.
33
+ */
34
+ watchRoots() {
35
+ const sessionRoot = path.join(this.#baseDir, "storage", "session");
36
+ return fs.existsSync(sessionRoot) ? [sessionRoot] : [];
37
+ }
29
38
  *readEvents(input) {
30
39
  // Legacy behavior: stream raw log lines from the top-level dir and `log/`
31
40
  // subdirectory. Kept to keep `getExecutionLogCandidates` working without
@@ -28,6 +28,22 @@ const ERROR_PATTERNS = /error|failed|exception|cannot|undefined|null pointer|ENO
28
28
  export function getAvailableHarnesses() {
29
29
  return HARNESSES.filter((harness) => harness.isAvailable());
30
30
  }
31
+ /**
32
+ * Map each available harness to its `{ harnessName, roots }` watch target,
33
+ * skipping harnesses that expose no roots (absent `watchRoots()` or an empty
34
+ * result). This is the one stable entry point the watcher uses so it never
35
+ * reaches into providers directly.
36
+ */
37
+ export function getWatchTargets() {
38
+ const targets = [];
39
+ for (const harness of getAvailableHarnesses()) {
40
+ const roots = harness.watchRoots?.() ?? [];
41
+ if (roots.length === 0)
42
+ continue;
43
+ targets.push({ harnessName: harness.name, roots });
44
+ }
45
+ return targets;
46
+ }
31
47
  export function normalizeSessionTopic(text) {
32
48
  const normalized = text.replace(/\s+/g, " ").trim().toLowerCase();
33
49
  if (normalized.length < 10)