akm-cli 0.9.0-rc.1 → 0.9.0-rc.13

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 (587) hide show
  1. package/CHANGELOG.md +1190 -52
  2. package/README.md +62 -37
  3. package/SECURITY.md +46 -31
  4. package/dist/akm +162 -38
  5. package/dist/akm-migrate +44 -0
  6. package/dist/assets/backends/schtasks-template.xml +2 -1
  7. package/dist/assets/hints/cli-hints-full.md +268 -118
  8. package/dist/assets/hints/cli-hints-short.md +87 -24
  9. package/dist/assets/{profiles → improve-strategies}/catchup.json +3 -1
  10. package/dist/assets/{profiles → improve-strategies}/consolidate.json +3 -1
  11. package/dist/assets/{profiles → improve-strategies}/default.json +6 -7
  12. package/dist/assets/improve-strategies/frequent.json +15 -0
  13. package/dist/assets/{profiles → improve-strategies}/graph-refresh.json +4 -2
  14. package/dist/assets/{profiles → improve-strategies}/memory-focus.json +4 -1
  15. package/dist/assets/{profiles → improve-strategies}/proactive-maintenance.json +5 -5
  16. package/dist/assets/{profiles → improve-strategies}/quick.json +4 -2
  17. package/dist/assets/improve-strategies/reflect-distill.json +30 -0
  18. package/dist/assets/{profiles → improve-strategies}/thorough.json +1 -1
  19. package/dist/assets/prompts/consolidate-system.md +5 -5
  20. package/dist/assets/prompts/extract-session.md +2 -6
  21. package/dist/assets/prompts/memory-infer-user.md +2 -3
  22. package/dist/assets/prompts/reflect-llm-framed-contract.md +11 -0
  23. package/dist/assets/prompts/reflect-llm-schema-contract.md +3 -0
  24. package/dist/assets/prompts/reflect-output-repair.md +3 -0
  25. package/dist/assets/stash-skeleton/README.md +38 -10
  26. package/dist/assets/stash-skeleton/facts/conventions/assets/agent.md +8 -0
  27. package/dist/assets/stash-skeleton/facts/conventions/assets/command.md +8 -0
  28. package/dist/assets/stash-skeleton/facts/conventions/assets/fact.md +14 -1
  29. package/dist/assets/stash-skeleton/facts/conventions/assets/knowledge.md +13 -1
  30. package/dist/assets/stash-skeleton/facts/conventions/assets/lesson.md +9 -1
  31. package/dist/assets/stash-skeleton/facts/conventions/assets/memory.md +11 -0
  32. package/dist/assets/stash-skeleton/facts/conventions/assets/script.md +9 -0
  33. package/dist/assets/stash-skeleton/facts/conventions/assets/skill.md +9 -0
  34. package/dist/assets/stash-skeleton/facts/conventions/assets/workflow.md +8 -0
  35. package/dist/assets/stash-skeleton/facts/conventions/backlinks.md +100 -0
  36. package/dist/assets/stash-skeleton/facts/conventions/domains.md +64 -0
  37. package/dist/assets/stash-skeleton/facts/conventions/organization.md +136 -0
  38. package/dist/assets/tasks/core/extract.yml +3 -2
  39. package/dist/assets/tasks/core/improve.yml +2 -1
  40. package/dist/assets/tasks/core/index-refresh.yml +1 -0
  41. package/dist/assets/tasks/core/sync.yml +1 -0
  42. package/dist/assets/tasks/core/version-check.yml +2 -1
  43. package/dist/assets/tasks/improve/akm-graph-refresh-weekly.yml +5 -0
  44. package/dist/assets/tasks/improve/akm-improve-catchup.yml +8 -0
  45. package/dist/assets/tasks/improve/akm-improve-consolidate.yml +5 -0
  46. package/dist/assets/tasks/improve/akm-improve-frequent.yml +5 -0
  47. package/dist/assets/tasks/improve/akm-improve-nightly.yml +5 -0
  48. package/dist/assets/templates/html/health.html +5 -4
  49. package/dist/assets/workflows/workflow-template.md +31 -15
  50. package/dist/cli/invocation.js +279 -0
  51. package/dist/cli/parse-args.js +5 -90
  52. package/dist/cli/retired-commands.js +78 -0
  53. package/dist/cli/shared.js +158 -48
  54. package/dist/cli-node.mjs +2 -1
  55. package/dist/cli.js +747 -293
  56. package/dist/commands/agent/agent-dispatch.js +19 -18
  57. package/dist/commands/agent/agent-support.js +0 -24
  58. package/dist/commands/agent/contribute-cli.js +43 -97
  59. package/dist/commands/completions.js +80 -23
  60. package/dist/commands/config-cli.js +44 -281
  61. package/dist/commands/env/env-binding.js +13 -9
  62. package/dist/commands/env/env-cli.js +76 -159
  63. package/dist/commands/env/env.js +12 -163
  64. package/dist/commands/env/marker-path.js +6 -0
  65. package/dist/commands/env/secret-cli.js +45 -61
  66. package/dist/commands/env/secret.js +32 -62
  67. package/dist/commands/feedback-cli.js +179 -85
  68. package/dist/commands/health/accept-rate.js +58 -0
  69. package/dist/commands/health/advisories.js +7 -8
  70. package/dist/commands/health/checks.js +279 -94
  71. package/dist/commands/health/html-report.js +197 -578
  72. package/dist/commands/health/improve-metrics.js +277 -246
  73. package/dist/commands/health/llm-usage.js +19 -19
  74. package/dist/commands/health/md-report.js +16 -7
  75. package/dist/commands/health/metrics.js +67 -32
  76. package/dist/commands/health/renderers.js +47 -0
  77. package/dist/commands/health/report-view-model.js +508 -0
  78. package/dist/commands/health/stash-exposure.js +1 -1
  79. package/dist/commands/health/surfaces.js +16 -56
  80. package/dist/commands/health/task-runs.js +3 -67
  81. package/dist/{migrate-storage-node.mjs → commands/health/types-checks.js} +1 -5
  82. package/dist/commands/health/types-improve.js +29 -0
  83. package/dist/{output/text/save.js → commands/health/types-metrics.js} +1 -2
  84. package/dist/commands/health/types-result.js +7 -0
  85. package/dist/commands/health/types-runs.js +4 -0
  86. package/dist/commands/health/types-session-log.js +4 -0
  87. package/dist/commands/health/types-windows.js +4 -0
  88. package/dist/commands/health/types.js +26 -21
  89. package/dist/commands/health/windows.js +2 -3
  90. package/dist/commands/health.js +296 -167
  91. package/dist/commands/improve/anti-collapse.js +5 -5
  92. package/dist/commands/improve/autonomy-gate.js +68 -0
  93. package/dist/commands/improve/collapse-detector.js +65 -52
  94. package/dist/commands/improve/consolidate/chunking.js +9 -7
  95. package/dist/commands/improve/consolidate/eligibility.js +1 -23
  96. package/dist/commands/improve/consolidate/merge.js +4 -0
  97. package/dist/commands/improve/consolidate.js +454 -1354
  98. package/dist/commands/improve/content-hash.js +39 -0
  99. package/dist/commands/improve/distill/content-repair.js +4 -10
  100. package/dist/commands/improve/distill/promote-memory.js +89 -64
  101. package/dist/commands/improve/distill/quality-gate.js +118 -42
  102. package/dist/commands/improve/distill-guards.js +1 -1
  103. package/dist/commands/improve/distill-promotion-policy.js +33 -888
  104. package/dist/commands/improve/distill.js +607 -363
  105. package/dist/commands/improve/eligibility.js +165 -79
  106. package/dist/commands/improve/extract-cli.js +35 -126
  107. package/dist/commands/improve/extract-prompt.js +6 -35
  108. package/dist/commands/improve/extract.js +640 -391
  109. package/dist/commands/improve/feedback-valence.js +2 -12
  110. package/dist/commands/improve/improve-cli.js +134 -135
  111. package/dist/commands/improve/improve-result-file.js +30 -50
  112. package/dist/commands/improve/improve-run-types.js +4 -0
  113. package/dist/commands/improve/improve-strategies.js +135 -0
  114. package/dist/commands/improve/improve.js +904 -701
  115. package/dist/commands/improve/locks.js +64 -111
  116. package/dist/commands/improve/loop-stages.js +1110 -923
  117. package/dist/commands/improve/memory/derived-ref.js +124 -0
  118. package/dist/commands/improve/memory/memory-belief.js +79 -7
  119. package/dist/commands/improve/memory/memory-contradiction-detect.js +49 -52
  120. package/dist/commands/improve/memory/memory-improve.js +25 -37
  121. package/dist/commands/improve/outcome-loop.js +25 -88
  122. package/dist/commands/improve/preparation.js +1034 -813
  123. package/dist/commands/improve/proactive-maintenance.js +34 -9
  124. package/dist/commands/improve/proposal-envelope.js +31 -0
  125. package/dist/commands/improve/reflect.js +983 -794
  126. package/dist/commands/improve/run-context.js +119 -0
  127. package/dist/commands/improve/salience.js +24 -127
  128. package/dist/commands/improve/session-asset.js +7 -3
  129. package/dist/commands/improve/shared.js +14 -34
  130. package/dist/commands/improve/source-identity.js +28 -0
  131. package/dist/commands/improve/triage.js +20 -17
  132. package/dist/commands/lint/base-linter.js +340 -313
  133. package/dist/commands/lint/env-key-rules.js +31 -47
  134. package/dist/commands/lint/index.js +185 -30
  135. package/dist/commands/{events.js → log.js} +28 -38
  136. package/dist/commands/migrate-cli.js +54 -0
  137. package/dist/commands/migration-tool.js +55 -0
  138. package/dist/commands/observability-cli.js +70 -208
  139. package/dist/commands/proposal/diff-format.js +50 -0
  140. package/dist/commands/proposal/drain-policies.js +0 -6
  141. package/dist/commands/proposal/drain.js +91 -40
  142. package/dist/commands/proposal/proposal-cli.js +134 -132
  143. package/dist/commands/proposal/proposal-types.js +56 -0
  144. package/dist/commands/proposal/proposal.js +83 -65
  145. package/dist/commands/proposal/propose-cli.js +88 -0
  146. package/dist/commands/proposal/propose.js +105 -88
  147. package/dist/commands/proposal/repository.js +1303 -278
  148. package/dist/commands/proposal/validators/proposal-quality-validators.js +16 -6
  149. package/dist/commands/proposal/validators/proposal-validators.js +61 -12
  150. package/dist/commands/proposal/validators/proposals.js +6 -8
  151. package/dist/commands/read/curate.js +78 -73
  152. package/dist/commands/read/knowledge.js +510 -13
  153. package/dist/commands/read/registry-search.js +2 -2
  154. package/dist/commands/read/remember-cli.js +84 -15
  155. package/dist/commands/read/search-cli.js +203 -96
  156. package/dist/commands/read/search.js +126 -94
  157. package/dist/commands/read/show.js +226 -250
  158. package/dist/commands/registry-cli.js +34 -60
  159. package/dist/commands/remember.js +18 -57
  160. package/dist/commands/sources/add-cli.js +104 -49
  161. package/dist/commands/sources/bundle-cli.js +166 -0
  162. package/dist/commands/sources/bundle-config-ops.js +63 -0
  163. package/dist/commands/sources/info.js +27 -15
  164. package/dist/commands/sources/init.js +30 -40
  165. package/dist/commands/sources/installed-stashes.js +469 -172
  166. package/dist/commands/sources/schema-repair.js +10 -9
  167. package/dist/commands/sources/self-update.js +182 -121
  168. package/dist/commands/sources/source-add.js +169 -178
  169. package/dist/commands/sources/source-clone.js +144 -41
  170. package/dist/commands/sources/source-manage.js +94 -59
  171. package/dist/commands/sources/sources-cli.js +64 -205
  172. package/dist/commands/sources/stash-cli.js +91 -54
  173. package/dist/commands/sources/stash-skeleton.js +1 -1
  174. package/dist/commands/tasks/tasks-cli.js +106 -104
  175. package/dist/commands/tasks/tasks.js +445 -262
  176. package/dist/commands/workflow-cli.js +75 -228
  177. package/dist/core/action-contributors.js +1 -1
  178. package/dist/core/activation-policy.js +49 -0
  179. package/dist/core/adapter/adapters/agent-skills-adapter.js +181 -0
  180. package/dist/core/adapter/adapters/akm-adapter.js +528 -0
  181. package/dist/core/adapter/adapters/akm-lint.js +392 -0
  182. package/dist/core/adapter/adapters/akm-metadata.js +387 -0
  183. package/dist/core/adapter/adapters/akm-task-adapter.js +149 -0
  184. package/dist/core/adapter/adapters/akm-workflow-adapter.js +180 -0
  185. package/dist/core/adapter/adapters/claude-adapter.js +61 -0
  186. package/dist/core/adapter/adapters/dotenv-adapter.js +187 -0
  187. package/dist/core/adapter/adapters/generic-files-adapter.js +119 -0
  188. package/dist/core/adapter/adapters/index.js +80 -0
  189. package/dist/core/adapter/adapters/llm-wiki-adapter.js +419 -0
  190. package/dist/core/adapter/adapters/okf-adapter.js +391 -0
  191. package/dist/core/adapter/adapters/opencode-adapter.js +68 -0
  192. package/dist/core/adapter/adapters/shared.js +286 -0
  193. package/dist/core/adapter/adapters/tool-dir-shared.js +217 -0
  194. package/dist/core/adapter/adapters/website-snapshot-adapter.js +155 -0
  195. package/dist/core/adapter/bundle-adapter.js +4 -0
  196. package/dist/core/adapter/detect-adapter.js +17 -0
  197. package/dist/core/adapter/recognize-match.js +44 -0
  198. package/dist/core/adapter/registry.js +56 -0
  199. package/dist/core/adapter/types.js +4 -0
  200. package/dist/core/asset/akm-markdown.js +30 -0
  201. package/dist/core/asset/asset-placement.js +243 -0
  202. package/dist/core/asset/asset-ref.js +110 -79
  203. package/dist/core/asset/asset-serialize.js +20 -0
  204. package/dist/core/asset/frontmatter.js +28 -12
  205. package/dist/core/asset/markdown.js +40 -51
  206. package/dist/core/asset/resolve-ref.js +274 -0
  207. package/dist/core/asset/stash-meta.js +2 -2
  208. package/dist/core/bundle-id.js +51 -0
  209. package/dist/core/common.js +281 -86
  210. package/dist/core/config/config-io.js +42 -128
  211. package/dist/core/config/config-schema.js +233 -855
  212. package/dist/core/config/config-sources.js +162 -39
  213. package/dist/core/config/config-types.js +16 -11
  214. package/dist/core/config/config-version.js +29 -0
  215. package/dist/core/config/config-walker.js +126 -37
  216. package/dist/core/config/config.js +154 -331
  217. package/dist/core/config/deep-merge.js +41 -0
  218. package/dist/core/config/engine-semantics.js +28 -0
  219. package/dist/core/config/experimental.js +21 -0
  220. package/dist/core/config/schema/embedding.js +38 -0
  221. package/dist/core/config/schema/engines.js +116 -0
  222. package/dist/core/config/schema/experimental.js +47 -0
  223. package/dist/core/config/schema/feedback.js +31 -0
  224. package/dist/core/config/schema/improve-processes.js +389 -0
  225. package/dist/core/config/schema/improve.js +94 -0
  226. package/dist/core/config/schema/index-config.js +176 -0
  227. package/dist/core/config/schema/output.js +18 -0
  228. package/dist/core/config/schema/primitives.js +94 -0
  229. package/dist/core/config/schema/search.js +30 -0
  230. package/dist/core/config/schema/setup.js +18 -0
  231. package/dist/core/config/schema/sources-bundles.js +169 -0
  232. package/dist/core/config/schema/workflow.js +29 -0
  233. package/dist/core/env-secret-ref.js +155 -20
  234. package/dist/core/errors.js +17 -15
  235. package/dist/core/events-types.js +4 -0
  236. package/dist/core/events.js +46 -128
  237. package/dist/core/extra-params.js +62 -0
  238. package/dist/core/file-change.js +17 -0
  239. package/dist/core/file-lock.js +202 -57
  240. package/dist/core/fs-txn.js +392 -0
  241. package/dist/core/git-message.js +59 -0
  242. package/dist/core/improve-result.js +167 -0
  243. package/dist/core/lesson-lint.js +1 -17
  244. package/dist/core/logs-db.js +1 -1
  245. package/dist/core/maintenance-barrier.js +135 -0
  246. package/dist/core/migration-operation.js +44 -0
  247. package/dist/core/mutation-target.js +78 -0
  248. package/dist/core/paths.js +22 -25
  249. package/dist/core/platform.js +10 -0
  250. package/dist/core/recognition-util.js +128 -0
  251. package/dist/core/redaction.js +392 -0
  252. package/dist/core/standards/resolve-standards-context.js +36 -65
  253. package/dist/core/standards/resolve-stash-standards.js +2 -2
  254. package/dist/core/standards/resolve-type-conventions.js +5 -5
  255. package/dist/core/state/migrations.js +242 -11
  256. package/dist/core/state-db.js +98 -10
  257. package/dist/core/structured.js +1 -1
  258. package/dist/core/subprocess.js +303 -0
  259. package/dist/core/text-truncation.js +9 -5
  260. package/dist/core/time.js +20 -0
  261. package/dist/core/type-presentation.js +130 -0
  262. package/dist/core/warn.js +0 -3
  263. package/dist/core/write-source.js +834 -118
  264. package/dist/indexer/bundle-identity-guard.js +92 -0
  265. package/dist/indexer/db/graph-db.js +1 -25
  266. package/dist/indexer/db/llm-cache.js +1 -1
  267. package/dist/indexer/ensure-index.js +30 -9
  268. package/dist/indexer/graph/graph-boost.js +9 -30
  269. package/dist/indexer/graph/graph-extraction.js +41 -27
  270. package/dist/indexer/graph/graph-types.js +4 -0
  271. package/dist/indexer/index-writer-lock.js +93 -49
  272. package/dist/indexer/index-written-assets.js +100 -53
  273. package/dist/indexer/indexer.js +746 -329
  274. package/dist/indexer/init.js +18 -25
  275. package/dist/indexer/installations.js +142 -0
  276. package/dist/indexer/passes/dir-staleness.js +18 -10
  277. package/dist/indexer/passes/memory-inference.js +25 -15
  278. package/dist/indexer/passes/metadata.js +412 -243
  279. package/dist/indexer/scan/doc-to-entry.js +160 -0
  280. package/dist/indexer/scan/drain-dir.js +134 -0
  281. package/dist/indexer/search/db-search.js +292 -108
  282. package/dist/indexer/search/fts-query.js +64 -0
  283. package/dist/indexer/search/ranking-contributors.js +145 -25
  284. package/dist/indexer/search/ranking-types.js +4 -0
  285. package/dist/indexer/search/ranking.js +28 -71
  286. package/dist/indexer/search/search-attribution.js +67 -0
  287. package/dist/indexer/search/search-fields.js +18 -3
  288. package/dist/indexer/search/search-hit-enrichers.js +30 -40
  289. package/dist/indexer/search/search-source.js +157 -111
  290. package/dist/indexer/search/semantic-status.js +4 -1
  291. package/dist/indexer/usage/usage-events.js +10 -30
  292. package/dist/indexer/walk/file-context.js +3 -45
  293. package/dist/indexer/walk/matchers.js +42 -73
  294. package/dist/indexer/walk/path-resolver.js +11 -5
  295. package/dist/indexer/walk/walker.js +42 -14
  296. package/dist/integrations/agent/builder-shared.js +7 -0
  297. package/dist/integrations/agent/builders.js +5 -58
  298. package/dist/integrations/agent/config.js +3 -143
  299. package/dist/integrations/agent/detect.js +17 -2
  300. package/dist/integrations/agent/engine-resolution.js +231 -0
  301. package/dist/integrations/agent/index.js +1 -2
  302. package/dist/integrations/agent/model-aliases.js +8 -3
  303. package/dist/integrations/agent/profiles.js +6 -99
  304. package/dist/integrations/agent/prompts.js +46 -18
  305. package/dist/integrations/agent/runner-dispatch.js +78 -13
  306. package/dist/integrations/agent/runner.js +76 -208
  307. package/dist/integrations/agent/spawn.js +48 -279
  308. package/dist/integrations/harnesses/aider/agent-builder.js +9 -8
  309. package/dist/integrations/harnesses/aider/index.js +2 -12
  310. package/dist/integrations/harnesses/amazonq/agent-builder.js +10 -16
  311. package/dist/integrations/harnesses/amazonq/index.js +3 -17
  312. package/dist/integrations/harnesses/claude/agent-builder.js +2 -3
  313. package/dist/integrations/harnesses/claude/config-import.js +1 -3
  314. package/dist/integrations/harnesses/claude/index.js +1 -14
  315. package/dist/integrations/harnesses/claude/session-log.js +27 -75
  316. package/dist/integrations/harnesses/codex/agent-builder.js +8 -7
  317. package/dist/integrations/harnesses/codex/index.js +2 -13
  318. package/dist/integrations/harnesses/copilot/agent-builder.js +9 -9
  319. package/dist/integrations/harnesses/copilot/index.js +1 -13
  320. package/dist/integrations/harnesses/gemini/agent-builder.js +8 -9
  321. package/dist/integrations/harnesses/gemini/index.js +1 -13
  322. package/dist/integrations/harnesses/ids.js +24 -0
  323. package/dist/integrations/harnesses/index.js +31 -33
  324. package/dist/integrations/harnesses/opencode/agent-builder.js +23 -5
  325. package/dist/integrations/harnesses/opencode/config-import.js +1 -3
  326. package/dist/integrations/harnesses/opencode/index.js +1 -18
  327. package/dist/integrations/harnesses/opencode/session-log.js +67 -125
  328. package/dist/integrations/harnesses/opencode-sdk/harness.js +3 -17
  329. package/dist/integrations/harnesses/opencode-sdk/sdk-runner.js +274 -191
  330. package/dist/integrations/harnesses/openhands/agent-builder.js +12 -10
  331. package/dist/integrations/harnesses/openhands/index.js +2 -12
  332. package/dist/integrations/harnesses/pi/agent-builder.js +11 -18
  333. package/dist/integrations/harnesses/pi/index.js +3 -16
  334. package/dist/integrations/harnesses/shared.js +17 -0
  335. package/dist/integrations/harnesses/types.js +38 -34
  336. package/dist/integrations/lockfile.js +211 -24
  337. package/dist/integrations/session-logs/index.js +24 -40
  338. package/dist/integrations/session-logs/provider-base.js +113 -0
  339. package/dist/llm/client.js +182 -110
  340. package/dist/llm/embedders/deterministic.js +2 -2
  341. package/dist/llm/embedders/remote.js +21 -9
  342. package/dist/llm/feature-gate.js +17 -57
  343. package/dist/llm/graph-extract.js +12 -13
  344. package/dist/llm/index-passes.js +8 -42
  345. package/dist/llm/memory-infer.js +144 -1
  346. package/dist/llm/metadata-enhance.js +45 -30
  347. package/dist/llm/structured-call.js +16 -8
  348. package/dist/llm/usage-persist.js +30 -5
  349. package/dist/llm/usage-telemetry.js +59 -6
  350. package/dist/output/cli-hints.js +1 -2
  351. package/dist/output/command-registry.js +27 -0
  352. package/dist/output/context.js +22 -7
  353. package/dist/output/format-exempt.js +80 -0
  354. package/dist/output/generic-render.js +251 -0
  355. package/dist/output/html-render.js +11 -16
  356. package/dist/output/render-registry.js +57 -0
  357. package/dist/output/renderers.js +15 -281
  358. package/dist/output/shapes/curate.js +10 -1
  359. package/dist/output/shapes/events.js +12 -7
  360. package/dist/output/shapes/helpers.js +58 -84
  361. package/dist/output/shapes/passthrough.js +8 -40
  362. package/dist/output/shapes/proposal/producer.js +15 -7
  363. package/dist/output/shapes/registry.js +12 -6
  364. package/dist/output/shapes.js +0 -9
  365. package/dist/output/text/{init.js → bundle-create.js} +3 -1
  366. package/dist/output/text/bundle-show.js +7 -0
  367. package/dist/output/text/command-format.js +562 -0
  368. package/dist/output/text/env.js +1 -3
  369. package/dist/output/text/events.js +8 -7
  370. package/dist/output/text/helpers.js +15 -1375
  371. package/dist/output/text/proposal/producer.js +4 -2
  372. package/dist/output/text/proposal-format.js +202 -0
  373. package/dist/output/text/registry-commands.js +1 -2
  374. package/dist/output/text/registry.js +12 -6
  375. package/dist/output/text/show-directives.js +117 -0
  376. package/dist/output/text/show-format.js +103 -0
  377. package/dist/output/text/sync.js +5 -0
  378. package/dist/output/text/workflow-format.js +332 -0
  379. package/dist/output/text/workflow.js +1 -2
  380. package/dist/output/text.js +10 -19
  381. package/dist/registry/factory.js +4 -6
  382. package/dist/registry/origin-resolve.js +16 -27
  383. package/dist/registry/providers/skills-sh.js +3 -3
  384. package/dist/registry/providers/static-index.js +15 -25
  385. package/dist/registry/resolve.js +43 -94
  386. package/dist/registry/semver.js +43 -0
  387. package/dist/runtime.js +81 -12
  388. package/dist/scripts/akm-migrate.js +35529 -0
  389. package/dist/setup/detect.js +5 -7
  390. package/dist/setup/detected-engines.js +136 -0
  391. package/dist/setup/engine-config.js +100 -0
  392. package/dist/setup/registry-stash-loader.js +3 -3
  393. package/dist/setup/semantic-assets.js +12 -9
  394. package/dist/setup/setup.js +444 -208
  395. package/dist/setup/steps/connection-shared.js +120 -0
  396. package/dist/setup/steps/connection.js +108 -305
  397. package/dist/setup/steps/platforms.js +13 -12
  398. package/dist/setup/steps/semantic.js +15 -3
  399. package/dist/setup/steps/sources.js +21 -15
  400. package/dist/setup/steps/stashdir.js +6 -4
  401. package/dist/setup/steps/tasks.js +236 -119
  402. package/dist/setup/steps.js +3 -2
  403. package/dist/sources/freshness.js +39 -0
  404. package/dist/sources/provider-factory.js +11 -17
  405. package/dist/sources/providers/filesystem.js +2 -3
  406. package/dist/sources/providers/git-install.js +278 -34
  407. package/dist/sources/providers/git-provider.js +54 -56
  408. package/dist/sources/providers/git-stash.js +420 -91
  409. package/dist/sources/providers/git.js +2 -2
  410. package/dist/sources/providers/npm.js +16 -19
  411. package/dist/sources/providers/provider-utils.js +47 -22
  412. package/dist/sources/providers/sync-from-ref.js +3 -9
  413. package/dist/sources/providers/website.js +2 -2
  414. package/dist/sources/resolve.js +11 -10
  415. package/dist/sources/snapshot-fetchers/types.js +4 -0
  416. package/dist/sources/{website-ingest.js → snapshot-fetchers/website-ingest.js} +110 -41
  417. package/dist/storage/database.js +60 -4
  418. package/dist/storage/engines/sqlite-migrations.js +156 -5
  419. package/dist/storage/locations.js +1 -2
  420. package/dist/storage/repositories/canaries-repository.js +1 -1
  421. package/dist/storage/repositories/events-repository.js +51 -11
  422. package/dist/storage/repositories/improve-runs-repository.js +6 -32
  423. package/dist/storage/repositories/index-connection.js +79 -0
  424. package/dist/storage/repositories/index-db.js +4 -3
  425. package/dist/storage/repositories/index-entries-repository.js +863 -0
  426. package/dist/{indexer/db/entry-mapper.js → storage/repositories/index-entry-mapper.js} +19 -2
  427. package/dist/storage/repositories/index-entry-types.js +4 -0
  428. package/dist/storage/repositories/index-fts-repository.js +167 -0
  429. package/dist/storage/repositories/index-llm-cache-repository.js +108 -0
  430. package/dist/storage/repositories/index-meta-repository.js +49 -0
  431. package/dist/{indexer/db/schema.js → storage/repositories/index-schema.js} +226 -100
  432. package/dist/storage/repositories/index-sql.js +12 -0
  433. package/dist/storage/repositories/index-utility-repository.js +356 -0
  434. package/dist/storage/repositories/index-vec-repository.js +250 -0
  435. package/dist/storage/repositories/outcome-repository.js +119 -0
  436. package/dist/storage/repositories/proposals-repository.js +317 -75
  437. package/dist/storage/repositories/registry-cache.js +1 -1
  438. package/dist/storage/repositories/salience-repository.js +172 -0
  439. package/dist/storage/repositories/task-history-repository.js +110 -3
  440. package/dist/storage/repositories/workflow-runs-repository.js +68 -35
  441. package/dist/tasks/backends/cron.js +169 -46
  442. package/dist/tasks/backends/exec-utils.js +76 -3
  443. package/dist/tasks/backends/index.js +6 -9
  444. package/dist/tasks/backends/launchd.js +292 -55
  445. package/dist/tasks/backends/schtasks.js +557 -70
  446. package/dist/tasks/backends/types.js +4 -0
  447. package/dist/tasks/command-executable.js +93 -0
  448. package/dist/tasks/embedded.js +56 -38
  449. package/dist/tasks/parser.js +156 -64
  450. package/dist/tasks/resolve-akm-bin.js +144 -51
  451. package/dist/tasks/runner.js +377 -209
  452. package/dist/tasks/schedule.js +108 -19
  453. package/dist/tasks/scheduler-invocation.js +296 -0
  454. package/dist/tasks/schema.js +1 -1
  455. package/dist/tasks/task-id.js +35 -0
  456. package/dist/tasks/validator.js +30 -16
  457. package/dist/workflows/authoring/authoring.js +96 -148
  458. package/dist/workflows/authoring/scope-key.js +1 -1
  459. package/dist/workflows/cli.js +0 -20
  460. package/dist/workflows/concurrency-policy.js +15 -0
  461. package/dist/workflows/exec/brief.js +25 -59
  462. package/dist/workflows/exec/frozen-judge.js +47 -0
  463. package/dist/workflows/exec/native-executor.js +157 -94
  464. package/dist/workflows/exec/report.js +365 -200
  465. package/dist/workflows/exec/run-workflow.js +46 -40
  466. package/dist/workflows/exec/scheduler.js +12 -41
  467. package/dist/workflows/exec/step-work.js +239 -205
  468. package/dist/workflows/exec/workflow-engine-gate.js +67 -0
  469. package/dist/workflows/ir/compile.js +141 -283
  470. package/dist/workflows/ir/freeze.js +233 -0
  471. package/dist/workflows/ir/plan-hash.js +40 -5
  472. package/dist/workflows/ir/schema.js +537 -1
  473. package/dist/workflows/parser.js +878 -306
  474. package/dist/workflows/program/expressions.js +20 -208
  475. package/dist/workflows/program/schema.js +7 -10
  476. package/dist/workflows/renderer.js +99 -121
  477. package/dist/workflows/resource-limits.js +22 -0
  478. package/dist/workflows/runtime/checkin.js +1 -1
  479. package/dist/workflows/runtime/plan-classifier.js +131 -0
  480. package/dist/workflows/runtime/runs.js +200 -113
  481. package/dist/workflows/runtime/unit-checkin.js +1 -1
  482. package/dist/workflows/runtime/unit-phases.js +20 -0
  483. package/dist/workflows/runtime/workflow-asset-loader.js +235 -97
  484. package/dist/workflows/schema.js +1 -11
  485. package/dist/workflows/validate-summary.js +4 -26
  486. package/dist/workflows/validator.js +52 -30
  487. package/docs/README.md +42 -78
  488. package/docs/migration/README.md +8 -0
  489. package/docs/migration/release-notes/0.6.0.md +1 -1
  490. package/docs/migration/release-notes/0.7.0.md +9 -8
  491. package/docs/migration/release-notes/0.9.0.md +158 -14
  492. package/docs/migration/v0.7-to-v0.8.md +46 -47
  493. package/docs/migration/v0.8-to-v0.9.md +844 -0
  494. package/docs/reference/README.md +12 -0
  495. package/docs/reference/data-and-telemetry.md +333 -0
  496. package/package.json +21 -17
  497. package/schemas/akm-asset-envelope.json +93 -0
  498. package/schemas/akm-config.json +4636 -0
  499. package/schemas/akm-task.json +87 -0
  500. package/{dist/schemas → schemas}/akm-workflow.json +127 -82
  501. package/dist/akm-migrate-storage +0 -38
  502. package/dist/assets/help/help-accept.md +0 -12
  503. package/dist/assets/help/help-improve.md +0 -84
  504. package/dist/assets/help/help-proposals.md +0 -17
  505. package/dist/assets/help/help-propose.md +0 -17
  506. package/dist/assets/help/help-reject.md +0 -11
  507. package/dist/assets/profiles/frequent.json +0 -13
  508. package/dist/assets/profiles/recombine-only.json +0 -21
  509. package/dist/assets/profiles/reflect-distill.json +0 -30
  510. package/dist/assets/profiles/synthesize.json +0 -15
  511. package/dist/assets/prompts/procedural-system.md +0 -44
  512. package/dist/assets/prompts/recombine-system.md +0 -40
  513. package/dist/assets/prompts/staleness-detect-system.md +0 -6
  514. package/dist/assets/tasks/core/backup.yml +0 -4
  515. package/dist/assets/tasks/graph-refresh-weekly.yml +0 -10
  516. package/dist/assets/templates/html/default.html +0 -78
  517. package/dist/assets/templates/html/vendor/echarts.min.js +0 -45
  518. package/dist/assets/wiki/index-template.md +0 -12
  519. package/dist/assets/wiki/ingest-workflow-template.md +0 -83
  520. package/dist/assets/wiki/log-template.md +0 -8
  521. package/dist/assets/wiki/schema-template.md +0 -61
  522. package/dist/cli/config-migrate.js +0 -150
  523. package/dist/cli/config-validate.js +0 -39
  524. package/dist/commands/graph/graph-cli.js +0 -124
  525. package/dist/commands/graph/graph.js +0 -487
  526. package/dist/commands/improve/calibration.js +0 -161
  527. package/dist/commands/improve/dedup.js +0 -482
  528. package/dist/commands/improve/extract-watch.js +0 -140
  529. package/dist/commands/improve/hot-probation.js +0 -45
  530. package/dist/commands/improve/improve-auto-accept.js +0 -276
  531. package/dist/commands/improve/improve-profiles.js +0 -168
  532. package/dist/commands/improve/procedural.js +0 -398
  533. package/dist/commands/improve/recombine.js +0 -818
  534. package/dist/commands/improve/schema-similarity-gate.js +0 -168
  535. package/dist/commands/lint/agent-linter.js +0 -44
  536. package/dist/commands/lint/command-linter.js +0 -44
  537. package/dist/commands/lint/default-linter.js +0 -16
  538. package/dist/commands/lint/fact-linter.js +0 -39
  539. package/dist/commands/lint/knowledge-linter.js +0 -16
  540. package/dist/commands/lint/memory-linter.js +0 -61
  541. package/dist/commands/lint/registry.js +0 -41
  542. package/dist/commands/lint/skill-linter.js +0 -45
  543. package/dist/commands/lint/task-linter.js +0 -50
  544. package/dist/commands/lint/workflow-linter.js +0 -81
  545. package/dist/commands/proposal/legacy-import.js +0 -115
  546. package/dist/commands/sources/history.js +0 -196
  547. package/dist/commands/tasks/default-tasks.js +0 -186
  548. package/dist/commands/wiki-cli.js +0 -292
  549. package/dist/core/asset/asset-registry.js +0 -76
  550. package/dist/core/asset/asset-spec.js +0 -316
  551. package/dist/core/config/config-migration.js +0 -602
  552. package/dist/core/deep-merge.js +0 -38
  553. package/dist/core/eval/rank-metrics.js +0 -113
  554. package/dist/core/ripgrep/install.js +0 -163
  555. package/dist/core/ripgrep/resolve.js +0 -81
  556. package/dist/indexer/db/db.js +0 -1414
  557. package/dist/indexer/manifest.js +0 -170
  558. package/dist/indexer/passes/metadata-contributors.js +0 -31
  559. package/dist/indexer/usage/unmigrated-vaults-guard.js +0 -94
  560. package/dist/integrations/harnesses/opencode-sdk/index.js +0 -25
  561. package/dist/llm/call-ai.js +0 -62
  562. package/dist/llm/memory-infer-impl.js +0 -138
  563. package/dist/output/shapes/distill.js +0 -14
  564. package/dist/output/shapes/history.js +0 -11
  565. package/dist/output/text/distill.js +0 -6
  566. package/dist/output/text/enable-disable.js +0 -8
  567. package/dist/output/text/history.js +0 -6
  568. package/dist/output/text/wiki.js +0 -16
  569. package/dist/registry/build-index.js +0 -386
  570. package/dist/schemas/akm-config.json +0 -14225
  571. package/dist/scripts/migrate-storage.js +0 -13169
  572. package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +0 -10169
  573. package/dist/scripts/migrations/v16-to-v17.js +0 -141
  574. package/dist/setup/legacy-config.js +0 -106
  575. package/dist/storage/repositories/consolidation-repository.js +0 -38
  576. package/dist/storage/repositories/recombine-repository.js +0 -213
  577. package/dist/wiki/wiki-templates.js +0 -15
  578. package/dist/wiki/wiki.js +0 -1012
  579. package/dist/workflows/authoring/workflow-program-template.yaml +0 -31
  580. package/dist/workflows/db.js +0 -350
  581. package/dist/workflows/exec/watch.js +0 -116
  582. package/dist/workflows/program/parser.js +0 -760
  583. package/dist/workflows/program/project.js +0 -105
  584. package/docs/data-and-telemetry.md +0 -227
  585. package/docs/migration/release-notes/0.9.0-beta.60.md +0 -19
  586. /package/dist/sources/{wiki-fetchers → snapshot-fetchers}/registry.js +0 -0
  587. /package/dist/sources/{wiki-fetchers → snapshot-fetchers}/youtube.js +0 -0
@@ -3,42 +3,64 @@
3
3
  // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
4
  import fs from "node:fs";
5
5
  import path from "node:path";
6
- import { SCRIPT_EXTENSIONS } from "../core/asset/asset-spec.js";
7
- import { isHttpUrl, resolveStashDir, toErrorMessage } from "../core/common.js";
6
+ import { detectAdapterId } from "../core/adapter/detect-adapter.js";
7
+ import { adapterForId } from "../core/adapter/registry.js";
8
+ import { isHttpUrl, toErrorMessage } from "../core/common.js";
8
9
  import { concurrentMap } from "../core/concurrent.js";
9
10
  import { getDbPath } from "../core/paths.js";
11
+ import { SCRIPT_EXTENSIONS } from "../core/recognition-util.js";
12
+ import { withStateDb } from "../core/state-db.js";
10
13
  import { isVerbose, warn, warnVerbose } from "../core/warn.js";
11
14
  import { resolveIndexPassLLM } from "../llm/index-passes.js";
15
+ import { resolveSourcesForOrigin } from "../registry/origin-resolve.js";
16
+ import { closeDatabase, openExistingDatabase, openIndexDatabase } from "../storage/repositories/index-connection.js";
17
+ import { deleteEntriesByDirAndStash, deleteEntriesByDirExceptKeys, deleteEntriesByIds, deleteEntriesByStashDir, deleteUsageEventsByEntryIds, findEntryIdByRef, getAllEntries, getEmbeddableEntryCount, getEntryCount, getIndexedDirPathsByStashDir, getIndexedStashDirsByDir, relinkUsageEvents, upsertEntry, upsertWorkflowDocument, } from "../storage/repositories/index-entries-repository.js";
18
+ import { rebuildFts } from "../storage/repositories/index-fts-repository.js";
19
+ import { clearStaleCacheEntries } from "../storage/repositories/index-llm-cache-repository.js";
20
+ import { deleteIndexDirState, deleteIndexDirStatesByStashDir, getMeta, setMeta, upsertIndexDirState, } from "../storage/repositories/index-meta-repository.js";
21
+ import { upsertUtilityScore } from "../storage/repositories/index-utility-repository.js";
22
+ import { getAllEntriesForEmbedding, getEmbeddingCount, isVecAvailable, purgeEmbeddings, setVecFastPathReady, upsertEmbedding, warnIfVecMissing, } from "../storage/repositories/index-vec-repository.js";
12
23
  import { takeWorkflowDocument } from "../workflows/runtime/document-cache.js";
13
- import { clearStaleCacheEntries, closeDatabase, deleteEntriesByDir, deleteEntriesByIds, deleteEntriesByStashDir, deleteIndexDirStatesByStashDir, getAllEntriesForEmbedding, getEmbeddableEntryCount, getEmbeddingCount, getEntryCount, getMeta, isVecAvailable, openExistingDatabase, openIndexDatabase, purgeEmbeddings, rebuildFts, relinkUsageEvents, setMeta, upsertEmbedding, upsertEntry, upsertIndexDirState, upsertUtilityScore, upsertWorkflowDocument, warnIfVecMissing, } from "./db/db.js";
14
24
  import { deleteStoredGraph } from "./db/graph-db.js";
15
25
  import { withIndexWriterLease } from "./index-writer-lock.js";
26
+ import { deriveEntryProvenance, deriveInstallations } from "./installations.js";
16
27
  import { canUseIncrementalSkip, computeDirFingerprint, getCachedZeroRowDirState, getDirIndexState, inferZeroRowReason, } from "./passes/dir-staleness.js";
17
- import { applyCuratedFrontmatter, applyWikiFrontmatter, generateMetadataFlat, isEnrichmentComplete, isWorkflowSkipWarning, loadStashFile, shouldIndexStashFile, } from "./passes/metadata.js";
28
+ import { isEnrichmentComplete, isWorkflowSkipWarning } from "./passes/metadata.js";
29
+ import { drainDirDocuments } from "./scan/drain-dir.js";
18
30
  import { buildSearchText } from "./search/search-fields.js";
19
31
  import { classifySemanticFailure, clearSemanticStatus, deriveSemanticProviderFingerprint, writeSemanticStatus, } from "./search/semantic-status.js";
20
- import { ensureUsageEventsSchema, purgeOldUsageEvents } from "./usage/usage-events.js";
21
- import { walkStashFlat } from "./walk/walker.js";
32
+ import { purgeOldUsageEvents } from "./usage/usage-events.js";
33
+ import { walkStashFlatWithStatus } from "./walk/walker.js";
22
34
  function throwIfAborted(signal) {
23
35
  if (signal?.aborted) {
24
36
  throw signal.reason instanceof Error ? signal.reason : new Error("index interrupted");
25
37
  }
26
38
  }
27
- function getDefaultLlmConcurrency(llmConfig) {
39
+ export function getDefaultLlmConcurrency(llmConfig) {
28
40
  if (typeof llmConfig?.concurrency === "number")
29
41
  return llmConfig.concurrency;
30
42
  if (!llmConfig?.endpoint)
31
43
  return 1;
32
44
  try {
33
45
  const url = new URL(llmConfig.endpoint);
34
- const host = url.hostname.toLowerCase();
46
+ // URL.hostname keeps IPv6 brackets ("[::1]") — strip them so the loopback
47
+ // comparison actually matches.
48
+ const host = url.hostname.toLowerCase().replace(/^\[|\]$/g, "");
35
49
  if (host === "localhost" || host === "127.0.0.1" || host === "::1" || host.endsWith(".localhost"))
36
50
  return 1;
37
51
  }
38
52
  catch {
39
53
  return 1;
40
54
  }
41
- return 4;
55
+ // Remote endpoints default to a modest 2-wide pool (owner ruling 2026-07-21):
56
+ // enough to overlap request latency without hammering rate-limited APIs.
57
+ // Local model servers stay at 1 (single loaded model; parallel requests
58
+ // trigger reload thrash). The explicit-override branch above only fires for
59
+ // callers that put `concurrency` on the connection themselves —
60
+ // `engines.<name>.concurrency` is a valid schema field but `resolveLlmEngineUse`
61
+ // does NOT copy it into the resolved connection, so on the enrichment path the
62
+ // auto-derived 1/2 is what runs (see docs/architecture/internals/indexing.md).
63
+ return 2;
42
64
  }
43
65
  // ── Phase functions ──────────────────────────────────────────────────────────
44
66
  /**
@@ -69,9 +91,7 @@ async function runSourceCachePhase(ctx) {
69
91
  for (const dir of prevStashDirs) {
70
92
  if (!currentSet.has(dir)) {
71
93
  ctx.hadRemovedSources = true;
72
- deleteEntriesByStashDir(db, dir);
73
- deleteIndexDirStatesByStashDir(db, dir);
74
- deleteStoredGraph(db, dir);
94
+ ctx.removedSourceDirs.push(dir);
75
95
  }
76
96
  }
77
97
  }
@@ -80,6 +100,15 @@ async function runSourceCachePhase(ctx) {
80
100
  // further to do here. The flag is exposed on ctx for runWalkPhase().
81
101
  void config;
82
102
  }
103
+ function applyRemovedSources(ctx) {
104
+ if (!ctx.scanComplete)
105
+ return;
106
+ for (const dir of ctx.removedSourceDirs) {
107
+ deleteEntriesByStashDir(ctx.db, dir);
108
+ deleteIndexDirStatesByStashDir(ctx.db, dir);
109
+ deleteStoredGraph(ctx.db, dir);
110
+ }
111
+ }
83
112
  /**
84
113
  * Walk phase: scan the filesystem, generate metadata, and persist entries to
85
114
  * the database. Also kicks off LLM enrichment for directories that need it.
@@ -88,16 +117,17 @@ async function runSourceCachePhase(ctx) {
88
117
  * `ctx.walkWarnings`, and `ctx.dirsNeedingLlm` for downstream phases.
89
118
  */
90
119
  async function runWalkPhase(ctx) {
91
- const { db, sources, isIncremental, builtAtMs, hadRemovedSources, full, reEnrich, signal, onProgress, config } = ctx;
120
+ const { db, sources, isIncremental, builtAtMs, hadRemovedSources, full, clean, signal, onProgress, config } = ctx;
92
121
  throwIfAborted(signal);
93
122
  ctx.timing.tWalkStart = Date.now();
94
123
  const doFullDelete = full || !isIncremental;
95
- const { scannedDirs, skippedDirs, generatedCount, dirsNeedingLlm, warnings } = await indexEntries(db, sources, isIncremental, builtAtMs, hadRemovedSources, doFullDelete, onProgress);
124
+ const { scannedDirs, skippedDirs, generatedCount, dirsNeedingLlm, warnings, complete } = await indexEntries(db, sources, isIncremental, builtAtMs, hadRemovedSources, doFullDelete, onProgress, !clean);
96
125
  ctx.scannedDirs = scannedDirs;
97
126
  ctx.skippedDirs = skippedDirs;
98
127
  ctx.generatedCount = generatedCount;
99
128
  ctx.walkWarnings = warnings;
100
129
  ctx.dirsNeedingLlm = dirsNeedingLlm;
130
+ ctx.scanComplete = complete;
101
131
  onProgress({
102
132
  phase: "scan",
103
133
  message: `Scanned ${scannedDirs} ${scannedDirs === 1 ? "directory" : "directories"} and skipped ${skippedDirs}.`,
@@ -118,7 +148,7 @@ async function runWalkPhase(ctx) {
118
148
  ctx.timing.tWalkEnd = Date.now();
119
149
  throwIfAborted(signal);
120
150
  // LLM enrichment for directories that need it
121
- await enhanceDirsWithLlm(db, config, dirsNeedingLlm, onProgress, signal, reEnrich);
151
+ await enhanceDirsWithLlm(db, config, dirsNeedingLlm, onProgress, signal);
122
152
  onProgress({
123
153
  phase: "llm",
124
154
  message: resolveIndexPassLLM("enrichment", config)
@@ -153,11 +183,20 @@ async function runFinalizePhase(ctx) {
153
183
  message: isIncremental ? "Rebuilt full-text search index (dirty rows only)." : "Rebuilt full-text search index.",
154
184
  });
155
185
  ctx.timing.tFtsEnd = Date.now();
156
- // Re-link detached usage_events and recompute utility scores.
157
- onProgress({ phase: "finalize", message: "Relinking usage events." });
158
- relinkUsageEvents(db);
159
- onProgress({ phase: "finalize", message: "Recomputing utility scores." });
160
- recomputeUtilityScores(db);
186
+ // Re-link detached usage_events and recompute utility scores. The one-time
187
+ // §11.4 legacy→item_ref re-key is owned by the migration cutover
188
+ // (020-three-db-cutover) now, so index finalize only re-resolves entry_ids
189
+ // (idempotent) — every stored `entry_ref` is already the item_ref spelling.
190
+ //
191
+ // Chunk-8 WI-8.3: usage_events lives in state.db now (index.db no longer holds
192
+ // it), so these cross-DB passes take both handles — entries in `db` (index.db),
193
+ // usage_events in the loaned state.db.
194
+ withStateDb((stateDb) => {
195
+ onProgress({ phase: "finalize", message: "Relinking usage events." });
196
+ relinkUsageEvents(db, stateDb, { sources, defaultStashDir: stashDir });
197
+ onProgress({ phase: "finalize", message: "Recomputing utility scores." });
198
+ recomputeUtilityScores(db, stateDb);
199
+ });
161
200
  // Purge LLM cache entries for assets that no longer exist in the index.
162
201
  try {
163
202
  onProgress({ phase: "finalize", message: "Clearing stale LLM cache entries." });
@@ -166,22 +205,22 @@ async function runFinalizePhase(ctx) {
166
205
  catch {
167
206
  /* ignore */
168
207
  }
169
- // Regenerate each wiki's index.md from its pages' frontmatter. Best-effort.
170
- try {
171
- onProgress({ phase: "finalize", message: "Regenerating wiki indexes." });
172
- const { regenerateAllWikiIndexes } = await import("../wiki/wiki.js");
173
- regenerateAllWikiIndexes(stashDir);
174
- }
175
- catch {
176
- /* best-effort */
177
- }
178
208
  throwIfAborted(signal);
179
- // Update index metadata
209
+ // An incomplete run preserves the prior freshness watermark. Advancing it
210
+ // could make a recovered source look unchanged even though this run never
211
+ // persisted its files.
180
212
  const embeddingResult = ctx.embeddingResult ?? { success: false };
181
- setMeta(db, "builtAt", new Date().toISOString());
182
- setMeta(db, "stashDir", stashDir);
183
- setMeta(db, "stashDirs", JSON.stringify(sourceDirs));
213
+ if (ctx.scanComplete) {
214
+ setMeta(db, "builtAt", new Date().toISOString());
215
+ setMeta(db, "stashDir", stashDir);
216
+ setMeta(db, "stashDirs", JSON.stringify(sourceDirs));
217
+ }
184
218
  setMeta(db, "hasEmbeddings", embeddingResult.success ? "1" : "0");
219
+ // Stash-organization conventions (SPEC-8): track which `index.indexBodyOpening`
220
+ // state the index was built with, and warn while the flag diverges from it.
221
+ const bodyOpeningWarning = reconcileBodyOpeningIndexState(db, config.index?.indexBodyOpening === true, (ctx.full || !isIncremental) && ctx.scanComplete);
222
+ if (bodyOpeningWarning)
223
+ warn(bodyOpeningWarning);
185
224
  warnIfVecMissing(db);
186
225
  const totalEntries = getEntryCount(db);
187
226
  const semanticEntryCount = getEmbeddableEntryCount(db);
@@ -209,6 +248,41 @@ async function runFinalizePhase(ctx) {
209
248
  // suppress unused warning — sources was previously used inline
210
249
  void sources;
211
250
  }
251
+ /**
252
+ * Stash-organization conventions (SPEC-8): reconcile the `index.indexBodyOpening`
253
+ * flag with the state the index was last FULLY built with (index_meta key
254
+ * `indexBodyOpening`), returning a warning message while they diverge.
255
+ *
256
+ * Incremental runs re-extract only changed files (and embeddings are only
257
+ * generated for rows lacking one), so a flag toggle leaves the index MIXED
258
+ * until a full rebuild — `akm index --full` re-extracts every entry and wipes
259
+ * embeddings so they regenerate from the new text. The warning therefore
260
+ * repeats on every incremental run until a full walk records the flag state
261
+ * as applied.
262
+ *
263
+ * A missing meta key on an incremental run means the index predates this
264
+ * feature, i.e. it was necessarily built with the flag OFF — so the absent
265
+ * key reads (and is seeded) as "0", never as the current flag value. This
266
+ * keeps the most likely real toggle scenario — upgrade, enable the flag, run
267
+ * a plain `akm index` — warning until `--full` runs (review finding).
268
+ *
269
+ * Exported for tests; production's only caller is the finalize phase above.
270
+ */
271
+ export function reconcileBodyOpeningIndexState(db, flagEnabled, isFullWalk) {
272
+ const bodyOpeningFlag = flagEnabled ? "1" : "0";
273
+ const prevBodyOpeningFlag = getMeta(db, "indexBodyOpening") ?? "0";
274
+ // Only a full walk (which includes the first build ever) may record the
275
+ // current flag as the applied state; incremental runs preserve — or, for a
276
+ // pre-feature index, seed — the state of the last full build.
277
+ setMeta(db, "indexBodyOpening", isFullWalk ? bodyOpeningFlag : prevBodyOpeningFlag);
278
+ if (isFullWalk || prevBodyOpeningFlag === bodyOpeningFlag)
279
+ return undefined;
280
+ return (`index.indexBodyOpening is ${flagEnabled ? "enabled" : "disabled"} but the index was built with it ` +
281
+ `${flagEnabled ? "disabled" : "enabled"}. Incremental runs only re-extract changed files, so ` +
282
+ "indexed text and embeddings are stale for unchanged entries. Run `akm index --full` to apply the new " +
283
+ "setting everywhere (embeddings regenerate), and re-mint collapse-detector canary baselines via " +
284
+ "`bun scripts/refresh-canary-set.ts --refresh` if you use them.");
285
+ }
212
286
  // ── Clean pass ───────────────────────────────────────────────────────────────
213
287
  /**
214
288
  * Post-index clean pass: scan the `entries` table for rows whose source file
@@ -245,7 +319,82 @@ export async function akmIndex(options) {
245
319
  return akmIndexOverride(options);
246
320
  return akmIndexReal(options);
247
321
  }
322
+ /**
323
+ * Detect an adapter for every resolvable source that does not declare one, and
324
+ * persist each detection into `config.json`.
325
+ *
326
+ * R-056: this config write previously had zero disclosure — it appeared in no
327
+ * result, on no stream, and in no doc. The returned `persistedAdapters` records
328
+ * exactly which bundle→adapter pairs the mutate callback actually applied, so
329
+ * the caller can surface them in the result envelope; a stderr notice is
330
+ * emitted here. The map is cleared at the top of every callback invocation
331
+ * because `mutateConfig` may retry optimistically, and a retry must not report
332
+ * a superseded attempt.
333
+ *
334
+ * Extracted from `akmIndexReal` as one self-contained named pass, both to keep
335
+ * that function under the src-wide function-size bar and because the detection
336
+ * and its disclosure belong together.
337
+ */
338
+ function detectAndPersistBundleAdapters(allSourceEntries, config, mutateConfig, opts) {
339
+ const detectedByBundle = new Map();
340
+ for (const source of allSourceEntries) {
341
+ if (source.adapterId || source.unresolved)
342
+ continue;
343
+ if (allSourceRootsReadable([source.path])) {
344
+ source.adapterId = detectAdapterId(source.path);
345
+ if (source.registryId)
346
+ detectedByBundle.set(source.registryId, source.adapterId);
347
+ }
348
+ }
349
+ const persistedAdapters = {};
350
+ if (detectedByBundle.size === 0)
351
+ return { config, persistedAdapters };
352
+ const nextConfig = mutateConfig((current) => {
353
+ if (!current.bundles)
354
+ return current;
355
+ let changed = false;
356
+ const bundles = { ...current.bundles };
357
+ for (const key of Object.keys(persistedAdapters))
358
+ delete persistedAdapters[key];
359
+ for (const [bundleId, adapter] of detectedByBundle) {
360
+ const bundle = bundles[bundleId];
361
+ if (!bundle)
362
+ continue;
363
+ const componentEntries = Object.entries(bundle.components ?? {});
364
+ const [componentId, component] = componentEntries[0] ?? ["main", {}];
365
+ if (component.adapter)
366
+ continue;
367
+ bundles[bundleId] = { ...bundle, components: { [componentId]: { ...component, adapter } } };
368
+ changed = true;
369
+ persistedAdapters[bundleId] = adapter;
370
+ }
371
+ return changed ? { ...current, bundles } : current;
372
+ }, { absentNoop: true }).config;
373
+ const persistedCount = Object.keys(persistedAdapters).length;
374
+ if (persistedCount > 0 && opts.announce) {
375
+ const summary = Object.entries(persistedAdapters)
376
+ .map(([bundleId, adapter]) => `${bundleId} → ${adapter}`)
377
+ .join(", ");
378
+ warn(`[index] Detected adapter${persistedCount === 1 ? "" : "s"} for ${summary}; persisted to config.json ` +
379
+ "(bundles.<id>.components.<component>.adapter).");
380
+ }
381
+ return { config: nextConfig, persistedAdapters };
382
+ }
248
383
  async function akmIndexReal(options) {
384
+ // R-022: `dryRun` only ever gated the `--clean` stale-entry removal pass
385
+ // (see `runCleanPass` below) — every other phase (walk, LLM enrichment,
386
+ // embeddings, FTS, the adapter-detection config write) ran for real
387
+ // regardless, so `akm index --dry-run` alone silently performed a full,
388
+ // real index. The flag's own docs (`IndexOptions.dryRun` above, and the
389
+ // CLI help in stash-cli.ts) already scope it to `--clean`; reject the
390
+ // combination that was never implemented instead of quietly doing
391
+ // something other than what "dry run" promised. Checked before the writer
392
+ // lease is even requested so a bad invocation fails instantly.
393
+ if (options?.dryRun === true && options?.clean !== true) {
394
+ const { UsageError } = await import("../core/errors.js");
395
+ throw new UsageError("`--dry-run` only applies together with `--clean` (it previews which stale entries `--clean` would remove). " +
396
+ "Pass `akm index --clean --dry-run`, or drop `--dry-run` to run a real index.", "INVALID_FLAG_VALUE", "Run `akm index --clean --dry-run` to preview, or `akm index --clean` to apply.");
397
+ }
249
398
  const requestedAt = Date.now();
250
399
  let acquiredAt = requestedAt;
251
400
  return withIndexWriterLease({
@@ -261,30 +410,28 @@ async function akmIndexReal(options) {
261
410
  acquiredAt = requestedAt + waitedMs;
262
411
  },
263
412
  }, async () => {
264
- const stashDir = options?.stashDir || resolveStashDir();
413
+ const stashDir = options.stashDir;
265
414
  const onProgress = options?.onProgress ?? (() => { });
266
415
  const signal = options?.signal;
267
- const reEnrich = options?.reEnrich === true;
268
416
  const full = options?.full === true;
269
417
  const clean = options?.clean === true;
270
418
  const dryRun = options?.dryRun === true;
271
419
  // Load config and resolve all stash sources
272
- const { loadConfig } = await import("../core/config/config.js");
273
- const config = loadConfig();
274
- // One-time, read-only guard: warn if the writable stash still holds an
275
- // un-migrated `vaults/` directory. In 0.9.0 the indexer skips `vaults/`
276
- // entirely, so an unmigrated vault's `.env` data would silently never be
277
- // indexed. Non-destructive — only stats, never reads/writes/deletes.
278
- const { warnOnUnmigratedVaults } = await import("./usage/unmigrated-vaults-guard.js");
279
- warnOnUnmigratedVaults(stashDir);
420
+ const { loadConfig, mutateConfig } = await import("../core/config/config.js");
421
+ let config = loadConfig();
280
422
  // Ensure git stash caches are extracted before resolving stash dirs,
281
423
  // so their content directories exist on disk for the walker to discover.
282
424
  const sourceCacheStart = Date.now();
283
425
  onProgress({ phase: "preflight", message: "Hydrating source caches." });
284
426
  const { ensureSourceCaches, resolveSourceEntries } = await import("./search/search-source.js");
285
- await ensureSourceCaches(config, { force: full });
427
+ await ensureSourceCaches(config, { force: full, materialize: options.hydrateSources !== false });
286
428
  const sourceCacheEnd = Date.now();
287
429
  const allSourceEntries = resolveSourceEntries(stashDir, config);
430
+ const detected = detectAndPersistBundleAdapters(allSourceEntries, config, mutateConfig, {
431
+ announce: options.implicit !== true,
432
+ });
433
+ config = detected.config;
434
+ const persistedAdapters = detected.persistedAdapters;
288
435
  const allSourceDirs = allSourceEntries.map((s) => s.path);
289
436
  onProgress({
290
437
  phase: "preflight",
@@ -308,7 +455,7 @@ async function akmIndexReal(options) {
308
455
  sources: allSourceEntries,
309
456
  sourceDirs: allSourceDirs,
310
457
  full,
311
- reEnrich,
458
+ clean,
312
459
  stashDir,
313
460
  onProgress,
314
461
  signal,
@@ -325,6 +472,8 @@ async function akmIndexReal(options) {
325
472
  isIncremental,
326
473
  builtAtMs,
327
474
  hadRemovedSources: false,
475
+ removedSourceDirs: [],
476
+ scanComplete: true,
328
477
  scannedDirs: 0,
329
478
  skippedDirs: 0,
330
479
  generatedCount: 0,
@@ -346,6 +495,7 @@ async function akmIndexReal(options) {
346
495
  // ── Phase sequence ───────────────────────────────────────────────────────
347
496
  await runSourceCachePhase(ctx);
348
497
  await runWalkPhase(ctx);
498
+ applyRemovedSources(ctx);
349
499
  await runEmbeddingPhase(ctx);
350
500
  await runFinalizePhase(ctx);
351
501
  // ────────────────────────────────────────────────────────────────────────
@@ -363,7 +513,13 @@ async function akmIndexReal(options) {
363
513
  phase: "finalize",
364
514
  message: dryRun ? "Scanning for stale index entries (dry run)." : "Removing stale index entries.",
365
515
  });
366
- cleanResult = runCleanPass(db, dryRun);
516
+ if (ctx.scanComplete) {
517
+ cleanResult = runCleanPass(db, dryRun);
518
+ }
519
+ else {
520
+ warn("[index] --clean skipped because one or more configured sources were not scanned completely.");
521
+ cleanResult = { checked: 0, removed: 0, removedRefs: [], dryRun };
522
+ }
367
523
  }
368
524
  const cleanEnd = Date.now();
369
525
  // ────────────────────────────────────────────────────────────────────────
@@ -376,6 +532,9 @@ async function akmIndexReal(options) {
376
532
  directoriesScanned: ctx.scannedDirs,
377
533
  directoriesSkipped: ctx.skippedDirs,
378
534
  ...(ctx.walkWarnings.length > 0 ? { warnings: ctx.walkWarnings } : {}),
535
+ ...(Object.keys(persistedAdapters).length > 0
536
+ ? { configUpdated: { detectedAdapters: persistedAdapters } }
537
+ : {}),
379
538
  verification,
380
539
  timing: {
381
540
  totalMs: Date.now() - timing.t0,
@@ -398,32 +557,183 @@ async function akmIndexReal(options) {
398
557
  }
399
558
  });
400
559
  }
560
+ function removalsFirst(records) {
561
+ return [...records.filter((record) => record.remove), ...records.filter((record) => !record.remove)];
562
+ }
563
+ function addEntryIds(target, ids) {
564
+ for (const id of ids)
565
+ target.add(id);
566
+ }
567
+ /**
568
+ * Map each source root → its durable `BundleComponent` (`deriveInstallations`,
569
+ * batch-unique bundle ids, source order preserved). The per-dir document drain
570
+ * dispatches `adapterForId(component.adapter).recognize` for this component. The
571
+ * component id only surfaces on `IndexDocument.ref`, which the persist layer
572
+ * re-derives independently — so a source missing from the map (never happens: the
573
+ * map is built from the same sources) is harmless.
574
+ */
575
+ function buildComponentBySource(sources) {
576
+ const map = new Map();
577
+ const installations = deriveInstallations(sources);
578
+ sources.forEach((source, i) => {
579
+ const component = installations[i]?.components[0];
580
+ if (component)
581
+ map.set(source.path, component);
582
+ });
583
+ return map;
584
+ }
585
+ function componentForSource(components, sourcePath) {
586
+ return (components.get(sourcePath) ?? {
587
+ id: sourcePath,
588
+ adapter: "akm",
589
+ root: sourcePath,
590
+ writable: false,
591
+ });
592
+ }
593
+ function groupFileContextsByDir(fileContexts) {
594
+ const groups = new Map();
595
+ for (const ctx of fileContexts) {
596
+ const group = groups.get(ctx.parentDirAbs);
597
+ if (group)
598
+ group.push(ctx);
599
+ else
600
+ groups.set(ctx.parentDirAbs, [ctx]);
601
+ }
602
+ return groups;
603
+ }
604
+ function sourceSnapshotRemovals(db, currentStashDir, currentDirs, allIndexedDirsBySource) {
605
+ const indexedDirs = allIndexedDirsBySource?.get(path.resolve(currentStashDir)) ?? getIndexedDirPathsByStashDir(db, currentStashDir);
606
+ return [...indexedDirs]
607
+ .map((dirPath) => path.resolve(dirPath))
608
+ .filter((dirPath) => !currentDirs.has(dirPath))
609
+ .map((dirPath) => ({
610
+ dirPath,
611
+ currentStashDir,
612
+ files: [],
613
+ stash: null,
614
+ skip: false,
615
+ remove: true,
616
+ reason: { kind: "not-in-source-snapshot" },
617
+ }));
618
+ }
619
+ function buildSourceScanPlans(db, allSourceEntries, isIncremental, reconcileMissingDirs) {
620
+ const componentBySource = buildComponentBySource(allSourceEntries);
621
+ const handoffDirs = new Set();
622
+ const plans = allSourceEntries.map((sourceAdded) => {
623
+ const currentStashDir = sourceAdded.path;
624
+ const component = componentForSource(componentBySource, currentStashDir);
625
+ if (sourceAdded.unresolved) {
626
+ return {
627
+ currentStashDir,
628
+ component,
629
+ adapter: undefined,
630
+ indexVariant: undefined,
631
+ dirGroups: new Map(),
632
+ removals: [],
633
+ walkComplete: false,
634
+ };
635
+ }
636
+ const walked = walkStashFlatWithStatus(currentStashDir, {
637
+ includeAllDirectories: component.adapter === "okf",
638
+ });
639
+ const dirGroups = groupFileContextsByDir(walked.files);
640
+ const adapter = adapterForId(component.adapter);
641
+ return {
642
+ currentStashDir,
643
+ component,
644
+ adapter,
645
+ indexVariant: adapter ? `${adapter.id}@${adapter.version}` : undefined,
646
+ dirGroups,
647
+ removals: [],
648
+ walkComplete: walked.complete,
649
+ };
650
+ });
651
+ const removalKeys = new Set();
652
+ const addRemoval = (plan, dirPath, stashDir) => {
653
+ const resolvedDir = path.resolve(dirPath);
654
+ const key = `${resolvedDir}\0${path.resolve(stashDir)}`;
655
+ if (removalKeys.has(key))
656
+ return;
657
+ removalKeys.add(key);
658
+ plan.removals.push({
659
+ dirPath,
660
+ currentStashDir: stashDir,
661
+ files: [],
662
+ stash: null,
663
+ skip: false,
664
+ remove: true,
665
+ reason: { kind: "not-in-source-snapshot" },
666
+ });
667
+ handoffDirs.add(resolvedDir);
668
+ };
669
+ const allComplete = plans.every((plan) => plan.walkComplete && plan.adapter !== undefined);
670
+ // A full, globally-complete run uses the atomic table wipe below. Every
671
+ // other run reconciles only sources that produced trustworthy snapshots.
672
+ if (reconcileMissingDirs && (isIncremental || !allComplete)) {
673
+ const allIndexedDirsBySource = !isIncremental ? new Map() : undefined;
674
+ if (allIndexedDirsBySource) {
675
+ for (const entry of getAllEntries(db)) {
676
+ const sourceRoot = path.resolve(entry.stashDir);
677
+ const dirs = allIndexedDirsBySource.get(sourceRoot) ?? new Set();
678
+ dirs.add(path.resolve(entry.dirPath));
679
+ allIndexedDirsBySource.set(sourceRoot, dirs);
680
+ }
681
+ }
682
+ for (const plan of plans) {
683
+ if (!plan.walkComplete || !plan.adapter)
684
+ continue;
685
+ const currentDirs = new Set([...plan.dirGroups.keys()].map((dirPath) => path.resolve(dirPath)));
686
+ for (const removal of sourceSnapshotRemovals(db, plan.currentStashDir, currentDirs, allIndexedDirsBySource)) {
687
+ addRemoval(plan, removal.dirPath, removal.currentStashDir);
688
+ }
689
+ }
690
+ }
691
+ // Cross-source ownership handoffs can delete another source's rows, so they
692
+ // still require every possible owner to have completed its scan.
693
+ if (!allComplete)
694
+ return { plans, handoffDirs };
695
+ // The first configured source that exposes a physical directory owns it.
696
+ // Remove rows left by a prior owner even when both adapters are identical.
697
+ const claimedDirs = new Set();
698
+ for (const plan of plans) {
699
+ for (const dirPath of plan.dirGroups.keys()) {
700
+ const resolvedDir = path.resolve(dirPath);
701
+ if (claimedDirs.has(resolvedDir))
702
+ continue;
703
+ claimedDirs.add(resolvedDir);
704
+ for (const priorOwner of getIndexedStashDirsByDir(db, dirPath)) {
705
+ if (path.resolve(priorOwner) !== path.resolve(plan.currentStashDir)) {
706
+ addRemoval(plan, dirPath, priorOwner);
707
+ }
708
+ }
709
+ }
710
+ }
711
+ return { plans, handoffDirs };
712
+ }
401
713
  /**
402
714
  * Phase 1 (async): walk every source directory and pre-generate all metadata
403
715
  * outside any transaction, producing the per-directory scan records that
404
716
  * {@link persistDirRecords} later writes.
405
717
  *
406
- * generateMetadataFlat is async (uses dynamic import for the matcher/renderer
407
- * registry), so it cannot run inside a db.transaction() callback — hence the
408
- * split into a pure-ish scan pass and a synchronous persist pass.
718
+ * The per-dir document drain (`drainDirDocuments` × the component's dispatched
719
+ * `adapter.recognize`, F4a M-core-2) is synchronous, but the walk still runs
720
+ * outside `db.transaction()` so the persist pass can be a single synchronous
721
+ * transaction.
409
722
  */
410
- async function scanSourceDirs(db, allSourceEntries, isIncremental, builtAtMs, hadRemovedSources, onProgress) {
723
+ function reportSourceScanProgress(onProgress, processed, total, message) {
724
+ onProgress?.({ phase: "scan", message, processed, total });
725
+ }
726
+ async function scanSourceDirs(db, allSourceEntries, isIncremental, builtAtMs, hadRemovedSources, onProgress, reconcileMissingDirs = true) {
411
727
  let scannedDirs = 0;
412
728
  let skippedDirs = 0;
413
729
  let generatedCount = 0;
414
730
  const warnings = [];
415
731
  const seenPaths = new Set();
732
+ const { plans, handoffDirs } = buildSourceScanPlans(db, allSourceEntries, isIncremental, reconcileMissingDirs);
416
733
  const dirRecords = [];
417
734
  let processedDirs = 0;
418
735
  let priorDirsChanged = hadRemovedSources;
419
- const reportScanProgress = (message) => {
420
- onProgress?.({
421
- phase: "scan",
422
- message,
423
- processed: processedDirs,
424
- total: allSourceEntries.length,
425
- });
426
- };
736
+ const reportScanProgress = (message) => reportSourceScanProgress(onProgress, processedDirs, allSourceEntries.length, message);
427
737
  const reportDirDecision = (kind, dirPath, currentStashDir, reason, persistedRowCount) => {
428
738
  if (!isVerbose())
429
739
  return;
@@ -432,10 +742,7 @@ async function scanSourceDirs(db, allSourceEntries, isIncremental, builtAtMs, ha
432
742
  reportScanProgress(`${kind === "scan" ? "Rescanning" : "Skipping"} ${path.relative(currentStashDir, dirPath) || "."} ` +
433
743
  `from ${currentStashDir}: ${reason.kind}${detail}${rowInfo}`);
434
744
  };
435
- // Duplicate-directory guard shared by the wiki-root and normal branches. A
436
- // dir may surface via multiple stash roots; only the first occurrence is
437
- // indexed, and the later ones are recorded as skips. Returns true when the
438
- // dir was already seen (caller should skip further processing).
745
+ // Only the first source that exposes a physical directory may index it.
439
746
  const markSeenOrSkipDuplicate = (dirPath, currentStashDir, files) => {
440
747
  const resolved = path.resolve(dirPath);
441
748
  if (seenPaths.has(resolved)) {
@@ -450,9 +757,9 @@ async function scanSourceDirs(db, allSourceEntries, isIncremental, builtAtMs, ha
450
757
  // Incremental freshness gate shared by both branches: consult the persisted
451
758
  // dir state and record either a skip (unchanged + eligible for incremental
452
759
  // skip) or a scan record carrying the candidate stash.
453
- const recordFreshnessDecision = (dirPath, currentStashDir, stateFiles, stash) => {
454
- const previousState = getDirIndexState(db, dirPath, stateFiles, builtAtMs);
455
- if (isIncremental && !previousState.stale && canUseIncrementalSkip(previousState, priorDirsChanged)) {
760
+ const recordFreshnessDecision = (dirPath, currentStashDir, stateFiles, stash, hashByFile, conceptIdByFile, indexVariant, forceScan, pruneMissing) => {
761
+ const previousState = getDirIndexState(db, dirPath, stateFiles, builtAtMs, indexVariant);
762
+ if (isIncremental && !forceScan && !previousState.stale && canUseIncrementalSkip(previousState, priorDirsChanged)) {
456
763
  skippedDirs++;
457
764
  dirRecords.push({
458
765
  dirPath,
@@ -461,6 +768,7 @@ async function scanSourceDirs(db, allSourceEntries, isIncremental, builtAtMs, ha
461
768
  stash: null,
462
769
  skip: true,
463
770
  reason: previousState.reason,
771
+ indexVariant,
464
772
  });
465
773
  reportDirDecision("skip", dirPath, currentStashDir, previousState.reason, previousState.persistedRowCount);
466
774
  return;
@@ -476,63 +784,46 @@ async function scanSourceDirs(db, allSourceEntries, isIncremental, builtAtMs, ha
476
784
  skip: false,
477
785
  reason,
478
786
  persistedRowCount: previousState.persistedRowCount,
787
+ hashByFile,
788
+ conceptIdByFile,
789
+ indexVariant,
790
+ pruneMissing,
479
791
  });
480
792
  reportDirDecision("scan", dirPath, currentStashDir, reason, previousState.persistedRowCount);
481
793
  };
482
- for (const sourceAdded of allSourceEntries) {
483
- const currentStashDir = sourceAdded.path;
484
- const fileContexts = walkStashFlat(currentStashDir);
794
+ for (const plan of plans) {
795
+ const { currentStashDir, component, adapter, dirGroups, removals, walkComplete } = plan;
485
796
  processedDirs++;
486
797
  reportScanProgress(`Processed ${processedDirs}/${allSourceEntries.length} source${allSourceEntries.length === 1 ? "" : "s"}.`);
487
- // Wiki-root stashes: all .md files are indexed as wiki pages under wikiName
488
- if (sourceAdded.wikiName) {
489
- const wikiName = sourceAdded.wikiName;
490
- const wikiDirGroups = new Map();
491
- for (const ctx of fileContexts) {
492
- if (ctx.ext !== ".md")
493
- continue;
494
- if (!shouldIndexStashFile(currentStashDir, ctx.absPath, { treatStashRootAsWikiRoot: true }))
495
- continue;
496
- const relNoExt = ctx.relPath.replace(/\.md$/, "");
497
- const frontmatter = ctx.frontmatter() ?? {};
498
- const entry = {
499
- name: `${wikiName}/${relNoExt}`,
500
- type: "wiki",
501
- filename: ctx.fileName,
502
- quality: "generated",
503
- confidence: 0.55,
504
- source: "filename",
505
- };
506
- applyCuratedFrontmatter(entry, frontmatter);
507
- applyWikiFrontmatter(entry, frontmatter);
508
- const dir = ctx.parentDirAbs;
509
- const group = wikiDirGroups.get(dir);
510
- if (group) {
511
- group.files.push(ctx.absPath);
512
- group.entries.push(entry);
513
- }
514
- else {
515
- wikiDirGroups.set(dir, { files: [ctx.absPath], entries: [entry] });
516
- }
517
- }
518
- for (const [dirPath, { files, entries }] of wikiDirGroups) {
519
- if (markSeenOrSkipDuplicate(dirPath, currentStashDir, files))
520
- continue;
521
- recordFreshnessDecision(dirPath, currentStashDir, files, { entries });
522
- }
798
+ if (!walkComplete) {
799
+ for (const dirPath of dirGroups.keys())
800
+ seenPaths.add(path.resolve(dirPath));
801
+ warn(`[index] source "${component.id}" was not scanned completely; preserving its last-known-good rows.`);
523
802
  continue;
524
803
  }
525
- const dirGroups = new Map();
526
- for (const ctx of fileContexts) {
527
- const dir = ctx.parentDirAbs;
528
- const group = dirGroups.get(dir);
529
- if (group)
530
- group.push(ctx.absPath);
531
- else
532
- dirGroups.set(dir, [ctx.absPath]);
804
+ // Owner ruling 2026-07-21: dispatch each component's DETECTED adapter (§4).
805
+ // An unknown adapter id has no `adapterForId` match → skip the whole
806
+ // component with a warning (one bundle = one component = one adapter).
807
+ if (!adapter) {
808
+ for (const dirPath of dirGroups.keys())
809
+ seenPaths.add(path.resolve(dirPath));
810
+ warn(`Skipping component "${component.id}": unknown adapter id "${component.adapter}".`);
811
+ continue;
812
+ }
813
+ const indexVariant = plan.indexVariant ?? `${adapter.id}@${adapter.version}`;
814
+ for (const removal of removals) {
815
+ dirRecords.push(removal);
816
+ scannedDirs++;
817
+ priorDirsChanged = true;
818
+ reportDirDecision("scan", removal.dirPath, currentStashDir, removal.reason);
533
819
  }
534
- for (const [dirPath, files] of dirGroups) {
535
- const indexableFiles = files.filter((file) => shouldIndexStashFile(currentStashDir, file));
820
+ for (const [dirPath, ctxs] of dirGroups) {
821
+ // Adapter-owned filtering (owner ruling 2026-07-21): the drain no longer
822
+ // pre-filters with AKM-stash policy — each adapter's `recognize` claims or
823
+ // abstains on its own bundle's walked files. The core walk keeps only the
824
+ // universal hygiene `walkStashFlat` already applies (.git/dot-dirs/etc.).
825
+ const indexableFiles = ctxs.map((ctx) => ctx.absPath);
826
+ const forceScan = handoffDirs.has(path.resolve(dirPath));
536
827
  if (markSeenOrSkipDuplicate(dirPath, currentStashDir, indexableFiles))
537
828
  continue;
538
829
  if (indexableFiles.length === 0) {
@@ -542,7 +833,9 @@ async function scanSourceDirs(db, allSourceEntries, isIncremental, builtAtMs, ha
542
833
  reportDirDecision("skip", dirPath, currentStashDir, reason);
543
834
  continue;
544
835
  }
545
- const cachedZeroRowState = isIncremental && getCachedZeroRowDirState(db, dirPath, indexableFiles, builtAtMs, priorDirsChanged);
836
+ const cachedZeroRowState = isIncremental &&
837
+ !forceScan &&
838
+ getCachedZeroRowDirState(db, dirPath, indexableFiles, builtAtMs, priorDirsChanged, indexVariant);
546
839
  if (cachedZeroRowState) {
547
840
  skippedDirs++;
548
841
  dirRecords.push({
@@ -552,40 +845,100 @@ async function scanSourceDirs(db, allSourceEntries, isIncremental, builtAtMs, ha
552
845
  stash: null,
553
846
  skip: true,
554
847
  reason: cachedZeroRowState.reason,
848
+ indexVariant,
555
849
  });
556
850
  reportDirDecision("skip", dirPath, currentStashDir, cachedZeroRowState.reason, cachedZeroRowState.persistedRowCount);
557
851
  continue;
558
852
  }
559
- const generated = await generateMetadataFlat(currentStashDir, indexableFiles);
560
- if (generated.warnings?.length)
561
- warnings.push(...generated.warnings);
562
- const legacyOverrides = loadStashFile(dirPath, { requireFilename: true });
563
- const { stash, staleFiles } = buildIndexedDirCandidate(dirPath, indexableFiles, generated, legacyOverrides);
853
+ // F4a M-core-2 (the flip): drain the dir's `IndexDocument` stream via the
854
+ // component's dispatched `adapter.recognize` (broken workflows dropped-with-
855
+ // warning at the drain layer) and reconstruct the durable `IndexDocument`s.
856
+ const drained = drainDirDocuments(adapter, component, ctxs);
857
+ if (drained.warnings.length)
858
+ warnings.push(...drained.warnings);
859
+ const generated = drained.warnings.length
860
+ ? { entries: drained.entries, warnings: drained.warnings }
861
+ : { entries: drained.entries };
862
+ // `.stash.json` sidecar overrides retired (#39): the cutover's content
863
+ // migration folded sidecar metadata into frontmatter and deleted the
864
+ // files; the runtime no longer reads them.
865
+ const { stash, staleFiles } = buildIndexedDirCandidate(dirPath, indexableFiles, generated);
564
866
  if (generated.entries.length > 0) {
565
867
  generatedCount += generated.entries.length;
566
868
  }
567
- recordFreshnessDecision(dirPath, currentStashDir, staleFiles, stash);
869
+ recordFreshnessDecision(dirPath, currentStashDir, staleFiles, stash, drained.hashByFile, drained.conceptIdByFile, indexVariant, forceScan, walkComplete);
568
870
  }
569
871
  }
570
- return { dirRecords, scannedDirs, skippedDirs, generatedCount, warnings };
872
+ return {
873
+ dirRecords: removalsFirst(dirRecords),
874
+ scannedDirs,
875
+ skippedDirs,
876
+ generatedCount,
877
+ warnings,
878
+ complete: plans.every((plan) => plan.walkComplete && plan.adapter !== undefined),
879
+ };
880
+ }
881
+ function preserveExistingIndex(doFullDelete, dirRecords, sourceRoots) {
882
+ if (!doFullDelete)
883
+ return false;
884
+ const incomingDocCount = dirRecords.reduce((n, record) => n + (record.skip ? 0 : (record.stash?.entries.length ?? 0)), 0);
885
+ if (incomingDocCount > 0 || allSourceRootsReadable(sourceRoots))
886
+ return false;
887
+ warn("[index] --full produced zero documents while one or more source roots are missing or unreadable — " +
888
+ "preserving the existing index (last-known-good) rather than wiping it. Re-run once the sources are available.");
889
+ return true;
890
+ }
891
+ /**
892
+ * #624-P1 zero-document preflight probe. A source root counts as "readable"
893
+ * when it exists on disk as a directory whose listing can be read. A root that
894
+ * is missing or unreadable (a transient mount failure, a permission race, or a
895
+ * source that vanished mid-run) makes a zero-document scan untrustworthy: the
896
+ * walk saw nothing not because the stash is empty but because it could not be
897
+ * read. Returns true only when EVERY root is readable, so a single unreadable
898
+ * root blocks the full-rebuild wipe.
899
+ */
900
+ function allSourceRootsReadable(roots) {
901
+ for (const root of roots) {
902
+ try {
903
+ const st = fs.statSync(root);
904
+ if (!st.isDirectory())
905
+ return false;
906
+ fs.readdirSync(root); // probe readability, not just existence
907
+ }
908
+ catch {
909
+ return false;
910
+ }
911
+ }
912
+ return true;
571
913
  }
572
914
  /**
573
915
  * Phase 2 (sync): write all pre-generated scan records inside a single
574
916
  * transaction, returning the directories that still need LLM enrichment.
575
917
  */
576
- function persistDirRecords(db, dirRecords, doFullDelete, warnings) {
918
+ function persistDirRecords(db, dirRecords, doFullDelete, warnings, sourceRoots, scanComplete, bundleByRoot) {
577
919
  const dirsNeedingLlm = [];
578
- // Cross-stash dedup: track indexed assets by content identity
579
- // (type + filename + description) so the same asset from a lower-priority
580
- // stash root is skipped when a higher-priority root already covers it.
581
- // Sources are ordered by priority (primary stash first), so the first
582
- // occurrence wins.
920
+ const fullDelete = doFullDelete && scanComplete;
921
+ // #624-P1 zero-document preflight (spec §4). A full-rebuild wipe is a
922
+ // legitimate mass-delete ONLY when the scan legitimately found nothing. If
923
+ // the walk produced zero documents AND any configured source root is missing
924
+ // or unreadable, the empty result is almost certainly a transient scan
925
+ // failure, not an emptied stash — wiping here would cascade-destroy the
926
+ // last-known-good index (entries + embeddings + utility/usage). Preserve it
927
+ // and warn instead; the next successful run reconciles. A genuinely empty
928
+ // stash whose roots ARE readable still wipes, as before.
929
+ if (preserveExistingIndex(fullDelete, dirRecords, sourceRoots))
930
+ return { dirsNeedingLlm };
931
+ // Per-source dedup: the same logical asset can appear more than once within
932
+ // one owning source, where source order still makes the first occurrence win.
933
+ // The owner is part of the key so identical concepts in different bundles
934
+ // remain distinct indexed rows.
583
935
  const indexedAssetIdentities = new Set();
936
+ const deletedUsageEntryIds = new Set();
584
937
  const insertTransaction = db.transaction(() => {
585
938
  // Perform the full-rebuild wipe as the FIRST step of the insert
586
939
  // transaction so delete and re-insert are atomic — a concurrent reader
587
940
  // never observes an empty database between the two operations.
588
- if (doFullDelete) {
941
+ if (fullDelete) {
589
942
  try {
590
943
  db.exec("DELETE FROM embeddings");
591
944
  }
@@ -603,20 +956,22 @@ function persistDirRecords(db, dirRecords, doFullDelete, warnings) {
603
956
  db.exec("DELETE FROM entries_fts");
604
957
  db.exec("DELETE FROM utility_scores");
605
958
  db.exec("DELETE FROM index_dir_state");
606
- // Detach usage_events from entries about to be deleted — null out entry_id
607
- // but keep entry_ref so events can be re-linked after entries are rebuilt.
608
- try {
609
- db.exec("UPDATE usage_events SET entry_id = NULL WHERE entry_id IS NOT NULL");
610
- }
611
- catch {
612
- /* ignore if table doesn't exist */
613
- }
959
+ // Chunk-8 WI-8.3: usage_events lives in state.db now (not index.db), so the
960
+ // wipe no longer detaches it here. The finalize pass's relinkUsageEvents
961
+ // (cross-DB) nulls entry_ids that no longer resolve to a rebuilt entry and
962
+ // re-resolves the rest by entry_ref — subsuming the old detach.
614
963
  db.exec("DELETE FROM entries");
615
964
  }
616
- for (const { dirPath, currentStashDir, files, stash, skip, reason } of dirRecords) {
965
+ for (const { dirPath, currentStashDir, files, stash, skip, reason, hashByFile, conceptIdByFile, indexVariant, remove, pruneMissing, } of dirRecords) {
966
+ if (remove) {
967
+ const removedIds = deleteEntriesByDirAndStash(db, dirPath, currentStashDir, { cleanupUsageEvents: false });
968
+ addEntryIds(deletedUsageEntryIds, removedIds);
969
+ deleteIndexDirState(db, dirPath);
970
+ continue;
971
+ }
617
972
  if (skip) {
618
973
  if (reason?.kind === "unchanged") {
619
- const fingerprint = computeDirFingerprint(dirPath, files);
974
+ const fingerprint = computeDirFingerprint(dirPath, files, indexVariant);
620
975
  upsertIndexDirState(db, {
621
976
  dirPath,
622
977
  fileSetHash: fingerprint.fileSetHash,
@@ -626,30 +981,49 @@ function persistDirRecords(db, dirRecords, doFullDelete, warnings) {
626
981
  }
627
982
  continue;
628
983
  }
629
- // Delete old entries for this dir (will be re-inserted)
630
- deleteEntriesByDir(db, dirPath);
984
+ // Diff-persist (F4a M-core-2, spec §14.2): upsert the current file set
985
+ // FIRST (ON CONFLICT preserving `entries.id` so embeddings / utility /
986
+ // usage stay attached to unchanged rows), tracking every upserted
987
+ // `entry_key`, then prune only the DEPARTED rows below. Replaces the old
988
+ // `deleteEntriesByDir` truncate-and-reinsert (which discarded ids).
989
+ const keptEntryKeys = new Set();
631
990
  let persistedRows = 0;
632
991
  let dedupedRows = 0;
633
992
  if (stash) {
993
+ const bundle = bundleByRoot.get(path.resolve(currentStashDir));
994
+ if (!bundle)
995
+ throw new Error(`Missing bundle provenance for indexed source ${currentStashDir}`);
996
+ const ownerIdentity = bundle.bundleId;
634
997
  for (const entry of stash.entries) {
635
998
  const entryPath = entry.filename ? path.join(dirPath, entry.filename) : null;
636
999
  if (!entryPath) {
637
1000
  warn(`Skipping entry with no resolvable path in ${dirPath}`);
638
1001
  continue;
639
1002
  }
640
- if (!shouldIndexStashFile(currentStashDir, entryPath))
1003
+ const adapterConceptId = conceptIdByFile?.get(entryPath);
1004
+ if (!adapterConceptId) {
1005
+ warn(`Skipping entry without adapter-owned concept identity: ${entryPath}`);
641
1006
  continue;
642
- // Skip if a higher-priority stash root already indexed this asset
643
- const identityKey = `${entry.type}\0${entry.name}`;
1007
+ }
1008
+ // Adapter-owned concept identity is path-based and cannot be replaced
1009
+ // by presentation fields such as type/title.
1010
+ const identityKey = `${ownerIdentity}\0${adapterConceptId}`;
644
1011
  if (indexedAssetIdentities.has(identityKey)) {
645
1012
  dedupedRows++;
646
1013
  continue;
647
1014
  }
648
1015
  indexedAssetIdentities.add(identityKey);
649
- const entryKey = `${currentStashDir}:${entry.type}:${entry.name}`;
1016
+ const entryKey = bundle?.adapterId !== "akm" && adapterConceptId
1017
+ ? `${currentStashDir}:concept:${adapterConceptId}`
1018
+ : `${currentStashDir}:${entry.type}:${entry.name}`;
1019
+ keptEntryKeys.add(entryKey);
650
1020
  const searchText = buildSearchText(entry);
651
1021
  const entryWithSize = attachFileSize(entry, entryPath);
652
- const entryId = upsertEntry(db, entryKey, dirPath, entryPath, currentStashDir, entryWithSize, searchText);
1022
+ // content_hash = doc.hash from the drain, keyed by the recognized
1023
+ // file's path. A missing hash preserves the existing value on upsert.
1024
+ const contentHash = hashByFile?.get(entryPath);
1025
+ const provenance = deriveEntryProvenance(bundle, entry.type, entry.name, adapterConceptId);
1026
+ const entryId = upsertEntry(db, entryKey, dirPath, entryPath, currentStashDir, entryWithSize, searchText, provenance, contentHash);
653
1027
  persistedRows++;
654
1028
  if (entry.type === "workflow") {
655
1029
  const doc = takeWorkflowDocument(entry);
@@ -659,13 +1033,19 @@ function persistDirRecords(db, dirRecords, doFullDelete, warnings) {
659
1033
  }
660
1034
  }
661
1035
  // Collect dirs needing LLM enhancement during the first walk.
662
- // Only dirs with "generated" entries need enrichment (unless reEnrich
663
- // forces re-processing of already-enriched entries).
1036
+ // Only dirs with "generated" entries need enrichment.
664
1037
  if (stash.entries.some((e) => e.quality === "generated")) {
665
1038
  dirsNeedingLlm.push({ dirPath, files, currentStashDir, stash });
666
1039
  }
667
1040
  }
668
- const fingerprint = computeDirFingerprint(dirPath, files);
1041
+ // Prune the departed rows: everything under this dir NOT re-upserted above
1042
+ // (files deleted, deduped away, or abstained on by the adapter). With
1043
+ // an empty kept-set this deletes every row for the dir — the exact net
1044
+ // effect of the old unconditional `deleteEntriesByDir`, minus the id churn.
1045
+ if (pruneMissing !== false) {
1046
+ addEntryIds(deletedUsageEntryIds, deleteEntriesByDirExceptKeys(db, dirPath, currentStashDir, keptEntryKeys, { cleanupUsageEvents: false }));
1047
+ }
1048
+ const fingerprint = computeDirFingerprint(dirPath, files, indexVariant);
669
1049
  const persistedReason = persistedRows === 0
670
1050
  ? inferZeroRowReason(stash, reason, warnings, dirPath, dedupedRows)
671
1051
  : reason?.kind === "full-rebuild"
@@ -696,17 +1076,55 @@ function persistDirRecords(db, dirRecords, doFullDelete, warnings) {
696
1076
  }
697
1077
  });
698
1078
  insertTransaction();
1079
+ deleteUsageEventsByEntryIds([...deletedUsageEntryIds]);
699
1080
  return { dirsNeedingLlm };
700
1081
  }
701
- async function indexEntries(db, allSourceEntries, isIncremental, builtAtMs, hadRemovedSources, doFullDelete = false, onProgress) {
1082
+ async function indexEntries(db, allSourceEntries, isIncremental, builtAtMs, hadRemovedSources, doFullDelete = false, onProgress, reconcileMissingDirs = true) {
702
1083
  // Phase 1 (async): walk directories and pre-generate all metadata outside the
703
1084
  // transaction.
704
- const { dirRecords, scannedDirs, skippedDirs, generatedCount, warnings } = await scanSourceDirs(db, allSourceEntries, isIncremental, builtAtMs, hadRemovedSources, onProgress);
1085
+ const { dirRecords, scannedDirs, skippedDirs, generatedCount, warnings, complete } = await scanSourceDirs(db, allSourceEntries, isIncremental, builtAtMs, hadRemovedSources, onProgress, reconcileMissingDirs);
705
1086
  // Phase 2 (sync): write all pre-generated metadata inside a single transaction.
706
- const { dirsNeedingLlm } = persistDirRecords(db, dirRecords, doFullDelete, warnings);
707
- return { scannedDirs, skippedDirs, generatedCount, warnings, dirsNeedingLlm };
1087
+ // Source roots feed the #624-P1 zero-document preflight (a full-rebuild wipe
1088
+ // is suppressed when the scan is empty because roots are unreadable).
1089
+ const sourceRoots = allSourceEntries.map((s) => s.path);
1090
+ // Chunk-5 Step 2 (spec §14.4): map each source root → its durable bundle id so
1091
+ // the writer can persist `item_ref = <bundle>//<conceptId>` and the component/
1092
+ // adapter provenance alongside the legacy columns. `deriveInstallations`
1093
+ // preserves source order, so a positional zip yields the SAME bundle id the
1094
+ // dispatched `adapter.recognize` emits as `IndexDocument.ref` for that root.
1095
+ const installations = deriveInstallations(allSourceEntries);
1096
+ const bundleByRoot = new Map();
1097
+ allSourceEntries.forEach((source, i) => {
1098
+ const inst = installations[i];
1099
+ if (!inst)
1100
+ return;
1101
+ const component = inst.components[0];
1102
+ bundleByRoot.set(path.resolve(source.path), {
1103
+ bundleId: inst.id,
1104
+ componentId: component?.id ?? inst.id,
1105
+ adapterId: component?.adapter ?? "akm",
1106
+ });
1107
+ });
1108
+ const { dirsNeedingLlm } = persistDirRecords(db, dirRecords, doFullDelete, warnings, sourceRoots, complete, bundleByRoot);
1109
+ return { scannedDirs, skippedDirs, generatedCount, warnings, dirsNeedingLlm, complete };
708
1110
  }
709
- async function enhanceDirsWithLlm(db, config, dirsNeedingLlm, onProgress, signal, reEnrich = false) {
1111
+ function indexedProvenanceForFile(db, filePath) {
1112
+ const row = db
1113
+ .prepare("SELECT item_ref AS itemRef, bundle_id AS bundleId, component_id AS componentId, " +
1114
+ "concept_id AS conceptId, adapter_id AS adapterId FROM entries WHERE file_path = ? LIMIT 1")
1115
+ .get(filePath);
1116
+ if (!row?.itemRef || !row.bundleId || !row.componentId || !row.conceptId || !row.adapterId) {
1117
+ throw new Error(`Missing indexed provenance for ${filePath}`);
1118
+ }
1119
+ return {
1120
+ itemRef: row.itemRef,
1121
+ bundleId: row.bundleId,
1122
+ componentId: row.componentId,
1123
+ conceptId: row.conceptId,
1124
+ adapterId: row.adapterId,
1125
+ };
1126
+ }
1127
+ async function enhanceDirsWithLlm(db, config, dirsNeedingLlm, onProgress, signal) {
710
1128
  // Resolve per-pass LLM config via the unified shim. Returns undefined when
711
1129
  // either no `akm.llm` is configured or the user opted this pass out via
712
1130
  // `index.enrichment.llm = false`. (#208)
@@ -716,27 +1134,29 @@ async function enhanceDirsWithLlm(db, config, dirsNeedingLlm, onProgress, signal
716
1134
  // Aggregate per-entry failures so a misconfigured LLM endpoint surfaces
717
1135
  // as a single visible warning instead of silently degrading every entry
718
1136
  // and leaving the user wondering why nothing got enhanced.
719
- const summary = { attempted: 0, succeeded: 0, failureSamples: [] };
1137
+ const summary = { attempted: 0, succeeded: 0, skipped: 0, failureSamples: [] };
720
1138
  let completedDirs = 0;
721
1139
  let completedEntries = 0;
722
1140
  const totalDirs = dirsNeedingLlm.length;
723
1141
  const totalEntries = dirsNeedingLlm.reduce((sum, { stash }) => {
724
1142
  const entriesToEnhance = stash.entries.filter((e) => {
725
- if (e.quality !== "generated" && !(reEnrich && e.quality === "enriched"))
1143
+ if (e.quality !== "generated")
726
1144
  return false;
727
- if (!reEnrich && isEnrichmentComplete(e))
1145
+ if (isEnrichmentComplete(e))
728
1146
  return false;
729
1147
  return true;
730
1148
  });
731
1149
  return sum + entriesToEnhance.length;
732
1150
  }, 0);
733
- // P3 — wall-clock budget for the enrichment pass. Defaults to llm.timeoutMs
734
- // (or 10 minutes if not set). Users can extend this via llm.timeoutMs in
735
- // config — no separate knob needed.
736
- const budgetMs = (llmConfig.timeoutMs ?? 10 * 60 * 1000) * Math.max(totalEntries, 1);
737
- const enrichDeadline = AbortSignal.timeout(budgetMs);
1151
+ // P3 — wall-clock budget for the enrichment pass. Defaults to the resolved
1152
+ // engine's timeoutMs (or 10 minutes if not set). Users can extend it via
1153
+ // `index.enrichment.timeoutMs` (or `index.defaults.timeoutMs`, or the
1154
+ // engine's own `engines.<name>.timeoutMs`) — no separate knob needed.
1155
+ const enrichDeadline = createEnrichmentDeadline(llmConfig.timeoutMs, totalEntries);
738
1156
  let deadlineHit = false;
739
1157
  const enrichSignal = (() => {
1158
+ if (!enrichDeadline)
1159
+ return signal ?? new AbortController().signal;
740
1160
  if (!signal)
741
1161
  return enrichDeadline;
742
1162
  // Combine: abort when either fires.
@@ -780,14 +1200,13 @@ async function enhanceDirsWithLlm(db, config, dirsNeedingLlm, onProgress, signal
780
1200
  await concurrentMap(dirsNeedingLlm, async ({ dirPath, files, currentStashDir, stash: originalStash }) => {
781
1201
  if (enrichSignal.aborted)
782
1202
  return undefined;
783
- // Only enhance generated entries (or all when reEnrich=true);
784
- // user-provided overrides should not be overwritten.
785
- // Skip entries that are already fully enriched (description + tags + searchHints)
786
- // unless the caller explicitly requests re-enrichment via reEnrich=true.
1203
+ // Only enhance generated entries; user-provided overrides should not
1204
+ // be overwritten. Skip entries that are already fully enriched
1205
+ // (description + tags + searchHints).
787
1206
  const entriesToEnhance = originalStash.entries.filter((e) => {
788
- if (e.quality !== "generated" && !(reEnrich && e.quality === "enriched"))
1207
+ if (e.quality !== "generated")
789
1208
  return false;
790
- if (!reEnrich && isEnrichmentComplete(e)) {
1209
+ if (isEnrichmentComplete(e)) {
791
1210
  warnVerbose(`[akm] skipping LLM enrichment for "${e.name}" — entry already complete`);
792
1211
  return false;
793
1212
  }
@@ -806,7 +1225,7 @@ async function enhanceDirsWithLlm(db, config, dirsNeedingLlm, onProgress, signal
806
1225
  lastProgressAt = Date.now();
807
1226
  const targetStash = { entries: entriesToEnhance };
808
1227
  const entryKeys = entriesToEnhance.map((e) => `${currentStashDir}:${e.type}:${e.name}`);
809
- const enhanced = await enhanceStashWithLlm(llmConfig, targetStash, files, summary, enrichSignal, db, entryKeys, reEnrich, config, (event) => {
1228
+ const enhanced = await enhanceStashWithLlm(llmConfig, targetStash, files, summary, enrichSignal, db, entryKeys, config, (event) => {
810
1229
  completedEntries++;
811
1230
  lastProgressAt = Date.now();
812
1231
  onProgress?.({
@@ -827,7 +1246,8 @@ async function enhanceDirsWithLlm(db, config, dirsNeedingLlm, onProgress, signal
827
1246
  const entryPath = entry.filename ? path.join(dirPath, entry.filename) : files[0] || dirPath;
828
1247
  const entryKey = `${currentStashDir}:${entry.type}:${entry.name}`;
829
1248
  const searchText = buildSearchText(entry);
830
- upsertEntry(db, entryKey, dirPath, entryPath, currentStashDir, attachFileSize(entry, entryPath), searchText);
1249
+ const provenance = indexedProvenanceForFile(db, entryPath);
1250
+ upsertEntry(db, entryKey, dirPath, entryPath, currentStashDir, attachFileSize(entry, entryPath), searchText, provenance);
831
1251
  }
832
1252
  })();
833
1253
  completedDirs++;
@@ -841,10 +1261,10 @@ async function enhanceDirsWithLlm(db, config, dirsNeedingLlm, onProgress, signal
841
1261
  });
842
1262
  return undefined;
843
1263
  },
844
- // Default concurrency of 4 works well for cloud LLM APIs. Local model
845
- // servers (LM Studio, Ollama) run one inference at a time — set
846
- // `llm.concurrency: 1` in config.json to avoid "Model reloaded" / 500
847
- // errors from concurrent request overload.
1264
+ // Defaults: 2 for remote LLM APIs, 1 for local model servers (LM
1265
+ // Studio, Ollama run one inference at a time — parallel requests cause
1266
+ // "Model reloaded" / 500 errors). No config override reaches this path:
1267
+ // `resolveLlmEngineUse` does not forward `engines.<name>.concurrency`.
848
1268
  getDefaultLlmConcurrency(llmConfig));
849
1269
  }
850
1270
  finally {
@@ -852,19 +1272,25 @@ async function enhanceDirsWithLlm(db, config, dirsNeedingLlm, onProgress, signal
852
1272
  clearInterval(heartbeatTimer);
853
1273
  }
854
1274
  if (deadlineHit) {
855
- warn("[akm] LLM enrichment budget exceeded. Re-run `akm index` to continue. Increase llm.timeoutMs for a larger budget.");
1275
+ warn("[akm] LLM enrichment budget exceeded. Re-run `akm index` to continue. Increase index.enrichment.timeoutMs for a larger budget.");
856
1276
  }
857
- if (summary.attempted > 0 && summary.succeeded === 0) {
1277
+ // Gate-closed (`skipped`) entries are not failures — exclude them so a
1278
+ // deliberately disabled feature never surfaces as an enrichment error.
1279
+ const failed = summary.attempted - summary.succeeded - summary.skipped;
1280
+ if (failed > 0 && summary.succeeded === 0) {
858
1281
  const sample = summary.failureSamples.length ? ` Example: ${summary.failureSamples[0]}` : "";
859
- warn(`LLM enhancement failed for all ${summary.attempted} attempted entries — index built without LLM enrichment.` +
1282
+ warn(`LLM enhancement failed for all ${failed} attempted entries — index built without LLM enrichment.` +
860
1283
  ` Check llm.endpoint and llm.model in your config.${sample}`);
861
1284
  }
862
- else if (summary.attempted > 0 && summary.succeeded < summary.attempted) {
863
- const failed = summary.attempted - summary.succeeded;
1285
+ else if (failed > 0) {
864
1286
  const sample = summary.failureSamples.length ? ` Examples: ${summary.failureSamples.join("; ")}` : "";
865
1287
  warn(`LLM enhancement failed for ${failed}/${summary.attempted} entries — they were left un-enhanced.${sample}`);
866
1288
  }
867
1289
  }
1290
+ export function createEnrichmentDeadline(timeoutMs, totalEntries) {
1291
+ const perEntryTimeoutMs = timeoutMs === undefined ? 10 * 60 * 1000 : timeoutMs;
1292
+ return perEntryTimeoutMs === null ? undefined : AbortSignal.timeout(perEntryTimeoutMs * Math.max(totalEntries, 1));
1293
+ }
868
1294
  async function generateEmbeddingsForDb(db, config, onProgress, signal) {
869
1295
  throwIfAborted(signal);
870
1296
  if (config.semanticSearchMode === "off") {
@@ -922,25 +1348,39 @@ async function generateEmbeddingsForDb(db, config, onProgress, signal) {
922
1348
  // state is rolled back on failure rather than leaving the table half-filled.
923
1349
  let storedCount = 0;
924
1350
  let skippedCount = 0;
1351
+ let vecFailedCount = 0;
925
1352
  db.transaction(() => {
926
1353
  for (let i = 0; i < allEntries.length; i++) {
927
- if (upsertEmbedding(db, allEntries[i].id, embeddings[i])) {
1354
+ const res = upsertEmbedding(db, allEntries[i].id, embeddings[i]);
1355
+ if (res.stored) {
928
1356
  storedCount++;
929
1357
  }
930
1358
  else {
931
1359
  skippedCount++;
932
1360
  }
1361
+ if (res.vec === "failed")
1362
+ vecFailedCount++;
933
1363
  }
934
1364
  })();
935
1365
  if (skippedCount > 0) {
936
1366
  warn(`[embed] ${skippedCount} embedding${skippedCount === 1 ? "" : "s"} skipped (entry deleted between queue and write)`);
937
1367
  }
1368
+ // Record the ACTUAL vec-insert outcome so semantic search reflects it
1369
+ // instead of inferring readiness from stored-BLOB counts. Any failure
1370
+ // marks the fast path degraded, routing search to the JS-cosine fallback
1371
+ // over the (complete) BLOB table — honest degradation, not a hard failure.
1372
+ setVecFastPathReady(db, vecFailedCount === 0);
1373
+ if (vecFailedCount > 0) {
1374
+ warn(`[embed] ${vecFailedCount} sqlite-vec fast-path insert${vecFailedCount === 1 ? "" : "s"} failed — ` +
1375
+ "semantic search will use the slower JS-cosine fallback over stored embeddings. " +
1376
+ "Rebuild with 'akm index --full' after resolving the vec table (often a vector-dimension mismatch).");
1377
+ }
938
1378
  onProgress({
939
1379
  phase: "embeddings",
940
1380
  message: `Stored ${storedCount} embedding${storedCount === 1 ? "" : "s"}.`,
941
1381
  });
942
1382
  setMeta(db, "embeddingFingerprint", currentFingerprint);
943
- return { success: true };
1383
+ return { success: true, vecInsertFailures: vecFailedCount };
944
1384
  }
945
1385
  finally {
946
1386
  if (heartbeatTimer)
@@ -1032,7 +1472,7 @@ function verifyIndexState(db, config, embeddableEntries, embeddingResult) {
1032
1472
  `Semantic search verification failed (${embeddingCount}/${embeddableEntries} embeddings available).`,
1033
1473
  guidance: embeddingProvider === "remote"
1034
1474
  ? "Check your embedding endpoint and credentials, then retry `akm index --full --verbose`."
1035
- : "Retry `akm index --full --verbose`. If it still fails, confirm local model downloads are permitted and see docs/configuration.md for local embedding dependency setup.",
1475
+ : "Retry `akm index --full --verbose`. If it still fails, confirm local model downloads are permitted and see docs/reference/configuration.md for local embedding dependency setup.",
1036
1476
  semanticSearchEnabled: true,
1037
1477
  semanticSearchMode: config.semanticSearchMode,
1038
1478
  semanticStatus: "blocked",
@@ -1042,29 +1482,22 @@ function verifyIndexState(db, config, embeddableEntries, embeddingResult) {
1042
1482
  vecAvailable,
1043
1483
  };
1044
1484
  }
1045
- function buildIndexedDirCandidate(dirPath, indexableFiles, generated, legacyOverrides) {
1046
- const mergedEntries = legacyOverrides
1047
- ? generated.entries.map((entry) => mergeLegacyEntry(entry, legacyOverrides.entries))
1048
- : generated.entries;
1049
- const stash = mergedEntries.length > 0 ? { entries: mergedEntries } : legacyOverrides;
1485
+ function buildIndexedDirCandidate(dirPath, indexableFiles, generated) {
1486
+ const stash = generated.entries.length > 0 ? { entries: generated.entries } : null;
1050
1487
  const staleFiles = stash ? resolveIndexedFiles(dirPath, indexableFiles, stash) : indexableFiles;
1051
1488
  return { stash, staleFiles };
1052
1489
  }
1053
1490
  function resolveIndexedFiles(dirPath, files, stash) {
1054
- const fileBasenameMap = buildFileBasenameMap(files);
1055
1491
  const resolved = new Set();
1056
1492
  for (const entry of stash.entries) {
1057
- const entryPath = entry.filename
1058
- ? path.join(dirPath, entry.filename)
1059
- : matchEntryToFile(entry.name, fileBasenameMap);
1060
- if (entryPath)
1061
- resolved.add(entryPath);
1493
+ if (entry.filename)
1494
+ resolved.add(path.join(dirPath, entry.filename));
1062
1495
  }
1063
1496
  return resolved.size > 0 ? [...resolved] : files;
1064
1497
  }
1065
- async function enhanceStashWithLlm(llmConfig, stash, files, summary, signal, db, entryKeys, reEnrich, akmConfig, onEntryDone) {
1498
+ async function enhanceStashWithLlm(llmConfig, stash, files, summary, signal, db, entryKeys, akmConfig, onEntryDone) {
1066
1499
  const { enhanceMetadata } = await import("../llm/metadata-enhance.js");
1067
- const { computeBodyHash, getLlmCacheEntry, upsertLlmCacheEntry } = await import("./db/db.js");
1500
+ const { computeBodyHash, getLlmCacheEntry, upsertLlmCacheEntry } = await import("../storage/repositories/index-llm-cache-repository.js");
1068
1501
  const results = await concurrentMap(stash.entries, async (entry, idx) => {
1069
1502
  if (signal?.aborted)
1070
1503
  return entry;
@@ -1082,13 +1515,13 @@ async function enhanceStashWithLlm(llmConfig, stash, files, summary, signal, db,
1082
1515
  warn(`Could not read file for LLM enrichment: ${entry.filename ?? entry.name}`);
1083
1516
  }
1084
1517
  }
1085
- // Incremental cache: skip LLM call when file body is unchanged and
1086
- // --re-enrich was not requested. The cache key is the entry_key
1087
- // (stashDir:type:name) which is stable across index runs.
1518
+ // Incremental cache: skip LLM call when file body is unchanged. The
1519
+ // cache key is the entry_key (stashDir:type:name) which is stable
1520
+ // across index runs.
1088
1521
  const cacheBody = fileContent ?? `${entry.name}\n${entry.description ?? ""}`;
1089
1522
  const bodyHash = computeBodyHash(cacheBody);
1090
1523
  const cacheKey = entryKeys?.[idx] ?? `${entry.type}:${entry.name}`;
1091
- if (db && !reEnrich) {
1524
+ if (db) {
1092
1525
  const cached = getLlmCacheEntry(db, cacheKey, bodyHash);
1093
1526
  if (cached) {
1094
1527
  try {
@@ -1110,7 +1543,27 @@ async function enhanceStashWithLlm(llmConfig, stash, files, summary, signal, db,
1110
1543
  }
1111
1544
  }
1112
1545
  }
1113
- const improvements = await enhanceMetadata(llmConfig, entry, fileContent, signal, akmConfig);
1546
+ const outcome = await enhanceMetadata(llmConfig, entry, fileContent, signal, akmConfig);
1547
+ if (outcome.status !== "enriched") {
1548
+ // Not a genuine LLM success: the gate was closed (`skipped`) or the
1549
+ // call errored/timed out (`failed`). Do NOT mark the entry enriched
1550
+ // and do NOT write the LLM cache — caching here would poison the
1551
+ // entry into a permanent enrichment skip even though nothing was
1552
+ // enhanced. Surface failures honestly; stay silent on gated-off skips.
1553
+ if (outcome.status === "failed") {
1554
+ const msg = outcome.error ?? "metadata enrichment failed";
1555
+ if (summary.failureSamples.length < 3 && !summary.failureSamples.includes(msg)) {
1556
+ summary.failureSamples.push(msg);
1557
+ }
1558
+ onEntryDone?.({ entryName: entry.name, outcome: "failed" });
1559
+ }
1560
+ else {
1561
+ summary.skipped++;
1562
+ onEntryDone?.({ entryName: entry.name, outcome: "skipped" });
1563
+ }
1564
+ return entry;
1565
+ }
1566
+ const improvements = outcome.metadata;
1114
1567
  const updated = { ...entry };
1115
1568
  if (improvements.description)
1116
1569
  updated.description = improvements.description;
@@ -1118,7 +1571,11 @@ async function enhanceStashWithLlm(llmConfig, stash, files, summary, signal, db,
1118
1571
  updated.searchHints = improvements.searchHints;
1119
1572
  if (improvements.tags?.length)
1120
1573
  updated.tags = improvements.tags;
1121
- // Mark as enriched so subsequent index runs skip re-enrichment (P2)
1574
+ // Mark as enriched so subsequent index runs skip re-enrichment (P2).
1575
+ // An empty-but-successful response is still cached: the LLM was paid
1576
+ // for this body_hash and produced no improvements, so re-running would
1577
+ // only re-pay for the same no-op. (The cache protects against re-paying
1578
+ // for the LLM call when the file body is unchanged.)
1122
1579
  updated.quality = "enriched";
1123
1580
  // Persist to cache so the next run can skip the LLM call when the
1124
1581
  // file body has not changed.
@@ -1144,8 +1601,8 @@ async function enhanceStashWithLlm(llmConfig, stash, files, summary, signal, db,
1144
1601
  return entry;
1145
1602
  }
1146
1603
  },
1147
- // Default concurrency of 4 works well for cloud LLM APIs. Set
1148
- // `llm.concurrency: 1` in config.json for local model servers.
1604
+ // Defaults: 2 for remote LLM APIs, 1 for local model servers. No config
1605
+ // override reaches this path (see getDefaultLlmConcurrency).
1149
1606
  getDefaultLlmConcurrency(llmConfig));
1150
1607
  // concurrentMap returns Array<T | undefined>; filter out undefined slots
1151
1608
  // (which can only occur if the callback itself returned undefined, which
@@ -1153,54 +1610,68 @@ async function enhanceStashWithLlm(llmConfig, stash, files, summary, signal, db,
1153
1610
  const enhanced = results.map((r, i) => r ?? stash.entries[i]);
1154
1611
  return { entries: enhanced };
1155
1612
  }
1156
- /**
1157
- * Build a map from base filename (without extension) to full path for quick lookups.
1158
- */
1159
- export function buildFileBasenameMap(files) {
1160
- const map = new Map();
1161
- for (const file of files) {
1162
- const base = path.basename(file, path.extname(file));
1163
- // Only keep first match per base name to avoid ambiguity
1164
- if (!map.has(base))
1165
- map.set(base, file);
1166
- }
1167
- return map;
1613
+ // ── lookup ─────────────────────────────────────────────────────────────────
1614
+ import { makeBundleRef } from "../core/asset/asset-ref.js";
1615
+ import { conceptIdFromTypeName } from "../core/asset/resolve-ref.js";
1616
+ async function resolveLookupSources() {
1617
+ const { loadConfig } = await import("../core/config/config.js");
1618
+ const { resolveSourceEntries } = await import("./search/search-source.js");
1619
+ return resolveSourceEntries(undefined, loadConfig());
1168
1620
  }
1169
- /**
1170
- * Try to match a filename-less entry to an actual file in the directory.
1171
- *
1172
- * Matching strategy (in priority order):
1173
- * 1. Exact basename match: entry.name === filename without extension
1174
- * 2. Last path segment match: for entries with names like "dir/sub-entry",
1175
- * try matching the last segment
1176
- * 3. No implicit file fallback: ambiguous legacy entries are skipped
1177
- */
1178
- export function matchEntryToFile(entryName, fileMap) {
1179
- // Exact match on entry name
1180
- const exact = fileMap.get(entryName);
1181
- if (exact)
1182
- return exact;
1183
- // Try last segment for hierarchical names (e.g. "corpus/agentic-patterns/foo")
1184
- const lastSegment = entryName.split("/").pop() ?? entryName;
1185
- if (lastSegment !== entryName) {
1186
- const segmentMatch = fileMap.get(lastSegment);
1187
- if (segmentMatch)
1188
- return segmentMatch;
1189
- }
1190
- return null;
1621
+ function resolveLookupScope(bundle, sources) {
1622
+ if (!bundle)
1623
+ return { candidateDirs: sources.map((source) => source.path), qualified: false };
1624
+ return { candidateDirs: resolveSourcesForOrigin(bundle, sources).map((source) => source.path), qualified: true };
1191
1625
  }
1192
- function mergeLegacyEntry(entry, legacyEntries) {
1193
- const legacy = legacyEntries.find((candidate) => candidate.filename === entry.filename);
1194
- if (!legacy)
1195
- return entry;
1196
- return {
1197
- ...entry,
1198
- ...legacy,
1199
- filename: entry.filename,
1200
- source: legacy.source ?? entry.source,
1201
- quality: legacy.quality ?? entry.quality,
1202
- confidence: legacy.confidence ?? entry.confidence,
1203
- };
1626
+ /** Resolve an adapter-owned `[bundle//]conceptId` without interpreting its path as an AKM type. */
1627
+ export async function lookupBundleRef(ref) {
1628
+ const sources = await resolveLookupSources();
1629
+ if (sources.length === 0)
1630
+ return null;
1631
+ const { candidateDirs, qualified } = resolveLookupScope(ref.bundle, sources);
1632
+ if (candidateDirs.length === 0)
1633
+ return null;
1634
+ const db = openExistingDatabase(getDbPath());
1635
+ try {
1636
+ const inputRef = makeBundleRef(qualified ? ref.bundle : undefined, ref.conceptId);
1637
+ for (const dir of candidateDirs) {
1638
+ const id = findEntryIdByRef(db, inputRef, dir);
1639
+ if (id === undefined)
1640
+ continue;
1641
+ const row = db
1642
+ .prepare("SELECT entry_key AS entryKey, file_path AS filePath, stash_dir AS stashDir, entry_type AS type, " +
1643
+ "entry_json AS entryJson, item_ref AS itemRef, bundle_id AS bundleId, concept_id AS conceptId, " +
1644
+ "adapter_id AS adapterId FROM entries WHERE id = ?")
1645
+ .get(id);
1646
+ if (!row)
1647
+ continue;
1648
+ let document;
1649
+ try {
1650
+ document = JSON.parse(row.entryJson);
1651
+ }
1652
+ catch {
1653
+ // Corrupt optional projection does not erase the durable path identity.
1654
+ }
1655
+ if (!row.itemRef || !row.bundleId || !row.conceptId || !row.adapterId)
1656
+ continue;
1657
+ return {
1658
+ entryKey: row.entryKey,
1659
+ filePath: row.filePath,
1660
+ stashDir: row.stashDir,
1661
+ type: row.type,
1662
+ name: document?.name ?? ref.conceptId.split("/").pop() ?? ref.conceptId,
1663
+ adapterId: row.adapterId,
1664
+ document,
1665
+ itemRef: row.itemRef,
1666
+ bundleId: row.bundleId,
1667
+ conceptId: row.conceptId,
1668
+ };
1669
+ }
1670
+ return null;
1671
+ }
1672
+ finally {
1673
+ closeDatabase(db);
1674
+ }
1204
1675
  }
1205
1676
  /**
1206
1677
  * Look up a single asset by ref. Spec §6.2 — `akm show` queries this and
@@ -1208,70 +1679,11 @@ function mergeLegacyEntry(entry, legacyEntries) {
1208
1679
  * file corresponds to which ref; the indexer walks `provider.path()` for
1209
1680
  * every configured source, so this query covers all source kinds.
1210
1681
  *
1211
- * Match rules:
1212
- * - `ref.origin === undefined` → first match across all sources (primary
1213
- * source first, then in declared order — same priority as the indexer's
1214
- * write order).
1215
- * - `ref.origin === "local"` → primary source only (entry_key prefix is
1216
- * the primary stash dir).
1217
- * - `ref.origin === <name>` → restrict to the matching source name. We
1218
- * resolve the source's directory and match on `entry_key` prefix.
1219
- *
1220
1682
  * Returns `null` when no row matches — callers translate that into a
1221
1683
  * `NotFoundError` with their own messaging.
1222
1684
  */
1223
1685
  export async function lookup(ref) {
1224
- const { loadConfig } = await import("../core/config/config.js");
1225
- const { resolveSourceEntries } = await import("./search/search-source.js");
1226
- const config = loadConfig();
1227
- const sources = resolveSourceEntries(undefined, config);
1228
- if (sources.length === 0)
1229
- return null;
1230
- const dbPath = getDbPath();
1231
- const db = openExistingDatabase(dbPath);
1232
- try {
1233
- const escapeLike = (value) => value.replace(/\\/g, "\\\\").replace(/%/g, "\\%").replace(/_/g, "\\_");
1234
- // Canonical names strip .md for markdown assets, but users often pass
1235
- // refs with .md (e.g. command:release.md). Normalize by trying both.
1236
- const nameVariants = [ref.name];
1237
- if (ref.name.endsWith(".md")) {
1238
- nameVariants.push(ref.name.slice(0, -3));
1239
- }
1240
- const candidateDirs = (() => {
1241
- if (!ref.origin)
1242
- return sources.map((s) => s.path);
1243
- if (ref.origin === "local")
1244
- return [sources[0].path];
1245
- const named = sources.find((s) => s.registryId === ref.origin);
1246
- return named ? [named.path] : [];
1247
- })();
1248
- if (candidateDirs.length === 0)
1249
- return null;
1250
- for (const name of nameVariants) {
1251
- const suffix = `:${ref.type}:${name}`;
1252
- const escapedSuffix = escapeLike(suffix);
1253
- for (const dir of candidateDirs) {
1254
- const escapedDir = escapeLike(dir);
1255
- const row = db
1256
- .prepare("SELECT entry_key AS entryKey, file_path AS filePath, stash_dir AS stashDir, entry_type AS type FROM entries " +
1257
- "WHERE entry_key LIKE ? ESCAPE '\\' AND entry_type = ? LIMIT 1")
1258
- .get(`${escapedDir}${escapedSuffix}`, ref.type);
1259
- if (row) {
1260
- return {
1261
- entryKey: row.entryKey,
1262
- filePath: row.filePath,
1263
- stashDir: row.stashDir,
1264
- type: row.type,
1265
- name: ref.name,
1266
- };
1267
- }
1268
- }
1269
- }
1270
- return null;
1271
- }
1272
- finally {
1273
- closeDatabase(db);
1274
- }
1686
+ return lookupBundleRef({ bundle: ref.origin, conceptId: conceptIdFromTypeName(ref.type, ref.name) });
1275
1687
  }
1276
1688
  // ── Utility score recomputation ──────────────────────────────────────────────
1277
1689
  /** Retention window for usage events: events older than this are purged. */
@@ -1292,12 +1704,11 @@ const USAGE_EVENT_RETENTION_DAYS = 90;
1292
1704
  *
1293
1705
  * Called during `akm index` after FTS rebuild.
1294
1706
  */
1295
- export function recomputeUtilityScores(db) {
1707
+ export function recomputeUtilityScores(db, stateDb) {
1296
1708
  const EMA_DECAY = 0.7;
1297
- // Ensure usage_events table exists before querying
1298
- ensureUsageEventsSchema(db);
1299
- // Purge stale usage events (90-day retention)
1300
- purgeOldUsageEvents(db, USAGE_EVENT_RETENTION_DAYS);
1709
+ // Purge stale usage events (90-day retention). usage_events lives in state.db
1710
+ // (Chunk-8 WI-8.3); its table is created by state migration 020.
1711
+ purgeOldUsageEvents(stateDb, USAGE_EVENT_RETENTION_DAYS);
1301
1712
  // Time-proportional decay: apply one round of EMA per elapsed day so
1302
1713
  // indexing frequency doesn't affect how fast scores decay.
1303
1714
  const lastComputedAt = getMeta(db, "last_utility_computed_at");
@@ -1308,15 +1719,14 @@ export function recomputeUtilityScores(db) {
1308
1719
  }
1309
1720
  const emaDecay = EMA_DECAY ** elapsedDays;
1310
1721
  const emaNew = 1 - emaDecay; // complement so weights still sum to 1
1311
- // Single aggregate query instead of N+1 per-entry queries.
1312
- // Only processes entries that actually have usage events AND still exist
1313
- // in `entries`. The latter check is critical: usage_events has no FK to
1314
- // entries, so its entry_id can become stale (entry deleted, re-keyed,
1315
- // moved between sources). Without the JOIN, writing the derived row to
1316
- // utility_scores (which DOES have an FK) raises "FOREIGN KEY constraint
1317
- // failed" and rolls back the whole finalize transaction — failing every
1318
- // index run.
1319
- const usageRows = db
1722
+ // Aggregate explicit user demand per entry_id from state.db's usage_events, then keep only entries
1723
+ // that STILL EXIST in index.db's `entries` (the former in-SQL JOIN is now a
1724
+ // cross-DB filter). This latter check is critical: usage_events has no FK to
1725
+ // entries, so its entry_id can become stale (entry deleted, re-keyed, moved
1726
+ // between sources). Without it, writing the derived row to utility_scores
1727
+ // (which DOES have an FK) raises "FOREIGN KEY constraint failed" and rolls
1728
+ // back the whole finalize transaction — failing every index run.
1729
+ const aggregatedRows = stateDb
1320
1730
  .prepare(`
1321
1731
  SELECT u.entry_id,
1322
1732
  SUM(CASE WHEN u.event_type = 'search' THEN 1 ELSE 0 END) AS search_count,
@@ -1325,15 +1735,13 @@ export function recomputeUtilityScores(db) {
1325
1735
  SUM(CASE WHEN u.event_type = 'feedback' AND u.signal = 'negative' THEN 1 ELSE 0 END) AS negative_feedback_count,
1326
1736
  MAX(u.created_at) AS last_used_at
1327
1737
  FROM usage_events u
1328
- JOIN entries e ON e.id = u.entry_id
1329
1738
  WHERE u.entry_id IS NOT NULL
1739
+ AND u.source = 'user'
1330
1740
  GROUP BY u.entry_id
1331
1741
  `)
1332
1742
  .all();
1333
- if (usageRows.length === 0) {
1334
- setMeta(db, "last_utility_computed_at", new Date().toISOString());
1335
- return;
1336
- }
1743
+ const entryExists = db.prepare("SELECT 1 FROM entries WHERE id = ?");
1744
+ const usageByEntry = new Map(aggregatedRows.filter((row) => entryExists.get(row.entry_id) != null).map((row) => [row.entry_id, row]));
1337
1745
  // Batch-load existing utility scores
1338
1746
  const existingScores = new Map();
1339
1747
  const scoreRows = db.prepare("SELECT entry_id, utility, last_used_at FROM utility_scores").all();
@@ -1341,7 +1749,16 @@ export function recomputeUtilityScores(db) {
1341
1749
  existingScores.set(row.entry_id, { utility: row.utility, lastUsedAt: row.last_used_at ?? undefined });
1342
1750
  }
1343
1751
  const now = new Date().toISOString();
1344
- for (const row of usageRows) {
1752
+ const entryIds = new Set([...existingScores.keys(), ...usageByEntry.keys()]);
1753
+ for (const entryId of entryIds) {
1754
+ const row = usageByEntry.get(entryId) ?? {
1755
+ entry_id: entryId,
1756
+ search_count: 0,
1757
+ show_count: 0,
1758
+ positive_feedback_count: 0,
1759
+ negative_feedback_count: 0,
1760
+ last_used_at: null,
1761
+ };
1345
1762
  const selectRate = row.search_count > 0 ? Math.min(1, row.show_count / row.search_count) : 0;
1346
1763
  const feedbackTotal = row.positive_feedback_count + row.negative_feedback_count;
1347
1764
  const feedbackRate = feedbackTotal > 0 ? Math.max(0, row.positive_feedback_count - row.negative_feedback_count) / feedbackTotal : 0;