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
@@ -14,8 +14,8 @@
14
14
  *
15
15
  * Disabling — three preconditions must ALL hold for the pass to run:
16
16
  * 1. An LLM profile must be configured (no provider = no extraction). When
17
- * absent, `resolveIndexPassLLM("graph", config)` returns `undefined`
18
- * and the pass short-circuits.
17
+ * absent, `resolveIndexPassExecution("graph", config).runner` is
18
+ * `undefined` and the pass short-circuits.
19
19
  * 2. The selected strategy's `processes.graphExtraction.enabled !== false`
20
20
  * — the feature-gate layer (historically v1 spec §14, since superseded by
21
21
  * the 0.8.0 profile shape). Set to `false` to block the pass at the
@@ -28,7 +28,8 @@
28
28
  * refreshing.
29
29
  *
30
30
  * Locked v1 contract:
31
- * - LLM access is exclusively via `resolveIndexPassLLM("graph", config)`.
31
+ * - LLM access is exclusively via the frozen runner returned by
32
+ * `resolveIndexPassExecution("graph", config)`.
32
33
  * - The graph rows are an indexer artifact, NOT a user-visible
33
34
  * asset. It does not have an asset ref, does not appear in search
34
35
  * hits, and is not addressable via `akm show`. The persisted artifact
@@ -44,18 +45,26 @@ import { stashDirFor } from "../../core/asset/asset-placement.js";
44
45
  import { parseFrontmatter } from "../../core/asset/frontmatter.js";
45
46
  import { concurrentMap } from "../../core/concurrent.js";
46
47
  import { getIndexPassConfig, loadConfig, resolveBatchSize } from "../../core/config/config.js";
47
- import { rethrowIfTestIsolationError } from "../../core/errors.js";
48
+ import { ConfigError, rethrowIfTestIsolationError } from "../../core/errors.js";
48
49
  import { warn, warnVerbose } from "../../core/warn.js";
50
+ import { disposeLoweredExecutionDispatchLease, } from "../../integrations/agent/execution-lowering.js";
49
51
  import { isProcessEnabled } from "../../llm/feature-gate.js";
50
52
  import * as graphExtract from "../../llm/graph-extract.js";
51
- import { resolveIndexPassLLM } from "../../llm/index-passes.js";
52
- import { computeBodyHash, getLlmCacheEntriesByRefs, getLlmCacheEntry, upsertLlmCacheEntry, } from "../../storage/repositories/index-llm-cache-repository.js";
53
+ import { resolveIndexPassExecution } from "../../llm/index-passes.js";
54
+ import { preflightStructuredLlmRunner } from "../../llm/structured-call.js";
55
+ import { computeBodyHash, getLlmCacheEntriesByRefs, upsertLlmCacheEntry, } from "../../storage/repositories/index-llm-cache-repository.js";
53
56
  import { GRAPH_SCHEMA_VERSION } from "../../storage/repositories/index-schema.js";
54
- import { drainExtractionQueue, loadStoredGraphSnapshot, replaceStoredGraph } from "../db/graph-db.js";
57
+ import { acknowledgeExtractionQueueEntry, enqueueGraphExtraction, loadStoredGraphSnapshot, peekExtractionQueue, replaceStoredGraph, } from "../db/graph-db.js";
55
58
  import { walkMarkdownFiles } from "../walk/walker.js";
56
59
  import { deduplicateGraph } from "./graph-dedup.js";
57
60
  /** Schema version for the persisted artifact — bumps trigger a full rebuild. */
58
61
  export const GRAPH_FILE_SCHEMA_VERSION = GRAPH_SCHEMA_VERSION;
62
+ function graphExecutionForContext(ctx, config) {
63
+ if (Object.hasOwn(ctx, "llmRunner")) {
64
+ return Object.freeze({ runner: ctx.llmRunner ?? undefined, notices: Object.freeze([]) });
65
+ }
66
+ return resolveIndexPassExecution("graph", config);
67
+ }
59
68
  const EMPTY_QUALITY = {
60
69
  consideredFiles: 0,
61
70
  extractedFiles: 0,
@@ -254,14 +263,244 @@ function reuseGraphNode(previousNodes, candidate, bodyHash) {
254
263
  ...(node.reason ? { reason: node.reason } : {}),
255
264
  };
256
265
  }
266
+ function planEligibleGraphExtractions(args) {
267
+ const { eligible, db, reEnrich, cacheVariant, previousNodes, canReusePreviousGraph } = args;
268
+ const bodyHashes = eligible.map((candidate) => computeBodyHash(candidate.body));
269
+ const cacheEntries = db && !reEnrich
270
+ ? getLlmCacheEntriesByRefs(db, eligible.map((candidate) => candidate.absPath), cacheVariant)
271
+ : new Map();
272
+ return eligible.map((candidate, index) => {
273
+ const bodyHash = bodyHashes[index] ?? "";
274
+ if (!reEnrich && db) {
275
+ const entry = cacheEntries.get(candidate.absPath);
276
+ if (entry?.bodyHash === bodyHash) {
277
+ try {
278
+ const cached = validateGraphCacheShape(JSON.parse(entry.resultJson));
279
+ if (cached)
280
+ return { kind: "cache-hit", candidate, bodyHash, cached, persistCache: false };
281
+ }
282
+ catch {
283
+ // Corrupt cache rows are immutable model plans for this pass.
284
+ }
285
+ }
286
+ }
287
+ if (!reEnrich && (!db || canReusePreviousGraph)) {
288
+ const cached = reuseGraphNode(previousNodes, candidate, bodyHash);
289
+ if (cached)
290
+ return { kind: "cache-hit", candidate, bodyHash, cached, persistCache: Boolean(db) };
291
+ }
292
+ return { kind: "model", candidate, bodyHash };
293
+ });
294
+ }
295
+ function graphRecordFromCachePlan(plan) {
296
+ return {
297
+ absPath: plan.candidate.absPath,
298
+ type: plan.candidate.type,
299
+ bodyHash: plan.bodyHash,
300
+ entities: plan.cached.entities,
301
+ relations: plan.cached.relations,
302
+ ...(plan.cached.confidence !== undefined ? { confidence: plan.cached.confidence } : {}),
303
+ ...(plan.cached.status ? { status: plan.cached.status } : {}),
304
+ ...(plan.cached.reason ? { reason: plan.cached.reason } : {}),
305
+ };
306
+ }
307
+ async function extractGraphBatches(args) {
308
+ const { plans, batchSize, signal, db, cacheVariant, telemetry, llmRunner, lease, featureConfig, onFallback, batchState, runtimeTelemetry, onNotices, reportProgress, } = args;
309
+ const results = new Array(plans.length).fill(undefined);
310
+ const chunkStarts = [];
311
+ for (let start = 0; start < plans.length; start += batchSize)
312
+ chunkStarts.push(start);
313
+ let configFailure;
314
+ await concurrentMap(chunkStarts, async (start) => {
315
+ if (signal?.aborted)
316
+ return;
317
+ const chunk = plans.slice(start, start + batchSize);
318
+ const reportChunkProgress = () => {
319
+ for (let j = 0; j < chunk.length; j++) {
320
+ const plan = chunk[j];
321
+ if (plan)
322
+ reportProgress(plan.candidate.absPath, results[start + j]);
323
+ }
324
+ };
325
+ for (let index = 0; index < chunk.length; index++) {
326
+ const plan = chunk[index];
327
+ if (!plan || plan.kind !== "cache-hit")
328
+ continue;
329
+ telemetry.cacheHits += 1;
330
+ results[start + index] = graphRecordFromCachePlan(plan);
331
+ if (db && plan.persistCache) {
332
+ upsertLlmCacheEntry(db, plan.candidate.absPath, plan.bodyHash, JSON.stringify(plan.cached), cacheVariant);
333
+ }
334
+ }
335
+ const modelPlans = chunk.filter((plan) => plan.kind === "model");
336
+ if (modelPlans.length === 0) {
337
+ reportChunkProgress();
338
+ return;
339
+ }
340
+ telemetry.cacheMisses += modelPlans.length;
341
+ let batchExtractions;
342
+ try {
343
+ batchExtractions = await graphExtract.extractGraphFromBodies(llmRunner, modelPlans.map((plan) => plan.candidate.body), signal, featureConfig, onFallback, {
344
+ batchState,
345
+ telemetry: runtimeTelemetry,
346
+ onNotices,
347
+ ...(lease ? { lease } : {}),
348
+ });
349
+ }
350
+ catch (error) {
351
+ if (error instanceof ConfigError) {
352
+ configFailure ??= error;
353
+ return;
354
+ }
355
+ throw error;
356
+ }
357
+ let llmIndex = 0;
358
+ for (let index = 0; index < chunk.length; index++) {
359
+ const plan = chunk[index];
360
+ if (!plan || plan.kind !== "model")
361
+ continue;
362
+ const extraction = batchExtractions[llmIndex++];
363
+ if (!extraction)
364
+ continue;
365
+ const cacheShape = {
366
+ entities: extraction.entities,
367
+ relations: extraction.relations,
368
+ ...(extraction.confidence !== undefined ? { confidence: extraction.confidence } : {}),
369
+ ...(extraction.status ? { status: extraction.status } : {}),
370
+ ...(extraction.reason ? { reason: extraction.reason } : {}),
371
+ };
372
+ if (db) {
373
+ upsertLlmCacheEntry(db, plan.candidate.absPath, plan.bodyHash, JSON.stringify(cacheShape), cacheVariant);
374
+ }
375
+ results[start + index] = {
376
+ absPath: plan.candidate.absPath,
377
+ type: plan.candidate.type,
378
+ bodyHash: plan.bodyHash,
379
+ ...cacheShape,
380
+ };
381
+ }
382
+ reportChunkProgress();
383
+ }, llmRunner.connection.concurrency ?? 1);
384
+ return { results, ...(configFailure ? { configFailure } : {}) };
385
+ }
386
+ function readCurrentGraphBodyHash(filePath) {
387
+ try {
388
+ const body = parseFrontmatter(fs.readFileSync(filePath, "utf8")).content.trim();
389
+ return body ? computeBodyHash(body) : undefined;
390
+ }
391
+ catch {
392
+ return undefined;
393
+ }
394
+ }
395
+ function planQueuedGraphExtractions(args) {
396
+ const { db, stashRoot, previousNodes, signal, reEnrich } = args;
397
+ if (!db)
398
+ return [];
399
+ return peekExtractionQueue(db, stashRoot, GRAPH_EXTRACTION_QUEUE_DRAIN_LIMIT).map((queued) => {
400
+ if (signal?.aborted) {
401
+ return {
402
+ kind: "deferred",
403
+ filePath: queued.filePath,
404
+ queuedBodyHash: queued.bodyHash,
405
+ priority: queued.priority,
406
+ };
407
+ }
408
+ let raw;
409
+ try {
410
+ raw = fs.readFileSync(queued.filePath, "utf8");
411
+ }
412
+ catch {
413
+ return { kind: "discard", filePath: queued.filePath, queuedBodyHash: queued.bodyHash, priority: queued.priority };
414
+ }
415
+ const body = parseFrontmatter(raw).content.trim();
416
+ if (!body) {
417
+ return { kind: "discard", filePath: queued.filePath, queuedBodyHash: queued.bodyHash, priority: queued.priority };
418
+ }
419
+ const currentBodyHash = computeBodyHash(body);
420
+ const type = inferGraphTypeForPath(stashRoot, queued.filePath) ?? "memory";
421
+ if (!reEnrich && reuseGraphNode(previousNodes, { absPath: queued.filePath, type }, currentBodyHash)) {
422
+ return {
423
+ kind: "hit",
424
+ filePath: queued.filePath,
425
+ queuedBodyHash: queued.bodyHash,
426
+ currentBodyHash,
427
+ priority: queued.priority,
428
+ };
429
+ }
430
+ return {
431
+ kind: "model",
432
+ filePath: queued.filePath,
433
+ queuedBodyHash: queued.bodyHash,
434
+ currentBodyHash,
435
+ priority: queued.priority,
436
+ };
437
+ });
438
+ }
439
+ async function executeQueuedGraphPlans(args) {
440
+ const { plans, db, stashRoot, featureConfig, signal, llmRunner, lease, onNotices } = args;
441
+ if (!db)
442
+ return { graphChanged: false, acknowledgements: [] };
443
+ let graphChanged = false;
444
+ const acknowledgements = [];
445
+ for (const plan of plans) {
446
+ if (signal?.aborted || plan.kind === "deferred")
447
+ break;
448
+ if (plan.kind === "discard") {
449
+ const currentBodyHash = readCurrentGraphBodyHash(plan.filePath);
450
+ if (currentBodyHash) {
451
+ enqueueGraphExtraction(db, stashRoot, plan.filePath, currentBodyHash, plan.priority);
452
+ continue;
453
+ }
454
+ acknowledgements.push({ filePath: plan.filePath, queuedBodyHash: plan.queuedBodyHash });
455
+ continue;
456
+ }
457
+ if (plan.kind === "hit") {
458
+ const currentBodyHash = readCurrentGraphBodyHash(plan.filePath);
459
+ if (currentBodyHash !== plan.currentBodyHash) {
460
+ if (currentBodyHash)
461
+ enqueueGraphExtraction(db, stashRoot, plan.filePath, currentBodyHash, plan.priority);
462
+ continue;
463
+ }
464
+ acknowledgements.push({ filePath: plan.filePath, queuedBodyHash: plan.queuedBodyHash });
465
+ continue;
466
+ }
467
+ if (plan.kind === "model") {
468
+ const outcome = await extractGraphForSingleFileRevision(db, stashRoot, plan.filePath, {
469
+ config: featureConfig,
470
+ signal,
471
+ llmRunner,
472
+ lease,
473
+ onNotices,
474
+ });
475
+ if (!outcome.written)
476
+ continue;
477
+ graphChanged = true;
478
+ const currentBodyHash = readCurrentGraphBodyHash(plan.filePath);
479
+ if (currentBodyHash !== outcome.bodyHash) {
480
+ if (currentBodyHash)
481
+ enqueueGraphExtraction(db, stashRoot, plan.filePath, currentBodyHash, plan.priority);
482
+ continue;
483
+ }
484
+ }
485
+ acknowledgements.push({ filePath: plan.filePath, queuedBodyHash: plan.queuedBodyHash });
486
+ }
487
+ return { graphChanged, acknowledgements };
488
+ }
489
+ function acknowledgeQueuedGraphPlans(db, stashRoot, execution) {
490
+ if (!db)
491
+ return;
492
+ for (const intent of execution.acknowledgements) {
493
+ acknowledgeExtractionQueueEntry(db, stashRoot, intent.filePath, intent.queuedBodyHash);
494
+ }
495
+ }
257
496
  /**
258
497
  * Top-level entry point. Returns a no-op result when the pass is disabled.
259
498
  *
260
499
  * Three preconditions — ALL must hold for the pass to run:
261
500
  *
262
501
  * 1. **Provider configured** — an LLM profile must be selectable. Without a
263
- * configured provider, `resolveIndexPassLLM("graph", config)` returns
264
- * `undefined` (the pass cannot run because there is no model to call).
502
+ * configured provider, `resolveIndexPassExecution("graph", config).runner`
503
+ * is `undefined` (the pass cannot run because there is no model to call).
265
504
  * 2. **Feature gate** — the selected strategy's `processes.graphExtraction.enabled`
266
505
  * (defaults to `true`). When `false`, no network call may issue regardless
267
506
  * of per-pass settings.
@@ -278,23 +517,34 @@ function reuseGraphNode(previousNodes, candidate, bodyHash) {
278
517
  */
279
518
  export async function runGraphExtractionPass(ctx) {
280
519
  const { config, sources, signal, db, reEnrich, onProgress, options = {} } = ctx;
281
- const invocationOwnsConnection = Object.hasOwn(ctx, "llmConfig");
520
+ const invocationOwnsRunner = Object.hasOwn(ctx, "llmRunner");
282
521
  // Gate 1 — feature gate via isProcessEnabled, which reads the 0.8.0 path
283
522
  // (selected strategy's processes.graphExtraction.enabled). Defaults to
284
523
  // enabled when the key is absent.
285
- if (!invocationOwnsConnection && !isProcessEnabled("index", "graph_extraction", config))
524
+ if (!invocationOwnsRunner && !isProcessEnabled("index", "graph_extraction", config))
286
525
  return { ...EMPTY_RESULT };
287
- // Gate 2 per-pass opt-out (#208). Returns the resolved llm config or
288
- // `undefined` when the pass should not run.
289
- const llmConfig = Object.hasOwn(ctx, "llmConfig") ? ctx.llmConfig : resolveIndexPassLLM("graph", config);
290
- if (!llmConfig) {
526
+ const noticesByKey = new Map();
527
+ const onNotices = (notices) => {
528
+ for (const notice of notices)
529
+ noticesByKey.set(JSON.stringify(notice), notice);
530
+ };
531
+ const emptyResult = () => ({
532
+ ...EMPTY_RESULT,
533
+ ...(noticesByKey.size > 0 ? { notices: Object.freeze([...noticesByKey.values()]) } : {}),
534
+ });
535
+ // Gate 2 — per-pass opt-out (#208). Retain the whole frozen resolution so
536
+ // selection-time lowering notices cannot be separated from the runner.
537
+ const execution = graphExecutionForContext(ctx, config);
538
+ onNotices(execution.notices);
539
+ const llmRunner = execution.runner;
540
+ if (!llmRunner) {
291
541
  const reason = getIndexPassConfig(config.index, "graph")?.enabled === false
292
542
  ? "index.graph.enabled is false"
293
543
  : "no LLM engine is configured";
294
544
  warnVerbose(`graph extraction: skipped because ${reason}.`);
295
- return { ...EMPTY_RESULT };
545
+ return emptyResult();
296
546
  }
297
- const featureConfig = invocationOwnsConnection
547
+ const featureConfig = invocationOwnsRunner
298
548
  ? { ...config, index: { ...config.index, graph: { ...config.index?.graph, enabled: true } } }
299
549
  : config;
300
550
  // The pass only writes to the primary (working) stash. Read-only caches
@@ -303,27 +553,24 @@ export async function runGraphExtractionPass(ctx) {
303
553
  const primary = sources[0];
304
554
  if (!primary) {
305
555
  warnVerbose("graph extraction: skipped because no primary stash source is available.");
306
- return { ...EMPTY_RESULT };
307
- }
308
- // #624-P3: drain the lazy-extraction queue BEFORE the ranked sweep, highest
309
- // priority first. Queued paths are extracted individually (per-file merge,
310
- // other files untouched) so they are processed even when they fall outside
311
- // the normal candidate set. Default (empty queue) is a byte-identical no-op:
312
- // drainExtractionQueue returns [] and the loop body never runs.
313
- if (db) {
314
- const drained = drainExtractionQueue(db, primary.path, GRAPH_EXTRACTION_QUEUE_DRAIN_LIMIT);
315
- for (const queued of drained) {
316
- if (signal?.aborted)
317
- break;
318
- await extractGraphForSingleFile(db, primary.path, queued.filePath, queued.bodyHash, {
319
- config: featureConfig,
320
- signal,
321
- llmConfig,
322
- });
323
- }
556
+ return emptyResult();
324
557
  }
325
558
  const includeTypes = options.includeTypes ?? getGraphExtractionIncludeTypes(config);
326
- let eligible = collectEligibleFiles(primary.path, includeTypes).filter((candidate) => !options.candidatePaths || options.candidatePaths.has(candidate.absPath));
559
+ let previousGraph = loadGraphFile(primary.path, db);
560
+ const previousNodes = new Map(previousGraph.files.map((node) => [node.path, node]));
561
+ const batchSize = resolveBatchSize(options.batchSize ?? getIndexPassConfig(config.index, "graph")?.graphExtractionBatchSize, llmRunner.connection.contextLength);
562
+ const extractorId = getGraphExtractorId({ model: llmRunner.connection.model, batchSize, includeTypes });
563
+ const cacheVariant = extractorId;
564
+ const canReusePreviousGraph = previousGraph.telemetry?.extractorId === extractorId;
565
+ const queuePlans = planQueuedGraphExtractions({
566
+ db,
567
+ stashRoot: primary.path,
568
+ previousNodes,
569
+ signal,
570
+ reEnrich,
571
+ });
572
+ const queuedPaths = new Set(queuePlans.map((plan) => plan.filePath));
573
+ let eligible = collectEligibleFiles(primary.path, includeTypes).filter((candidate) => (!options.candidatePaths || options.candidatePaths.has(candidate.absPath)) && !queuedPaths.has(candidate.absPath));
327
574
  // P2 (#624): when topN is set and a DB is available, rank the (already
328
575
  // candidate-filtered) eligible set by utility_scores DESC and keep only the
329
576
  // top-N. Default (topN unset) is byte-identical to today — no ranking query
@@ -333,341 +580,263 @@ export async function runGraphExtractionPass(ctx) {
333
580
  eligible = rankCandidatesByUtility(db, eligible, primary.path).slice(0, options.topN);
334
581
  }
335
582
  const considered = eligible.length;
336
- if (considered === 0) {
337
- const scoped = options.candidatePaths ? ` matching ${options.candidatePaths.size} candidate path(s)` : "";
338
- warnVerbose(`graph extraction: skipped because no eligible files${scoped} were found under ${primary.path}. ` +
339
- `includeTypes=${includeTypes.join(",")}`);
340
- return { ...EMPTY_RESULT };
341
- }
342
- const previousGraph = loadGraphFile(primary.path, db);
343
- const previousNodes = new Map(previousGraph.files.map((node) => [node.path, node]));
344
- const nodes = [];
345
- let totalEntities = 0;
346
- let totalRelations = 0;
347
- let processed = 0;
348
- let extracted = 0;
349
- onProgress?.({ processed, total: considered, extracted, totalEntities, totalRelations });
350
- const reportProgress = (currentPath, result) => {
351
- processed += 1;
352
- if (result) {
353
- if (result.entities.length > 0)
354
- extracted += 1;
355
- totalEntities += result.entities.length;
356
- totalRelations += result.relations.length;
357
- }
358
- onProgress?.({
359
- processed,
360
- total: considered,
361
- extracted,
362
- totalEntities,
363
- totalRelations,
364
- currentPath,
583
+ const eligiblePlans = planEligibleGraphExtractions({
584
+ eligible,
585
+ db,
586
+ reEnrich,
587
+ cacheVariant,
588
+ previousNodes,
589
+ canReusePreviousGraph,
590
+ });
591
+ const queueNeedsModel = queuePlans.some((plan) => plan.kind === "model");
592
+ const eligibleNeedsModel = !signal?.aborted && eligiblePlans.some((plan) => plan.kind === "model");
593
+ if (signal?.aborted)
594
+ return emptyResult();
595
+ // Validate exactly once iff classification found real model work. Queue
596
+ // acknowledgements, cache writes, and graph replacement all happen after
597
+ // this boundary, so a missing credential cannot partially mutate a batch.
598
+ const dispatchLease = queueNeedsModel || eligibleNeedsModel ? await preflightStructuredLlmRunner(llmRunner) : undefined;
599
+ try {
600
+ const queueExecution = await executeQueuedGraphPlans({
601
+ plans: queuePlans,
602
+ db,
603
+ stashRoot: primary.path,
604
+ featureConfig,
605
+ signal,
606
+ llmRunner,
607
+ lease: dispatchLease,
608
+ onNotices,
365
609
  });
366
- };
367
- // Resolve the effective batch size. Falls back to
368
- // DEFAULT_GRAPH_EXTRACTION_BATCH_SIZE (4) when unset, and clamps against
369
- // `llm.contextLength` if the model's context window is configured.
370
- const batchSize = resolveBatchSize(options.batchSize ?? getIndexPassConfig(config.index, "graph")?.graphExtractionBatchSize, llmConfig.contextLength);
371
- const extractionRunId = crypto.randomUUID();
372
- const extractorId = getGraphExtractorId({ model: llmConfig.model, batchSize, includeTypes });
373
- const cacheVariant = extractorId;
374
- const telemetry = {
375
- extractorId,
376
- extractionRunId,
377
- model: llmConfig.model,
378
- promptVersion: graphExtract.GRAPH_EXTRACT_PROMPT_VERSION,
379
- batchSize,
380
- cacheHits: 0,
381
- cacheMisses: 0,
382
- truncationCount: 0,
383
- failureCount: 0,
384
- htmlErrorCount: 0,
385
- retryAttempts: 0,
386
- nonArrayBatchFailures: 0,
387
- };
388
- const canReusePreviousGraph = previousGraph.telemetry?.extractorId === extractorId;
389
- const runtimeTelemetry = {
390
- truncationCount: 0,
391
- failureCount: 0,
392
- htmlErrorCount: 0,
393
- retryAttempts: 0,
394
- filteredGenericEntities: 0,
395
- filteredInvalidRelations: 0,
396
- filteredLowConfidenceRelations: 0,
397
- contextBatchRetries: 0,
398
- nonArrayBatchFailures: 0,
399
- };
400
- const batchState = {
401
- batchingDisabled: false,
402
- nonArrayBatchFailures: 0,
403
- };
404
- warnVerbose(`graph extraction: starting for ${considered} eligible file(s) under ${primary.path}; ` +
405
- `includeTypes=${includeTypes.join(",")}, batchSize=${batchSize}, concurrency=${llmConfig.concurrency ?? 1}, ` +
406
- `reEnrich=${reEnrich === true}, candidateScoped=${options.candidatePaths ? "true" : "false"}.`);
407
- const onFallback = (evt) => {
408
- warn(`[akm] LLM fallback for ${evt.feature}: ${evt.reason}`);
409
- };
410
- let extractionResults;
411
- if (batchSize <= 1) {
412
- // ── Original per-asset path (with incremental cache) ─────────────────
413
- extractionResults = await concurrentMap(eligible, async (candidate) => {
414
- if (signal?.aborted) {
415
- reportProgress(candidate.absPath, undefined);
416
- return undefined;
417
- }
418
- const bodyHash = computeBodyHash(candidate.body);
419
- let cached;
420
- if (db) {
421
- if (!(reEnrich ?? false)) {
422
- const cacheEntry = getLlmCacheEntry(db, candidate.absPath, bodyHash, cacheVariant);
423
- if (cacheEntry) {
424
- try {
425
- cached = validateGraphCacheShape(JSON.parse(cacheEntry.resultJson));
426
- if (cached)
427
- telemetry.cacheHits += 1;
428
- }
429
- catch {
430
- cached = undefined;
431
- }
432
- }
433
- }
434
- }
435
- else if (!(reEnrich ?? false)) {
436
- // No DB — best-effort reuse from the previous in-memory graph.
437
- cached = reuseGraphNode(previousNodes, candidate, bodyHash);
438
- }
439
- if (!cached && !(reEnrich ?? false) && canReusePreviousGraph) {
440
- const reused = reuseGraphNode(previousNodes, candidate, bodyHash);
441
- if (reused) {
442
- cached = reused;
443
- if (db) {
444
- upsertLlmCacheEntry(db, candidate.absPath, bodyHash, JSON.stringify(reused), cacheVariant);
445
- }
446
- telemetry.cacheHits += 1;
447
- }
448
- }
449
- if (!cached) {
450
- telemetry.cacheMisses += 1;
451
- const extraction = await graphExtract.extractGraphFromBody(llmConfig, candidate.body, signal, featureConfig, onFallback, { batchState, telemetry: runtimeTelemetry });
452
- cached = {
453
- entities: extraction.entities,
454
- relations: extraction.relations,
455
- ...(extraction.confidence !== undefined ? { confidence: extraction.confidence } : {}),
456
- ...(extraction.status ? { status: extraction.status } : {}),
457
- ...(extraction.reason ? { reason: extraction.reason } : {}),
458
- };
459
- if (db) {
460
- upsertLlmCacheEntry(db, candidate.absPath, bodyHash, JSON.stringify(cached), cacheVariant);
461
- }
610
+ if (queueExecution.graphChanged) {
611
+ previousGraph = loadGraphFile(primary.path, db);
612
+ }
613
+ if (considered === 0) {
614
+ acknowledgeQueuedGraphPlans(db, primary.path, queueExecution);
615
+ const scoped = options.candidatePaths ? ` matching ${options.candidatePaths.size} candidate path(s)` : "";
616
+ warnVerbose(`graph extraction: skipped because no eligible files${scoped} were found under ${primary.path}. ` +
617
+ `includeTypes=${includeTypes.join(",")}`);
618
+ return emptyResult();
619
+ }
620
+ const nodes = [];
621
+ let totalEntities = 0;
622
+ let totalRelations = 0;
623
+ let processed = 0;
624
+ let extracted = 0;
625
+ onProgress?.({ processed, total: considered, extracted, totalEntities, totalRelations });
626
+ const reportProgress = (currentPath, result) => {
627
+ processed += 1;
628
+ if (result) {
629
+ if (result.entities.length > 0)
630
+ extracted += 1;
631
+ totalEntities += result.entities.length;
632
+ totalRelations += result.relations.length;
462
633
  }
463
- const result = {
464
- absPath: candidate.absPath,
465
- type: candidate.type,
466
- bodyHash,
467
- entities: cached.entities,
468
- relations: cached.relations,
469
- ...(cached.confidence !== undefined ? { confidence: cached.confidence } : {}),
470
- ...(cached.status ? { status: cached.status } : {}),
471
- ...(cached.reason ? { reason: cached.reason } : {}),
472
- };
473
- reportProgress(candidate.absPath, result);
474
- return result;
475
- },
476
- // Caller-set connection concurrency or 1: `resolveLlmEngineUse` does
477
- // not forward `engines.<name>.concurrency`, so config cannot raise this.
478
- llmConfig.concurrency ?? 1);
479
- }
480
- else {
481
- // ── Batched path (with incremental cache) ────────────────────────────
482
- // Chunk eligible files into groups of `batchSize` and call
483
- // `extractGraphFromBodies` once per chunk. Cache hits are resolved
484
- // before chunking so they don't consume LLM tokens in the batch call.
485
- const rawResults = new Array(eligible.length).fill(undefined);
486
- const chunkStarts = [];
487
- for (let start = 0; start < eligible.length; start += batchSize)
488
- chunkStarts.push(start);
489
- await concurrentMap(chunkStarts, async (start) => {
490
- if (signal?.aborted)
491
- return;
492
- const chunk = eligible.slice(start, start + batchSize);
493
- const reportChunkProgress = () => {
494
- for (let j = 0; j < chunk.length; j++) {
495
- const candidate = chunk[j];
496
- if (!candidate)
497
- continue;
498
- reportProgress(candidate.absPath, rawResults[start + j]);
499
- }
500
- };
501
- // Pre-resolve cache hits for this chunk; track which positions need LLM.
502
- const bodyHashes = chunk.map((c) => computeBodyHash(c.body));
503
- // Batch the cache lookup: one IN(...) query for the whole chunk instead
504
- // of N individual SELECTs. The map covers every ref in this chunk that
505
- // has any cached row; the per-position hash check happens below.
506
- const chunkCache = db && !reEnrich
507
- ? getLlmCacheEntriesByRefs(db, chunk.map((c) => c.absPath), cacheVariant)
508
- : new Map();
509
- const needsLlm = chunk.map((c, j) => {
510
- if (!db || reEnrich)
511
- return true;
512
- const cached = chunkCache.get(c.absPath);
513
- // Hash mismatch → body changed, treat as cache miss.
514
- if (!cached || cached.bodyHash !== (bodyHashes[j] ?? ""))
515
- return true;
516
- try {
517
- const parsed = validateGraphCacheShape(JSON.parse(cached.resultJson));
518
- if (!parsed)
519
- return true;
520
- telemetry.cacheHits += 1;
521
- rawResults[start + j] = {
522
- absPath: c.absPath,
523
- type: c.type,
524
- bodyHash: bodyHashes[j] ?? "",
525
- entities: parsed.entities,
526
- relations: parsed.relations,
527
- ...(parsed.confidence !== undefined ? { confidence: parsed.confidence } : {}),
528
- ...(parsed.status ? { status: parsed.status } : {}),
529
- ...(parsed.reason ? { reason: parsed.reason } : {}),
530
- };
531
- return false;
532
- }
533
- catch {
534
- return true;
535
- }
634
+ onProgress?.({
635
+ processed,
636
+ total: considered,
637
+ extracted,
638
+ totalEntities,
639
+ totalRelations,
640
+ currentPath,
536
641
  });
537
- // Secondary incremental path: reuse previous graph nodes when the body hash
538
- // still matches and DB cache is missing/stale/unavailable.
539
- if (!(reEnrich ?? false) && canReusePreviousGraph) {
540
- for (let j = 0; j < chunk.length; j++) {
541
- if (!needsLlm[j])
542
- continue;
543
- const candidate = chunk[j];
544
- if (!candidate)
545
- continue;
546
- const reused = reuseGraphNode(previousNodes, candidate, bodyHashes[j] ?? "");
547
- if (!reused)
548
- continue;
642
+ };
643
+ const extractionRunId = crypto.randomUUID();
644
+ const telemetry = {
645
+ extractorId,
646
+ extractionRunId,
647
+ model: llmRunner.connection.model,
648
+ promptVersion: graphExtract.GRAPH_EXTRACT_PROMPT_VERSION,
649
+ batchSize,
650
+ cacheHits: 0,
651
+ cacheMisses: 0,
652
+ truncationCount: 0,
653
+ failureCount: 0,
654
+ htmlErrorCount: 0,
655
+ retryAttempts: 0,
656
+ nonArrayBatchFailures: 0,
657
+ };
658
+ const runtimeTelemetry = {
659
+ truncationCount: 0,
660
+ failureCount: 0,
661
+ htmlErrorCount: 0,
662
+ retryAttempts: 0,
663
+ filteredGenericEntities: 0,
664
+ filteredInvalidRelations: 0,
665
+ filteredLowConfidenceRelations: 0,
666
+ contextBatchRetries: 0,
667
+ nonArrayBatchFailures: 0,
668
+ };
669
+ const batchState = {
670
+ batchingDisabled: false,
671
+ nonArrayBatchFailures: 0,
672
+ };
673
+ warnVerbose(`graph extraction: starting for ${considered} eligible file(s) under ${primary.path}; ` +
674
+ `includeTypes=${includeTypes.join(",")}, batchSize=${batchSize}, concurrency=${llmRunner.connection.concurrency ?? 1}, ` +
675
+ `reEnrich=${reEnrich === true}, candidateScoped=${options.candidatePaths ? "true" : "false"}.`);
676
+ const onFallback = (evt) => {
677
+ warn(`[akm] LLM fallback for ${evt.feature}: ${evt.reason}`);
678
+ };
679
+ let extractionResults;
680
+ let configFailure;
681
+ if (batchSize <= 1) {
682
+ // ── Original per-asset path (with incremental cache) ─────────────────
683
+ extractionResults = await concurrentMap(eligiblePlans, async (plan) => {
684
+ const { candidate, bodyHash } = plan;
685
+ if (signal?.aborted) {
686
+ reportProgress(candidate.absPath, undefined);
687
+ return undefined;
688
+ }
689
+ let cached;
690
+ if (plan.kind === "cache-hit") {
549
691
  telemetry.cacheHits += 1;
550
- rawResults[start + j] = {
551
- absPath: candidate.absPath,
552
- type: candidate.type,
553
- bodyHash: bodyHashes[j] ?? "",
554
- entities: reused.entities,
555
- relations: reused.relations,
556
- ...(reused.confidence !== undefined ? { confidence: reused.confidence } : {}),
557
- ...(reused.status ? { status: reused.status } : {}),
558
- ...(reused.reason ? { reason: reused.reason } : {}),
559
- };
560
- if (db) {
561
- upsertLlmCacheEntry(db, candidate.absPath, bodyHashes[j] ?? "", JSON.stringify(reused), cacheVariant);
692
+ cached = plan.cached;
693
+ if (db && plan.persistCache) {
694
+ upsertLlmCacheEntry(db, candidate.absPath, bodyHash, JSON.stringify(cached), cacheVariant);
562
695
  }
563
- needsLlm[j] = false;
564
696
  }
565
- }
566
- const uncachedChunk = chunk.filter((_, j) => needsLlm[j]);
567
- if (uncachedChunk.length === 0) {
568
- reportChunkProgress();
569
- return;
570
- }
571
- const bodies = uncachedChunk.map((c) => c.body);
572
- telemetry.cacheMisses += uncachedChunk.length;
573
- // extractGraphFromBodies always returns an array of the same length
574
- // as bodies (it falls back per-asset for any missing indices).
575
- const batchExtractions = await graphExtract.extractGraphFromBodies(llmConfig, bodies, signal, featureConfig, onFallback, { batchState, telemetry: runtimeTelemetry });
576
- // Map LLM results back to original positions and write cache entries.
577
- let llmIdx = 0;
578
- for (let j = 0; j < chunk.length; j++) {
579
- if (!needsLlm[j])
580
- continue;
581
- const candidate = chunk[j];
582
- const extraction = batchExtractions[llmIdx++];
583
- if (!candidate || !extraction)
584
- continue;
585
- if (db) {
586
- upsertLlmCacheEntry(db, candidate.absPath, bodyHashes[j] ?? "", JSON.stringify({
697
+ else {
698
+ telemetry.cacheMisses += 1;
699
+ let extraction;
700
+ try {
701
+ extraction = await graphExtract.extractGraphFromBody(llmRunner, candidate.body, signal, featureConfig, onFallback, {
702
+ batchState,
703
+ telemetry: runtimeTelemetry,
704
+ onNotices,
705
+ ...(dispatchLease ? { lease: dispatchLease } : {}),
706
+ });
707
+ }
708
+ catch (err) {
709
+ if (err instanceof ConfigError) {
710
+ configFailure ??= err;
711
+ return undefined;
712
+ }
713
+ throw err;
714
+ }
715
+ cached = {
587
716
  entities: extraction.entities,
588
717
  relations: extraction.relations,
589
718
  ...(extraction.confidence !== undefined ? { confidence: extraction.confidence } : {}),
590
719
  ...(extraction.status ? { status: extraction.status } : {}),
591
720
  ...(extraction.reason ? { reason: extraction.reason } : {}),
592
- }), cacheVariant);
721
+ };
722
+ if (db) {
723
+ upsertLlmCacheEntry(db, candidate.absPath, bodyHash, JSON.stringify(cached), cacheVariant);
724
+ }
593
725
  }
594
- rawResults[start + j] = {
726
+ const result = {
595
727
  absPath: candidate.absPath,
596
728
  type: candidate.type,
597
- bodyHash: bodyHashes[j] ?? "",
598
- entities: extraction.entities,
599
- relations: extraction.relations,
600
- ...(extraction.confidence !== undefined ? { confidence: extraction.confidence } : {}),
601
- ...(extraction.status ? { status: extraction.status } : {}),
602
- ...(extraction.reason ? { reason: extraction.reason } : {}),
729
+ bodyHash,
730
+ entities: cached.entities,
731
+ relations: cached.relations,
732
+ ...(cached.confidence !== undefined ? { confidence: cached.confidence } : {}),
733
+ ...(cached.status ? { status: cached.status } : {}),
734
+ ...(cached.reason ? { reason: cached.reason } : {}),
603
735
  };
604
- }
605
- reportChunkProgress();
606
- }, llmConfig.concurrency ?? 1);
607
- extractionResults = rawResults;
736
+ reportProgress(candidate.absPath, result);
737
+ return result;
738
+ },
739
+ // Caller-set connection concurrency or 1: `resolveLlmEngineUse` does
740
+ // not forward `engines.<name>.concurrency`, so config cannot raise this.
741
+ llmRunner.connection.concurrency ?? 1);
742
+ }
743
+ else {
744
+ const batch = await extractGraphBatches({
745
+ plans: eligiblePlans,
746
+ batchSize,
747
+ signal,
748
+ db,
749
+ cacheVariant,
750
+ telemetry,
751
+ llmRunner,
752
+ lease: dispatchLease,
753
+ featureConfig,
754
+ onFallback,
755
+ batchState,
756
+ runtimeTelemetry,
757
+ onNotices,
758
+ reportProgress,
759
+ });
760
+ extractionResults = batch.results;
761
+ configFailure ??= batch.configFailure;
762
+ }
763
+ if (configFailure)
764
+ throw configFailure;
765
+ acknowledgeQueuedGraphPlans(db, primary.path, queueExecution);
766
+ for (const result of extractionResults) {
767
+ if (!result)
768
+ continue;
769
+ nodes.push({
770
+ path: result.absPath,
771
+ type: result.type,
772
+ bodyHash: result.bodyHash,
773
+ entities: [...new Set(result.entities.map((entity) => entity.trim()).filter(Boolean))],
774
+ relations: result.relations
775
+ .map((r) => ({
776
+ from: r.from.trim(),
777
+ to: r.to.trim(),
778
+ ...(r.type ? { type: r.type.trim() } : {}),
779
+ ...(normalizeConfidence(r.confidence) !== undefined
780
+ ? { confidence: normalizeConfidence(r.confidence) }
781
+ : {}),
782
+ }))
783
+ .filter((relation) => relation.from && relation.to),
784
+ ...(normalizeConfidence(result.confidence) !== undefined
785
+ ? { confidence: normalizeConfidence(result.confidence) }
786
+ : {}),
787
+ status: result.status ?? (result.entities.length > 0 ? "extracted" : "empty"),
788
+ reason: result.reason ?? (result.entities.length > 0 ? "none" : "no_graph_content"),
789
+ extractionRunId,
790
+ });
791
+ }
792
+ const queuedNodes = options.candidatePaths
793
+ ? []
794
+ : previousGraph.files.filter((node) => queuePlans.some((plan) => plan.filePath === node.path && plan.kind !== "discard") &&
795
+ !nodes.some((candidate) => candidate.path === node.path));
796
+ const mergedNodes = mergeGraphNodes(previousGraph.files, [...queuedNodes, ...nodes], options.candidatePaths);
797
+ const assetRefs = mergedNodes.map((node) => node.path);
798
+ const deduped = deduplicateGraph(mergedNodes.map((node) => ({ entities: node.entities, relations: node.relations })), assetRefs);
799
+ telemetry.truncationCount = runtimeTelemetry.truncationCount ?? 0;
800
+ telemetry.failureCount = runtimeTelemetry.failureCount ?? 0;
801
+ telemetry.htmlErrorCount = runtimeTelemetry.htmlErrorCount ?? 0;
802
+ telemetry.retryAttempts = runtimeTelemetry.retryAttempts ?? 0;
803
+ telemetry.nonArrayBatchFailures = runtimeTelemetry.nonArrayBatchFailures ?? 0;
804
+ const qualityConsidered = mergedNodes.length;
805
+ const qualityExtracted = mergedNodes.filter((node) => node.status === "extracted" && node.entities.length > 0).length;
806
+ const quality = computeGraphQualityTelemetry(qualityConsidered, qualityExtracted, deduped.entities.length, deduped.relations.length);
807
+ const warnings = buildLowQualityWarnings(quality, telemetry);
808
+ for (const warning of warnings)
809
+ warnVerbose(`graph extraction quality: ${warning}`);
810
+ const graph = {
811
+ schemaVersion: GRAPH_FILE_SCHEMA_VERSION,
812
+ generatedAt: new Date().toISOString(),
813
+ stashRoot: primary.path,
814
+ files: mergedNodes,
815
+ entities: deduped.entities,
816
+ relations: deduped.relations,
817
+ quality,
818
+ telemetry,
819
+ };
820
+ const written = writeGraphFile(primary.path, graph, db);
821
+ warnVerbose(`graph extraction: ${written ? "persisted" : "did not persist"} graph for ${primary.path}; ` +
822
+ `considered=${considered}, extractedThisRun=${extracted}, storedFiles=${mergedNodes.length}, ` +
823
+ `entities=${deduped.entities.length}, relations=${deduped.relations.length}, coverage=${quality.extractionCoverage}.`);
824
+ return {
825
+ considered,
826
+ extracted,
827
+ totalEntities,
828
+ totalRelations,
829
+ written,
830
+ quality,
831
+ telemetry,
832
+ warnings,
833
+ ...(noticesByKey.size > 0 ? { notices: Object.freeze([...noticesByKey.values()]) } : {}),
834
+ };
608
835
  }
609
- for (const result of extractionResults) {
610
- if (!result)
611
- continue;
612
- nodes.push({
613
- path: result.absPath,
614
- type: result.type,
615
- bodyHash: result.bodyHash,
616
- entities: [...new Set(result.entities.map((entity) => entity.trim()).filter(Boolean))],
617
- relations: result.relations
618
- .map((r) => ({
619
- from: r.from.trim(),
620
- to: r.to.trim(),
621
- ...(r.type ? { type: r.type.trim() } : {}),
622
- ...(normalizeConfidence(r.confidence) !== undefined ? { confidence: normalizeConfidence(r.confidence) } : {}),
623
- }))
624
- .filter((relation) => relation.from && relation.to),
625
- ...(normalizeConfidence(result.confidence) !== undefined
626
- ? { confidence: normalizeConfidence(result.confidence) }
627
- : {}),
628
- status: result.status ?? (result.entities.length > 0 ? "extracted" : "empty"),
629
- reason: result.reason ?? (result.entities.length > 0 ? "none" : "no_graph_content"),
630
- extractionRunId,
631
- });
836
+ finally {
837
+ if (dispatchLease)
838
+ disposeLoweredExecutionDispatchLease(dispatchLease);
632
839
  }
633
- const mergedNodes = mergeGraphNodes(previousGraph.files, nodes, options.candidatePaths);
634
- const assetRefs = mergedNodes.map((node) => node.path);
635
- const deduped = deduplicateGraph(mergedNodes.map((node) => ({ entities: node.entities, relations: node.relations })), assetRefs);
636
- telemetry.truncationCount = runtimeTelemetry.truncationCount ?? 0;
637
- telemetry.failureCount = runtimeTelemetry.failureCount ?? 0;
638
- telemetry.htmlErrorCount = runtimeTelemetry.htmlErrorCount ?? 0;
639
- telemetry.retryAttempts = runtimeTelemetry.retryAttempts ?? 0;
640
- telemetry.nonArrayBatchFailures = runtimeTelemetry.nonArrayBatchFailures ?? 0;
641
- const qualityConsidered = mergedNodes.length;
642
- const qualityExtracted = mergedNodes.filter((node) => node.status === "extracted" && node.entities.length > 0).length;
643
- const quality = computeGraphQualityTelemetry(qualityConsidered, qualityExtracted, deduped.entities.length, deduped.relations.length);
644
- const warnings = buildLowQualityWarnings(quality, telemetry);
645
- for (const warning of warnings)
646
- warnVerbose(`graph extraction quality: ${warning}`);
647
- const graph = {
648
- schemaVersion: GRAPH_FILE_SCHEMA_VERSION,
649
- generatedAt: new Date().toISOString(),
650
- stashRoot: primary.path,
651
- files: mergedNodes,
652
- entities: deduped.entities,
653
- relations: deduped.relations,
654
- quality,
655
- telemetry,
656
- };
657
- const written = writeGraphFile(primary.path, graph, db);
658
- warnVerbose(`graph extraction: ${written ? "persisted" : "did not persist"} graph for ${primary.path}; ` +
659
- `considered=${considered}, extractedThisRun=${extracted}, storedFiles=${mergedNodes.length}, ` +
660
- `entities=${deduped.entities.length}, relations=${deduped.relations.length}, coverage=${quality.extractionCoverage}.`);
661
- return {
662
- considered,
663
- extracted,
664
- totalEntities,
665
- totalRelations,
666
- written,
667
- quality,
668
- telemetry,
669
- warnings,
670
- };
671
840
  }
672
841
  /**
673
842
  * Infer the asset type (`memory`, `knowledge`, …) for a path from the stash
@@ -691,16 +860,17 @@ function inferGraphTypeForPath(stashRoot, absPath) {
691
860
  *
692
861
  * Re-reads the body from disk at call time (the queued body_hash is NOT trusted
693
862
  * blindly — the file may have been deleted or changed since enqueue) and skips
694
- * silently (returns `false`) when the file is gone or empty. Resolves the LLM
695
- * via {@link resolveIndexPassLLM} (model-available guard: returns `false` when
696
- * no provider is configured) UNLESS `opts.llmOverride` is supplied, in which
697
- * case the override is the extractor seam (used by tests and by callers that
698
- * already hold a resolved model).
863
+ * silently (returns `false`) when the file is gone or empty. Resolves a frozen
864
+ * LLM execution via {@link resolveIndexPassExecution} (model-available guard:
865
+ * returns `false` when no provider is configured) UNLESS a caller supplies
866
+ * `opts.llmRunner` or `opts.llmOverride`. Those seams let a command reuse an
867
+ * invocation-owned selection or provide a test extractor without re-resolving.
699
868
  *
700
869
  * Returns `true` when a graph row was written for the file, `false` on any
701
870
  * skip (missing file, empty body, unknown type, no model, or extraction error).
702
871
  */
703
- export async function extractGraphForSingleFile(db, stashRoot, filePath, bodyHash, opts) {
872
+ async function extractGraphForSingleFileRevision(db, stashRoot, filePath, opts) {
873
+ let ownedLease;
704
874
  try {
705
875
  // Re-read from disk — never trust a stale queued body.
706
876
  let raw;
@@ -708,14 +878,14 @@ export async function extractGraphForSingleFile(db, stashRoot, filePath, bodyHas
708
878
  raw = fs.readFileSync(filePath, "utf8");
709
879
  }
710
880
  catch {
711
- return false; // file gone / unreadable → silent skip
881
+ return { written: false }; // file gone / unreadable → silent skip
712
882
  }
713
883
  const parsed = parseFrontmatter(raw);
714
884
  const body = parsed.content.trim();
715
885
  if (!body)
716
- return false;
886
+ return { written: false };
717
887
  const type = inferGraphTypeForPath(stashRoot, filePath) ?? "memory";
718
- const effectiveHash = bodyHash ?? computeBodyHash(body);
888
+ const effectiveHash = computeBodyHash(body);
719
889
  // Extract — via the injected seam, or the real per-asset path.
720
890
  let extraction;
721
891
  if (opts?.llmOverride) {
@@ -728,16 +898,26 @@ export async function extractGraphForSingleFile(db, stashRoot, filePath, bodyHas
728
898
  }
729
899
  else {
730
900
  const config = opts?.config ?? loadConfig();
731
- const invocationOwnsConnection = Object.hasOwn(opts ?? {}, "llmConfig");
732
- if (!invocationOwnsConnection && !isProcessEnabled("index", "graph_extraction", config))
733
- return false;
734
- const llmConfig = Object.hasOwn(opts ?? {}, "llmConfig") ? opts?.llmConfig : resolveIndexPassLLM("graph", config);
735
- if (!llmConfig)
736
- return false; // model-available guard
737
- const featureConfig = invocationOwnsConnection
901
+ const invocationOwnsRunner = Object.hasOwn(opts ?? {}, "llmRunner");
902
+ if (!invocationOwnsRunner && !isProcessEnabled("index", "graph_extraction", config))
903
+ return { written: false };
904
+ const execution = Object.hasOwn(opts ?? {}, "llmRunner")
905
+ ? Object.freeze({ runner: opts?.llmRunner ?? undefined, notices: Object.freeze([]) })
906
+ : resolveIndexPassExecution("graph", config);
907
+ opts?.onNotices?.(execution.notices);
908
+ const llmRunner = execution.runner;
909
+ if (!llmRunner)
910
+ return { written: false }; // model-available guard
911
+ const featureConfig = invocationOwnsRunner
738
912
  ? { ...config, index: { ...config.index, graph: { ...config.index?.graph, enabled: true } } }
739
913
  : config;
740
- const result = await graphExtract.extractGraphFromBody(llmConfig, body, opts?.signal, featureConfig);
914
+ const lease = opts?.lease ?? (await preflightStructuredLlmRunner(llmRunner));
915
+ if (!opts?.lease)
916
+ ownedLease = lease;
917
+ const result = await graphExtract.extractGraphFromBody(llmRunner, body, opts?.signal, featureConfig, undefined, {
918
+ ...(opts?.onNotices ? { onNotices: opts.onNotices } : {}),
919
+ lease,
920
+ });
741
921
  extraction = {
742
922
  entities: result.entities,
743
923
  relations: result.relations,
@@ -785,13 +965,22 @@ export async function extractGraphForSingleFile(db, stashRoot, filePath, bodyHas
785
965
  quality,
786
966
  ...(previousGraph.telemetry ? { telemetry: previousGraph.telemetry } : {}),
787
967
  };
788
- return writeGraphFile(stashRoot, graph, db);
968
+ return writeGraphFile(stashRoot, graph, db) ? { written: true, bodyHash: effectiveHash } : { written: false };
789
969
  }
790
970
  catch (err) {
971
+ if (err instanceof ConfigError)
972
+ throw err;
791
973
  rethrowIfTestIsolationError(err);
792
- return false;
974
+ return { written: false };
975
+ }
976
+ finally {
977
+ if (ownedLease)
978
+ disposeLoweredExecutionDispatchLease(ownedLease);
793
979
  }
794
980
  }
981
+ export async function extractGraphForSingleFile(db, stashRoot, filePath, _bodyHash, opts) {
982
+ return (await extractGraphForSingleFileRevision(db, stashRoot, filePath, opts)).written;
983
+ }
795
984
  // ── Eligible-file detection ─────────────────────────────────────────────────
796
985
  /**
797
986
  * Rank eligible graph-extraction candidates by their entry `utility_scores`,