akm-cli 0.9.16 → 0.9.17-alpha.10

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 (403) hide show
  1. package/CHANGELOG.md +2101 -0
  2. package/STABILITY.md +11 -10
  3. package/dist/akm +124 -193
  4. package/dist/akm-migrate +38 -19
  5. package/dist/assets/hints/cli-hints-full.md +6 -7
  6. package/dist/assets/improve-strategies/catchup.json +0 -3
  7. package/dist/assets/improve-strategies/consolidate.json +0 -1
  8. package/dist/assets/improve-strategies/default.json +1 -2
  9. package/dist/assets/improve-strategies/proactive-maintenance.json +1 -2
  10. package/dist/assets/improve-strategies/quick.json +1 -2
  11. package/dist/assets/improve-strategies/reflect-distill.json +1 -2
  12. package/dist/assets/improve-strategies/thorough.json +0 -3
  13. package/dist/assets/prompts/consolidate-pair.md +20 -0
  14. package/dist/assets/prompts/consolidate-system.md +4 -11
  15. package/dist/assets/prompts/retrieval-relevance-judge.md +6 -0
  16. package/dist/assets/stash-skeleton/facts/conventions/backlinks.md +20 -20
  17. package/dist/assets/stash-skeleton/facts/conventions/domains.md +2 -2
  18. package/dist/assets/templates/html/health.html +3 -5
  19. package/dist/cli/retired-commands.js +1 -1
  20. package/dist/cli/shared.js +6 -2
  21. package/dist/cli/unknown-flags.js +24 -1
  22. package/dist/cli.js +68 -10
  23. package/dist/commands/agent/agent-dispatch.js +1 -1
  24. package/dist/commands/command/command-execution.js +24 -62
  25. package/dist/commands/feedback-cli.js +0 -1
  26. package/dist/commands/health/accept-rate.js +6 -0
  27. package/dist/commands/health/archive-usage.js +92 -0
  28. package/dist/commands/health/checks.js +83 -74
  29. package/dist/commands/health/config-skew.js +38 -0
  30. package/dist/commands/health/data-dir-usage.js +25 -13
  31. package/dist/commands/health/egress.js +54 -0
  32. package/dist/commands/health/html-report.js +1 -42
  33. package/dist/commands/health/improve-metrics.js +136 -591
  34. package/dist/commands/health/md-report.js +1 -6
  35. package/dist/commands/health/plugin-staleness.js +53 -3
  36. package/dist/commands/health/renderers.js +12 -4
  37. package/dist/commands/health/report-view-model.js +14 -120
  38. package/dist/commands/health/types-improve.js +4 -19
  39. package/dist/commands/health/windows.js +64 -74
  40. package/dist/commands/health.js +145 -143
  41. package/dist/commands/improve/consolidate/chunking.js +26 -117
  42. package/dist/commands/improve/consolidate/continuity-check.js +137 -0
  43. package/dist/commands/improve/consolidate/pair-pass.js +791 -0
  44. package/dist/commands/improve/consolidate/sanitize.js +54 -149
  45. package/dist/commands/improve/consolidate.js +589 -1127
  46. package/dist/commands/improve/content-hash.js +16 -24
  47. package/dist/commands/improve/distill/content-repair.js +18 -100
  48. package/dist/commands/improve/distill-guards.js +20 -81
  49. package/dist/commands/improve/distill-promotion-policy.js +23 -243
  50. package/dist/commands/improve/distill.js +608 -1041
  51. package/dist/commands/improve/eligibility.js +126 -390
  52. package/dist/commands/improve/execution.js +8 -10
  53. package/dist/commands/improve/extract-prompt.js +1 -2
  54. package/dist/commands/improve/extract.js +487 -1046
  55. package/dist/commands/improve/feedback-valence.js +0 -25
  56. package/dist/commands/improve/improve-cli.js +75 -169
  57. package/dist/commands/improve/improve-result-file.js +10 -66
  58. package/dist/commands/improve/improve-strategies.js +52 -4
  59. package/dist/commands/improve/improve-usage-report.js +18 -64
  60. package/dist/commands/improve/improve.js +480 -1074
  61. package/dist/commands/improve/ledger.js +119 -0
  62. package/dist/commands/improve/locks.js +2 -8
  63. package/dist/commands/improve/loop-stages.js +415 -1073
  64. package/dist/commands/improve/memory/derived-ref.js +12 -77
  65. package/dist/commands/improve/memory/memory-belief.js +16 -118
  66. package/dist/commands/improve/memory/memory-improve.js +266 -14
  67. package/dist/commands/improve/outcome-loop.js +28 -156
  68. package/dist/commands/improve/planner.js +5 -15
  69. package/dist/commands/improve/preparation.js +779 -2319
  70. package/dist/commands/improve/proactive-maintenance.js +34 -101
  71. package/dist/commands/improve/reflect-noise.js +104 -280
  72. package/dist/commands/improve/reflect.js +642 -1353
  73. package/dist/commands/improve/retrieval-gate.js +127 -0
  74. package/dist/commands/improve/retrieval-scope.js +92 -0
  75. package/dist/commands/improve/salience.js +41 -240
  76. package/dist/commands/improve/session-asset.js +19 -100
  77. package/dist/commands/improve/stage.js +322 -0
  78. package/dist/commands/lint/base-linter.js +37 -15
  79. package/dist/commands/proposal/drain.js +261 -578
  80. package/dist/commands/proposal/proposal-cli.js +19 -20
  81. package/dist/commands/proposal/proposal-types.js +31 -24
  82. package/dist/commands/proposal/proposal.js +38 -8
  83. package/dist/commands/proposal/propose.js +134 -160
  84. package/dist/commands/proposal/repository.js +1097 -1394
  85. package/dist/commands/proposal/validators/proposal-quality-validators.js +71 -174
  86. package/dist/commands/proposal/validators/proposal-validators.js +1 -1
  87. package/dist/commands/proposal/validators/proposals.js +22 -89
  88. package/dist/commands/read/curate.js +105 -462
  89. package/dist/commands/read/knowledge.js +3 -2
  90. package/dist/commands/read/search-cli.js +16 -33
  91. package/dist/commands/read/search.js +17 -23
  92. package/dist/commands/read/show.js +57 -108
  93. package/dist/commands/sources/bundle-cli.js +25 -2
  94. package/dist/commands/sources/bundle-config-ops.js +4 -0
  95. package/dist/commands/sources/dangerous-env-audit.js +1 -2
  96. package/dist/commands/sources/info.js +127 -29
  97. package/dist/commands/sources/installed-stashes.js +197 -746
  98. package/dist/commands/sources/schema-repair.js +98 -129
  99. package/dist/commands/sources/source-add.js +62 -12
  100. package/dist/commands/sources/source-manage.js +9 -2
  101. package/dist/commands/sources/stash-cli.js +24 -4
  102. package/dist/commands/tasks/explain.js +10 -13
  103. package/dist/commands/tasks/tasks-cli.js +12 -13
  104. package/dist/commands/tasks/tasks.js +350 -936
  105. package/dist/commands/tasks/validate.js +26 -24
  106. package/dist/commands/workflow/plan.js +22 -29
  107. package/dist/commands/workflow-cli.js +4 -4
  108. package/dist/core/adapter/adapters/akm-adapter.js +2 -1
  109. package/dist/core/adapter/adapters/akm-lint.js +2 -3
  110. package/dist/core/adapter/adapters/akm-metadata.js +42 -12
  111. package/dist/core/adapter/adapters/akm-task-adapter.js +29 -8
  112. package/dist/core/adapter/adapters/akm-workflow-adapter.js +1 -1
  113. package/dist/core/adapter/execution-source.js +17 -29
  114. package/dist/core/asset/asset-placement.js +4 -13
  115. package/dist/core/asset/frontmatter.js +106 -1
  116. package/dist/core/asset/resolve-ref.js +1 -1
  117. package/dist/core/bundle-id.js +42 -5
  118. package/dist/core/bundle-rename.js +285 -0
  119. package/dist/core/config/config-io.js +1 -2
  120. package/dist/core/config/config-schema.js +9 -34
  121. package/dist/core/config/config-walker.js +1 -1
  122. package/dist/core/config/config.js +184 -111
  123. package/dist/core/config/engine-semantics.js +0 -2
  124. package/dist/core/config/legacy-source-shape-shim.js +38 -9
  125. package/dist/core/config/schema/embedding.js +20 -5
  126. package/dist/core/config/schema/engines.js +5 -0
  127. package/dist/core/config/schema/execution.js +1 -1
  128. package/dist/core/config/schema/experimental.js +1 -1
  129. package/dist/core/config/schema/improve-processes.js +54 -125
  130. package/dist/core/config/schema/improve.js +4 -42
  131. package/dist/core/config/schema/index-config.js +9 -48
  132. package/dist/core/config/schema/scheduler.js +12 -12
  133. package/dist/core/config/schema/search.js +6 -22
  134. package/dist/core/env-secret-ref.js +0 -1
  135. package/dist/core/errors.js +8 -9
  136. package/dist/core/file-change.js +13 -5
  137. package/dist/core/file-lock.js +76 -173
  138. package/dist/core/improve-result.js +35 -7
  139. package/dist/core/improve-types.js +0 -1
  140. package/dist/core/logs-db.js +2 -2
  141. package/dist/core/loopback.js +7 -12
  142. package/dist/core/non-task-input.js +20 -0
  143. package/dist/core/parse.js +13 -16
  144. package/dist/core/paths.js +0 -24
  145. package/dist/core/redaction.js +109 -2
  146. package/dist/core/run-lock.js +2 -5
  147. package/dist/core/spawn-env.js +1 -1
  148. package/dist/core/state/migrations.js +123 -61
  149. package/dist/core/state-db-scope.js +2 -4
  150. package/dist/core/state-db.js +126 -692
  151. package/dist/core/time.js +0 -20
  152. package/dist/core/type-presentation.js +1 -9
  153. package/dist/core/write-source.js +294 -1005
  154. package/dist/execution/input-contract.js +1 -1
  155. package/dist/execution/resolved-request.js +135 -689
  156. package/dist/execution/source.js +63 -257
  157. package/dist/execution/target-ref.js +1 -1
  158. package/dist/indexer/bundle-identity-guard.js +2 -2
  159. package/dist/indexer/db/llm-cache.js +2 -2
  160. package/dist/indexer/ensure-index.js +77 -73
  161. package/dist/indexer/index-rebuild-lock.js +3 -11
  162. package/dist/indexer/index-writer-lock.js +8 -17
  163. package/dist/indexer/index-written-assets.js +141 -154
  164. package/dist/indexer/indexer.js +400 -1124
  165. package/dist/indexer/links/declared-links.js +90 -0
  166. package/dist/indexer/materialize-embeddings.js +60 -397
  167. package/dist/indexer/passes/memory-inference.js +96 -90
  168. package/dist/indexer/passes/metadata.js +132 -219
  169. package/dist/indexer/read-preflight.js +0 -7
  170. package/dist/indexer/scan/doc-to-entry.js +2 -3
  171. package/dist/indexer/scan/drain-dir.js +1 -1
  172. package/dist/indexer/search/db-search.js +190 -590
  173. package/dist/indexer/search/fts-query.js +30 -41
  174. package/dist/indexer/search/ranking.js +28 -154
  175. package/dist/indexer/search/search-attribution.js +12 -32
  176. package/dist/indexer/search/search-fields.js +11 -15
  177. package/dist/indexer/search/search-hit-enrichers.js +54 -85
  178. package/dist/indexer/search/search-source.js +1 -4
  179. package/dist/indexer/usage/usage-events.js +36 -7
  180. package/dist/indexer/walk/walker.js +3 -4
  181. package/dist/integrations/agent/engine-fallback.js +23 -40
  182. package/dist/integrations/agent/engine-resolution.js +93 -183
  183. package/dist/integrations/agent/execution.js +507 -0
  184. package/dist/integrations/agent/model-map.js +28 -156
  185. package/dist/integrations/agent/request-lowering.js +66 -141
  186. package/dist/integrations/agent/runner-dispatch.js +143 -321
  187. package/dist/integrations/agent/runner.js +54 -14
  188. package/dist/integrations/lockfile.js +53 -101
  189. package/dist/llm/client.js +18 -6
  190. package/dist/llm/embedders/deterministic.js +2 -3
  191. package/dist/llm/embedders/profile.js +71 -0
  192. package/dist/llm/embedders/remote.js +11 -17
  193. package/dist/llm/feature-gate.js +0 -8
  194. package/dist/llm/index-passes.js +3 -5
  195. package/dist/llm/memory-infer.js +1 -2
  196. package/dist/llm/structured-call.js +5 -24
  197. package/dist/output/generic-render.js +23 -11
  198. package/dist/output/html-render.js +13 -10
  199. package/dist/output/render-registry.js +3 -32
  200. package/dist/output/shapes/helpers.js +25 -38
  201. package/dist/output/shapes/passthrough.js +1 -9
  202. package/dist/{indexer/graph/graph-types.js → output/text/bundle-rename.js} +4 -1
  203. package/dist/output/text/command-format.js +69 -31
  204. package/dist/output/text/helpers.js +1 -1
  205. package/dist/output/text/migrate.js +5 -14
  206. package/dist/output/text/proposal-format.js +48 -3
  207. package/dist/output/text/show-format.js +13 -17
  208. package/dist/output/text/workflow-format.js +0 -32
  209. package/dist/output/text.js +2 -0
  210. package/dist/registry/factory.js +4 -19
  211. package/dist/registry/network.js +66 -220
  212. package/dist/registry/providers/index.js +0 -2
  213. package/dist/registry/providers/skills-sh.js +3 -14
  214. package/dist/registry/providers/static-index.js +24 -26
  215. package/dist/registry/resolve.js +55 -131
  216. package/dist/scripts/akm-migrate-node.js +42948 -92369
  217. package/dist/scripts/akm-migrate.js +42935 -92354
  218. package/dist/setup/registry-stash-loader.js +4 -13
  219. package/dist/setup/semantic-assets.js +3 -44
  220. package/dist/setup/setup.js +1 -1
  221. package/dist/setup/steps/connection.js +5 -6
  222. package/dist/setup/steps/platforms.js +2 -2
  223. package/dist/setup/steps/tasks.js +25 -15
  224. package/dist/sources/provider-factory.js +17 -18
  225. package/dist/sources/providers/filesystem.js +2 -3
  226. package/dist/sources/providers/git-install.js +7 -1
  227. package/dist/sources/providers/git-provider.js +0 -3
  228. package/dist/sources/providers/git-stash.js +83 -21
  229. package/dist/sources/providers/npm.js +2 -4
  230. package/dist/sources/providers/provider-utils.js +5 -10
  231. package/dist/sources/providers/website.js +0 -2
  232. package/dist/sources/snapshot-fetchers/website-ingest.js +1 -1
  233. package/dist/sources/website-url.js +2 -2
  234. package/dist/storage/database.js +9 -35
  235. package/dist/storage/repositories/improve-ledger-repository.js +209 -0
  236. package/dist/storage/repositories/index-connection.js +39 -72
  237. package/dist/storage/repositories/index-entries-repository.js +131 -129
  238. package/dist/storage/repositories/index-entry-mapper.js +1 -2
  239. package/dist/storage/repositories/index-entry-schema.js +101 -268
  240. package/dist/storage/repositories/index-fts-repository.js +86 -256
  241. package/dist/storage/repositories/index-links-repository.js +143 -0
  242. package/dist/storage/repositories/index-llm-cache-repository.js +7 -9
  243. package/dist/storage/repositories/index-meta-repository.js +6 -4
  244. package/dist/storage/repositories/index-schema.js +257 -325
  245. package/dist/storage/repositories/index-utility-repository.js +8 -29
  246. package/dist/storage/repositories/index-vec-repository.js +133 -414
  247. package/dist/storage/repositories/outcome-repository.js +2 -1
  248. package/dist/storage/repositories/proposals-repository.js +104 -1
  249. package/dist/storage/repositories/registry-index-cache-repository.js +100 -0
  250. package/dist/storage/repositories/salience-repository.js +1 -19
  251. package/dist/storage/repositories/task-history-repository.js +26 -4
  252. package/dist/storage/repositories/workflow-runs-repository.js +53 -244
  253. package/dist/storage/sqlite-migrations.js +136 -0
  254. package/dist/storage/sqlite-pragmas.js +11 -9
  255. package/dist/storage/sqlite-transaction.js +170 -0
  256. package/dist/storage/state-db-integrity.js +130 -0
  257. package/dist/tasks/activation-config.js +134 -62
  258. package/dist/tasks/backends/cron.js +191 -302
  259. package/dist/tasks/backends/exec-utils.js +2 -5
  260. package/dist/tasks/backends/launchd.js +141 -748
  261. package/dist/tasks/backends/schtasks.js +119 -623
  262. package/dist/tasks/prepare/prepare-support.js +5 -15
  263. package/dist/tasks/prepare/prepare.js +0 -2
  264. package/dist/tasks/resolve-akm-bin.js +20 -79
  265. package/dist/tasks/run/attempt-lifecycle.js +0 -1
  266. package/dist/tasks/run/load-task.js +1 -1
  267. package/dist/tasks/scheduler-binding.js +20 -238
  268. package/dist/tasks/scheduler-invocation.js +136 -244
  269. package/dist/tasks/scheduler-lock.js +53 -0
  270. package/dist/tasks/scheduler-sync.js +368 -679
  271. package/dist/tasks/source/parse-task-source.js +55 -9
  272. package/dist/tasks/source/task-source-v3-frozen.js +3 -4
  273. package/dist/tasks/source/task-to-v4.js +464 -88
  274. package/dist/workflows/authoring/authoring.js +3 -12
  275. package/dist/workflows/compile.js +211 -0
  276. package/dist/workflows/concurrency-policy.js +13 -74
  277. package/dist/workflows/exec/child-invocation.js +3 -17
  278. package/dist/workflows/exec/child-workflow.js +32 -141
  279. package/dist/workflows/exec/dispatch-redaction.js +13 -53
  280. package/dist/workflows/exec/environment.js +98 -0
  281. package/dist/workflows/exec/exec-unit.js +33 -140
  282. package/dist/workflows/exec/frozen-judge.js +7 -59
  283. package/dist/workflows/exec/native-executor.js +82 -341
  284. package/dist/workflows/exec/param-secrets.js +29 -47
  285. package/dist/workflows/exec/run-workflow.js +154 -387
  286. package/dist/workflows/exec/scheduler.js +9 -36
  287. package/dist/workflows/exec/step-work.js +127 -430
  288. package/dist/workflows/exec/unit-dispatch.js +11 -63
  289. package/dist/workflows/exec/unit-writer.js +8 -52
  290. package/dist/workflows/exec/worktree.js +39 -273
  291. package/dist/workflows/freeze/child-output-references.js +4 -15
  292. package/dist/workflows/freeze/environment.js +99 -92
  293. package/dist/workflows/freeze/freeze.js +172 -0
  294. package/dist/workflows/freeze/step-values.js +19 -21
  295. package/dist/workflows/freeze/targets/child-workflow.js +23 -92
  296. package/dist/workflows/freeze/targets/command.js +10 -33
  297. package/dist/workflows/freeze/targets/script.js +5 -12
  298. package/dist/workflows/freeze/targets/shell.js +3 -6
  299. package/dist/workflows/freeze/targets/task.js +25 -80
  300. package/dist/workflows/freeze/task-bindings.js +20 -67
  301. package/dist/workflows/{source-ir/github-yaml.js → github-yaml.js} +88 -206
  302. package/dist/workflows/ir/params.js +6 -51
  303. package/dist/workflows/ir/plan-hash.js +2 -34
  304. package/dist/workflows/parser.js +140 -43
  305. package/dist/{commands/improve/consolidate/types.js → workflows/plan.js} +2 -1
  306. package/dist/workflows/renderer.js +36 -69
  307. package/dist/workflows/resource-limits.js +12 -120
  308. package/dist/workflows/runtime/agent-identity.js +8 -40
  309. package/dist/workflows/runtime/run-outputs.js +3 -6
  310. package/dist/workflows/runtime/run-plan.js +316 -0
  311. package/dist/workflows/runtime/runs.js +48 -200
  312. package/dist/workflows/runtime/workflow-asset-loader.js +24 -57
  313. package/dist/workflows/{source-ir/semantics.js → source-semantics.js} +16 -20
  314. package/dist/workflows/validate-summary.js +2 -7
  315. package/docs/integration/bundling-akm.md +49 -42
  316. package/docs/migration/README.md +1 -0
  317. package/docs/migration/release-notes/0.9.17.md +43 -0
  318. package/docs/migration/v0.9.1-to-v0.9.2.md +23 -7
  319. package/docs/reference/cli.md +232 -135
  320. package/docs/reference/configuration.md +71 -57
  321. package/docs/reference/data-and-telemetry.md +20 -21
  322. package/docs/reference/tasks.md +105 -39
  323. package/docs/reference/workflow-schema.md +14 -18
  324. package/docs/reference/workflows.md +6 -9
  325. package/package.json +1 -1
  326. package/schemas/akm-config.json +115 -738
  327. package/schemas/akm-workflow.json +1 -0
  328. package/dist/assets/improve-strategies/graph-refresh.json +0 -15
  329. package/dist/assets/prompts/contradiction-judge.md +0 -33
  330. package/dist/assets/prompts/graph-extract-system.md +0 -1
  331. package/dist/assets/prompts/graph-extract-user-prompt.md +0 -35
  332. package/dist/assets/prompts/metadata-enhance-system.md +0 -1
  333. package/dist/assets/tasks/improve/akm-graph-refresh-weekly.yml +0 -4
  334. package/dist/commands/health/advisories.js +0 -150
  335. package/dist/commands/health/metrics.js +0 -329
  336. package/dist/commands/health/surfaces.js +0 -102
  337. package/dist/commands/improve/anti-collapse.js +0 -83
  338. package/dist/commands/improve/collapse-detector.js +0 -432
  339. package/dist/commands/improve/consolidate/eligibility.js +0 -48
  340. package/dist/commands/improve/consolidate/merge.js +0 -149
  341. package/dist/commands/improve/distill/promote-memory.js +0 -291
  342. package/dist/commands/improve/distill/quality-gate.js +0 -337
  343. package/dist/commands/improve/eval-cases.js +0 -52
  344. package/dist/commands/improve/memory/memory-contradiction-detect.js +0 -291
  345. package/dist/commands/improve/proposal-envelope.js +0 -31
  346. package/dist/commands/improve/run-context.js +0 -123
  347. package/dist/commands/improve/shared.js +0 -31
  348. package/dist/commands/improve/source-identity.js +0 -28
  349. package/dist/commands/improve/triage.js +0 -96
  350. package/dist/commands/proposal/drain-policies.js +0 -151
  351. package/dist/commands/sources/update-transaction.js +0 -220
  352. package/dist/core/action-contributors.js +0 -28
  353. package/dist/core/config/config-version-shim.js +0 -101
  354. package/dist/core/fs-txn.js +0 -405
  355. package/dist/core/lexical-score.js +0 -25
  356. package/dist/core/maintenance-barrier.js +0 -167
  357. package/dist/execution/executable-identity.js +0 -105
  358. package/dist/execution/guarded-source.js +0 -427
  359. package/dist/indexer/db/graph-db.js +0 -444
  360. package/dist/indexer/graph/graph-boost.js +0 -427
  361. package/dist/indexer/graph/graph-dedup.js +0 -95
  362. package/dist/indexer/graph/graph-extraction.js +0 -1108
  363. package/dist/indexer/search/name-match.js +0 -35
  364. package/dist/indexer/search/ranking-contributors.js +0 -515
  365. package/dist/indexer/search/ranking-types.js +0 -4
  366. package/dist/indexer/walk/project-context.js +0 -192
  367. package/dist/integrations/agent/execution-cascade.js +0 -566
  368. package/dist/integrations/agent/execution-definitions.js +0 -202
  369. package/dist/integrations/agent/execution-lowering.js +0 -841
  370. package/dist/integrations/agent/execution-preparation.js +0 -98
  371. package/dist/integrations/agent/inline-execution.js +0 -74
  372. package/dist/llm/graph-extract.js +0 -728
  373. package/dist/llm/metadata-enhance.js +0 -96
  374. package/dist/registry/create-provider-registry.js +0 -29
  375. package/dist/registry/pinned-request-helper.js +0 -247
  376. package/dist/registry/pinned-transport.js +0 -717
  377. package/dist/sources/providers/index.js +0 -14
  378. package/dist/storage/engines/sqlite-migrations.js +0 -271
  379. package/dist/storage/repositories/canaries-repository.js +0 -107
  380. package/dist/storage/repositories/embedding-salvage-repository.js +0 -184
  381. package/dist/storage/repositories/registry-cache.js +0 -113
  382. package/dist/tasks/scheduler-sync-preview.js +0 -52
  383. package/dist/tasks/source/task-to-v3.js +0 -507
  384. package/dist/workflows/freeze/resolve-steps.js +0 -86
  385. package/dist/workflows/freeze/source-freeze.js +0 -64
  386. package/dist/workflows/ir/compile.js +0 -321
  387. package/dist/workflows/ir/environment-v4.js +0 -330
  388. package/dist/workflows/ir/freeze-v4.js +0 -153
  389. package/dist/workflows/ir/schema-v4.js +0 -745
  390. package/dist/workflows/ir/schema.js +0 -354
  391. package/dist/workflows/program/schema.js +0 -77
  392. package/dist/workflows/runtime/checkin.js +0 -57
  393. package/dist/workflows/runtime/plan-classifier.js +0 -196
  394. package/dist/workflows/runtime/unit-checkin.js +0 -45
  395. package/dist/workflows/runtime/unit-phases.js +0 -20
  396. package/dist/workflows/schema.js +0 -4
  397. package/dist/workflows/source-ir/compile.js +0 -200
  398. package/dist/workflows/source-ir/program.js +0 -50
  399. package/dist/workflows/source-ir/result.js +0 -26
  400. package/dist/workflows/source-ir/schema.js +0 -786
  401. package/dist/workflows/source-ir/triggers.js +0 -79
  402. package/dist/workflows/source-ir/uses.js +0 -40
  403. package/dist/workflows/validator.js +0 -60
@@ -5,75 +5,46 @@ import fs from "node:fs";
5
5
  import path from "node:path";
6
6
  import { detectAdapterId } from "../core/adapter/detect-adapter.js";
7
7
  import { adapterForId } from "../core/adapter/registry.js";
8
- import { isHttpUrl, toErrorMessage } from "../core/common.js";
9
- import { concurrentMap } from "../core/concurrent.js";
10
- import { ConfigError } from "../core/errors.js";
11
- import { defaultConcurrencyForEndpoint } from "../core/loopback.js";
8
+ import { isHttpUrl } from "../core/common.js";
12
9
  import { classifyPathAccess, describeInaccessiblePath } from "../core/path-access.js";
13
10
  import { getDbPath } from "../core/paths.js";
14
11
  import { SCRIPT_EXTENSIONS } from "../core/recognition-util.js";
15
12
  import { withStateDb } from "../core/state-db.js";
16
13
  import { isVerbose, warn, warnOnce, warnVerbose } from "../core/warn.js";
17
- import { disposeLoweredExecutionDispatchLease, } from "../integrations/agent/execution-lowering.js";
18
- import { isLlmFeatureEnabled } from "../llm/feature-gate.js";
19
- import { resolveIndexPassExecution } from "../llm/index-passes.js";
20
- import { preflightStructuredLlmRunner } from "../llm/structured-call.js";
21
14
  import { resolveSourcesForOrigin } from "../registry/origin-resolve.js";
22
- import { salvageEmbeddingsBeforeDiscard } from "../storage/repositories/embedding-salvage-repository.js";
23
15
  import { closeDatabase, openExistingDatabase, openIndexDatabase, openReadonlyExistingDatabase, } from "../storage/repositories/index-connection.js";
24
- import { deleteAllEntries, deleteEntriesByBundle, deleteEntriesByDirAndBundle, deleteEntriesByDirExceptRefs, deleteEntriesByIds, deleteUsageEventsByEntryIds, findEntryIdByRef, getAllEntries, getEmbeddableEntryCount, getEntryCount, getIndexedBundleIdsByDir, getIndexedDirPathsByBundleId, relinkUsageEvents, upsertEntry, } from "../storage/repositories/index-entries-repository.js";
25
- import { clearStaleCacheEntries, computeBodyHash, getLlmCacheEntry, } from "../storage/repositories/index-llm-cache-repository.js";
26
- import { deleteIndexDirState, getMeta, setMeta, upsertIndexDirState, } from "../storage/repositories/index-meta-repository.js";
16
+ import { deleteEntriesByBundle, deleteEntriesByDirAndBundle, deleteEntriesByDirExceptRefs, deleteEntriesByIds, deleteUsageEventsByEntryIds, findEntryIdByRef, getAllEntries, getEmbeddableEntryCount, getEntryCount, getIndexedBundleIdsByDir, getIndexedDirPathsByBundleId, relinkUsageEvents, upsertEntry, } from "../storage/repositories/index-entries-repository.js";
17
+ import { clearStaleCacheEntries } from "../storage/repositories/index-llm-cache-repository.js";
18
+ import { deleteIndexDirState, deleteMeta, getIndexDirState, getMeta, setMeta, upsertIndexDirState, } from "../storage/repositories/index-meta-repository.js";
19
+ import { VACUUM_PENDING_META } from "../storage/repositories/index-schema.js";
27
20
  import { upsertUtilityScore } from "../storage/repositories/index-utility-repository.js";
28
- import { getEmbeddingCount, isVecAvailable, isVecFastPathReady, warnIfVecMissing, } from "../storage/repositories/index-vec-repository.js";
21
+ import { getEmbeddingCount } from "../storage/repositories/index-vec-repository.js";
22
+ import { INDEX_DB_VACUUMED_EVENT, readFreelistInfo, vacuumIfReclaimable } from "../storage/state-db-integrity.js";
29
23
  import { assertIndexedWorkflowSourceIdentity, WorkflowSourceIdentityError } from "../workflows/source-files.js";
30
- import { deleteStoredGraph } from "./db/graph-db.js";
31
24
  import { reclassifyIndexDbContention } from "./index-db-contention.js";
32
25
  import { deriveEntryProvenance, deriveInstallations } from "./installations.js";
33
26
  import { indexedPathMatchesOwner, resolveAdapterConceptOwner, } from "./lookup/adapter-concept-owner.js";
34
27
  import { generateEmbeddingsForDb } from "./materialize-embeddings.js";
35
28
  import { canUseIncrementalSkip, computeDirFingerprint, getCachedDirState, getDirIndexState, inferZeroRowReason, } from "./passes/dir-staleness.js";
36
- import { getMarkdownFragmentContent, hasMarkdownFragmentContent, isEnrichmentComplete, isWorkflowSkipWarning, setMarkdownFragmentContent, } from "./passes/metadata.js";
29
+ import { isWorkflowSkipWarning, withFileSize } from "./passes/metadata.js";
37
30
  import { drainDirDocuments } from "./scan/drain-dir.js";
38
- import { buildSearchText } from "./search/search-fields.js";
39
- import { purgeOldUsageEvents } from "./usage/usage-events.js";
31
+ import { purgeOldUsageEvents, USAGE_EVENT_RETENTION_DAYS } from "./usage/usage-events.js";
40
32
  import { walkStashFlatWithStatus } from "./walk/walker.js";
41
- function collectLoweringNotices(target, notices) {
42
- const keys = new Set(target.map((notice) => JSON.stringify(notice)));
43
- for (const notice of notices) {
44
- const key = JSON.stringify(notice);
45
- if (keys.has(key))
46
- continue;
47
- keys.add(key);
48
- target.push(notice);
49
- }
50
- }
51
33
  function throwIfAborted(signal) {
52
34
  if (signal?.aborted) {
53
35
  throw signal.reason instanceof Error ? signal.reason : new Error("index interrupted");
54
36
  }
55
37
  }
56
- export function getDefaultLlmConcurrency(llmConfig) {
57
- if (typeof llmConfig?.concurrency === "number")
58
- return llmConfig.concurrency;
59
- // ONE classifier decides the local-vs-remote default (`core/loopback.ts`'s
60
- // `defaultConcurrencyForEndpoint`), shared with the embedding pool
61
- // (`resolveEmbeddingConcurrency`, `src/llm/embedders/remote.ts`) and the
62
- // workflow engine's frozen concurrency default.
63
- //
64
- // The explicit-override branch above only fires for callers that put
65
- // `concurrency` on the connection themselves — `engines.<name>.concurrency`
66
- // is a valid schema field but `resolveLlmEngineUse` does NOT copy it into
67
- // the resolved connection, so on the enrichment path the auto-derived 1/2
68
- // is what runs (see docs/architecture/internals/indexing.md).
69
- return defaultConcurrencyForEndpoint(llmConfig?.endpoint);
38
+ // ── Source ownership ─────────────────────────────────────────────────────────
39
+ /** Each source's durable bundle component (`deriveInstallations`: one per source, source order). */
40
+ function sourceComponents(sources) {
41
+ return deriveInstallations([...sources]).map((installation) => installation.components[0]);
70
42
  }
71
43
  function sourceOwners(sources) {
72
- const installations = deriveInstallations([...sources]);
73
- return sources.flatMap((source, index) => {
74
- const installation = installations[index];
75
- return installation ? [{ bundleId: installation.id, sourceRoot: path.resolve(source.path) }] : [];
76
- });
44
+ return sourceComponents(sources).map((component, index) => ({
45
+ bundleId: component.id,
46
+ sourceRoot: path.resolve(sources[index].path),
47
+ }));
77
48
  }
78
49
  function parseStoredSourceOwners(raw) {
79
50
  if (!raw)
@@ -99,102 +70,40 @@ function parseStoredSourceOwners(raw) {
99
70
  }
100
71
  }
101
72
  /**
102
- * Source cache phase: ensure git stash caches are up to date and purge orphaned
103
- * entries from removed sources (incremental only).
73
+ * Sources removed (or moved) since the last complete run. Their entries are
74
+ * purged by {@link applyRemovedSources} once the walk completes.
104
75
  */
105
- async function runSourceCachePhase(ctx) {
106
- const { db, isIncremental, full, sources } = ctx;
107
- if (isIncremental && !full) {
108
- const currentByBundle = new Map(sourceOwners(sources).map((owner) => [owner.bundleId, owner]));
109
- for (const previous of parseStoredSourceOwners(getMeta(db, "sourceOwners"))) {
110
- const current = currentByBundle.get(previous.bundleId);
111
- if (!current || current.sourceRoot !== previous.sourceRoot) {
112
- ctx.hadRemovedSources = true;
113
- ctx.removedSources.push({
114
- ...previous,
115
- removeBundleEntries: current === undefined,
116
- });
117
- }
118
- }
119
- }
120
- // Source caches are hydrated before akmIndex() calls this phase; nothing
121
- // further to do here. The flag is exposed on ctx for runWalkPhase().
76
+ function findRemovedSources(db, sources) {
77
+ const currentByBundle = new Map(sourceOwners(sources).map((owner) => [owner.bundleId, owner]));
78
+ return parseStoredSourceOwners(getMeta(db, "sourceOwners")).flatMap((previous) => {
79
+ const current = currentByBundle.get(previous.bundleId);
80
+ if (current && current.sourceRoot === previous.sourceRoot)
81
+ return [];
82
+ return [{ ...previous, removeBundleEntries: current === undefined }];
83
+ });
122
84
  }
123
- function applyRemovedSources(ctx) {
124
- if (!ctx.scanComplete)
125
- return;
126
- const currentRoots = new Set(sourceOwners(ctx.sources).map((owner) => owner.sourceRoot));
127
- for (const removed of ctx.removedSources) {
85
+ function applyRemovedSources(db, sources, removedSources, isIncremental) {
86
+ const owners = sourceOwners(sources);
87
+ for (const removed of removedSources) {
128
88
  if (removed.removeBundleEntries)
129
- deleteEntriesByBundle(ctx.db, removed.bundleId);
130
- if (!currentRoots.has(removed.sourceRoot))
131
- deleteStoredGraph(ctx.db, removed.sourceRoot);
89
+ deleteEntriesByBundle(db, removed.bundleId);
132
90
  }
133
- }
134
- /**
135
- * Walk phase: scan the filesystem, generate metadata, and persist entries to
136
- * the database. Also kicks off LLM enrichment for directories that need it.
137
- *
138
- * Writes `ctx.scannedDirs`, `ctx.skippedDirs`, `ctx.generatedCount`,
139
- * `ctx.walkWarnings`, and `ctx.dirsNeedingLlm` for downstream phases.
140
- */
141
- async function runWalkPhase(ctx) {
142
- const { db, sources, isIncremental, builtAtMs, hadRemovedSources, full, clean, signal, onProgress, config } = ctx;
143
- throwIfAborted(signal);
144
- ctx.timing.tWalkStart = Date.now();
145
- const doFullDelete = full || !isIncremental;
146
- const { scannedDirs, skippedDirs, generatedCount, dirsNeedingLlm, warnings, complete } = await indexEntries(db, sources, isIncremental, builtAtMs, hadRemovedSources, doFullDelete, onProgress, !clean, async (dirRecords, ownersByRoot) => {
147
- const runner = ctx.enrichmentExecution.runner;
148
- if (runner &&
149
- isLlmFeatureEnabled(config, "metadata_enhance") &&
150
- dirRecordsNeedMetadataDispatch(db, dirRecords, ownersByRoot)) {
151
- ctx.enrichmentLease = await preflightStructuredLlmRunner(runner);
152
- }
153
- });
154
- ctx.scannedDirs = scannedDirs;
155
- ctx.skippedDirs = skippedDirs;
156
- ctx.generatedCount = generatedCount;
157
- ctx.walkWarnings = warnings;
158
- ctx.dirsNeedingLlm = dirsNeedingLlm;
159
- ctx.scanComplete = complete;
160
- onProgress({
161
- phase: "scan",
162
- message: `Scanned ${scannedDirs} ${scannedDirs === 1 ? "directory" : "directories"} and skipped ${skippedDirs}.`,
163
- });
164
- // Workflow validation noise gate (issue #273): suppress per-spec stderr lines
165
- // at default verbosity and emit a single summary instead.
166
- // In verbose mode the per-spec lines are already printed by
167
- // buildMetadataSkipWarning at generation time — no second pass needed here.
168
- if (!isVerbose()) {
169
- const workflowSkipWarnings = warnings.filter(isWorkflowSkipWarning);
170
- const skippedWorkflowCount = workflowSkipWarnings.length;
171
- if (skippedWorkflowCount > 0) {
172
- const noun = skippedWorkflowCount === 1 ? "workflow spec" : "workflow specs";
173
- warn(`${skippedWorkflowCount} ${noun} skipped due to validation errors; ` +
174
- "rerun with --verbose (or AKM_VERBOSE=1) to see details.");
175
- }
91
+ // A full run re-drains every configured source, so any other bundle's rows
92
+ // are stale even when no stored owner names them.
93
+ if (isIncremental)
94
+ return;
95
+ const currentBundles = new Set(owners.map((owner) => owner.bundleId));
96
+ const indexed = db.prepare("SELECT DISTINCT bundle_id AS bundleId FROM entries").all();
97
+ for (const { bundleId } of indexed) {
98
+ if (!currentBundles.has(bundleId))
99
+ deleteEntriesByBundle(db, bundleId);
176
100
  }
177
- ctx.timing.tWalkEnd = Date.now();
178
- throwIfAborted(signal);
179
- // LLM enrichment for directories that need it
180
- await enhanceDirsWithLlm(db, config, ctx.enrichmentExecution, dirsNeedingLlm, onProgress, signal, (notices) => collectLoweringNotices(ctx.loweringNotices, notices), ctx.enrichmentLease);
181
- onProgress({
182
- phase: "llm",
183
- message: ctx.enrichmentExecution.runner
184
- ? `LLM enhancement reviewed ${dirsNeedingLlm.length} ${dirsNeedingLlm.length === 1 ? "directory" : "directories"}.`
185
- : "LLM enhancement disabled.",
186
- });
187
- ctx.timing.tLlmEnd = Date.now();
188
101
  }
189
102
  /**
190
- * The ONE embedding-phase implementation (#954): generate and
191
- * store vectors for every entry missing one, then compute the `hasEmbeddings`
192
- * fact and the semantic-search verification off the result. `akmIndex`'s own
193
- * (non-deferred) run calls this from {@link runEmbeddingPhase} below; `akm
194
- * bundle update`'s coordinator calls it directly on its own connection AFTER
195
- * its unified update transaction commits, since the ambient-transaction drift
196
- * guard (and the whole point of per-batch commit, #954) requires `db` to have
197
- * no ambient transaction open.
103
+ * The embedding phase (#954): generate and store vectors for every entry
104
+ * missing one, then compute the `hasEmbeddings` fact and the semantic-search
105
+ * verification off the result. `db` must have no ambient transaction open —
106
+ * each per-batch commit is its own durable write.
198
107
  */
199
108
  export async function runEmbeddingPass(params) {
200
109
  const { db, config, onProgress, signal, reembed } = params;
@@ -209,69 +118,24 @@ export async function runEmbeddingPass(params) {
209
118
  return { embeddingResult, verification };
210
119
  }
211
120
  /**
212
- * Embedding phase: generate and store vector embeddings for all unembedded
213
- * entries. Writes `ctx.embeddingResult` and `ctx.verification` for the
214
- * finalize phase / caller.
215
- */
216
- async function runEmbeddingPhase(ctx) {
217
- const { db, config, signal, onProgress, reembed, deferredUpdateTransaction } = ctx;
218
- throwIfAborted(signal);
219
- if (deferredUpdateTransaction) {
220
- // `akm bundle update`'s deferred pass (#954): the embedding
221
- // phase runs AFTER the coordinator's own commit, on its own connection,
222
- // via the coordinator's direct `runEmbeddingPass` call — never here,
223
- // inside the borrowed transaction (the ambient-transaction drift guard
224
- // would reject it anyway). `runFinalizePhase` records semantic state as
225
- // "pending".
226
- ctx.timing.tEmbedEnd = Date.now();
227
- return;
228
- }
229
- // Forward the signal. Without it generateEmbeddingsForDb's abort machinery was
230
- // inert — its throwIfAborted checks and the signal it threads into embedBatch
231
- // (which RemoteEmbedder passes to every fetch and LocalEmbedder honours between
232
- // chunks) never saw a controller. Ctrl-C and the improve budget abort could not
233
- // stop the embedding phase, the longest phase of an index run.
234
- const { embeddingResult, verification } = await runEmbeddingPass({ db, config, onProgress, signal, reembed });
235
- ctx.embeddingResult = embeddingResult;
236
- ctx.verification = verification;
237
- ctx.timing.tEmbedEnd = Date.now();
238
- }
239
- /**
240
- * Finalize phase: confirm transactionally materialized FTS state, re-link
241
- * usage events, recompute utility scores, update index metadata, and emit the
242
- * verify event.
121
+ * Finalize: re-link usage events, recompute utility scores, clear stale LLM
122
+ * cache rows and record this run's freshness metadata. `upsertEntry` and every
123
+ * canonical delete own their FTS projection, so the "fts" event is an
124
+ * observation point, not a second materialization pass.
243
125
  */
244
- async function runFinalizePhase(ctx) {
245
- const { db, config, sources, sourceDirs, stashDir, signal, onProgress, deferredUpdateTransaction } = ctx;
246
- ctx.timing.tFinalizeStart = Date.now();
247
- // `upsertEntry` and every canonical delete own their FTS projection. This is
248
- // an observation point, not a second materialization pass.
249
- onProgress({
250
- phase: "fts",
251
- message: "Full-text search index is current.",
252
- });
253
- ctx.timing.tFtsEnd = Date.now();
126
+ function finalizeIndex(args) {
127
+ const { db, sources, sourceDirs, stashDir, signal, onProgress } = args;
128
+ onProgress({ phase: "fts", message: "Full-text search index is current." });
129
+ const tFtsEnd = Date.now();
254
130
  // Re-link state.db usage events to the regenerated index and recompute the
255
131
  // derived utility cache. Stored refs already use the current item-ref grammar,
256
132
  // so this idempotent pass only restores derived entry ids.
257
- const mutateState = (stateDb, stateSchema) => {
133
+ withStateDb((stateDb) => {
258
134
  onProgress({ phase: "finalize", message: "Relinking usage events." });
259
- relinkUsageEvents(db, stateDb, { sources, defaultStashDir: stashDir, stateSchema });
135
+ relinkUsageEvents(db, stateDb);
260
136
  onProgress({ phase: "finalize", message: "Recomputing utility scores." });
261
- recomputeUtilityScores(db, stateDb, { stateSchema });
262
- };
263
- if (deferredUpdateTransaction) {
264
- if (deferredUpdateTransaction.db !== db || !db.inTransaction) {
265
- throw new Error("Source update index finalization requires its borrowed unified transaction.");
266
- }
267
- // state.db is ATTACHed to this same index connection before the outer
268
- // BEGIN IMMEDIATE. Index and state mutations therefore share one SQLite
269
- // commit/rollback decision rather than an unsafe two-connection ordering.
270
- mutateState(db, deferredUpdateTransaction.stateSchema);
271
- }
272
- else {
273
- withStateDb(mutateState);
274
- }
137
+ recomputeUtilityScores(db, stateDb);
138
+ });
275
139
  // Purge LLM cache entries for assets that no longer exist in the index.
276
140
  try {
277
141
  onProgress({ phase: "finalize", message: "Clearing stale LLM cache entries." });
@@ -284,41 +148,34 @@ async function runFinalizePhase(ctx) {
284
148
  // An incomplete run preserves the prior freshness watermark. Advancing it
285
149
  // could make a recovered source look unchanged even though this run never
286
150
  // persisted its files.
287
- if (ctx.scanComplete) {
151
+ if (args.scanComplete) {
288
152
  setMeta(db, "builtAt", new Date().toISOString());
289
153
  setMeta(db, "stashDir", stashDir);
290
154
  setMeta(db, "stashDirs", JSON.stringify(sourceDirs));
291
155
  setMeta(db, "sourceOwners", JSON.stringify(sourceOwners(sources)));
292
156
  }
293
- warnIfVecMissing(db);
294
- const totalEntries = getEntryCount(db);
295
- if (deferredUpdateTransaction) {
296
- // #954: the embedding phase was skipped for this borrowed
297
- // transaction — record semantic state as pending, never ready, until the
298
- // coordinator's own post-commit `runEmbeddingPass` call reports the
299
- // truth on a fresh connection.
300
- setMeta(db, "hasEmbeddings", "0");
301
- const semanticEntryCount = getEmbeddableEntryCount(db);
302
- const message = "Semantic index update deferred until after the source-update commit.";
303
- onProgress({ phase: "verify", message });
304
- ctx.verification = {
305
- ok: true,
306
- message,
307
- semanticSearchEnabled: config.semanticSearchMode === "auto",
308
- semanticSearchMode: config.semanticSearchMode,
309
- semanticStatus: config.semanticSearchMode === "off" ? "disabled" : "pending",
310
- embeddingProvider: getEmbeddingProvider(config.embedding),
311
- entryCount: semanticEntryCount,
312
- embeddingCount: getEmbeddingCount(db),
313
- vecAvailable: isVecAvailable(db),
314
- };
315
- }
316
- // Non-deferred: ctx.verification was already populated by runEmbeddingPhase
317
- // (via the shared runEmbeddingPass).
318
- ctx.totalEntries = totalEntries;
319
- ctx.timing.tFinalizeEnd = Date.now();
320
- // suppress unused warning — sources was previously used inline
321
- void sources;
157
+ return { tFtsEnd };
158
+ }
159
+ /**
160
+ * Compact index.db once a layout migration has left tables and columns'
161
+ * pages free, or whenever more than half its pages are free (the same
162
+ * threshold improve applies to state.db). Nothing else ever VACUUMs this
163
+ * file, and a delete-heavy history leaves it mostly free pages.
164
+ */
165
+ function vacuumIndexDb(db, onProgress) {
166
+ const migrated = getMeta(db, VACUUM_PENDING_META) === "1";
167
+ const outcome = vacuumIfReclaimable(db, readFreelistInfo(db), {
168
+ eventType: INDEX_DB_VACUUMED_EVENT,
169
+ force: migrated,
170
+ });
171
+ if (!outcome.ran)
172
+ return;
173
+ if (migrated)
174
+ deleteMeta(db, VACUUM_PENDING_META);
175
+ onProgress({
176
+ phase: "finalize",
177
+ message: `Compacted index.db with VACUUM: ${outcome.pagesBefore} → ${outcome.pagesAfter} pages.`,
178
+ });
322
179
  }
323
180
  // ── Clean pass ───────────────────────────────────────────────────────────────
324
181
  /**
@@ -372,29 +229,12 @@ let akmIndexOverride;
372
229
  export function _setAkmIndexForTests(fake) {
373
230
  akmIndexOverride = fake;
374
231
  }
375
- // Moved to its own module (field follow-up to #956) so
376
- // `generateEmbeddingsForDb` (materialize-embeddings.ts) can reuse the same
377
- // classifier without an indexer.ts <-> materialize-embeddings.ts import
378
- // cycle. Re-exported here for back-compat with existing call sites/tests
379
- // that import it from `./indexer`. See index-db-contention.ts for the full
380
- // rationale.
381
- export { reclassifyIndexDbContention };
382
232
  export async function akmIndex(options) {
383
233
  try {
384
234
  const override = akmIndexOverride;
385
235
  return override ? await override(options) : await akmIndexReal(options);
386
236
  }
387
237
  catch (error) {
388
- const updateDb = options.deferredUpdateTransaction?.db;
389
- if (updateDb?.inTransaction) {
390
- try {
391
- updateDb.exec("ROLLBACK");
392
- }
393
- catch {
394
- // Preserve the indexing error. The update coordinator will retry
395
- // rollback before closing its borrowed unified handle.
396
- }
397
- }
398
238
  throw reclassifyIndexDbContention(error);
399
239
  }
400
240
  }
@@ -402,20 +242,16 @@ let indexTransactionHookForTests;
402
242
  /**
403
243
  * TEST-ONLY. Observe the in-flight reindex transaction; `undefined` restores.
404
244
  *
405
- * Exists because the delete-then-reinsert atomicity guarantee is, by
406
- * construction, invisible from outside the transaction: by the time
407
- * `akmIndex()` resolves, the commit has already collapsed both generations
408
- * into one observable state. Concurrency tests install a hook that opens a
409
- * SECOND connection at these points and asserts it still sees the previous
410
- * complete generation. Inert in production (one `undefined?.()` per reindex).
245
+ * Exists because the persist transaction's atomicity is, by construction,
246
+ * invisible from outside it: by the time `akmIndex()` resolves, the commit
247
+ * has already collapsed both generations into one observable state.
248
+ * Concurrency tests install a hook that opens a SECOND connection at this
249
+ * point and asserts it still sees the previous complete generation. Inert in
250
+ * production (one `undefined?.()` per reindex).
411
251
  */
412
252
  export function _setIndexTransactionHookForTests(hook) {
413
253
  indexTransactionHookForTests = hook;
414
254
  }
415
- /** Fire a named in-transaction observation point (no-op outside tests). */
416
- function indexTransactionHook(point) {
417
- indexTransactionHookForTests?.(point);
418
- }
419
255
  let drainObserverForTests;
420
256
  /**
421
257
  * TEST-ONLY. Observe every directory that actually reaches
@@ -429,6 +265,18 @@ let drainObserverForTests;
429
265
  export function _setDrainObserverForTests(observer) {
430
266
  drainObserverForTests = observer;
431
267
  }
268
+ /** Whether `root` is a directory whose listing can be read (adapter detection only probes such roots). */
269
+ function isReadableDirectory(root) {
270
+ try {
271
+ if (!fs.statSync(root).isDirectory())
272
+ return false;
273
+ fs.readdirSync(root); // probe readability, not just existence
274
+ return true;
275
+ }
276
+ catch {
277
+ return false;
278
+ }
279
+ }
432
280
  /**
433
281
  * Detect an adapter for every resolvable source that does not declare one, and
434
282
  * persist each detection into `config.json`.
@@ -440,17 +288,13 @@ export function _setDrainObserverForTests(observer) {
440
288
  * emitted here. The map is cleared at the top of every callback invocation
441
289
  * because `mutateConfig` may retry optimistically, and a retry must not report
442
290
  * a superseded attempt.
443
- *
444
- * Extracted from `akmIndexReal` as one self-contained named pass, both to keep
445
- * that function under the src-wide function-size bar and because the detection
446
- * and its disclosure belong together.
447
291
  */
448
292
  function detectAndPersistBundleAdapters(allSourceEntries, config, mutateConfig, opts) {
449
293
  const detectedByBundle = new Map();
450
294
  for (const source of allSourceEntries) {
451
295
  if (source.adapterId || source.unresolved)
452
296
  continue;
453
- if (allSourceRootsReadable([source.path])) {
297
+ if (isReadableDirectory(source.path)) {
454
298
  source.adapterId = detectAdapterId(source.path);
455
299
  if (source.registryId)
456
300
  detectedByBundle.set(source.registryId, source.adapterId);
@@ -490,43 +334,11 @@ function detectAndPersistBundleAdapters(allSourceEntries, config, mutateConfig,
490
334
  }
491
335
  return { config: nextConfig, persistedAdapters };
492
336
  }
493
- function createIndexRunContext(options) {
494
- const prevStashDir = getMeta(options.db, "stashDir");
495
- const prevBuiltAt = getMeta(options.db, "builtAt");
496
- const isIncremental = !options.full && prevStashDir === options.stashDir && !!prevBuiltAt;
497
- const builtAtMs = isIncremental && prevBuiltAt ? new Date(prevBuiltAt).getTime() : 0;
498
- const { t0, ...context } = options;
499
- return {
500
- ...context,
501
- loweringNotices: [...options.enrichmentExecution.notices],
502
- timing: {
503
- t0,
504
- tWalkStart: t0,
505
- tWalkEnd: t0,
506
- tLlmEnd: t0,
507
- tFtsEnd: t0,
508
- tEmbedEnd: t0,
509
- tFinalizeStart: t0,
510
- tFinalizeEnd: t0,
511
- },
512
- isIncremental,
513
- builtAtMs,
514
- hadRemovedSources: false,
515
- removedSources: [],
516
- scanComplete: true,
517
- scannedDirs: 0,
518
- skippedDirs: 0,
519
- generatedCount: 0,
520
- walkWarnings: [],
521
- dirsNeedingLlm: [],
522
- embeddingResult: null,
523
- };
524
- }
525
337
  async function akmIndexReal(options) {
526
338
  // R-022: `dryRun` only ever gated the `--clean` stale-entry removal pass
527
- // (see `runCleanPass` below) — every other phase (walk, LLM enrichment,
528
- // embeddings, FTS, the adapter-detection config write) ran for real
529
- // regardless, so `akm index --dry-run` alone silently performed a full,
339
+ // (see `runCleanPass` below) — every other phase (walk, embeddings, FTS,
340
+ // the adapter-detection config write) ran for real regardless, so
341
+ // `akm index --dry-run` alone silently performed a full,
530
342
  // real index. The flag's own docs (`IndexOptions.dryRun` above, and the
531
343
  // CLI help in stash-cli.ts) already scope it to `--clean`; reject the
532
344
  // combination that was never implemented instead of quietly doing
@@ -538,222 +350,185 @@ async function akmIndexReal(options) {
538
350
  "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.");
539
351
  }
540
352
  const requestedAt = Date.now();
541
- return (async () => {
542
- const stashDir = options.stashDir;
543
- const onProgress = options?.onProgress ?? (() => { });
544
- const signal = options?.signal;
545
- const full = options?.full === true;
546
- const clean = options?.clean === true;
547
- const dryRun = options?.dryRun === true;
548
- const reembed = options?.reembed === true;
549
- // Load config and resolve all stash sources
550
- const { loadConfig, mutateConfig } = await import("../core/config/config.js");
551
- let config = loadConfig();
552
- // Durable state must be runtime-compatible before source hydration,
553
- // adapter persistence, or index.db creation can mutate the installation.
554
- onProgress({ phase: "preflight", message: "Validating durable state." });
555
- if (!options.deferredUpdateTransaction)
556
- withStateDb(() => undefined);
557
- // Ensure git stash caches are extracted before resolving stash dirs,
558
- // so their content directories exist on disk for the walker to discover.
559
- const sourceCacheStart = Date.now();
560
- onProgress({ phase: "preflight", message: "Hydrating source caches." });
561
- const { ensureSourceCaches, resolveSourceEntries } = await import("./search/search-source.js");
562
- // Inject the store-backed secret resolver from here — a composition root
563
- // ABOVE the provider/fetcher import cycle (this module reaches
564
- // search-source only via dynamic import). This is what lets a website
565
- // source's X fetcher resolve `secrets/x-bearer-token` during
566
- // bundle-update / hydrate, not just from the command-layer URL-ingest
567
- // path. `secret-seam` is imported here, never from inside the cycle.
568
- const { storeSecretResolver } = await import("../sources/snapshot-fetchers/secret-seam.js");
569
- await ensureSourceCaches(config, {
570
- force: full,
571
- materialize: options.hydrateSources !== false,
572
- secrets: storeSecretResolver,
573
- // Same progress channel as every other phase (#954) — a
574
- // stalled clone/fetch here runs BEFORE index.db is even opened, so
575
- // without this it looked identical to "no database open, nothing
576
- // written".
577
- onProgress: (message) => onProgress({ phase: "preflight", message }),
578
- });
579
- const sourceCacheEnd = Date.now();
580
- const allSourceEntries = resolveSourceEntries(stashDir, config);
581
- const detected = detectAndPersistBundleAdapters(allSourceEntries, config, mutateConfig, {
582
- announce: options.implicit !== true,
583
- persist: options.persistDetectedAdapters !== false,
353
+ const { stashDir, signal } = options;
354
+ const onProgress = options.onProgress ?? (() => { });
355
+ const full = options.full === true;
356
+ const clean = options.clean === true;
357
+ const dryRun = options.dryRun === true;
358
+ // Load config and resolve all stash sources
359
+ const { loadConfig, mutateConfig } = await import("../core/config/config.js");
360
+ let config = loadConfig();
361
+ // Durable state must be runtime-compatible before source hydration,
362
+ // adapter persistence, or index.db creation can mutate the installation.
363
+ onProgress({ phase: "preflight", message: "Validating durable state." });
364
+ withStateDb(() => undefined);
365
+ // Ensure git stash caches are extracted before resolving stash dirs,
366
+ // so their content directories exist on disk for the walker to discover.
367
+ const sourceCacheStart = Date.now();
368
+ onProgress({ phase: "preflight", message: "Hydrating source caches." });
369
+ const { ensureSourceCaches, resolveSourceEntries } = await import("./search/search-source.js");
370
+ // Inject the store-backed secret resolver from here — a composition root
371
+ // ABOVE the provider/fetcher import cycle (this module reaches
372
+ // search-source only via dynamic import). This is what lets a website
373
+ // source's X fetcher resolve `secrets/x-bearer-token` during
374
+ // bundle-update / hydrate, not just from the command-layer URL-ingest
375
+ // path. `secret-seam` is imported here, never from inside the cycle.
376
+ const { storeSecretResolver } = await import("../sources/snapshot-fetchers/secret-seam.js");
377
+ await ensureSourceCaches(config, {
378
+ force: full,
379
+ materialize: options.hydrateSources !== false,
380
+ secrets: storeSecretResolver,
381
+ // Same progress channel as every other phase (#954) — a
382
+ // stalled clone/fetch here runs BEFORE index.db is even opened, so
383
+ // without this it looked identical to "no database open, nothing
384
+ // written".
385
+ onProgress: (message) => onProgress({ phase: "preflight", message }),
386
+ });
387
+ const sourceCacheEnd = Date.now();
388
+ const sources = resolveSourceEntries(stashDir, config);
389
+ const detected = detectAndPersistBundleAdapters(sources, config, mutateConfig, {
390
+ announce: options.implicit !== true,
391
+ persist: options.persistDetectedAdapters !== false,
392
+ });
393
+ config = detected.config;
394
+ const { persistedAdapters } = detected;
395
+ const sourceDirs = sources.map((s) => s.path);
396
+ onProgress({
397
+ phase: "preflight",
398
+ message: `Resolved ${sourceDirs.length} stash source${sourceDirs.length === 1 ? "" : "s"}.`,
399
+ });
400
+ const t0 = Date.now();
401
+ const dbPath = getDbPath();
402
+ const db = openIndexDatabase(dbPath);
403
+ try {
404
+ // `--full` folds into `isIncremental`: a full run drains every directory
405
+ // through the same diff-persist path as an incremental one.
406
+ const prevStashDir = getMeta(db, "stashDir");
407
+ const prevBuiltAt = getMeta(db, "builtAt");
408
+ const isIncremental = !full && prevStashDir === stashDir && !!prevBuiltAt;
409
+ const builtAtMs = isIncremental && prevBuiltAt ? new Date(prevBuiltAt).getTime() : 0;
410
+ const mode = isIncremental ? "incremental" : "full";
411
+ onProgress({
412
+ phase: "summary",
413
+ message: buildIndexSummaryMessage({
414
+ mode,
415
+ sourcesCount: sourceDirs.length,
416
+ semanticSearchMode: config.semanticSearchMode,
417
+ embeddingProvider: getEmbeddingProvider(config.embedding),
418
+ }),
584
419
  });
585
- config = detected.config;
586
- const persistedAdapters = detected.persistedAdapters;
587
- const allSourceDirs = allSourceEntries.map((s) => s.path);
420
+ const removedSources = findRemovedSources(db, sources);
421
+ // ── Walk: scan the filesystem, generate metadata, persist entries ─────
422
+ throwIfAborted(signal);
423
+ const tWalkStart = Date.now();
424
+ // Walk every source and pre-generate all metadata outside any transaction…
425
+ const { dirRecords, scannedDirs, skippedDirs, generatedCount, warnings, complete } = await scanSourceDirs(db, sources, isIncremental, builtAtMs, removedSources.length > 0, onProgress, !clean);
426
+ // …then write it in one transaction: `item_ref = <bundle>//<conceptId>`
427
+ // plus canonical component/adapter provenance per source root.
428
+ const bundleByRoot = buildIndexedSourceOwners(sources);
429
+ persistDirRecords(db, dirRecords, warnings, bundleByRoot);
588
430
  onProgress({
589
- phase: "preflight",
590
- message: `Resolved ${allSourceDirs.length} stash source${allSourceDirs.length === 1 ? "" : "s"}.`,
431
+ phase: "scan",
432
+ message: `Scanned ${scannedDirs} ${scannedDirs === 1 ? "directory" : "directories"} and skipped ${skippedDirs}.`,
591
433
  });
592
- const t0 = Date.now();
593
- const enrichmentExecution = resolveIndexPassExecution("enrichment", config);
594
- // Open database — pass embedding dimension from config if available
595
- const dbPath = getDbPath();
596
- const embeddingDim = config.embedding?.dimension;
597
- const borrowedUpdateDb = options.deferredUpdateTransaction?.db;
598
- const db = borrowedUpdateDb ?? openIndexDatabase(dbPath, embeddingDim ? { embeddingDim } : undefined);
599
- if (borrowedUpdateDb && !borrowedUpdateDb.inTransaction) {
600
- throw new Error("Source update index requires an active borrowed index transaction.");
434
+ // Workflow validation noise gate (issue #273): suppress per-spec stderr
435
+ // lines at default verbosity and emit a single summary instead. In verbose
436
+ // mode the per-spec lines are already printed by buildMetadataSkipWarning.
437
+ if (!isVerbose()) {
438
+ const skippedWorkflowCount = warnings.filter(isWorkflowSkipWarning).length;
439
+ if (skippedWorkflowCount > 0) {
440
+ const noun = skippedWorkflowCount === 1 ? "workflow spec" : "workflow specs";
441
+ warn(`${skippedWorkflowCount} ${noun} skipped due to validation errors; ` +
442
+ "rerun with --verbose (or AKM_VERBOSE=1) to see details.");
443
+ }
601
444
  }
602
- let indexRunContext;
603
- try {
604
- // Assemble the run context
605
- const ctx = createIndexRunContext({
606
- db,
607
- config,
608
- enrichmentExecution,
609
- sources: allSourceEntries,
610
- sourceDirs: allSourceDirs,
611
- full,
612
- clean,
613
- reembed,
614
- stashDir,
615
- onProgress,
616
- signal,
617
- t0,
618
- deferredUpdateTransaction: options.deferredUpdateTransaction,
619
- });
620
- indexRunContext = ctx;
445
+ const tWalkEnd = Date.now();
446
+ // Metadata enhancement (the LLM pass that used to run here) is retired
447
+ // (RS-D, 0.9.17-alpha.9) — see CHANGELOG. `timing.llmMs` stays (always
448
+ // ~0 now) so the JSON shape is unchanged.
449
+ const tLlmEnd = Date.now();
450
+ if (complete)
451
+ applyRemovedSources(db, sources, removedSources, isIncremental);
452
+ // Reconcile explicit missing-file cleanup before embeddings, totals, or
453
+ // verification describe this generation. Dry-run intentionally leaves
454
+ // the generation unchanged while still returning the previewed refs.
455
+ let cleanResult;
456
+ const cleanStart = Date.now();
457
+ if (clean) {
621
458
  onProgress({
622
- phase: "summary",
623
- message: buildIndexSummaryMessage({
624
- mode: ctx.isIncremental ? "incremental" : "full",
625
- sourcesCount: allSourceDirs.length,
626
- semanticSearchMode: config.semanticSearchMode,
627
- embeddingProvider: getEmbeddingProvider(config.embedding),
628
- llmEnabled: !!enrichmentExecution.runner,
629
- vecAvailable: isVecAvailable(db),
630
- }),
459
+ phase: "finalize",
460
+ message: dryRun ? "Scanning for stale index entries (dry run)." : "Removing stale index entries.",
631
461
  });
632
- let cleanResult;
633
- let cleanStart = Date.now();
634
- let cleanEnd = cleanStart;
635
- // ── Phase sequence ───────────────────────────────────────────────────────
636
- await runSourceCachePhase(ctx);
637
- await runWalkPhase(ctx);
638
- applyRemovedSources(ctx);
639
- // Reconcile explicit missing-file cleanup before embeddings, totals, or
640
- // verification describe this generation. Dry-run intentionally leaves
641
- // the generation unchanged while still returning the previewed refs.
642
- cleanStart = Date.now();
643
- if (clean) {
644
- onProgress({
645
- phase: "finalize",
646
- message: dryRun ? "Scanning for stale index entries (dry run)." : "Removing stale index entries.",
647
- });
648
- if (ctx.scanComplete) {
649
- cleanResult = runCleanPass(db, dryRun);
650
- }
651
- else {
652
- warn("[index] --clean skipped because one or more configured sources were not scanned completely.");
653
- cleanResult = { checked: 0, removed: 0, removedRefs: [], dryRun };
654
- }
462
+ if (complete) {
463
+ cleanResult = runCleanPass(db, dryRun);
655
464
  }
656
- cleanEnd = Date.now();
657
- await runEmbeddingPhase(ctx);
658
- await runFinalizePhase(ctx);
659
- // ────────────────────────────────────────────────────────────────────────
660
- // runFinalizePhase always populates these before returning.
661
- const verification = ctx.verification;
662
- const totalEntries = ctx.totalEntries;
663
- const { timing } = ctx;
664
- return {
665
- stashDir,
666
- totalEntries,
667
- generatedMetadata: ctx.generatedCount,
668
- indexPath: dbPath,
669
- mode: ctx.isIncremental ? "incremental" : "full",
670
- directoriesScanned: ctx.scannedDirs,
671
- directoriesSkipped: ctx.skippedDirs,
672
- scanComplete: ctx.scanComplete,
673
- ...(ctx.walkWarnings.length > 0 ? { warnings: ctx.walkWarnings } : {}),
674
- ...(ctx.loweringNotices.length > 0 ? { notices: Object.freeze([...ctx.loweringNotices]) } : {}),
675
- ...(Object.keys(persistedAdapters).length > 0
676
- ? { configUpdated: { detectedAdapters: persistedAdapters } }
677
- : {}),
678
- verification,
679
- timing: {
680
- totalMs: Date.now() - timing.t0,
681
- walkMs: timing.tWalkEnd - timing.tWalkStart,
682
- llmMs: timing.tLlmEnd - timing.tWalkEnd,
683
- embedMs: timing.tEmbedEnd - timing.tLlmEnd,
684
- ftsMs: timing.tFtsEnd - timing.tEmbedEnd,
685
- finalizeMs: timing.tFinalizeEnd - timing.tFinalizeStart,
686
- cleanMs: clean ? cleanEnd - cleanStart : 0,
687
- preflightMs: timing.t0 - requestedAt,
688
- sourceCacheMs: sourceCacheEnd - sourceCacheStart,
689
- endToEndMs: Date.now() - requestedAt,
690
- },
691
- ...(cleanResult !== undefined ? { clean: cleanResult } : {}),
692
- };
693
- }
694
- finally {
695
- if (indexRunContext?.enrichmentLease) {
696
- disposeLoweredExecutionDispatchLease(indexRunContext.enrichmentLease);
465
+ else {
466
+ warn("[index] --clean skipped because one or more configured sources were not scanned completely.");
467
+ cleanResult = { checked: 0, removed: 0, removedRefs: [], dryRun };
697
468
  }
698
- if (!borrowedUpdateDb)
699
- closeDatabase(db);
700
469
  }
701
- })();
702
- }
703
- function buildIndexedSourceOwners(sources) {
704
- const installations = deriveInstallations([...sources]);
705
- const owners = new Map();
706
- sources.forEach((source, index) => {
707
- const installation = installations[index];
708
- if (!installation)
709
- return;
710
- const component = installation.components[0];
711
- owners.set(path.resolve(source.path), {
712
- bundleId: installation.id,
713
- componentId: component?.id ?? installation.id,
714
- adapterId: component?.adapter ?? "akm",
470
+ const cleanEnd = Date.now();
471
+ // ── Embeddings ─────────────────────────────────────────────────────────
472
+ throwIfAborted(signal);
473
+ const { verification } = await runEmbeddingPass({
474
+ db,
475
+ config,
476
+ onProgress,
477
+ signal,
478
+ reembed: options.reembed === true,
715
479
  });
716
- });
717
- return owners;
718
- }
719
- /** Read-only mirror of the enrichment cache gate used before entry persistence. */
720
- function dirRecordsNeedMetadataDispatch(db, records, ownersByRoot) {
721
- for (const record of records) {
722
- if (record.skip || record.remove || !record.stash)
723
- continue;
724
- const owner = ownersByRoot.get(path.resolve(record.currentStashDir));
725
- if (!owner)
726
- throw new Error(`Missing bundle provenance for indexed source ${record.currentStashDir}`);
727
- for (const entry of record.stash.entries) {
728
- if (entry.quality !== "generated" || isEnrichmentComplete(entry))
729
- continue;
730
- const entryFile = entry.filename ? path.join(record.dirPath, entry.filename) : undefined;
731
- if (!entryFile)
732
- continue;
733
- const adapterConceptId = record.conceptIdByFile?.get(entryFile);
734
- if (!adapterConceptId)
735
- continue;
736
- let fileContent;
737
- try {
738
- fileContent = fs.readFileSync(entryFile, "utf8");
739
- }
740
- catch {
741
- // The dispatch path uses the same deterministic metadata fallback.
742
- }
743
- const bodyHash = computeBodyHash(fileContent ?? `${entry.name}\n${entry.description ?? ""}`);
744
- const cacheKey = deriveEntryProvenance(owner, entry.type, entry.name, adapterConceptId).itemRef;
745
- const cached = getLlmCacheEntry(db, cacheKey, bodyHash);
746
- if (!cached)
747
- return true;
748
- try {
749
- JSON.parse(cached.resultJson);
750
- }
751
- catch {
752
- return true;
753
- }
754
- }
480
+ const tEmbedEnd = Date.now();
481
+ // ── Finalize ──────────────────────────────────────────────────────────
482
+ const tFinalizeStart = Date.now();
483
+ const { tFtsEnd } = finalizeIndex({
484
+ db,
485
+ sources,
486
+ sourceDirs,
487
+ stashDir,
488
+ scanComplete: complete,
489
+ signal,
490
+ onProgress,
491
+ });
492
+ vacuumIndexDb(db, onProgress);
493
+ const totalEntries = getEntryCount(db);
494
+ const tFinalizeEnd = Date.now();
495
+ return {
496
+ stashDir,
497
+ totalEntries,
498
+ generatedMetadata: generatedCount,
499
+ indexPath: dbPath,
500
+ mode,
501
+ directoriesScanned: scannedDirs,
502
+ directoriesSkipped: skippedDirs,
503
+ scanComplete: complete,
504
+ ...(warnings.length > 0 ? { warnings } : {}),
505
+ ...(Object.keys(persistedAdapters).length > 0 ? { configUpdated: { detectedAdapters: persistedAdapters } } : {}),
506
+ verification,
507
+ timing: {
508
+ totalMs: Date.now() - t0,
509
+ walkMs: tWalkEnd - tWalkStart,
510
+ llmMs: tLlmEnd - tWalkEnd,
511
+ embedMs: tEmbedEnd - tLlmEnd,
512
+ ftsMs: tFtsEnd - tEmbedEnd,
513
+ finalizeMs: tFinalizeEnd - tFinalizeStart,
514
+ cleanMs: clean ? cleanEnd - cleanStart : 0,
515
+ preflightMs: t0 - requestedAt,
516
+ sourceCacheMs: sourceCacheEnd - sourceCacheStart,
517
+ endToEndMs: Date.now() - requestedAt,
518
+ },
519
+ ...(cleanResult !== undefined ? { clean: cleanResult } : {}),
520
+ };
521
+ }
522
+ finally {
523
+ closeDatabase(db);
755
524
  }
756
- return false;
525
+ }
526
+ function buildIndexedSourceOwners(sources) {
527
+ const components = sourceComponents(sources);
528
+ return new Map(sources.map((source, index) => {
529
+ const { id, adapter } = components[index];
530
+ return [path.resolve(source.path), { bundleId: id, componentId: id, adapterId: adapter }];
531
+ }));
757
532
  }
758
533
  function removalsFirst(records) {
759
534
  return [...records.filter((record) => record.remove), ...records.filter((record) => !record.remove)];
@@ -762,32 +537,6 @@ function addEntryIds(target, ids) {
762
537
  for (const id of ids)
763
538
  target.add(id);
764
539
  }
765
- /**
766
- * Map each source root → its durable `BundleComponent` (`deriveInstallations`,
767
- * batch-unique bundle ids, source order preserved). The per-dir document drain
768
- * dispatches `adapterForId(component.adapter).recognize` for this component. The
769
- * component id only surfaces on `IndexDocument.ref`, which the persist layer
770
- * re-derives independently — so a source missing from the map (never happens: the
771
- * map is built from the same sources) is harmless.
772
- */
773
- function buildComponentBySource(sources) {
774
- const map = new Map();
775
- const installations = deriveInstallations(sources);
776
- sources.forEach((source, i) => {
777
- const component = installations[i]?.components[0];
778
- if (component)
779
- map.set(source.path, component);
780
- });
781
- return map;
782
- }
783
- function componentForSource(components, sourcePath) {
784
- return (components.get(sourcePath) ?? {
785
- id: sourcePath,
786
- adapter: "akm",
787
- root: sourcePath,
788
- writable: false,
789
- });
790
- }
791
540
  function groupFileContextsByDir(fileContexts) {
792
541
  const groups = new Map();
793
542
  for (const ctx of fileContexts) {
@@ -876,17 +625,19 @@ function warnIfAdapterSkipsAkmContent(component, files, adapter) {
876
625
  `${skippedDirs.map((dir) => `${dir}/`).join(", ")} — set components.<name>.adapter to "akm" to index them`);
877
626
  }
878
627
  function buildSourceScanPlans(db, allSourceEntries, isIncremental, reconcileMissingDirs) {
879
- const componentBySource = buildComponentBySource(allSourceEntries);
628
+ // Each source root → its durable `BundleComponent`; the per-dir document
629
+ // drain dispatches `adapterForId(component.adapter).recognize` for it.
630
+ const components = sourceComponents(allSourceEntries);
631
+ const componentBySource = new Map(allSourceEntries.map((source, index) => [source.path, components[index]]));
880
632
  const handoffDirs = new Set();
881
633
  const plans = allSourceEntries.map((sourceAdded) => {
882
634
  const currentStashDir = sourceAdded.path;
883
- const component = componentForSource(componentBySource, currentStashDir);
635
+ const component = componentBySource.get(currentStashDir);
884
636
  if (sourceAdded.unresolved) {
885
637
  return {
886
638
  currentStashDir,
887
639
  component,
888
640
  adapter: undefined,
889
- indexVariant: undefined,
890
641
  dirGroups: new Map(),
891
642
  removals: [],
892
643
  walkComplete: false,
@@ -906,7 +657,6 @@ function buildSourceScanPlans(db, allSourceEntries, isIncremental, reconcileMiss
906
657
  currentStashDir,
907
658
  component,
908
659
  adapter,
909
- indexVariant: adapter ? `${adapter.id}@${adapter.version}` : undefined,
910
660
  dirGroups,
911
661
  removals: [],
912
662
  walkComplete: walked.complete,
@@ -931,9 +681,10 @@ function buildSourceScanPlans(db, allSourceEntries, isIncremental, reconcileMiss
931
681
  handoffDirs.add(resolvedDir);
932
682
  };
933
683
  const allComplete = plans.every((plan) => plan.walkComplete && plan.adapter !== undefined);
934
- // A full, globally-complete run uses the atomic table wipe below. Every
935
- // other run reconciles only sources that produced trustworthy snapshots.
936
- if (reconcileMissingDirs && (isIncremental || !allComplete)) {
684
+ // Reconcile departed directories for every source that produced a
685
+ // trustworthy snapshot; a source that was not walked completely keeps its
686
+ // last-known-good rows.
687
+ if (reconcileMissingDirs) {
937
688
  const allIndexedDirsByBundle = !isIncremental ? new Map() : undefined;
938
689
  if (allIndexedDirsByBundle) {
939
690
  for (const entry of getAllEntries(db)) {
@@ -986,9 +737,6 @@ function buildSourceScanPlans(db, allSourceEntries, isIncremental, reconcileMiss
986
737
  * outside `db.transaction()` so the persist pass can be a single synchronous
987
738
  * transaction.
988
739
  */
989
- function reportSourceScanProgress(onProgress, processed, total, message) {
990
- onProgress?.({ phase: "scan", message, processed, total });
991
- }
992
740
  async function scanSourceDirs(db, allSourceEntries, isIncremental, builtAtMs, hadRemovedSources, onProgress, reconcileMissingDirs = true) {
993
741
  let scannedDirs = 0;
994
742
  let skippedDirs = 0;
@@ -999,7 +747,7 @@ async function scanSourceDirs(db, allSourceEntries, isIncremental, builtAtMs, ha
999
747
  const dirRecords = [];
1000
748
  let processedDirs = 0;
1001
749
  let priorDirsChanged = hadRemovedSources;
1002
- const reportScanProgress = (message) => reportSourceScanProgress(onProgress, processedDirs, allSourceEntries.length, message);
750
+ const reportScanProgress = (message) => onProgress?.({ phase: "scan", message, processed: processedDirs, total: allSourceEntries.length });
1003
751
  const reportDirDecision = (kind, dirPath, currentStashDir, reason, persistedRowCount) => {
1004
752
  if (!isVerbose())
1005
753
  return;
@@ -1020,46 +768,6 @@ async function scanSourceDirs(db, allSourceEntries, isIncremental, builtAtMs, ha
1020
768
  seenPaths.add(resolved);
1021
769
  return false;
1022
770
  };
1023
- // Incremental freshness gate shared by both branches: consult the persisted
1024
- // dir state and record either a skip (unchanged + eligible for incremental
1025
- // skip) or a scan record carrying the candidate stash.
1026
- const recordFreshnessDecision = (dirPath, currentStashDir, stateFiles, fingerprint, stash, hashByFile, conceptIdByFile, indexVariant, forceScan, pruneMissing) => {
1027
- const previousState = getDirIndexState(db, dirPath, stateFiles, builtAtMs, indexVariant, fingerprint);
1028
- if (isIncremental && !forceScan && !previousState.stale && canUseIncrementalSkip(previousState, priorDirsChanged)) {
1029
- skippedDirs++;
1030
- dirRecords.push({
1031
- dirPath,
1032
- currentStashDir,
1033
- files: stateFiles,
1034
- fingerprint,
1035
- stash: null,
1036
- skip: true,
1037
- reason: previousState.reason,
1038
- persistedRowCount: previousState.persistedRowCount,
1039
- indexVariant,
1040
- });
1041
- reportDirDecision("skip", dirPath, currentStashDir, previousState.reason, previousState.persistedRowCount);
1042
- return;
1043
- }
1044
- scannedDirs++;
1045
- priorDirsChanged = true;
1046
- const reason = isIncremental ? previousState.reason : { kind: "full-rebuild" };
1047
- dirRecords.push({
1048
- dirPath,
1049
- currentStashDir,
1050
- files: stateFiles,
1051
- fingerprint,
1052
- stash,
1053
- skip: false,
1054
- reason,
1055
- persistedRowCount: previousState.persistedRowCount,
1056
- hashByFile,
1057
- conceptIdByFile,
1058
- indexVariant,
1059
- pruneMissing,
1060
- });
1061
- reportDirDecision("scan", dirPath, currentStashDir, reason, previousState.persistedRowCount);
1062
- };
1063
771
  for (const plan of plans) {
1064
772
  const { currentStashDir, component, adapter, dirGroups, removals, walkComplete } = plan;
1065
773
  processedDirs++;
@@ -1079,7 +787,7 @@ async function scanSourceDirs(db, allSourceEntries, isIncremental, builtAtMs, ha
1079
787
  warn(`Skipping component "${component.id}": unknown adapter id "${component.adapter}".`);
1080
788
  continue;
1081
789
  }
1082
- const indexVariant = plan.indexVariant ?? `${adapter.id}@${adapter.version}`;
790
+ const indexVariant = `${adapter.id}@${adapter.version}`;
1083
791
  for (const removal of removals) {
1084
792
  dirRecords.push(removal);
1085
793
  scannedDirs++;
@@ -1129,17 +837,53 @@ async function scanSourceDirs(db, allSourceEntries, isIncremental, builtAtMs, ha
1129
837
  const drained = drainDirDocuments(adapter, component, ctxs);
1130
838
  if (drained.warnings.length)
1131
839
  warnings.push(...drained.warnings);
1132
- const generated = drained.warnings.length
1133
- ? { entries: drained.entries, warnings: drained.warnings }
1134
- : { entries: drained.entries };
1135
- // `.stash.json` sidecar overrides retired (#39): the cutover's content
1136
- // migration folded sidecar metadata into frontmatter and deleted the
1137
- // files; the runtime no longer reads them.
1138
- const { stash, staleFiles } = buildIndexedDirCandidate(dirPath, indexableFiles, generated);
1139
- if (generated.entries.length > 0) {
1140
- generatedCount += generated.entries.length;
840
+ generatedCount += drained.entries.length;
841
+ const stash = drained.entries.length > 0 ? { entries: drained.entries } : null;
842
+ // Freshness is judged over the files the entries came from (every walked
843
+ // file when none was recognized).
844
+ const entryFiles = new Set(drained.entries.flatMap((entry) => (entry.filename ? [path.join(dirPath, entry.filename)] : [])));
845
+ const staleFiles = entryFiles.size > 0 ? [...entryFiles] : indexableFiles;
846
+ // Incremental freshness gate: consult the persisted dir state and record
847
+ // either a skip (unchanged + eligible) or a scan carrying the drained stash.
848
+ const previousState = getDirIndexState(db, dirPath, staleFiles, builtAtMs, indexVariant, fingerprint);
849
+ const { persistedRowCount } = previousState;
850
+ if (isIncremental &&
851
+ !forceScan &&
852
+ !previousState.stale &&
853
+ canUseIncrementalSkip(previousState, priorDirsChanged)) {
854
+ skippedDirs++;
855
+ const reason = previousState.reason;
856
+ dirRecords.push({
857
+ dirPath,
858
+ currentStashDir,
859
+ files: staleFiles,
860
+ fingerprint,
861
+ stash: null,
862
+ skip: true,
863
+ reason,
864
+ persistedRowCount,
865
+ indexVariant,
866
+ });
867
+ reportDirDecision("skip", dirPath, currentStashDir, reason, persistedRowCount);
868
+ continue;
1141
869
  }
1142
- recordFreshnessDecision(dirPath, currentStashDir, staleFiles, fingerprint, stash, drained.hashByFile, drained.conceptIdByFile, indexVariant, forceScan, walkComplete);
870
+ scannedDirs++;
871
+ priorDirsChanged = true;
872
+ const reason = isIncremental ? previousState.reason : { kind: "full-rebuild" };
873
+ dirRecords.push({
874
+ dirPath,
875
+ currentStashDir,
876
+ files: staleFiles,
877
+ fingerprint,
878
+ stash,
879
+ skip: false,
880
+ reason,
881
+ persistedRowCount,
882
+ hashByFile: drained.hashByFile,
883
+ conceptIdByFile: drained.conceptIdByFile,
884
+ indexVariant,
885
+ });
886
+ reportDirDecision("scan", dirPath, currentStashDir, reason, persistedRowCount);
1143
887
  }
1144
888
  }
1145
889
  return {
@@ -1161,90 +905,17 @@ function requiresWorkflowSourcePreflight(ctxs) {
1161
905
  }
1162
906
  });
1163
907
  }
1164
- function preserveExistingIndex(doFullDelete, dirRecords, sourceRoots) {
1165
- if (!doFullDelete)
1166
- return false;
1167
- const incomingDocCount = dirRecords.reduce((n, record) => n + (record.skip ? 0 : (record.stash?.entries.length ?? 0)), 0);
1168
- if (incomingDocCount > 0 || allSourceRootsReadable(sourceRoots))
1169
- return false;
1170
- warn("[index] --full produced zero documents while one or more source roots are missing or unreadable — " +
1171
- "preserving the existing index (last-known-good) rather than wiping it. Re-run once the sources are available.");
1172
- return true;
1173
- }
1174
- /**
1175
- * #624-P1 zero-document preflight probe. A source root counts as "readable"
1176
- * when it exists on disk as a directory whose listing can be read. A root that
1177
- * is missing or unreadable (a transient mount failure, a permission race, or a
1178
- * source that vanished mid-run) makes a zero-document scan untrustworthy: the
1179
- * walk saw nothing not because the stash is empty but because it could not be
1180
- * read. Returns true only when EVERY root is readable, so a single unreadable
1181
- * root blocks the full-rebuild wipe.
1182
- */
1183
- function allSourceRootsReadable(roots) {
1184
- for (const root of roots) {
1185
- try {
1186
- const st = fs.statSync(root);
1187
- if (!st.isDirectory())
1188
- return false;
1189
- fs.readdirSync(root); // probe readability, not just existence
1190
- }
1191
- catch {
1192
- return false;
1193
- }
1194
- }
1195
- return true;
1196
- }
1197
- /**
1198
- * Phase 2 (sync): write all pre-generated scan records inside a single
1199
- * transaction, returning the directories that still need LLM enrichment.
1200
- */
1201
- function persistDirRecords(db, dirRecords, doFullDelete, warnings, sourceRoots, scanComplete, bundleByRoot) {
1202
- const dirsNeedingLlm = [];
1203
- const fullDelete = doFullDelete && scanComplete;
1204
- // #624-P1 zero-document preflight (spec §4). A full-rebuild wipe is a
1205
- // legitimate mass-delete ONLY when the scan legitimately found nothing. If
1206
- // the walk produced zero documents AND any configured source root is missing
1207
- // or unreadable, the empty result is almost certainly a transient scan
1208
- // failure, not an emptied stash — wiping here would cascade-destroy the
1209
- // last-known-good index (entries + embeddings + utility/usage). Preserve it
1210
- // and warn instead; the next successful run reconciles. A genuinely empty
1211
- // stash whose roots ARE readable still wipes, as before.
1212
- if (preserveExistingIndex(fullDelete, dirRecords, sourceRoots))
1213
- return { dirsNeedingLlm };
908
+ /** Phase 2 (sync): write all pre-generated scan records inside a single transaction. */
909
+ function persistDirRecords(db, dirRecords, warnings, bundleByRoot) {
1214
910
  // Per-source dedup: the same logical asset can appear more than once within
1215
911
  // one owning source, where source order still makes the first occurrence win.
1216
912
  // The owner is part of the key so identical concepts in different bundles
1217
913
  // remain distinct indexed rows.
1218
914
  const indexedAssetIdentities = new Set();
1219
915
  const deletedUsageEntryIds = new Set();
916
+ const findPersisted = db.prepare("SELECT id, content_hash, file_path, adapter_id FROM entries WHERE item_ref = ?");
1220
917
  const insertTransaction = db.transaction(() => {
1221
- // Perform the full-rebuild wipe as the FIRST step of the insert
1222
- // transaction so delete and re-insert are atomic — a concurrent reader
1223
- // never observes an empty database between the two operations.
1224
- if (fullDelete) {
1225
- // #955: copy every (search_text hash, embedding) pair about to be
1226
- // discarded wholesale into `embedding_salvage`, tagged with the
1227
- // fingerprint the discarded vectors were generated under, BEFORE the
1228
- // wipe below — inside the SAME transaction so the copy and the
1229
- // discard commit or roll back together. The embedding phase later in
1230
- // this run hands salvaged vectors back to unchanged content instead
1231
- // of re-embedding the whole corpus.
1232
- salvageEmbeddingsBeforeDiscard(db);
1233
- // Entries and every child materialization share one deletion authority.
1234
- // Usage events live in state.db and survive so finalize can relink them
1235
- // to the replacement generation's row ids.
1236
- deleteAllEntries(db, { cleanupUsageEvents: false });
1237
- db.exec("DELETE FROM index_dir_state");
1238
- // Chunk-8 WI-8.3: usage_events lives in state.db now (not index.db), so the
1239
- // wipe no longer detaches it here. The finalize pass's relinkUsageEvents
1240
- // (cross-DB) nulls entry_ids that no longer resolve to a rebuilt entry and
1241
- // re-resolves the rest by entry_ref — subsuming the old detach.
1242
- // Atomicity observation point: inside the transaction the tables are now
1243
- // empty, but no other connection may observe that. See
1244
- // tests/integration/indexer/reindex-generation-atomicity.test.ts.
1245
- indexTransactionHook("full-delete-applied");
1246
- }
1247
- for (const { dirPath, currentStashDir, files, fingerprint, stash, skip, reason, persistedRowCount, hashByFile, conceptIdByFile, indexVariant, remove, pruneMissing, } of dirRecords) {
918
+ for (const { dirPath, currentStashDir, files, fingerprint, stash, skip, reason, persistedRowCount, hashByFile, conceptIdByFile, indexVariant, remove, } of dirRecords) {
1248
919
  const bundle = bundleByRoot.get(path.resolve(currentStashDir));
1249
920
  if (!bundle)
1250
921
  throw new Error(`Missing bundle provenance for indexed source ${currentStashDir}`);
@@ -1261,7 +932,13 @@ function persistDirRecords(db, dirRecords, doFullDelete, warnings, sourceRoots,
1261
932
  // row_count and the gate skips this directory before draining next
1262
933
  // time. "unchanged-precheck" already matched the stored row.
1263
934
  if (reason?.kind === "unchanged" && fingerprint) {
1264
- upsertIndexDirState(db, { dirPath, ...fingerprint, reason: reason.kind, rowCount: persistedRowCount });
935
+ upsertIndexDirState(db, {
936
+ dirPath,
937
+ ...fingerprint,
938
+ reason: reason.kind,
939
+ rowCount: persistedRowCount,
940
+ indexVariant,
941
+ });
1265
942
  }
1266
943
  continue;
1267
944
  }
@@ -1271,6 +948,16 @@ function persistDirRecords(db, dirRecords, doFullDelete, warnings, sourceRoots,
1271
948
  // durable `item_ref`, then prune only the departed rows below. Replaces the old
1272
949
  // `deleteEntriesByDir` truncate-and-reinsert (which discarded ids).
1273
950
  const keptItemRefs = new Set();
951
+ // Per-file cursor: a directory is drained whole (one changed file
952
+ // re-reads its siblings), but on an incremental run a sibling whose
953
+ // content hash, path and adapter are unchanged since the last drain
954
+ // under the same adapter variant is already persisted exactly as this
955
+ // drain would persist it — including any metadata layered onto its row
956
+ // — so it is neither rewritten nor re-persisted. `--full` re-persists
957
+ // every entry.
958
+ const sameVariant = reason?.kind !== "full-rebuild" &&
959
+ indexVariant !== undefined &&
960
+ getIndexDirState(db, dirPath)?.indexVariant === indexVariant;
1274
961
  let persistedRows = 0;
1275
962
  let dedupedRows = 0;
1276
963
  if (stash) {
@@ -1294,29 +981,32 @@ function persistDirRecords(db, dirRecords, doFullDelete, warnings, sourceRoots,
1294
981
  continue;
1295
982
  }
1296
983
  indexedAssetIdentities.add(identityKey);
1297
- const searchText = buildSearchText(entry);
1298
- const entryWithSize = attachFileSize(entry, entryPath);
1299
984
  // content_hash = doc.hash from the drain, keyed by the recognized
1300
985
  // file's path. A missing hash preserves the existing value on upsert.
1301
986
  const contentHash = hashByFile?.get(entryPath);
1302
987
  const provenance = deriveEntryProvenance(bundle, entry.type, entry.name, adapterConceptId);
1303
988
  keptItemRefs.add(provenance.itemRef);
1304
- upsertEntry(db, entryPath, entryWithSize, searchText, provenance, contentHash);
1305
989
  persistedRows++;
1306
- }
1307
- // Collect dirs needing LLM enhancement during the first walk.
1308
- // Only dirs with "generated" entries need enrichment.
1309
- if (stash.entries.some((e) => e.quality === "generated")) {
1310
- dirsNeedingLlm.push({ dirPath, files, currentStashDir, stash });
990
+ const previous = sameVariant
991
+ ? (findPersisted.get(provenance.itemRef) ?? undefined)
992
+ : undefined;
993
+ const unchanged = previous !== undefined &&
994
+ contentHash !== undefined &&
995
+ previous.content_hash === contentHash &&
996
+ previous.file_path === entryPath &&
997
+ previous.adapter_id === bundle.adapterId;
998
+ if (unchanged) {
999
+ continue;
1000
+ }
1001
+ upsertEntry(db, entryPath, withFileSize(entry, entryPath), provenance, contentHash);
1311
1002
  }
1312
1003
  }
1313
1004
  // Prune the departed rows: everything under this dir NOT re-upserted above
1314
1005
  // (files deleted, deduped away, or abstained on by the adapter). With
1315
1006
  // an empty kept-set this deletes every row for the dir — the exact net
1316
1007
  // effect of the old unconditional `deleteEntriesByDir`, minus the id churn.
1317
- if (pruneMissing !== false) {
1318
- addEntryIds(deletedUsageEntryIds, deleteEntriesByDirExceptRefs(db, dirPath, bundle.bundleId, keptItemRefs, { cleanupUsageEvents: false }));
1319
- }
1008
+ // (A source whose walk was incomplete never reaches this point.)
1009
+ addEntryIds(deletedUsageEntryIds, deleteEntriesByDirExceptRefs(db, dirPath, bundle.bundleId, keptItemRefs, { cleanupUsageEvents: false }));
1320
1010
  const persistedFingerprint = fingerprint ?? computeDirFingerprint(dirPath, files, indexVariant);
1321
1011
  const persistedReason = persistedRows === 0
1322
1012
  ? inferZeroRowReason(stash, reason, warnings, dirPath, dedupedRows)
@@ -1332,6 +1022,7 @@ function persistDirRecords(db, dirRecords, doFullDelete, warnings, sourceRoots,
1332
1022
  // must keep draining every run (as it did before the gate) until a
1333
1023
  // drain persists it without dedup. NULL keeps the gate closed.
1334
1024
  rowCount: dedupedRows === 0 ? persistedRows : undefined,
1025
+ indexVariant,
1335
1026
  });
1336
1027
  if (persistedRows === 0) {
1337
1028
  // Warn only when the dir had files that *could* produce entries (.md or
@@ -1352,457 +1043,48 @@ function persistDirRecords(db, dirRecords, doFullDelete, warnings, sourceRoots,
1352
1043
  }
1353
1044
  // Atomicity observation point: the new generation is fully written but
1354
1045
  // uncommitted, so it must still be invisible to other connections.
1355
- indexTransactionHook("records-persisted");
1046
+ indexTransactionHookForTests?.("records-persisted");
1356
1047
  });
1357
1048
  insertTransaction();
1358
1049
  deleteUsageEventsByEntryIds([...deletedUsageEntryIds]);
1359
- return { dirsNeedingLlm };
1360
- }
1361
- async function indexEntries(db, allSourceEntries, isIncremental, builtAtMs, hadRemovedSources, doFullDelete = false, onProgress, reconcileMissingDirs = true, beforePersist) {
1362
- // Phase 1 (async): walk directories and pre-generate all metadata outside the
1363
- // transaction.
1364
- const { dirRecords, scannedDirs, skippedDirs, generatedCount, warnings, complete } = await scanSourceDirs(db, allSourceEntries, isIncremental, builtAtMs, hadRemovedSources, onProgress, reconcileMissingDirs);
1365
- const bundleByRoot = buildIndexedSourceOwners(allSourceEntries);
1366
- await beforePersist?.(dirRecords, bundleByRoot);
1367
- // Phase 2 (sync): write all pre-generated metadata inside a single transaction.
1368
- // Source roots feed the #624-P1 zero-document preflight (a full-rebuild wipe
1369
- // is suppressed when the scan is empty because roots are unreadable).
1370
- const sourceRoots = allSourceEntries.map((s) => s.path);
1371
- // Map each source root → its durable bundle id so the writer can persist
1372
- // `item_ref = <bundle>//<conceptId>` and canonical component/adapter
1373
- // provenance. `deriveInstallations`
1374
- // preserves source order, so a positional zip yields the SAME bundle id the
1375
- // dispatched `adapter.recognize` emits as `IndexDocument.ref` for that root.
1376
- const { dirsNeedingLlm } = persistDirRecords(db, dirRecords, doFullDelete, warnings, sourceRoots, complete, bundleByRoot);
1377
- return { scannedDirs, skippedDirs, generatedCount, warnings, dirsNeedingLlm, complete };
1378
- }
1379
- function indexedProvenanceForFile(db, filePath) {
1380
- const row = db
1381
- .prepare("SELECT item_ref AS itemRef, bundle_id AS bundleId, component_id AS componentId, " +
1382
- "concept_id AS conceptId, adapter_id AS adapterId FROM entries WHERE file_path = ? LIMIT 1")
1383
- .get(filePath);
1384
- if (!row?.itemRef || !row.bundleId || !row.componentId || !row.conceptId || !row.adapterId) {
1385
- throw new Error(`Missing indexed provenance for ${filePath}`);
1386
- }
1387
- return {
1388
- itemRef: row.itemRef,
1389
- bundleId: row.bundleId,
1390
- componentId: row.componentId,
1391
- conceptId: row.conceptId,
1392
- adapterId: row.adapterId,
1393
- };
1394
- }
1395
- async function enhanceDirsWithLlm(db, config, execution, dirsNeedingLlm, onProgress, signal, onNotices, lease) {
1396
- // The invocation owns one frozen symbolic selection. Summary reporting and
1397
- // every enrichment dispatch consume this same snapshot.
1398
- const llmRunner = execution.runner;
1399
- if (!llmRunner || dirsNeedingLlm.length === 0)
1400
- return;
1401
- // Aggregate per-entry failures so a misconfigured LLM endpoint surfaces
1402
- // as a single visible warning instead of silently degrading every entry
1403
- // and leaving the user wondering why nothing got enhanced.
1404
- const summary = { attempted: 0, succeeded: 0, skipped: 0, failureSamples: [] };
1405
- let completedDirs = 0;
1406
- let completedEntries = 0;
1407
- const totalDirs = dirsNeedingLlm.length;
1408
- const totalEntries = dirsNeedingLlm.reduce((sum, { stash }) => {
1409
- const entriesToEnhance = stash.entries.filter((e) => {
1410
- if (e.quality !== "generated")
1411
- return false;
1412
- if (isEnrichmentComplete(e))
1413
- return false;
1414
- return true;
1415
- });
1416
- return sum + entriesToEnhance.length;
1417
- }, 0);
1418
- // P3 — wall-clock budget for the enrichment pass. Defaults to the resolved
1419
- // engine's timeoutMs (or 10 minutes if not set). Users can extend it via
1420
- // `index.enrichment.timeoutMs` (or `index.defaults.timeoutMs`, or the
1421
- // engine's own `engines.<name>.timeoutMs`) — no separate knob needed.
1422
- const enrichDeadline = createEnrichmentDeadline(llmRunner.timeoutMs, totalEntries);
1423
- let deadlineHit = false;
1424
- const enrichSignal = (() => {
1425
- if (!enrichDeadline)
1426
- return signal ?? new AbortController().signal;
1427
- if (!signal)
1428
- return enrichDeadline;
1429
- // Combine: abort when either fires.
1430
- const controller = new AbortController();
1431
- const onAbort = () => controller.abort();
1432
- signal.addEventListener("abort", onAbort, { once: true });
1433
- enrichDeadline.addEventListener("abort", () => {
1434
- deadlineHit = true;
1435
- controller.abort();
1436
- }, { once: true });
1437
- return controller.signal;
1438
- })();
1439
- if (totalEntries > 0) {
1440
- onProgress?.({
1441
- phase: "llm",
1442
- message: `LLM enhancement starting for ${totalEntries} entr${totalEntries === 1 ? "y" : "ies"} ` +
1443
- `across ${totalDirs} director${totalDirs === 1 ? "y" : "ies"} (concurrency ${getDefaultLlmConcurrency(llmRunner.connection)}).`,
1444
- processed: 0,
1445
- total: totalEntries,
1446
- });
1447
- }
1448
- let currentDirLabel;
1449
- let configFailure;
1450
- let lastProgressAt = Date.now();
1451
- let heartbeatTimer;
1452
- if (totalEntries > 0 && onProgress) {
1453
- heartbeatTimer = setInterval(() => {
1454
- if (Date.now() - lastProgressAt < 15000)
1455
- return;
1456
- onProgress({
1457
- phase: "llm",
1458
- message: `Still enriching ${completedEntries}/${totalEntries} entr${totalEntries === 1 ? "y" : "ies"}` +
1459
- (currentDirLabel ? `; waiting on ${currentDirLabel}` : "") +
1460
- ".",
1461
- processed: completedEntries,
1462
- total: totalEntries,
1463
- });
1464
- lastProgressAt = Date.now();
1465
- }, 15000);
1466
- }
1467
- try {
1468
- await concurrentMap(dirsNeedingLlm, async ({ dirPath, files, currentStashDir, stash: originalStash }) => {
1469
- if (enrichSignal.aborted)
1470
- return undefined;
1471
- // Only enhance generated entries; user-provided overrides should not
1472
- // be overwritten. Skip entries that are already fully enriched
1473
- // (description + tags + searchHints).
1474
- const entriesToEnhance = originalStash.entries.filter((e) => {
1475
- if (e.quality !== "generated")
1476
- return false;
1477
- if (isEnrichmentComplete(e)) {
1478
- warnVerbose(`[akm] skipping LLM enrichment for "${e.name}" — entry already complete`);
1479
- return false;
1480
- }
1481
- return true;
1482
- });
1483
- if (entriesToEnhance.length === 0)
1484
- return undefined;
1485
- currentDirLabel = path.relative(currentStashDir, dirPath) || ".";
1486
- onProgress?.({
1487
- phase: "llm",
1488
- message: `Enhancing ${currentDirLabel} ` +
1489
- `(${entriesToEnhance.length} entr${entriesToEnhance.length === 1 ? "y" : "ies"}).`,
1490
- processed: completedEntries,
1491
- total: totalEntries,
1492
- });
1493
- lastProgressAt = Date.now();
1494
- const targetStash = { entries: entriesToEnhance };
1495
- const itemRefs = entriesToEnhance.map((entry) => {
1496
- const entryPath = entry.filename ? path.join(dirPath, entry.filename) : files[0] || dirPath;
1497
- return indexedProvenanceForFile(db, entryPath).itemRef;
1498
- });
1499
- let enhanced;
1500
- try {
1501
- enhanced = await enhanceStashWithLlm(llmRunner, targetStash, files, summary, enrichSignal, db, itemRefs, config, (event) => {
1502
- completedEntries++;
1503
- lastProgressAt = Date.now();
1504
- onProgress?.({
1505
- phase: "llm",
1506
- message: `Enhanced ${completedEntries}/${totalEntries} entr${totalEntries === 1 ? "y" : "ies"}; ` +
1507
- `${completedDirs}/${totalDirs} director${totalDirs === 1 ? "y" : "ies"} complete` +
1508
- (event.entryName ? `; current ${event.entryName}` : "") +
1509
- (currentDirLabel ? ` in ${currentDirLabel}` : "") +
1510
- (event.outcome === "cache-hit" ? " (cache hit)" : ""),
1511
- processed: completedEntries,
1512
- total: totalEntries,
1513
- });
1514
- }, onNotices, lease);
1515
- }
1516
- catch (err) {
1517
- if (err instanceof ConfigError) {
1518
- configFailure ??= err;
1519
- return undefined;
1520
- }
1521
- throw err;
1522
- }
1523
- // Re-upsert the enhanced entries in a single transaction so a crash
1524
- // cannot leave half the entries updated and the rest stale.
1525
- db.transaction(() => {
1526
- for (const entry of enhanced.entries) {
1527
- const entryPath = entry.filename ? path.join(dirPath, entry.filename) : files[0] || dirPath;
1528
- const searchText = buildSearchText(entry);
1529
- const provenance = indexedProvenanceForFile(db, entryPath);
1530
- upsertEntry(db, entryPath, attachFileSize(entry, entryPath), searchText, provenance);
1531
- }
1532
- })();
1533
- completedDirs++;
1534
- lastProgressAt = Date.now();
1535
- onProgress?.({
1536
- phase: "llm",
1537
- message: `Completed ${completedDirs}/${totalDirs} director${totalDirs === 1 ? "y" : "ies"}; ` +
1538
- `${completedEntries}/${totalEntries} entr${totalEntries === 1 ? "y" : "ies"} processed.`,
1539
- processed: completedEntries,
1540
- total: totalEntries,
1541
- });
1542
- return undefined;
1543
- },
1544
- // Defaults: 2 for remote LLM APIs, 1 for local model servers (LM
1545
- // Studio, Ollama run one inference at a time — parallel requests cause
1546
- // "Model reloaded" / 500 errors). No config override reaches this path:
1547
- // `resolveLlmEngineUse` does not forward `engines.<name>.concurrency`.
1548
- getDefaultLlmConcurrency(llmRunner.connection));
1549
- if (configFailure)
1550
- throw configFailure;
1551
- }
1552
- finally {
1553
- if (heartbeatTimer)
1554
- clearInterval(heartbeatTimer);
1555
- }
1556
- if (deadlineHit) {
1557
- warn("[akm] LLM enrichment budget exceeded. Re-run `akm index` to continue. Increase index.enrichment.timeoutMs for a larger budget.");
1558
- }
1559
- // Gate-closed (`skipped`) entries are not failures — exclude them so a
1560
- // deliberately disabled feature never surfaces as an enrichment error.
1561
- const failed = summary.attempted - summary.succeeded - summary.skipped;
1562
- if (failed > 0 && summary.succeeded === 0) {
1563
- const sample = summary.failureSamples.length ? ` Example: ${summary.failureSamples[0]}` : "";
1564
- warn(`LLM enhancement failed for all ${failed} attempted entries — index built without LLM enrichment.` +
1565
- ` Check llm.endpoint and llm.model in your config.${sample}`);
1566
- }
1567
- else if (failed > 0) {
1568
- const sample = summary.failureSamples.length ? ` Examples: ${summary.failureSamples.join("; ")}` : "";
1569
- warn(`LLM enhancement failed for ${failed}/${summary.attempted} entries — they were left un-enhanced.${sample}`);
1570
- }
1571
- }
1572
- export function createEnrichmentDeadline(timeoutMs, totalEntries) {
1573
- const perEntryTimeoutMs = timeoutMs === undefined ? 10 * 60 * 1000 : timeoutMs;
1574
- return perEntryTimeoutMs === null ? undefined : AbortSignal.timeout(perEntryTimeoutMs * Math.max(totalEntries, 1));
1575
1050
  }
1576
1051
  // ── Helpers ─────────────────────────────────────────────────────────────────
1577
- function attachFileSize(entry, entryPath) {
1578
- try {
1579
- const sized = { ...entry, fileSize: fs.statSync(entryPath).size };
1580
- if (hasMarkdownFragmentContent(entry))
1581
- setMarkdownFragmentContent(sized, getMarkdownFragmentContent(entry));
1582
- return sized;
1583
- }
1584
- catch {
1585
- return entry;
1586
- }
1587
- }
1588
1052
  function buildIndexSummaryMessage(options) {
1589
1053
  const stashSourceLabel = options.sourcesCount === 1 ? "stash source" : "stash sources";
1590
- const semanticDetail = getSemanticSearchLabel(options.semanticSearchMode, options.embeddingProvider, options.vecAvailable);
1591
- return `Starting ${options.mode} index (${options.sourcesCount} ${stashSourceLabel}, semantic search: ${semanticDetail}, LLM: ${options.llmEnabled ? "enabled" : "disabled"}).`;
1054
+ const semanticDetail = options.semanticSearchMode === "off" ? "disabled" : `${options.embeddingProvider} embeddings`;
1055
+ return `Starting ${options.mode} index (${options.sourcesCount} ${stashSourceLabel}, semantic search: ${semanticDetail}).`;
1592
1056
  }
1593
1057
  function getEmbeddingProvider(embedding) {
1594
1058
  return isHttpUrl(embedding?.endpoint) ? "remote" : "local";
1595
1059
  }
1596
- function getSemanticSearchLabel(semanticSearchMode, embeddingProvider, vecAvailable) {
1597
- if (semanticSearchMode === "off")
1598
- return "disabled";
1599
- return `${embeddingProvider} embeddings, ${vecAvailable ? "sqlite-vec" : "JS fallback"}`;
1600
- }
1601
1060
  function verifyIndexState(db, config, embeddableEntries, embeddingResult) {
1602
1061
  const embeddingCount = getEmbeddingCount(db);
1603
- const vecAvailable = isVecAvailable(db);
1604
1062
  const embeddingProvider = getEmbeddingProvider(config.embedding);
1063
+ const verification = (ok, message, semanticSearchEnabled, semanticStatus, guidance) => ({
1064
+ ok,
1065
+ message,
1066
+ ...(guidance !== undefined ? { guidance } : {}),
1067
+ semanticSearchEnabled,
1068
+ semanticSearchMode: config.semanticSearchMode,
1069
+ semanticStatus,
1070
+ embeddingProvider,
1071
+ entryCount: embeddableEntries,
1072
+ embeddingCount,
1073
+ });
1074
+ const pendingStatus = config.semanticSearchMode === "off" ? "disabled" : "pending";
1605
1075
  if (embeddableEntries === 0) {
1606
- return {
1607
- ok: true,
1608
- message: "Index ready. No assets were found yet.",
1609
- semanticSearchEnabled: config.semanticSearchMode === "auto",
1610
- semanticSearchMode: config.semanticSearchMode,
1611
- semanticStatus: config.semanticSearchMode === "off" ? "disabled" : "pending",
1612
- embeddingProvider,
1613
- entryCount: embeddableEntries,
1614
- embeddingCount,
1615
- vecAvailable,
1616
- };
1076
+ return verification(true, "Index ready. No assets were found yet.", config.semanticSearchMode === "auto", pendingStatus);
1617
1077
  }
1618
1078
  if (config.semanticSearchMode === "off") {
1619
- return {
1620
- ok: true,
1621
- message: "Keyword index ready. Semantic search is disabled.",
1622
- semanticSearchEnabled: false,
1623
- semanticSearchMode: config.semanticSearchMode,
1624
- semanticStatus: "disabled",
1625
- embeddingProvider,
1626
- entryCount: embeddableEntries,
1627
- embeddingCount,
1628
- vecAvailable,
1629
- };
1079
+ return verification(true, "Keyword index ready. Semantic search is disabled.", false, "disabled");
1630
1080
  }
1631
1081
  if (embeddingCount >= embeddableEntries) {
1632
- // "ready-vec" must reflect the path search will ACTUALLY take: the vec
1633
- // extension being loaded is not enough when the embedding phase recorded
1634
- // fast-path insert failures (searchVec then routes to the JS-cosine
1635
- // fallback via isVecFastPathReady). Reporting vec health from
1636
- // isVecAvailable alone overstated `akm info` after partial vec failures
1637
- // (§24.2 "Semantic" gate — truthful ready-vec).
1638
- const vecActive = vecAvailable && isVecFastPathReady(db);
1639
- return {
1640
- ok: true,
1641
- message: `Semantic search ready (${embeddingCount}/${embeddableEntries} embeddings, ${vecActive
1642
- ? "sqlite-vec active"
1643
- : vecAvailable
1644
- ? "JS fallback active — vec fast path degraded; run 'akm index' to repair valid stored vectors or 'akm index --reembed' to regenerate incompatible ones"
1645
- : "JS fallback active"}).`,
1646
- semanticSearchEnabled: true,
1647
- semanticSearchMode: config.semanticSearchMode,
1648
- semanticStatus: vecActive ? "ready-vec" : "ready-js",
1649
- embeddingProvider,
1650
- entryCount: embeddableEntries,
1651
- embeddingCount,
1652
- vecAvailable,
1653
- };
1654
- }
1655
- return {
1656
- ok: false,
1657
- message: embeddingResult.message ??
1658
- `Semantic search verification failed (${embeddingCount}/${embeddableEntries} embeddings available).`,
1659
- guidance: embeddingProvider === "remote"
1660
- ? "Check your embedding endpoint and credentials, then retry `akm index --full --verbose`."
1661
- : "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.",
1662
- semanticSearchEnabled: true,
1663
- semanticSearchMode: config.semanticSearchMode,
1664
- semanticStatus: "blocked",
1665
- embeddingProvider,
1666
- entryCount: embeddableEntries,
1667
- embeddingCount,
1668
- vecAvailable,
1669
- };
1670
- }
1671
- function buildIndexedDirCandidate(dirPath, indexableFiles, generated) {
1672
- const stash = generated.entries.length > 0 ? { entries: generated.entries } : null;
1673
- const staleFiles = stash ? resolveIndexedFiles(dirPath, indexableFiles, stash) : indexableFiles;
1674
- return { stash, staleFiles };
1675
- }
1676
- function resolveIndexedFiles(dirPath, files, stash) {
1677
- const resolved = new Set();
1678
- for (const entry of stash.entries) {
1679
- if (entry.filename)
1680
- resolved.add(path.join(dirPath, entry.filename));
1082
+ return verification(true, `Semantic search ready (${embeddingCount}/${embeddableEntries} embeddings).`, true, "ready-js");
1681
1083
  }
1682
- return resolved.size > 0 ? [...resolved] : files;
1683
- }
1684
- async function enhanceStashWithLlm(llmRunner, stash, files, summary, signal, db, itemRefs, akmConfig, onEntryDone, onNotices, lease) {
1685
- const { enhanceMetadata } = await import("../llm/metadata-enhance.js");
1686
- const { computeBodyHash, getLlmCacheEntry, upsertLlmCacheEntry } = await import("../storage/repositories/index-llm-cache-repository.js");
1687
- let configFailure;
1688
- const results = await concurrentMap(stash.entries, async (entry, idx) => {
1689
- if (signal?.aborted)
1690
- return entry;
1691
- summary.attempted++;
1692
- try {
1693
- const entryFile = entry.filename
1694
- ? (files.find((f) => path.basename(f) === entry.filename) ?? files[0])
1695
- : files[0];
1696
- let fileContent;
1697
- if (entryFile) {
1698
- try {
1699
- fileContent = fs.readFileSync(entryFile, "utf8");
1700
- }
1701
- catch {
1702
- warn(`Could not read file for LLM enrichment: ${entry.filename ?? entry.name}`);
1703
- }
1704
- }
1705
- // Incremental cache: skip LLM call when file body is unchanged. The
1706
- // Cache metadata enrichment by the canonical durable item ref.
1707
- const cacheBody = fileContent ?? `${entry.name}\n${entry.description ?? ""}`;
1708
- const bodyHash = computeBodyHash(cacheBody);
1709
- const cacheKey = itemRefs?.[idx];
1710
- if (!cacheKey)
1711
- throw new Error(`Missing canonical item ref for enrichment entry ${entry.name}.`);
1712
- if (db) {
1713
- const cached = getLlmCacheEntry(db, cacheKey, bodyHash);
1714
- if (cached) {
1715
- try {
1716
- const parsed = JSON.parse(cached.resultJson);
1717
- const updated = { ...entry };
1718
- if (parsed.description)
1719
- updated.description = parsed.description;
1720
- if (parsed.searchHints?.length)
1721
- updated.searchHints = parsed.searchHints;
1722
- if (parsed.tags?.length)
1723
- updated.tags = parsed.tags;
1724
- updated.quality = "enriched";
1725
- summary.succeeded++;
1726
- onEntryDone?.({ entryName: entry.name, outcome: "cache-hit" });
1727
- return updated;
1728
- }
1729
- catch {
1730
- warn(`LLM enrichment cache entry corrupt for ${entry.name}; re-running enrichment`);
1731
- }
1732
- }
1733
- }
1734
- const outcome = await enhanceMetadata(llmRunner, entry, fileContent, signal, akmConfig, onNotices, lease);
1735
- if (outcome.status !== "enriched") {
1736
- // Not a genuine LLM success: the gate was closed (`skipped`) or the
1737
- // call errored/timed out (`failed`). Do NOT mark the entry enriched
1738
- // and do NOT write the LLM cache — caching here would poison the
1739
- // entry into a permanent enrichment skip even though nothing was
1740
- // enhanced. Surface failures honestly; stay silent on gated-off skips.
1741
- if (outcome.status === "failed") {
1742
- const msg = outcome.error ?? "metadata enrichment failed";
1743
- if (summary.failureSamples.length < 3 && !summary.failureSamples.includes(msg)) {
1744
- summary.failureSamples.push(msg);
1745
- }
1746
- onEntryDone?.({ entryName: entry.name, outcome: "failed" });
1747
- }
1748
- else {
1749
- summary.skipped++;
1750
- onEntryDone?.({ entryName: entry.name, outcome: "skipped" });
1751
- }
1752
- return entry;
1753
- }
1754
- const improvements = outcome.metadata;
1755
- const updated = { ...entry };
1756
- if (improvements.description)
1757
- updated.description = improvements.description;
1758
- if (improvements.searchHints?.length)
1759
- updated.searchHints = improvements.searchHints;
1760
- if (improvements.tags?.length)
1761
- updated.tags = improvements.tags;
1762
- // Mark as enriched so subsequent index runs skip re-enrichment (P2).
1763
- // An empty-but-successful response is still cached: the LLM was paid
1764
- // for this body_hash and produced no improvements, so re-running would
1765
- // only re-pay for the same no-op. (The cache protects against re-paying
1766
- // for the LLM call when the file body is unchanged.)
1767
- updated.quality = "enriched";
1768
- // Persist to cache so the next run can skip the LLM call when the
1769
- // file body has not changed.
1770
- if (db) {
1771
- upsertLlmCacheEntry(db, cacheKey, bodyHash, JSON.stringify({
1772
- description: improvements.description,
1773
- searchHints: improvements.searchHints,
1774
- tags: improvements.tags,
1775
- }));
1776
- }
1777
- summary.succeeded++;
1778
- onEntryDone?.({ entryName: entry.name, outcome: "llm" });
1779
- return updated;
1780
- }
1781
- catch (err) {
1782
- if (err instanceof ConfigError) {
1783
- configFailure ??= err;
1784
- return entry;
1785
- }
1786
- const msg = toErrorMessage(err);
1787
- // failureSamples is bounded to 3 items, so a linear scan is cheaper
1788
- // than maintaining a parallel Set for membership checks (#177 review).
1789
- if (summary.failureSamples.length < 3 && !summary.failureSamples.includes(msg)) {
1790
- summary.failureSamples.push(msg);
1791
- }
1792
- onEntryDone?.({ entryName: entry.name, outcome: "failed" });
1793
- return entry;
1794
- }
1795
- },
1796
- // Defaults: 2 for remote LLM APIs, 1 for local model servers. No config
1797
- // override reaches this path (see getDefaultLlmConcurrency).
1798
- getDefaultLlmConcurrency(llmRunner.connection));
1799
- if (configFailure)
1800
- throw configFailure;
1801
- // concurrentMap returns Array<T | undefined>; filter out undefined slots
1802
- // (which can only occur if the callback itself returned undefined, which
1803
- // it never does above — but TypeScript needs the filter for type safety).
1804
- const enhanced = results.map((r, i) => r ?? stash.entries[i]);
1805
- return { entries: enhanced };
1084
+ return verification(false, embeddingResult.message ??
1085
+ `Semantic search verification failed (${embeddingCount}/${embeddableEntries} embeddings available).`, true, "blocked", embeddingProvider === "remote"
1086
+ ? "Check your embedding endpoint and credentials, then retry `akm index --full --verbose`."
1087
+ : "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.");
1806
1088
  }
1807
1089
  // ── lookup ─────────────────────────────────────────────────────────────────
1808
1090
  import { makeBundleRef } from "../core/asset/asset-ref.js";
@@ -1812,17 +1094,13 @@ async function resolveLookupSources() {
1812
1094
  const { resolveSourceEntries } = await import("./search/search-source.js");
1813
1095
  return resolveSourceEntries(undefined, loadConfig());
1814
1096
  }
1815
- function resolveLookupScope(bundle, sources) {
1816
- if (!bundle)
1817
- return { candidateSources: sources, qualified: false };
1818
- return { candidateSources: resolveSourcesForOrigin(bundle, sources), qualified: true };
1819
- }
1820
1097
  async function lookupBundleRefWithResolutionUsing(ref, openLookupDatabase) {
1821
1098
  const sources = await resolveLookupSources();
1822
1099
  if (sources.length === 0)
1823
1100
  return { entry: null };
1824
- const bundleBySourcePath = new Map(deriveInstallations(sources).map((installation, index) => [path.resolve(sources[index].path), installation.id]));
1825
- const { candidateSources, qualified } = resolveLookupScope(ref.bundle, sources);
1101
+ const bundleBySourcePath = new Map(sourceComponents(sources).map((component, index) => [path.resolve(sources[index].path), component.id]));
1102
+ const qualified = Boolean(ref.bundle);
1103
+ const candidateSources = ref.bundle ? resolveSourcesForOrigin(ref.bundle, sources) : sources;
1826
1104
  if (candidateSources.length === 0)
1827
1105
  return { entry: null };
1828
1106
  let db;
@@ -1833,6 +1111,11 @@ async function lookupBundleRefWithResolutionUsing(ref, openLookupDatabase) {
1833
1111
  catch (error) {
1834
1112
  indexError = error;
1835
1113
  }
1114
+ const resolved = (entry, owner) => ({
1115
+ entry,
1116
+ ...(owner ? { owner } : {}),
1117
+ ...(indexError === undefined ? {} : { indexError }),
1118
+ });
1836
1119
  try {
1837
1120
  for (const source of candidateSources) {
1838
1121
  const adapterId = source.adapterId ?? detectAdapterId(source.path);
@@ -1855,21 +1138,21 @@ async function lookupBundleRefWithResolutionUsing(ref, openLookupDatabase) {
1855
1138
  if (!(error instanceof WorkflowSourceIdentityError))
1856
1139
  throw error;
1857
1140
  warn(`${error.message} Falling back to the physical owner.`);
1858
- return { entry: null, owner, ...(indexError === undefined ? {} : { indexError }) };
1141
+ return resolved(null, owner);
1859
1142
  }
1860
1143
  }
1861
1144
  else if (entry.adapterId !== adapterId || !indexedPathMatchesOwner(entry.filePath, owner)) {
1862
- return { entry: null, owner, ...(indexError === undefined ? {} : { indexError }) };
1145
+ return resolved(null, owner);
1863
1146
  }
1864
- return { entry, owner, ...(indexError === undefined ? {} : { indexError }) };
1147
+ return resolved(entry, owner);
1865
1148
  }
1866
1149
  }
1867
1150
  // A physical owner with a missing/incomplete index row still owns this
1868
1151
  // unqualified concept. Stop here so a later source cannot retarget it.
1869
1152
  if (owner)
1870
- return { entry: null, owner, ...(indexError === undefined ? {} : { indexError }) };
1153
+ return resolved(null, owner);
1871
1154
  }
1872
- return { entry: null, ...(indexError === undefined ? {} : { indexError }) };
1155
+ return resolved(null);
1873
1156
  }
1874
1157
  finally {
1875
1158
  if (db)
@@ -1941,8 +1224,6 @@ export async function lookup(ref) {
1941
1224
  return lookupBundleRef({ bundle: ref.origin, conceptId: conceptIdFromTypeName(ref.type, ref.name) });
1942
1225
  }
1943
1226
  // ── Utility score recomputation ──────────────────────────────────────────────
1944
- /** Retention window for usage events: events older than this are purged. */
1945
- const USAGE_EVENT_RETENTION_DAYS = 90;
1946
1227
  /**
1947
1228
  * Recompute utility scores for all entries based on usage_events data.
1948
1229
  *
@@ -1959,16 +1240,11 @@ const USAGE_EVENT_RETENTION_DAYS = 90;
1959
1240
  *
1960
1241
  * Called during `akm index` after FTS rebuild.
1961
1242
  */
1962
- export function recomputeUtilityScores(db, stateDb, options) {
1243
+ export function recomputeUtilityScores(db, stateDb) {
1963
1244
  const EMA_DECAY = 0.7;
1964
- const stateSchema = options?.stateSchema;
1965
- if (stateSchema !== undefined && !/^[A-Za-z_][A-Za-z0-9_]*$/.test(stateSchema)) {
1966
- throw new Error("Invalid attached state schema name.");
1967
- }
1968
- const usageEvents = stateSchema === undefined ? "usage_events" : `"${stateSchema}".usage_events`;
1969
1245
  // Purge stale usage events (90-day retention). usage_events lives in state.db
1970
1246
  // (Chunk-8 WI-8.3); its table is created by state migration 020.
1971
- purgeOldUsageEvents(stateDb, USAGE_EVENT_RETENTION_DAYS, { stateSchema });
1247
+ purgeOldUsageEvents(stateDb, USAGE_EVENT_RETENTION_DAYS);
1972
1248
  // Time-proportional decay: apply one round of EMA per elapsed day so
1973
1249
  // indexing frequency doesn't affect how fast scores decay.
1974
1250
  const lastComputedAt = getMeta(db, "last_utility_computed_at");
@@ -1999,7 +1275,7 @@ export function recomputeUtilityScores(db, stateDb, options) {
1999
1275
  ELSE NULL
2000
1276
  END
2001
1277
  ) AS last_used_at
2002
- FROM ${usageEvents} u
1278
+ FROM usage_events u
2003
1279
  WHERE u.entry_id IS NOT NULL
2004
1280
  AND u.source = 'user'
2005
1281
  GROUP BY u.entry_id