akm-cli 0.9.0-beta.9 → 0.9.0-rc.1

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 (381) hide show
  1. package/CHANGELOG.md +715 -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/help/help-improve.md +9 -6
  6. package/dist/assets/hints/cli-hints-full.md +6 -5
  7. package/dist/assets/profiles/default.json +9 -4
  8. package/dist/assets/profiles/frequent.json +1 -1
  9. package/dist/assets/profiles/memory-focus.json +1 -1
  10. package/dist/assets/profiles/proactive-maintenance.json +25 -0
  11. package/dist/assets/profiles/quick.json +1 -1
  12. package/dist/assets/profiles/recombine-only.json +21 -0
  13. package/dist/assets/profiles/reflect-distill.json +30 -0
  14. package/dist/assets/profiles/synthesize.json +15 -0
  15. package/dist/assets/profiles/thorough.json +1 -1
  16. package/dist/assets/prompts/consolidate-system.md +23 -0
  17. package/dist/assets/prompts/contradiction-judge.md +33 -0
  18. package/dist/assets/prompts/distill-knowledge-system.md +22 -0
  19. package/dist/assets/prompts/distill-lesson-system.md +36 -0
  20. package/dist/assets/prompts/extract-session.md +11 -3
  21. package/dist/assets/prompts/graph-extract-system.md +1 -0
  22. package/dist/assets/prompts/graph-extract-user-prompt.md +1 -1
  23. package/dist/assets/prompts/memory-infer-system.md +1 -0
  24. package/dist/assets/prompts/memory-infer-user.md +5 -0
  25. package/dist/assets/prompts/metadata-enhance-system.md +1 -0
  26. package/dist/assets/prompts/procedural-system.md +44 -0
  27. package/dist/assets/prompts/recombine-system.md +40 -0
  28. package/dist/assets/prompts/staleness-detect-system.md +6 -0
  29. package/dist/assets/prompts/validate-summary-judge.md +1 -0
  30. package/dist/assets/prompts/workflow-unit-preamble.md +26 -0
  31. package/dist/assets/stash-skeleton/facts/conventions/assets/agent.md +38 -0
  32. package/dist/assets/stash-skeleton/facts/conventions/assets/command.md +38 -0
  33. package/dist/assets/stash-skeleton/facts/conventions/assets/fact.md +39 -0
  34. package/dist/assets/stash-skeleton/facts/conventions/assets/knowledge.md +40 -0
  35. package/dist/assets/stash-skeleton/facts/conventions/assets/lesson.md +43 -0
  36. package/dist/assets/stash-skeleton/facts/conventions/assets/memory.md +38 -0
  37. package/dist/assets/stash-skeleton/facts/conventions/assets/script.md +43 -0
  38. package/dist/assets/stash-skeleton/facts/conventions/assets/skill.md +40 -0
  39. package/dist/assets/stash-skeleton/facts/conventions/assets/workflow.md +43 -0
  40. package/dist/assets/templates/html/health.html +281 -111
  41. package/dist/assets/wiki/ingest-workflow-template.md +45 -16
  42. package/dist/assets/wiki/schema-template.md +4 -4
  43. package/dist/cli/clack.js +56 -0
  44. package/dist/cli/config-migrate.js +7 -1
  45. package/dist/cli/confirm.js +1 -1
  46. package/dist/cli/parse-args.js +46 -1
  47. package/dist/cli/shared.js +28 -0
  48. package/dist/cli.js +25 -21
  49. package/dist/commands/agent/agent-dispatch.js +3 -2
  50. package/dist/commands/agent/agent-support.js +0 -7
  51. package/dist/commands/agent/contribute-cli.js +26 -7
  52. package/dist/commands/config-cli.js +26 -13
  53. package/dist/commands/env/child-env.js +47 -0
  54. package/dist/commands/env/env-binding.js +95 -0
  55. package/dist/commands/env/env-cli.js +228 -292
  56. package/dist/commands/env/env.js +14 -67
  57. package/dist/commands/env/secret-cli.js +140 -138
  58. package/dist/commands/feedback-cli.js +156 -155
  59. package/dist/commands/graph/graph-cli.js +5 -13
  60. package/dist/commands/graph/graph.js +3 -3
  61. package/dist/commands/health/advisories.js +151 -0
  62. package/dist/commands/health/checks.js +103 -16
  63. package/dist/commands/health/html-report.js +447 -81
  64. package/dist/commands/health/improve-metrics.js +771 -0
  65. package/dist/commands/health/llm-usage.js +65 -0
  66. package/dist/commands/health/md-report.js +103 -0
  67. package/dist/commands/health/metrics.js +278 -0
  68. package/dist/commands/health/stash-exposure.js +46 -0
  69. package/dist/commands/health/surfaces.js +216 -0
  70. package/dist/commands/health/task-runs.js +135 -0
  71. package/dist/commands/health/types.js +26 -0
  72. package/dist/commands/health/windows.js +195 -0
  73. package/dist/commands/health.js +91 -1091
  74. package/dist/commands/improve/anti-collapse.js +170 -0
  75. package/dist/commands/improve/calibration.js +161 -0
  76. package/dist/commands/improve/collapse-detector.js +421 -0
  77. package/dist/commands/improve/consolidate/chunking.js +141 -0
  78. package/dist/commands/improve/consolidate/eligibility.js +64 -0
  79. package/dist/commands/improve/consolidate/merge.js +145 -0
  80. package/dist/commands/improve/consolidate/sanitize.js +231 -0
  81. package/dist/commands/{lint.js → improve/consolidate/types.js} +1 -1
  82. package/dist/commands/improve/consolidate.js +1295 -1277
  83. package/dist/commands/improve/dedup.js +482 -0
  84. package/dist/commands/improve/distill/content-repair.js +202 -0
  85. package/dist/commands/improve/distill/promote-memory.js +229 -0
  86. package/dist/commands/improve/distill/quality-gate.js +236 -0
  87. package/dist/commands/improve/distill-guards.js +127 -0
  88. package/dist/commands/improve/distill-promotion-policy.js +826 -167
  89. package/dist/commands/improve/distill.js +228 -605
  90. package/dist/commands/improve/eligibility.js +434 -0
  91. package/dist/commands/improve/encoding-salience.js +205 -0
  92. package/dist/commands/improve/extract-cli.js +179 -59
  93. package/dist/commands/improve/extract-prompt.js +54 -3
  94. package/dist/commands/improve/extract-watch.js +140 -0
  95. package/dist/commands/improve/extract.js +409 -43
  96. package/dist/commands/improve/feedback-valence.js +54 -0
  97. package/dist/commands/improve/hot-probation.js +45 -0
  98. package/dist/commands/improve/improve-auto-accept.js +157 -10
  99. package/dist/commands/improve/improve-cli.js +115 -73
  100. package/dist/commands/improve/improve-profiles.js +28 -8
  101. package/dist/commands/improve/improve-result-file.js +15 -25
  102. package/dist/commands/improve/improve-session.js +58 -0
  103. package/dist/commands/improve/improve.js +485 -2764
  104. package/dist/commands/improve/locks.js +154 -0
  105. package/dist/commands/improve/loop-stages.js +1100 -0
  106. package/dist/commands/improve/memory/memory-belief.js +14 -15
  107. package/dist/commands/improve/memory/memory-contradiction-detect.js +83 -60
  108. package/dist/commands/improve/memory/memory-improve.js +27 -27
  109. package/dist/commands/improve/outcome-loop.js +270 -0
  110. package/dist/commands/improve/preparation.js +2002 -0
  111. package/dist/commands/improve/proactive-maintenance.js +37 -35
  112. package/dist/commands/improve/procedural.js +398 -0
  113. package/dist/commands/improve/recombine.js +818 -0
  114. package/dist/commands/improve/reflect-noise.js +0 -0
  115. package/dist/commands/improve/reflect.js +206 -45
  116. package/dist/commands/improve/salience.js +455 -0
  117. package/dist/commands/improve/schema-similarity-gate.js +168 -0
  118. package/dist/commands/improve/shared.js +51 -0
  119. package/dist/commands/improve/triage.js +93 -0
  120. package/dist/commands/lint/agent-linter.js +19 -24
  121. package/dist/commands/lint/base-linter.js +173 -60
  122. package/dist/commands/lint/command-linter.js +19 -24
  123. package/dist/commands/lint/env-key-rules.js +38 -1
  124. package/dist/commands/lint/fact-linter.js +39 -0
  125. package/dist/commands/lint/index.js +31 -13
  126. package/dist/commands/lint/memory-linter.js +1 -1
  127. package/dist/commands/lint/registry.js +7 -2
  128. package/dist/commands/lint/task-linter.js +3 -3
  129. package/dist/commands/lint/workflow-linter.js +26 -1
  130. package/dist/commands/observability-cli.js +4 -4
  131. package/dist/commands/proposal/drain-policies.js +13 -4
  132. package/dist/commands/proposal/drain.js +45 -51
  133. package/dist/commands/proposal/legacy-import.js +115 -0
  134. package/dist/commands/proposal/proposal-cli.js +24 -34
  135. package/dist/commands/proposal/proposal.js +2 -1
  136. package/dist/commands/proposal/propose.js +8 -3
  137. package/dist/commands/proposal/repository.js +829 -0
  138. package/dist/commands/proposal/validators/proposal-quality-validators.js +9 -8
  139. package/dist/commands/proposal/validators/proposals.js +93 -895
  140. package/dist/commands/read/curate.js +410 -111
  141. package/dist/commands/read/knowledge.js +10 -3
  142. package/dist/commands/read/remember-cli.js +133 -138
  143. package/dist/commands/read/search-cli.js +15 -8
  144. package/dist/commands/read/search.js +22 -11
  145. package/dist/commands/read/show.js +106 -14
  146. package/dist/commands/registry-cli.js +76 -87
  147. package/dist/commands/remember.js +11 -12
  148. package/dist/commands/sources/add-cli.js +91 -95
  149. package/dist/commands/sources/history.js +1 -1
  150. package/dist/commands/sources/init.js +66 -18
  151. package/dist/commands/sources/installed-stashes.js +11 -3
  152. package/dist/commands/sources/migration-help.js +7 -4
  153. package/dist/commands/sources/schema-repair.js +44 -46
  154. package/dist/commands/sources/self-update.js +2 -2
  155. package/dist/commands/sources/source-add.js +7 -3
  156. package/dist/commands/sources/sources-cli.js +3 -3
  157. package/dist/commands/sources/stash-cli.js +19 -39
  158. package/dist/commands/sources/stash-skeleton.js +57 -8
  159. package/dist/commands/tasks/default-tasks.js +15 -2
  160. package/dist/commands/tasks/tasks-cli.js +20 -29
  161. package/dist/commands/tasks/tasks.js +39 -11
  162. package/dist/commands/wiki-cli.js +23 -38
  163. package/dist/commands/workflow-cli.js +291 -13
  164. package/dist/core/asset/asset-registry.js +3 -1
  165. package/dist/core/asset/asset-spec.js +79 -5
  166. package/dist/core/asset/frontmatter.js +188 -167
  167. package/dist/core/asset/markdown.js +8 -0
  168. package/dist/core/authoring-rules.js +92 -0
  169. package/dist/core/common.js +4 -23
  170. package/dist/core/concurrent.js +10 -1
  171. package/dist/core/config/config-io.js +10 -1
  172. package/dist/core/config/config-migration.js +18 -40
  173. package/dist/core/config/config-schema.js +403 -62
  174. package/dist/core/config/config-types.js +3 -3
  175. package/dist/core/config/config.js +67 -22
  176. package/dist/core/deep-merge.js +38 -0
  177. package/dist/core/errors.js +1 -0
  178. package/dist/core/eval/rank-metrics.js +113 -0
  179. package/dist/core/events.js +4 -7
  180. package/dist/core/improve-types.js +47 -8
  181. package/dist/core/json-schema.js +142 -0
  182. package/dist/core/logs-db.js +14 -75
  183. package/dist/core/parse.js +36 -16
  184. package/dist/core/paths.js +18 -18
  185. package/dist/core/standards/resolve-standards-context.js +87 -0
  186. package/dist/core/standards/resolve-stash-standards.js +99 -0
  187. package/dist/core/standards/resolve-type-conventions.js +66 -0
  188. package/dist/core/state/migrations.js +770 -0
  189. package/dist/core/state-db.js +132 -1126
  190. package/dist/core/structured.js +69 -0
  191. package/dist/core/time.js +53 -0
  192. package/dist/core/warn.js +21 -0
  193. package/dist/core/write-source.js +37 -0
  194. package/dist/indexer/db/db.js +261 -770
  195. package/dist/indexer/db/entry-mapper.js +41 -0
  196. package/dist/indexer/db/graph-db.js +129 -86
  197. package/dist/indexer/db/llm-cache.js +2 -2
  198. package/dist/indexer/db/schema.js +516 -0
  199. package/dist/indexer/ensure-index.js +36 -92
  200. package/dist/indexer/feedback/utility-policy.js +75 -0
  201. package/dist/indexer/graph/graph-boost.js +51 -41
  202. package/dist/indexer/graph/graph-extraction.js +207 -4
  203. package/dist/indexer/index-writer-lock.js +18 -11
  204. package/dist/indexer/index-written-assets.js +105 -0
  205. package/dist/indexer/indexer.js +182 -204
  206. package/dist/indexer/passes/dir-staleness.js +114 -0
  207. package/dist/indexer/passes/memory-inference.js +13 -5
  208. package/dist/indexer/passes/metadata.js +20 -0
  209. package/dist/indexer/read-preflight.js +23 -0
  210. package/dist/indexer/search/db-search.js +89 -13
  211. package/dist/indexer/search/fts-query.js +51 -0
  212. package/dist/indexer/search/ranking-contributors.js +95 -9
  213. package/dist/indexer/search/ranking.js +79 -3
  214. package/dist/indexer/search/search-fields.js +6 -0
  215. package/dist/indexer/search/search-source.js +32 -21
  216. package/dist/indexer/search/semantic-status.js +4 -0
  217. package/dist/indexer/walk/matchers.js +48 -0
  218. package/dist/indexer/walk/walker.js +21 -13
  219. package/dist/integrations/agent/builders.js +41 -13
  220. package/dist/integrations/agent/config.js +20 -59
  221. package/dist/integrations/agent/detect.js +9 -0
  222. package/dist/integrations/agent/index.js +3 -19
  223. package/dist/integrations/agent/model-aliases.js +16 -2
  224. package/dist/integrations/agent/profiles.js +79 -6
  225. package/dist/integrations/agent/prompts.js +75 -9
  226. package/dist/integrations/agent/runner-dispatch.js +83 -0
  227. package/dist/integrations/agent/runner.js +13 -9
  228. package/dist/integrations/agent/spawn.js +206 -81
  229. package/dist/integrations/harnesses/aider/agent-builder.js +113 -0
  230. package/dist/integrations/harnesses/aider/index.js +58 -0
  231. package/dist/integrations/harnesses/aider/result-extractor.js +53 -0
  232. package/dist/integrations/harnesses/amazonq/agent-builder.js +153 -0
  233. package/dist/integrations/harnesses/amazonq/index.js +59 -0
  234. package/dist/integrations/harnesses/amazonq/result-extractor.js +48 -0
  235. package/dist/integrations/harnesses/claude/agent-builder.js +46 -7
  236. package/dist/integrations/harnesses/claude/index.js +27 -23
  237. package/dist/integrations/harnesses/claude/result-extractor.js +52 -0
  238. package/dist/integrations/harnesses/claude/session-log.js +10 -0
  239. package/dist/integrations/harnesses/codex/agent-builder.js +137 -0
  240. package/dist/integrations/harnesses/codex/index.js +63 -0
  241. package/dist/integrations/harnesses/codex/result-extractor.js +73 -0
  242. package/dist/integrations/harnesses/copilot/agent-builder.js +122 -0
  243. package/dist/integrations/harnesses/copilot/index.js +60 -0
  244. package/dist/integrations/harnesses/copilot/result-extractor.js +151 -0
  245. package/dist/integrations/harnesses/gemini/agent-builder.js +121 -0
  246. package/dist/integrations/harnesses/gemini/index.js +60 -0
  247. package/dist/integrations/harnesses/gemini/result-extractor.js +121 -0
  248. package/dist/integrations/harnesses/index.js +28 -7
  249. package/dist/integrations/harnesses/opencode/agent-builder.js +1 -1
  250. package/dist/integrations/harnesses/opencode/index.js +17 -16
  251. package/dist/integrations/harnesses/opencode/session-log.js +173 -3
  252. package/dist/integrations/harnesses/opencode-sdk/harness.js +65 -0
  253. package/dist/integrations/harnesses/opencode-sdk/index.js +10 -34
  254. package/dist/integrations/harnesses/opencode-sdk/sdk-runner.js +642 -71
  255. package/dist/integrations/harnesses/openhands/agent-builder.js +126 -0
  256. package/dist/integrations/harnesses/openhands/index.js +58 -0
  257. package/dist/integrations/harnesses/openhands/result-extractor.js +103 -0
  258. package/dist/integrations/harnesses/pi/agent-builder.js +104 -0
  259. package/dist/integrations/harnesses/pi/index.js +58 -0
  260. package/dist/integrations/harnesses/pi/result-extractor.js +135 -0
  261. package/dist/integrations/harnesses/types.js +8 -0
  262. package/dist/integrations/session-logs/index.js +40 -11
  263. package/dist/llm/call-ai.js +2 -2
  264. package/dist/llm/client.js +34 -11
  265. package/dist/llm/embedder.js +67 -4
  266. package/dist/llm/embedders/cache.js +3 -1
  267. package/dist/llm/embedders/deterministic.js +66 -0
  268. package/dist/llm/embedders/local.js +73 -3
  269. package/dist/llm/feature-gate.js +16 -15
  270. package/dist/llm/graph-extract.js +67 -44
  271. package/dist/llm/memory-infer-impl.js +138 -0
  272. package/dist/llm/memory-infer.js +1 -127
  273. package/dist/llm/metadata-enhance.js +44 -31
  274. package/dist/llm/structured-call.js +49 -0
  275. package/dist/migrate-storage-node.mjs +8 -0
  276. package/dist/output/context.js +5 -5
  277. package/dist/output/renderers.js +87 -15
  278. package/dist/output/shapes/curate.js +14 -2
  279. package/dist/output/shapes/helpers.js +0 -3
  280. package/dist/output/shapes/passthrough.js +6 -1
  281. package/dist/output/text/helpers.js +241 -2
  282. package/dist/output/text/workflow.js +4 -1
  283. package/dist/registry/providers/skills-sh.js +21 -147
  284. package/dist/registry/providers/static-index.js +15 -157
  285. package/dist/registry/resolve.js +27 -9
  286. package/dist/runtime.js +25 -1
  287. package/dist/schemas/akm-config.json +14225 -0
  288. package/dist/schemas/akm-workflow.json +328 -0
  289. package/dist/scripts/migrate-storage.js +2743 -8390
  290. package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +1652 -607
  291. package/dist/setup/detect.js +9 -0
  292. package/dist/setup/legacy-config.js +106 -0
  293. package/dist/setup/prompt.js +57 -0
  294. package/dist/setup/providers.js +14 -0
  295. package/dist/setup/registry-stash-loader.js +12 -0
  296. package/dist/setup/semantic-assets.js +124 -0
  297. package/dist/setup/setup.js +52 -1614
  298. package/dist/setup/steps/connection.js +734 -0
  299. package/dist/setup/steps/output.js +31 -0
  300. package/dist/setup/steps/platforms.js +124 -0
  301. package/dist/setup/steps/semantic.js +27 -0
  302. package/dist/setup/steps/sources.js +222 -0
  303. package/dist/setup/steps/stashdir.js +42 -0
  304. package/dist/setup/steps/tasks.js +152 -0
  305. package/dist/sources/include.js +6 -2
  306. package/dist/sources/providers/filesystem.js +0 -1
  307. package/dist/sources/providers/git-install.js +210 -0
  308. package/dist/sources/providers/git-provider.js +234 -0
  309. package/dist/sources/providers/git-stash.js +248 -0
  310. package/dist/sources/providers/git.js +10 -661
  311. package/dist/sources/providers/npm.js +2 -6
  312. package/dist/sources/providers/provider-utils.js +13 -7
  313. package/dist/sources/providers/sync-from-ref.js +9 -1
  314. package/dist/sources/providers/website.js +9 -5
  315. package/dist/sources/website-ingest.js +187 -29
  316. package/dist/sources/wiki-fetchers/registry.js +53 -0
  317. package/dist/sources/wiki-fetchers/youtube.js +239 -0
  318. package/dist/storage/database.js +45 -10
  319. package/dist/storage/managed-db.js +82 -0
  320. package/dist/storage/repositories/canaries-repository.js +107 -0
  321. package/dist/storage/repositories/consolidation-repository.js +38 -0
  322. package/dist/storage/repositories/embeddings-repository.js +72 -0
  323. package/dist/storage/repositories/events-repository.js +187 -0
  324. package/dist/storage/repositories/extract-sessions-repository.js +96 -0
  325. package/dist/storage/repositories/improve-runs-repository.js +146 -0
  326. package/dist/storage/repositories/index-db.js +14 -8
  327. package/dist/storage/repositories/proposals-repository.js +220 -0
  328. package/dist/storage/repositories/recombine-repository.js +213 -0
  329. package/dist/storage/repositories/registry-cache.js +93 -0
  330. package/dist/storage/repositories/registry-index-cache-repository.js +46 -0
  331. package/dist/storage/repositories/task-history-repository.js +93 -0
  332. package/dist/storage/repositories/workflow-runs-repository.js +189 -1
  333. package/dist/storage/sqlite-pragmas.js +146 -0
  334. package/dist/tasks/backends/cron.js +1 -1
  335. package/dist/tasks/backends/index.js +9 -0
  336. package/dist/tasks/backends/launchd.js +1 -1
  337. package/dist/tasks/backends/schtasks.js +1 -1
  338. package/dist/tasks/{resolveAkmBin.js → resolve-akm-bin.js} +2 -2
  339. package/dist/tasks/runner.js +15 -13
  340. package/dist/text-import-hook.mjs +1 -1
  341. package/dist/wiki/wiki.js +52 -11
  342. package/dist/workflows/authoring/authoring.js +123 -10
  343. package/dist/workflows/authoring/workflow-program-template.yaml +31 -0
  344. package/dist/workflows/cli.js +5 -0
  345. package/dist/workflows/db.js +138 -4
  346. package/dist/workflows/exec/brief.js +484 -0
  347. package/dist/workflows/exec/native-executor.js +975 -0
  348. package/dist/workflows/exec/param-secrets.js +115 -0
  349. package/dist/workflows/exec/report.js +1295 -0
  350. package/dist/workflows/exec/run-workflow.js +596 -0
  351. package/dist/workflows/exec/scheduler.js +100 -0
  352. package/dist/workflows/exec/step-work.js +1156 -0
  353. package/dist/workflows/exec/unit-writer.js +23 -0
  354. package/dist/workflows/exec/watch.js +116 -0
  355. package/dist/workflows/exec/worktree.js +171 -0
  356. package/dist/workflows/ir/compile.js +388 -0
  357. package/dist/workflows/ir/params.js +54 -0
  358. package/dist/workflows/ir/plan-hash.js +33 -0
  359. package/dist/workflows/ir/schema.js +4 -0
  360. package/dist/workflows/parser.js +3 -1
  361. package/dist/workflows/program/expressions.js +369 -0
  362. package/dist/workflows/program/parser.js +760 -0
  363. package/dist/workflows/program/project.js +105 -0
  364. package/dist/workflows/program/schema.js +54 -0
  365. package/dist/workflows/renderer.js +82 -5
  366. package/dist/workflows/runtime/agent-identity.js +59 -14
  367. package/dist/workflows/runtime/runs.js +248 -153
  368. package/dist/workflows/runtime/unit-checkin.js +45 -0
  369. package/dist/workflows/runtime/workflow-asset-loader.js +188 -0
  370. package/dist/workflows/validate-summary.js +26 -10
  371. package/dist/workflows/validator.js +1 -1
  372. package/docs/README.md +69 -18
  373. package/docs/data-and-telemetry.md +7 -5
  374. package/docs/migration/release-notes/0.7.0.md +1 -1
  375. package/docs/migration/release-notes/0.9.0-beta.60.md +19 -0
  376. package/docs/migration/release-notes/0.9.0.md +39 -0
  377. package/package.json +10 -10
  378. package/dist/assets/tasks/core/update-stashes.yml +0 -4
  379. package/dist/commands/db-cli.js +0 -23
  380. package/dist/indexer/db/db-backup.js +0 -376
  381. package/dist/indexer/passes/staleness-detect.js +0 -488
@@ -1,47 +1,39 @@
1
1
  // This Source Code Form is subject to the terms of the Mozilla Public
2
2
  // License, v. 2.0. If a copy of the MPL was not distributed with this
3
3
  // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
- import fs from "node:fs";
5
4
  import { createRequire } from "node:module";
6
5
  import path from "node:path";
7
6
  import { parseAssetRef } from "../../core/asset/asset-ref.js";
8
7
  import { bestEffort } from "../../core/best-effort.js";
9
8
  import { getDbPath } from "../../core/paths.js";
10
- import { REGISTRY_INDEX_CACHE_DDL } from "../../core/state-db.js";
11
9
  import { warn } from "../../core/warn.js";
12
10
  import { cosineSimilarity } from "../../llm/embedders/types.js";
13
11
  import { sha256Hex } from "../../runtime.js";
14
- import { openDatabase as openSqlite } from "../../storage/database.js";
12
+ import { openManagedDatabase } from "../../storage/managed-db.js";
13
+ import { computeNextUtility, HIGH_UTILITY_THRESHOLD, UTILITY_REVIEW_THRESHOLD, } from "../feedback/utility-policy.js";
14
+ import { buildPrefixQuery, sanitizeFtsQuery } from "../search/fts-query.js";
15
15
  import { buildSearchFields } from "../search/search-fields.js";
16
- import { ensureUsageEventsSchema } from "../usage/usage-events.js";
17
- import { backupDataDir, EMBEDDING_DIM_CHANGE_REASON } from "./db-backup.js";
18
- // ── Constants ───────────────────────────────────────────────────────────────
19
- export const DB_VERSION = 17;
20
- export const EMBEDDING_DIM = 384;
21
- export const GRAPH_SCHEMA_VERSION = 3;
16
+ import { ENTRY_COLUMNS, rowToIndexedEntry } from "./entry-mapper.js";
17
+ import { ensureSchema } from "./schema.js";
18
+ export { HIGH_UTILITY_THRESHOLD, sanitizeFtsQuery, UTILITY_REVIEW_THRESHOLD };
22
19
  // ── Database lifecycle ──────────────────────────────────────────────────────
23
- export function openDatabase(dbPath, options) {
24
- const resolvedPath = dbPath ?? getDbPath();
25
- const dir = path.dirname(resolvedPath);
26
- if (!fs.existsSync(dir)) {
27
- fs.mkdirSync(dir, { recursive: true });
28
- }
29
- 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");
33
- // Try to load sqlite-vec extension
34
- loadVecExtension(db);
35
- // Dim resolution: explicit option wins; otherwise consult the on-disk
36
- // config so unparameterised opens (registry providers, graph helpers,
37
- // ad-hoc CLI subcommands) honour the operator-declared dimension. Only if
38
- // both are absent do we fall through to the no-clobber path, which keeps
39
- // ensureSchema from touching `index_meta.embeddingDim` at all.
40
- const resolvedDim = options?.embeddingDim ?? resolveConfiguredEmbeddingDim();
41
- ensureSchema(db, resolvedDim, { dataDir: dir });
42
- // Warn once at init if using JS fallback with many entries
43
- warnIfVecMissing(db, { once: true });
44
- return db;
20
+ export function openIndexDatabase(dbPath, options) {
21
+ return openManagedDatabase({
22
+ path: dbPath ?? getDbPath(),
23
+ init: (db) => {
24
+ // Try to load sqlite-vec extension
25
+ loadVecExtension(db);
26
+ // Dim resolution: explicit option wins; otherwise consult the on-disk
27
+ // config so unparameterised opens (registry providers, graph helpers,
28
+ // ad-hoc CLI subcommands) honour the operator-declared dimension. Only if
29
+ // both are absent do we fall through to the no-clobber path, which keeps
30
+ // ensureSchema from touching `index_meta.embeddingDim` at all.
31
+ const resolvedDim = options?.embeddingDim ?? resolveConfiguredEmbeddingDim();
32
+ ensureSchema(db, resolvedDim);
33
+ // Warn once at init if using JS fallback with many entries
34
+ warnIfVecMissing(db, { once: true });
35
+ },
36
+ });
45
37
  }
46
38
  /**
47
39
  * Read the operator-configured embedding dimension from the on-disk config.
@@ -54,7 +46,8 @@ export function openDatabase(dbPath, options) {
54
46
  */
55
47
  function resolveConfiguredEmbeddingDim() {
56
48
  try {
57
- const { loadConfig } = require("../../core/config/config");
49
+ const esmRequire = createRequire(import.meta.url);
50
+ const { loadConfig } = esmRequire("../../core/config/config");
58
51
  const dim = loadConfig().embedding?.dimension;
59
52
  if (typeof dim === "number" && Number.isInteger(dim) && dim > 0 && dim <= 4096) {
60
53
  return dim;
@@ -66,15 +59,10 @@ function resolveConfiguredEmbeddingDim() {
66
59
  }
67
60
  }
68
61
  export function openExistingDatabase(dbPath) {
69
- const resolvedPath = dbPath ?? getDbPath();
70
- 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");
74
62
  // Existing-DB callers must not mutate schema or embedding metadata on open,
75
- // but some paths still need write access to usage_events and other tables.
76
- loadVecExtension(db);
77
- return db;
63
+ // but some paths still need write access to usage_events and other tables
64
+ // so init only loads the vec extension, it does not run ensureSchema.
65
+ return openManagedDatabase({ path: dbPath ?? getDbPath(), init: loadVecExtension });
78
66
  }
79
67
  export function closeDatabase(db) {
80
68
  db.close();
@@ -123,480 +111,25 @@ export function warnIfVecMissing(db, { once } = { once: false }) {
123
111
  }
124
112
  }, "embeddings table may not exist yet during init");
125
113
  }
126
- function ensureSchema(db, embeddingDim, options) {
127
- // Create meta table first so we can check version
128
- db.exec(`
129
- CREATE TABLE IF NOT EXISTS index_meta (
130
- key TEXT PRIMARY KEY,
131
- value TEXT NOT NULL
132
- );
133
- `);
134
- // MVP DB-backup hook (0.8.x): when the stored DB version differs from the
135
- // running binary's DB_VERSION, snapshot the data directory BEFORE
136
- // `handleVersionUpgrade()` drops tables. This is best-effort —
137
- // `backupDataDir` returns null on opt-out, missing data dir, low free
138
- // space, or copy errors, and we proceed with the upgrade in all cases.
139
- // The proper migration framework lands in 0.9.0; until then this lets
140
- // operators recover with `scripts/migrations/restore-data-dir.sh`.
141
- if (options?.dataDir) {
142
- const storedVersionRaw = getMeta(db, "version");
143
- const storedVersion = storedVersionRaw !== undefined && storedVersionRaw !== "" ? Number.parseInt(storedVersionRaw, 10) : null;
144
- const willUpgrade = storedVersionRaw !== undefined && storedVersionRaw !== "" && storedVersionRaw !== String(DB_VERSION);
145
- if (willUpgrade) {
146
- try {
147
- // Pass env explicitly so tests can override AKM_DB_BACKUP / AKM_DB_BACKUP_RETAIN
148
- // without mutating process.env. Production callers default to process.env.
149
- const result = backupDataDir({
150
- dataDir: options.dataDir,
151
- sourceVersion: storedVersion !== null && !Number.isNaN(storedVersion) ? storedVersion : null,
152
- targetVersion: DB_VERSION,
153
- env: process.env,
154
- });
155
- if (result) {
156
- warn("[akm] data directory backed up to %s before v%s→v%d upgrade", result.path, storedVersionRaw, DB_VERSION);
157
- }
158
- }
159
- catch (err) {
160
- // Defensive — backupDataDir already swallows most errors, but if it
161
- // throws for an unexpected reason we must still proceed with the
162
- // upgrade so the user isn't locked out of their binary.
163
- warn("[akm] pre-upgrade data dir backup raised an unexpected error — %s; upgrade will proceed without a snapshot", err instanceof Error ? err.message : String(err));
164
- }
165
- }
166
- }
167
- // Check stored version — if it differs from DB_VERSION, drop and recreate all tables.
168
- // Usage events are preserved across version upgrades so that utility score
169
- // history is not silently lost. The backup is captured here and threaded
170
- // explicitly to `restoreUsageEventsBackup` below — the previous version
171
- // attached `__usageBackup` to the Database instance via a typeless property
172
- // injection, which was a source of fragile coupling.
173
- const usageBackup = handleVersionUpgrade(db);
174
- db.exec(`
175
- CREATE TABLE IF NOT EXISTS entries (
176
- id INTEGER PRIMARY KEY AUTOINCREMENT,
177
- entry_key TEXT NOT NULL UNIQUE,
178
- dir_path TEXT NOT NULL,
179
- file_path TEXT NOT NULL,
180
- stash_dir TEXT NOT NULL,
181
- entry_json TEXT NOT NULL,
182
- search_text TEXT NOT NULL,
183
- entry_type TEXT NOT NULL,
184
- derived_from TEXT
185
- );
186
-
187
- CREATE INDEX IF NOT EXISTS idx_entries_dir ON entries(dir_path);
188
- CREATE INDEX IF NOT EXISTS idx_entries_type ON entries(entry_type);
189
- CREATE INDEX IF NOT EXISTS idx_entries_file_path ON entries(file_path);
190
- `);
191
- // Phase 5A / DB v17: backfill `derived_from` column + index on databases
192
- // that were created at v17 fresh OR carry a partial v17 schema (a DB whose
193
- // `index_meta.version` was bumped to 17 but whose `entries` table still
194
- // lacks the column — this happens when a previous v17 binary opened a
195
- // pre-v17 DB without taking the upgrade path because no version mismatch
196
- // was seen at boot). The PRAGMA-then-ALTER guard runs unconditionally so
197
- // both fresh and partial schemas converge. The CREATE INDEX for
198
- // `derived_from` MUST run after this helper so we never reference a
199
- // column that has not yet been added on partial schemas.
200
- ensureDerivedFromColumn(db);
201
- // Validated WorkflowDocument JSON, one row per indexed workflow entry.
202
- // Pure index data — fully rebuilt on each `akm index`. ON DELETE CASCADE
203
- // means clearing entries (full rebuild or per-dir delete) drops these too.
204
- db.exec(`
205
- CREATE TABLE IF NOT EXISTS workflow_documents (
206
- entry_id INTEGER PRIMARY KEY REFERENCES entries(id) ON DELETE CASCADE,
207
- schema_version INTEGER NOT NULL,
208
- document_json TEXT NOT NULL,
209
- source_path TEXT NOT NULL,
210
- source_hash TEXT NOT NULL,
211
- updated_at TEXT NOT NULL
212
- );
213
-
214
- CREATE INDEX IF NOT EXISTS idx_workflow_documents_source_path
215
- ON workflow_documents(source_path);
216
- `);
217
- // Set version immediately after table creation so a crash before the end of
218
- // ensureSchema() does not leave the database in a versionless state on next open.
219
- const versionAfterCreate = getMeta(db, "version");
220
- if (!versionAfterCreate) {
221
- setMeta(db, "version", String(DB_VERSION));
222
- }
223
- // BLOB-based embedding storage (always available, no sqlite-vec needed)
224
- db.exec(`
225
- CREATE TABLE IF NOT EXISTS embeddings (
226
- id INTEGER PRIMARY KEY,
227
- embedding BLOB NOT NULL,
228
- FOREIGN KEY (id) REFERENCES entries(id)
229
- );
230
- `);
231
- // FTS5 table — multi-column with per-field weighting via bm25()
232
- const ftsExists = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='entries_fts'").get();
233
- if (!ftsExists) {
234
- db.exec(`
235
- CREATE VIRTUAL TABLE entries_fts USING fts5(
236
- entry_id UNINDEXED,
237
- name,
238
- description,
239
- tags,
240
- hints,
241
- content,
242
- tokenize='porter unicode61'
243
- );
244
- `);
245
- }
246
- // Usage events table — created by ensureUsageEventsSchema() at runtime.
247
- // Utility scores table (aggregated per-entry utility metrics)
248
- db.exec(`
249
- CREATE TABLE IF NOT EXISTS utility_scores (
250
- entry_id INTEGER PRIMARY KEY,
251
- utility REAL NOT NULL DEFAULT 0,
252
- show_count INTEGER NOT NULL DEFAULT 0,
253
- search_count INTEGER NOT NULL DEFAULT 0,
254
- select_rate REAL NOT NULL DEFAULT 0,
255
- last_used_at TEXT,
256
- updated_at TEXT NOT NULL DEFAULT (datetime('now')),
257
- FOREIGN KEY (entry_id) REFERENCES entries(id) ON DELETE CASCADE
258
- );
259
- `);
260
- // Per-project scoped utility scores — tracks usage per (entry, cwd-anchor)
261
- // so assets useful in project A don't pollute rankings in project B.
262
- // The global utility_scores table is preserved as a fallback / cold-start aid.
263
- db.exec(`
264
- CREATE TABLE IF NOT EXISTS utility_scores_scoped (
265
- entry_id INTEGER NOT NULL,
266
- scope_key TEXT NOT NULL,
267
- utility REAL NOT NULL DEFAULT 0,
268
- last_used_at INTEGER NOT NULL,
269
- PRIMARY KEY (entry_id, scope_key)
270
- );
271
- CREATE INDEX IF NOT EXISTS idx_utility_scores_scoped_entry_id
272
- ON utility_scores_scoped(entry_id);
273
- `);
274
- db.exec(`
275
- CREATE TABLE IF NOT EXISTS index_dir_state (
276
- dir_path TEXT PRIMARY KEY,
277
- file_set_hash TEXT NOT NULL,
278
- file_mtime_max_ms REAL NOT NULL,
279
- reason TEXT NOT NULL,
280
- updated_at TEXT NOT NULL
281
- );
282
- `);
283
- // LLM enrichment result cache. Stores a SHA-256 body hash and the JSON
284
- // result for each asset so that subsequent `akm index --enrich` runs can
285
- // skip the LLM call when the body hasn't changed. The cache is keyed by
286
- // a stable asset_ref string (e.g. the absolute file path for graph/memory
287
- // passes, or `entryKey:passId` for the metadata-enhance pass).
288
- // Entries are cleaned up when assets are removed or --re-enrich is used.
289
- db.exec(`
290
- CREATE TABLE IF NOT EXISTS llm_enrichment_cache (
291
- asset_ref TEXT NOT NULL,
292
- cache_variant TEXT NOT NULL,
293
- body_hash TEXT NOT NULL,
294
- result_json TEXT NOT NULL,
295
- updated_at INTEGER NOT NULL,
296
- PRIMARY KEY (asset_ref, cache_variant)
297
- );
298
-
299
- CREATE INDEX IF NOT EXISTS idx_llm_cache_updated
300
- ON llm_enrichment_cache(updated_at);
301
- `);
302
- // Graph extraction tables — schema v2 (entry_id PK).
303
- //
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.
308
- //
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.
311
- db.exec(`
312
- CREATE TABLE IF NOT EXISTS graph_meta (
313
- stash_root TEXT PRIMARY KEY,
314
- schema_version INTEGER NOT NULL,
315
- generated_at TEXT NOT NULL,
316
- considered_files INTEGER NOT NULL DEFAULT 0,
317
- extracted_files INTEGER NOT NULL DEFAULT 0,
318
- entity_count INTEGER NOT NULL DEFAULT 0,
319
- relation_count INTEGER NOT NULL DEFAULT 0,
320
- extraction_coverage REAL NOT NULL DEFAULT 0,
321
- density REAL NOT NULL DEFAULT 0,
322
- extractor_id TEXT,
323
- extraction_run_id TEXT,
324
- model TEXT,
325
- prompt_version TEXT,
326
- batch_size INTEGER,
327
- cache_hits INTEGER NOT NULL DEFAULT 0,
328
- cache_misses INTEGER NOT NULL DEFAULT 0,
329
- truncation_count INTEGER NOT NULL DEFAULT 0,
330
- failure_count INTEGER NOT NULL DEFAULT 0
331
- );
332
-
333
- CREATE TABLE IF NOT EXISTS graph_files (
334
- entry_id INTEGER PRIMARY KEY REFERENCES entries(id) ON DELETE CASCADE,
335
- stash_root TEXT NOT NULL,
336
- file_path TEXT NOT NULL,
337
- file_order INTEGER NOT NULL,
338
- file_type TEXT NOT NULL,
339
- body_hash TEXT NOT NULL,
340
- confidence REAL,
341
- status TEXT NOT NULL DEFAULT 'extracted',
342
- reason TEXT,
343
- extraction_run_id TEXT,
344
- UNIQUE(stash_root, file_path)
345
- );
346
-
347
- CREATE INDEX IF NOT EXISTS idx_graph_files_stash_order
348
- ON graph_files(stash_root, file_order);
349
-
350
- 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
- stash_root TEXT NOT NULL,
354
- entity_norm TEXT NOT NULL,
355
- entity TEXT NOT NULL,
356
- PRIMARY KEY (entry_id, entity_order)
357
- );
358
-
359
- CREATE INDEX IF NOT EXISTS idx_graph_file_entities_entity_norm
360
- ON graph_file_entities(stash_root, entity_norm);
361
-
362
- CREATE TABLE IF NOT EXISTS graph_file_relations (
363
- entry_id INTEGER NOT NULL REFERENCES graph_files(entry_id) ON DELETE CASCADE,
364
- relation_order INTEGER NOT NULL,
365
- from_entity_norm TEXT NOT NULL,
366
- from_entity TEXT NOT NULL,
367
- to_entity_norm TEXT NOT NULL,
368
- to_entity TEXT NOT NULL,
369
- relation_type TEXT,
370
- confidence REAL,
371
- PRIMARY KEY (entry_id, relation_order)
372
- );
373
- `);
374
- // FTS-dirty queue. Created here (not lazily on first upsert) so the
375
- // per-entry write path doesn't issue a CREATE TABLE IF NOT EXISTS on
376
- // every call — that DDL would fire thousands of times during a full
377
- // index. See `markFtsDirty` and `rebuildFts({ incremental: true })`.
378
- db.exec(`
379
- CREATE TABLE IF NOT EXISTS entries_fts_dirty (
380
- entry_id INTEGER PRIMARY KEY
381
- );
382
- `);
383
- // sqlite-vec table
384
- //
385
- // Dimension contract:
386
- // - When `embeddingDim` is `undefined`, the caller did NOT request a
387
- // specific dim. Do not touch `index_meta.embeddingDim` and do not run
388
- // the dim-change wipe — fall back to the stored dim (or the static
389
- // default) only when we have to materialise the vec table for the
390
- // first time. Without this guard, registry-side and other dim-unaware
391
- // `openDatabase()` callers would silently overwrite the dim-aware
392
- // improve/index value and oscillate the stored dim.
393
- // - When `embeddingDim` is a number, the caller explicitly asked for
394
- // that dim and owns the dim-change/backup/wipe semantics.
395
- const dimExplicit = embeddingDim !== undefined;
396
- const effectiveDim = embeddingDim ?? (Number(getMeta(db, "embeddingDim")) || EMBEDDING_DIM);
397
- if (isVecAvailable(db)) {
398
- // Check if stored embedding dimension differs from configured one
399
- if (dimExplicit) {
400
- const storedDim = getMeta(db, "embeddingDim");
401
- if (storedDim && storedDim !== String(embeddingDim)) {
402
- // Re-embedding the whole stash is expensive (LLM API calls + cache
403
- // misses), so snapshot the data dir before we drop the vec table and
404
- // wipe `embeddings`. This is the SAME hook the version-upgrade path
405
- // uses earlier in this function, just gated on embedding-dim mismatch
406
- // and tagged so operators can tell the two backup kinds apart.
407
- backupBeforeEmbeddingDimChange(options?.dataDir, storedDim, String(embeddingDim));
408
- bestEffort(() => db.exec("DROP TABLE IF EXISTS entries_vec"), "drop entries_vec on dim change");
409
- // Delete stale BLOB embeddings so they don't produce silently wrong
410
- // similarity scores against the new-dimension vec table.
411
- bestEffort(() => db.exec("DELETE FROM embeddings"), "delete stale embeddings on dim change");
412
- setMeta(db, "hasEmbeddings", "0");
413
- }
414
- }
415
- const vecExists = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='entries_vec'").get();
416
- if (!vecExists) {
417
- if (!Number.isInteger(effectiveDim) || effectiveDim <= 0 || effectiveDim > 4096) {
418
- throw new Error(`Invalid embedding dimension: ${effectiveDim}`);
419
- }
420
- db.exec(`
421
- CREATE VIRTUAL TABLE entries_vec USING vec0(
422
- id INTEGER PRIMARY KEY,
423
- embedding FLOAT[${effectiveDim}]
424
- );
425
- `);
426
- }
427
- if (dimExplicit) {
428
- setMeta(db, "embeddingDim", String(embeddingDim));
429
- }
430
- }
431
- else {
432
- // Also purge BLOB embeddings on dimension change (JS fallback path).
433
- // When sqlite-vec is unavailable, entries_vec doesn't exist but the BLOB
434
- // embeddings table still stores vectors. If the configured dimension
435
- // changes, those stored BLOBs become silently incompatible.
436
- if (dimExplicit) {
437
- const storedDim = getMeta(db, "embeddingDim");
438
- if (storedDim && storedDim !== String(embeddingDim)) {
439
- backupBeforeEmbeddingDimChange(options?.dataDir, storedDim, String(embeddingDim));
440
- bestEffort(() => db.exec("DELETE FROM embeddings"), "delete embeddings on explicit dim change");
441
- setMeta(db, "hasEmbeddings", "0");
442
- }
443
- setMeta(db, "embeddingDim", String(embeddingDim));
444
- }
445
- }
446
- // Usage telemetry table
447
- ensureUsageEventsSchema(db);
448
- // Registry index cache table — caches remote registry index documents so
449
- // `akm search` does not hit the network on every invocation. The DDL is
450
- // defined in state-db.ts and shared here to avoid duplication.
451
- db.exec(REGISTRY_INDEX_CACHE_DDL);
452
- // Restore usage_events backed up by the version-upgrade path above.
453
- restoreUsageEventsBackup(db, usageBackup);
454
- }
455
114
  /**
456
- * Detect a stored DB version that differs from {@link DB_VERSION}, drop the
457
- * old schema, and return a backup of the previous `usage_events` rows so the
458
- * rest of `ensureSchema()` can restore them once the new table exists.
115
+ * Purge stored embeddings (BLOB rows in `embeddings`, plus the `entries_vec`
116
+ * virtual table) and mark the index as embedding-free. The single place that
117
+ * invalidates embeddings used on a dimension change, a model/provider change,
118
+ * and a full rebuild.
459
119
  *
460
- * Returns an empty array when no upgrade is needed or when the previous
461
- * `usage_events` table is unreadable.
462
- */
463
- function handleVersionUpgrade(db) {
464
- const storedVersion = getMeta(db, "version");
465
- // BUG-L4: distinguish "missing" (undefined) from "present but empty" — both
466
- // were previously coerced through `!storedVersion` and treated as "no
467
- // upgrade needed", which caused fresh databases (with no version row) to
468
- // skip the upgrade path correctly, but also caused the upgrade path to be
469
- // taken when a corrupted/empty version string was persisted. The current
470
- // tables get dropped only when the stored version exists AND differs from
471
- // DB_VERSION; missing or empty version means a fresh DB and no upgrade.
472
- if (storedVersion === undefined || storedVersion === "" || storedVersion === String(DB_VERSION))
473
- return [];
474
- let usageBackup = [];
475
- bestEffort(() => {
476
- usageBackup = db.prepare("SELECT * FROM usage_events").all();
477
- }, "usage_events table may not exist in older versions");
478
- db.exec("DROP TABLE IF EXISTS utility_scores");
479
- db.exec("DROP TABLE IF EXISTS utility_scores_scoped");
480
- db.exec("DROP INDEX IF EXISTS idx_utility_scores_scoped_entry_id");
481
- db.exec("DROP TABLE IF EXISTS usage_events");
482
- db.exec("DROP TABLE IF EXISTS embeddings");
483
- db.exec("DROP TABLE IF EXISTS entries_vec");
484
- db.exec("DROP TABLE IF EXISTS entries_fts");
485
- db.exec("DROP TABLE IF EXISTS index_dir_state");
486
- db.exec("DROP TABLE IF EXISTS llm_enrichment_cache");
487
- db.exec("DROP INDEX IF EXISTS idx_llm_cache_updated");
488
- db.exec("DROP TABLE IF EXISTS graph_file_relations");
489
- db.exec("DROP TABLE IF EXISTS graph_file_entities");
490
- db.exec("DROP TABLE IF EXISTS graph_files");
491
- db.exec("DROP TABLE IF EXISTS graph_meta");
492
- db.exec("DROP TABLE IF EXISTS graph_relations");
493
- db.exec("DROP TABLE IF EXISTS graph_entities");
494
- db.exec("DROP TABLE IF EXISTS graph_nodes");
495
- db.exec("DROP TABLE IF EXISTS graph_stashes");
496
- db.exec("DROP INDEX IF EXISTS idx_entries_dir");
497
- db.exec("DROP INDEX IF EXISTS idx_entries_type");
498
- db.exec("DROP TABLE IF EXISTS entries");
499
- db.exec("DELETE FROM index_meta");
500
- warn("[akm] Index rebuilt due to version upgrade. Run 'akm index' to repopulate.");
501
- return usageBackup;
502
- }
503
- /**
504
- * Snapshot the data directory before the embedding-dimension drop path wipes
505
- * `embeddings` and recreates `entries_vec`. Re-embedding a real-world stash
506
- * is expensive (LLM calls + cache misses), so we capture the pre-drop state
507
- * here using the same MVP backup helper the version-upgrade hook uses
508
- * earlier in {@link ensureSchema}.
509
- *
510
- * The backup is tagged with the `embedding-dim-change` reason so it lands in
511
- * `<dataDir>/backups/<timestamp>-embedding-dim-change/` instead of the
512
- * version-upgrade-flavored `<timestamp>-pre-v<N>/` directory. Restoration
513
- * works identically via `scripts/migrations/restore-data-dir.sh`.
120
+ * No backup: embeddings are a derived cache, fully regenerable from the markdown
121
+ * by the next `akm index`. (Recovery model decided 2026-06-25.)
514
122
  *
515
- * Failures are non-fatalthey downgrade to a warning and the destructive
516
- * ops run anyway, matching the version-upgrade hook's behavior so a broken
517
- * backup cannot brick a binary that bumped the configured dim. Likewise,
518
- * `AKM_DB_BACKUP=0` opts out via the same path.
123
+ * `dropVecTable: true` DROPs `entries_vec` used on a DIMENSION change, where
124
+ * the vec0 table must be recreated at the new width by the caller. The default
125
+ * clears its rows in place (same dimension, stale vectors).
519
126
  */
520
- function backupBeforeEmbeddingDimChange(dataDir, fromDim, toDim) {
521
- if (!dataDir)
522
- return;
523
- try {
524
- const result = backupDataDir({
525
- dataDir,
526
- // The DB version isn't changing here — pass the current DB_VERSION for
527
- // both source and target so the metadata sidecar still records the
528
- // running binary's version for forensic context.
529
- sourceVersion: DB_VERSION,
530
- targetVersion: DB_VERSION,
531
- reason: EMBEDDING_DIM_CHANGE_REASON,
532
- env: process.env,
533
- });
534
- if (result) {
535
- warn("[akm] embedding dimension changed %s→%s; data directory backed up to %s; embeddings will be regenerated", fromDim, toDim, result.path);
536
- }
537
- }
538
- catch (err) {
539
- // Defensive — backupDataDir already swallows most errors, but if it
540
- // throws for an unexpected reason we must still proceed with the drop
541
- // so the user isn't locked out of their binary on a changed dim.
542
- warn("[akm] pre-embedding-dim-change data dir backup raised an unexpected error — %s; embeddings will be regenerated without a snapshot", err instanceof Error ? err.message : String(err));
543
- }
544
- }
545
- /**
546
- * Re-insert backed-up `usage_events` rows into the freshly-created table.
547
- *
548
- * Wrapped in an outer try/catch because schema changes across versions may
549
- * make the backup incompatible with the new table definition; in that case
550
- * the backup is discarded silently rather than blocking startup.
551
- */
552
- function restoreUsageEventsBackup(db, backup) {
553
- if (backup.length === 0)
554
- return;
555
- try {
556
- // BUG-H4: introspect the *target* table's columns rather than relying on
557
- // `row[0]`'s keys. The backup may carry columns the new schema dropped,
558
- // and the new schema may have NOT-NULL columns without DEFAULT that the
559
- // old backup never carried. Project the backup onto the intersection so
560
- // we don't silently lose every row to per-row INSERT errors, and warn
561
- // once if any backup column was dropped from the new schema.
562
- const targetCols = db.prepare("PRAGMA table_info(usage_events)").all().map((c) => c.name);
563
- if (targetCols.length === 0) {
564
- warn("[db] restoreUsageEventsBackup: usage_events table missing — discarding %d backup row(s)", backup.length);
565
- return;
566
- }
567
- const targetSet = new Set(targetCols);
568
- const backupCols = Object.keys(backup[0] ?? {});
569
- const projectedCols = backupCols.filter((c) => targetSet.has(c));
570
- const droppedCols = backupCols.filter((c) => !targetSet.has(c));
571
- if (projectedCols.length === 0) {
572
- warn("[db] restoreUsageEventsBackup: no overlapping columns between backup and current schema — discarding %d row(s); dropped: %s", backup.length, droppedCols.join(", ") || "(none)");
573
- return;
574
- }
575
- if (droppedCols.length > 0) {
576
- warn("[db] restoreUsageEventsBackup: dropping columns no longer in usage_events schema: %s", droppedCols.join(", "));
577
- }
578
- let restored = 0;
579
- let failed = 0;
580
- db.transaction(() => {
581
- const placeholders = projectedCols.map(() => "?").join(", ");
582
- const insert = db.prepare(`INSERT INTO usage_events (${projectedCols.join(", ")}) VALUES (${placeholders})`);
583
- for (const row of backup) {
584
- try {
585
- insert.run(...projectedCols.map((c) => row[c]));
586
- restored++;
587
- }
588
- catch {
589
- failed++;
590
- }
591
- }
592
- })();
593
- if (failed > 0) {
594
- warn("[db] restoreUsageEventsBackup: restored %d row(s); skipped %d incompatible row(s)", restored, failed);
595
- }
596
- }
597
- catch (err) {
598
- warn("[db] restoreUsageEventsBackup: discarded %d backup row(s) — %s", backup.length, err instanceof Error ? err.message : String(err));
127
+ export function purgeEmbeddings(db, opts) {
128
+ bestEffort(() => db.exec("DELETE FROM embeddings"), "purge embeddings");
129
+ if (isVecAvailable(db)) {
130
+ bestEffort(() => db.exec(opts?.dropVecTable ? "DROP TABLE IF EXISTS entries_vec" : "DELETE FROM entries_vec"), "purge entries_vec");
599
131
  }
132
+ setMeta(db, "hasEmbeddings", "0");
600
133
  }
601
134
  // ── Meta helpers ────────────────────────────────────────────────────────────
602
135
  export function getMeta(db, key) {
@@ -695,26 +228,6 @@ function getUpsertStmts(db) {
695
228
  upsertStmtsByDb.set(db, stmts);
696
229
  return stmts;
697
230
  }
698
- /**
699
- * Phase 5A / DB v17 schema guard.
700
- *
701
- * Ensures the `entries.derived_from` column + index exist on the open
702
- * connection. Called from `ensureSchema()` after the entries CREATE so that
703
- * legacy databases (created against a pre-v17 binary but reopened without
704
- * triggering `handleVersionUpgrade()`) still gain the new column without
705
- * data loss. Idempotent: a `PRAGMA table_info` lookup gates the ALTER.
706
- */
707
- function ensureDerivedFromColumn(db) {
708
- bestEffort(() => {
709
- const cols = db.prepare("PRAGMA table_info(entries)").all();
710
- const hasColumn = cols.some((c) => c.name === "derived_from");
711
- if (!hasColumn) {
712
- db.exec("ALTER TABLE entries ADD COLUMN derived_from TEXT");
713
- }
714
- // Index creation is idempotent on its own; safe to call unconditionally.
715
- db.exec("CREATE INDEX IF NOT EXISTS idx_entries_derived_from ON entries(derived_from)");
716
- }, "entries table may not exist on a brand-new DB before CREATE — caller is responsible");
717
- }
718
231
  /**
719
232
  * Phase 5A / Advantage D5: look up the derived-memory child row whose
720
233
  * `derived_from` column matches `parentRef` (e.g. `"memory:claude-prefs"`).
@@ -729,7 +242,7 @@ export function getDerivedForParent(db, parentRef) {
729
242
  return null;
730
243
  try {
731
244
  const row = db
732
- .prepare(`SELECT id, entry_key, dir_path, file_path, stash_dir, entry_json, search_text
245
+ .prepare(`SELECT ${ENTRY_COLUMNS}
733
246
  FROM entries
734
247
  WHERE derived_from = ?
735
248
  ORDER BY id DESC
@@ -737,23 +250,7 @@ export function getDerivedForParent(db, parentRef) {
737
250
  .get(parentRef);
738
251
  if (!row)
739
252
  return null;
740
- let entry;
741
- try {
742
- entry = JSON.parse(row.entry_json);
743
- }
744
- catch {
745
- warn(`[db] getDerivedForParent: skipping entry id=${row.id} — corrupt entry_json`);
746
- return null;
747
- }
748
- return {
749
- id: row.id,
750
- entryKey: row.entry_key,
751
- dirPath: row.dir_path,
752
- filePath: row.file_path,
753
- stashDir: row.stash_dir,
754
- entry,
755
- searchText: row.search_text,
756
- };
253
+ return rowToIndexedEntry(row, "getDerivedForParent");
757
254
  }
758
255
  catch {
759
256
  /* `derived_from` column may not exist on legacy DBs that haven't been
@@ -761,6 +258,49 @@ export function getDerivedForParent(db, parentRef) {
761
258
  return null;
762
259
  }
763
260
  }
261
+ /**
262
+ * 03-R3: for the given derived-twin row ids, fetch each twin's BASE memory
263
+ * `beliefState`, keyed by twin id.
264
+ *
265
+ * Used by the derived-twin belief inheritance in search ranking: a `.derived`
266
+ * twin has no belief state of its own, so it inherits its base memory's
267
+ * demoting state (contradicted/superseded/…) at search time. A twin's
268
+ * `entry_key` is exactly its base's `entry_key` plus the `.derived` suffix
269
+ * (same stash + type prefix, `<name>` vs `<name>.derived`), so the base is
270
+ * found by stripping that suffix — no ref/prefix reconstruction. Returns a map
271
+ * of twin id → base beliefState for bases that carry a non-empty state.
272
+ * Best-effort: any query error (e.g. legacy DB) yields no inheritance rather
273
+ * than failing the search.
274
+ */
275
+ export function getBaseBeliefStatesForDerivedTwins(db, twinIds) {
276
+ const out = new Map();
277
+ if (twinIds.length === 0)
278
+ return out;
279
+ // Chunk at SQLITE_CHUNK_SIZE like the sibling bulk-by-id helpers, so a large
280
+ // `--limit` candidate set never trips SQLITE_MAX_VARIABLE_NUMBER (which would
281
+ // otherwise fall into the best-effort catch and silently disable the feature).
282
+ for (let i = 0; i < twinIds.length; i += SQLITE_CHUNK_SIZE) {
283
+ const chunk = twinIds.slice(i, i + SQLITE_CHUNK_SIZE);
284
+ const placeholders = chunk.map(() => "?").join(",");
285
+ bestEffort(() => {
286
+ const rows = db
287
+ .prepare(`SELECT twin.id AS twin_id, json_extract(base.entry_json, '$.beliefState') AS belief
288
+ FROM entries twin
289
+ JOIN entries base
290
+ ON base.entry_type = 'memory'
291
+ AND base.entry_key = substr(twin.entry_key, 1, length(twin.entry_key) - length('.derived'))
292
+ WHERE twin.id IN (${placeholders})
293
+ AND twin.entry_key LIKE '%.derived'
294
+ AND json_extract(base.entry_json, '$.beliefState') IS NOT NULL`)
295
+ .all(...chunk);
296
+ for (const r of rows) {
297
+ if (typeof r.belief === "string" && r.belief.trim().length > 0)
298
+ out.set(r.twin_id, r.belief.trim());
299
+ }
300
+ }, "legacy DB / entry_json without beliefState — treat as no twin inheritance");
301
+ }
302
+ return out;
303
+ }
764
304
  /**
765
305
  * Phase 2A / Rec 5: bulk-load positive feedback event counts for the given
766
306
  * entry ids. Used by the utility-decay forgetting curve to stabilize
@@ -839,6 +379,38 @@ function deleteRelatedRows(db, ids) {
839
379
  // Clean up usage events before deleting entries
840
380
  bestEffort(() => db.prepare(`DELETE FROM usage_events WHERE entry_id IN (${placeholders})`).run(...chunk), "delete usage_events for entries");
841
381
  }
382
+ // #624-P1: graph_files is NO LONGER keyed on entries.id, so deleting an
383
+ // entries row must NOT wipe the extracted graph (that is the whole point —
384
+ // the graph survives a reindex when body_hash is unchanged). We therefore do
385
+ // NOT delete graph_files here. We DO, however, recompute graph_meta counts
386
+ // for the stash roots touched by the deleted entries so the summary numbers
387
+ // stay consistent with the live child rows (the counts are derived, and the
388
+ // entries delete may have changed which files are considered/indexed).
389
+ //
390
+ // Resolve the affected stash roots from the entries rows BEFORE deletion.
391
+ const affectedStashRoots = new Set();
392
+ for (let i = 0; i < numericIds.length; i += SQLITE_CHUNK_SIZE) {
393
+ const chunk = numericIds.slice(i, i + SQLITE_CHUNK_SIZE);
394
+ const placeholders = chunk.map(() => "?").join(",");
395
+ bestEffort(() => {
396
+ const rows = db
397
+ .prepare(`SELECT DISTINCT stash_dir FROM entries WHERE id IN (${placeholders})`)
398
+ .all(...chunk);
399
+ for (const row of rows) {
400
+ if (row.stash_dir)
401
+ affectedStashRoots.add(row.stash_dir);
402
+ }
403
+ }, "resolve stash roots for graph_meta recompute");
404
+ }
405
+ for (const stashRoot of affectedStashRoots) {
406
+ bestEffort(() => db
407
+ .prepare(`UPDATE graph_meta
408
+ SET extracted_files = (SELECT COUNT(*) FROM graph_files WHERE stash_root = ?),
409
+ entity_count = (SELECT COUNT(*) FROM graph_file_entities WHERE stash_root = ?),
410
+ relation_count = (SELECT COUNT(*) FROM graph_file_relations WHERE stash_root = ?)
411
+ WHERE stash_root = ?`)
412
+ .run(stashRoot, stashRoot, stashRoot, stashRoot), "sync graph_meta counts after entries delete");
413
+ }
842
414
  }
843
415
  /**
844
416
  * Delete entries by their primary key IDs, along with all related rows
@@ -1048,12 +620,12 @@ function searchBlobVec(db, queryEmbedding, k) {
1048
620
  }
1049
621
  }
1050
622
  // ── FTS5 search ─────────────────────────────────────────────────────────────
1051
- export function searchFts(db, query, limit, entryType) {
623
+ export function searchFts(db, query, limit, entryType, excludeTypes) {
1052
624
  const ftsQuery = sanitizeFtsQuery(query);
1053
625
  if (!ftsQuery)
1054
626
  return [];
1055
627
  // Try the exact AND query first
1056
- const exactResults = runFtsQuery(db, ftsQuery, limit, entryType);
628
+ const exactResults = runFtsQuery(db, ftsQuery, limit, entryType, excludeTypes);
1057
629
  if (exactResults.length > 0)
1058
630
  return exactResults;
1059
631
  // Exact match returned zero results — try prefix fallback.
@@ -1063,59 +635,40 @@ export function searchFts(db, query, limit, entryType) {
1063
635
  const prefixQuery = buildPrefixQuery(ftsQuery);
1064
636
  if (!prefixQuery)
1065
637
  return [];
1066
- return runFtsQuery(db, prefixQuery, limit, entryType);
1067
- }
1068
- /**
1069
- * Build a prefix query from an FTS5 query string by appending `*` to each
1070
- * token that is 3+ characters long. Tokens shorter than 3 characters are
1071
- * kept as-is (no prefix expansion) to avoid overly broad matches.
1072
- *
1073
- * Returns null if no tokens qualify for prefix expansion.
1074
- */
1075
- function buildPrefixQuery(ftsQuery) {
1076
- const tokens = ftsQuery.split(/\s+/).filter(Boolean);
1077
- let hasPrefix = false;
1078
- const prefixTokens = tokens.map((t) => {
1079
- if (t.length >= 3) {
1080
- hasPrefix = true;
1081
- return `${t}*`;
1082
- }
1083
- return t;
1084
- });
1085
- if (!hasPrefix)
1086
- return null;
1087
- return prefixTokens.join(" ");
1088
- }
1089
- function runFtsQuery(db, ftsQuery, limit, entryType) {
1090
- let sql;
638
+ return runFtsQuery(db, prefixQuery, limit, entryType, excludeTypes);
639
+ }
640
+ function runFtsQuery(db, ftsQuery, limit, entryType, excludeTypes) {
641
+ // #627 exclude-type clause. Only applies on the untyped ('any') path; an
642
+ // explicit include filter (entryType) already narrows to a single type, so
643
+ // exclusion is redundant there. An empty list skips the clause entirely
644
+ // (never emit `NOT IN ()`, which is a SQL error / always-false).
645
+ const excludes = excludeTypes && excludeTypes.length > 0 ? excludeTypes : [];
646
+ // The typed and untyped paths differ ONLY by one WHERE clause (an entry_type
647
+ // equality vs. an optional NOT IN exclusion) and their param order — the
648
+ // SELECT/JOIN/ORDER/LIMIT is shared, so build it once. Join on integer
649
+ // entry_id directly (no CAST; we store integer). bm25() per-column weights:
650
+ // entry_id(0), name(10), description(5), tags(3), hints(2), content(1).
651
+ let filterClause;
1091
652
  let params;
1092
- // Join on integer entry_id directly (no CAST needed; we store integer)
1093
- // Use bm25() with per-column weights: entry_id(0), name(10), description(5), tags(3), hints(2), content(1)
1094
653
  if (entryType && entryType !== "any") {
1095
- sql = `
1096
- SELECT e.id, e.file_path AS filePath, e.entry_json, e.search_text AS searchText,
1097
- bm25(entries_fts, 0, 10.0, 5.0, 3.0, 2.0, 1.0) AS bm25Score
1098
- FROM entries_fts f
1099
- JOIN entries e ON e.id = f.entry_id
1100
- WHERE entries_fts MATCH ?
1101
- AND e.entry_type = ?
1102
- ORDER BY bm25Score, e.id ASC
1103
- LIMIT ?
1104
- `;
654
+ filterClause = "AND e.entry_type = ?";
1105
655
  params = [ftsQuery, entryType, limit];
1106
656
  }
1107
657
  else {
1108
- sql = `
1109
- SELECT e.id, e.file_path AS filePath, e.entry_json, e.search_text AS searchText,
1110
- bm25(entries_fts, 0, 10.0, 5.0, 3.0, 2.0, 1.0) AS bm25Score
1111
- FROM entries_fts f
1112
- JOIN entries e ON e.id = f.entry_id
1113
- WHERE entries_fts MATCH ?
1114
- ORDER BY bm25Score, e.id ASC
1115
- LIMIT ?
1116
- `;
1117
- params = [ftsQuery, limit];
658
+ filterClause = excludes.length > 0 ? `AND e.entry_type NOT IN (${excludes.map(() => "?").join(", ")})` : "";
659
+ // Param order: MATCH, then the NOT IN values, then LIMIT.
660
+ params = [ftsQuery, ...excludes, limit];
1118
661
  }
662
+ const sql = `
663
+ SELECT e.id, e.file_path AS filePath, e.entry_json, e.search_text AS searchText,
664
+ bm25(entries_fts, 0, 10.0, 5.0, 3.0, 2.0, 1.0) AS bm25Score
665
+ FROM entries_fts f
666
+ JOIN entries e ON e.id = f.entry_id
667
+ WHERE entries_fts MATCH ?
668
+ ${filterClause}
669
+ ORDER BY bm25Score, e.id ASC
670
+ LIMIT ?
671
+ `;
1119
672
  try {
1120
673
  const rows = db.prepare(sql).all(...params);
1121
674
  // Guard against corrupt JSON — skip the row rather than crashing
@@ -1139,65 +692,83 @@ function runFtsQuery(db, ftsQuery, limit, entryType) {
1139
692
  }
1140
693
  return results;
1141
694
  }
1142
- catch {
695
+ catch (err) {
696
+ warn("[db] runFtsQuery failed:", err instanceof Error ? err.message : String(err));
1143
697
  return [];
1144
698
  }
1145
699
  }
1146
- export function sanitizeFtsQuery(query) {
1147
- // Allow only characters safe in FTS5 queries: letters, digits, underscores,
1148
- // and whitespace. Everything else (hyphens, dots, quotes, parens, asterisks,
1149
- // colons, carets, @, !, etc.) is replaced with a space so that compound
1150
- // identifiers like "code-review" or "k8s.setup" become AND-joined tokens
1151
- // ("code review", "k8s setup") rather than triggering FTS5 syntax errors.
1152
- let sanitized = query.replace(/[^a-zA-Z0-9_\s]/g, " ");
1153
- // Neutralize the NEAR operator (FTS5 proximity syntax)
1154
- sanitized = sanitized.replace(/\bNEAR\b/g, " ");
1155
- const tokens = sanitized.split(/\s+/).filter((t) => t.length >= 1);
1156
- if (tokens.length === 0)
1157
- return "";
1158
- // Use implicit AND (space-separated tokens) for precision. FTS5 treats
1159
- // space-separated tokens as an implicit AND, matching only rows that
1160
- // contain ALL terms.
1161
- return tokens.join(" ");
1162
- }
700
+ // ── All entries ─────────────────────────────────────────────────────────────
1163
701
  function parseEntryRows(rows, context) {
1164
702
  const entries = [];
1165
703
  for (const row of rows) {
1166
- let entry;
1167
- try {
1168
- entry = JSON.parse(row.entry_json);
1169
- }
1170
- catch {
1171
- warn(`[db] ${context}: skipping entry id=${row.id} — corrupt entry_json`);
1172
- continue;
1173
- }
1174
- entries.push({
1175
- id: row.id,
1176
- entryKey: row.entry_key,
1177
- dirPath: row.dir_path,
1178
- filePath: row.file_path,
1179
- stashDir: row.stash_dir,
1180
- entry,
1181
- searchText: row.search_text,
1182
- });
704
+ const mapped = rowToIndexedEntry(row, context);
705
+ if (mapped)
706
+ entries.push(mapped);
1183
707
  }
1184
708
  return entries;
1185
709
  }
1186
- export function getAllEntries(db, entryType) {
710
+ export function getAllEntries(db, entryType, excludeTypes) {
1187
711
  let sql;
1188
712
  let params;
713
+ // #627 — exclude-type clause applies only on the untyped ('any') path. Empty
714
+ // list skips the clause (never `NOT IN ()`).
715
+ const excludes = excludeTypes && excludeTypes.length > 0 ? excludeTypes : [];
1189
716
  if (entryType && entryType !== "any") {
1190
- sql =
1191
- "SELECT id, entry_key, dir_path, file_path, stash_dir, entry_json, search_text FROM entries WHERE entry_type = ?";
717
+ sql = `SELECT ${ENTRY_COLUMNS} FROM entries WHERE entry_type = ?`;
1192
718
  params = [entryType];
1193
719
  }
720
+ else if (excludes.length > 0) {
721
+ sql = `SELECT ${ENTRY_COLUMNS} FROM entries WHERE entry_type NOT IN (${excludes.map(() => "?").join(", ")})`;
722
+ params = [...excludes];
723
+ }
1194
724
  else {
1195
- sql = "SELECT id, entry_key, dir_path, file_path, stash_dir, entry_json, search_text FROM entries";
725
+ sql = `SELECT ${ENTRY_COLUMNS} FROM entries`;
1196
726
  params = [];
1197
727
  }
1198
728
  const rows = db.prepare(sql).all(...params);
1199
729
  return parseEntryRows(rows, "getAllEntries");
1200
730
  }
731
+ /**
732
+ * #609 — read graph entities (normalized) for a set of entry ids. Used by the
733
+ * recombine pass to cluster memories by shared graph entity ("graph"
734
+ * relatedness source). Returns a map of `entry_id -> entity_norm[]`. Entries
735
+ * with no graph entities (graph extraction has not run, or the file produced
736
+ * no entities) are simply absent from the map — callers must fail open
737
+ * (fall back to tag relatedness) when the map is empty.
738
+ */
739
+ export function getEntitiesByEntryIds(db, entryIds) {
740
+ const result = new Map();
741
+ if (entryIds.length === 0)
742
+ return result;
743
+ // #624-P1: graph_file_entities no longer carries entry_id. Re-derive the
744
+ // entry_id -> entity_norm[] contract by JOINing through entries on
745
+ // (stash_dir, file_path) -> graph_files. Chunk the IN(?) list because the
746
+ // recombine pass can pass 10k+ entry ids (well over the SQLite param limit).
747
+ for (let i = 0; i < entryIds.length; i += SQLITE_CHUNK_SIZE) {
748
+ const chunk = entryIds.slice(i, i + SQLITE_CHUNK_SIZE);
749
+ const placeholders = chunk.map(() => "?").join(", ");
750
+ const rows = db
751
+ .prepare(`SELECT e.id AS entry_id, gfe.entity_norm AS entity_norm
752
+ FROM entries e
753
+ JOIN graph_files gf
754
+ ON gf.stash_root = e.stash_dir AND gf.file_path = e.file_path
755
+ JOIN graph_file_entities gfe
756
+ ON gfe.stash_root = gf.stash_root
757
+ AND gfe.file_path = gf.file_path
758
+ AND gfe.body_hash = gf.body_hash
759
+ WHERE e.id IN (${placeholders})
760
+ ORDER BY e.id, gfe.entity_order`)
761
+ .all(...chunk);
762
+ for (const row of rows) {
763
+ const list = result.get(row.entry_id);
764
+ if (list)
765
+ list.push(row.entity_norm);
766
+ else
767
+ result.set(row.entry_id, [row.entity_norm]);
768
+ }
769
+ }
770
+ return result;
771
+ }
1201
772
  export function findEntryIdByRef(db, ref) {
1202
773
  const parsed = parseAssetRef(ref);
1203
774
  const nameVariants = [parsed.name];
@@ -1221,8 +792,7 @@ export function getEntryCount(db) {
1221
792
  return row.cnt;
1222
793
  }
1223
794
  export function getEmbeddableEntryCount(db) {
1224
- const row = db.prepare("SELECT COUNT(*) AS cnt FROM entries").get();
1225
- return row.cnt;
795
+ return getEntryCount(db);
1226
796
  }
1227
797
  export function getEmbeddingCount(db) {
1228
798
  const row = db.prepare("SELECT COUNT(*) AS cnt FROM embeddings").get();
@@ -1244,9 +814,7 @@ export function getEntryById(db, id) {
1244
814
  return { filePath: row.file_path, entry };
1245
815
  }
1246
816
  export function getEntriesByDir(db, dirPath) {
1247
- const rows = db
1248
- .prepare("SELECT id, entry_key, dir_path, file_path, stash_dir, entry_json, search_text FROM entries WHERE dir_path = ?")
1249
- .all(dirPath);
817
+ const rows = db.prepare(`SELECT ${ENTRY_COLUMNS} FROM entries WHERE dir_path = ?`).all(dirPath);
1250
818
  return parseEntryRows(rows, "getEntriesByDir");
1251
819
  }
1252
820
  /**
@@ -1527,6 +1095,11 @@ function bareRef(ref) {
1527
1095
  * entry_ref populated (see logCurateEvent), so curation is a real retrieval
1528
1096
  * signal here. Legacy summary-only curate rows with a NULL entry_ref simply
1529
1097
  * contribute nothing.
1098
+ *
1099
+ * Machine-sourced events (`source` = 'improve' or 'task') are EXCLUDED: this
1100
+ * count feeds salience/ranking, and pipeline probe traffic counting as demand
1101
+ * creates a self-reinforcing loop (meta-review 05 DRIFT-6). NULL sources
1102
+ * (pre-column rows) count as user demand.
1530
1103
  */
1531
1104
  export function getRetrievalCounts(db, refs) {
1532
1105
  if (refs.length === 0)
@@ -1565,6 +1138,7 @@ export function getRetrievalCounts(db, refs) {
1565
1138
  FROM usage_events
1566
1139
  WHERE event_type IN ('search','show','curate')
1567
1140
  AND entry_ref IS NOT NULL
1141
+ AND (source IS NULL OR source NOT IN ('improve','task'))
1568
1142
  AND CASE
1569
1143
  WHEN instr(entry_ref, '//') > 0
1570
1144
  THEN substr(entry_ref, instr(entry_ref, '//') + 2)
@@ -1717,84 +1291,24 @@ export function getEntryByRef(db, type, name) {
1717
1291
  return db.prepare("SELECT id FROM entries WHERE entry_type = ? AND entry_key = ?").get(type, `${type}:${name}`);
1718
1292
  }
1719
1293
  /**
1720
- * MemRL learning rate for feedback-driven utility updates (F-5 / #386).
1721
- *
1722
- * Follows the bounded-step formula from MemRL (arXiv:2601.03192):
1723
- * next = clamp(current + lr × (reward − current), 0, 1)
1724
- *
1725
- * This replaces the unbounded `-0.03 × negativeCount` delta that could
1726
- * silently remove high-utility assets from the improvement loop.
1727
- */
1728
- const FEEDBACK_LR = 0.1;
1729
- /**
1730
- * Positive reward signal for a single positive feedback event.
1731
- * Reward 1.0 means "fully correct / helpful".
1732
- */
1733
- const FEEDBACK_REWARD_POSITIVE = 1.0;
1734
- /**
1735
- * Negative reward signal for a single negative feedback event.
1736
- * Reward 0.0 means "not helpful" (lowest MemRL signal).
1737
- */
1738
- const FEEDBACK_REWARD_NEGATIVE = 0.0;
1739
- /**
1740
- * Maximum total negative utility delta allowed in a single
1741
- * `applyFeedbackToUtilityScore` call regardless of negativeCount.
1742
- *
1743
- * This caps the per-day negative impact (the function is called once per
1744
- * feedback event — spamming 10 negatives in one session can move utility
1745
- * at most `MAX_NEG_DELTA_PER_CALL`). The cap prevents a noisy negative-
1746
- * feedback stream from silently destroying a high-utility asset's ranking.
1747
- */
1748
- const MAX_NEG_DELTA_PER_CALL = 0.15;
1749
- /**
1750
- * Utility threshold below which a review-needed escalation is triggered.
1751
- * When a previously high-utility asset (≥ HIGH_UTILITY_THRESHOLD) drops
1752
- * below this value, the caller should create an escalation proposal.
1753
- */
1754
- export const UTILITY_REVIEW_THRESHOLD = 0.5;
1755
- /**
1756
- * Utility level considered "high" — assets above this are tracked for
1757
- * threshold-crossing escalation.
1758
- */
1759
- export const HIGH_UTILITY_THRESHOLD = 0.5;
1760
- /**
1761
- * Apply accumulated feedback counts to the utility score of an entry using the
1762
- * MemRL bounded-step EMA formula (F-5 / #386, arXiv:2601.03192).
1763
- *
1764
- * Replaces the previous unbounded `-0.03 × negativeCount` formula with:
1765
- *
1766
- * reward = weighted average of positive and negative signals
1767
- * nextUtil = clamp(currentUtil + lr × (reward − currentUtil), 0, 1)
1768
- *
1769
- * The negative impact is additionally capped at {@link MAX_NEG_DELTA_PER_CALL}
1770
- * to prevent a noisy feedback stream from silently erasing a high-utility asset.
1294
+ * Apply accumulated feedback counts to the utility score of an entry, persisting
1295
+ * the result. The bounded-step EMA policy itself (MemRL, F-5 / #386,
1296
+ * arXiv:2601.03192) lives in {@link computeNextUtility} (feedback/utility-policy);
1297
+ * this function only reads the current utility, applies the policy, and writes
1298
+ * the new value.
1771
1299
  *
1772
1300
  * A new entry starts at 0.5 (neutral midpoint) before the EMA step is applied.
1773
- *
1774
- * Returns a {@link FeedbackUtilityResult} so the caller can detect when a
1775
- * previously high-utility asset crosses below the review threshold and create
1776
- * an escalation proposal.
1301
+ * When there is no feedback (both counts zero) the score is left untouched — no
1302
+ * DB write. Returns a {@link FeedbackUtilityResult} so the caller can detect a
1303
+ * previously high-utility asset crossing below the review threshold and escalate.
1777
1304
  */
1778
1305
  export function applyFeedbackToUtilityScore(db, entryId, positiveCount, negativeCount) {
1779
1306
  const existing = getUtilityScore(db, entryId);
1780
1307
  const previousUtility = existing?.utility ?? 0.5;
1308
+ const result = computeNextUtility(previousUtility, positiveCount, negativeCount);
1781
1309
  if (positiveCount === 0 && negativeCount === 0) {
1782
- return { previousUtility, nextUtility: previousUtility, crossedReviewThreshold: false };
1783
- }
1784
- const total = positiveCount + negativeCount;
1785
- // Weighted reward: proportion of positive signals.
1786
- const reward = positiveCount > 0 && negativeCount === 0
1787
- ? FEEDBACK_REWARD_POSITIVE
1788
- : negativeCount > 0 && positiveCount === 0
1789
- ? FEEDBACK_REWARD_NEGATIVE
1790
- : (positiveCount * FEEDBACK_REWARD_POSITIVE + negativeCount * FEEDBACK_REWARD_NEGATIVE) / total;
1791
- // MemRL bounded-step EMA: lr × (reward − current)
1792
- let delta = FEEDBACK_LR * (reward - previousUtility);
1793
- // Per-call negative cap: if delta is negative (net negative feedback), cap it.
1794
- if (delta < 0) {
1795
- delta = Math.max(delta, -MAX_NEG_DELTA_PER_CALL);
1310
+ return result;
1796
1311
  }
1797
- const nextUtility = Math.max(0, Math.min(1, previousUtility + delta));
1798
1312
  const now = new Date().toISOString();
1799
1313
  db.prepare(`
1800
1314
  INSERT INTO utility_scores (entry_id, utility, show_count, search_count, select_rate, last_used_at, updated_at)
@@ -1802,16 +1316,14 @@ export function applyFeedbackToUtilityScore(db, entryId, positiveCount, negative
1802
1316
  ON CONFLICT(entry_id) DO UPDATE SET
1803
1317
  utility = ?,
1804
1318
  updated_at = ?
1805
- `).run(entryId, nextUtility, now, now, nextUtility, now);
1806
- const crossedReviewThreshold = previousUtility >= HIGH_UTILITY_THRESHOLD && nextUtility < UTILITY_REVIEW_THRESHOLD;
1807
- return { previousUtility, nextUtility, crossedReviewThreshold };
1319
+ `).run(entryId, result.nextUtility, now, now, result.nextUtility, now);
1320
+ return result;
1808
1321
  }
1809
1322
  /**
1810
1323
  * Re-link detached usage_events to their current entry_ids via entry_ref.
1811
1324
  *
1812
- * After a full rebuild, entry IDs change. This query matches events to their
1813
- * new entry rows using the stable `entry_ref` ("type:name") column so usage
1814
- * history survives a full reindex.
1325
+ * After a full rebuild, entry IDs change. This restores each event's link
1326
+ * using the stable `entry_ref` column so usage history survives a reindex.
1815
1327
  */
1816
1328
  export function relinkUsageEvents(db) {
1817
1329
  bestEffort(() => {
@@ -1828,63 +1340,42 @@ export function relinkUsageEvents(db) {
1828
1340
  WHERE entry_id IS NOT NULL
1829
1341
  AND entry_id NOT IN (SELECT id FROM entries)
1830
1342
  `);
1831
- // Step 2: re-resolve any null entry_id from entry_ref against the
1832
- // current entries table. Picks up entries that were re-created with
1833
- // the same ref (e.g. an asset moved between sources).
1834
- db.exec(`
1835
- UPDATE usage_events SET entry_id = (
1836
- SELECT e.id FROM entries e
1837
- WHERE substr(e.entry_key, length(e.entry_key) - length(usage_events.entry_ref)) = ':' || usage_events.entry_ref
1838
- LIMIT 1
1839
- )
1840
- WHERE entry_id IS NULL AND entry_ref IS NOT NULL
1841
- `);
1343
+ // Step 2: re-resolve any null entry_id from entry_ref against the current
1344
+ // entries table, reusing the SAME canonical resolver the read path uses at
1345
+ // insert time (`findEntryIdByRef` `parseAssetRef`). Resolving per DISTINCT
1346
+ // ref keeps this O(distinct-refs) indexed lookups instead of the previous
1347
+ // O(events × entries) non-indexable `substr(entry_key, …)` scan. It also
1348
+ // fixes a silent correctness bug: the old suffix match compared the RAW
1349
+ // `entry_ref`, so origin-qualified refs ("source//type:name") never matched
1350
+ // an `entry_key` and lost their usage history on every full rebuild.
1351
+ const refs = db
1352
+ .prepare("SELECT DISTINCT entry_ref AS ref FROM usage_events WHERE entry_id IS NULL AND entry_ref IS NOT NULL")
1353
+ .all();
1354
+ const update = db.prepare("UPDATE usage_events SET entry_id = ? WHERE entry_ref = ? AND entry_id IS NULL");
1355
+ const relinkTx = db.transaction(() => {
1356
+ for (const { ref } of refs) {
1357
+ let id;
1358
+ try {
1359
+ id = findEntryIdByRef(db, ref);
1360
+ }
1361
+ catch (err) {
1362
+ if (err instanceof Error && err.name === "UsageError")
1363
+ continue;
1364
+ throw err;
1365
+ }
1366
+ if (id !== undefined)
1367
+ update.run(id, ref);
1368
+ }
1369
+ });
1370
+ relinkTx();
1842
1371
  }, "usage_events table may not exist yet during entry_id re-resolution");
1843
1372
  }
1844
1373
  // ── registry_index_cache helpers ─────────────────────────────────────────────
1845
- /**
1846
- * Upsert a registry index cache entry in index.db.
1847
- *
1848
- * @param db - Open index.db connection (from openDatabase / openExistingDatabase).
1849
- * @param registryUrl - Canonical URL of the registry (used as primary key).
1850
- * @param indexJson - Serialised registry index document (JSON string).
1851
- * @param opts.etag - HTTP ETag from the response (optional).
1852
- * @param opts.lastModified - HTTP Last-Modified from the response (optional).
1853
- */
1854
- export function upsertRegistryIndexCache(db, registryUrl, indexJson, opts) {
1855
- db.prepare(`
1856
- INSERT INTO registry_index_cache (registry_url, fetched_at, etag, last_modified, index_json)
1857
- VALUES (?, ?, ?, ?, ?)
1858
- ON CONFLICT(registry_url) DO UPDATE SET
1859
- fetched_at = excluded.fetched_at,
1860
- etag = excluded.etag,
1861
- last_modified = excluded.last_modified,
1862
- index_json = excluded.index_json
1863
- `).run(registryUrl, new Date().toISOString(), opts?.etag ?? null, opts?.lastModified ?? null, indexJson);
1864
- }
1865
- /**
1866
- * Look up a cached registry index entry from index.db.
1867
- * Returns undefined when not found or when the entry is older than `maxAgeMs`.
1868
- *
1869
- * TTL check: if `Date.now() - new Date(fetched_at).getTime() > maxAgeMs` the
1870
- * entry is considered a cache miss and undefined is returned.
1871
- *
1872
- * @param db - Open index.db connection.
1873
- * @param registryUrl - Canonical URL of the registry (primary key).
1874
- * @param maxAgeMs - Maximum age in milliseconds before the entry is stale (default: 1 hour).
1875
- */
1876
- export function getRegistryIndexCache(db, registryUrl, maxAgeMs = 3_600_000 /* 1 hour */) {
1877
- const row = db
1878
- .prepare(`SELECT fetched_at, etag, last_modified, index_json
1879
- FROM registry_index_cache WHERE registry_url = ?`)
1880
- .get(registryUrl);
1881
- if (!row)
1882
- return undefined;
1883
- const fetchedAt = Date.parse(row.fetched_at);
1884
- if (Number.isNaN(fetchedAt) || Date.now() - fetchedAt > maxAgeMs)
1885
- return undefined;
1886
- return { indexJson: row.index_json, etag: row.etag, lastModified: row.last_modified };
1887
- }
1374
+ // The raw SQL for the `registry_index_cache` table now lives in the storage
1375
+ // layer (`src/storage/repositories/registry-index-cache-repository.ts`) so the
1376
+ // dependency arrow points indexer → storage. These thin re-exports preserve the
1377
+ // previously-public symbols for any importer of this module.
1378
+ export { getRegistryIndexCache, upsertRegistryIndexCache, } from "../../storage/repositories/registry-index-cache-repository.js";
1888
1379
  /**
1889
1380
  * Walk indexed entries and collect a deduplicated set of tags. When
1890
1381
  * `entryType` is provided, only entries of that type contribute tags.