akm-cli 0.9.1 → 0.9.2-alpha.2

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 (350) hide show
  1. package/CHANGELOG.md +103 -28
  2. package/README.md +3 -1
  3. package/SECURITY.md +1 -1
  4. package/STABILITY.md +1 -1
  5. package/dist/akm +2 -2
  6. package/dist/akm-migrate +2 -2
  7. package/dist/assets/hints/cli-hints-full.md +14 -9
  8. package/dist/assets/improve-strategies/proactive-maintenance.json +1 -1
  9. package/dist/assets/improve-strategies/reflect-distill.json +1 -1
  10. package/dist/assets/models.json +35 -0
  11. package/dist/assets/stash-skeleton/facts/conventions/backlinks.md +3 -4
  12. package/dist/assets/stash-skeleton/facts/conventions/organization.md +1 -3
  13. package/dist/assets/tasks/core/extract.yml +6 -5
  14. package/dist/assets/tasks/core/improve.yml +6 -5
  15. package/dist/assets/tasks/core/index-refresh.yml +6 -5
  16. package/dist/assets/tasks/core/sync.yml +6 -5
  17. package/dist/assets/tasks/core/version-check.yml +6 -5
  18. package/dist/assets/tasks/improve/akm-graph-refresh-weekly.yml +6 -5
  19. package/dist/assets/tasks/improve/akm-improve-catchup.yml +6 -5
  20. package/dist/assets/tasks/improve/akm-improve-consolidate.yml +6 -5
  21. package/dist/assets/tasks/improve/akm-improve-frequent.yml +6 -5
  22. package/dist/assets/tasks/improve/akm-improve-nightly.yml +6 -5
  23. package/dist/cli/confirm.js +2 -2
  24. package/dist/cli/parse-args.js +3 -24
  25. package/dist/cli/retired-commands.js +1 -1
  26. package/dist/cli/shared.js +2 -2
  27. package/dist/cli.js +11 -9
  28. package/dist/commands/agent/agent-dispatch.js +55 -89
  29. package/dist/commands/agent/contribute-cli.js +12 -45
  30. package/dist/commands/command/builtin-action.js +32 -0
  31. package/dist/commands/command/command-cli.js +99 -0
  32. package/dist/commands/command/command-execution.js +308 -0
  33. package/dist/commands/command/execution-source-loader.js +176 -0
  34. package/dist/commands/command/portable-template.js +60 -0
  35. package/dist/commands/config-cli.js +10 -4
  36. package/dist/commands/env/env.js +4 -2
  37. package/dist/commands/feedback-cli.js +1 -1
  38. package/dist/commands/health/checks.js +241 -29
  39. package/dist/commands/health/html-report.js +0 -14
  40. package/dist/commands/health/report-view-model.js +0 -1
  41. package/dist/commands/health/surfaces.js +6 -7
  42. package/dist/commands/health/types.js +0 -2
  43. package/dist/commands/health.js +63 -18
  44. package/dist/commands/improve/collapse-detector.js +5 -6
  45. package/dist/commands/improve/consolidate.js +251 -214
  46. package/dist/commands/improve/distill/promote-memory.js +71 -34
  47. package/dist/commands/improve/distill/quality-gate.js +17 -5
  48. package/dist/commands/improve/distill.js +232 -155
  49. package/dist/commands/improve/eligibility.js +112 -79
  50. package/dist/commands/improve/execution.js +57 -0
  51. package/dist/commands/improve/extract-cli.js +5 -5
  52. package/dist/commands/improve/extract-prompt.js +64 -22
  53. package/dist/commands/improve/extract.js +608 -360
  54. package/dist/commands/improve/improve-strategies.js +43 -14
  55. package/dist/commands/improve/improve.js +249 -29
  56. package/dist/commands/improve/loop-stages.js +11 -17
  57. package/dist/commands/improve/memory/memory-contradiction-detect.js +90 -66
  58. package/dist/commands/improve/outcome-loop.js +22 -38
  59. package/dist/commands/improve/planner.js +134 -0
  60. package/dist/commands/improve/preparation.js +730 -409
  61. package/dist/commands/improve/reflect.js +386 -223
  62. package/dist/commands/improve/run-context.js +3 -4
  63. package/dist/commands/improve/salience.js +6 -58
  64. package/dist/commands/improve/session-asset.js +12 -12
  65. package/dist/commands/lint/index.js +101 -29
  66. package/dist/commands/migrate-cli.js +11 -69
  67. package/dist/commands/migration-tool.js +6 -9
  68. package/dist/commands/models-cli.js +27 -0
  69. package/dist/commands/proposal/drain.js +258 -186
  70. package/dist/commands/proposal/proposal-cli.js +32 -10
  71. package/dist/commands/proposal/proposal.js +2 -5
  72. package/dist/commands/proposal/propose.js +192 -172
  73. package/dist/commands/proposal/repository.js +54 -91
  74. package/dist/commands/proposal/validators/proposal-validators.js +9 -7
  75. package/dist/commands/read/curate.js +53 -22
  76. package/dist/commands/read/registry-search.js +25 -9
  77. package/dist/commands/read/remember-cli.js +14 -2
  78. package/dist/commands/read/search.js +10 -4
  79. package/dist/commands/read/show.js +139 -153
  80. package/dist/commands/registry-cli.js +16 -7
  81. package/dist/commands/remember.js +33 -18
  82. package/dist/commands/sources/add-cli.js +19 -178
  83. package/dist/commands/sources/bundle-cli.js +15 -3
  84. package/dist/commands/sources/dangerous-env-audit.js +135 -0
  85. package/dist/commands/sources/info.js +2 -1
  86. package/dist/commands/sources/installed-stashes.js +901 -177
  87. package/dist/commands/sources/schema-repair.js +174 -95
  88. package/dist/commands/sources/self-update.js +30 -74
  89. package/dist/commands/sources/source-add.js +3 -5
  90. package/dist/commands/sources/sources-cli.js +2 -15
  91. package/dist/commands/sources/update-transaction.js +220 -0
  92. package/dist/commands/tasks/tasks-cli.js +3 -3
  93. package/dist/commands/tasks/tasks.js +736 -317
  94. package/dist/commands/workflow-cli.js +2 -2
  95. package/dist/core/adapter/adapters/agent-skills-adapter.js +3 -0
  96. package/dist/core/adapter/adapters/akm-adapter.js +85 -35
  97. package/dist/core/adapter/adapters/akm-lint.js +54 -39
  98. package/dist/core/adapter/adapters/akm-metadata.js +45 -45
  99. package/dist/core/adapter/adapters/akm-task-adapter.js +32 -49
  100. package/dist/core/adapter/adapters/akm-workflow-adapter.js +38 -23
  101. package/dist/core/adapter/adapters/dotenv-adapter.js +30 -1
  102. package/dist/core/adapter/adapters/generic-files-adapter.js +11 -0
  103. package/dist/core/adapter/adapters/index.js +0 -9
  104. package/dist/core/adapter/adapters/llm-wiki-adapter.js +4 -0
  105. package/dist/core/adapter/adapters/okf-adapter.js +4 -0
  106. package/dist/core/adapter/adapters/opencode-adapter.js +5 -8
  107. package/dist/core/adapter/adapters/tool-dir-shared.js +63 -6
  108. package/dist/core/adapter/adapters/website-snapshot-adapter.js +4 -0
  109. package/dist/core/adapter/execution-source.js +308 -0
  110. package/dist/core/adapter/recognize-match.js +36 -13
  111. package/dist/core/adapter/registry.js +0 -9
  112. package/dist/core/asset/stash-meta.js +94 -4
  113. package/dist/core/common.js +6 -11
  114. package/dist/core/config/config-io.js +3 -3
  115. package/dist/core/config/config-schema.js +18 -40
  116. package/dist/core/config/config-sources.js +11 -21
  117. package/dist/core/config/config-walker.js +31 -13
  118. package/dist/core/config/config.js +23 -26
  119. package/dist/core/config/schema/engines.js +8 -7
  120. package/dist/core/config/schema/improve-processes.js +29 -5
  121. package/dist/core/config/schema/index-config.js +0 -27
  122. package/dist/core/config/schema/primitives.js +1 -23
  123. package/dist/core/config/schema/sources-bundles.js +13 -16
  124. package/dist/core/errors.js +2 -0
  125. package/dist/core/events.js +68 -32
  126. package/dist/core/extra-params.js +1 -0
  127. package/dist/core/improve-result.js +315 -0
  128. package/dist/core/lesson-lint.js +0 -6
  129. package/dist/core/maintenance-barrier.js +4 -4
  130. package/dist/core/network-policy.js +152 -0
  131. package/dist/core/paths.js +1 -1
  132. package/dist/core/recognition-util.js +4 -4
  133. package/dist/core/registry-url.js +456 -0
  134. package/dist/core/state/migrations.js +161 -47
  135. package/dist/core/state-db.js +453 -80
  136. package/dist/core/system-error.js +32 -0
  137. package/dist/core/time.js +2 -12
  138. package/dist/core/write-source.js +0 -18
  139. package/dist/execution/directory-identity.js +52 -0
  140. package/dist/execution/executable-identity.js +107 -0
  141. package/dist/execution/guarded-source.js +398 -0
  142. package/dist/execution/json.js +95 -0
  143. package/dist/{commands/health/types-session-log.js → execution/limits.js} +2 -1
  144. package/dist/execution/record.js +55 -0
  145. package/dist/execution/resolved-request.js +730 -0
  146. package/dist/execution/source.js +320 -0
  147. package/dist/indexer/bundle-identity-guard.js +5 -4
  148. package/dist/indexer/db/graph-db.js +33 -0
  149. package/dist/indexer/graph/graph-boost.js +3 -4
  150. package/dist/indexer/graph/graph-extraction.js +562 -373
  151. package/dist/indexer/index-written-assets.js +78 -39
  152. package/dist/indexer/indexer.js +471 -432
  153. package/dist/indexer/installations.js +6 -0
  154. package/dist/indexer/lookup/adapter-concept-owner.js +283 -0
  155. package/dist/indexer/materialize-embeddings.js +155 -0
  156. package/dist/indexer/passes/memory-inference.js +227 -174
  157. package/dist/indexer/passes/metadata.js +263 -118
  158. package/dist/indexer/scan/doc-to-entry.js +7 -10
  159. package/dist/indexer/scan/drain-dir.js +51 -23
  160. package/dist/indexer/search/db-search.js +156 -50
  161. package/dist/indexer/search/fts-query.js +40 -40
  162. package/dist/indexer/search/ranking.js +36 -1
  163. package/dist/indexer/search/search-attribution.js +3 -1
  164. package/dist/indexer/search/search-fields.js +23 -14
  165. package/dist/indexer/search/search-hit-enrichers.js +1 -1
  166. package/dist/indexer/search/search-source.js +7 -16
  167. package/dist/indexer/search/semantic-status.js +10 -1
  168. package/dist/indexer/usage/show-usage.js +105 -0
  169. package/dist/indexer/usage/usage-events.js +7 -2
  170. package/dist/indexer/walk/matchers.js +40 -10
  171. package/dist/indexer/walk/path-resolver.js +5 -2
  172. package/dist/indexer/walk/walker.js +20 -2
  173. package/dist/integrations/agent/builder-shared.js +3 -6
  174. package/dist/integrations/agent/conversation-fallback.js +16 -0
  175. package/dist/integrations/agent/engine-resolution.js +87 -87
  176. package/dist/integrations/agent/execution-cascade.js +566 -0
  177. package/dist/integrations/agent/execution-definitions.js +211 -0
  178. package/dist/integrations/agent/execution-lowering.js +811 -0
  179. package/dist/integrations/agent/execution-preparation.js +67 -0
  180. package/dist/integrations/agent/index.js +0 -2
  181. package/dist/integrations/agent/inline-execution.js +74 -0
  182. package/dist/integrations/agent/model-map.js +515 -0
  183. package/dist/integrations/agent/persona-fallback.js +30 -0
  184. package/dist/integrations/agent/request-lowering.js +186 -0
  185. package/dist/integrations/agent/runner-dispatch.js +230 -37
  186. package/dist/integrations/agent/runner.js +12 -83
  187. package/dist/integrations/harnesses/aider/agent-builder.js +8 -0
  188. package/dist/integrations/harnesses/aider/index.js +0 -1
  189. package/dist/integrations/harnesses/amazonq/agent-builder.js +8 -0
  190. package/dist/integrations/harnesses/amazonq/index.js +0 -1
  191. package/dist/integrations/harnesses/claude/agent-builder.js +14 -1
  192. package/dist/integrations/harnesses/claude/index.js +1 -5
  193. package/dist/integrations/harnesses/claude/session-log.js +3 -33
  194. package/dist/integrations/harnesses/codex/agent-builder.js +8 -0
  195. package/dist/integrations/harnesses/codex/index.js +0 -1
  196. package/dist/integrations/harnesses/copilot/agent-builder.js +8 -0
  197. package/dist/integrations/harnesses/copilot/index.js +0 -1
  198. package/dist/integrations/harnesses/gemini/agent-builder.js +8 -0
  199. package/dist/integrations/harnesses/gemini/index.js +0 -1
  200. package/dist/integrations/harnesses/index.js +4 -44
  201. package/dist/integrations/harnesses/opencode/agent-builder.js +16 -9
  202. package/dist/integrations/harnesses/opencode/index.js +0 -2
  203. package/dist/integrations/harnesses/opencode/session-log.js +14 -204
  204. package/dist/integrations/harnesses/opencode-sdk/harness.js +12 -1
  205. package/dist/integrations/harnesses/opencode-sdk/sdk-runner.js +40 -42
  206. package/dist/integrations/harnesses/openhands/agent-builder.js +8 -0
  207. package/dist/integrations/harnesses/openhands/index.js +0 -1
  208. package/dist/integrations/harnesses/pi/agent-builder.js +8 -0
  209. package/dist/integrations/harnesses/pi/index.js +0 -1
  210. package/dist/integrations/harnesses/shared.js +0 -1
  211. package/dist/integrations/harnesses/types.js +1 -3
  212. package/dist/integrations/lockfile.js +82 -79
  213. package/dist/integrations/session-logs/index.js +6 -17
  214. package/dist/integrations/session-logs/provider-base.js +1 -29
  215. package/dist/llm/client.js +10 -5
  216. package/dist/llm/embedder.js +6 -7
  217. package/dist/llm/embedders/local.js +37 -88
  218. package/dist/llm/embedders/types.js +1 -1
  219. package/dist/llm/graph-extract.js +75 -50
  220. package/dist/llm/index-passes.js +43 -5
  221. package/dist/llm/memory-infer.js +8 -6
  222. package/dist/llm/metadata-enhance.js +5 -3
  223. package/dist/llm/structured-call.js +122 -25
  224. package/dist/output/format-exempt.js +1 -1
  225. package/dist/output/render-registry.js +0 -16
  226. package/dist/output/renderers.js +12 -7
  227. package/dist/output/shapes/curate.js +1 -0
  228. package/dist/output/shapes/helpers.js +10 -2
  229. package/dist/output/shapes/passthrough.js +2 -0
  230. package/dist/output/text/command-format.js +31 -33
  231. package/dist/output/text/health-format.js +1 -29
  232. package/dist/output/text/migrate.js +6 -56
  233. package/dist/output/text/proposal-format.js +16 -1
  234. package/dist/output/text/workflow-format.js +16 -0
  235. package/dist/registry/network.js +279 -0
  236. package/dist/registry/pinned-request-helper.js +247 -0
  237. package/dist/registry/pinned-transport.js +717 -0
  238. package/dist/registry/providers/skills-sh.js +18 -6
  239. package/dist/registry/providers/static-index.js +20 -7
  240. package/dist/registry/resolve.js +53 -28
  241. package/dist/scripts/akm-migrate-node.js +19334 -52269
  242. package/dist/scripts/akm-migrate.js +19270 -51612
  243. package/dist/setup/registry-stash-loader.js +64 -20
  244. package/dist/setup/semantic-assets.js +9 -34
  245. package/dist/setup/setup.js +12 -30
  246. package/dist/setup/source-identity.js +17 -0
  247. package/dist/setup/steps/sources.js +36 -15
  248. package/dist/setup/steps/tasks.js +39 -11
  249. package/dist/sources/providers/git-provider.js +3 -3
  250. package/dist/sources/providers/npm.js +2 -2
  251. package/dist/sources/providers/provider-utils.js +4 -3
  252. package/dist/sources/providers/website.js +11 -7
  253. package/dist/sources/snapshot-fetchers/host-guard.js +9 -136
  254. package/dist/sources/snapshot-fetchers/website-ingest.js +25 -109
  255. package/dist/sources/website-url.js +73 -0
  256. package/dist/storage/engines/sqlite-migrations.js +81 -26
  257. package/dist/storage/managed-db.js +27 -24
  258. package/dist/storage/repositories/events-repository.js +3 -0
  259. package/dist/storage/repositories/index-connection.js +42 -10
  260. package/dist/storage/repositories/index-entries-repository.js +203 -229
  261. package/dist/storage/repositories/index-entry-mapper.js +8 -12
  262. package/dist/storage/repositories/index-entry-schema.js +255 -0
  263. package/dist/storage/repositories/index-fts-repository.js +64 -71
  264. package/dist/storage/repositories/index-llm-cache-repository.js +8 -13
  265. package/dist/storage/repositories/index-meta-repository.js +0 -11
  266. package/dist/storage/repositories/index-schema.js +74 -350
  267. package/dist/storage/repositories/index-utility-repository.js +12 -17
  268. package/dist/storage/repositories/index-vec-repository.js +56 -7
  269. package/dist/storage/repositories/proposals-repository.js +4 -127
  270. package/dist/storage/repositories/registry-cache.js +2 -1
  271. package/dist/storage/repositories/task-history-repository.js +20 -40
  272. package/dist/storage/repositories/workflow-runs-repository.js +228 -129
  273. package/dist/storage/sqlite-read-snapshot.js +148 -0
  274. package/dist/tasks/backends/cron.js +170 -42
  275. package/dist/tasks/backends/index.js +1 -1
  276. package/dist/tasks/backends/launchd.js +787 -202
  277. package/dist/tasks/backends/schtasks.js +282 -83
  278. package/dist/tasks/embedded.js +7 -7
  279. package/dist/tasks/frozen-script.js +50 -0
  280. package/dist/tasks/resolve-akm-bin.js +5 -1
  281. package/dist/tasks/runner.js +239 -251
  282. package/dist/tasks/runtime-v3.js +281 -0
  283. package/dist/tasks/scheduler-binding.js +272 -0
  284. package/dist/tasks/scheduler-invocation.js +57 -43
  285. package/dist/tasks/scheduler-sync.js +654 -0
  286. package/dist/tasks/source-v3.js +752 -0
  287. package/dist/tasks/standalone-script-entry.js +5 -0
  288. package/dist/tasks/task-id.js +29 -0
  289. package/dist/workflows/authoring/authoring.js +15 -32
  290. package/dist/workflows/exec/dispatch-redaction.js +14 -8
  291. package/dist/workflows/exec/exec-unit.js +7 -28
  292. package/dist/workflows/exec/frozen-judge.js +57 -89
  293. package/dist/workflows/exec/lowering-notices.js +23 -0
  294. package/dist/workflows/exec/native-executor.js +301 -458
  295. package/dist/workflows/exec/param-secrets.js +4 -3
  296. package/dist/workflows/exec/run-workflow.js +26 -32
  297. package/dist/workflows/exec/step-work.js +105 -109
  298. package/dist/workflows/exec/unit-dispatch.js +103 -27
  299. package/dist/workflows/exec/unit-writer.js +3 -3
  300. package/dist/workflows/exec/worktree.js +2 -2
  301. package/dist/workflows/ir/compile.js +86 -72
  302. package/dist/workflows/ir/environment-v4.js +328 -0
  303. package/dist/workflows/ir/freeze-v4.js +122 -0
  304. package/dist/workflows/ir/plan-hash.js +13 -7
  305. package/dist/workflows/ir/schema-v4.js +525 -0
  306. package/dist/workflows/ir/schema.js +25 -284
  307. package/dist/workflows/ir/source-freeze-v4.js +506 -0
  308. package/dist/workflows/parser.js +27 -24
  309. package/dist/workflows/program/schema.js +1 -2
  310. package/dist/workflows/renderer.js +42 -29
  311. package/dist/workflows/resource-limits.js +4 -5
  312. package/dist/workflows/runtime/agent-identity.js +11 -13
  313. package/dist/workflows/runtime/plan-classifier.js +8 -8
  314. package/dist/workflows/runtime/runs.js +27 -43
  315. package/dist/workflows/runtime/workflow-asset-loader.js +45 -205
  316. package/dist/workflows/source-files.js +373 -0
  317. package/dist/workflows/source-ir/compile.js +196 -0
  318. package/dist/workflows/source-ir/github-yaml.js +577 -0
  319. package/dist/workflows/source-ir/ordering.js +38 -0
  320. package/dist/workflows/source-ir/program.js +50 -0
  321. package/dist/workflows/source-ir/result.js +26 -0
  322. package/dist/workflows/source-ir/schema.js +772 -0
  323. package/dist/workflows/source-ir/semantics.js +242 -0
  324. package/dist/workflows/source-ir/uses.js +14 -0
  325. package/docs/README.md +2 -0
  326. package/docs/migration/README.md +3 -1
  327. package/docs/migration/release-notes/0.9.2.md +55 -0
  328. package/docs/migration/release-notes/README.md +5 -0
  329. package/docs/migration/v0.8-to-v0.9.md +76 -1077
  330. package/docs/migration/v0.9.0-troubleshooting.md +104 -516
  331. package/docs/migration/v0.9.1-to-v0.9.2.md +150 -0
  332. package/docs/reference/README.md +1 -0
  333. package/docs/reference/cli.md +230 -98
  334. package/docs/reference/configuration.md +159 -36
  335. package/docs/reference/data-and-telemetry.md +19 -1
  336. package/docs/reference/supported-formats.md +23 -3
  337. package/docs/reference/tasks.md +182 -0
  338. package/docs/reference/workflow-schema.md +91 -40
  339. package/docs/reference/workflows.md +33 -6
  340. package/package.json +10 -6
  341. package/schemas/akm-config.json +372 -224
  342. package/schemas/akm-task.json +324 -80
  343. package/schemas/akm-workflow.json +6 -9
  344. package/dist/core/migration-operation.js +0 -75
  345. package/dist/integrations/agent/model-aliases.js +0 -74
  346. package/dist/tasks/parser.js +0 -380
  347. package/dist/tasks/schema.js +0 -123
  348. package/dist/tasks/validator.js +0 -80
  349. package/dist/workflows/ir/freeze.js +0 -320
  350. package/dist/workflows/runtime/document-cache.js +0 -13
@@ -7,32 +7,47 @@ import { detectAdapterId } from "../core/adapter/detect-adapter.js";
7
7
  import { adapterForId } from "../core/adapter/registry.js";
8
8
  import { isHttpUrl, toErrorMessage } from "../core/common.js";
9
9
  import { concurrentMap } from "../core/concurrent.js";
10
+ import { ConfigError } from "../core/errors.js";
10
11
  import { isLoopbackEndpoint } from "../core/loopback.js";
11
12
  import { classifyPathAccess, describeInaccessiblePath } from "../core/path-access.js";
12
13
  import { getDbPath } from "../core/paths.js";
13
14
  import { SCRIPT_EXTENSIONS } from "../core/recognition-util.js";
14
15
  import { withStateDb } from "../core/state-db.js";
15
16
  import { isVerbose, warn, warnVerbose } from "../core/warn.js";
16
- import { resolveIndexPassLLM } from "../llm/index-passes.js";
17
+ import { disposeLoweredExecutionDispatchLease, } from "../integrations/agent/execution-lowering.js";
18
+ import { isLlmFeatureEnabled } from "../llm/feature-gate.js";
19
+ import { resolveIndexPassExecution } from "../llm/index-passes.js";
20
+ import { preflightStructuredLlmRunner } from "../llm/structured-call.js";
17
21
  import { resolveSourcesForOrigin } from "../registry/origin-resolve.js";
18
- import { closeDatabase, openExistingDatabase, openIndexDatabase } from "../storage/repositories/index-connection.js";
19
- import { deleteEntriesByDirAndStash, deleteEntriesByDirExceptKeys, deleteEntriesByIds, deleteEntriesByStashDir, deleteUsageEventsByEntryIds, findEntryIdByRef, getAllEntries, getEmbeddableEntryCount, getEntryCount, getIndexedDirPathsByStashDir, getIndexedStashDirsByDir, relinkUsageEvents, upsertEntry, upsertWorkflowDocument, } from "../storage/repositories/index-entries-repository.js";
20
- import { rebuildFts } from "../storage/repositories/index-fts-repository.js";
21
- import { clearStaleCacheEntries } from "../storage/repositories/index-llm-cache-repository.js";
22
- import { deleteIndexDirState, deleteIndexDirStatesByStashDir, deleteMeta, getMeta, setMeta, upsertIndexDirState, } from "../storage/repositories/index-meta-repository.js";
22
+ import { closeDatabase, openExistingDatabase, openIndexDatabase, openReadonlyExistingDatabase, } from "../storage/repositories/index-connection.js";
23
+ import { deleteAllEntries, deleteEntriesByBundle, deleteEntriesByDirAndBundle, deleteEntriesByDirExceptRefs, deleteEntriesByIds, deleteUsageEventsByEntryIds, findEntryIdByRef, getAllEntries, getEmbeddableEntryCount, getEntryCount, getIndexedBundleIdsByDir, getIndexedDirPathsByBundleId, relinkUsageEvents, upsertEntry, } from "../storage/repositories/index-entries-repository.js";
24
+ import { clearStaleCacheEntries, computeBodyHash, getLlmCacheEntry, } from "../storage/repositories/index-llm-cache-repository.js";
25
+ import { deleteIndexDirState, getMeta, setMeta, upsertIndexDirState, } from "../storage/repositories/index-meta-repository.js";
23
26
  import { upsertUtilityScore } from "../storage/repositories/index-utility-repository.js";
24
- import { getAllEntriesForEmbedding, getEmbeddingCount, isVecAvailable, isVecFastPathReady, purgeEmbeddings, setVecFastPathReady, upsertEmbedding, warnIfVecMissing, } from "../storage/repositories/index-vec-repository.js";
25
- import { takeWorkflowDocument } from "../workflows/runtime/document-cache.js";
27
+ import { getEmbeddingCount, isVecAvailable, isVecFastPathReady, warnIfVecMissing, } from "../storage/repositories/index-vec-repository.js";
28
+ import { assertIndexedWorkflowSourceIdentity, WorkflowSourceIdentityError } from "../workflows/source-files.js";
26
29
  import { deleteStoredGraph } from "./db/graph-db.js";
27
30
  import { withIndexWriterLease } from "./index-writer-lock.js";
28
31
  import { deriveEntryProvenance, deriveInstallations } from "./installations.js";
32
+ import { indexedPathMatchesOwner, resolveAdapterConceptOwner, } from "./lookup/adapter-concept-owner.js";
33
+ import { generateEmbeddingsForDb } from "./materialize-embeddings.js";
29
34
  import { canUseIncrementalSkip, computeDirFingerprint, getCachedZeroRowDirState, getDirIndexState, inferZeroRowReason, } from "./passes/dir-staleness.js";
30
35
  import { isEnrichmentComplete, isWorkflowSkipWarning } from "./passes/metadata.js";
31
36
  import { drainDirDocuments } from "./scan/drain-dir.js";
32
37
  import { buildSearchText } from "./search/search-fields.js";
33
- import { classifySemanticFailure, clearSemanticStatus, deriveSemanticProviderFingerprint, writeSemanticStatus, } from "./search/semantic-status.js";
38
+ import { clearSemanticStatus, deriveSemanticProviderFingerprint, writeSemanticStatus } from "./search/semantic-status.js";
34
39
  import { purgeOldUsageEvents } from "./usage/usage-events.js";
35
40
  import { walkStashFlatWithStatus } from "./walk/walker.js";
41
+ function collectLoweringNotices(target, notices) {
42
+ const keys = new Set(target.map((notice) => JSON.stringify(notice)));
43
+ for (const notice of notices) {
44
+ const key = JSON.stringify(notice);
45
+ if (keys.has(key))
46
+ continue;
47
+ keys.add(key);
48
+ target.push(notice);
49
+ }
50
+ }
36
51
  function throwIfAborted(signal) {
37
52
  if (signal?.aborted) {
38
53
  throw signal.reason instanceof Error ? signal.reason : new Error("index interrupted");
@@ -56,51 +71,67 @@ export function getDefaultLlmConcurrency(llmConfig) {
56
71
  // auto-derived 1/2 is what runs (see docs/architecture/internals/indexing.md).
57
72
  return 2;
58
73
  }
59
- // ── Phase functions ──────────────────────────────────────────────────────────
74
+ function sourceOwners(sources) {
75
+ const installations = deriveInstallations([...sources]);
76
+ return sources.flatMap((source, index) => {
77
+ const installation = installations[index];
78
+ return installation ? [{ bundleId: installation.id, sourceRoot: path.resolve(source.path) }] : [];
79
+ });
80
+ }
81
+ function parseStoredSourceOwners(raw) {
82
+ if (!raw)
83
+ return [];
84
+ try {
85
+ const parsed = JSON.parse(raw);
86
+ if (!Array.isArray(parsed) ||
87
+ parsed.some((owner) => typeof owner !== "object" ||
88
+ owner === null ||
89
+ typeof owner.bundleId !== "string" ||
90
+ typeof owner.sourceRoot !== "string")) {
91
+ warn("index_meta sourceOwners value is invalid — treating as empty");
92
+ return [];
93
+ }
94
+ return parsed.map((owner) => {
95
+ const stored = owner;
96
+ return { bundleId: stored.bundleId, sourceRoot: path.resolve(stored.sourceRoot) };
97
+ });
98
+ }
99
+ catch {
100
+ warn("index_meta sourceOwners value is corrupt JSON — treating as empty");
101
+ return [];
102
+ }
103
+ }
60
104
  /**
61
105
  * Source cache phase: ensure git stash caches are up to date and purge orphaned
62
106
  * entries from removed sources (incremental only).
63
107
  */
64
108
  async function runSourceCachePhase(ctx) {
65
- const { db, config, sourceDirs, isIncremental, full } = ctx;
109
+ const { db, isIncremental, full, sources } = ctx;
66
110
  if (isIncremental && !full) {
67
- // Purge entries from stash dirs that have been removed since the last run
68
- // (e.g. after `akm remove`) so orphaned entries don't linger.
69
- const prevStashDirsJson = getMeta(db, "stashDirs");
70
- if (prevStashDirsJson) {
71
- let prevStashDirs = [];
72
- try {
73
- const parsed = JSON.parse(prevStashDirsJson);
74
- if (Array.isArray(parsed)) {
75
- prevStashDirs = parsed.filter((d) => typeof d === "string");
76
- }
77
- else {
78
- warn("index_meta stashDirs value is not an array — treating as empty");
79
- }
80
- }
81
- catch {
82
- warn("index_meta stashDirs value is corrupt JSON — treating as empty");
83
- }
84
- const currentSet = new Set(sourceDirs);
85
- for (const dir of prevStashDirs) {
86
- if (!currentSet.has(dir)) {
87
- ctx.hadRemovedSources = true;
88
- ctx.removedSourceDirs.push(dir);
89
- }
111
+ const currentByBundle = new Map(sourceOwners(sources).map((owner) => [owner.bundleId, owner]));
112
+ for (const previous of parseStoredSourceOwners(getMeta(db, "sourceOwners"))) {
113
+ const current = currentByBundle.get(previous.bundleId);
114
+ if (!current || current.sourceRoot !== previous.sourceRoot) {
115
+ ctx.hadRemovedSources = true;
116
+ ctx.removedSources.push({
117
+ ...previous,
118
+ removeBundleEntries: current === undefined,
119
+ });
90
120
  }
91
121
  }
92
122
  }
93
123
  // Source caches are hydrated before akmIndex() calls this phase; nothing
94
124
  // further to do here. The flag is exposed on ctx for runWalkPhase().
95
- void config;
96
125
  }
97
126
  function applyRemovedSources(ctx) {
98
127
  if (!ctx.scanComplete)
99
128
  return;
100
- for (const dir of ctx.removedSourceDirs) {
101
- deleteEntriesByStashDir(ctx.db, dir);
102
- deleteIndexDirStatesByStashDir(ctx.db, dir);
103
- deleteStoredGraph(ctx.db, dir);
129
+ const currentRoots = new Set(sourceOwners(ctx.sources).map((owner) => owner.sourceRoot));
130
+ for (const removed of ctx.removedSources) {
131
+ if (removed.removeBundleEntries)
132
+ deleteEntriesByBundle(ctx.db, removed.bundleId);
133
+ if (!currentRoots.has(removed.sourceRoot))
134
+ deleteStoredGraph(ctx.db, removed.sourceRoot);
104
135
  }
105
136
  }
106
137
  /**
@@ -115,7 +146,14 @@ async function runWalkPhase(ctx) {
115
146
  throwIfAborted(signal);
116
147
  ctx.timing.tWalkStart = Date.now();
117
148
  const doFullDelete = full || !isIncremental;
118
- const { scannedDirs, skippedDirs, generatedCount, dirsNeedingLlm, warnings, complete } = await indexEntries(db, sources, isIncremental, builtAtMs, hadRemovedSources, doFullDelete, onProgress, !clean);
149
+ const { scannedDirs, skippedDirs, generatedCount, dirsNeedingLlm, warnings, complete } = await indexEntries(db, sources, isIncremental, builtAtMs, hadRemovedSources, doFullDelete, onProgress, !clean, async (dirRecords, ownersByRoot) => {
150
+ const runner = ctx.enrichmentExecution.runner;
151
+ if (runner &&
152
+ isLlmFeatureEnabled(config, "metadata_enhance") &&
153
+ dirRecordsNeedMetadataDispatch(db, dirRecords, ownersByRoot)) {
154
+ ctx.enrichmentLease = await preflightStructuredLlmRunner(runner);
155
+ }
156
+ });
119
157
  ctx.scannedDirs = scannedDirs;
120
158
  ctx.skippedDirs = skippedDirs;
121
159
  ctx.generatedCount = generatedCount;
@@ -142,10 +180,10 @@ async function runWalkPhase(ctx) {
142
180
  ctx.timing.tWalkEnd = Date.now();
143
181
  throwIfAborted(signal);
144
182
  // LLM enrichment for directories that need it
145
- await enhanceDirsWithLlm(db, config, dirsNeedingLlm, onProgress, signal);
183
+ await enhanceDirsWithLlm(db, config, ctx.enrichmentExecution, dirsNeedingLlm, onProgress, signal, (notices) => collectLoweringNotices(ctx.loweringNotices, notices), ctx.enrichmentLease);
146
184
  onProgress({
147
185
  phase: "llm",
148
- message: resolveIndexPassLLM("enrichment", config)
186
+ message: ctx.enrichmentExecution.runner
149
187
  ? `LLM enhancement reviewed ${dirsNeedingLlm.length} ${dirsNeedingLlm.length === 1 ? "directory" : "directories"}.`
150
188
  : "LLM enhancement disabled.",
151
189
  });
@@ -167,35 +205,41 @@ async function runEmbeddingPhase(ctx) {
167
205
  ctx.timing.tEmbedEnd = Date.now();
168
206
  }
169
207
  /**
170
- * Finalize phase: rebuild FTS, re-link usage events, recompute utility scores,
171
- * regenerate wiki indexes, update index metadata, and emit the verify event.
208
+ * Finalize phase: confirm transactionally materialized FTS state, re-link
209
+ * usage events, recompute utility scores, update index metadata, and emit the
210
+ * verify event.
172
211
  */
173
- async function runFinalizePhase(ctx) {
212
+ async function runFinalizePhase(ctx, deferredUpdateTransaction) {
174
213
  const { db, config, sources, sourceDirs, isIncremental, stashDir, signal, onProgress } = ctx;
175
214
  ctx.timing.tFinalizeStart = Date.now();
176
- // Rebuild FTS after all inserts. Use incremental mode when this whole
177
- // index run is incremental only entries touched by `upsertEntry`
178
- // since the last rebuild are re-indexed.
179
- rebuildFts(db, { incremental: isIncremental });
215
+ // `upsertEntry` and every canonical delete own their FTS projection. This is
216
+ // an observation point, not a second materialization pass.
180
217
  onProgress({
181
218
  phase: "fts",
182
- message: isIncremental ? "Rebuilt full-text search index (dirty rows only)." : "Rebuilt full-text search index.",
219
+ message: "Full-text search index is current.",
183
220
  });
184
221
  ctx.timing.tFtsEnd = Date.now();
185
- // Re-link detached usage_events and recompute utility scores. The one-time
186
- // §11.4 legacy→item_ref re-key is owned by the migration cutover
187
- // (020-three-db-cutover) now, so index finalize only re-resolves entry_ids
188
- // (idempotent) every stored `entry_ref` is already the item_ref spelling.
189
- //
190
- // Chunk-8 WI-8.3: usage_events lives in state.db now (index.db no longer holds
191
- // it), so these cross-DB passes take both handles — entries in `db` (index.db),
192
- // usage_events in the loaned state.db.
193
- withStateDb((stateDb) => {
222
+ // Re-link state.db usage events to the regenerated index and recompute the
223
+ // derived utility cache. Stored refs already use the current item-ref grammar,
224
+ // so this idempotent pass only restores derived entry ids.
225
+ const mutateState = (stateDb, stateSchema) => {
194
226
  onProgress({ phase: "finalize", message: "Relinking usage events." });
195
- relinkUsageEvents(db, stateDb, { sources, defaultStashDir: stashDir });
227
+ relinkUsageEvents(db, stateDb, { sources, defaultStashDir: stashDir, stateSchema });
196
228
  onProgress({ phase: "finalize", message: "Recomputing utility scores." });
197
- recomputeUtilityScores(db, stateDb);
198
- });
229
+ recomputeUtilityScores(db, stateDb, { stateSchema });
230
+ };
231
+ if (deferredUpdateTransaction) {
232
+ if (deferredUpdateTransaction.db !== db || !db.inTransaction) {
233
+ throw new Error("Source update index finalization requires its borrowed unified transaction.");
234
+ }
235
+ // state.db is ATTACHed to this same index connection before the outer
236
+ // BEGIN IMMEDIATE. Index and state mutations therefore share one SQLite
237
+ // commit/rollback decision rather than an unsafe two-connection ordering.
238
+ mutateState(db, deferredUpdateTransaction.stateSchema);
239
+ }
240
+ else {
241
+ withStateDb(mutateState);
242
+ }
199
243
  // Purge LLM cache entries for assets that no longer exist in the index.
200
244
  try {
201
245
  onProgress({ phase: "finalize", message: "Clearing stale LLM cache entries." });
@@ -213,22 +257,19 @@ async function runFinalizePhase(ctx) {
213
257
  setMeta(db, "builtAt", new Date().toISOString());
214
258
  setMeta(db, "stashDir", stashDir);
215
259
  setMeta(db, "stashDirs", JSON.stringify(sourceDirs));
260
+ setMeta(db, "sourceOwners", JSON.stringify(sourceOwners(sources)));
216
261
  }
217
262
  setMeta(db, "hasEmbeddings", embeddingResult.success ? "1" : "0");
218
- // Stash-organization conventions (SPEC-8): track which `index.indexBodyOpening`
219
- // state the index was built with, and warn while the flag diverges from it.
220
- const bodyOpeningWarning = reconcileBodyOpeningIndexState(db, config.index?.indexBodyOpening === true, (ctx.full || !isIncremental) && ctx.scanComplete);
221
- if (bodyOpeningWarning)
222
- warn(bodyOpeningWarning);
223
263
  warnIfVecMissing(db);
224
264
  const totalEntries = getEntryCount(db);
225
265
  const semanticEntryCount = getEmbeddableEntryCount(db);
226
266
  onProgress({ phase: "finalize", message: "Verifying semantic search state." });
227
267
  const verification = verifyIndexState(db, config, semanticEntryCount, embeddingResult);
228
- if (config.semanticSearchMode === "off") {
229
- clearSemanticStatus();
230
- }
231
- else {
268
+ const persistSemanticStatus = () => {
269
+ if (config.semanticSearchMode === "off") {
270
+ clearSemanticStatus();
271
+ return;
272
+ }
232
273
  writeSemanticStatus({
233
274
  status: verification.semanticStatus === "disabled" ? "pending" : verification.semanticStatus,
234
275
  ...(embeddingResult.reason ? { reason: embeddingResult.reason } : {}),
@@ -238,6 +279,12 @@ async function runFinalizePhase(ctx) {
238
279
  entryCount: verification.entryCount,
239
280
  embeddingCount: verification.embeddingCount,
240
281
  });
282
+ };
283
+ if (deferredUpdateTransaction) {
284
+ deferredUpdateTransaction.afterCommit = persistSemanticStatus;
285
+ }
286
+ else {
287
+ persistSemanticStatus();
241
288
  }
242
289
  onProgress({ phase: "verify", message: verification.message });
243
290
  // Store verification result and totalEntries on ctx for the caller to use
@@ -247,45 +294,10 @@ async function runFinalizePhase(ctx) {
247
294
  // suppress unused warning — sources was previously used inline
248
295
  void sources;
249
296
  }
250
- /**
251
- * Stash-organization conventions (SPEC-8): reconcile the `index.indexBodyOpening`
252
- * flag with the state the index was last FULLY built with (index_meta key
253
- * `indexBodyOpening`), returning a warning message while they diverge.
254
- *
255
- * Incremental runs re-extract only changed files (and embeddings are only
256
- * generated for rows lacking one), so a flag toggle leaves the index MIXED
257
- * until a full rebuild — `akm index --full` re-extracts every entry and wipes
258
- * embeddings so they regenerate from the new text. The warning therefore
259
- * repeats on every incremental run until a full walk records the flag state
260
- * as applied.
261
- *
262
- * A missing meta key on an incremental run means the index predates this
263
- * feature, i.e. it was necessarily built with the flag OFF — so the absent
264
- * key reads (and is seeded) as "0", never as the current flag value. This
265
- * keeps the most likely real toggle scenario — upgrade, enable the flag, run
266
- * a plain `akm index` — warning until `--full` runs (review finding).
267
- *
268
- * Exported for tests; production's only caller is the finalize phase above.
269
- */
270
- export function reconcileBodyOpeningIndexState(db, flagEnabled, isFullWalk) {
271
- const bodyOpeningFlag = flagEnabled ? "1" : "0";
272
- const prevBodyOpeningFlag = getMeta(db, "indexBodyOpening") ?? "0";
273
- // Only a full walk (which includes the first build ever) may record the
274
- // current flag as the applied state; incremental runs preserve — or, for a
275
- // pre-feature index, seed — the state of the last full build.
276
- setMeta(db, "indexBodyOpening", isFullWalk ? bodyOpeningFlag : prevBodyOpeningFlag);
277
- if (isFullWalk || prevBodyOpeningFlag === bodyOpeningFlag)
278
- return undefined;
279
- return (`index.indexBodyOpening is ${flagEnabled ? "enabled" : "disabled"} but the index was built with it ` +
280
- `${flagEnabled ? "disabled" : "enabled"}. Incremental runs only re-extract changed files, so ` +
281
- "indexed text and embeddings are stale for unchanged entries. Run `akm index --full` to apply the new " +
282
- "setting everywhere (embeddings regenerate), and re-mint collapse-detector canary baselines via " +
283
- "`bun scripts/refresh-canary-set.ts --refresh` if you use them.");
284
- }
285
297
  // ── Clean pass ───────────────────────────────────────────────────────────────
286
298
  /**
287
- * Post-index clean pass: scan the `entries` table for rows whose source file
288
- * no longer exists on disk and remove them (unless `dryRun` is true).
299
+ * Missing-file reconciliation: scan the `entries` table for rows whose source
300
+ * file no longer exists on disk and remove them (unless `dryRun` is true).
289
301
  *
290
302
  * Only rows with a non-empty `file_path` are checked — remote/virtual entries
291
303
  * that have no local path are always skipped.
@@ -299,7 +311,7 @@ export function reconcileBodyOpeningIndexState(db, flagEnabled, isFullWalk) {
299
311
  * reported instead.
300
312
  */
301
313
  function runCleanPass(db, dryRun) {
302
- const allEntries = db.prepare("SELECT id, entry_key AS ref, file_path AS path FROM entries").all();
314
+ const allEntries = db.prepare("SELECT id, item_ref AS ref, file_path AS path FROM entries").all();
303
315
  // Only check entries that have a non-empty local path (skip remote/virtual).
304
316
  const localEntries = allEntries.filter((e) => typeof e.path === "string" && e.path.trim() !== "");
305
317
  const missing = [];
@@ -335,9 +347,23 @@ export function _setAkmIndexForTests(fake) {
335
347
  akmIndexOverride = fake;
336
348
  }
337
349
  export async function akmIndex(options) {
338
- if (akmIndexOverride)
339
- return akmIndexOverride(options);
340
- return akmIndexReal(options);
350
+ try {
351
+ const override = akmIndexOverride;
352
+ return override ? await override(options) : await akmIndexReal(options);
353
+ }
354
+ catch (error) {
355
+ const updateDb = options.deferredUpdateTransaction?.db;
356
+ if (updateDb?.inTransaction) {
357
+ try {
358
+ updateDb.exec("ROLLBACK");
359
+ }
360
+ catch {
361
+ // Preserve the indexing error. The update coordinator will retry
362
+ // rollback before closing its borrowed unified handle.
363
+ }
364
+ }
365
+ throw error;
366
+ }
341
367
  }
342
368
  let indexTransactionHookForTests;
343
369
  /**
@@ -385,7 +411,7 @@ function detectAndPersistBundleAdapters(allSourceEntries, config, mutateConfig,
385
411
  }
386
412
  }
387
413
  const persistedAdapters = {};
388
- if (detectedByBundle.size === 0)
414
+ if (detectedByBundle.size === 0 || !opts.persist)
389
415
  return { config, persistedAdapters };
390
416
  const nextConfig = mutateConfig((current) => {
391
417
  if (!current.bundles)
@@ -418,6 +444,38 @@ function detectAndPersistBundleAdapters(allSourceEntries, config, mutateConfig,
418
444
  }
419
445
  return { config: nextConfig, persistedAdapters };
420
446
  }
447
+ function createIndexRunContext(options) {
448
+ const prevStashDir = getMeta(options.db, "stashDir");
449
+ const prevBuiltAt = getMeta(options.db, "builtAt");
450
+ const isIncremental = !options.full && prevStashDir === options.stashDir && !!prevBuiltAt;
451
+ const builtAtMs = isIncremental && prevBuiltAt ? new Date(prevBuiltAt).getTime() : 0;
452
+ const { t0, ...context } = options;
453
+ return {
454
+ ...context,
455
+ loweringNotices: [...options.enrichmentExecution.notices],
456
+ timing: {
457
+ t0,
458
+ tWalkStart: t0,
459
+ tWalkEnd: t0,
460
+ tLlmEnd: t0,
461
+ tFtsEnd: t0,
462
+ tEmbedEnd: t0,
463
+ tFinalizeStart: t0,
464
+ tFinalizeEnd: t0,
465
+ },
466
+ isIncremental,
467
+ builtAtMs,
468
+ hadRemovedSources: false,
469
+ removedSources: [],
470
+ scanComplete: true,
471
+ scannedDirs: 0,
472
+ skippedDirs: 0,
473
+ generatedCount: 0,
474
+ walkWarnings: [],
475
+ dirsNeedingLlm: [],
476
+ embeddingResult: null,
477
+ };
478
+ }
421
479
  async function akmIndexReal(options) {
422
480
  // R-022: `dryRun` only ever gated the `--clean` stale-entry removal pass
423
481
  // (see `runCleanPass` below) — every other phase (walk, LLM enrichment,
@@ -460,7 +518,8 @@ async function akmIndexReal(options) {
460
518
  // Durable state must be runtime-compatible before source hydration,
461
519
  // adapter persistence, or index.db creation can mutate the installation.
462
520
  onProgress({ phase: "preflight", message: "Validating durable state." });
463
- withStateDb(() => undefined);
521
+ if (!options.deferredUpdateTransaction)
522
+ withStateDb(() => undefined);
464
523
  // Ensure git stash caches are extracted before resolving stash dirs,
465
524
  // so their content directories exist on disk for the walker to discover.
466
525
  const sourceCacheStart = Date.now();
@@ -482,6 +541,7 @@ async function akmIndexReal(options) {
482
541
  const allSourceEntries = resolveSourceEntries(stashDir, config);
483
542
  const detected = detectAndPersistBundleAdapters(allSourceEntries, config, mutateConfig, {
484
543
  announce: options.implicit !== true,
544
+ persist: options.persistDetectedAdapters !== false,
485
545
  });
486
546
  config = detected.config;
487
547
  const persistedAdapters = detected.persistedAdapters;
@@ -491,20 +551,22 @@ async function akmIndexReal(options) {
491
551
  message: `Resolved ${allSourceDirs.length} stash source${allSourceDirs.length === 1 ? "" : "s"}.`,
492
552
  });
493
553
  const t0 = Date.now();
554
+ const enrichmentExecution = resolveIndexPassExecution("enrichment", config);
494
555
  // Open database — pass embedding dimension from config if available
495
556
  const dbPath = getDbPath();
496
557
  const embeddingDim = config.embedding?.dimension;
497
- const db = openIndexDatabase(dbPath, embeddingDim ? { embeddingDim } : undefined);
558
+ const borrowedUpdateDb = options.deferredUpdateTransaction?.db;
559
+ const db = borrowedUpdateDb ?? openIndexDatabase(dbPath, embeddingDim ? { embeddingDim } : undefined);
560
+ if (borrowedUpdateDb && !borrowedUpdateDb.inTransaction) {
561
+ throw new Error("Source update index requires an active borrowed index transaction.");
562
+ }
563
+ let indexRunContext;
498
564
  try {
499
- // Determine incremental vs full mode
500
- const prevStashDir = getMeta(db, "stashDir");
501
- const prevBuiltAt = getMeta(db, "builtAt");
502
- const isIncremental = !full && prevStashDir === stashDir && !!prevBuiltAt;
503
- const builtAtMs = isIncremental && prevBuiltAt ? new Date(prevBuiltAt).getTime() : 0;
504
565
  // Assemble the run context
505
- const ctx = {
566
+ const ctx = createIndexRunContext({
506
567
  db,
507
568
  config,
569
+ enrichmentExecution,
508
570
  sources: allSourceEntries,
509
571
  sourceDirs: allSourceDirs,
510
572
  full,
@@ -512,55 +574,31 @@ async function akmIndexReal(options) {
512
574
  stashDir,
513
575
  onProgress,
514
576
  signal,
515
- timing: {
516
- t0,
517
- tWalkStart: t0,
518
- tWalkEnd: t0,
519
- tLlmEnd: t0,
520
- tFtsEnd: t0,
521
- tEmbedEnd: t0,
522
- tFinalizeStart: t0,
523
- tFinalizeEnd: t0,
524
- },
525
- isIncremental,
526
- builtAtMs,
527
- hadRemovedSources: false,
528
- removedSourceDirs: [],
529
- scanComplete: true,
530
- scannedDirs: 0,
531
- skippedDirs: 0,
532
- generatedCount: 0,
533
- walkWarnings: [],
534
- dirsNeedingLlm: [],
535
- embeddingResult: null,
536
- };
577
+ t0,
578
+ });
579
+ indexRunContext = ctx;
537
580
  onProgress({
538
581
  phase: "summary",
539
582
  message: buildIndexSummaryMessage({
540
- mode: isIncremental ? "incremental" : "full",
583
+ mode: ctx.isIncremental ? "incremental" : "full",
541
584
  sourcesCount: allSourceDirs.length,
542
585
  semanticSearchMode: config.semanticSearchMode,
543
586
  embeddingProvider: getEmbeddingProvider(config.embedding),
544
- llmEnabled: !!resolveIndexPassLLM("enrichment", config),
587
+ llmEnabled: !!enrichmentExecution.runner,
545
588
  vecAvailable: isVecAvailable(db),
546
589
  }),
547
590
  });
591
+ let cleanResult;
592
+ let cleanStart = Date.now();
593
+ let cleanEnd = cleanStart;
548
594
  // ── Phase sequence ───────────────────────────────────────────────────────
549
595
  await runSourceCachePhase(ctx);
550
596
  await runWalkPhase(ctx);
551
597
  applyRemovedSources(ctx);
552
- await runEmbeddingPhase(ctx);
553
- await runFinalizePhase(ctx);
554
- // ────────────────────────────────────────────────────────────────────────
555
- // runFinalizePhase always populates these before returning.
556
- const verification = ctx.verification;
557
- const totalEntries = ctx.totalEntries;
558
- const { timing } = ctx;
559
- // ── Clean pass ───────────────────────────────────────────────────────────
560
- // After the normal index completes, remove entries whose source files no
561
- // longer exist on disk. Remote entries (empty file_path) are skipped.
562
- let cleanResult;
563
- const cleanStart = Date.now();
598
+ // Reconcile explicit missing-file cleanup before embeddings, totals, or
599
+ // verification describe this generation. Dry-run intentionally leaves
600
+ // the generation unchanged while still returning the previewed refs.
601
+ cleanStart = Date.now();
564
602
  if (clean) {
565
603
  onProgress({
566
604
  phase: "finalize",
@@ -574,17 +612,25 @@ async function akmIndexReal(options) {
574
612
  cleanResult = { checked: 0, removed: 0, removedRefs: [], dryRun };
575
613
  }
576
614
  }
577
- const cleanEnd = Date.now();
615
+ cleanEnd = Date.now();
616
+ await runEmbeddingPhase(ctx);
617
+ await runFinalizePhase(ctx, options.deferredUpdateTransaction);
578
618
  // ────────────────────────────────────────────────────────────────────────
619
+ // runFinalizePhase always populates these before returning.
620
+ const verification = ctx.verification;
621
+ const totalEntries = ctx.totalEntries;
622
+ const { timing } = ctx;
579
623
  return {
580
624
  stashDir,
581
625
  totalEntries,
582
626
  generatedMetadata: ctx.generatedCount,
583
627
  indexPath: dbPath,
584
- mode: isIncremental ? "incremental" : "full",
628
+ mode: ctx.isIncremental ? "incremental" : "full",
585
629
  directoriesScanned: ctx.scannedDirs,
586
630
  directoriesSkipped: ctx.skippedDirs,
631
+ scanComplete: ctx.scanComplete,
587
632
  ...(ctx.walkWarnings.length > 0 ? { warnings: ctx.walkWarnings } : {}),
633
+ ...(ctx.loweringNotices.length > 0 ? { notices: Object.freeze([...ctx.loweringNotices]) } : {}),
588
634
  ...(Object.keys(persistedAdapters).length > 0
589
635
  ? { configUpdated: { detectedAdapters: persistedAdapters } }
590
636
  : {}),
@@ -606,10 +652,69 @@ async function akmIndexReal(options) {
606
652
  };
607
653
  }
608
654
  finally {
609
- closeDatabase(db);
655
+ if (indexRunContext?.enrichmentLease) {
656
+ disposeLoweredExecutionDispatchLease(indexRunContext.enrichmentLease);
657
+ }
658
+ if (!borrowedUpdateDb)
659
+ closeDatabase(db);
610
660
  }
611
661
  });
612
662
  }
663
+ function buildIndexedSourceOwners(sources) {
664
+ const installations = deriveInstallations([...sources]);
665
+ const owners = new Map();
666
+ sources.forEach((source, index) => {
667
+ const installation = installations[index];
668
+ if (!installation)
669
+ return;
670
+ const component = installation.components[0];
671
+ owners.set(path.resolve(source.path), {
672
+ bundleId: installation.id,
673
+ componentId: component?.id ?? installation.id,
674
+ adapterId: component?.adapter ?? "akm",
675
+ });
676
+ });
677
+ return owners;
678
+ }
679
+ /** Read-only mirror of the enrichment cache gate used before entry persistence. */
680
+ function dirRecordsNeedMetadataDispatch(db, records, ownersByRoot) {
681
+ for (const record of records) {
682
+ if (record.skip || record.remove || !record.stash)
683
+ continue;
684
+ const owner = ownersByRoot.get(path.resolve(record.currentStashDir));
685
+ if (!owner)
686
+ throw new Error(`Missing bundle provenance for indexed source ${record.currentStashDir}`);
687
+ for (const entry of record.stash.entries) {
688
+ if (entry.quality !== "generated" || isEnrichmentComplete(entry))
689
+ continue;
690
+ const entryFile = entry.filename ? path.join(record.dirPath, entry.filename) : undefined;
691
+ if (!entryFile)
692
+ continue;
693
+ const adapterConceptId = record.conceptIdByFile?.get(entryFile);
694
+ if (!adapterConceptId)
695
+ continue;
696
+ let fileContent;
697
+ try {
698
+ fileContent = fs.readFileSync(entryFile, "utf8");
699
+ }
700
+ catch {
701
+ // The dispatch path uses the same deterministic metadata fallback.
702
+ }
703
+ const bodyHash = computeBodyHash(fileContent ?? `${entry.name}\n${entry.description ?? ""}`);
704
+ const cacheKey = deriveEntryProvenance(owner, entry.type, entry.name, adapterConceptId).itemRef;
705
+ const cached = getLlmCacheEntry(db, cacheKey, bodyHash);
706
+ if (!cached)
707
+ return true;
708
+ try {
709
+ JSON.parse(cached.resultJson);
710
+ }
711
+ catch {
712
+ return true;
713
+ }
714
+ }
715
+ }
716
+ return false;
717
+ }
613
718
  function removalsFirst(records) {
614
719
  return [...records.filter((record) => record.remove), ...records.filter((record) => !record.remove)];
615
720
  }
@@ -654,8 +759,8 @@ function groupFileContextsByDir(fileContexts) {
654
759
  }
655
760
  return groups;
656
761
  }
657
- function sourceSnapshotRemovals(db, currentStashDir, currentDirs, allIndexedDirsBySource) {
658
- const indexedDirs = allIndexedDirsBySource?.get(path.resolve(currentStashDir)) ?? getIndexedDirPathsByStashDir(db, currentStashDir);
762
+ function sourceSnapshotRemovals(db, currentStashDir, bundleId, currentDirs, allIndexedDirsByBundle) {
763
+ const indexedDirs = allIndexedDirsByBundle?.get(bundleId) ?? getIndexedDirPathsByBundleId(db, bundleId);
659
764
  return [...indexedDirs]
660
765
  .map((dirPath) => path.resolve(dirPath))
661
766
  .filter((dirPath) => !currentDirs.has(dirPath))
@@ -688,6 +793,9 @@ function buildSourceScanPlans(db, allSourceEntries, isIncremental, reconcileMiss
688
793
  }
689
794
  const walked = walkStashFlatWithStatus(currentStashDir, {
690
795
  includeAllDirectories: component.adapter === "okf",
796
+ ...(component.adapter === "akm" || component.adapter === "akm-workflow"
797
+ ? { workflowSymlinkAdapter: component.adapter }
798
+ : {}),
691
799
  });
692
800
  const dirGroups = groupFileContextsByDir(walked.files);
693
801
  const adapter = adapterForId(component.adapter);
@@ -723,20 +831,19 @@ function buildSourceScanPlans(db, allSourceEntries, isIncremental, reconcileMiss
723
831
  // A full, globally-complete run uses the atomic table wipe below. Every
724
832
  // other run reconciles only sources that produced trustworthy snapshots.
725
833
  if (reconcileMissingDirs && (isIncremental || !allComplete)) {
726
- const allIndexedDirsBySource = !isIncremental ? new Map() : undefined;
727
- if (allIndexedDirsBySource) {
834
+ const allIndexedDirsByBundle = !isIncremental ? new Map() : undefined;
835
+ if (allIndexedDirsByBundle) {
728
836
  for (const entry of getAllEntries(db)) {
729
- const sourceRoot = path.resolve(entry.stashDir);
730
- const dirs = allIndexedDirsBySource.get(sourceRoot) ?? new Set();
731
- dirs.add(path.resolve(entry.dirPath));
732
- allIndexedDirsBySource.set(sourceRoot, dirs);
837
+ const dirs = allIndexedDirsByBundle.get(entry.bundleId) ?? new Set();
838
+ dirs.add(path.dirname(path.resolve(entry.filePath)));
839
+ allIndexedDirsByBundle.set(entry.bundleId, dirs);
733
840
  }
734
841
  }
735
842
  for (const plan of plans) {
736
843
  if (!plan.walkComplete || !plan.adapter)
737
844
  continue;
738
845
  const currentDirs = new Set([...plan.dirGroups.keys()].map((dirPath) => path.resolve(dirPath)));
739
- for (const removal of sourceSnapshotRemovals(db, plan.currentStashDir, currentDirs, allIndexedDirsBySource)) {
846
+ for (const removal of sourceSnapshotRemovals(db, plan.currentStashDir, plan.component.id, currentDirs, allIndexedDirsByBundle)) {
740
847
  addRemoval(plan, removal.dirPath, removal.currentStashDir);
741
848
  }
742
849
  }
@@ -748,15 +855,18 @@ function buildSourceScanPlans(db, allSourceEntries, isIncremental, reconcileMiss
748
855
  // The first configured source that exposes a physical directory owns it.
749
856
  // Remove rows left by a prior owner even when both adapters are identical.
750
857
  const claimedDirs = new Set();
858
+ const sourcePathByBundle = new Map(plans.map((plan) => [plan.component.id, plan.currentStashDir]));
751
859
  for (const plan of plans) {
752
860
  for (const dirPath of plan.dirGroups.keys()) {
753
861
  const resolvedDir = path.resolve(dirPath);
754
862
  if (claimedDirs.has(resolvedDir))
755
863
  continue;
756
864
  claimedDirs.add(resolvedDir);
757
- for (const priorOwner of getIndexedStashDirsByDir(db, dirPath)) {
758
- if (path.resolve(priorOwner) !== path.resolve(plan.currentStashDir)) {
759
- addRemoval(plan, dirPath, priorOwner);
865
+ for (const priorOwnerBundle of getIndexedBundleIdsByDir(db, dirPath)) {
866
+ if (priorOwnerBundle !== plan.component.id) {
867
+ const priorOwnerPath = sourcePathByBundle.get(priorOwnerBundle);
868
+ if (priorOwnerPath)
869
+ addRemoval(plan, dirPath, priorOwnerPath);
760
870
  }
761
871
  }
762
872
  }
@@ -876,7 +986,7 @@ async function scanSourceDirs(db, allSourceEntries, isIncremental, builtAtMs, ha
876
986
  // abstains on its own bundle's walked files. The core walk keeps only the
877
987
  // universal hygiene `walkStashFlat` already applies (.git/dot-dirs/etc.).
878
988
  const indexableFiles = ctxs.map((ctx) => ctx.absPath);
879
- const forceScan = handoffDirs.has(path.resolve(dirPath));
989
+ const forceScan = handoffDirs.has(path.resolve(dirPath)) || requiresWorkflowSourcePreflight(ctxs);
880
990
  if (markSeenOrSkipDuplicate(dirPath, currentStashDir, indexableFiles))
881
991
  continue;
882
992
  if (indexableFiles.length === 0) {
@@ -931,6 +1041,16 @@ async function scanSourceDirs(db, allSourceEntries, isIncremental, builtAtMs, ha
931
1041
  complete: plans.every((plan) => plan.walkComplete && plan.adapter !== undefined),
932
1042
  };
933
1043
  }
1044
+ function requiresWorkflowSourcePreflight(ctxs) {
1045
+ return ctxs.some((ctx) => {
1046
+ try {
1047
+ return fs.lstatSync(ctx.absPath).isSymbolicLink();
1048
+ }
1049
+ catch {
1050
+ return true;
1051
+ }
1052
+ });
1053
+ }
934
1054
  function preserveExistingIndex(doFullDelete, dirRecords, sourceRoots) {
935
1055
  if (!doFullDelete)
936
1056
  return false;
@@ -992,36 +1112,28 @@ function persistDirRecords(db, dirRecords, doFullDelete, warnings, sourceRoots,
992
1112
  // transaction so delete and re-insert are atomic — a concurrent reader
993
1113
  // never observes an empty database between the two operations.
994
1114
  if (fullDelete) {
995
- try {
996
- db.exec("DELETE FROM embeddings");
997
- }
998
- catch {
999
- /* ignore */
1000
- }
1001
- if (isVecAvailable(db)) {
1002
- try {
1003
- db.exec("DELETE FROM entries_vec");
1004
- }
1005
- catch {
1006
- /* ignore */
1007
- }
1008
- }
1009
- db.exec("DELETE FROM entries_fts");
1010
- db.exec("DELETE FROM utility_scores");
1115
+ // Entries and every child materialization share one deletion authority.
1116
+ // Usage events live in state.db and survive so finalize can relink them
1117
+ // to the replacement generation's row ids.
1118
+ deleteAllEntries(db, { cleanupUsageEvents: false });
1011
1119
  db.exec("DELETE FROM index_dir_state");
1012
1120
  // Chunk-8 WI-8.3: usage_events lives in state.db now (not index.db), so the
1013
1121
  // wipe no longer detaches it here. The finalize pass's relinkUsageEvents
1014
1122
  // (cross-DB) nulls entry_ids that no longer resolve to a rebuilt entry and
1015
1123
  // re-resolves the rest by entry_ref — subsuming the old detach.
1016
- db.exec("DELETE FROM entries");
1017
1124
  // Atomicity observation point: inside the transaction the tables are now
1018
1125
  // empty, but no other connection may observe that. See
1019
1126
  // tests/integration/indexer/reindex-generation-atomicity.test.ts.
1020
1127
  indexTransactionHook("full-delete-applied");
1021
1128
  }
1022
1129
  for (const { dirPath, currentStashDir, files, stash, skip, reason, hashByFile, conceptIdByFile, indexVariant, remove, pruneMissing, } of dirRecords) {
1130
+ const bundle = bundleByRoot.get(path.resolve(currentStashDir));
1131
+ if (!bundle)
1132
+ throw new Error(`Missing bundle provenance for indexed source ${currentStashDir}`);
1023
1133
  if (remove) {
1024
- const removedIds = deleteEntriesByDirAndStash(db, dirPath, currentStashDir, { cleanupUsageEvents: false });
1134
+ const removedIds = deleteEntriesByDirAndBundle(db, dirPath, bundle.bundleId, {
1135
+ cleanupUsageEvents: false,
1136
+ });
1025
1137
  addEntryIds(deletedUsageEntryIds, removedIds);
1026
1138
  deleteIndexDirState(db, dirPath);
1027
1139
  continue;
@@ -1041,15 +1153,12 @@ function persistDirRecords(db, dirRecords, doFullDelete, warnings, sourceRoots,
1041
1153
  // Diff-persist (F4a M-core-2, spec §14.2): upsert the current file set
1042
1154
  // FIRST (ON CONFLICT preserving `entries.id` so embeddings / utility /
1043
1155
  // usage stay attached to unchanged rows), tracking every upserted
1044
- // `entry_key`, then prune only the DEPARTED rows below. Replaces the old
1156
+ // durable `item_ref`, then prune only the departed rows below. Replaces the old
1045
1157
  // `deleteEntriesByDir` truncate-and-reinsert (which discarded ids).
1046
- const keptEntryKeys = new Set();
1158
+ const keptItemRefs = new Set();
1047
1159
  let persistedRows = 0;
1048
1160
  let dedupedRows = 0;
1049
1161
  if (stash) {
1050
- const bundle = bundleByRoot.get(path.resolve(currentStashDir));
1051
- if (!bundle)
1052
- throw new Error(`Missing bundle provenance for indexed source ${currentStashDir}`);
1053
1162
  const ownerIdentity = bundle.bundleId;
1054
1163
  for (const entry of stash.entries) {
1055
1164
  const entryPath = entry.filename ? path.join(dirPath, entry.filename) : null;
@@ -1070,24 +1179,15 @@ function persistDirRecords(db, dirRecords, doFullDelete, warnings, sourceRoots,
1070
1179
  continue;
1071
1180
  }
1072
1181
  indexedAssetIdentities.add(identityKey);
1073
- const entryKey = bundle?.adapterId !== "akm" && adapterConceptId
1074
- ? `${currentStashDir}:concept:${adapterConceptId}`
1075
- : `${currentStashDir}:${entry.type}:${entry.name}`;
1076
- keptEntryKeys.add(entryKey);
1077
1182
  const searchText = buildSearchText(entry);
1078
1183
  const entryWithSize = attachFileSize(entry, entryPath);
1079
1184
  // content_hash = doc.hash from the drain, keyed by the recognized
1080
1185
  // file's path. A missing hash preserves the existing value on upsert.
1081
1186
  const contentHash = hashByFile?.get(entryPath);
1082
1187
  const provenance = deriveEntryProvenance(bundle, entry.type, entry.name, adapterConceptId);
1083
- const entryId = upsertEntry(db, entryKey, dirPath, entryPath, currentStashDir, entryWithSize, searchText, provenance, contentHash);
1188
+ keptItemRefs.add(provenance.itemRef);
1189
+ upsertEntry(db, entryPath, entryWithSize, searchText, provenance, contentHash);
1084
1190
  persistedRows++;
1085
- if (entry.type === "workflow") {
1086
- const doc = takeWorkflowDocument(entry);
1087
- if (doc) {
1088
- upsertWorkflowDocument(db, entryId, doc, fs.readFileSync(entryPath));
1089
- }
1090
- }
1091
1191
  }
1092
1192
  // Collect dirs needing LLM enhancement during the first walk.
1093
1193
  // Only dirs with "generated" entries need enrichment.
@@ -1100,7 +1200,7 @@ function persistDirRecords(db, dirRecords, doFullDelete, warnings, sourceRoots,
1100
1200
  // an empty kept-set this deletes every row for the dir — the exact net
1101
1201
  // effect of the old unconditional `deleteEntriesByDir`, minus the id churn.
1102
1202
  if (pruneMissing !== false) {
1103
- addEntryIds(deletedUsageEntryIds, deleteEntriesByDirExceptKeys(db, dirPath, currentStashDir, keptEntryKeys, { cleanupUsageEvents: false }));
1203
+ addEntryIds(deletedUsageEntryIds, deleteEntriesByDirExceptRefs(db, dirPath, bundle.bundleId, keptItemRefs, { cleanupUsageEvents: false }));
1104
1204
  }
1105
1205
  const fingerprint = computeDirFingerprint(dirPath, files, indexVariant);
1106
1206
  const persistedReason = persistedRows === 0
@@ -1139,32 +1239,21 @@ function persistDirRecords(db, dirRecords, doFullDelete, warnings, sourceRoots,
1139
1239
  deleteUsageEventsByEntryIds([...deletedUsageEntryIds]);
1140
1240
  return { dirsNeedingLlm };
1141
1241
  }
1142
- async function indexEntries(db, allSourceEntries, isIncremental, builtAtMs, hadRemovedSources, doFullDelete = false, onProgress, reconcileMissingDirs = true) {
1242
+ async function indexEntries(db, allSourceEntries, isIncremental, builtAtMs, hadRemovedSources, doFullDelete = false, onProgress, reconcileMissingDirs = true, beforePersist) {
1143
1243
  // Phase 1 (async): walk directories and pre-generate all metadata outside the
1144
1244
  // transaction.
1145
1245
  const { dirRecords, scannedDirs, skippedDirs, generatedCount, warnings, complete } = await scanSourceDirs(db, allSourceEntries, isIncremental, builtAtMs, hadRemovedSources, onProgress, reconcileMissingDirs);
1246
+ const bundleByRoot = buildIndexedSourceOwners(allSourceEntries);
1247
+ await beforePersist?.(dirRecords, bundleByRoot);
1146
1248
  // Phase 2 (sync): write all pre-generated metadata inside a single transaction.
1147
1249
  // Source roots feed the #624-P1 zero-document preflight (a full-rebuild wipe
1148
1250
  // is suppressed when the scan is empty because roots are unreadable).
1149
1251
  const sourceRoots = allSourceEntries.map((s) => s.path);
1150
- // Chunk-5 Step 2 (spec §14.4): map each source root → its durable bundle id so
1151
- // the writer can persist `item_ref = <bundle>//<conceptId>` and the component/
1152
- // adapter provenance alongside the legacy columns. `deriveInstallations`
1252
+ // Map each source root → its durable bundle id so the writer can persist
1253
+ // `item_ref = <bundle>//<conceptId>` and canonical component/adapter
1254
+ // provenance. `deriveInstallations`
1153
1255
  // preserves source order, so a positional zip yields the SAME bundle id the
1154
1256
  // dispatched `adapter.recognize` emits as `IndexDocument.ref` for that root.
1155
- const installations = deriveInstallations(allSourceEntries);
1156
- const bundleByRoot = new Map();
1157
- allSourceEntries.forEach((source, i) => {
1158
- const inst = installations[i];
1159
- if (!inst)
1160
- return;
1161
- const component = inst.components[0];
1162
- bundleByRoot.set(path.resolve(source.path), {
1163
- bundleId: inst.id,
1164
- componentId: component?.id ?? inst.id,
1165
- adapterId: component?.adapter ?? "akm",
1166
- });
1167
- });
1168
1257
  const { dirsNeedingLlm } = persistDirRecords(db, dirRecords, doFullDelete, warnings, sourceRoots, complete, bundleByRoot);
1169
1258
  return { scannedDirs, skippedDirs, generatedCount, warnings, dirsNeedingLlm, complete };
1170
1259
  }
@@ -1184,12 +1273,11 @@ function indexedProvenanceForFile(db, filePath) {
1184
1273
  adapterId: row.adapterId,
1185
1274
  };
1186
1275
  }
1187
- async function enhanceDirsWithLlm(db, config, dirsNeedingLlm, onProgress, signal) {
1188
- // Resolve per-pass LLM config via the unified shim. Returns undefined when
1189
- // either no `akm.llm` is configured or the user opted this pass out via
1190
- // `index.enrichment.llm = false`. (#208)
1191
- const llmConfig = resolveIndexPassLLM("enrichment", config);
1192
- if (!llmConfig || dirsNeedingLlm.length === 0)
1276
+ async function enhanceDirsWithLlm(db, config, execution, dirsNeedingLlm, onProgress, signal, onNotices, lease) {
1277
+ // The invocation owns one frozen symbolic selection. Summary reporting and
1278
+ // every enrichment dispatch consume this same snapshot.
1279
+ const llmRunner = execution.runner;
1280
+ if (!llmRunner || dirsNeedingLlm.length === 0)
1193
1281
  return;
1194
1282
  // Aggregate per-entry failures so a misconfigured LLM endpoint surfaces
1195
1283
  // as a single visible warning instead of silently degrading every entry
@@ -1212,7 +1300,7 @@ async function enhanceDirsWithLlm(db, config, dirsNeedingLlm, onProgress, signal
1212
1300
  // engine's timeoutMs (or 10 minutes if not set). Users can extend it via
1213
1301
  // `index.enrichment.timeoutMs` (or `index.defaults.timeoutMs`, or the
1214
1302
  // engine's own `engines.<name>.timeoutMs`) — no separate knob needed.
1215
- const enrichDeadline = createEnrichmentDeadline(llmConfig.timeoutMs, totalEntries);
1303
+ const enrichDeadline = createEnrichmentDeadline(llmRunner.timeoutMs, totalEntries);
1216
1304
  let deadlineHit = false;
1217
1305
  const enrichSignal = (() => {
1218
1306
  if (!enrichDeadline)
@@ -1233,12 +1321,13 @@ async function enhanceDirsWithLlm(db, config, dirsNeedingLlm, onProgress, signal
1233
1321
  onProgress?.({
1234
1322
  phase: "llm",
1235
1323
  message: `LLM enhancement starting for ${totalEntries} entr${totalEntries === 1 ? "y" : "ies"} ` +
1236
- `across ${totalDirs} director${totalDirs === 1 ? "y" : "ies"} (concurrency ${getDefaultLlmConcurrency(llmConfig)}).`,
1324
+ `across ${totalDirs} director${totalDirs === 1 ? "y" : "ies"} (concurrency ${getDefaultLlmConcurrency(llmRunner.connection)}).`,
1237
1325
  processed: 0,
1238
1326
  total: totalEntries,
1239
1327
  });
1240
1328
  }
1241
1329
  let currentDirLabel;
1330
+ let configFailure;
1242
1331
  let lastProgressAt = Date.now();
1243
1332
  let heartbeatTimer;
1244
1333
  if (totalEntries > 0 && onProgress) {
@@ -1284,30 +1373,42 @@ async function enhanceDirsWithLlm(db, config, dirsNeedingLlm, onProgress, signal
1284
1373
  });
1285
1374
  lastProgressAt = Date.now();
1286
1375
  const targetStash = { entries: entriesToEnhance };
1287
- const entryKeys = entriesToEnhance.map((e) => `${currentStashDir}:${e.type}:${e.name}`);
1288
- const enhanced = await enhanceStashWithLlm(llmConfig, targetStash, files, summary, enrichSignal, db, entryKeys, config, (event) => {
1289
- completedEntries++;
1290
- lastProgressAt = Date.now();
1291
- onProgress?.({
1292
- phase: "llm",
1293
- message: `Enhanced ${completedEntries}/${totalEntries} entr${totalEntries === 1 ? "y" : "ies"}; ` +
1294
- `${completedDirs}/${totalDirs} director${totalDirs === 1 ? "y" : "ies"} complete` +
1295
- (event.entryName ? `; current ${event.entryName}` : "") +
1296
- (currentDirLabel ? ` in ${currentDirLabel}` : "") +
1297
- (event.outcome === "cache-hit" ? " (cache hit)" : ""),
1298
- processed: completedEntries,
1299
- total: totalEntries,
1300
- });
1376
+ const itemRefs = entriesToEnhance.map((entry) => {
1377
+ const entryPath = entry.filename ? path.join(dirPath, entry.filename) : files[0] || dirPath;
1378
+ return indexedProvenanceForFile(db, entryPath).itemRef;
1301
1379
  });
1380
+ let enhanced;
1381
+ try {
1382
+ enhanced = await enhanceStashWithLlm(llmRunner, targetStash, files, summary, enrichSignal, db, itemRefs, config, (event) => {
1383
+ completedEntries++;
1384
+ lastProgressAt = Date.now();
1385
+ onProgress?.({
1386
+ phase: "llm",
1387
+ message: `Enhanced ${completedEntries}/${totalEntries} entr${totalEntries === 1 ? "y" : "ies"}; ` +
1388
+ `${completedDirs}/${totalDirs} director${totalDirs === 1 ? "y" : "ies"} complete` +
1389
+ (event.entryName ? `; current ${event.entryName}` : "") +
1390
+ (currentDirLabel ? ` in ${currentDirLabel}` : "") +
1391
+ (event.outcome === "cache-hit" ? " (cache hit)" : ""),
1392
+ processed: completedEntries,
1393
+ total: totalEntries,
1394
+ });
1395
+ }, onNotices, lease);
1396
+ }
1397
+ catch (err) {
1398
+ if (err instanceof ConfigError) {
1399
+ configFailure ??= err;
1400
+ return undefined;
1401
+ }
1402
+ throw err;
1403
+ }
1302
1404
  // Re-upsert the enhanced entries in a single transaction so a crash
1303
1405
  // cannot leave half the entries updated and the rest stale.
1304
1406
  db.transaction(() => {
1305
1407
  for (const entry of enhanced.entries) {
1306
1408
  const entryPath = entry.filename ? path.join(dirPath, entry.filename) : files[0] || dirPath;
1307
- const entryKey = `${currentStashDir}:${entry.type}:${entry.name}`;
1308
1409
  const searchText = buildSearchText(entry);
1309
1410
  const provenance = indexedProvenanceForFile(db, entryPath);
1310
- upsertEntry(db, entryKey, dirPath, entryPath, currentStashDir, attachFileSize(entry, entryPath), searchText, provenance);
1411
+ upsertEntry(db, entryPath, attachFileSize(entry, entryPath), searchText, provenance);
1311
1412
  }
1312
1413
  })();
1313
1414
  completedDirs++;
@@ -1325,7 +1426,9 @@ async function enhanceDirsWithLlm(db, config, dirsNeedingLlm, onProgress, signal
1325
1426
  // Studio, Ollama run one inference at a time — parallel requests cause
1326
1427
  // "Model reloaded" / 500 errors). No config override reaches this path:
1327
1428
  // `resolveLlmEngineUse` does not forward `engines.<name>.concurrency`.
1328
- getDefaultLlmConcurrency(llmConfig));
1429
+ getDefaultLlmConcurrency(llmRunner.connection));
1430
+ if (configFailure)
1431
+ throw configFailure;
1329
1432
  }
1330
1433
  finally {
1331
1434
  if (heartbeatTimer)
@@ -1351,136 +1454,6 @@ export function createEnrichmentDeadline(timeoutMs, totalEntries) {
1351
1454
  const perEntryTimeoutMs = timeoutMs === undefined ? 10 * 60 * 1000 : timeoutMs;
1352
1455
  return perEntryTimeoutMs === null ? undefined : AbortSignal.timeout(perEntryTimeoutMs * Math.max(totalEntries, 1));
1353
1456
  }
1354
- async function generateEmbeddingsForDb(db, config, onProgress, signal) {
1355
- throwIfAborted(signal);
1356
- if (config.semanticSearchMode === "off") {
1357
- onProgress({ phase: "embeddings", message: "Semantic search disabled; skipping embeddings." });
1358
- return { success: false, reason: "index-missing", message: "Semantic search is disabled." };
1359
- }
1360
- // Detect embedding model/provider changes and purge stale embeddings
1361
- // so that incremental reindex regenerates all vectors with the new model.
1362
- const currentFingerprint = deriveSemanticProviderFingerprint(config.embedding);
1363
- const storedFingerprint = getMeta(db, "embeddingFingerprint");
1364
- if (storedFingerprint && storedFingerprint !== currentFingerprint) {
1365
- // Model/provider changed → stored vectors are incompatible. Clear them;
1366
- // re-embedded by this index run.
1367
- //
1368
- // The vec table goes too. "Same dimension, so keep the vec table" only held
1369
- // for a same-width model swap: entries_vec is a vec0 virtual table declared
1370
- // at a FIXED width, so after a dimension-changing model change every insert
1371
- // failed against the old width, and ensureSchema's dim-change rebuild never
1372
- // fired because it only runs for callers that pass an explicit
1373
- // embeddingDim. The stale table survived `--full` — the exact remedy the
1374
- // warning recommended. Clearing the stored dim lets the next ensureSchema
1375
- // materialize it at the new width; until then the fast-path flag reads
1376
- // false (no table) and search uses the complete BLOB table.
1377
- purgeEmbeddings(db, { dropVecTable: true });
1378
- deleteMeta(db, "embeddingDim");
1379
- }
1380
- try {
1381
- const { embedBatch } = await import("../llm/embedder.js");
1382
- const { estimateTokenCount } = await import("../llm/embedders/remote.js");
1383
- throwIfAborted(signal);
1384
- const allEntries = getAllEntriesForEmbedding(db);
1385
- if (allEntries.length === 0) {
1386
- onProgress({ phase: "embeddings", message: "Embeddings already up to date." });
1387
- setMeta(db, "embeddingFingerprint", currentFingerprint);
1388
- return { success: true };
1389
- }
1390
- onProgress({
1391
- phase: "embeddings",
1392
- message: `Generating embeddings for ${allEntries.length} entr${allEntries.length === 1 ? "y" : "ies"}.`,
1393
- });
1394
- const texts = allEntries.map((e) => e.searchText);
1395
- // Verbose: log each document before it is sent to the embedding API so
1396
- // operators can see exactly where embedding fails without waiting for an error.
1397
- if (isVerbose()) {
1398
- const EMBED_BATCH_SIZE = 100; // mirrors REMOTE_BATCH_SIZE in remote.ts
1399
- const totalBatches = Math.ceil(texts.length / EMBED_BATCH_SIZE);
1400
- for (let i = 0; i < texts.length; i++) {
1401
- const batchNum = Math.floor(i / EMBED_BATCH_SIZE) + 1;
1402
- const chars = texts[i].length;
1403
- const tokens = estimateTokenCount(texts[i]);
1404
- const ref = allEntries[i].entryKey.split(":").slice(1).join(":"); // strip stashDir prefix
1405
- warnVerbose(`[embed] ${ref} (${chars} chars, est. ${tokens} tokens) → batch ${batchNum}/${totalBatches}`);
1406
- }
1407
- }
1408
- let heartbeatTimer;
1409
- try {
1410
- heartbeatTimer = setInterval(() => {
1411
- onProgress({
1412
- phase: "embeddings",
1413
- message: `Still generating embeddings for ${allEntries.length} entr${allEntries.length === 1 ? "y" : "ies"}; waiting on embedding provider.`,
1414
- });
1415
- }, 15000);
1416
- const embeddings = await embedBatch(texts, config.embedding, signal);
1417
- throwIfAborted(signal);
1418
- // Wrap all embedding upserts in a single transaction so partial
1419
- // state is rolled back on failure rather than leaving the table half-filled.
1420
- let storedCount = 0;
1421
- let skippedCount = 0;
1422
- let vecFailedCount = 0;
1423
- let vecUnavailableCount = 0;
1424
- db.transaction(() => {
1425
- for (let i = 0; i < allEntries.length; i++) {
1426
- const res = upsertEmbedding(db, allEntries[i].id, embeddings[i]);
1427
- if (res.stored) {
1428
- storedCount++;
1429
- }
1430
- else {
1431
- skippedCount++;
1432
- }
1433
- if (res.vec === "failed")
1434
- vecFailedCount++;
1435
- if (res.vec === "unavailable")
1436
- vecUnavailableCount++;
1437
- }
1438
- })();
1439
- if (skippedCount > 0) {
1440
- warn(`[embed] ${skippedCount} embedding${skippedCount === 1 ? "" : "s"} skipped (entry deleted between queue and write)`);
1441
- }
1442
- // Record the ACTUAL vec-insert outcome so semantic search reflects it
1443
- // instead of inferring readiness from stored-BLOB counts. Any failure
1444
- // marks the fast path degraded, routing search to the JS-cosine fallback
1445
- // over the (complete) BLOB table — honest degradation, not a hard failure.
1446
- //
1447
- // 'unavailable' has to degrade the flag too. It means no vec row was
1448
- // written at all, so marking the fast path ready left a later open (a
1449
- // different runtime, or sqlite-vec installed afterwards) trusting an
1450
- // empty entries_vec and returning zero semantic hits against a fully
1451
- // populated BLOB table.
1452
- setVecFastPathReady(db, vecFailedCount === 0 && vecUnavailableCount === 0);
1453
- if (vecFailedCount > 0) {
1454
- warn(`[embed] ${vecFailedCount} sqlite-vec fast-path insert${vecFailedCount === 1 ? "" : "s"} failed — ` +
1455
- "semantic search will use the slower JS-cosine fallback over stored embeddings. " +
1456
- "Rebuild with 'akm index --full' after resolving the vec table (often a vector-dimension mismatch).");
1457
- }
1458
- onProgress({
1459
- phase: "embeddings",
1460
- message: `Stored ${storedCount} embedding${storedCount === 1 ? "" : "s"}.`,
1461
- });
1462
- setMeta(db, "embeddingFingerprint", currentFingerprint);
1463
- return { success: true, vecInsertFailures: vecFailedCount };
1464
- }
1465
- finally {
1466
- if (heartbeatTimer)
1467
- clearInterval(heartbeatTimer);
1468
- }
1469
- }
1470
- catch (error) {
1471
- const message = error instanceof Error ? error.message : String(error);
1472
- warn("Embedding generation failed, continuing without:", message);
1473
- onProgress({
1474
- phase: "embeddings",
1475
- message: `Embedding generation failed: ${message}`,
1476
- });
1477
- return {
1478
- success: false,
1479
- reason: classifySemanticFailure(message),
1480
- message: `Semantic search verification failed: ${message}`,
1481
- };
1482
- }
1483
- }
1484
1457
  // ── Helpers ─────────────────────────────────────────────────────────────────
1485
1458
  function attachFileSize(entry, entryPath) {
1486
1459
  try {
@@ -1586,9 +1559,10 @@ function resolveIndexedFiles(dirPath, files, stash) {
1586
1559
  }
1587
1560
  return resolved.size > 0 ? [...resolved] : files;
1588
1561
  }
1589
- async function enhanceStashWithLlm(llmConfig, stash, files, summary, signal, db, entryKeys, akmConfig, onEntryDone) {
1562
+ async function enhanceStashWithLlm(llmRunner, stash, files, summary, signal, db, itemRefs, akmConfig, onEntryDone, onNotices, lease) {
1590
1563
  const { enhanceMetadata } = await import("../llm/metadata-enhance.js");
1591
1564
  const { computeBodyHash, getLlmCacheEntry, upsertLlmCacheEntry } = await import("../storage/repositories/index-llm-cache-repository.js");
1565
+ let configFailure;
1592
1566
  const results = await concurrentMap(stash.entries, async (entry, idx) => {
1593
1567
  if (signal?.aborted)
1594
1568
  return entry;
@@ -1607,11 +1581,12 @@ async function enhanceStashWithLlm(llmConfig, stash, files, summary, signal, db,
1607
1581
  }
1608
1582
  }
1609
1583
  // Incremental cache: skip LLM call when file body is unchanged. The
1610
- // cache key is the entry_key (stashDir:type:name) which is stable
1611
- // across index runs.
1584
+ // Cache metadata enrichment by the canonical durable item ref.
1612
1585
  const cacheBody = fileContent ?? `${entry.name}\n${entry.description ?? ""}`;
1613
1586
  const bodyHash = computeBodyHash(cacheBody);
1614
- const cacheKey = entryKeys?.[idx] ?? `${entry.type}:${entry.name}`;
1587
+ const cacheKey = itemRefs?.[idx];
1588
+ if (!cacheKey)
1589
+ throw new Error(`Missing canonical item ref for enrichment entry ${entry.name}.`);
1615
1590
  if (db) {
1616
1591
  const cached = getLlmCacheEntry(db, cacheKey, bodyHash);
1617
1592
  if (cached) {
@@ -1634,7 +1609,7 @@ async function enhanceStashWithLlm(llmConfig, stash, files, summary, signal, db,
1634
1609
  }
1635
1610
  }
1636
1611
  }
1637
- const outcome = await enhanceMetadata(llmConfig, entry, fileContent, signal, akmConfig);
1612
+ const outcome = await enhanceMetadata(llmRunner, entry, fileContent, signal, akmConfig, onNotices, lease);
1638
1613
  if (outcome.status !== "enriched") {
1639
1614
  // Not a genuine LLM success: the gate was closed (`skipped`) or the
1640
1615
  // call errored/timed out (`failed`). Do NOT mark the entry enriched
@@ -1682,6 +1657,10 @@ async function enhanceStashWithLlm(llmConfig, stash, files, summary, signal, db,
1682
1657
  return updated;
1683
1658
  }
1684
1659
  catch (err) {
1660
+ if (err instanceof ConfigError) {
1661
+ configFailure ??= err;
1662
+ return entry;
1663
+ }
1685
1664
  const msg = toErrorMessage(err);
1686
1665
  // failureSamples is bounded to 3 items, so a linear scan is cheaper
1687
1666
  // than maintaining a parallel Set for membership checks (#177 review).
@@ -1694,7 +1673,9 @@ async function enhanceStashWithLlm(llmConfig, stash, files, summary, signal, db,
1694
1673
  },
1695
1674
  // Defaults: 2 for remote LLM APIs, 1 for local model servers. No config
1696
1675
  // override reaches this path (see getDefaultLlmConcurrency).
1697
- getDefaultLlmConcurrency(llmConfig));
1676
+ getDefaultLlmConcurrency(llmRunner.connection));
1677
+ if (configFailure)
1678
+ throw configFailure;
1698
1679
  // concurrentMap returns Array<T | undefined>; filter out undefined slots
1699
1680
  // (which can only occur if the callback itself returned undefined, which
1700
1681
  // it never does above — but TypeScript needs the filter for type safety).
@@ -1711,58 +1692,111 @@ async function resolveLookupSources() {
1711
1692
  }
1712
1693
  function resolveLookupScope(bundle, sources) {
1713
1694
  if (!bundle)
1714
- return { candidateDirs: sources.map((source) => source.path), qualified: false };
1715
- return { candidateDirs: resolveSourcesForOrigin(bundle, sources).map((source) => source.path), qualified: true };
1695
+ return { candidateSources: sources, qualified: false };
1696
+ return { candidateSources: resolveSourcesForOrigin(bundle, sources), qualified: true };
1716
1697
  }
1717
- /** Resolve an adapter-owned `[bundle//]conceptId` without interpreting its path as an AKM type. */
1718
- export async function lookupBundleRef(ref) {
1698
+ async function lookupBundleRefWithResolutionUsing(ref, openLookupDatabase) {
1719
1699
  const sources = await resolveLookupSources();
1720
1700
  if (sources.length === 0)
1721
- return null;
1722
- const { candidateDirs, qualified } = resolveLookupScope(ref.bundle, sources);
1723
- if (candidateDirs.length === 0)
1724
- return null;
1725
- const db = openExistingDatabase(getDbPath());
1701
+ return { entry: null };
1702
+ const bundleBySourcePath = new Map(deriveInstallations(sources).map((installation, index) => [path.resolve(sources[index].path), installation.id]));
1703
+ const { candidateSources, qualified } = resolveLookupScope(ref.bundle, sources);
1704
+ if (candidateSources.length === 0)
1705
+ return { entry: null };
1706
+ let db;
1707
+ let indexError;
1726
1708
  try {
1727
- const inputRef = makeBundleRef(qualified ? ref.bundle : undefined, ref.conceptId);
1728
- for (const dir of candidateDirs) {
1729
- const id = findEntryIdByRef(db, inputRef, dir);
1730
- if (id === undefined)
1731
- continue;
1732
- const row = db
1733
- .prepare("SELECT entry_key AS entryKey, file_path AS filePath, stash_dir AS stashDir, entry_type AS type, " +
1734
- "entry_json AS entryJson, item_ref AS itemRef, bundle_id AS bundleId, concept_id AS conceptId, " +
1735
- "adapter_id AS adapterId FROM entries WHERE id = ?")
1736
- .get(id);
1737
- if (!row)
1738
- continue;
1739
- let document;
1740
- try {
1741
- document = JSON.parse(row.entryJson);
1742
- }
1743
- catch {
1744
- // Corrupt optional projection does not erase the durable path identity.
1709
+ db = openLookupDatabase(getDbPath());
1710
+ }
1711
+ catch (error) {
1712
+ indexError = error;
1713
+ }
1714
+ try {
1715
+ for (const source of candidateSources) {
1716
+ const adapterId = source.adapterId ?? detectAdapterId(source.path);
1717
+ const owner = resolveAdapterConceptOwner(source.path, adapterId, ref.conceptId);
1718
+ const lookupConceptId = owner?.conceptId ?? ref.conceptId;
1719
+ const inputRef = makeBundleRef(qualified ? ref.bundle : undefined, lookupConceptId);
1720
+ const sourceBundleId = bundleBySourcePath.get(path.resolve(source.path));
1721
+ const id = db && sourceBundleId ? findEntryIdByRef(db, inputRef, sourceBundleId) : undefined;
1722
+ if (id !== undefined && owner && db) {
1723
+ const entry = readLookupEntry(db, id, ref.conceptId, source.path);
1724
+ if (entry) {
1725
+ if (owner.workflowSource) {
1726
+ assertIndexedWorkflowSourceIdentity(inputRef, entry.filePath, owner.workflowSource);
1727
+ if (entry.adapterId !== adapterId) {
1728
+ throw new WorkflowSourceIdentityError(inputRef, entry.filePath, owner.path);
1729
+ }
1730
+ }
1731
+ else if (entry.adapterId !== adapterId || !indexedPathMatchesOwner(entry.filePath, owner)) {
1732
+ return { entry: null, owner, ...(indexError === undefined ? {} : { indexError }) };
1733
+ }
1734
+ return { entry, owner, ...(indexError === undefined ? {} : { indexError }) };
1735
+ }
1745
1736
  }
1746
- if (!row.itemRef || !row.bundleId || !row.conceptId || !row.adapterId)
1747
- continue;
1748
- return {
1749
- entryKey: row.entryKey,
1750
- filePath: row.filePath,
1751
- stashDir: row.stashDir,
1752
- type: row.type,
1753
- name: document?.name ?? ref.conceptId.split("/").pop() ?? ref.conceptId,
1754
- adapterId: row.adapterId,
1755
- document,
1756
- itemRef: row.itemRef,
1757
- bundleId: row.bundleId,
1758
- conceptId: row.conceptId,
1759
- };
1737
+ // A physical owner with a missing/incomplete index row still owns this
1738
+ // unqualified concept. Stop here so a later source cannot retarget it.
1739
+ if (owner)
1740
+ return { entry: null, owner, ...(indexError === undefined ? {} : { indexError }) };
1760
1741
  }
1761
- return null;
1742
+ return { entry: null, ...(indexError === undefined ? {} : { indexError }) };
1762
1743
  }
1763
1744
  finally {
1764
- closeDatabase(db);
1745
+ if (db)
1746
+ closeDatabase(db);
1747
+ }
1748
+ }
1749
+ export async function lookupBundleRefWithResolution(ref) {
1750
+ return lookupBundleRefWithResolutionUsing(ref, openExistingDatabase);
1751
+ }
1752
+ /** Resolve an adapter-owned `[bundle//]conceptId` without interpreting its path as an AKM type. */
1753
+ export async function lookupBundleRef(ref) {
1754
+ const resolution = await lookupBundleRefWithResolution(ref);
1755
+ if (resolution.indexError !== undefined)
1756
+ throw resolution.indexError;
1757
+ return resolution.entry;
1758
+ }
1759
+ /**
1760
+ * Resolve one execution source without opening the live index database for
1761
+ * write or allowing SQLite read-lock bookkeeping to touch its SHM file.
1762
+ */
1763
+ export async function lookupBundleRefReadonly(ref) {
1764
+ const resolution = await lookupBundleRefWithResolutionUsing(ref, (dbPath) => {
1765
+ const db = openReadonlyExistingDatabase(dbPath, { isolatedSnapshot: true });
1766
+ if (!db)
1767
+ throw new Error(`Index database not found at ${dbPath}. Run 'akm index' to build it.`);
1768
+ return db;
1769
+ });
1770
+ if (resolution.indexError !== undefined)
1771
+ throw resolution.indexError;
1772
+ return resolution.entry;
1773
+ }
1774
+ function readLookupEntry(db, id, fallbackConceptId, sourceRoot) {
1775
+ const row = db
1776
+ .prepare("SELECT file_path AS filePath, type, document_json AS documentJson, " +
1777
+ "item_ref AS itemRef, bundle_id AS bundleId, concept_id AS conceptId, " +
1778
+ "adapter_id AS adapterId FROM entries WHERE id = ?")
1779
+ .get(id);
1780
+ if (!row)
1781
+ return null;
1782
+ let document;
1783
+ try {
1784
+ document = JSON.parse(row.documentJson);
1765
1785
  }
1786
+ catch {
1787
+ // Corrupt optional projection does not erase the durable path identity.
1788
+ }
1789
+ return {
1790
+ filePath: row.filePath,
1791
+ stashDir: sourceRoot,
1792
+ type: row.type,
1793
+ name: document?.name ?? fallbackConceptId.split("/").pop() ?? fallbackConceptId,
1794
+ adapterId: row.adapterId,
1795
+ document,
1796
+ itemRef: row.itemRef,
1797
+ bundleId: row.bundleId,
1798
+ conceptId: row.conceptId,
1799
+ };
1766
1800
  }
1767
1801
  /**
1768
1802
  * Look up a single asset by ref. Spec §6.2 — `akm show` queries this and
@@ -1795,11 +1829,16 @@ const USAGE_EVENT_RETENTION_DAYS = 90;
1795
1829
  *
1796
1830
  * Called during `akm index` after FTS rebuild.
1797
1831
  */
1798
- export function recomputeUtilityScores(db, stateDb) {
1832
+ export function recomputeUtilityScores(db, stateDb, options) {
1799
1833
  const EMA_DECAY = 0.7;
1834
+ const stateSchema = options?.stateSchema;
1835
+ if (stateSchema !== undefined && !/^[A-Za-z_][A-Za-z0-9_]*$/.test(stateSchema)) {
1836
+ throw new Error("Invalid attached state schema name.");
1837
+ }
1838
+ const usageEvents = stateSchema === undefined ? "usage_events" : `"${stateSchema}".usage_events`;
1800
1839
  // Purge stale usage events (90-day retention). usage_events lives in state.db
1801
1840
  // (Chunk-8 WI-8.3); its table is created by state migration 020.
1802
- purgeOldUsageEvents(stateDb, USAGE_EVENT_RETENTION_DAYS);
1841
+ purgeOldUsageEvents(stateDb, USAGE_EVENT_RETENTION_DAYS, { stateSchema });
1803
1842
  // Time-proportional decay: apply one round of EMA per elapsed day so
1804
1843
  // indexing frequency doesn't affect how fast scores decay.
1805
1844
  const lastComputedAt = getMeta(db, "last_utility_computed_at");
@@ -1830,7 +1869,7 @@ export function recomputeUtilityScores(db, stateDb) {
1830
1869
  ELSE NULL
1831
1870
  END
1832
1871
  ) AS last_used_at
1833
- FROM usage_events u
1872
+ FROM ${usageEvents} u
1834
1873
  WHERE u.entry_id IS NOT NULL
1835
1874
  AND u.source = 'user'
1836
1875
  GROUP BY u.entry_id