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

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 (257) hide show
  1. package/CHANGELOG.md +249 -9
  2. package/SECURITY.md +23 -24
  3. package/dist/assets/help/help-improve.md +10 -10
  4. package/dist/assets/hints/cli-hints-full.md +44 -10
  5. package/dist/assets/hints/cli-hints-short.md +6 -2
  6. package/dist/assets/{profiles → improve-strategies}/catchup.json +3 -1
  7. package/dist/assets/{profiles → improve-strategies}/consolidate.json +3 -1
  8. package/dist/assets/{profiles → improve-strategies}/default.json +1 -0
  9. package/dist/assets/{profiles → improve-strategies}/frequent.json +3 -1
  10. package/dist/assets/{profiles → improve-strategies}/graph-refresh.json +4 -2
  11. package/dist/assets/{profiles → improve-strategies}/memory-focus.json +4 -1
  12. package/dist/assets/{profiles → improve-strategies}/proactive-maintenance.json +3 -3
  13. package/dist/assets/{profiles → improve-strategies}/quick.json +3 -1
  14. package/dist/assets/{profiles → improve-strategies}/recombine-only.json +2 -0
  15. package/dist/assets/{profiles → improve-strategies}/reflect-distill.json +4 -4
  16. package/dist/assets/{profiles → improve-strategies}/synthesize.json +2 -0
  17. package/dist/assets/stash-skeleton/README.md +28 -0
  18. package/dist/assets/stash-skeleton/facts/conventions/assets/agent.md +6 -0
  19. package/dist/assets/stash-skeleton/facts/conventions/assets/command.md +6 -0
  20. package/dist/assets/stash-skeleton/facts/conventions/assets/fact.md +12 -1
  21. package/dist/assets/stash-skeleton/facts/conventions/assets/knowledge.md +11 -1
  22. package/dist/assets/stash-skeleton/facts/conventions/assets/lesson.md +6 -0
  23. package/dist/assets/stash-skeleton/facts/conventions/assets/memory.md +9 -0
  24. package/dist/assets/stash-skeleton/facts/conventions/assets/script.md +7 -0
  25. package/dist/assets/stash-skeleton/facts/conventions/assets/skill.md +7 -0
  26. package/dist/assets/stash-skeleton/facts/conventions/assets/workflow.md +6 -0
  27. package/dist/assets/stash-skeleton/facts/conventions/backlinks.md +98 -0
  28. package/dist/assets/stash-skeleton/facts/conventions/domains.md +63 -0
  29. package/dist/assets/stash-skeleton/facts/conventions/organization.md +127 -0
  30. package/dist/assets/tasks/core/backup.yml +3 -2
  31. package/dist/assets/tasks/core/extract.yml +1 -0
  32. package/dist/assets/tasks/core/improve.yml +1 -0
  33. package/dist/assets/tasks/core/index-refresh.yml +1 -0
  34. package/dist/assets/tasks/core/sync.yml +1 -0
  35. package/dist/assets/tasks/core/version-check.yml +1 -0
  36. package/dist/assets/tasks/graph-refresh-weekly.yml +4 -4
  37. package/dist/assets/templates/html/health.html +5 -1
  38. package/dist/cli/config-migrate.js +547 -126
  39. package/dist/cli/config-validate.js +10 -8
  40. package/dist/cli.js +50 -14
  41. package/dist/commands/agent/agent-dispatch.js +17 -16
  42. package/dist/commands/agent/agent-support.js +0 -24
  43. package/dist/commands/agent/contribute-cli.js +5 -15
  44. package/dist/commands/backup-cli.js +56 -0
  45. package/dist/commands/config-cli.js +48 -155
  46. package/dist/commands/env/secret.js +8 -5
  47. package/dist/commands/feedback-cli.js +24 -7
  48. package/dist/commands/health/checks.js +200 -74
  49. package/dist/commands/health/html-report.js +4 -0
  50. package/dist/commands/health/improve-metrics.js +30 -32
  51. package/dist/commands/health/llm-usage.js +19 -19
  52. package/dist/commands/health/md-report.js +4 -0
  53. package/dist/commands/health/metrics.js +2 -1
  54. package/dist/commands/health/surfaces.js +7 -5
  55. package/dist/commands/health.js +1 -1
  56. package/dist/commands/improve/anti-collapse.js +3 -3
  57. package/dist/commands/improve/collapse-detector.js +5 -5
  58. package/dist/commands/improve/consolidate/chunking.js +2 -2
  59. package/dist/commands/improve/consolidate.js +148 -88
  60. package/dist/commands/improve/distill/promote-memory.js +13 -18
  61. package/dist/commands/improve/distill/quality-gate.js +6 -8
  62. package/dist/commands/improve/distill-guards.js +1 -1
  63. package/dist/commands/improve/distill-promotion-policy.js +32 -26
  64. package/dist/commands/improve/distill.js +68 -41
  65. package/dist/commands/improve/eligibility.js +31 -20
  66. package/dist/commands/improve/extract-cli.js +47 -19
  67. package/dist/commands/improve/extract-prompt.js +2 -2
  68. package/dist/commands/improve/extract.js +153 -73
  69. package/dist/commands/improve/improve-auto-accept.js +14 -28
  70. package/dist/commands/improve/improve-cli.js +41 -17
  71. package/dist/commands/improve/improve-result-file.js +30 -24
  72. package/dist/commands/improve/improve-strategies.js +138 -0
  73. package/dist/commands/improve/improve.js +109 -38
  74. package/dist/commands/improve/locks.js +66 -45
  75. package/dist/commands/improve/loop-stages.js +86 -39
  76. package/dist/commands/improve/memory/memory-belief.js +79 -7
  77. package/dist/commands/improve/memory/memory-contradiction-detect.js +12 -4
  78. package/dist/commands/improve/preparation.js +122 -90
  79. package/dist/commands/improve/procedural.js +13 -8
  80. package/dist/commands/improve/recombine.js +33 -8
  81. package/dist/commands/improve/reflect.js +143 -228
  82. package/dist/commands/improve/salience.js +5 -1
  83. package/dist/commands/improve/shared.js +11 -5
  84. package/dist/commands/improve/source-identity.js +43 -0
  85. package/dist/commands/lint/base-linter.js +152 -42
  86. package/dist/commands/migrate-cli.js +36 -0
  87. package/dist/commands/mv-cli.js +1198 -0
  88. package/dist/commands/proposal/drain.js +16 -4
  89. package/dist/commands/proposal/proposal-cli.js +21 -10
  90. package/dist/commands/proposal/proposal.js +16 -55
  91. package/dist/commands/proposal/propose.js +64 -69
  92. package/dist/commands/proposal/repository.js +664 -58
  93. package/dist/commands/read/curate.js +4 -2
  94. package/dist/commands/read/knowledge.js +440 -5
  95. package/dist/commands/read/remember-cli.js +39 -2
  96. package/dist/commands/read/search-cli.js +6 -1
  97. package/dist/commands/read/search.js +6 -2
  98. package/dist/commands/read/show.js +8 -7
  99. package/dist/commands/registry-cli.js +29 -14
  100. package/dist/commands/remember.js +2 -0
  101. package/dist/commands/sources/init.js +13 -14
  102. package/dist/commands/sources/schema-repair.js +2 -4
  103. package/dist/commands/sources/self-update.js +156 -112
  104. package/dist/commands/sources/source-add.js +62 -73
  105. package/dist/commands/sources/source-manage.js +50 -46
  106. package/dist/commands/sources/sources-cli.js +7 -2
  107. package/dist/commands/sources/stash-cli.js +41 -4
  108. package/dist/commands/tasks/default-tasks.js +12 -12
  109. package/dist/commands/tasks/tasks-cli.js +7 -3
  110. package/dist/commands/tasks/tasks.js +113 -18
  111. package/dist/commands/wiki-cli.js +9 -10
  112. package/dist/core/asset/frontmatter.js +12 -2
  113. package/dist/core/common.js +41 -6
  114. package/dist/core/config/config-io.js +30 -19
  115. package/dist/core/config/config-schema.js +373 -69
  116. package/dist/core/config/config-types.js +3 -3
  117. package/dist/core/config/config-version.js +29 -0
  118. package/dist/core/config/config-walker.js +98 -27
  119. package/dist/core/config/config.js +135 -268
  120. package/dist/core/config/deep-merge.js +41 -0
  121. package/dist/core/config/engine-semantics.js +32 -0
  122. package/dist/core/errors.js +2 -2
  123. package/dist/core/extra-params.js +61 -0
  124. package/dist/core/file-lock.js +202 -57
  125. package/dist/core/improve-result.js +178 -0
  126. package/dist/core/maintenance-barrier.js +119 -0
  127. package/dist/core/migration-backup.js +1061 -0
  128. package/dist/core/migration-operation.js +44 -0
  129. package/dist/core/paths.js +3 -0
  130. package/dist/core/redaction.js +358 -0
  131. package/dist/core/state/migrations.js +15 -3
  132. package/dist/core/state-db.js +60 -2
  133. package/dist/core/write-source.js +86 -18
  134. package/dist/indexer/db/db.js +212 -41
  135. package/dist/indexer/graph/graph-extraction.js +28 -16
  136. package/dist/indexer/index-writer-lock.js +59 -51
  137. package/dist/indexer/index-written-assets.js +75 -49
  138. package/dist/indexer/indexer.js +52 -3
  139. package/dist/indexer/passes/memory-inference.js +10 -6
  140. package/dist/indexer/passes/metadata.js +250 -0
  141. package/dist/indexer/search/db-search.js +111 -44
  142. package/dist/indexer/search/fts-query.js +41 -0
  143. package/dist/indexer/search/ranking-contributors.js +48 -0
  144. package/dist/indexer/search/ranking.js +36 -23
  145. package/dist/indexer/search/search-fields.js +11 -1
  146. package/dist/indexer/search/search-source.js +2 -2
  147. package/dist/indexer/usage/usage-events.js +8 -2
  148. package/dist/integrations/agent/builder-shared.js +7 -0
  149. package/dist/integrations/agent/builders.js +5 -52
  150. package/dist/integrations/agent/config.js +3 -143
  151. package/dist/integrations/agent/detect.js +17 -2
  152. package/dist/integrations/agent/engine-resolution.js +209 -0
  153. package/dist/integrations/agent/index.js +1 -2
  154. package/dist/integrations/agent/model-aliases.js +7 -2
  155. package/dist/integrations/agent/profiles.js +6 -99
  156. package/dist/integrations/agent/runner-dispatch.js +76 -13
  157. package/dist/integrations/agent/runner.js +76 -207
  158. package/dist/integrations/agent/spawn.js +4 -6
  159. package/dist/integrations/harnesses/aider/agent-builder.js +2 -3
  160. package/dist/integrations/harnesses/aider/index.js +0 -1
  161. package/dist/integrations/harnesses/amazonq/agent-builder.js +2 -3
  162. package/dist/integrations/harnesses/amazonq/index.js +0 -1
  163. package/dist/integrations/harnesses/claude/agent-builder.js +2 -3
  164. package/dist/integrations/harnesses/claude/index.js +0 -2
  165. package/dist/integrations/harnesses/codex/agent-builder.js +2 -3
  166. package/dist/integrations/harnesses/codex/index.js +0 -1
  167. package/dist/integrations/harnesses/copilot/agent-builder.js +2 -3
  168. package/dist/integrations/harnesses/copilot/index.js +0 -1
  169. package/dist/integrations/harnesses/gemini/agent-builder.js +2 -3
  170. package/dist/integrations/harnesses/gemini/index.js +0 -1
  171. package/dist/integrations/harnesses/index.js +1 -24
  172. package/dist/integrations/harnesses/opencode/agent-builder.js +2 -3
  173. package/dist/integrations/harnesses/opencode/index.js +0 -6
  174. package/dist/integrations/harnesses/opencode-sdk/harness.js +1 -6
  175. package/dist/integrations/harnesses/opencode-sdk/sdk-runner.js +274 -191
  176. package/dist/integrations/harnesses/openhands/agent-builder.js +2 -3
  177. package/dist/integrations/harnesses/openhands/index.js +0 -1
  178. package/dist/integrations/harnesses/pi/agent-builder.js +2 -3
  179. package/dist/integrations/harnesses/pi/index.js +0 -1
  180. package/dist/integrations/harnesses/types.js +1 -32
  181. package/dist/integrations/lockfile.js +32 -21
  182. package/dist/llm/client.js +48 -14
  183. package/dist/llm/feature-gate.js +15 -47
  184. package/dist/llm/graph-extract.js +1 -1
  185. package/dist/llm/index-passes.js +8 -42
  186. package/dist/llm/memory-infer-impl.js +1 -1
  187. package/dist/llm/usage-persist.js +4 -0
  188. package/dist/llm/usage-telemetry.js +35 -5
  189. package/dist/output/shapes/helpers.js +2 -1
  190. package/dist/output/shapes/passthrough.js +2 -0
  191. package/dist/output/text/helpers.js +3 -1
  192. package/dist/schemas/akm-config.json +11013 -8600
  193. package/dist/schemas/akm-task.json +87 -0
  194. package/dist/schemas/akm-workflow.json +57 -13
  195. package/dist/scripts/migrate-storage.js +8502 -543
  196. package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +8493 -515
  197. package/dist/setup/detected-engines.js +136 -0
  198. package/dist/setup/engine-config.js +99 -0
  199. package/dist/setup/setup.js +237 -133
  200. package/dist/setup/steps/connection.js +62 -33
  201. package/dist/setup/steps/platforms.js +4 -4
  202. package/dist/setup/steps/tasks.js +1 -1
  203. package/dist/setup/steps.js +3 -2
  204. package/dist/sources/providers/git-stash.js +58 -21
  205. package/dist/sources/providers/git.js +1 -1
  206. package/dist/storage/database.js +13 -1
  207. package/dist/storage/engines/sqlite-migrations.js +139 -3
  208. package/dist/storage/repositories/events-repository.js +24 -0
  209. package/dist/storage/repositories/improve-runs-repository.js +5 -5
  210. package/dist/storage/repositories/proposals-repository.js +14 -0
  211. package/dist/storage/repositories/task-history-repository.js +78 -0
  212. package/dist/storage/repositories/workflow-runs-repository.js +9 -8
  213. package/dist/tasks/embedded.js +2 -0
  214. package/dist/tasks/parser.js +138 -52
  215. package/dist/tasks/runner.js +71 -75
  216. package/dist/tasks/schema.js +1 -1
  217. package/dist/tasks/validator.js +11 -6
  218. package/dist/wiki/wiki.js +9 -8
  219. package/dist/workflows/authoring/workflow-program-template.yaml +3 -3
  220. package/dist/workflows/concurrency-policy.js +15 -0
  221. package/dist/workflows/db.js +78 -15
  222. package/dist/workflows/exec/brief.js +18 -24
  223. package/dist/workflows/exec/frozen-judge.js +47 -0
  224. package/dist/workflows/exec/native-executor.js +124 -65
  225. package/dist/workflows/exec/report.js +93 -33
  226. package/dist/workflows/exec/run-workflow.js +38 -25
  227. package/dist/workflows/exec/scheduler.js +12 -41
  228. package/dist/workflows/exec/step-work.js +91 -35
  229. package/dist/workflows/ir/compile.js +13 -26
  230. package/dist/workflows/ir/freeze.js +243 -0
  231. package/dist/workflows/ir/plan-hash.js +40 -5
  232. package/dist/workflows/ir/schema.js +542 -1
  233. package/dist/workflows/parser.js +7 -0
  234. package/dist/workflows/program/parser.js +132 -23
  235. package/dist/workflows/program/project.js +3 -4
  236. package/dist/workflows/program/schema.js +2 -2
  237. package/dist/workflows/resource-limits.js +20 -0
  238. package/dist/workflows/runtime/plan-classifier.js +187 -0
  239. package/dist/workflows/runtime/runs.js +54 -47
  240. package/dist/workflows/runtime/workflow-asset-loader.js +0 -22
  241. package/dist/workflows/validator.js +49 -2
  242. package/docs/data-and-telemetry.md +2 -2
  243. package/docs/migration/release-notes/0.6.0.md +1 -1
  244. package/docs/migration/release-notes/0.7.0.md +5 -4
  245. package/docs/migration/release-notes/0.9.0.md +24 -7
  246. package/docs/migration/v0.8-to-v0.9.md +506 -0
  247. package/package.json +4 -2
  248. package/schemas/akm-config.json +16638 -0
  249. package/schemas/akm-task.json +87 -0
  250. package/schemas/akm-workflow.json +372 -0
  251. package/dist/commands/improve/improve-profiles.js +0 -168
  252. package/dist/core/config/config-migration.js +0 -602
  253. package/dist/core/deep-merge.js +0 -38
  254. package/dist/llm/call-ai.js +0 -62
  255. package/dist/setup/legacy-config.js +0 -106
  256. package/docs/README.md +0 -104
  257. /package/dist/assets/{profiles → improve-strategies}/thorough.json +0 -0
@@ -16,7 +16,7 @@
16
16
  * 1. An LLM profile must be configured (no provider = no extraction). When
17
17
  * absent, `resolveIndexPassLLM("graph", config)` returns `undefined`
18
18
  * and the pass short-circuits.
19
- * 2. `profiles.improve.default.processes.graphExtraction.enabled !== false`
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
22
22
  * feature-gate layer (no network call may ever issue).
@@ -260,7 +260,7 @@ function reuseGraphNode(previousNodes, candidate, bodyHash) {
260
260
  * 1. **Provider configured** — an LLM profile must be selectable. Without a
261
261
  * configured provider, `resolveIndexPassLLM("graph", config)` returns
262
262
  * `undefined` (the pass cannot run because there is no model to call).
263
- * 2. **Feature gate** — `profiles.improve.default.processes.graphExtraction.enabled`
263
+ * 2. **Feature gate** — the selected strategy's `processes.graphExtraction.enabled`
264
264
  * (defaults to `true`). When `false`, no network call may issue regardless
265
265
  * of per-pass settings.
266
266
  * 3. **Per-pass gate** — `index.graph.llm` (defaults to `true`). When
@@ -276,21 +276,25 @@ function reuseGraphNode(previousNodes, candidate, bodyHash) {
276
276
  */
277
277
  export async function runGraphExtractionPass(ctx) {
278
278
  const { config, sources, signal, db, reEnrich, onProgress, options = {} } = ctx;
279
+ const invocationOwnsConnection = Object.hasOwn(ctx, "llmConfig");
279
280
  // Gate 1 — feature gate via isProcessEnabled, which reads the 0.8.0 path
280
- // (profiles.improve.default.processes.graphExtraction.enabled). Defaults to
281
+ // (selected strategy's processes.graphExtraction.enabled). Defaults to
281
282
  // enabled when the key is absent.
282
- if (!isProcessEnabled("index", "graph_extraction", config))
283
+ if (!invocationOwnsConnection && !isProcessEnabled("index", "graph_extraction", config))
283
284
  return { ...EMPTY_RESULT };
284
285
  // Gate 2 — per-pass opt-out (#208). Returns the resolved llm config or
285
286
  // `undefined` when the pass should not run.
286
- const llmConfig = resolveIndexPassLLM("graph", config);
287
+ const llmConfig = Object.hasOwn(ctx, "llmConfig") ? ctx.llmConfig : resolveIndexPassLLM("graph", config);
287
288
  if (!llmConfig) {
288
- const reason = getIndexPassConfig(config.index, "graph")?.llm === false
289
- ? "index.graph.llm is false"
290
- : "no default LLM profile is configured";
289
+ const reason = getIndexPassConfig(config.index, "graph")?.enabled === false
290
+ ? "index.graph.enabled is false"
291
+ : "no LLM engine is configured";
291
292
  warnVerbose(`graph extraction: skipped because ${reason}.`);
292
293
  return { ...EMPTY_RESULT };
293
294
  }
295
+ const featureConfig = invocationOwnsConnection
296
+ ? { ...config, index: { ...config.index, graph: { ...config.index?.graph, enabled: true } } }
297
+ : config;
294
298
  // The pass only writes to the primary (working) stash. Read-only caches
295
299
  // (git, npm, website) are deliberately untouched — the graph artifact for
296
300
  // those sources would be clobbered by the next sync().
@@ -309,10 +313,14 @@ export async function runGraphExtractionPass(ctx) {
309
313
  for (const queued of drained) {
310
314
  if (signal?.aborted)
311
315
  break;
312
- await extractGraphForSingleFile(db, primary.path, queued.filePath, queued.bodyHash, { config, signal });
316
+ await extractGraphForSingleFile(db, primary.path, queued.filePath, queued.bodyHash, {
317
+ config: featureConfig,
318
+ signal,
319
+ llmConfig,
320
+ });
313
321
  }
314
322
  }
315
- const includeTypes = getGraphExtractionIncludeTypes(config);
323
+ const includeTypes = options.includeTypes ?? getGraphExtractionIncludeTypes(config);
316
324
  let eligible = collectEligibleFiles(primary.path, includeTypes).filter((candidate) => !options.candidatePaths || options.candidatePaths.has(candidate.absPath));
317
325
  // P2 (#624): when topN is set and a DB is available, rank the (already
318
326
  // candidate-filtered) eligible set by utility_scores DESC and keep only the
@@ -357,7 +365,7 @@ export async function runGraphExtractionPass(ctx) {
357
365
  // Resolve the effective batch size. Falls back to
358
366
  // DEFAULT_GRAPH_EXTRACTION_BATCH_SIZE (4) when unset, and clamps against
359
367
  // `llm.contextLength` if the model's context window is configured.
360
- const batchSize = resolveBatchSize(getIndexPassConfig(config.index, "graph")?.graphExtractionBatchSize, llmConfig.contextLength);
368
+ const batchSize = resolveBatchSize(options.batchSize ?? getIndexPassConfig(config.index, "graph")?.graphExtractionBatchSize, llmConfig.contextLength);
361
369
  const extractionRunId = crypto.randomUUID();
362
370
  const extractorId = getGraphExtractorId({ model: llmConfig.model, batchSize, includeTypes });
363
371
  const cacheVariant = extractorId;
@@ -438,7 +446,7 @@ export async function runGraphExtractionPass(ctx) {
438
446
  }
439
447
  if (!cached) {
440
448
  telemetry.cacheMisses += 1;
441
- const extraction = await graphExtract.extractGraphFromBody(llmConfig, candidate.body, signal, config, onFallback, { batchState, telemetry: runtimeTelemetry });
449
+ const extraction = await graphExtract.extractGraphFromBody(llmConfig, candidate.body, signal, featureConfig, onFallback, { batchState, telemetry: runtimeTelemetry });
442
450
  cached = {
443
451
  entities: extraction.entities,
444
452
  relations: extraction.relations,
@@ -562,7 +570,7 @@ export async function runGraphExtractionPass(ctx) {
562
570
  telemetry.cacheMisses += uncachedChunk.length;
563
571
  // extractGraphFromBodies always returns an array of the same length
564
572
  // as bodies (it falls back per-asset for any missing indices).
565
- const batchExtractions = await graphExtract.extractGraphFromBodies(llmConfig, bodies, signal, config, onFallback, { batchState, telemetry: runtimeTelemetry });
573
+ const batchExtractions = await graphExtract.extractGraphFromBodies(llmConfig, bodies, signal, featureConfig, onFallback, { batchState, telemetry: runtimeTelemetry });
566
574
  // Map LLM results back to original positions and write cache entries.
567
575
  let llmIdx = 0;
568
576
  for (let j = 0; j < chunk.length; j++) {
@@ -718,12 +726,16 @@ export async function extractGraphForSingleFile(db, stashRoot, filePath, bodyHas
718
726
  }
719
727
  else {
720
728
  const config = opts?.config ?? loadConfig();
721
- if (!isProcessEnabled("index", "graph_extraction", config))
729
+ const invocationOwnsConnection = Object.hasOwn(opts ?? {}, "llmConfig");
730
+ if (!invocationOwnsConnection && !isProcessEnabled("index", "graph_extraction", config))
722
731
  return false;
723
- const llmConfig = resolveIndexPassLLM("graph", config);
732
+ const llmConfig = Object.hasOwn(opts ?? {}, "llmConfig") ? opts?.llmConfig : resolveIndexPassLLM("graph", config);
724
733
  if (!llmConfig)
725
734
  return false; // model-available guard
726
- const result = await graphExtract.extractGraphFromBody(llmConfig, body, opts?.signal, config);
735
+ const featureConfig = invocationOwnsConnection
736
+ ? { ...config, index: { ...config.index, graph: { ...config.index?.graph, enabled: true } } }
737
+ : config;
738
+ const result = await graphExtract.extractGraphFromBody(llmConfig, body, opts?.signal, featureConfig);
727
739
  extraction = {
728
740
  entities: result.entities,
729
741
  relations: result.relations,
@@ -1,17 +1,18 @@
1
1
  // This Source Code Form is subject to the terms of the Mozilla Public
2
2
  // License, v. 2.0. If a copy of the MPL was not distributed with this
3
3
  // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
+ import { AsyncLocalStorage } from "node:async_hooks";
4
5
  import fs from "node:fs";
5
6
  import path from "node:path";
6
- import { probeLock, releaseLock, releaseLockIfOwned, tryAcquireLockSync } from "../core/file-lock.js";
7
+ import { createLockPayload, probeLock, reclaimStaleLock, releaseLock, tryAcquireLockSync, } from "../core/file-lock.js";
8
+ import { tryAcquireMaintenanceBarrier } from "../core/maintenance-barrier.js";
7
9
  import { getDbPath, getIndexWriterLockPath } from "../core/paths.js";
8
10
  const INDEX_WRITER_LOCK_STALE_AFTER_MS = 12 * 60 * 60 * 1000;
9
11
  const INDEX_WRITER_WAIT_MS = 100;
10
12
  const DEFAULT_INDEX_WRITER_MAX_WAIT_MS = 10 * 60 * 1000;
11
- const heldLocks = new Map();
12
- function buildPayload(purpose, pid = process.pid) {
13
- return JSON.stringify({
14
- pid,
13
+ const leaseContext = new AsyncLocalStorage();
14
+ function buildPayload(purpose) {
15
+ return createLockPayload({
15
16
  purpose,
16
17
  dbPath: getDbPath(),
17
18
  startedAt: new Date().toISOString(),
@@ -25,27 +26,20 @@ function throwIfAborted(signal) {
25
26
  return;
26
27
  throw signal.reason instanceof Error ? signal.reason : new Error("index writer wait aborted");
27
28
  }
28
- function releaseHeldLock(lockPath) {
29
- const held = heldLocks.get(lockPath);
30
- if (!held)
31
- return;
32
- held.depth -= 1;
33
- if (held.depth > 0)
34
- return;
35
- heldLocks.delete(lockPath);
36
- process.off("exit", held.exitHandler);
37
- releaseLockIfOwned(lockPath, process.pid);
38
- }
39
- function retainHeldLock(lockPath) {
40
- const existing = heldLocks.get(lockPath);
41
- if (existing) {
42
- existing.depth += 1;
43
- return { lockPath, release: () => releaseHeldLock(lockPath) };
44
- }
45
- const exitHandler = () => releaseLockIfOwned(lockPath, process.pid);
29
+ function createLease(lockPath, ownership) {
30
+ const exitHandler = () => releaseLock(ownership);
46
31
  process.on("exit", exitHandler);
47
- heldLocks.set(lockPath, { depth: 1, exitHandler });
48
- return { lockPath, release: () => releaseHeldLock(lockPath) };
32
+ let released = false;
33
+ return {
34
+ lockPath,
35
+ release: () => {
36
+ if (released)
37
+ return;
38
+ released = true;
39
+ process.off("exit", exitHandler);
40
+ releaseLock(ownership);
41
+ },
42
+ };
49
43
  }
50
44
  export async function acquireIndexWriterLease(options) {
51
45
  const mode = options.mode ?? "wait";
@@ -53,25 +47,26 @@ export async function acquireIndexWriterLease(options) {
53
47
  const startedAt = Date.now();
54
48
  const maxWaitMs = options.maxWaitMs ?? DEFAULT_INDEX_WRITER_MAX_WAIT_MS;
55
49
  fs.mkdirSync(path.dirname(lockPath), { recursive: true });
56
- if (heldLocks.has(lockPath)) {
57
- options.onAcquired?.({ waitedMs: 0 });
58
- return retainHeldLock(lockPath);
59
- }
60
50
  let lastWaitNoticeMs = 0;
61
51
  while (true) {
62
52
  throwIfAborted(options.signal);
63
- if (tryAcquireLockSync(lockPath, buildPayload(options.purpose))) {
64
- options.onAcquired?.({ waitedMs: Date.now() - startedAt });
65
- return retainHeldLock(lockPath);
66
- }
67
- const probe = probeLock(lockPath, { staleAfterMs: INDEX_WRITER_LOCK_STALE_AFTER_MS });
68
- if (probe.state === "held" && probe.holderPid === process.pid) {
69
- options.onAcquired?.({ waitedMs: Date.now() - startedAt });
70
- return retainHeldLock(lockPath);
71
- }
72
- if (probe.state === "stale") {
73
- releaseLock(lockPath);
74
- continue;
53
+ const releaseBarrier = tryAcquireMaintenanceBarrier();
54
+ if (releaseBarrier) {
55
+ try {
56
+ const ownership = tryAcquireLockSync(lockPath, buildPayload(options.purpose));
57
+ if (ownership) {
58
+ options.onAcquired?.({ waitedMs: Date.now() - startedAt });
59
+ return createLease(lockPath, ownership);
60
+ }
61
+ const probe = probeLock(lockPath, { staleAfterMs: INDEX_WRITER_LOCK_STALE_AFTER_MS });
62
+ if (probe.state === "stale") {
63
+ if (reclaimStaleLock(lockPath, probe))
64
+ continue;
65
+ }
66
+ }
67
+ finally {
68
+ releaseBarrier();
69
+ }
75
70
  }
76
71
  if (mode === "try")
77
72
  return undefined;
@@ -90,16 +85,29 @@ export async function acquireIndexWriterLease(options) {
90
85
  }
91
86
  }
92
87
  export async function withIndexWriterLease(options, run) {
93
- const lease = await acquireIndexWriterLease(options);
94
- if (!lease) {
95
- throw new Error(`index writer lease unavailable for ${options.purpose}`);
96
- }
97
- try {
98
- return await run();
99
- }
100
- finally {
101
- lease.release();
102
- }
88
+ const lockPath = getIndexWriterLockPath();
89
+ const inherited = leaseContext.getStore();
90
+ if (inherited?.has(lockPath))
91
+ return run();
92
+ const context = inherited ?? new Set();
93
+ const execute = async () => {
94
+ const lease = await acquireIndexWriterLease(options);
95
+ if (!lease)
96
+ throw new Error(`index writer lease unavailable for ${options.purpose}`);
97
+ context.add(lockPath);
98
+ try {
99
+ return await run();
100
+ }
101
+ finally {
102
+ context.delete(lockPath);
103
+ lease.release();
104
+ }
105
+ };
106
+ return inherited ? execute() : leaseContext.run(context, execute);
107
+ }
108
+ /** Asset writes and index rebuilds share one lease so scans cannot publish stale snapshots. */
109
+ export function withAssetMutationLease(purpose, run) {
110
+ return withIndexWriterLease({ purpose }, run);
103
111
  }
104
112
  export function probeIndexWriterLease() {
105
113
  return probeLock(getIndexWriterLockPath(), { staleAfterMs: INDEX_WRITER_LOCK_STALE_AFTER_MS });
@@ -23,7 +23,9 @@ import fs from "node:fs";
23
23
  import path from "node:path";
24
24
  import { getDbPath } from "../core/paths.js";
25
25
  import { warnVerbose } from "../core/warn.js";
26
- import { closeDatabase, getEntryCount, openExistingDatabase, rebuildFts, upsertEntry } from "./db/db.js";
26
+ import { takeWorkflowDocument } from "../workflows/runtime/document-cache.js";
27
+ import { closeDatabase, deleteEntriesByIds, getEntryCount, openExistingDatabase, rebuildFts, upsertEntry, upsertWorkflowDocument, } from "./db/db.js";
28
+ import { withIndexWriterLease } from "./index-writer-lock.js";
27
29
  import { generateMetadataFlat } from "./passes/metadata.js";
28
30
  import { buildSearchText } from "./search/search-fields.js";
29
31
  /**
@@ -46,60 +48,84 @@ export const WRITE_PATH_INDEX_BUSY_TIMEOUT_MS = 5_000;
46
48
  * first read (`ensureIndex`) or an explicit `akm index`, which also cover
47
49
  * embeddings and the other passes this fast path skips.
48
50
  */
49
- export async function indexWrittenAssets(stashDir, filePaths) {
51
+ export async function indexWrittenAssets(stashDir, filePaths, options = {}) {
50
52
  try {
51
- const dbPath = getDbPath();
52
- if (!fs.existsSync(dbPath))
53
- return;
54
- // The full walk never descends into dot-directories (they hold state like
55
- // `.meta/`, `.stash.json`), and `shouldIndexStashFile` relies on the walker
56
- // for that — mirror it here so this fast path indexes exactly what a full
57
- // run would.
58
- const files = filePaths.filter((f) => {
59
- if (!fs.existsSync(f))
60
- return false;
61
- const rel = path.relative(stashDir, f);
62
- return !rel.split(/[\\/]+/).some((segment) => segment.startsWith("."));
63
- });
64
- if (files.length === 0)
65
- return;
66
- // Generate metadata BEFORE opening the DB so the write window stays
67
- // short. One call per file keeps the entry↔path pairing exact.
68
- const pairs = [];
69
- for (const file of files) {
70
- const generated = await generateMetadataFlat(stashDir, [file]);
71
- const entry = generated.entries[0];
72
- // Workflows carry a side-table document upsert this fast path doesn't
73
- // do; no current caller writes them, but guard so one never lands
74
- // half-indexed.
75
- if (entry && entry.type !== "workflow")
76
- pairs.push({ file, entry });
77
- }
78
- if (pairs.length === 0)
79
- return;
80
- const db = openExistingDatabase(dbPath);
81
- try {
82
- db.exec(`PRAGMA busy_timeout = ${WRITE_PATH_INDEX_BUSY_TIMEOUT_MS}`);
83
- if (getEntryCount(db) === 0)
84
- return;
85
- for (const { file, entry } of pairs) {
86
- const entryKey = `${stashDir}:${entry.type}:${entry.name}`;
87
- let entryWithSize = entry;
88
- try {
89
- entryWithSize = { ...entry, fileSize: fs.statSync(file).size };
53
+ return await withIndexWriterLease({ purpose: "index-written-assets" }, async () => {
54
+ if (options.recoverMoves !== false) {
55
+ const { recoverInterruptedMoveTransactions } = await import("../commands/mv-cli.js");
56
+ await recoverInterruptedMoveTransactions(stashDir);
57
+ }
58
+ const dbPath = getDbPath();
59
+ if (!fs.existsSync(dbPath))
60
+ return true;
61
+ // The full walk never descends into dot-directories (they hold state like
62
+ // `.meta/`, `.stash.json`), and `shouldIndexStashFile` relies on the walker
63
+ // for that — mirror it here so this fast path indexes exactly what a full
64
+ // run would.
65
+ const files = filePaths.filter((f) => {
66
+ const rel = path.relative(stashDir, f);
67
+ return !rel.split(/[\\/]+/).some((segment) => segment.startsWith("."));
68
+ });
69
+ if (files.length === 0)
70
+ return true;
71
+ // Generate metadata BEFORE opening the DB so the write window stays
72
+ // short. One call per file keeps the entry↔path pairing exact.
73
+ const pairs = [];
74
+ const unindexable = new Set();
75
+ for (const file of files) {
76
+ if (!fs.existsSync(file)) {
77
+ unindexable.add(file);
78
+ continue;
79
+ }
80
+ const generated = await generateMetadataFlat(stashDir, [file]);
81
+ const entry = generated.entries[0];
82
+ // Workflows also carry a workflow_documents side-table upsert — handled
83
+ // below, mirroring the full walk — since `akm mv` rewrites citer files
84
+ // that can be workflows.
85
+ if (entry)
86
+ pairs.push({ file, entry });
87
+ else
88
+ unindexable.add(file);
89
+ }
90
+ const db = openExistingDatabase(dbPath);
91
+ try {
92
+ db.exec(`PRAGMA busy_timeout = ${WRITE_PATH_INDEX_BUSY_TIMEOUT_MS}`);
93
+ if (getEntryCount(db) === 0)
94
+ return true;
95
+ for (const file of unindexable) {
96
+ const rows = db.prepare("SELECT id FROM entries WHERE file_path = ?").all(file);
97
+ deleteEntriesByIds(db, rows.map((row) => row.id));
90
98
  }
91
- catch {
92
- // stat raced a delete — index without the size, like the full walk does.
99
+ for (const { file, entry } of pairs) {
100
+ const entryKey = `${stashDir}:${entry.type}:${entry.name}`;
101
+ let entryWithSize = entry;
102
+ try {
103
+ entryWithSize = { ...entry, fileSize: fs.statSync(file).size };
104
+ }
105
+ catch {
106
+ // stat raced a delete — index without the size, like the full walk does.
107
+ }
108
+ const entryId = upsertEntry(db, entryKey, path.dirname(file), file, stashDir, entryWithSize, buildSearchText(entry));
109
+ if (entry.type === "workflow") {
110
+ // Same contract as the full walk (indexer.ts): the renderer cached
111
+ // the parsed document during metadata generation; persist it so the
112
+ // workflow runtime never sees an entry without its document.
113
+ const doc = takeWorkflowDocument(entry);
114
+ if (doc)
115
+ upsertWorkflowDocument(db, entryId, doc, fs.readFileSync(file));
116
+ }
93
117
  }
94
- upsertEntry(db, entryKey, path.dirname(file), file, stashDir, entryWithSize, buildSearchText(entry));
118
+ if (pairs.length > 0 || unindexable.size > 0)
119
+ rebuildFts(db, { incremental: true });
120
+ }
121
+ finally {
122
+ closeDatabase(db);
95
123
  }
96
- rebuildFts(db, { incremental: true });
97
- }
98
- finally {
99
- closeDatabase(db);
100
- }
124
+ return true;
125
+ });
101
126
  }
102
127
  catch (error) {
103
128
  warnVerbose("Write-path index update skipped (asset appears after the next full index):", error instanceof Error ? error.message : String(error));
129
+ return false;
104
130
  }
105
131
  }
@@ -155,7 +155,7 @@ async function runFinalizePhase(ctx) {
155
155
  ctx.timing.tFtsEnd = Date.now();
156
156
  // Re-link detached usage_events and recompute utility scores.
157
157
  onProgress({ phase: "finalize", message: "Relinking usage events." });
158
- relinkUsageEvents(db);
158
+ relinkUsageEvents(db, { sources, defaultStashDir: stashDir });
159
159
  onProgress({ phase: "finalize", message: "Recomputing utility scores." });
160
160
  recomputeUtilityScores(db);
161
161
  // Purge LLM cache entries for assets that no longer exist in the index.
@@ -182,6 +182,11 @@ async function runFinalizePhase(ctx) {
182
182
  setMeta(db, "stashDir", stashDir);
183
183
  setMeta(db, "stashDirs", JSON.stringify(sourceDirs));
184
184
  setMeta(db, "hasEmbeddings", embeddingResult.success ? "1" : "0");
185
+ // Stash-organization conventions (SPEC-8): track which `index.indexBodyOpening`
186
+ // state the index was built with, and warn while the flag diverges from it.
187
+ const bodyOpeningWarning = reconcileBodyOpeningIndexState(db, config.index?.indexBodyOpening === true, ctx.full || !isIncremental);
188
+ if (bodyOpeningWarning)
189
+ warn(bodyOpeningWarning);
185
190
  warnIfVecMissing(db);
186
191
  const totalEntries = getEntryCount(db);
187
192
  const semanticEntryCount = getEmbeddableEntryCount(db);
@@ -209,6 +214,41 @@ async function runFinalizePhase(ctx) {
209
214
  // suppress unused warning — sources was previously used inline
210
215
  void sources;
211
216
  }
217
+ /**
218
+ * Stash-organization conventions (SPEC-8): reconcile the `index.indexBodyOpening`
219
+ * flag with the state the index was last FULLY built with (index_meta key
220
+ * `indexBodyOpening`), returning a warning message while they diverge.
221
+ *
222
+ * Incremental runs re-extract only changed files (and embeddings are only
223
+ * generated for rows lacking one), so a flag toggle leaves the index MIXED
224
+ * until a full rebuild — `akm index --full` re-extracts every entry and wipes
225
+ * embeddings so they regenerate from the new text. The warning therefore
226
+ * repeats on every incremental run until a full walk records the flag state
227
+ * as applied.
228
+ *
229
+ * A missing meta key on an incremental run means the index predates this
230
+ * feature, i.e. it was necessarily built with the flag OFF — so the absent
231
+ * key reads (and is seeded) as "0", never as the current flag value. This
232
+ * keeps the most likely real toggle scenario — upgrade, enable the flag, run
233
+ * a plain `akm index` — warning until `--full` runs (review finding).
234
+ *
235
+ * Exported for tests; production's only caller is the finalize phase above.
236
+ */
237
+ export function reconcileBodyOpeningIndexState(db, flagEnabled, isFullWalk) {
238
+ const bodyOpeningFlag = flagEnabled ? "1" : "0";
239
+ const prevBodyOpeningFlag = getMeta(db, "indexBodyOpening") ?? "0";
240
+ // Only a full walk (which includes the first build ever) may record the
241
+ // current flag as the applied state; incremental runs preserve — or, for a
242
+ // pre-feature index, seed — the state of the last full build.
243
+ setMeta(db, "indexBodyOpening", isFullWalk ? bodyOpeningFlag : prevBodyOpeningFlag);
244
+ if (isFullWalk || prevBodyOpeningFlag === bodyOpeningFlag)
245
+ return undefined;
246
+ return (`index.indexBodyOpening is ${flagEnabled ? "enabled" : "disabled"} but the index was built with it ` +
247
+ `${flagEnabled ? "disabled" : "enabled"}. Incremental runs only re-extract changed files, so ` +
248
+ "indexed text and embeddings are stale for unchanged entries. Run `akm index --full` to apply the new " +
249
+ "setting everywhere (embeddings regenerate), and re-mint collapse-detector canary baselines via " +
250
+ "`akm improve canary --refresh` if you use them.");
251
+ }
212
252
  // ── Clean pass ───────────────────────────────────────────────────────────────
213
253
  /**
214
254
  * Post-index clean pass: scan the `entries` table for rows whose source file
@@ -286,6 +326,10 @@ async function akmIndexReal(options) {
286
326
  const sourceCacheEnd = Date.now();
287
327
  const allSourceEntries = resolveSourceEntries(stashDir, config);
288
328
  const allSourceDirs = allSourceEntries.map((s) => s.path);
329
+ const { recoverInterruptedMoveTransactions } = await import("../commands/mv-cli.js");
330
+ for (const sourceDir of new Set([stashDir, ...allSourceDirs])) {
331
+ await recoverInterruptedMoveTransactions(sourceDir);
332
+ }
289
333
  onProgress({
290
334
  phase: "preflight",
291
335
  message: `Resolved ${allSourceDirs.length} stash source${allSourceDirs.length === 1 ? "" : "s"}.`,
@@ -733,10 +777,11 @@ async function enhanceDirsWithLlm(db, config, dirsNeedingLlm, onProgress, signal
733
777
  // P3 — wall-clock budget for the enrichment pass. Defaults to llm.timeoutMs
734
778
  // (or 10 minutes if not set). Users can extend this via llm.timeoutMs in
735
779
  // config — no separate knob needed.
736
- const budgetMs = (llmConfig.timeoutMs ?? 10 * 60 * 1000) * Math.max(totalEntries, 1);
737
- const enrichDeadline = AbortSignal.timeout(budgetMs);
780
+ const enrichDeadline = createEnrichmentDeadline(llmConfig.timeoutMs, totalEntries);
738
781
  let deadlineHit = false;
739
782
  const enrichSignal = (() => {
783
+ if (!enrichDeadline)
784
+ return signal ?? new AbortController().signal;
740
785
  if (!signal)
741
786
  return enrichDeadline;
742
787
  // Combine: abort when either fires.
@@ -865,6 +910,10 @@ async function enhanceDirsWithLlm(db, config, dirsNeedingLlm, onProgress, signal
865
910
  warn(`LLM enhancement failed for ${failed}/${summary.attempted} entries — they were left un-enhanced.${sample}`);
866
911
  }
867
912
  }
913
+ export function createEnrichmentDeadline(timeoutMs, totalEntries) {
914
+ const perEntryTimeoutMs = timeoutMs === undefined ? 10 * 60 * 1000 : timeoutMs;
915
+ return perEntryTimeoutMs === null ? undefined : AbortSignal.timeout(perEntryTimeoutMs * Math.max(totalEntries, 1));
916
+ }
868
917
  async function generateEmbeddingsForDb(db, config, onProgress, signal) {
869
918
  throwIfAborted(signal);
870
919
  if (config.semanticSearchMode === "off") {
@@ -19,7 +19,7 @@
19
19
  * the parent without re-running the LLM.
20
20
  *
21
21
  * Disabling — two orthogonal gates:
22
- * 1. `profiles.improve.default.processes.memoryInference.enabled = false`
22
+ * 1. The selected strategy sets `processes.memoryInference.enabled = false`
23
23
  * blocks the pass at the feature-flag layer (no network call may ever
24
24
  * issue). Historically the v1 spec §14 gate, superseded by the 0.8.0
25
25
  * profile shape.
@@ -60,7 +60,7 @@ const FM_CAPTURE_MODE = "captureMode";
60
60
  *
61
61
  * Two orthogonal gates:
62
62
  *
63
- * 1. **Feature gate** — `profiles.improve.default.processes.memoryInference.enabled`
63
+ * 1. **Feature gate** — the selected strategy's `processes.memoryInference.enabled`
64
64
  * (defaults to `true`). When `false`, no network call may issue regardless
65
65
  * of per-pass settings.
66
66
  * 2. **Per-pass gate** — `resolveIndexPassLLM("memory", config)` (which
@@ -72,6 +72,7 @@ const FM_CAPTURE_MODE = "captureMode";
72
72
  */
73
73
  export async function runMemoryInferencePass(ctx) {
74
74
  const { config, sources, signal, db, reEnrich, onProgress, options = {} } = ctx;
75
+ const invocationOwnsConnection = Object.hasOwn(ctx, "llmConfig");
75
76
  const compressMemoryToDerivedMemory = options.compressMemoryToDerivedMemory ?? memoryInfer.compressMemoryToDerivedMemory;
76
77
  const result = {
77
78
  considered: 0,
@@ -90,15 +91,18 @@ export async function runMemoryInferencePass(ctx) {
90
91
  // gate) bubbles up into the pass result.
91
92
  const inferTelemetry = {};
92
93
  // Gate 1 — feature gate via isProcessEnabled, which reads the 0.8.0 path
93
- // (profiles.improve.default.processes.memoryInference.enabled). Defaults to
94
+ // (selected strategy's processes.memoryInference.enabled). Defaults to
94
95
  // enabled when the key is absent.
95
- if (!isProcessEnabled("index", "memory_inference", config))
96
+ if (!invocationOwnsConnection && !isProcessEnabled("index", "memory_inference", config))
96
97
  return result;
97
98
  // Gate 2 — per-pass opt-out (#208). Returns the resolved llm config or
98
99
  // `undefined` when the pass should not run.
99
- const llmConfig = resolveIndexPassLLM("memory", config);
100
+ const llmConfig = Object.hasOwn(ctx, "llmConfig") ? ctx.llmConfig : resolveIndexPassLLM("memory", config);
100
101
  if (!llmConfig)
101
102
  return result;
103
+ const featureConfig = invocationOwnsConnection
104
+ ? { ...config, index: { ...config.index, memory: { ...config.index?.memory, enabled: true } } }
105
+ : config;
102
106
  // The pass only writes to the primary (working) stash. Read-only caches
103
107
  // (git, npm, website) are deliberately untouched — writing inferred
104
108
  // children there would be clobbered by the next sync().
@@ -173,7 +177,7 @@ export async function runMemoryInferencePass(ctx) {
173
177
  retryAttempts += 1;
174
178
  };
175
179
  const derived = db
176
- ? await withLlmCache(db, record.filePath, record.body, reEnrich ?? false, () => compressMemoryToDerivedMemory(llmConfig, record.body, signal, config, (evt) => {
180
+ ? await withLlmCache(db, record.filePath, record.body, reEnrich ?? false, () => compressMemoryToDerivedMemory(llmConfig, record.body, signal, featureConfig, (evt) => {
177
181
  warn(`[akm] LLM fallback for ${evt.feature}: ${evt.reason}`);
178
182
  }, inferTelemetry, onRetryAttempt), validate, undefined, "", {
179
183
  onCacheHit: () => {