akm-cli 0.9.17-alpha.3 → 0.9.17-alpha.4

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 (343) hide show
  1. package/CHANGELOG.md +731 -0
  2. package/dist/akm +94 -196
  3. package/dist/cli/shared.js +6 -2
  4. package/dist/cli.js +22 -9
  5. package/dist/commands/agent/agent-dispatch.js +1 -1
  6. package/dist/commands/command/command-execution.js +24 -62
  7. package/dist/commands/feedback-cli.js +0 -1
  8. package/dist/commands/health/accept-rate.js +2 -2
  9. package/dist/commands/health/checks.js +30 -75
  10. package/dist/commands/health/config-skew.js +38 -0
  11. package/dist/commands/health/egress.js +54 -0
  12. package/dist/commands/health/html-report.js +0 -38
  13. package/dist/commands/health/improve-metrics.js +123 -562
  14. package/dist/commands/health/plugin-staleness.js +53 -3
  15. package/dist/commands/health/renderers.js +12 -4
  16. package/dist/commands/health/report-view-model.js +11 -106
  17. package/dist/commands/health/types-improve.js +4 -19
  18. package/dist/commands/health/windows.js +64 -73
  19. package/dist/commands/health.js +122 -143
  20. package/dist/commands/improve/consolidate/chunking.js +25 -100
  21. package/dist/commands/improve/consolidate/sanitize.js +54 -149
  22. package/dist/commands/improve/consolidate.js +538 -1075
  23. package/dist/commands/improve/content-hash.js +16 -24
  24. package/dist/commands/improve/distill/content-repair.js +18 -100
  25. package/dist/commands/improve/distill-guards.js +20 -81
  26. package/dist/commands/improve/distill-promotion-policy.js +23 -243
  27. package/dist/commands/improve/distill.js +608 -1075
  28. package/dist/commands/improve/eligibility.js +126 -400
  29. package/dist/commands/improve/execution.js +3 -5
  30. package/dist/commands/improve/extract.js +487 -1046
  31. package/dist/commands/improve/feedback-valence.js +0 -25
  32. package/dist/commands/improve/improve-cli.js +29 -166
  33. package/dist/commands/improve/improve-result-file.js +10 -66
  34. package/dist/commands/improve/improve-strategies.js +12 -7
  35. package/dist/commands/improve/improve-usage-report.js +18 -64
  36. package/dist/commands/improve/improve.js +443 -1063
  37. package/dist/commands/improve/ledger.js +114 -0
  38. package/dist/commands/improve/locks.js +2 -8
  39. package/dist/commands/improve/loop-stages.js +459 -1172
  40. package/dist/commands/improve/memory/derived-ref.js +12 -77
  41. package/dist/commands/improve/memory/memory-belief.js +14 -118
  42. package/dist/commands/improve/memory/memory-improve.js +4 -3
  43. package/dist/commands/improve/outcome-loop.js +28 -156
  44. package/dist/commands/improve/planner.js +5 -10
  45. package/dist/commands/improve/preparation.js +851 -2339
  46. package/dist/commands/improve/proactive-maintenance.js +34 -101
  47. package/dist/commands/improve/reflect-noise.js +104 -280
  48. package/dist/commands/improve/reflect.js +621 -1367
  49. package/dist/commands/improve/salience.js +46 -232
  50. package/dist/commands/improve/session-asset.js +19 -100
  51. package/dist/commands/improve/stage.js +323 -0
  52. package/dist/commands/proposal/drain.js +251 -644
  53. package/dist/commands/proposal/proposal-cli.js +3 -18
  54. package/dist/commands/proposal/proposal-types.js +20 -41
  55. package/dist/commands/proposal/proposal.js +1 -2
  56. package/dist/commands/proposal/propose.js +134 -160
  57. package/dist/commands/proposal/repository.js +502 -1487
  58. package/dist/commands/proposal/validators/proposal-quality-validators.js +71 -174
  59. package/dist/commands/proposal/validators/proposal-validators.js +1 -1
  60. package/dist/commands/proposal/validators/proposals.js +13 -89
  61. package/dist/commands/read/curate.js +63 -413
  62. package/dist/commands/read/search-cli.js +16 -33
  63. package/dist/commands/read/search.js +17 -23
  64. package/dist/commands/read/show.js +2 -13
  65. package/dist/commands/sources/bundle-cli.js +25 -2
  66. package/dist/commands/sources/bundle-config-ops.js +7 -0
  67. package/dist/commands/sources/dangerous-env-audit.js +1 -2
  68. package/dist/commands/sources/info.js +2 -11
  69. package/dist/commands/sources/installed-stashes.js +197 -746
  70. package/dist/commands/sources/schema-repair.js +98 -129
  71. package/dist/commands/sources/source-add.js +62 -12
  72. package/dist/commands/sources/stash-cli.js +1 -1
  73. package/dist/commands/tasks/explain.js +10 -13
  74. package/dist/commands/tasks/tasks-cli.js +9 -8
  75. package/dist/commands/tasks/tasks.js +326 -930
  76. package/dist/commands/tasks/validate.js +42 -21
  77. package/dist/commands/workflow/plan.js +22 -29
  78. package/dist/commands/workflow-cli.js +4 -4
  79. package/dist/core/adapter/adapters/akm-adapter.js +0 -1
  80. package/dist/core/adapter/adapters/akm-lint.js +2 -3
  81. package/dist/core/adapter/adapters/akm-metadata.js +11 -12
  82. package/dist/core/adapter/adapters/akm-workflow-adapter.js +1 -1
  83. package/dist/core/adapter/execution-source.js +17 -29
  84. package/dist/core/asset/resolve-ref.js +1 -1
  85. package/dist/core/bundle-id.js +42 -5
  86. package/dist/core/bundle-rename.js +291 -0
  87. package/dist/core/config/config-io.js +1 -2
  88. package/dist/core/config/config-schema.js +1 -33
  89. package/dist/core/config/config-walker.js +1 -1
  90. package/dist/core/config/config.js +163 -68
  91. package/dist/core/config/legacy-source-shape-shim.js +38 -9
  92. package/dist/core/config/schema/embedding.js +20 -5
  93. package/dist/core/config/schema/engines.js +5 -0
  94. package/dist/core/config/schema/execution.js +1 -1
  95. package/dist/core/config/schema/experimental.js +1 -1
  96. package/dist/core/config/schema/improve-processes.js +21 -95
  97. package/dist/core/config/schema/improve.js +4 -42
  98. package/dist/core/config/schema/scheduler.js +12 -12
  99. package/dist/core/config/schema/search.js +6 -22
  100. package/dist/core/env-secret-ref.js +0 -1
  101. package/dist/core/errors.js +8 -9
  102. package/dist/core/file-lock.js +76 -173
  103. package/dist/core/logs-db.js +2 -2
  104. package/dist/core/paths.js +0 -27
  105. package/dist/core/redaction.js +109 -2
  106. package/dist/core/run-lock.js +2 -5
  107. package/dist/core/spawn-env.js +1 -1
  108. package/dist/core/state/migrations.js +108 -61
  109. package/dist/core/state-db-scope.js +2 -4
  110. package/dist/core/state-db.js +126 -692
  111. package/dist/core/type-presentation.js +1 -9
  112. package/dist/core/write-source.js +293 -1012
  113. package/dist/execution/input-contract.js +1 -1
  114. package/dist/execution/resolved-request.js +135 -689
  115. package/dist/execution/source.js +63 -257
  116. package/dist/execution/target-ref.js +1 -1
  117. package/dist/indexer/bundle-identity-guard.js +2 -2
  118. package/dist/indexer/db/graph-db.js +106 -46
  119. package/dist/indexer/ensure-index.js +44 -85
  120. package/dist/indexer/graph/graph-extraction.js +340 -562
  121. package/dist/indexer/graph/graph-related.js +130 -0
  122. package/dist/indexer/index-rebuild-lock.js +3 -11
  123. package/dist/indexer/index-writer-lock.js +8 -17
  124. package/dist/indexer/index-written-assets.js +139 -151
  125. package/dist/indexer/indexer.js +524 -846
  126. package/dist/indexer/materialize-embeddings.js +60 -397
  127. package/dist/indexer/passes/memory-inference.js +81 -90
  128. package/dist/indexer/passes/metadata.js +132 -200
  129. package/dist/indexer/read-preflight.js +0 -7
  130. package/dist/indexer/scan/doc-to-entry.js +1 -3
  131. package/dist/indexer/scan/drain-dir.js +1 -1
  132. package/dist/indexer/search/db-search.js +181 -590
  133. package/dist/indexer/search/fts-query.js +30 -41
  134. package/dist/indexer/search/ranking.js +28 -154
  135. package/dist/indexer/search/search-attribution.js +12 -32
  136. package/dist/indexer/search/search-fields.js +11 -15
  137. package/dist/indexer/search/search-hit-enrichers.js +54 -85
  138. package/dist/indexer/search/search-source.js +1 -4
  139. package/dist/indexer/usage/usage-events.js +2 -7
  140. package/dist/integrations/agent/engine-fallback.js +23 -40
  141. package/dist/integrations/agent/engine-resolution.js +93 -183
  142. package/dist/integrations/agent/execution.js +507 -0
  143. package/dist/integrations/agent/model-map.js +28 -156
  144. package/dist/integrations/agent/request-lowering.js +66 -141
  145. package/dist/integrations/agent/runner-dispatch.js +143 -321
  146. package/dist/integrations/agent/runner.js +54 -14
  147. package/dist/integrations/lockfile.js +53 -101
  148. package/dist/llm/embedders/deterministic.js +2 -3
  149. package/dist/llm/embedders/profile.js +71 -0
  150. package/dist/llm/embedders/remote.js +10 -15
  151. package/dist/llm/graph-extract.js +3 -12
  152. package/dist/llm/index-passes.js +3 -5
  153. package/dist/llm/memory-infer.js +1 -2
  154. package/dist/llm/metadata-enhance.js +1 -2
  155. package/dist/llm/structured-call.js +5 -24
  156. package/dist/output/generic-render.js +23 -11
  157. package/dist/output/html-render.js +13 -10
  158. package/dist/output/render-registry.js +3 -32
  159. package/dist/output/shapes/helpers.js +2 -34
  160. package/dist/output/shapes/passthrough.js +1 -9
  161. package/dist/{indexer/search/ranking-types.js → output/text/bundle-rename.js} +4 -1
  162. package/dist/output/text/command-format.js +60 -23
  163. package/dist/output/text/helpers.js +1 -1
  164. package/dist/output/text/migrate.js +5 -14
  165. package/dist/output/text/proposal-format.js +1 -2
  166. package/dist/output/text/workflow-format.js +0 -32
  167. package/dist/output/text.js +2 -0
  168. package/dist/registry/factory.js +4 -19
  169. package/dist/registry/network.js +66 -220
  170. package/dist/registry/providers/index.js +0 -2
  171. package/dist/registry/providers/skills-sh.js +3 -14
  172. package/dist/registry/providers/static-index.js +24 -26
  173. package/dist/registry/resolve.js +55 -131
  174. package/dist/scripts/akm-migrate-node.js +43937 -93313
  175. package/dist/scripts/akm-migrate.js +43697 -93071
  176. package/dist/setup/registry-stash-loader.js +4 -13
  177. package/dist/setup/semantic-assets.js +3 -44
  178. package/dist/setup/setup.js +1 -1
  179. package/dist/setup/steps/tasks.js +25 -15
  180. package/dist/sources/provider-factory.js +17 -18
  181. package/dist/sources/providers/filesystem.js +2 -3
  182. package/dist/sources/providers/git-install.js +7 -1
  183. package/dist/sources/providers/git-provider.js +0 -3
  184. package/dist/sources/providers/git-stash.js +0 -17
  185. package/dist/sources/providers/npm.js +2 -4
  186. package/dist/sources/providers/provider-utils.js +5 -10
  187. package/dist/sources/providers/website.js +0 -2
  188. package/dist/sources/snapshot-fetchers/website-ingest.js +1 -1
  189. package/dist/sources/website-url.js +2 -2
  190. package/dist/storage/database.js +9 -35
  191. package/dist/storage/repositories/improve-ledger-repository.js +168 -0
  192. package/dist/storage/repositories/index-connection.js +34 -70
  193. package/dist/storage/repositories/index-entries-repository.js +69 -111
  194. package/dist/storage/repositories/index-entry-mapper.js +1 -2
  195. package/dist/storage/repositories/index-entry-schema.js +83 -269
  196. package/dist/storage/repositories/index-fts-repository.js +86 -256
  197. package/dist/storage/repositories/index-llm-cache-repository.js +17 -0
  198. package/dist/storage/repositories/index-meta-repository.js +6 -4
  199. package/dist/storage/repositories/index-schema.js +192 -220
  200. package/dist/storage/repositories/index-utility-repository.js +8 -29
  201. package/dist/storage/repositories/index-vec-repository.js +133 -414
  202. package/dist/storage/repositories/outcome-repository.js +2 -1
  203. package/dist/storage/repositories/proposals-repository.js +35 -0
  204. package/dist/storage/repositories/registry-index-cache-repository.js +100 -0
  205. package/dist/storage/repositories/task-history-repository.js +26 -4
  206. package/dist/storage/repositories/workflow-runs-repository.js +53 -244
  207. package/dist/storage/sqlite-migrations.js +136 -0
  208. package/dist/storage/sqlite-pragmas.js +11 -9
  209. package/dist/storage/sqlite-transaction.js +170 -0
  210. package/dist/storage/state-db-integrity.js +34 -27
  211. package/dist/tasks/activation-config.js +134 -62
  212. package/dist/tasks/backends/cron.js +129 -277
  213. package/dist/tasks/backends/exec-utils.js +2 -5
  214. package/dist/tasks/backends/launchd.js +125 -745
  215. package/dist/tasks/backends/schtasks.js +101 -620
  216. package/dist/tasks/prepare/prepare-support.js +5 -15
  217. package/dist/tasks/prepare/prepare.js +0 -2
  218. package/dist/tasks/resolve-akm-bin.js +20 -79
  219. package/dist/tasks/run/attempt-lifecycle.js +0 -1
  220. package/dist/tasks/scheduler-binding.js +18 -238
  221. package/dist/tasks/scheduler-invocation.js +52 -52
  222. package/dist/tasks/scheduler-lock.js +53 -0
  223. package/dist/tasks/scheduler-sync.js +361 -751
  224. package/dist/tasks/source/parse-task-source.js +160 -10
  225. package/dist/tasks/source/task-source-v3-frozen.js +3 -4
  226. package/dist/tasks/source/task-to-v4.js +2 -2
  227. package/dist/workflows/authoring/authoring.js +3 -12
  228. package/dist/workflows/compile.js +211 -0
  229. package/dist/workflows/concurrency-policy.js +13 -74
  230. package/dist/workflows/exec/child-invocation.js +3 -17
  231. package/dist/workflows/exec/child-workflow.js +32 -141
  232. package/dist/workflows/exec/dispatch-redaction.js +13 -53
  233. package/dist/workflows/exec/environment.js +98 -0
  234. package/dist/workflows/exec/exec-unit.js +33 -140
  235. package/dist/workflows/exec/frozen-judge.js +7 -59
  236. package/dist/workflows/exec/native-executor.js +82 -341
  237. package/dist/workflows/exec/param-secrets.js +29 -47
  238. package/dist/workflows/exec/run-workflow.js +154 -387
  239. package/dist/workflows/exec/scheduler.js +9 -36
  240. package/dist/workflows/exec/step-work.js +127 -430
  241. package/dist/workflows/exec/unit-dispatch.js +11 -63
  242. package/dist/workflows/exec/unit-writer.js +8 -52
  243. package/dist/workflows/exec/worktree.js +39 -273
  244. package/dist/workflows/freeze/child-output-references.js +4 -15
  245. package/dist/workflows/freeze/environment.js +99 -92
  246. package/dist/workflows/freeze/freeze.js +172 -0
  247. package/dist/workflows/freeze/step-values.js +19 -21
  248. package/dist/workflows/freeze/targets/child-workflow.js +23 -92
  249. package/dist/workflows/freeze/targets/command.js +10 -33
  250. package/dist/workflows/freeze/targets/script.js +5 -12
  251. package/dist/workflows/freeze/targets/shell.js +3 -6
  252. package/dist/workflows/freeze/targets/task.js +25 -80
  253. package/dist/workflows/freeze/task-bindings.js +20 -67
  254. package/dist/workflows/{source-ir/github-yaml.js → github-yaml.js} +88 -206
  255. package/dist/workflows/ir/params.js +6 -51
  256. package/dist/workflows/ir/plan-hash.js +2 -34
  257. package/dist/workflows/parser.js +140 -43
  258. package/dist/{commands/improve/consolidate/types.js → workflows/plan.js} +2 -1
  259. package/dist/workflows/renderer.js +36 -69
  260. package/dist/workflows/resource-limits.js +12 -120
  261. package/dist/workflows/runtime/agent-identity.js +8 -40
  262. package/dist/workflows/runtime/run-outputs.js +3 -6
  263. package/dist/workflows/runtime/run-plan.js +316 -0
  264. package/dist/workflows/runtime/runs.js +48 -200
  265. package/dist/workflows/runtime/workflow-asset-loader.js +24 -57
  266. package/dist/workflows/{source-ir/semantics.js → source-semantics.js} +16 -20
  267. package/dist/workflows/validate-summary.js +2 -7
  268. package/docs/integration/bundling-akm.md +49 -42
  269. package/docs/migration/README.md +1 -0
  270. package/docs/migration/release-notes/0.9.17.md +41 -0
  271. package/docs/migration/v0.9.1-to-v0.9.2.md +19 -7
  272. package/docs/reference/cli.md +182 -125
  273. package/docs/reference/configuration.md +49 -56
  274. package/docs/reference/data-and-telemetry.md +19 -20
  275. package/docs/reference/tasks.md +86 -38
  276. package/docs/reference/workflow-schema.md +14 -18
  277. package/docs/reference/workflows.md +6 -9
  278. package/package.json +1 -1
  279. package/schemas/akm-config.json +87 -406
  280. package/dist/commands/health/advisories.js +0 -150
  281. package/dist/commands/health/metrics.js +0 -329
  282. package/dist/commands/health/surfaces.js +0 -102
  283. package/dist/commands/improve/anti-collapse.js +0 -83
  284. package/dist/commands/improve/collapse-detector.js +0 -432
  285. package/dist/commands/improve/consolidate/eligibility.js +0 -48
  286. package/dist/commands/improve/consolidate/merge.js +0 -146
  287. package/dist/commands/improve/distill/promote-memory.js +0 -329
  288. package/dist/commands/improve/distill/quality-gate.js +0 -500
  289. package/dist/commands/improve/memory/memory-contradiction-detect.js +0 -291
  290. package/dist/commands/improve/proposal-envelope.js +0 -31
  291. package/dist/commands/improve/run-context.js +0 -123
  292. package/dist/commands/improve/shared.js +0 -21
  293. package/dist/commands/improve/source-identity.js +0 -28
  294. package/dist/commands/improve/triage.js +0 -96
  295. package/dist/commands/proposal/drain-policies.js +0 -151
  296. package/dist/commands/sources/update-transaction.js +0 -220
  297. package/dist/core/action-contributors.js +0 -28
  298. package/dist/core/config/config-version-shim.js +0 -101
  299. package/dist/core/config/retired-experimental-keys-shim.js +0 -62
  300. package/dist/core/fs-txn.js +0 -405
  301. package/dist/core/lexical-score.js +0 -25
  302. package/dist/core/maintenance-barrier.js +0 -167
  303. package/dist/execution/executable-identity.js +0 -105
  304. package/dist/execution/guarded-source.js +0 -441
  305. package/dist/indexer/graph/graph-boost.js +0 -427
  306. package/dist/indexer/graph/graph-dedup.js +0 -95
  307. package/dist/indexer/search/name-match.js +0 -35
  308. package/dist/indexer/search/ranking-contributors.js +0 -515
  309. package/dist/indexer/walk/project-context.js +0 -192
  310. package/dist/integrations/agent/execution-cascade.js +0 -566
  311. package/dist/integrations/agent/execution-definitions.js +0 -202
  312. package/dist/integrations/agent/execution-lowering.js +0 -841
  313. package/dist/integrations/agent/execution-preparation.js +0 -98
  314. package/dist/integrations/agent/inline-execution.js +0 -74
  315. package/dist/registry/create-provider-registry.js +0 -29
  316. package/dist/registry/pinned-request-helper.js +0 -247
  317. package/dist/registry/pinned-transport.js +0 -717
  318. package/dist/sources/providers/index.js +0 -14
  319. package/dist/storage/engines/sqlite-migrations.js +0 -271
  320. package/dist/storage/repositories/canaries-repository.js +0 -107
  321. package/dist/storage/repositories/embedding-salvage-repository.js +0 -184
  322. package/dist/storage/repositories/registry-cache.js +0 -113
  323. package/dist/tasks/scheduler-sync-preview.js +0 -52
  324. package/dist/workflows/freeze/resolve-steps.js +0 -86
  325. package/dist/workflows/freeze/source-freeze.js +0 -64
  326. package/dist/workflows/ir/compile.js +0 -321
  327. package/dist/workflows/ir/environment-v4.js +0 -330
  328. package/dist/workflows/ir/freeze-v4.js +0 -153
  329. package/dist/workflows/ir/schema-v4.js +0 -745
  330. package/dist/workflows/ir/schema.js +0 -354
  331. package/dist/workflows/program/schema.js +0 -78
  332. package/dist/workflows/runtime/checkin.js +0 -57
  333. package/dist/workflows/runtime/plan-classifier.js +0 -196
  334. package/dist/workflows/runtime/unit-checkin.js +0 -45
  335. package/dist/workflows/runtime/unit-phases.js +0 -20
  336. package/dist/workflows/schema.js +0 -4
  337. package/dist/workflows/source-ir/compile.js +0 -200
  338. package/dist/workflows/source-ir/program.js +0 -50
  339. package/dist/workflows/source-ir/result.js +0 -26
  340. package/dist/workflows/source-ir/schema.js +0 -786
  341. package/dist/workflows/source-ir/triggers.js +0 -79
  342. package/dist/workflows/source-ir/uses.js +0 -40
  343. package/dist/workflows/validator.js +0 -60
@@ -14,18 +14,18 @@ import { getDbPath } from "../core/paths.js";
14
14
  import { SCRIPT_EXTENSIONS } from "../core/recognition-util.js";
15
15
  import { withStateDb } from "../core/state-db.js";
16
16
  import { isVerbose, warn, warnOnce, warnVerbose } from "../core/warn.js";
17
- import { disposeLoweredExecutionDispatchLease, } from "../integrations/agent/execution-lowering.js";
17
+ import { assertRunnerCredentials } from "../integrations/agent/runner-dispatch.js";
18
18
  import { isLlmFeatureEnabled } from "../llm/feature-gate.js";
19
19
  import { resolveIndexPassExecution } from "../llm/index-passes.js";
20
- import { preflightStructuredLlmRunner } from "../llm/structured-call.js";
21
20
  import { resolveSourcesForOrigin } from "../registry/origin-resolve.js";
22
- import { salvageEmbeddingsBeforeDiscard } from "../storage/repositories/embedding-salvage-repository.js";
23
21
  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";
22
+ import { deleteEntriesByBundle, deleteEntriesByDirAndBundle, deleteEntriesByDirExceptRefs, deleteEntriesByIds, deleteUsageEventsByEntryIds, findEntryIdByRef, getAllEntries, getEmbeddableEntryCount, getEntryCount, getIndexedBundleIdsByDir, getIndexedDirPathsByBundleId, relinkUsageEvents, upsertEntry, } from "../storage/repositories/index-entries-repository.js";
23
+ import { clearStaleCacheEntries, computeBodyHash, getLlmCacheEntry, upsertLlmCacheEntry, } from "../storage/repositories/index-llm-cache-repository.js";
24
+ import { deleteIndexDirState, deleteMeta, getIndexDirState, getMeta, setMeta, upsertIndexDirState, } from "../storage/repositories/index-meta-repository.js";
25
+ import { VACUUM_PENDING_META } from "../storage/repositories/index-schema.js";
27
26
  import { upsertUtilityScore } from "../storage/repositories/index-utility-repository.js";
28
- import { getEmbeddingCount, isVecAvailable, isVecFastPathReady, warnIfVecMissing, } from "../storage/repositories/index-vec-repository.js";
27
+ import { getEmbeddingCount } from "../storage/repositories/index-vec-repository.js";
28
+ import { INDEX_DB_VACUUMED_EVENT, readFreelistInfo, vacuumIfReclaimable } from "../storage/state-db-integrity.js";
29
29
  import { assertIndexedWorkflowSourceIdentity, WorkflowSourceIdentityError } from "../workflows/source-files.js";
30
30
  import { deleteStoredGraph } from "./db/graph-db.js";
31
31
  import { reclassifyIndexDbContention } from "./index-db-contention.js";
@@ -33,9 +33,8 @@ import { deriveEntryProvenance, deriveInstallations } from "./installations.js";
33
33
  import { indexedPathMatchesOwner, resolveAdapterConceptOwner, } from "./lookup/adapter-concept-owner.js";
34
34
  import { generateEmbeddingsForDb } from "./materialize-embeddings.js";
35
35
  import { canUseIncrementalSkip, computeDirFingerprint, getCachedDirState, getDirIndexState, inferZeroRowReason, } from "./passes/dir-staleness.js";
36
- import { getMarkdownFragmentContent, hasMarkdownFragmentContent, isEnrichmentComplete, isWorkflowSkipWarning, setMarkdownFragmentContent, } from "./passes/metadata.js";
36
+ import { isEnrichmentComplete, isWorkflowSkipWarning, withFileSize, } from "./passes/metadata.js";
37
37
  import { drainDirDocuments } from "./scan/drain-dir.js";
38
- import { buildSearchText } from "./search/search-fields.js";
39
38
  import { purgeOldUsageEvents } from "./usage/usage-events.js";
40
39
  import { walkStashFlatWithStatus } from "./walk/walker.js";
41
40
  function collectLoweringNotices(target, notices) {
@@ -68,12 +67,16 @@ export function getDefaultLlmConcurrency(llmConfig) {
68
67
  // is what runs (see docs/architecture/internals/indexing.md).
69
68
  return defaultConcurrencyForEndpoint(llmConfig?.endpoint);
70
69
  }
70
+ // ── Source ownership ─────────────────────────────────────────────────────────
71
+ /** Each source's durable bundle component (`deriveInstallations`: one per source, source order). */
72
+ function sourceComponents(sources) {
73
+ return deriveInstallations([...sources]).map((installation) => installation.components[0]);
74
+ }
71
75
  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
- });
76
+ return sourceComponents(sources).map((component, index) => ({
77
+ bundleId: component.id,
78
+ sourceRoot: path.resolve(sources[index].path),
79
+ }));
77
80
  }
78
81
  function parseStoredSourceOwners(raw) {
79
82
  if (!raw)
@@ -99,102 +102,43 @@ function parseStoredSourceOwners(raw) {
99
102
  }
100
103
  }
101
104
  /**
102
- * Source cache phase: ensure git stash caches are up to date and purge orphaned
103
- * entries from removed sources (incremental only).
105
+ * Sources removed (or moved) since the last complete run. Their entries and
106
+ * graph rows are purged by {@link applyRemovedSources} once the walk completes.
104
107
  */
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().
108
+ function findRemovedSources(db, sources) {
109
+ const currentByBundle = new Map(sourceOwners(sources).map((owner) => [owner.bundleId, owner]));
110
+ return parseStoredSourceOwners(getMeta(db, "sourceOwners")).flatMap((previous) => {
111
+ const current = currentByBundle.get(previous.bundleId);
112
+ if (current && current.sourceRoot === previous.sourceRoot)
113
+ return [];
114
+ return [{ ...previous, removeBundleEntries: current === undefined }];
115
+ });
122
116
  }
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) {
117
+ function applyRemovedSources(db, sources, removedSources, isIncremental) {
118
+ const owners = sourceOwners(sources);
119
+ const currentRoots = new Set(owners.map((owner) => owner.sourceRoot));
120
+ for (const removed of removedSources) {
128
121
  if (removed.removeBundleEntries)
129
- deleteEntriesByBundle(ctx.db, removed.bundleId);
122
+ deleteEntriesByBundle(db, removed.bundleId);
130
123
  if (!currentRoots.has(removed.sourceRoot))
131
- deleteStoredGraph(ctx.db, removed.sourceRoot);
124
+ deleteStoredGraph(db, removed.sourceRoot);
132
125
  }
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
- }
126
+ // A full run re-drains every configured source, so any other bundle's rows
127
+ // are stale even when no stored owner names them.
128
+ if (isIncremental)
129
+ return;
130
+ const currentBundles = new Set(owners.map((owner) => owner.bundleId));
131
+ const indexed = db.prepare("SELECT DISTINCT bundle_id AS bundleId FROM entries").all();
132
+ for (const { bundleId } of indexed) {
133
+ if (!currentBundles.has(bundleId))
134
+ deleteEntriesByBundle(db, bundleId);
176
135
  }
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
136
  }
189
137
  /**
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.
138
+ * The embedding phase (#954): generate and store vectors for every entry
139
+ * missing one, then compute the `hasEmbeddings` fact and the semantic-search
140
+ * verification off the result. `db` must have no ambient transaction open —
141
+ * each per-batch commit is its own durable write.
198
142
  */
199
143
  export async function runEmbeddingPass(params) {
200
144
  const { db, config, onProgress, signal, reembed } = params;
@@ -209,69 +153,24 @@ export async function runEmbeddingPass(params) {
209
153
  return { embeddingResult, verification };
210
154
  }
211
155
  /**
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.
156
+ * Finalize: re-link usage events, recompute utility scores, clear stale LLM
157
+ * cache rows and record this run's freshness metadata. `upsertEntry` and every
158
+ * canonical delete own their FTS projection, so the "fts" event is an
159
+ * observation point, not a second materialization pass.
243
160
  */
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();
161
+ function finalizeIndex(args) {
162
+ const { db, sources, sourceDirs, stashDir, signal, onProgress } = args;
163
+ onProgress({ phase: "fts", message: "Full-text search index is current." });
164
+ const tFtsEnd = Date.now();
254
165
  // Re-link state.db usage events to the regenerated index and recompute the
255
166
  // derived utility cache. Stored refs already use the current item-ref grammar,
256
167
  // so this idempotent pass only restores derived entry ids.
257
- const mutateState = (stateDb, stateSchema) => {
168
+ withStateDb((stateDb) => {
258
169
  onProgress({ phase: "finalize", message: "Relinking usage events." });
259
- relinkUsageEvents(db, stateDb, { sources, defaultStashDir: stashDir, stateSchema });
170
+ relinkUsageEvents(db, stateDb);
260
171
  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
- }
172
+ recomputeUtilityScores(db, stateDb);
173
+ });
275
174
  // Purge LLM cache entries for assets that no longer exist in the index.
276
175
  try {
277
176
  onProgress({ phase: "finalize", message: "Clearing stale LLM cache entries." });
@@ -284,41 +183,34 @@ async function runFinalizePhase(ctx) {
284
183
  // An incomplete run preserves the prior freshness watermark. Advancing it
285
184
  // could make a recovered source look unchanged even though this run never
286
185
  // persisted its files.
287
- if (ctx.scanComplete) {
186
+ if (args.scanComplete) {
288
187
  setMeta(db, "builtAt", new Date().toISOString());
289
188
  setMeta(db, "stashDir", stashDir);
290
189
  setMeta(db, "stashDirs", JSON.stringify(sourceDirs));
291
190
  setMeta(db, "sourceOwners", JSON.stringify(sourceOwners(sources)));
292
191
  }
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;
192
+ return { tFtsEnd };
193
+ }
194
+ /**
195
+ * Compact index.db once a layout migration has left tables and columns'
196
+ * pages free, or whenever more than half its pages are free (the same
197
+ * threshold improve applies to state.db). Nothing else ever VACUUMs this
198
+ * file, and a delete-heavy history leaves it mostly free pages.
199
+ */
200
+ function vacuumIndexDb(db, onProgress) {
201
+ const migrated = getMeta(db, VACUUM_PENDING_META) === "1";
202
+ const outcome = vacuumIfReclaimable(db, readFreelistInfo(db), {
203
+ eventType: INDEX_DB_VACUUMED_EVENT,
204
+ force: migrated,
205
+ });
206
+ if (!outcome.ran)
207
+ return;
208
+ if (migrated)
209
+ deleteMeta(db, VACUUM_PENDING_META);
210
+ onProgress({
211
+ phase: "finalize",
212
+ message: `Compacted index.db with VACUUM: ${outcome.pagesBefore} → ${outcome.pagesAfter} pages.`,
213
+ });
322
214
  }
323
215
  // ── Clean pass ───────────────────────────────────────────────────────────────
324
216
  /**
@@ -372,29 +264,12 @@ let akmIndexOverride;
372
264
  export function _setAkmIndexForTests(fake) {
373
265
  akmIndexOverride = fake;
374
266
  }
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
267
  export async function akmIndex(options) {
383
268
  try {
384
269
  const override = akmIndexOverride;
385
270
  return override ? await override(options) : await akmIndexReal(options);
386
271
  }
387
272
  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
273
  throw reclassifyIndexDbContention(error);
399
274
  }
400
275
  }
@@ -402,20 +277,16 @@ let indexTransactionHookForTests;
402
277
  /**
403
278
  * TEST-ONLY. Observe the in-flight reindex transaction; `undefined` restores.
404
279
  *
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).
280
+ * Exists because the persist transaction's atomicity is, by construction,
281
+ * invisible from outside it: by the time `akmIndex()` resolves, the commit
282
+ * has already collapsed both generations into one observable state.
283
+ * Concurrency tests install a hook that opens a SECOND connection at this
284
+ * point and asserts it still sees the previous complete generation. Inert in
285
+ * production (one `undefined?.()` per reindex).
411
286
  */
412
287
  export function _setIndexTransactionHookForTests(hook) {
413
288
  indexTransactionHookForTests = hook;
414
289
  }
415
- /** Fire a named in-transaction observation point (no-op outside tests). */
416
- function indexTransactionHook(point) {
417
- indexTransactionHookForTests?.(point);
418
- }
419
290
  let drainObserverForTests;
420
291
  /**
421
292
  * TEST-ONLY. Observe every directory that actually reaches
@@ -429,6 +300,18 @@ let drainObserverForTests;
429
300
  export function _setDrainObserverForTests(observer) {
430
301
  drainObserverForTests = observer;
431
302
  }
303
+ /** Whether `root` is a directory whose listing can be read (adapter detection only probes such roots). */
304
+ function isReadableDirectory(root) {
305
+ try {
306
+ if (!fs.statSync(root).isDirectory())
307
+ return false;
308
+ fs.readdirSync(root); // probe readability, not just existence
309
+ return true;
310
+ }
311
+ catch {
312
+ return false;
313
+ }
314
+ }
432
315
  /**
433
316
  * Detect an adapter for every resolvable source that does not declare one, and
434
317
  * persist each detection into `config.json`.
@@ -440,17 +323,13 @@ export function _setDrainObserverForTests(observer) {
440
323
  * emitted here. The map is cleared at the top of every callback invocation
441
324
  * because `mutateConfig` may retry optimistically, and a retry must not report
442
325
  * 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
326
  */
448
327
  function detectAndPersistBundleAdapters(allSourceEntries, config, mutateConfig, opts) {
449
328
  const detectedByBundle = new Map();
450
329
  for (const source of allSourceEntries) {
451
330
  if (source.adapterId || source.unresolved)
452
331
  continue;
453
- if (allSourceRootsReadable([source.path])) {
332
+ if (isReadableDirectory(source.path)) {
454
333
  source.adapterId = detectAdapterId(source.path);
455
334
  if (source.registryId)
456
335
  detectedByBundle.set(source.registryId, source.adapterId);
@@ -490,38 +369,6 @@ function detectAndPersistBundleAdapters(allSourceEntries, config, mutateConfig,
490
369
  }
491
370
  return { config: nextConfig, persistedAdapters };
492
371
  }
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
372
  async function akmIndexReal(options) {
526
373
  // R-022: `dryRun` only ever gated the `--clean` stale-entry removal pass
527
374
  // (see `runCleanPass` below) — every other phase (walk, LLM enrichment,
@@ -538,183 +385,209 @@ async function akmIndexReal(options) {
538
385
  "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
386
  }
540
387
  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,
388
+ const { stashDir, signal } = options;
389
+ const onProgress = options.onProgress ?? (() => { });
390
+ const full = options.full === true;
391
+ const clean = options.clean === true;
392
+ const dryRun = options.dryRun === true;
393
+ // Load config and resolve all stash sources
394
+ const { loadConfig, mutateConfig } = await import("../core/config/config.js");
395
+ let config = loadConfig();
396
+ // Durable state must be runtime-compatible before source hydration,
397
+ // adapter persistence, or index.db creation can mutate the installation.
398
+ onProgress({ phase: "preflight", message: "Validating durable state." });
399
+ withStateDb(() => undefined);
400
+ // Ensure git stash caches are extracted before resolving stash dirs,
401
+ // so their content directories exist on disk for the walker to discover.
402
+ const sourceCacheStart = Date.now();
403
+ onProgress({ phase: "preflight", message: "Hydrating source caches." });
404
+ const { ensureSourceCaches, resolveSourceEntries } = await import("./search/search-source.js");
405
+ // Inject the store-backed secret resolver from here — a composition root
406
+ // ABOVE the provider/fetcher import cycle (this module reaches
407
+ // search-source only via dynamic import). This is what lets a website
408
+ // source's X fetcher resolve `secrets/x-bearer-token` during
409
+ // bundle-update / hydrate, not just from the command-layer URL-ingest
410
+ // path. `secret-seam` is imported here, never from inside the cycle.
411
+ const { storeSecretResolver } = await import("../sources/snapshot-fetchers/secret-seam.js");
412
+ await ensureSourceCaches(config, {
413
+ force: full,
414
+ materialize: options.hydrateSources !== false,
415
+ secrets: storeSecretResolver,
416
+ // Same progress channel as every other phase (#954) — a
417
+ // stalled clone/fetch here runs BEFORE index.db is even opened, so
418
+ // without this it looked identical to "no database open, nothing
419
+ // written".
420
+ onProgress: (message) => onProgress({ phase: "preflight", message }),
421
+ });
422
+ const sourceCacheEnd = Date.now();
423
+ const sources = resolveSourceEntries(stashDir, config);
424
+ const detected = detectAndPersistBundleAdapters(sources, config, mutateConfig, {
425
+ announce: options.implicit !== true,
426
+ persist: options.persistDetectedAdapters !== false,
427
+ });
428
+ config = detected.config;
429
+ const { persistedAdapters } = detected;
430
+ const sourceDirs = sources.map((s) => s.path);
431
+ onProgress({
432
+ phase: "preflight",
433
+ message: `Resolved ${sourceDirs.length} stash source${sourceDirs.length === 1 ? "" : "s"}.`,
434
+ });
435
+ const t0 = Date.now();
436
+ const enrichmentExecution = resolveIndexPassExecution("enrichment", config);
437
+ const loweringNotices = [...enrichmentExecution.notices];
438
+ const dbPath = getDbPath();
439
+ const db = openIndexDatabase(dbPath);
440
+ try {
441
+ // `--full` folds into `isIncremental`: a full run drains every directory
442
+ // through the same diff-persist path as an incremental one.
443
+ const prevStashDir = getMeta(db, "stashDir");
444
+ const prevBuiltAt = getMeta(db, "builtAt");
445
+ const isIncremental = !full && prevStashDir === stashDir && !!prevBuiltAt;
446
+ const builtAtMs = isIncremental && prevBuiltAt ? new Date(prevBuiltAt).getTime() : 0;
447
+ const mode = isIncremental ? "incremental" : "full";
448
+ onProgress({
449
+ phase: "summary",
450
+ message: buildIndexSummaryMessage({
451
+ mode,
452
+ sourcesCount: sourceDirs.length,
453
+ semanticSearchMode: config.semanticSearchMode,
454
+ embeddingProvider: getEmbeddingProvider(config.embedding),
455
+ llmEnabled: !!enrichmentExecution.runner,
456
+ }),
584
457
  });
585
- config = detected.config;
586
- const persistedAdapters = detected.persistedAdapters;
587
- const allSourceDirs = allSourceEntries.map((s) => s.path);
458
+ const removedSources = findRemovedSources(db, sources);
459
+ // ── Walk: scan the filesystem, generate metadata, persist entries ─────
460
+ throwIfAborted(signal);
461
+ const tWalkStart = Date.now();
462
+ // Walk every source and pre-generate all metadata outside any transaction…
463
+ const { dirRecords, scannedDirs, skippedDirs, generatedCount, warnings, complete } = await scanSourceDirs(db, sources, isIncremental, builtAtMs, removedSources.length > 0, onProgress, !clean);
464
+ // …fail on a missing enrichment credential before anything is persisted…
465
+ const bundleByRoot = buildIndexedSourceOwners(sources);
466
+ const runner = enrichmentExecution.runner;
467
+ if (runner &&
468
+ isLlmFeatureEnabled(config, "metadata_enhance") &&
469
+ dirRecordsNeedMetadataDispatch(db, dirRecords, bundleByRoot)) {
470
+ assertRunnerCredentials(runner);
471
+ }
472
+ // …then write it in one transaction: `item_ref = <bundle>//<conceptId>`
473
+ // plus canonical component/adapter provenance per source root.
474
+ const { dirsNeedingLlm } = persistDirRecords(db, dirRecords, warnings, bundleByRoot);
588
475
  onProgress({
589
- phase: "preflight",
590
- message: `Resolved ${allSourceDirs.length} stash source${allSourceDirs.length === 1 ? "" : "s"}.`,
476
+ phase: "scan",
477
+ message: `Scanned ${scannedDirs} ${scannedDirs === 1 ? "directory" : "directories"} and skipped ${skippedDirs}.`,
591
478
  });
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.");
479
+ // Workflow validation noise gate (issue #273): suppress per-spec stderr
480
+ // lines at default verbosity and emit a single summary instead. In verbose
481
+ // mode the per-spec lines are already printed by buildMetadataSkipWarning.
482
+ if (!isVerbose()) {
483
+ const skippedWorkflowCount = warnings.filter(isWorkflowSkipWarning).length;
484
+ if (skippedWorkflowCount > 0) {
485
+ const noun = skippedWorkflowCount === 1 ? "workflow spec" : "workflow specs";
486
+ warn(`${skippedWorkflowCount} ${noun} skipped due to validation errors; ` +
487
+ "rerun with --verbose (or AKM_VERBOSE=1) to see details.");
488
+ }
601
489
  }
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;
490
+ const tWalkEnd = Date.now();
491
+ // ── LLM enrichment for the directories that need it ───────────────────
492
+ throwIfAborted(signal);
493
+ await enhanceDirsWithLlm(db, config, enrichmentExecution, dirsNeedingLlm, onProgress, signal, (notices) => collectLoweringNotices(loweringNotices, notices));
494
+ onProgress({
495
+ phase: "llm",
496
+ message: enrichmentExecution.runner
497
+ ? `LLM enhancement reviewed ${dirsNeedingLlm.length} ${dirsNeedingLlm.length === 1 ? "directory" : "directories"}.`
498
+ : "LLM enhancement disabled.",
499
+ });
500
+ const tLlmEnd = Date.now();
501
+ if (complete)
502
+ applyRemovedSources(db, sources, removedSources, isIncremental);
503
+ // Reconcile explicit missing-file cleanup before embeddings, totals, or
504
+ // verification describe this generation. Dry-run intentionally leaves
505
+ // the generation unchanged while still returning the previewed refs.
506
+ let cleanResult;
507
+ const cleanStart = Date.now();
508
+ if (clean) {
621
509
  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
- }),
510
+ phase: "finalize",
511
+ message: dryRun ? "Scanning for stale index entries (dry run)." : "Removing stale index entries.",
631
512
  });
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
- }
513
+ if (complete) {
514
+ cleanResult = runCleanPass(db, dryRun);
655
515
  }
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);
516
+ else {
517
+ warn("[index] --clean skipped because one or more configured sources were not scanned completely.");
518
+ cleanResult = { checked: 0, removed: 0, removedRefs: [], dryRun };
697
519
  }
698
- if (!borrowedUpdateDb)
699
- closeDatabase(db);
700
520
  }
701
- })();
521
+ const cleanEnd = Date.now();
522
+ // ── Embeddings ─────────────────────────────────────────────────────────
523
+ throwIfAborted(signal);
524
+ const { verification } = await runEmbeddingPass({
525
+ db,
526
+ config,
527
+ onProgress,
528
+ signal,
529
+ reembed: options.reembed === true,
530
+ });
531
+ const tEmbedEnd = Date.now();
532
+ // ── Finalize ──────────────────────────────────────────────────────────
533
+ const tFinalizeStart = Date.now();
534
+ const { tFtsEnd } = finalizeIndex({
535
+ db,
536
+ sources,
537
+ sourceDirs,
538
+ stashDir,
539
+ scanComplete: complete,
540
+ signal,
541
+ onProgress,
542
+ });
543
+ vacuumIndexDb(db, onProgress);
544
+ const totalEntries = getEntryCount(db);
545
+ const tFinalizeEnd = Date.now();
546
+ return {
547
+ stashDir,
548
+ totalEntries,
549
+ generatedMetadata: generatedCount,
550
+ indexPath: dbPath,
551
+ mode,
552
+ directoriesScanned: scannedDirs,
553
+ directoriesSkipped: skippedDirs,
554
+ scanComplete: complete,
555
+ ...(warnings.length > 0 ? { warnings } : {}),
556
+ ...(loweringNotices.length > 0 ? { notices: Object.freeze([...loweringNotices]) } : {}),
557
+ ...(Object.keys(persistedAdapters).length > 0 ? { configUpdated: { detectedAdapters: persistedAdapters } } : {}),
558
+ verification,
559
+ timing: {
560
+ totalMs: Date.now() - t0,
561
+ walkMs: tWalkEnd - tWalkStart,
562
+ llmMs: tLlmEnd - tWalkEnd,
563
+ embedMs: tEmbedEnd - tLlmEnd,
564
+ ftsMs: tFtsEnd - tEmbedEnd,
565
+ finalizeMs: tFinalizeEnd - tFinalizeStart,
566
+ cleanMs: clean ? cleanEnd - cleanStart : 0,
567
+ preflightMs: t0 - requestedAt,
568
+ sourceCacheMs: sourceCacheEnd - sourceCacheStart,
569
+ endToEndMs: Date.now() - requestedAt,
570
+ },
571
+ ...(cleanResult !== undefined ? { clean: cleanResult } : {}),
572
+ };
573
+ }
574
+ finally {
575
+ closeDatabase(db);
576
+ }
702
577
  }
703
578
  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",
715
- });
716
- });
717
- return owners;
579
+ const components = sourceComponents(sources);
580
+ return new Map(sources.map((source, index) => {
581
+ const { id, adapter } = components[index];
582
+ return [path.resolve(source.path), { bundleId: id, componentId: id, adapterId: adapter }];
583
+ }));
584
+ }
585
+ /**
586
+ * The metadata-enrichment cache's body hash (`llm_enrichment_cache`, keyed by
587
+ * item ref + this hash): the entry file's text, else its name and description.
588
+ */
589
+ function enrichmentBodyHash(entry, fileContent) {
590
+ return computeBodyHash(fileContent ?? `${entry.name}\n${entry.description ?? ""}`);
718
591
  }
719
592
  /** Read-only mirror of the enrichment cache gate used before entry persistence. */
720
593
  function dirRecordsNeedMetadataDispatch(db, records, ownersByRoot) {
@@ -740,7 +613,7 @@ function dirRecordsNeedMetadataDispatch(db, records, ownersByRoot) {
740
613
  catch {
741
614
  // The dispatch path uses the same deterministic metadata fallback.
742
615
  }
743
- const bodyHash = computeBodyHash(fileContent ?? `${entry.name}\n${entry.description ?? ""}`);
616
+ const bodyHash = enrichmentBodyHash(entry, fileContent);
744
617
  const cacheKey = deriveEntryProvenance(owner, entry.type, entry.name, adapterConceptId).itemRef;
745
618
  const cached = getLlmCacheEntry(db, cacheKey, bodyHash);
746
619
  if (!cached)
@@ -762,32 +635,6 @@ function addEntryIds(target, ids) {
762
635
  for (const id of ids)
763
636
  target.add(id);
764
637
  }
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
638
  function groupFileContextsByDir(fileContexts) {
792
639
  const groups = new Map();
793
640
  for (const ctx of fileContexts) {
@@ -876,17 +723,19 @@ function warnIfAdapterSkipsAkmContent(component, files, adapter) {
876
723
  `${skippedDirs.map((dir) => `${dir}/`).join(", ")} — set components.<name>.adapter to "akm" to index them`);
877
724
  }
878
725
  function buildSourceScanPlans(db, allSourceEntries, isIncremental, reconcileMissingDirs) {
879
- const componentBySource = buildComponentBySource(allSourceEntries);
726
+ // Each source root → its durable `BundleComponent`; the per-dir document
727
+ // drain dispatches `adapterForId(component.adapter).recognize` for it.
728
+ const components = sourceComponents(allSourceEntries);
729
+ const componentBySource = new Map(allSourceEntries.map((source, index) => [source.path, components[index]]));
880
730
  const handoffDirs = new Set();
881
731
  const plans = allSourceEntries.map((sourceAdded) => {
882
732
  const currentStashDir = sourceAdded.path;
883
- const component = componentForSource(componentBySource, currentStashDir);
733
+ const component = componentBySource.get(currentStashDir);
884
734
  if (sourceAdded.unresolved) {
885
735
  return {
886
736
  currentStashDir,
887
737
  component,
888
738
  adapter: undefined,
889
- indexVariant: undefined,
890
739
  dirGroups: new Map(),
891
740
  removals: [],
892
741
  walkComplete: false,
@@ -906,7 +755,6 @@ function buildSourceScanPlans(db, allSourceEntries, isIncremental, reconcileMiss
906
755
  currentStashDir,
907
756
  component,
908
757
  adapter,
909
- indexVariant: adapter ? `${adapter.id}@${adapter.version}` : undefined,
910
758
  dirGroups,
911
759
  removals: [],
912
760
  walkComplete: walked.complete,
@@ -931,9 +779,10 @@ function buildSourceScanPlans(db, allSourceEntries, isIncremental, reconcileMiss
931
779
  handoffDirs.add(resolvedDir);
932
780
  };
933
781
  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)) {
782
+ // Reconcile departed directories for every source that produced a
783
+ // trustworthy snapshot; a source that was not walked completely keeps its
784
+ // last-known-good rows.
785
+ if (reconcileMissingDirs) {
937
786
  const allIndexedDirsByBundle = !isIncremental ? new Map() : undefined;
938
787
  if (allIndexedDirsByBundle) {
939
788
  for (const entry of getAllEntries(db)) {
@@ -986,9 +835,6 @@ function buildSourceScanPlans(db, allSourceEntries, isIncremental, reconcileMiss
986
835
  * outside `db.transaction()` so the persist pass can be a single synchronous
987
836
  * transaction.
988
837
  */
989
- function reportSourceScanProgress(onProgress, processed, total, message) {
990
- onProgress?.({ phase: "scan", message, processed, total });
991
- }
992
838
  async function scanSourceDirs(db, allSourceEntries, isIncremental, builtAtMs, hadRemovedSources, onProgress, reconcileMissingDirs = true) {
993
839
  let scannedDirs = 0;
994
840
  let skippedDirs = 0;
@@ -999,7 +845,7 @@ async function scanSourceDirs(db, allSourceEntries, isIncremental, builtAtMs, ha
999
845
  const dirRecords = [];
1000
846
  let processedDirs = 0;
1001
847
  let priorDirsChanged = hadRemovedSources;
1002
- const reportScanProgress = (message) => reportSourceScanProgress(onProgress, processedDirs, allSourceEntries.length, message);
848
+ const reportScanProgress = (message) => onProgress?.({ phase: "scan", message, processed: processedDirs, total: allSourceEntries.length });
1003
849
  const reportDirDecision = (kind, dirPath, currentStashDir, reason, persistedRowCount) => {
1004
850
  if (!isVerbose())
1005
851
  return;
@@ -1020,46 +866,6 @@ async function scanSourceDirs(db, allSourceEntries, isIncremental, builtAtMs, ha
1020
866
  seenPaths.add(resolved);
1021
867
  return false;
1022
868
  };
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
869
  for (const plan of plans) {
1064
870
  const { currentStashDir, component, adapter, dirGroups, removals, walkComplete } = plan;
1065
871
  processedDirs++;
@@ -1079,7 +885,7 @@ async function scanSourceDirs(db, allSourceEntries, isIncremental, builtAtMs, ha
1079
885
  warn(`Skipping component "${component.id}": unknown adapter id "${component.adapter}".`);
1080
886
  continue;
1081
887
  }
1082
- const indexVariant = plan.indexVariant ?? `${adapter.id}@${adapter.version}`;
888
+ const indexVariant = `${adapter.id}@${adapter.version}`;
1083
889
  for (const removal of removals) {
1084
890
  dirRecords.push(removal);
1085
891
  scannedDirs++;
@@ -1129,17 +935,53 @@ async function scanSourceDirs(db, allSourceEntries, isIncremental, builtAtMs, ha
1129
935
  const drained = drainDirDocuments(adapter, component, ctxs);
1130
936
  if (drained.warnings.length)
1131
937
  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;
938
+ generatedCount += drained.entries.length;
939
+ const stash = drained.entries.length > 0 ? { entries: drained.entries } : null;
940
+ // Freshness is judged over the files the entries came from (every walked
941
+ // file when none was recognized).
942
+ const entryFiles = new Set(drained.entries.flatMap((entry) => (entry.filename ? [path.join(dirPath, entry.filename)] : [])));
943
+ const staleFiles = entryFiles.size > 0 ? [...entryFiles] : indexableFiles;
944
+ // Incremental freshness gate: consult the persisted dir state and record
945
+ // either a skip (unchanged + eligible) or a scan carrying the drained stash.
946
+ const previousState = getDirIndexState(db, dirPath, staleFiles, builtAtMs, indexVariant, fingerprint);
947
+ const { persistedRowCount } = previousState;
948
+ if (isIncremental &&
949
+ !forceScan &&
950
+ !previousState.stale &&
951
+ canUseIncrementalSkip(previousState, priorDirsChanged)) {
952
+ skippedDirs++;
953
+ const reason = previousState.reason;
954
+ dirRecords.push({
955
+ dirPath,
956
+ currentStashDir,
957
+ files: staleFiles,
958
+ fingerprint,
959
+ stash: null,
960
+ skip: true,
961
+ reason,
962
+ persistedRowCount,
963
+ indexVariant,
964
+ });
965
+ reportDirDecision("skip", dirPath, currentStashDir, reason, persistedRowCount);
966
+ continue;
1141
967
  }
1142
- recordFreshnessDecision(dirPath, currentStashDir, staleFiles, fingerprint, stash, drained.hashByFile, drained.conceptIdByFile, indexVariant, forceScan, walkComplete);
968
+ scannedDirs++;
969
+ priorDirsChanged = true;
970
+ const reason = isIncremental ? previousState.reason : { kind: "full-rebuild" };
971
+ dirRecords.push({
972
+ dirPath,
973
+ currentStashDir,
974
+ files: staleFiles,
975
+ fingerprint,
976
+ stash,
977
+ skip: false,
978
+ reason,
979
+ persistedRowCount,
980
+ hashByFile: drained.hashByFile,
981
+ conceptIdByFile: drained.conceptIdByFile,
982
+ indexVariant,
983
+ });
984
+ reportDirDecision("scan", dirPath, currentStashDir, reason, persistedRowCount);
1143
985
  }
1144
986
  }
1145
987
  return {
@@ -1161,90 +1003,22 @@ function requiresWorkflowSourcePreflight(ctxs) {
1161
1003
  }
1162
1004
  });
1163
1005
  }
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
1006
  /**
1198
1007
  * Phase 2 (sync): write all pre-generated scan records inside a single
1199
1008
  * transaction, returning the directories that still need LLM enrichment.
1200
1009
  */
1201
- function persistDirRecords(db, dirRecords, doFullDelete, warnings, sourceRoots, scanComplete, bundleByRoot) {
1010
+ function persistDirRecords(db, dirRecords, warnings, bundleByRoot) {
1202
1011
  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 };
1214
1012
  // Per-source dedup: the same logical asset can appear more than once within
1215
1013
  // one owning source, where source order still makes the first occurrence win.
1216
1014
  // The owner is part of the key so identical concepts in different bundles
1217
1015
  // remain distinct indexed rows.
1218
1016
  const indexedAssetIdentities = new Set();
1219
1017
  const deletedUsageEntryIds = new Set();
1018
+ const findPersisted = db.prepare("SELECT id, content_hash, file_path, adapter_id, json_extract(document_json, '$.quality') AS quality " +
1019
+ "FROM entries WHERE item_ref = ?");
1220
1020
  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) {
1021
+ for (const { dirPath, currentStashDir, files, fingerprint, stash, skip, reason, persistedRowCount, hashByFile, conceptIdByFile, indexVariant, remove, } of dirRecords) {
1248
1022
  const bundle = bundleByRoot.get(path.resolve(currentStashDir));
1249
1023
  if (!bundle)
1250
1024
  throw new Error(`Missing bundle provenance for indexed source ${currentStashDir}`);
@@ -1261,7 +1035,13 @@ function persistDirRecords(db, dirRecords, doFullDelete, warnings, sourceRoots,
1261
1035
  // row_count and the gate skips this directory before draining next
1262
1036
  // time. "unchanged-precheck" already matched the stored row.
1263
1037
  if (reason?.kind === "unchanged" && fingerprint) {
1264
- upsertIndexDirState(db, { dirPath, ...fingerprint, reason: reason.kind, rowCount: persistedRowCount });
1038
+ upsertIndexDirState(db, {
1039
+ dirPath,
1040
+ ...fingerprint,
1041
+ reason: reason.kind,
1042
+ rowCount: persistedRowCount,
1043
+ indexVariant,
1044
+ });
1265
1045
  }
1266
1046
  continue;
1267
1047
  }
@@ -1271,8 +1051,19 @@ function persistDirRecords(db, dirRecords, doFullDelete, warnings, sourceRoots,
1271
1051
  // durable `item_ref`, then prune only the departed rows below. Replaces the old
1272
1052
  // `deleteEntriesByDir` truncate-and-reinsert (which discarded ids).
1273
1053
  const keptItemRefs = new Set();
1054
+ // Per-file cursor: a directory is drained whole (one changed file
1055
+ // re-reads its siblings), but on an incremental run a sibling whose
1056
+ // content hash, path and adapter are unchanged since the last drain
1057
+ // under the same adapter variant is already persisted exactly as this
1058
+ // drain would persist it — including any LLM enrichment layered onto its
1059
+ // row — so it is neither rewritten nor re-enriched. `--full` re-persists
1060
+ // every entry.
1061
+ const sameVariant = reason?.kind !== "full-rebuild" &&
1062
+ indexVariant !== undefined &&
1063
+ getIndexDirState(db, dirPath)?.indexVariant === indexVariant;
1274
1064
  let persistedRows = 0;
1275
1065
  let dedupedRows = 0;
1066
+ const entriesToEnrich = [];
1276
1067
  if (stash) {
1277
1068
  const ownerIdentity = bundle.bundleId;
1278
1069
  for (const entry of stash.entries) {
@@ -1294,29 +1085,42 @@ function persistDirRecords(db, dirRecords, doFullDelete, warnings, sourceRoots,
1294
1085
  continue;
1295
1086
  }
1296
1087
  indexedAssetIdentities.add(identityKey);
1297
- const searchText = buildSearchText(entry);
1298
- const entryWithSize = attachFileSize(entry, entryPath);
1299
1088
  // content_hash = doc.hash from the drain, keyed by the recognized
1300
1089
  // file's path. A missing hash preserves the existing value on upsert.
1301
1090
  const contentHash = hashByFile?.get(entryPath);
1302
1091
  const provenance = deriveEntryProvenance(bundle, entry.type, entry.name, adapterConceptId);
1303
1092
  keptItemRefs.add(provenance.itemRef);
1304
- upsertEntry(db, entryPath, entryWithSize, searchText, provenance, contentHash);
1305
1093
  persistedRows++;
1094
+ const previous = sameVariant
1095
+ ? (findPersisted.get(provenance.itemRef) ?? undefined)
1096
+ : undefined;
1097
+ const unchanged = previous !== undefined &&
1098
+ contentHash !== undefined &&
1099
+ previous.content_hash === contentHash &&
1100
+ previous.file_path === entryPath &&
1101
+ previous.adapter_id === bundle.adapterId;
1102
+ if (unchanged) {
1103
+ // An unchanged row that was never enriched still wants the LLM
1104
+ // pass (the cache decides whether a call is needed).
1105
+ if (entry.quality === "generated" && previous.quality !== "enriched")
1106
+ entriesToEnrich.push(entry);
1107
+ continue;
1108
+ }
1109
+ upsertEntry(db, entryPath, withFileSize(entry, entryPath), provenance, contentHash);
1110
+ if (entry.quality === "generated")
1111
+ entriesToEnrich.push(entry);
1306
1112
  }
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 });
1113
+ // Only "generated" entries (never user-curated ones) are enriched.
1114
+ if (entriesToEnrich.length > 0) {
1115
+ dirsNeedingLlm.push({ dirPath, files, currentStashDir, stash: { entries: entriesToEnrich } });
1311
1116
  }
1312
1117
  }
1313
1118
  // Prune the departed rows: everything under this dir NOT re-upserted above
1314
1119
  // (files deleted, deduped away, or abstained on by the adapter). With
1315
1120
  // an empty kept-set this deletes every row for the dir — the exact net
1316
1121
  // 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
- }
1122
+ // (A source whose walk was incomplete never reaches this point.)
1123
+ addEntryIds(deletedUsageEntryIds, deleteEntriesByDirExceptRefs(db, dirPath, bundle.bundleId, keptItemRefs, { cleanupUsageEvents: false }));
1320
1124
  const persistedFingerprint = fingerprint ?? computeDirFingerprint(dirPath, files, indexVariant);
1321
1125
  const persistedReason = persistedRows === 0
1322
1126
  ? inferZeroRowReason(stash, reason, warnings, dirPath, dedupedRows)
@@ -1332,6 +1136,7 @@ function persistDirRecords(db, dirRecords, doFullDelete, warnings, sourceRoots,
1332
1136
  // must keep draining every run (as it did before the gate) until a
1333
1137
  // drain persists it without dedup. NULL keeps the gate closed.
1334
1138
  rowCount: dedupedRows === 0 ? persistedRows : undefined,
1139
+ indexVariant,
1335
1140
  });
1336
1141
  if (persistedRows === 0) {
1337
1142
  // Warn only when the dir had files that *could* produce entries (.md or
@@ -1352,30 +1157,12 @@ function persistDirRecords(db, dirRecords, doFullDelete, warnings, sourceRoots,
1352
1157
  }
1353
1158
  // Atomicity observation point: the new generation is fully written but
1354
1159
  // uncommitted, so it must still be invisible to other connections.
1355
- indexTransactionHook("records-persisted");
1160
+ indexTransactionHookForTests?.("records-persisted");
1356
1161
  });
1357
1162
  insertTransaction();
1358
1163
  deleteUsageEventsByEntryIds([...deletedUsageEntryIds]);
1359
1164
  return { dirsNeedingLlm };
1360
1165
  }
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
1166
  function indexedProvenanceForFile(db, filePath) {
1380
1167
  const row = db
1381
1168
  .prepare("SELECT item_ref AS itemRef, bundle_id AS bundleId, component_id AS componentId, " +
@@ -1392,7 +1179,7 @@ function indexedProvenanceForFile(db, filePath) {
1392
1179
  adapterId: row.adapterId,
1393
1180
  };
1394
1181
  }
1395
- async function enhanceDirsWithLlm(db, config, execution, dirsNeedingLlm, onProgress, signal, onNotices, lease) {
1182
+ async function enhanceDirsWithLlm(db, config, execution, dirsNeedingLlm, onProgress, signal, onNotices) {
1396
1183
  // The invocation owns one frozen symbolic selection. Summary reporting and
1397
1184
  // every enrichment dispatch consume this same snapshot.
1398
1185
  const llmRunner = execution.runner;
@@ -1405,39 +1192,15 @@ async function enhanceDirsWithLlm(db, config, execution, dirsNeedingLlm, onProgr
1405
1192
  let completedDirs = 0;
1406
1193
  let completedEntries = 0;
1407
1194
  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);
1195
+ const totalEntries = dirsNeedingLlm.reduce((sum, { stash }) => sum + stash.entries.filter((e) => e.quality === "generated" && !isEnrichmentComplete(e)).length, 0);
1418
1196
  // P3 — wall-clock budget for the enrichment pass. Defaults to the resolved
1419
1197
  // engine's timeoutMs (or 10 minutes if not set). Users can extend it via
1420
1198
  // `index.enrichment.timeoutMs` (or `index.defaults.timeoutMs`, or the
1421
1199
  // engine's own `engines.<name>.timeoutMs`) — no separate knob needed.
1422
1200
  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
- })();
1201
+ const enrichSignal = AbortSignal.any([signal, enrichDeadline].filter((s) => s !== undefined));
1439
1202
  if (totalEntries > 0) {
1440
- onProgress?.({
1203
+ onProgress({
1441
1204
  phase: "llm",
1442
1205
  message: `LLM enhancement starting for ${totalEntries} entr${totalEntries === 1 ? "y" : "ies"} ` +
1443
1206
  `across ${totalDirs} director${totalDirs === 1 ? "y" : "ies"} (concurrency ${getDefaultLlmConcurrency(llmRunner.connection)}).`,
@@ -1449,7 +1212,7 @@ async function enhanceDirsWithLlm(db, config, execution, dirsNeedingLlm, onProgr
1449
1212
  let configFailure;
1450
1213
  let lastProgressAt = Date.now();
1451
1214
  let heartbeatTimer;
1452
- if (totalEntries > 0 && onProgress) {
1215
+ if (totalEntries > 0) {
1453
1216
  heartbeatTimer = setInterval(() => {
1454
1217
  if (Date.now() - lastProgressAt < 15000)
1455
1218
  return;
@@ -1483,7 +1246,7 @@ async function enhanceDirsWithLlm(db, config, execution, dirsNeedingLlm, onProgr
1483
1246
  if (entriesToEnhance.length === 0)
1484
1247
  return undefined;
1485
1248
  currentDirLabel = path.relative(currentStashDir, dirPath) || ".";
1486
- onProgress?.({
1249
+ onProgress({
1487
1250
  phase: "llm",
1488
1251
  message: `Enhancing ${currentDirLabel} ` +
1489
1252
  `(${entriesToEnhance.length} entr${entriesToEnhance.length === 1 ? "y" : "ies"}).`,
@@ -1491,27 +1254,35 @@ async function enhanceDirsWithLlm(db, config, execution, dirsNeedingLlm, onProgr
1491
1254
  total: totalEntries,
1492
1255
  });
1493
1256
  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
- });
1257
+ const entryPathOf = (entry) => entry.filename ? path.join(dirPath, entry.filename) : files[0] || dirPath;
1258
+ const itemRefs = entriesToEnhance.map((entry) => indexedProvenanceForFile(db, entryPathOf(entry)).itemRef);
1499
1259
  let enhanced;
1500
1260
  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);
1261
+ enhanced = await enhanceEntriesWithLlm({
1262
+ llmRunner,
1263
+ entries: entriesToEnhance,
1264
+ itemRefs,
1265
+ files,
1266
+ db,
1267
+ config,
1268
+ summary,
1269
+ signal: enrichSignal,
1270
+ onNotices,
1271
+ onEntryDone: (event) => {
1272
+ completedEntries++;
1273
+ lastProgressAt = Date.now();
1274
+ onProgress({
1275
+ phase: "llm",
1276
+ message: `Enhanced ${completedEntries}/${totalEntries} entr${totalEntries === 1 ? "y" : "ies"}; ` +
1277
+ `${completedDirs}/${totalDirs} director${totalDirs === 1 ? "y" : "ies"} complete` +
1278
+ (event.entryName ? `; current ${event.entryName}` : "") +
1279
+ (currentDirLabel ? ` in ${currentDirLabel}` : "") +
1280
+ (event.outcome === "cache-hit" ? " (cache hit)" : ""),
1281
+ processed: completedEntries,
1282
+ total: totalEntries,
1283
+ });
1284
+ },
1285
+ });
1515
1286
  }
1516
1287
  catch (err) {
1517
1288
  if (err instanceof ConfigError) {
@@ -1523,16 +1294,15 @@ async function enhanceDirsWithLlm(db, config, execution, dirsNeedingLlm, onProgr
1523
1294
  // Re-upsert the enhanced entries in a single transaction so a crash
1524
1295
  // cannot leave half the entries updated and the rest stale.
1525
1296
  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);
1297
+ for (const entry of enhanced) {
1298
+ const entryPath = entryPathOf(entry);
1529
1299
  const provenance = indexedProvenanceForFile(db, entryPath);
1530
- upsertEntry(db, entryPath, attachFileSize(entry, entryPath), searchText, provenance);
1300
+ upsertEntry(db, entryPath, withFileSize(entry, entryPath), provenance);
1531
1301
  }
1532
1302
  })();
1533
1303
  completedDirs++;
1534
1304
  lastProgressAt = Date.now();
1535
- onProgress?.({
1305
+ onProgress({
1536
1306
  phase: "llm",
1537
1307
  message: `Completed ${completedDirs}/${totalDirs} director${totalDirs === 1 ? "y" : "ies"}; ` +
1538
1308
  `${completedEntries}/${totalEntries} entr${totalEntries === 1 ? "y" : "ies"} processed.`,
@@ -1553,7 +1323,7 @@ async function enhanceDirsWithLlm(db, config, execution, dirsNeedingLlm, onProgr
1553
1323
  if (heartbeatTimer)
1554
1324
  clearInterval(heartbeatTimer);
1555
1325
  }
1556
- if (deadlineHit) {
1326
+ if (enrichDeadline?.aborted) {
1557
1327
  warn("[akm] LLM enrichment budget exceeded. Re-run `akm index` to continue. Increase index.enrichment.timeoutMs for a larger budget.");
1558
1328
  }
1559
1329
  // Gate-closed (`skipped`) entries are not failures — exclude them so a
@@ -1574,119 +1344,70 @@ export function createEnrichmentDeadline(timeoutMs, totalEntries) {
1574
1344
  return perEntryTimeoutMs === null ? undefined : AbortSignal.timeout(perEntryTimeoutMs * Math.max(totalEntries, 1));
1575
1345
  }
1576
1346
  // ── 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
1347
  function buildIndexSummaryMessage(options) {
1589
1348
  const stashSourceLabel = options.sourcesCount === 1 ? "stash source" : "stash sources";
1590
- const semanticDetail = getSemanticSearchLabel(options.semanticSearchMode, options.embeddingProvider, options.vecAvailable);
1349
+ const semanticDetail = options.semanticSearchMode === "off" ? "disabled" : `${options.embeddingProvider} embeddings`;
1591
1350
  return `Starting ${options.mode} index (${options.sourcesCount} ${stashSourceLabel}, semantic search: ${semanticDetail}, LLM: ${options.llmEnabled ? "enabled" : "disabled"}).`;
1592
1351
  }
1593
1352
  function getEmbeddingProvider(embedding) {
1594
1353
  return isHttpUrl(embedding?.endpoint) ? "remote" : "local";
1595
1354
  }
1596
- function getSemanticSearchLabel(semanticSearchMode, embeddingProvider, vecAvailable) {
1597
- if (semanticSearchMode === "off")
1598
- return "disabled";
1599
- return `${embeddingProvider} embeddings, ${vecAvailable ? "sqlite-vec" : "JS fallback"}`;
1600
- }
1601
1355
  function verifyIndexState(db, config, embeddableEntries, embeddingResult) {
1602
1356
  const embeddingCount = getEmbeddingCount(db);
1603
- const vecAvailable = isVecAvailable(db);
1604
1357
  const embeddingProvider = getEmbeddingProvider(config.embedding);
1358
+ const verification = (ok, message, semanticSearchEnabled, semanticStatus, guidance) => ({
1359
+ ok,
1360
+ message,
1361
+ ...(guidance !== undefined ? { guidance } : {}),
1362
+ semanticSearchEnabled,
1363
+ semanticSearchMode: config.semanticSearchMode,
1364
+ semanticStatus,
1365
+ embeddingProvider,
1366
+ entryCount: embeddableEntries,
1367
+ embeddingCount,
1368
+ });
1369
+ const pendingStatus = config.semanticSearchMode === "off" ? "disabled" : "pending";
1605
1370
  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
- };
1371
+ return verification(true, "Index ready. No assets were found yet.", config.semanticSearchMode === "auto", pendingStatus);
1617
1372
  }
1618
1373
  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
- };
1374
+ return verification(true, "Keyword index ready. Semantic search is disabled.", false, "disabled");
1630
1375
  }
1631
1376
  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));
1377
+ return verification(true, `Semantic search ready (${embeddingCount}/${embeddableEntries} embeddings).`, true, "ready-js");
1681
1378
  }
1682
- return resolved.size > 0 ? [...resolved] : files;
1379
+ return verification(false, embeddingResult.message ??
1380
+ `Semantic search verification failed (${embeddingCount}/${embeddableEntries} embeddings available).`, true, "blocked", embeddingProvider === "remote"
1381
+ ? "Check your embedding endpoint and credentials, then retry `akm index --full --verbose`."
1382
+ : "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.");
1683
1383
  }
1684
- async function enhanceStashWithLlm(llmRunner, stash, files, summary, signal, db, itemRefs, akmConfig, onEntryDone, onNotices, lease) {
1384
+ /**
1385
+ * Enrich each entry through the LLM. `llm_enrichment_cache` (keyed by the
1386
+ * canonical item ref + file body hash) skips the call when the body is
1387
+ * unchanged.
1388
+ */
1389
+ async function enhanceEntriesWithLlm(args) {
1390
+ const { llmRunner, entries, itemRefs, files, db, config, summary, signal, onEntryDone, onNotices } = args;
1685
1391
  const { enhanceMetadata } = await import("../llm/metadata-enhance.js");
1686
- const { computeBodyHash, getLlmCacheEntry, upsertLlmCacheEntry } = await import("../storage/repositories/index-llm-cache-repository.js");
1392
+ // failureSamples is bounded to 3 items, so a linear scan is cheaper than a
1393
+ // parallel Set for membership checks (#177 review).
1394
+ const recordFailure = (message) => {
1395
+ if (summary.failureSamples.length < 3 && !summary.failureSamples.includes(message)) {
1396
+ summary.failureSamples.push(message);
1397
+ }
1398
+ };
1399
+ // The non-empty enrichment fields replace the entry's, and the entry is
1400
+ // marked enriched so later runs skip it (P2).
1401
+ const withEnrichment = (entry, metadata) => ({
1402
+ ...entry,
1403
+ ...(metadata.description ? { description: metadata.description } : {}),
1404
+ ...(metadata.searchHints?.length ? { searchHints: metadata.searchHints } : {}),
1405
+ ...(metadata.tags?.length ? { tags: metadata.tags } : {}),
1406
+ quality: "enriched",
1407
+ });
1687
1408
  let configFailure;
1688
- const results = await concurrentMap(stash.entries, async (entry, idx) => {
1689
- if (signal?.aborted)
1409
+ const results = await concurrentMap(entries, async (entry, idx) => {
1410
+ if (signal.aborted)
1690
1411
  return entry;
1691
1412
  summary.attempted++;
1692
1413
  try {
@@ -1702,36 +1423,23 @@ async function enhanceStashWithLlm(llmRunner, stash, files, summary, signal, db,
1702
1423
  warn(`Could not read file for LLM enrichment: ${entry.filename ?? entry.name}`);
1703
1424
  }
1704
1425
  }
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];
1426
+ const bodyHash = enrichmentBodyHash(entry, fileContent);
1427
+ const cacheKey = itemRefs[idx];
1710
1428
  if (!cacheKey)
1711
1429
  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
- }
1430
+ const cached = getLlmCacheEntry(db, cacheKey, bodyHash);
1431
+ if (cached) {
1432
+ try {
1433
+ const updated = withEnrichment(entry, JSON.parse(cached.resultJson));
1434
+ summary.succeeded++;
1435
+ onEntryDone({ entryName: entry.name, outcome: "cache-hit" });
1436
+ return updated;
1437
+ }
1438
+ catch {
1439
+ warn(`LLM enrichment cache entry corrupt for ${entry.name}; re-running enrichment`);
1732
1440
  }
1733
1441
  }
1734
- const outcome = await enhanceMetadata(llmRunner, entry, fileContent, signal, akmConfig, onNotices, lease);
1442
+ const outcome = await enhanceMetadata(llmRunner, entry, fileContent, signal, config, onNotices);
1735
1443
  if (outcome.status !== "enriched") {
1736
1444
  // Not a genuine LLM success: the gate was closed (`skipped`) or the
1737
1445
  // call errored/timed out (`failed`). Do NOT mark the entry enriched
@@ -1739,57 +1447,35 @@ async function enhanceStashWithLlm(llmRunner, stash, files, summary, signal, db,
1739
1447
  // entry into a permanent enrichment skip even though nothing was
1740
1448
  // enhanced. Surface failures honestly; stay silent on gated-off skips.
1741
1449
  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" });
1450
+ recordFailure(outcome.error ?? "metadata enrichment failed");
1451
+ onEntryDone({ entryName: entry.name, outcome: "failed" });
1747
1452
  }
1748
1453
  else {
1749
1454
  summary.skipped++;
1750
- onEntryDone?.({ entryName: entry.name, outcome: "skipped" });
1455
+ onEntryDone({ entryName: entry.name, outcome: "skipped" });
1751
1456
  }
1752
1457
  return entry;
1753
1458
  }
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
1459
  // An empty-but-successful response is still cached: the LLM was paid
1764
1460
  // 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
- }
1461
+ // only re-pay for the same no-op.
1462
+ const improvements = outcome.metadata;
1463
+ upsertLlmCacheEntry(db, cacheKey, bodyHash, JSON.stringify({
1464
+ description: improvements.description,
1465
+ searchHints: improvements.searchHints,
1466
+ tags: improvements.tags,
1467
+ }));
1777
1468
  summary.succeeded++;
1778
- onEntryDone?.({ entryName: entry.name, outcome: "llm" });
1779
- return updated;
1469
+ onEntryDone({ entryName: entry.name, outcome: "llm" });
1470
+ return withEnrichment(entry, improvements);
1780
1471
  }
1781
1472
  catch (err) {
1782
1473
  if (err instanceof ConfigError) {
1783
1474
  configFailure ??= err;
1784
1475
  return entry;
1785
1476
  }
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" });
1477
+ recordFailure(toErrorMessage(err));
1478
+ onEntryDone({ entryName: entry.name, outcome: "failed" });
1793
1479
  return entry;
1794
1480
  }
1795
1481
  },
@@ -1798,11 +1484,7 @@ async function enhanceStashWithLlm(llmRunner, stash, files, summary, signal, db,
1798
1484
  getDefaultLlmConcurrency(llmRunner.connection));
1799
1485
  if (configFailure)
1800
1486
  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 };
1487
+ return results.map((result, i) => result ?? entries[i]);
1806
1488
  }
1807
1489
  // ── lookup ─────────────────────────────────────────────────────────────────
1808
1490
  import { makeBundleRef } from "../core/asset/asset-ref.js";
@@ -1812,17 +1494,13 @@ async function resolveLookupSources() {
1812
1494
  const { resolveSourceEntries } = await import("./search/search-source.js");
1813
1495
  return resolveSourceEntries(undefined, loadConfig());
1814
1496
  }
1815
- function resolveLookupScope(bundle, sources) {
1816
- if (!bundle)
1817
- return { candidateSources: sources, qualified: false };
1818
- return { candidateSources: resolveSourcesForOrigin(bundle, sources), qualified: true };
1819
- }
1820
1497
  async function lookupBundleRefWithResolutionUsing(ref, openLookupDatabase) {
1821
1498
  const sources = await resolveLookupSources();
1822
1499
  if (sources.length === 0)
1823
1500
  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);
1501
+ const bundleBySourcePath = new Map(sourceComponents(sources).map((component, index) => [path.resolve(sources[index].path), component.id]));
1502
+ const qualified = Boolean(ref.bundle);
1503
+ const candidateSources = ref.bundle ? resolveSourcesForOrigin(ref.bundle, sources) : sources;
1826
1504
  if (candidateSources.length === 0)
1827
1505
  return { entry: null };
1828
1506
  let db;
@@ -1833,6 +1511,11 @@ async function lookupBundleRefWithResolutionUsing(ref, openLookupDatabase) {
1833
1511
  catch (error) {
1834
1512
  indexError = error;
1835
1513
  }
1514
+ const resolved = (entry, owner) => ({
1515
+ entry,
1516
+ ...(owner ? { owner } : {}),
1517
+ ...(indexError === undefined ? {} : { indexError }),
1518
+ });
1836
1519
  try {
1837
1520
  for (const source of candidateSources) {
1838
1521
  const adapterId = source.adapterId ?? detectAdapterId(source.path);
@@ -1855,21 +1538,21 @@ async function lookupBundleRefWithResolutionUsing(ref, openLookupDatabase) {
1855
1538
  if (!(error instanceof WorkflowSourceIdentityError))
1856
1539
  throw error;
1857
1540
  warn(`${error.message} Falling back to the physical owner.`);
1858
- return { entry: null, owner, ...(indexError === undefined ? {} : { indexError }) };
1541
+ return resolved(null, owner);
1859
1542
  }
1860
1543
  }
1861
1544
  else if (entry.adapterId !== adapterId || !indexedPathMatchesOwner(entry.filePath, owner)) {
1862
- return { entry: null, owner, ...(indexError === undefined ? {} : { indexError }) };
1545
+ return resolved(null, owner);
1863
1546
  }
1864
- return { entry, owner, ...(indexError === undefined ? {} : { indexError }) };
1547
+ return resolved(entry, owner);
1865
1548
  }
1866
1549
  }
1867
1550
  // A physical owner with a missing/incomplete index row still owns this
1868
1551
  // unqualified concept. Stop here so a later source cannot retarget it.
1869
1552
  if (owner)
1870
- return { entry: null, owner, ...(indexError === undefined ? {} : { indexError }) };
1553
+ return resolved(null, owner);
1871
1554
  }
1872
- return { entry: null, ...(indexError === undefined ? {} : { indexError }) };
1555
+ return resolved(null);
1873
1556
  }
1874
1557
  finally {
1875
1558
  if (db)
@@ -1959,16 +1642,11 @@ const USAGE_EVENT_RETENTION_DAYS = 90;
1959
1642
  *
1960
1643
  * Called during `akm index` after FTS rebuild.
1961
1644
  */
1962
- export function recomputeUtilityScores(db, stateDb, options) {
1645
+ export function recomputeUtilityScores(db, stateDb) {
1963
1646
  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
1647
  // Purge stale usage events (90-day retention). usage_events lives in state.db
1970
1648
  // (Chunk-8 WI-8.3); its table is created by state migration 020.
1971
- purgeOldUsageEvents(stateDb, USAGE_EVENT_RETENTION_DAYS, { stateSchema });
1649
+ purgeOldUsageEvents(stateDb, USAGE_EVENT_RETENTION_DAYS);
1972
1650
  // Time-proportional decay: apply one round of EMA per elapsed day so
1973
1651
  // indexing frequency doesn't affect how fast scores decay.
1974
1652
  const lastComputedAt = getMeta(db, "last_utility_computed_at");
@@ -1999,7 +1677,7 @@ export function recomputeUtilityScores(db, stateDb, options) {
1999
1677
  ELSE NULL
2000
1678
  END
2001
1679
  ) AS last_used_at
2002
- FROM ${usageEvents} u
1680
+ FROM usage_events u
2003
1681
  WHERE u.entry_id IS NOT NULL
2004
1682
  AND u.source = 'user'
2005
1683
  GROUP BY u.entry_id