akm-cli 0.9.0-beta.5 → 0.9.0-beta.51

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 (221) hide show
  1. package/CHANGELOG.md +711 -0
  2. package/README.md +12 -4
  3. package/dist/akm +38 -0
  4. package/dist/akm-migrate-storage +38 -0
  5. package/dist/assets/profiles/default.json +9 -4
  6. package/dist/assets/profiles/frequent.json +1 -1
  7. package/dist/assets/profiles/memory-focus.json +1 -1
  8. package/dist/assets/profiles/quick.json +1 -1
  9. package/dist/assets/profiles/synthesize.json +15 -0
  10. package/dist/assets/profiles/thorough.json +1 -1
  11. package/dist/assets/prompts/consolidate-system.md +23 -0
  12. package/dist/assets/prompts/contradiction-judge.md +33 -0
  13. package/dist/assets/prompts/distill-knowledge-system.md +22 -0
  14. package/dist/assets/prompts/distill-lesson-system.md +36 -0
  15. package/dist/assets/prompts/extract-session.md +6 -2
  16. package/dist/assets/prompts/graph-extract-system.md +1 -0
  17. package/dist/assets/prompts/graph-extract-user-prompt.md +1 -1
  18. package/dist/assets/prompts/memory-infer-system.md +1 -0
  19. package/dist/assets/prompts/memory-infer-user.md +5 -0
  20. package/dist/assets/prompts/metadata-enhance-system.md +1 -0
  21. package/dist/assets/prompts/procedural-system.md +44 -0
  22. package/dist/assets/prompts/recombine-system.md +40 -0
  23. package/dist/assets/prompts/staleness-detect-system.md +6 -0
  24. package/dist/assets/prompts/validate-summary-judge.md +1 -0
  25. package/dist/assets/stash-skeleton/facts/conventions/assets/agent.md +38 -0
  26. package/dist/assets/stash-skeleton/facts/conventions/assets/command.md +38 -0
  27. package/dist/assets/stash-skeleton/facts/conventions/assets/fact.md +39 -0
  28. package/dist/assets/stash-skeleton/facts/conventions/assets/knowledge.md +40 -0
  29. package/dist/assets/stash-skeleton/facts/conventions/assets/lesson.md +43 -0
  30. package/dist/assets/stash-skeleton/facts/conventions/assets/memory.md +38 -0
  31. package/dist/assets/stash-skeleton/facts/conventions/assets/script.md +43 -0
  32. package/dist/assets/stash-skeleton/facts/conventions/assets/skill.md +40 -0
  33. package/dist/assets/stash-skeleton/facts/conventions/assets/workflow.md +43 -0
  34. package/dist/assets/templates/html/health.html +281 -111
  35. package/dist/assets/wiki/ingest-workflow-template.md +38 -10
  36. package/dist/cli/parse-args.js +46 -1
  37. package/dist/cli/shared.js +28 -0
  38. package/dist/cli.js +27 -11
  39. package/dist/commands/agent/agent-dispatch.js +2 -2
  40. package/dist/commands/agent/agent-support.js +0 -7
  41. package/dist/commands/agent/contribute-cli.js +17 -4
  42. package/dist/commands/config-cli.js +18 -2
  43. package/dist/commands/env/child-env.js +47 -0
  44. package/dist/commands/env/env-cli.js +33 -26
  45. package/dist/commands/env/secret-cli.js +36 -22
  46. package/dist/commands/feedback-cli.js +15 -6
  47. package/dist/commands/graph/graph-cli.js +5 -13
  48. package/dist/commands/graph/graph.js +76 -72
  49. package/dist/commands/health/checks.js +49 -1
  50. package/dist/commands/health/html-report.js +422 -80
  51. package/dist/commands/health.js +386 -9
  52. package/dist/commands/improve/calibration.js +161 -0
  53. package/dist/commands/improve/consolidate/chunking.js +141 -0
  54. package/dist/commands/improve/consolidate/eligibility.js +81 -0
  55. package/dist/commands/improve/consolidate/merge.js +145 -0
  56. package/dist/commands/improve/consolidate/sanitize.js +231 -0
  57. package/dist/commands/{lint.js → improve/consolidate/types.js} +1 -1
  58. package/dist/commands/improve/consolidate.js +635 -660
  59. package/dist/commands/improve/dedup.js +482 -0
  60. package/dist/commands/improve/distill.js +159 -69
  61. package/dist/commands/improve/eligibility.js +434 -0
  62. package/dist/commands/improve/encoding-salience.js +205 -0
  63. package/dist/commands/improve/extract-cli.js +124 -2
  64. package/dist/commands/improve/extract-prompt.js +39 -2
  65. package/dist/commands/improve/extract-watch.js +140 -0
  66. package/dist/commands/improve/extract.js +389 -40
  67. package/dist/commands/improve/feedback-valence.js +54 -0
  68. package/dist/commands/improve/homeostatic.js +467 -0
  69. package/dist/commands/improve/improve-auto-accept.js +138 -7
  70. package/dist/commands/improve/improve-cli.js +36 -61
  71. package/dist/commands/improve/improve-profiles.js +14 -0
  72. package/dist/commands/improve/improve-result-file.js +14 -25
  73. package/dist/commands/improve/improve-session.js +58 -0
  74. package/dist/commands/improve/improve.js +485 -2498
  75. package/dist/commands/improve/locks.js +154 -0
  76. package/dist/commands/improve/loop-stages.js +1083 -0
  77. package/dist/commands/improve/memory/memory-contradiction-detect.js +23 -28
  78. package/dist/commands/improve/outcome-loop.js +256 -0
  79. package/dist/commands/improve/preparation.js +1966 -0
  80. package/dist/commands/improve/proactive-maintenance.js +115 -0
  81. package/dist/commands/improve/procedural.js +418 -0
  82. package/dist/commands/improve/recombine.js +850 -0
  83. package/dist/commands/improve/reflect-noise.js +0 -0
  84. package/dist/commands/improve/reflect.js +183 -40
  85. package/dist/commands/improve/salience.js +438 -0
  86. package/dist/commands/improve/triage.js +93 -0
  87. package/dist/commands/lint/agent-linter.js +19 -24
  88. package/dist/commands/lint/base-linter.js +173 -60
  89. package/dist/commands/lint/command-linter.js +19 -24
  90. package/dist/commands/lint/env-key-rules.js +38 -1
  91. package/dist/commands/lint/fact-linter.js +39 -0
  92. package/dist/commands/lint/index.js +31 -13
  93. package/dist/commands/lint/memory-linter.js +1 -1
  94. package/dist/commands/lint/registry.js +7 -2
  95. package/dist/commands/lint/task-linter.js +3 -3
  96. package/dist/commands/lint/workflow-linter.js +26 -1
  97. package/dist/commands/proposal/drain-policies.js +5 -0
  98. package/dist/commands/proposal/drain.js +43 -50
  99. package/dist/commands/proposal/proposal-cli.js +21 -31
  100. package/dist/commands/proposal/proposal.js +5 -0
  101. package/dist/commands/proposal/propose.js +7 -2
  102. package/dist/commands/proposal/validators/proposal-quality-validators.js +9 -8
  103. package/dist/commands/proposal/validators/proposals.js +189 -63
  104. package/dist/commands/read/curate.js +414 -94
  105. package/dist/commands/read/knowledge.js +6 -3
  106. package/dist/commands/read/search-cli.js +9 -4
  107. package/dist/commands/read/search.js +10 -6
  108. package/dist/commands/read/show.js +86 -7
  109. package/dist/commands/sources/init.js +49 -17
  110. package/dist/commands/sources/installed-stashes.js +11 -3
  111. package/dist/commands/sources/schema-repair.js +43 -45
  112. package/dist/commands/sources/self-update.js +2 -2
  113. package/dist/commands/sources/source-add.js +7 -3
  114. package/dist/commands/sources/stash-cli.js +28 -40
  115. package/dist/commands/sources/stash-skeleton.js +23 -8
  116. package/dist/commands/tasks/tasks-cli.js +19 -27
  117. package/dist/commands/tasks/tasks.js +39 -11
  118. package/dist/commands/wiki-cli.js +21 -35
  119. package/dist/core/asset/asset-registry.js +3 -1
  120. package/dist/core/asset/asset-spec.js +18 -2
  121. package/dist/core/asset/frontmatter.js +166 -167
  122. package/dist/core/asset/markdown.js +8 -0
  123. package/dist/core/authoring-rules.js +92 -0
  124. package/dist/core/common.js +0 -5
  125. package/dist/core/config/config-migration.js +12 -11
  126. package/dist/core/config/config-schema.js +340 -56
  127. package/dist/core/config/config-types.js +3 -3
  128. package/dist/core/config/config.js +28 -7
  129. package/dist/core/events.js +3 -7
  130. package/dist/core/improve-types.js +11 -8
  131. package/dist/core/logs-db.js +10 -66
  132. package/dist/core/parse.js +36 -16
  133. package/dist/core/paths.js +3 -0
  134. package/dist/core/standards/resolve-standards-context.js +87 -0
  135. package/dist/core/standards/resolve-stash-standards.js +99 -0
  136. package/dist/core/standards/resolve-type-conventions.js +66 -0
  137. package/dist/core/state/migrations.js +714 -0
  138. package/dist/core/state-db.js +525 -474
  139. package/dist/indexer/db/db.js +439 -247
  140. package/dist/indexer/db/graph-db.js +129 -86
  141. package/dist/indexer/ensure-index.js +152 -17
  142. package/dist/indexer/graph/graph-boost.js +51 -41
  143. package/dist/indexer/graph/graph-extraction.js +218 -4
  144. package/dist/indexer/index-writer-lock.js +99 -0
  145. package/dist/indexer/indexer.js +123 -221
  146. package/dist/indexer/passes/dir-staleness.js +114 -0
  147. package/dist/indexer/passes/memory-inference.js +13 -5
  148. package/dist/indexer/passes/staleness-detect.js +2 -5
  149. package/dist/indexer/search/db-search.js +19 -6
  150. package/dist/indexer/search/ranking-contributors.js +22 -0
  151. package/dist/indexer/search/ranking.js +4 -0
  152. package/dist/indexer/search/search-source.js +17 -18
  153. package/dist/indexer/search/semantic-status.js +4 -0
  154. package/dist/indexer/walk/matchers.js +9 -0
  155. package/dist/integrations/agent/config.js +6 -53
  156. package/dist/integrations/agent/index.js +2 -18
  157. package/dist/integrations/agent/prompts.js +75 -9
  158. package/dist/integrations/agent/runner-dispatch.js +59 -0
  159. package/dist/integrations/harnesses/claude/session-log.js +11 -1
  160. package/dist/integrations/harnesses/index.js +2 -3
  161. package/dist/integrations/harnesses/opencode/session-log.js +173 -3
  162. package/dist/integrations/harnesses/opencode-sdk/index.js +2 -2
  163. package/dist/integrations/harnesses/opencode-sdk/sdk-runner.js +0 -2
  164. package/dist/integrations/session-logs/index.js +16 -0
  165. package/dist/llm/client.js +45 -15
  166. package/dist/llm/embedder.js +42 -3
  167. package/dist/llm/embedders/deterministic.js +66 -0
  168. package/dist/llm/embedders/local.js +66 -2
  169. package/dist/llm/feature-gate.js +8 -4
  170. package/dist/llm/graph-extract.js +67 -44
  171. package/dist/llm/memory-infer-impl.js +138 -0
  172. package/dist/llm/memory-infer.js +1 -127
  173. package/dist/llm/metadata-enhance.js +44 -31
  174. package/dist/llm/structured-call.js +49 -0
  175. package/dist/migrate-storage-node.mjs +8 -0
  176. package/dist/output/context.js +5 -5
  177. package/dist/output/renderers.js +74 -2
  178. package/dist/output/shapes/curate.js +14 -2
  179. package/dist/output/shapes/passthrough.js +0 -1
  180. package/dist/output/text/helpers.js +16 -1
  181. package/dist/registry/providers/skills-sh.js +21 -147
  182. package/dist/registry/providers/static-index.js +15 -157
  183. package/dist/registry/resolve.js +22 -9
  184. package/dist/runtime.js +25 -1
  185. package/dist/scripts/migrate-storage.js +2617 -1961
  186. package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +759 -510
  187. package/dist/setup/setup.js +29 -8
  188. package/dist/sources/include.js +6 -2
  189. package/dist/sources/providers/filesystem.js +0 -1
  190. package/dist/sources/providers/git-install.js +210 -0
  191. package/dist/sources/providers/git-provider.js +234 -0
  192. package/dist/sources/providers/git-stash.js +248 -0
  193. package/dist/sources/providers/git.js +10 -661
  194. package/dist/sources/providers/npm.js +2 -6
  195. package/dist/sources/providers/provider-utils.js +13 -7
  196. package/dist/sources/providers/sync-from-ref.js +9 -1
  197. package/dist/sources/providers/tar-utils.js +16 -8
  198. package/dist/sources/providers/website.js +9 -5
  199. package/dist/sources/website-ingest.js +187 -29
  200. package/dist/sources/wiki-fetchers/registry.js +53 -0
  201. package/dist/sources/wiki-fetchers/youtube.js +239 -0
  202. package/dist/storage/database.js +45 -10
  203. package/dist/storage/managed-db.js +82 -0
  204. package/dist/storage/repositories/registry-cache.js +92 -0
  205. package/dist/storage/sqlite-pragmas.js +146 -0
  206. package/dist/tasks/backends/cron.js +1 -1
  207. package/dist/tasks/backends/launchd.js +1 -1
  208. package/dist/tasks/backends/schtasks.js +1 -1
  209. package/dist/tasks/{resolveAkmBin.js → resolve-akm-bin.js} +2 -2
  210. package/dist/tasks/runner.js +5 -13
  211. package/dist/text-import-hook.mjs +0 -0
  212. package/dist/wiki/wiki.js +37 -0
  213. package/dist/workflows/db.js +3 -4
  214. package/dist/workflows/runtime/runs.js +1 -117
  215. package/dist/workflows/runtime/workflow-asset-loader.js +125 -0
  216. package/dist/workflows/validate-summary.js +2 -7
  217. package/docs/data-and-telemetry.md +3 -2
  218. package/docs/migration/release-notes/0.9.0.md +39 -0
  219. package/package.json +13 -11
  220. package/dist/commands/db-cli.js +0 -23
  221. package/dist/indexer/db/db-backup.js +0 -376
@@ -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,146 @@ 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
+ }
170
+ /**
171
+ * #624-P3 — enqueue a file for lazy graph extraction. Idempotent on the
172
+ * (stash_root, file_path) PK: a second enqueue refreshes body_hash + queued_at
173
+ * and keeps the HIGHER priority. Non-blocking, no LLM call — the queued row is
174
+ * drained later by the graph-extraction pass. Tolerant of a missing table /
175
+ * db error (best-effort), but never masks the bun-test isolation guard.
176
+ */
177
+ export function enqueueGraphExtraction(db, stashRoot, filePath, bodyHash, priority = 0) {
178
+ try {
179
+ db.prepare(`INSERT INTO graph_extraction_queue (stash_root, file_path, body_hash, priority)
180
+ VALUES (?, ?, ?, ?)
181
+ ON CONFLICT(stash_root, file_path) DO UPDATE SET
182
+ body_hash = excluded.body_hash,
183
+ priority = MAX(graph_extraction_queue.priority, excluded.priority),
184
+ queued_at = datetime('now')`).run(stashRoot, filePath, bodyHash, priority);
185
+ }
186
+ catch (err) {
187
+ rethrowIfTestIsolationError(err);
188
+ }
189
+ }
190
+ /**
191
+ * #624-P3 — drain up to `limit` queued files for a stash, highest-priority
192
+ * first (then oldest queued_at). The returned rows are DELETED from the queue
193
+ * in the SAME transaction (SELECT-then-DELETE-by-PK), so a drain is exactly
194
+ * once. Tolerant of a missing table / db error (returns []), but never masks
195
+ * the bun-test isolation guard.
196
+ */
197
+ export function drainExtractionQueue(db, stashRoot, limit) {
198
+ try {
199
+ return db.transaction(() => {
200
+ const rows = db
201
+ .prepare(`SELECT file_path, body_hash, priority
202
+ FROM graph_extraction_queue
203
+ WHERE stash_root = ?
204
+ ORDER BY priority DESC, queued_at ASC
205
+ LIMIT ?`)
206
+ .all(stashRoot, limit);
207
+ const del = db.prepare("DELETE FROM graph_extraction_queue WHERE stash_root = ? AND file_path = ?");
208
+ for (const row of rows)
209
+ del.run(stashRoot, row.file_path);
210
+ return rows.map((row) => ({ filePath: row.file_path, bodyHash: row.body_hash, priority: row.priority }));
211
+ })();
212
+ }
213
+ catch (err) {
214
+ rethrowIfTestIsolationError(err);
215
+ return [];
216
+ }
217
+ }
182
218
  /**
183
219
  * Scoped loader — only the graph_meta row for a stash. Used by callers that
184
220
  * only need summary numbers (e.g. `akm graph summary`).
@@ -195,13 +231,12 @@ export function loadGraphFilesOnly(stashPath, db) {
195
231
  return withReadableGraphDb(db, (readDb) => {
196
232
  try {
197
233
  const rows = readDb
198
- .prepare(`SELECT entry_id, file_path, file_type, body_hash, confidence, status, reason
234
+ .prepare(`SELECT file_path, file_type, body_hash, confidence, status, reason
199
235
  FROM graph_files
200
236
  WHERE stash_root = ?
201
237
  ORDER BY file_order`)
202
238
  .all(stashPath);
203
239
  return rows.map((row) => ({
204
- entryId: row.entry_id,
205
240
  path: row.file_path,
206
241
  type: row.file_type,
207
242
  bodyHash: row.body_hash,
@@ -222,13 +257,16 @@ export function loadGraphFilesOnly(stashPath, db) {
222
257
  }
223
258
  }
224
259
  /**
225
- * Scoped loader — entities for a single entry_id. Used by per-asset lookups.
260
+ * Scoped loader — entities for a single file, keyed on the #624-P1 composite
261
+ * (stash_root, file_path, body_hash). Used by per-asset show/curate lookups.
226
262
  */
227
- export function loadGraphEntitiesByEntry(db, entryId) {
263
+ export function loadGraphEntitiesByPath(db, stashRoot, filePath, bodyHash) {
228
264
  try {
229
265
  const rows = db
230
- .prepare("SELECT entity FROM graph_file_entities WHERE entry_id = ? ORDER BY entity_order")
231
- .all(entryId);
266
+ .prepare(`SELECT entity FROM graph_file_entities
267
+ WHERE stash_root = ? AND file_path = ? AND body_hash = ?
268
+ ORDER BY entity_order`)
269
+ .all(stashRoot, filePath, bodyHash);
232
270
  return rows.map((r) => r.entity);
233
271
  }
234
272
  catch {
@@ -314,27 +352,32 @@ export function loadStoredGraphSnapshot(stashPath, db) {
314
352
  return null;
315
353
  try {
316
354
  const fileRows = readDb
317
- .prepare(`SELECT entry_id, file_path, file_type, body_hash, confidence, status, reason, extraction_run_id
355
+ .prepare(`SELECT file_path, file_type, body_hash, confidence, status, reason, extraction_run_id
318
356
  FROM graph_files
319
357
  WHERE stash_root = ?
320
358
  ORDER BY file_order`)
321
359
  .all(stashPath);
322
360
  const entityRows = readDb
323
- .prepare(`SELECT gfe.entry_id AS entry_id, gf.file_path AS file_path, gfe.entity AS entity
361
+ .prepare(`SELECT gf.file_path AS file_path, gfe.entity AS entity
324
362
  FROM graph_file_entities gfe
325
- JOIN graph_files gf ON gf.entry_id = gfe.entry_id
363
+ JOIN graph_files gf
364
+ ON gf.stash_root = gfe.stash_root
365
+ AND gf.file_path = gfe.file_path
366
+ AND gf.body_hash = gfe.body_hash
326
367
  WHERE gf.stash_root = ?
327
368
  ORDER BY gf.file_order, gfe.entity_order`)
328
369
  .all(stashPath);
329
370
  const relationRows = readDb
330
- .prepare(`SELECT gfr.entry_id AS entry_id,
331
- gf.file_path AS file_path,
371
+ .prepare(`SELECT gf.file_path AS file_path,
332
372
  gfr.from_entity AS from_entity,
333
373
  gfr.to_entity AS to_entity,
334
374
  gfr.relation_type AS relation_type,
335
375
  gfr.confidence AS confidence
336
376
  FROM graph_file_relations gfr
337
- JOIN graph_files gf ON gf.entry_id = gfr.entry_id
377
+ JOIN graph_files gf
378
+ ON gf.stash_root = gfr.stash_root
379
+ AND gf.file_path = gfr.file_path
380
+ AND gf.body_hash = gfr.body_hash
338
381
  WHERE gf.stash_root = ?
339
382
  ORDER BY gf.file_order, gfr.relation_order`)
340
383
  .all(stashPath);
@@ -11,12 +11,14 @@
11
11
  * `searchLocal()` and `show.ts`, centralizing the "indexed yet?" gap handling
12
12
  * behind a single entry point.
13
13
  */
14
+ import { spawn } from "node:child_process";
14
15
  import fs from "node:fs";
15
16
  import path from "node:path";
16
17
  import { ASSET_SPECS, TYPE_DIRS } from "../core/asset/asset-spec.js";
17
- import { getDbPath } from "../core/paths.js";
18
+ import { getDataDir, getDbPath } from "../core/paths.js";
18
19
  import { warn } from "../core/warn.js";
19
- import { closeDatabase, getEntryCount, getMeta, openExistingDatabase } from "./db/db.js";
20
+ import { closeDatabase, getEntryCount, getIndexedFilePaths, getMeta, openExistingDatabase } from "./db/db.js";
21
+ import { acquireIndexWriterLease, handoffIndexWriterLeaseToPid } from "./index-writer-lock.js";
20
22
  function getIndexableFiles(root, spec) {
21
23
  if (!fs.existsSync(root))
22
24
  return [];
@@ -52,16 +54,34 @@ function getIndexableFiles(root, spec) {
52
54
  }
53
55
  return files;
54
56
  }
55
- function hasNewerIndexableFiles(stashDir, builtAt) {
56
- if (!builtAt)
57
- return true;
58
- const builtAtMs = new Date(builtAt).getTime();
59
- if (!Number.isFinite(builtAtMs))
60
- return true;
57
+ /**
58
+ * Whether any indexable file under `stashDir` is newer than the last build, or
59
+ * has never been indexed at all.
60
+ *
61
+ * Two independent signals, because neither alone is sufficient:
62
+ * 1. **mtime > builtAt** — catches in-place *edits* of already-indexed files.
63
+ * 2. **path not in `indexedPaths`** — catches *newly added* files. This is
64
+ * clock-independent on purpose: a freshly-written file can have a
65
+ * filesystem mtime that compares as *older* than the wall-clock `builtAt`
66
+ * (the two clocks are not perfectly synchronized and `builtAt` is
67
+ * millisecond-truncated), so the mtime test alone silently misses
68
+ * additions made within ~a millisecond of the previous build.
69
+ *
70
+ * `getIndexableFiles` applies each asset type's own relevance filter, so
71
+ * non-indexed companion files (e.g. `package.json` next to a knowledge doc) are
72
+ * never considered and do not produce false "new file" positives.
73
+ */
74
+ function hasNewerIndexableFiles(stashDir, builtAt, indexedPaths) {
75
+ const builtAtMs = builtAt ? new Date(builtAt).getTime() : Number.NaN;
76
+ const builtAtUsable = Number.isFinite(builtAtMs);
61
77
  for (const [type, spec] of Object.entries(ASSET_SPECS)) {
62
78
  const typeRoot = path.join(stashDir, TYPE_DIRS[type] ?? spec.stashDir);
63
79
  const files = getIndexableFiles(typeRoot, spec);
64
80
  for (const file of files) {
81
+ if (!indexedPaths.has(file))
82
+ return true;
83
+ if (!builtAtUsable)
84
+ return true;
65
85
  try {
66
86
  if (fs.statSync(file).mtimeMs > builtAtMs)
67
87
  return true;
@@ -89,7 +109,7 @@ export function isIndexStale(stashDir) {
89
109
  if (entryCount === 0)
90
110
  return true;
91
111
  const builtAt = getMeta(db, "builtAt");
92
- if (hasNewerIndexableFiles(stashDir, builtAt))
112
+ if (hasNewerIndexableFiles(stashDir, builtAt, getIndexedFilePaths(db)))
93
113
  return true;
94
114
  const storedStashDir = getMeta(db, "stashDir");
95
115
  if (storedStashDir !== stashDir) {
@@ -114,16 +134,84 @@ export function isIndexStale(stashDir) {
114
134
  }
115
135
  }
116
136
  /**
117
- * Run an incremental index when the local index is stale. Best-effort
118
- * failures are logged as warnings but never thrown, so the caller can
119
- * proceed (and surface a proper "not in index" error if the index is
120
- * still unusable).
121
- *
122
- * Returns `true` if an index run was attempted.
137
+ * Whether the existing index can serve queries for `stashDir` *right now*
138
+ * i.e. the DB file exists, the `entries` table holds rows, and those rows were
139
+ * built for this stash (it is the stored primary stash or appears in the
140
+ * stored `stashDirs` set). When this is true the index is at worst
141
+ * content-stale, so the `#607` background-reindex optimization is safe: the
142
+ * caller gets slightly-stale-but-relevant results immediately. When it is
143
+ * false the existing index has nothing relevant to return (no DB, no `entries`
144
+ * table, zero rows, or built for a different stash), so a background reindex
145
+ * would leave the caller empty until the next read — those cases must rebuild
146
+ * inline.
123
147
  */
124
- export async function ensureIndex(stashDir) {
125
- if (!isIndexStale(stashDir))
148
+ function indexCanServeStash(stashDir) {
149
+ const dbPath = getDbPath();
150
+ if (!fs.existsSync(dbPath))
151
+ return false;
152
+ let db;
153
+ try {
154
+ db = openExistingDatabase(dbPath);
155
+ if (getEntryCount(db) === 0)
156
+ return false;
157
+ const storedStashDir = getMeta(db, "stashDir");
158
+ if (storedStashDir === stashDir)
159
+ return true;
160
+ try {
161
+ const storedDirs = JSON.parse(getMeta(db, "stashDirs") ?? "[]");
162
+ return storedDirs.includes(stashDir);
163
+ }
164
+ catch {
165
+ return false;
166
+ }
167
+ }
168
+ catch {
169
+ // No `entries` table (or otherwise unreadable) — cannot serve.
126
170
  return false;
171
+ }
172
+ finally {
173
+ if (db)
174
+ closeDatabase(db);
175
+ }
176
+ }
177
+ /**
178
+ * Spawn a background `akm index` process. Non-blocking — returns immediately.
179
+ * Background callers share the same global index-writer lease as foreground
180
+ * writers, so stale-read-triggered auto-index attempts coalesce safely.
181
+ */
182
+ async function spawnBackgroundReindex(_stashDir) {
183
+ const dataDir = getDataDir();
184
+ const logFile = path.join(dataDir, "logs", "index-background.log");
185
+ fs.mkdirSync(path.dirname(logFile), { recursive: true });
186
+ const lease = await acquireIndexWriterLease({ mode: "try", purpose: "background-reindex-spawn" });
187
+ if (!lease)
188
+ return;
189
+ const akmBin = process.argv[0];
190
+ const akmScript = process.argv[1];
191
+ try {
192
+ const child = spawn(akmBin, [akmScript, "index", "--background"], {
193
+ detached: true,
194
+ stdio: ["ignore", fs.openSync(logFile, "a"), fs.openSync(logFile, "a")],
195
+ env: { ...process.env },
196
+ });
197
+ if (!child.pid) {
198
+ lease.release();
199
+ return;
200
+ }
201
+ handoffIndexWriterLeaseToPid(lease, child.pid, "background-reindex");
202
+ try {
203
+ child.unref();
204
+ }
205
+ catch {
206
+ // ignore
207
+ }
208
+ }
209
+ catch (error) {
210
+ lease.release();
211
+ throw error;
212
+ }
213
+ }
214
+ async function runInlineReindex(stashDir) {
127
215
  try {
128
216
  const { akmIndex } = await import("./indexer.js");
129
217
  await akmIndex({ stashDir });
@@ -134,3 +222,50 @@ export async function ensureIndex(stashDir) {
134
222
  return true;
135
223
  }
136
224
  }
225
+ /**
226
+ * Ensure the local index exists and is fresh enough for the caller's needs.
227
+ *
228
+ * Default mode is `background`, which preserves the low-latency behavior used
229
+ * by read paths (`search`, `show`, `feedback`): when a populated index is
230
+ * merely stale, spawn a detached reindex and proceed against the existing
231
+ * index. When the index is entirely absent (no DB / no `entries` table / zero
232
+ * rows) the rebuild runs inline regardless of mode, since there is nothing to
233
+ * proceed against.
234
+ *
235
+ * `mode: "blocking"` waits for the rebuild to finish before returning. Use
236
+ * this for callers like `improve` whose planning logic depends on a populated
237
+ * `entries` table in the same process.
238
+ *
239
+ * Returns `true` if an index run was attempted.
240
+ */
241
+ export async function ensureIndex(stashDir, options = {}) {
242
+ if (!isIndexStale(stashDir))
243
+ return false;
244
+ // Blocking when explicitly requested, or whenever the existing index cannot
245
+ // serve this stash (absent DB, no `entries` table, zero rows, or built for a
246
+ // different stash): a background reindex returns immediately and would leave
247
+ // a first-time caller (search, curate, wiki, show, feedback) with empty
248
+ // results. Building inline is a one-off cost; a populated index for this
249
+ // stash that is merely content-stale still refreshes in the background.
250
+ if (options.mode === "blocking" || !indexCanServeStash(stashDir)) {
251
+ return runInlineReindex(stashDir);
252
+ }
253
+ // The background path re-invokes the akm CLI as a detached child via
254
+ // `process.argv[1]`. That is only the akm entrypoint when THIS process is the
255
+ // akm CLI itself — which the CLI startup block signals with AKM_CLI_ENTRY=1.
256
+ // In any other host (the in-process test runner, a library embedding akm),
257
+ // argv[1] points at the host (e.g. the test runner), so spawning it would
258
+ // launch the wrong program and orphan it. Build inline there instead — same
259
+ // resulting index, no detached process.
260
+ if (process.env.AKM_CLI_ENTRY !== "1") {
261
+ return runInlineReindex(stashDir);
262
+ }
263
+ try {
264
+ await spawnBackgroundReindex(stashDir);
265
+ return true;
266
+ }
267
+ catch (error) {
268
+ warn("Background reindex spawn failed, proceeding with existing index:", error instanceof Error ? error.message : String(error));
269
+ return true;
270
+ }
271
+ }