akm-cli 0.9.0-beta.6 → 0.9.0-rc.0

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 (326) hide show
  1. package/CHANGELOG.md +663 -0
  2. package/README.md +12 -4
  3. package/dist/akm +38 -0
  4. package/dist/akm-migrate-storage +38 -0
  5. package/dist/assets/help/help-improve.md +9 -6
  6. package/dist/assets/hints/cli-hints-full.md +6 -5
  7. package/dist/assets/profiles/default.json +9 -4
  8. package/dist/assets/profiles/frequent.json +1 -1
  9. package/dist/assets/profiles/memory-focus.json +1 -1
  10. package/dist/assets/profiles/proactive-maintenance.json +25 -0
  11. package/dist/assets/profiles/quick.json +1 -1
  12. package/dist/assets/profiles/recombine-only.json +21 -0
  13. package/dist/assets/profiles/reflect-distill.json +30 -0
  14. package/dist/assets/profiles/synthesize.json +15 -0
  15. package/dist/assets/profiles/thorough.json +1 -1
  16. package/dist/assets/prompts/consolidate-system.md +23 -0
  17. package/dist/assets/prompts/contradiction-judge.md +33 -0
  18. package/dist/assets/prompts/distill-knowledge-system.md +22 -0
  19. package/dist/assets/prompts/distill-lesson-system.md +36 -0
  20. package/dist/assets/prompts/extract-session.md +11 -3
  21. package/dist/assets/prompts/graph-extract-system.md +1 -0
  22. package/dist/assets/prompts/graph-extract-user-prompt.md +1 -1
  23. package/dist/assets/prompts/memory-infer-system.md +1 -0
  24. package/dist/assets/prompts/memory-infer-user.md +5 -0
  25. package/dist/assets/prompts/metadata-enhance-system.md +1 -0
  26. package/dist/assets/prompts/procedural-system.md +44 -0
  27. package/dist/assets/prompts/recombine-system.md +40 -0
  28. package/dist/assets/prompts/staleness-detect-system.md +6 -0
  29. package/dist/assets/prompts/validate-summary-judge.md +1 -0
  30. package/dist/assets/stash-skeleton/facts/conventions/assets/agent.md +38 -0
  31. package/dist/assets/stash-skeleton/facts/conventions/assets/command.md +38 -0
  32. package/dist/assets/stash-skeleton/facts/conventions/assets/fact.md +39 -0
  33. package/dist/assets/stash-skeleton/facts/conventions/assets/knowledge.md +40 -0
  34. package/dist/assets/stash-skeleton/facts/conventions/assets/lesson.md +43 -0
  35. package/dist/assets/stash-skeleton/facts/conventions/assets/memory.md +38 -0
  36. package/dist/assets/stash-skeleton/facts/conventions/assets/script.md +43 -0
  37. package/dist/assets/stash-skeleton/facts/conventions/assets/skill.md +40 -0
  38. package/dist/assets/stash-skeleton/facts/conventions/assets/workflow.md +43 -0
  39. package/dist/assets/templates/html/health.html +281 -111
  40. package/dist/assets/wiki/ingest-workflow-template.md +45 -16
  41. package/dist/assets/wiki/schema-template.md +4 -4
  42. package/dist/cli/clack.js +56 -0
  43. package/dist/cli/config-migrate.js +7 -1
  44. package/dist/cli/confirm.js +1 -1
  45. package/dist/cli/parse-args.js +46 -1
  46. package/dist/cli/shared.js +28 -0
  47. package/dist/cli.js +25 -14
  48. package/dist/commands/agent/agent-dispatch.js +3 -2
  49. package/dist/commands/agent/agent-support.js +0 -7
  50. package/dist/commands/agent/contribute-cli.js +26 -7
  51. package/dist/commands/config-cli.js +26 -13
  52. package/dist/commands/env/child-env.js +47 -0
  53. package/dist/commands/env/env-cli.js +220 -227
  54. package/dist/commands/env/env.js +14 -67
  55. package/dist/commands/env/secret-cli.js +140 -138
  56. package/dist/commands/feedback-cli.js +153 -147
  57. package/dist/commands/graph/graph-cli.js +5 -13
  58. package/dist/commands/graph/graph.js +76 -72
  59. package/dist/commands/health/advisories.js +151 -0
  60. package/dist/commands/health/checks.js +103 -16
  61. package/dist/commands/health/html-report.js +447 -81
  62. package/dist/commands/health/improve-metrics.js +771 -0
  63. package/dist/commands/health/llm-usage.js +65 -0
  64. package/dist/commands/health/md-report.js +103 -0
  65. package/dist/commands/health/metrics.js +278 -0
  66. package/dist/commands/health/stash-exposure.js +46 -0
  67. package/dist/commands/health/surfaces.js +216 -0
  68. package/dist/commands/health/task-runs.js +135 -0
  69. package/dist/commands/health/types.js +26 -0
  70. package/dist/commands/health/windows.js +195 -0
  71. package/dist/commands/health.js +91 -1083
  72. package/dist/commands/improve/anti-collapse.js +170 -0
  73. package/dist/commands/improve/calibration.js +161 -0
  74. package/dist/commands/improve/collapse-detector.js +421 -0
  75. package/dist/commands/improve/consolidate/chunking.js +141 -0
  76. package/dist/commands/improve/consolidate/eligibility.js +64 -0
  77. package/dist/commands/improve/consolidate/merge.js +145 -0
  78. package/dist/commands/improve/consolidate/sanitize.js +231 -0
  79. package/dist/commands/{lint.js → improve/consolidate/types.js} +1 -1
  80. package/dist/commands/improve/consolidate.js +1313 -1278
  81. package/dist/commands/improve/dedup.js +482 -0
  82. package/dist/commands/improve/distill/content-repair.js +202 -0
  83. package/dist/commands/improve/distill/promote-memory.js +229 -0
  84. package/dist/commands/improve/distill/quality-gate.js +236 -0
  85. package/dist/commands/improve/distill-guards.js +127 -0
  86. package/dist/commands/improve/distill-promotion-policy.js +826 -167
  87. package/dist/commands/improve/distill.js +243 -599
  88. package/dist/commands/improve/eligibility.js +434 -0
  89. package/dist/commands/improve/encoding-salience.js +205 -0
  90. package/dist/commands/improve/extract-cli.js +179 -59
  91. package/dist/commands/improve/extract-prompt.js +55 -4
  92. package/dist/commands/improve/extract-watch.js +140 -0
  93. package/dist/commands/improve/extract.js +409 -43
  94. package/dist/commands/improve/feedback-valence.js +54 -0
  95. package/dist/commands/improve/hot-probation.js +45 -0
  96. package/dist/commands/improve/improve-auto-accept.js +160 -7
  97. package/dist/commands/improve/improve-cli.js +115 -73
  98. package/dist/commands/improve/improve-profiles.js +32 -8
  99. package/dist/commands/improve/improve-result-file.js +15 -25
  100. package/dist/commands/improve/improve-session.js +58 -0
  101. package/dist/commands/improve/improve.js +510 -2537
  102. package/dist/commands/improve/locks.js +154 -0
  103. package/dist/commands/improve/loop-stages.js +1100 -0
  104. package/dist/commands/improve/memory/memory-belief.js +14 -15
  105. package/dist/commands/improve/memory/memory-contradiction-detect.js +83 -60
  106. package/dist/commands/improve/memory/memory-improve.js +27 -27
  107. package/dist/commands/improve/outcome-loop.js +270 -0
  108. package/dist/commands/improve/preparation.js +2002 -0
  109. package/dist/commands/improve/proactive-maintenance.js +115 -0
  110. package/dist/commands/improve/procedural.js +398 -0
  111. package/dist/commands/improve/recombine.js +818 -0
  112. package/dist/commands/improve/reflect-noise.js +0 -0
  113. package/dist/commands/improve/reflect.js +212 -45
  114. package/dist/commands/improve/salience.js +455 -0
  115. package/dist/commands/improve/schema-similarity-gate.js +168 -0
  116. package/dist/commands/improve/shared.js +51 -0
  117. package/dist/commands/improve/triage.js +93 -0
  118. package/dist/commands/lint/agent-linter.js +19 -24
  119. package/dist/commands/lint/base-linter.js +173 -60
  120. package/dist/commands/lint/command-linter.js +19 -24
  121. package/dist/commands/lint/env-key-rules.js +38 -1
  122. package/dist/commands/lint/fact-linter.js +39 -0
  123. package/dist/commands/lint/index.js +31 -13
  124. package/dist/commands/lint/memory-linter.js +1 -1
  125. package/dist/commands/lint/registry.js +7 -2
  126. package/dist/commands/lint/task-linter.js +3 -3
  127. package/dist/commands/lint/workflow-linter.js +26 -1
  128. package/dist/commands/observability-cli.js +4 -4
  129. package/dist/commands/proposal/drain-policies.js +13 -4
  130. package/dist/commands/proposal/drain.js +45 -51
  131. package/dist/commands/proposal/legacy-import.js +115 -0
  132. package/dist/commands/proposal/proposal-cli.js +24 -34
  133. package/dist/commands/proposal/proposal.js +7 -1
  134. package/dist/commands/proposal/propose.js +8 -3
  135. package/dist/commands/proposal/repository.js +829 -0
  136. package/dist/commands/proposal/validators/proposal-quality-validators.js +9 -8
  137. package/dist/commands/proposal/validators/proposals.js +93 -882
  138. package/dist/commands/read/curate.js +419 -103
  139. package/dist/commands/read/knowledge.js +10 -3
  140. package/dist/commands/read/remember-cli.js +133 -138
  141. package/dist/commands/read/search-cli.js +15 -8
  142. package/dist/commands/read/search.js +22 -11
  143. package/dist/commands/read/show.js +106 -14
  144. package/dist/commands/registry-cli.js +76 -87
  145. package/dist/commands/remember.js +11 -12
  146. package/dist/commands/sources/add-cli.js +91 -95
  147. package/dist/commands/sources/history.js +1 -1
  148. package/dist/commands/sources/init.js +66 -18
  149. package/dist/commands/sources/installed-stashes.js +11 -3
  150. package/dist/commands/sources/schema-repair.js +44 -46
  151. package/dist/commands/sources/self-update.js +2 -2
  152. package/dist/commands/sources/source-add.js +7 -3
  153. package/dist/commands/sources/sources-cli.js +3 -3
  154. package/dist/commands/sources/stash-cli.js +29 -41
  155. package/dist/commands/sources/stash-skeleton.js +57 -8
  156. package/dist/commands/tasks/default-tasks.js +15 -2
  157. package/dist/commands/tasks/tasks-cli.js +20 -29
  158. package/dist/commands/tasks/tasks.js +39 -11
  159. package/dist/commands/wiki-cli.js +23 -38
  160. package/dist/commands/workflow-cli.js +15 -1
  161. package/dist/core/asset/asset-registry.js +3 -1
  162. package/dist/core/asset/asset-spec.js +21 -4
  163. package/dist/core/asset/frontmatter.js +188 -167
  164. package/dist/core/asset/markdown.js +8 -0
  165. package/dist/core/authoring-rules.js +92 -0
  166. package/dist/core/common.js +4 -23
  167. package/dist/core/concurrent.js +10 -1
  168. package/dist/core/config/config-io.js +10 -1
  169. package/dist/core/config/config-migration.js +18 -40
  170. package/dist/core/config/config-schema.js +389 -58
  171. package/dist/core/config/config-types.js +3 -3
  172. package/dist/core/config/config.js +67 -22
  173. package/dist/core/deep-merge.js +38 -0
  174. package/dist/core/errors.js +1 -0
  175. package/dist/core/eval/rank-metrics.js +113 -0
  176. package/dist/core/events.js +4 -7
  177. package/dist/core/improve-types.js +47 -8
  178. package/dist/core/logs-db.js +14 -75
  179. package/dist/core/parse.js +36 -16
  180. package/dist/core/paths.js +21 -18
  181. package/dist/core/standards/resolve-standards-context.js +87 -0
  182. package/dist/core/standards/resolve-stash-standards.js +99 -0
  183. package/dist/core/standards/resolve-type-conventions.js +66 -0
  184. package/dist/core/state/migrations.js +770 -0
  185. package/dist/core/state-db.js +142 -1091
  186. package/dist/core/structured.js +69 -0
  187. package/dist/core/time.js +53 -0
  188. package/dist/core/warn.js +21 -0
  189. package/dist/core/write-source.js +37 -0
  190. package/dist/indexer/db/db.js +356 -780
  191. package/dist/indexer/db/entry-mapper.js +41 -0
  192. package/dist/indexer/db/graph-db.js +129 -86
  193. package/dist/indexer/db/llm-cache.js +2 -2
  194. package/dist/indexer/db/schema.js +516 -0
  195. package/dist/indexer/ensure-index.js +103 -24
  196. package/dist/indexer/feedback/utility-policy.js +75 -0
  197. package/dist/indexer/graph/graph-boost.js +51 -41
  198. package/dist/indexer/graph/graph-extraction.js +207 -4
  199. package/dist/indexer/index-writer-lock.js +106 -0
  200. package/dist/indexer/index-written-assets.js +105 -0
  201. package/dist/indexer/indexer.js +291 -310
  202. package/dist/indexer/passes/dir-staleness.js +114 -0
  203. package/dist/indexer/passes/memory-inference.js +13 -5
  204. package/dist/indexer/passes/metadata.js +20 -0
  205. package/dist/indexer/read-preflight.js +23 -0
  206. package/dist/indexer/search/db-search.js +89 -13
  207. package/dist/indexer/search/fts-query.js +51 -0
  208. package/dist/indexer/search/ranking-contributors.js +95 -9
  209. package/dist/indexer/search/ranking.js +79 -3
  210. package/dist/indexer/search/search-fields.js +6 -0
  211. package/dist/indexer/search/search-source.js +32 -21
  212. package/dist/indexer/search/semantic-status.js +4 -0
  213. package/dist/indexer/walk/matchers.js +9 -0
  214. package/dist/indexer/walk/walker.js +21 -13
  215. package/dist/integrations/agent/builders.js +39 -13
  216. package/dist/integrations/agent/config.js +20 -59
  217. package/dist/integrations/agent/detect.js +9 -0
  218. package/dist/integrations/agent/index.js +3 -19
  219. package/dist/integrations/agent/model-aliases.js +7 -2
  220. package/dist/integrations/agent/profiles.js +7 -1
  221. package/dist/integrations/agent/prompts.js +75 -9
  222. package/dist/integrations/agent/runner-dispatch.js +59 -0
  223. package/dist/integrations/agent/runner.js +13 -9
  224. package/dist/integrations/agent/spawn.js +69 -67
  225. package/dist/integrations/harnesses/claude/agent-builder.js +1 -1
  226. package/dist/integrations/harnesses/claude/index.js +2 -0
  227. package/dist/integrations/harnesses/claude/session-log.js +11 -1
  228. package/dist/integrations/harnesses/index.js +2 -3
  229. package/dist/integrations/harnesses/opencode/agent-builder.js +1 -1
  230. package/dist/integrations/harnesses/opencode/index.js +2 -0
  231. package/dist/integrations/harnesses/opencode/session-log.js +173 -3
  232. package/dist/integrations/harnesses/opencode-sdk/index.js +2 -2
  233. package/dist/integrations/harnesses/opencode-sdk/sdk-runner.js +98 -17
  234. package/dist/integrations/harnesses/types.js +1 -0
  235. package/dist/integrations/session-logs/index.js +16 -0
  236. package/dist/llm/call-ai.js +2 -2
  237. package/dist/llm/client.js +57 -15
  238. package/dist/llm/embedder.js +67 -4
  239. package/dist/llm/embedders/cache.js +3 -1
  240. package/dist/llm/embedders/deterministic.js +66 -0
  241. package/dist/llm/embedders/local.js +73 -3
  242. package/dist/llm/feature-gate.js +16 -15
  243. package/dist/llm/graph-extract.js +67 -44
  244. package/dist/llm/memory-infer-impl.js +138 -0
  245. package/dist/llm/memory-infer.js +1 -127
  246. package/dist/llm/metadata-enhance.js +44 -31
  247. package/dist/llm/structured-call.js +49 -0
  248. package/dist/migrate-storage-node.mjs +8 -0
  249. package/dist/output/context.js +5 -5
  250. package/dist/output/renderers.js +85 -14
  251. package/dist/output/shapes/curate.js +14 -2
  252. package/dist/output/shapes/helpers.js +0 -3
  253. package/dist/output/shapes/passthrough.js +2 -1
  254. package/dist/output/text/helpers.js +29 -1
  255. package/dist/output/text/workflow.js +1 -0
  256. package/dist/registry/providers/skills-sh.js +21 -147
  257. package/dist/registry/providers/static-index.js +15 -157
  258. package/dist/registry/resolve.js +27 -9
  259. package/dist/runtime.js +25 -1
  260. package/dist/scripts/migrate-storage.js +2718 -2354
  261. package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +891 -597
  262. package/dist/setup/detect.js +9 -0
  263. package/dist/setup/legacy-config.js +106 -0
  264. package/dist/setup/prompt.js +57 -0
  265. package/dist/setup/providers.js +14 -0
  266. package/dist/setup/registry-stash-loader.js +12 -0
  267. package/dist/setup/semantic-assets.js +124 -0
  268. package/dist/setup/setup.js +52 -1614
  269. package/dist/setup/steps/connection.js +734 -0
  270. package/dist/setup/steps/output.js +31 -0
  271. package/dist/setup/steps/platforms.js +124 -0
  272. package/dist/setup/steps/semantic.js +27 -0
  273. package/dist/setup/steps/sources.js +222 -0
  274. package/dist/setup/steps/stashdir.js +42 -0
  275. package/dist/setup/steps/tasks.js +152 -0
  276. package/dist/sources/include.js +6 -2
  277. package/dist/sources/providers/filesystem.js +0 -1
  278. package/dist/sources/providers/git-install.js +210 -0
  279. package/dist/sources/providers/git-provider.js +234 -0
  280. package/dist/sources/providers/git-stash.js +248 -0
  281. package/dist/sources/providers/git.js +10 -661
  282. package/dist/sources/providers/npm.js +2 -6
  283. package/dist/sources/providers/provider-utils.js +13 -7
  284. package/dist/sources/providers/sync-from-ref.js +9 -1
  285. package/dist/sources/providers/tar-utils.js +16 -8
  286. package/dist/sources/providers/website.js +9 -5
  287. package/dist/sources/website-ingest.js +187 -29
  288. package/dist/sources/wiki-fetchers/registry.js +53 -0
  289. package/dist/sources/wiki-fetchers/youtube.js +239 -0
  290. package/dist/storage/database.js +45 -10
  291. package/dist/storage/managed-db.js +82 -0
  292. package/dist/storage/repositories/canaries-repository.js +107 -0
  293. package/dist/storage/repositories/consolidation-repository.js +38 -0
  294. package/dist/storage/repositories/embeddings-repository.js +72 -0
  295. package/dist/storage/repositories/events-repository.js +187 -0
  296. package/dist/storage/repositories/extract-sessions-repository.js +96 -0
  297. package/dist/storage/repositories/improve-runs-repository.js +146 -0
  298. package/dist/storage/repositories/index-db.js +14 -8
  299. package/dist/storage/repositories/proposals-repository.js +220 -0
  300. package/dist/storage/repositories/recombine-repository.js +213 -0
  301. package/dist/storage/repositories/registry-cache.js +93 -0
  302. package/dist/storage/repositories/registry-index-cache-repository.js +46 -0
  303. package/dist/storage/repositories/task-history-repository.js +93 -0
  304. package/dist/storage/sqlite-pragmas.js +146 -0
  305. package/dist/tasks/backends/cron.js +1 -1
  306. package/dist/tasks/backends/index.js +9 -0
  307. package/dist/tasks/backends/launchd.js +1 -1
  308. package/dist/tasks/backends/schtasks.js +1 -1
  309. package/dist/tasks/{resolveAkmBin.js → resolve-akm-bin.js} +2 -2
  310. package/dist/tasks/runner.js +15 -13
  311. package/dist/text-import-hook.mjs +0 -0
  312. package/dist/wiki/wiki.js +52 -11
  313. package/dist/workflows/cli.js +1 -0
  314. package/dist/workflows/db.js +3 -4
  315. package/dist/workflows/runtime/runs.js +43 -118
  316. package/dist/workflows/runtime/workflow-asset-loader.js +125 -0
  317. package/dist/workflows/validate-summary.js +2 -7
  318. package/docs/README.md +69 -18
  319. package/docs/data-and-telemetry.md +5 -4
  320. package/docs/migration/release-notes/0.7.0.md +1 -1
  321. package/docs/migration/release-notes/0.9.0.md +39 -0
  322. package/package.json +10 -10
  323. package/dist/assets/tasks/core/update-stashes.yml +0 -4
  324. package/dist/commands/db-cli.js +0 -23
  325. package/dist/indexer/db/db-backup.js +0 -376
  326. package/dist/indexer/passes/staleness-detect.js +0 -488
@@ -9,8 +9,8 @@ import { VALID_HARNESS_IDS } from "../../integrations/harnesses/index.js";
9
9
  /**
10
10
  * Canonical list of valid agent harness / platform ids. Re-exported from the
11
11
  * unified harness registry (#562) so the Zod `AgentPlatformSchema` enum, the
12
- * `AgentProfileConfigV2` platform union, `parseAgentProfilesMapV2`'s membership
13
- * check, and setup's `DetectedHarness` union all derive from one place and
14
- * cannot drift. Add a harness in `src/integrations/harnesses/index.ts`.
12
+ * `AgentProfileConfig` platform union, and setup's `DetectedHarness` union all
13
+ * derive from one place and cannot drift. Add a harness in
14
+ * `src/integrations/harnesses/index.ts`.
15
15
  */
16
16
  export { VALID_HARNESS_IDS };
@@ -13,20 +13,9 @@ import { warn } from "../warn.js";
13
13
  // Canonical harness-id source of truth (#565) — runtime value re-export.
14
14
  export { VALID_HARNESS_IDS } from "./config-types.js";
15
15
  // ── Feedback failure-mode constants (F-3 / #384) ────────────────────────────
16
- /**
17
- * Curated taxonomy of failure modes for negative feedback.
18
- *
19
- * Structured failure modes enable aggregation across feedback events so the
20
- * distill pipeline can detect that "5 assets failed for the same reason" and
21
- * act on it — free-text strings about the same issue are not aggregatable.
22
- */
23
- export const FEEDBACK_FAILURE_MODES = [
24
- "incorrect", // Factually wrong or logically flawed content
25
- "outdated", // Correct at some point but now stale
26
- "dangerous", // Could cause harm if followed (security, safety)
27
- "incomplete", // Missing key steps, context, or caveats
28
- "redundant", // Duplicates another asset without adding value
29
- ];
16
+ // Canonical taxonomy lives in the schema/validator layer; re-exported here so
17
+ // existing `../core/config/config` import sites keep working.
18
+ export { FEEDBACK_FAILURE_MODES } from "./config-schema.js";
30
19
  /**
31
20
  * Default value for {@link IndexPassConfig.graphExtractionBatchSize}. Chosen
32
21
  * empirically: 4 amortises the per-call HTTP overhead 4× while keeping the
@@ -205,6 +194,25 @@ export function getDefaultLlmConfig(config) {
205
194
  return undefined;
206
195
  return config.profiles?.llm?.[defaultName];
207
196
  }
197
+ /**
198
+ * Resolve the per-process config section for an improve process,
199
+ * centralizing the deeply-nested lookup
200
+ * `profile?.processes?.<name>` that was previously copy-pasted across the
201
+ * improve command family (20+ call sites).
202
+ *
203
+ * When an `activeProfile` is supplied (the profile resolved for the current
204
+ * `akm improve --profile <name>` run), its per-process override wins; otherwise
205
+ * — and as a fallback when the active profile does not define the section — the
206
+ * lookup falls back to the `"default"` improve profile from the on-disk config.
207
+ * Callers that have not yet threaded the active profile pass only `config` and
208
+ * get the historical default-profile behavior unchanged.
209
+ */
210
+ export function getImproveProcessConfig(config, processName, activeProfile) {
211
+ const fromActiveProfile = activeProfile?.processes?.[processName];
212
+ if (fromActiveProfile !== undefined)
213
+ return fromActiveProfile;
214
+ return config.profiles?.improve?.default?.processes?.[processName];
215
+ }
208
216
  /**
209
217
  * Run `migrateConfigShape` on the raw text and — unless `AKM_NO_AUTO_MIGRATE=1`
210
218
  * is set — persist the migrated result. Returns the (possibly migrated) text
@@ -221,7 +229,18 @@ function maybeAutoMigrateConfigFile(configPath, text) {
221
229
  catch {
222
230
  return text; // Malformed JSON — let parseAndValidate surface the error.
223
231
  }
224
- if (compareConfigVersion(obj.configVersion, CURRENT_CONFIG_VERSION) === 1) {
232
+ // Downgrade protection. Skip migration when the on-disk config is NEWER than
233
+ // this binary (=== 1), OR when it carries a configVersion we cannot order
234
+ // against ours (compareConfigVersion returns undefined for an unparseable
235
+ // value — e.g. one written by a newer/foreign akm). Migrating such a config
236
+ // could strip fields a newer binary added, a cross-version data-loss path.
237
+ //
238
+ // A MISSING configVersion (absent → undefined) is a legacy pre-versioning
239
+ // config that MUST still migrate, so the unparseable-skip is gated on the
240
+ // field being PRESENT (`onDiskVersion !== undefined`).
241
+ const onDiskVersion = obj.configVersion;
242
+ const versionOrder = compareConfigVersion(onDiskVersion, CURRENT_CONFIG_VERSION);
243
+ if (versionOrder === 1 || (onDiskVersion !== undefined && versionOrder === undefined)) {
225
244
  return text;
226
245
  }
227
246
  const { changed, result } = migrateConfigShape(obj);
@@ -249,8 +268,8 @@ function maybeAutoMigrateConfigFile(configPath, text) {
249
268
  " to preview a dry-run diff: akm config migrate --dry-run --print-diff",
250
269
  "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",
251
270
  ].join("\n");
252
- process.stderr.write(`${banner}\n`);
253
- process.stdout.write(`${banner}\n`);
271
+ process.stderr?.write?.(`${banner}\n`);
272
+ process.stdout?.write?.(`${banner}\n`);
254
273
  }
255
274
  catch (err) {
256
275
  // #461: never return migrated bytes when disk write fails — that triggers
@@ -267,15 +286,32 @@ export function loadConfig() {
267
286
  warnIfProjectConfigPresent(process.cwd());
268
287
  return loadUserConfig();
269
288
  }
289
+ let saveConfigOverride;
290
+ /** TEST-ONLY. Swap the implementation of `saveConfig`; pass undefined to restore. */
291
+ export function _setSaveConfigForTests(fake) {
292
+ saveConfigOverride = fake;
293
+ }
270
294
  export function saveConfig(config) {
295
+ if (saveConfigOverride) {
296
+ saveConfigOverride(config);
297
+ return;
298
+ }
299
+ saveConfigReal(config);
300
+ }
301
+ function saveConfigReal(config) {
271
302
  cachedConfig = undefined;
272
303
  const configPath = getConfigPath();
273
304
  const dir = path.dirname(configPath);
274
305
  fs.mkdirSync(dir, { recursive: true });
275
306
  const sanitized = sanitizeConfigForWrite(config);
276
- // Final validation gate before bytes hit disk. Catches schema violations
277
- // (unknown keys in registries[] / sources[] / profiles.*; out-of-range
278
- // numbers; etc. closes #462) before we corrupt the user's config.
307
+ // Final validation gate before bytes hit disk. Runs the FULL schema
308
+ // including the cross-field superRefine guards (removed `feedbackDistillation`
309
+ // process key, `defaultWriteTarget` resolution, writable npm/website sources)
310
+ // and all type/enum/range checks — so an `akm config set` (leaf OR object
311
+ // form) cannot persist a guard-violating or mistyped value. NOTE: unknown
312
+ // keys are intentionally NOT rejected here — object schemas are `.passthrough()`
313
+ // so cross-version skew round-trips (see config-schema.ts header); the lenient
314
+ // tolerance is by design, not an oversight.
279
315
  const parseResult = AkmConfigSchema.safeParse(sanitized);
280
316
  if (!parseResult.success) {
281
317
  const lines = parseResult.error.issues.map((i) => ` - ${i.path.join(".") || "(root)"}: ${i.message}`).join("\n");
@@ -398,7 +434,11 @@ export function resolveSecret(value) {
398
434
  * filtering out the reserved feature-section keys so callers don't mistake
399
435
  * `metadataEnhance` / `stalenessDetection` for a pass.
400
436
  */
401
- /** Reserved well-known keys on IndexConfig that are NOT per-pass entries. */
437
+ /**
438
+ * Reserved well-known keys on IndexConfig that are NOT per-pass entries.
439
+ * `stalenessDetection` is retired (10-Q3) but stays reserved so a leftover
440
+ * config section is never misread as a pass entry.
441
+ */
402
442
  const INDEX_RESERVED_KEYS = new Set(["metadataEnhance", "stalenessDetection"]);
403
443
  export function getIndexPassConfig(config, passName) {
404
444
  if (!config)
@@ -450,7 +490,12 @@ function applyRuntimeEnvApiKeys(config) {
450
490
  }
451
491
  // LLM profile keys: AKM_LLM_API_KEY for the default profile, then
452
492
  // AKM_PROFILE_<UPPER>_API_KEY for any profile (per-profile wins).
453
- const defaultProfile = next.defaults?.llm;
493
+ // Resolve the default profile the SAME way the rest of the config layer does
494
+ // (resolveDefaultLlmProfileName), so the implicit `profiles.llm.default`
495
+ // fallback is honored. Keying off the raw `defaults.llm` field alone silently
496
+ // dropped AKM_LLM_API_KEY for configs that rely on the implicit default —
497
+ // the same no-op-run class as the 2026-05-23 incident.
498
+ const defaultProfile = resolveDefaultLlmProfileName(next);
454
499
  if (next.profiles?.llm) {
455
500
  const updated = { ...next.profiles.llm };
456
501
  let changed = false;
@@ -0,0 +1,38 @@
1
+ // This Source Code Form is subject to the terms of the Mozilla Public
2
+ // License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
+ /**
5
+ * Generic recursive object merge, extracted from the setup wizard (it is not
6
+ * setup-specific). Plain objects merge key-by-key; arrays and scalars replace
7
+ * wholesale. Used to apply a partial `--file` config over the existing config
8
+ * without dropping sibling subkeys.
9
+ */
10
+ /** True for non-null, non-array plain objects. */
11
+ export function isPlainObject(value) {
12
+ return typeof value === "object" && value !== null && !Array.isArray(value);
13
+ }
14
+ /**
15
+ * Recursively merge `incoming` into `base`: plain objects merge key-by-key,
16
+ * while arrays and scalars replace wholesale. A partial input therefore only
17
+ * updates the keys it carries and never drops sibling subkeys (e.g. a file
18
+ * containing `{ output: { format: "text" } }` leaves `output.detail` intact).
19
+ *
20
+ * `base` is treated as immutable — a fresh object graph is returned.
21
+ */
22
+ export function deepMergeConfig(base, incoming) {
23
+ if (!isPlainObject(incoming))
24
+ return incoming;
25
+ const baseObj = isPlainObject(base) ? base : {};
26
+ const out = { ...baseObj };
27
+ for (const [key, value] of Object.entries(incoming)) {
28
+ if (value === undefined)
29
+ continue;
30
+ if (isPlainObject(value) && isPlainObject(baseObj[key])) {
31
+ out[key] = deepMergeConfig(baseObj[key], value);
32
+ }
33
+ else {
34
+ out[key] = value;
35
+ }
36
+ }
37
+ return out;
38
+ }
@@ -14,6 +14,7 @@ const CONFIG_HINTS = {
14
14
  TEST_ISOLATION_MISSING: "Under bun test, when AKM_STASH_DIR is set you MUST also set XDG_DATA_HOME (or AKM_DATA_DIR) and XDG_STATE_HOME (or AKM_STATE_DIR) to temp directories so the test does not touch the developer's real ~/.local/share/akm or ~/.local/state/akm.",
15
15
  SETUP_TMP_STASH_REFUSED: "Use a persistent directory, or set AKM_FORCE_SETUP_TMP_STASH=1 to opt in to a sandboxed setup (setup also pre-sets AKM_STASH_DIR so config and cache writes auto-isolate into $stashDir/.akm/ — host config is preserved).",
16
16
  UNSAFE_STASH_DIR: "Choose a path inside your home directory (e.g. ~/akm) or another empty workspace. The stash directory cannot be the filesystem root, your home directory itself, or a sensitive system path like /etc, /var, ~/.config, or ~/.ssh.",
17
+ UNKNOWN_IMPROVE_PROFILE: "Pass one of the listed profile names to `--profile`, or define it under `profiles.improve` in your config. Names are case-sensitive.",
17
18
  };
18
19
  /** Default hint for each UsageError code. */
19
20
  const USAGE_HINTS = {
@@ -0,0 +1,113 @@
1
+ // This Source Code Form is subject to the terms of the Mozilla Public
2
+ // License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
+ export const DEFAULT_CURATE_WEIGHTS = {
5
+ ndcg: 0.5,
6
+ recall: 0.2,
7
+ mrr: 0.1,
8
+ noBannedAboveRequired: 0.2,
9
+ };
10
+ /** nDCG@k with binary relevance: gain 1 for relevant refs, 0 otherwise. */
11
+ export function ndcgAtK(returned, relevant, k) {
12
+ const top = returned.slice(0, k);
13
+ let dcg = 0;
14
+ for (let i = 0; i < top.length; i++) {
15
+ if (relevant.has(top[i]))
16
+ dcg += 1 / Math.log2(i + 2);
17
+ }
18
+ const idealCount = Math.min(k, relevant.size);
19
+ let idcg = 0;
20
+ for (let i = 0; i < idealCount; i++)
21
+ idcg += 1 / Math.log2(i + 2);
22
+ return idcg === 0 ? 1 : dcg / idcg;
23
+ }
24
+ export function recallAtK(returned, relevant, k) {
25
+ if (relevant.size === 0)
26
+ return 1;
27
+ const top = new Set(returned.slice(0, k));
28
+ let hit = 0;
29
+ for (const r of relevant)
30
+ if (top.has(r))
31
+ hit += 1;
32
+ return hit / relevant.size;
33
+ }
34
+ export function mrr(returned, relevant) {
35
+ for (let i = 0; i < returned.length; i++) {
36
+ if (relevant.has(returned[i]))
37
+ return 1 / (i + 1);
38
+ }
39
+ return 0;
40
+ }
41
+ /**
42
+ * Leapfrog gate. A banned ref "leapfrogs" when it appears ABOVE at least one
43
+ * present relevant ref. Returns the fraction of present banned refs that do
44
+ * NOT leapfrog (1.0 when no banned ref is present, or none leapfrog), plus the
45
+ * raw violation count.
46
+ */
47
+ export function noBannedAboveRequired(returned, relevant, banned) {
48
+ const rankOf = new Map();
49
+ returned.forEach((ref, i) => {
50
+ if (!rankOf.has(ref))
51
+ rankOf.set(ref, i);
52
+ });
53
+ const relevantRanks = returned.map((ref, i) => (relevant.has(ref) ? i : -1)).filter((i) => i >= 0);
54
+ if (relevantRanks.length === 0) {
55
+ // No relevant ref present to be leapfrogged — gate is vacuously satisfied.
56
+ return { score: 1, leapfrogCount: 0 };
57
+ }
58
+ const worstRelevantRank = Math.max(...relevantRanks);
59
+ const bannedPresent = returned.filter((ref) => banned.has(ref));
60
+ if (bannedPresent.length === 0)
61
+ return { score: 1, leapfrogCount: 0 };
62
+ let leapfrog = 0;
63
+ for (const b of bannedPresent) {
64
+ const rb = rankOf.get(b);
65
+ if (rb !== undefined && rb < worstRelevantRank)
66
+ leapfrog += 1;
67
+ }
68
+ return { score: 1 - leapfrog / bannedPresent.length, leapfrogCount: leapfrog };
69
+ }
70
+ /** Score a single curate result (ordered refs) against its judgment. */
71
+ export function scoreCurateCase(returned, judgment, weights = DEFAULT_CURATE_WEIGHTS) {
72
+ const k = judgment.limit;
73
+ const relevant = new Set(judgment.relevant);
74
+ const banned = new Set(judgment.banned);
75
+ const ndcg = ndcgAtK(returned, relevant, k);
76
+ const recall = recallAtK(returned, relevant, k);
77
+ const rr = mrr(returned, relevant);
78
+ const gate = noBannedAboveRequired(returned, relevant, banned);
79
+ const score = ndcg * weights.ndcg + recall * weights.recall + rr * weights.mrr + gate.score * weights.noBannedAboveRequired;
80
+ return {
81
+ ndcg,
82
+ recall,
83
+ mrr: rr,
84
+ noBannedAboveRequired: gate.score,
85
+ bannedLeapfrogCount: gate.leapfrogCount,
86
+ score,
87
+ };
88
+ }
89
+ /** Aggregate per-case metrics into a suite summary. */
90
+ export function summarizeCurateMetrics(metrics) {
91
+ const n = metrics.length;
92
+ if (n === 0) {
93
+ return {
94
+ caseCount: 0,
95
+ meanScore: 0,
96
+ meanNdcg: 0,
97
+ meanRecall: 0,
98
+ meanMrr: 0,
99
+ meanNoBannedAboveRequired: 1,
100
+ totalBannedLeapfrog: 0,
101
+ };
102
+ }
103
+ const sum = (sel) => metrics.reduce((a, m) => a + sel(m), 0);
104
+ return {
105
+ caseCount: n,
106
+ meanScore: sum((m) => m.score) / n,
107
+ meanNdcg: sum((m) => m.ndcg) / n,
108
+ meanRecall: sum((m) => m.recall) / n,
109
+ meanMrr: sum((m) => m.mrr) / n,
110
+ meanNoBannedAboveRequired: sum((m) => m.noBannedAboveRequired) / n,
111
+ totalBannedLeapfrog: sum((m) => m.bannedLeapfrogCount),
112
+ };
113
+ }
@@ -25,9 +25,10 @@
25
25
  * - `ts` is ISO-8601 (UTC, millisecond precision).
26
26
  */
27
27
  import path from "node:path";
28
+ import { insertEvent, readStateEvents } from "../storage/repositories/events-repository.js";
28
29
  import { rethrowIfTestIsolationError } from "./errors.js";
29
30
  import { getDataDir } from "./paths.js";
30
- import { insertEvent, openStateDatabase, readStateEvents } from "./state-db.js";
31
+ import { openStateDatabase, withStateDb } from "./state-db.js";
31
32
  import { error } from "./warn.js";
32
33
  /**
33
34
  * Legacy events.jsonl path — used only by the migration script
@@ -82,18 +83,14 @@ export function appendEvent(input, ctx) {
82
83
  // Default path: open, insert, close.
83
84
  const dbPath = resolveDbPath(ctx);
84
85
  try {
85
- const db = openStateDatabase(dbPath);
86
- try {
86
+ withStateDb((db) => {
87
87
  insertEvent(db, {
88
88
  eventType: input.eventType,
89
89
  ts,
90
90
  ref: input.ref,
91
91
  metadata: input.metadata,
92
92
  });
93
- }
94
- finally {
95
- db.close();
96
- }
93
+ }, { path: dbPath });
97
94
  }
98
95
  catch (err) {
99
96
  // Never mask the bun-test isolation guard as a silent "events failed".
@@ -11,16 +11,18 @@ import { assertNever } from "./assert.js";
11
11
  *
12
12
  * Buckets:
13
13
  * - `accepted` — a write/content-authoring action succeeded.
14
- * - `rejected` — the action was deliberately not applied (cooldown, skip,
15
- * distill-skip, or a content-policy guard rejection). NOTE: as of the
16
- * round-2 health pass `reflect-guard-rejected` is bucketed here. Previously
17
- * the `state-db.ts` switch omitted it entirely (no case, no default), so a
18
- * guard rejection silently vanished from accepted/rejected/error totals — a
19
- * data-integrity miscount. It is a deliberate non-application of the action,
20
- * so it belongs with the other "rejected" outcomes.
14
+ * - `skipped` — the ref was GATED OUT before any content was produced: a
15
+ * cooldown, a signal-delta/eligibility skip, or a distill pool-delta skip.
16
+ * These are NOT rejections of produced content — they are the run declining
17
+ * to act on a ref it had no new reason to touch, and they scale with the
18
+ * whole indexed-ref pool (~13k/run), so folding them into `rejected` made the
19
+ * "accept rate" meaningless (deep-tuning analysis 2026-06-29, finding #1).
20
+ * - `rejected` the run PRODUCED a change and a content-policy guard then
21
+ * rejected it (`reflect-guard-rejected`). This is the genuine value-rejection
22
+ * signal; it is small and meaningful, no longer drowned by gated skips.
21
23
  * - `error` — the action failed (LLM/runtime error).
22
24
  * - `noop` — bookkeeping that is neither a write nor a rejection (memory-prune);
23
- * intentionally counted in none of the three numeric buckets.
25
+ * intentionally counted in none of the numeric buckets.
24
26
  *
25
27
  * The `default: assertNever(mode)` arm makes any future union variant a
26
28
  * compile-time error here, forcing an explicit bucket choice.
@@ -35,6 +37,7 @@ export function classifyImproveAction(mode) {
35
37
  case "reflect-cooldown":
36
38
  case "reflect-skipped":
37
39
  case "distill-skipped":
40
+ return "skipped";
38
41
  case "reflect-guard-rejected":
39
42
  return "rejected";
40
43
  case "reflect-failed":
@@ -46,3 +49,39 @@ export function classifyImproveAction(mode) {
46
49
  return assertNever(mode);
47
50
  }
48
51
  }
52
+ /** Upper bound on retained sample refs PER reason in {@link DistillSkippedAggregate}. */
53
+ export const DISTILL_SKIPPED_SAMPLE_CAP_PER_REASON = 3;
54
+ /**
55
+ * Partition an action list into the rows to persist and the `distill-skipped`
56
+ * aggregate. Pure — no I/O. Called once at improve-result assembly time so the
57
+ * serialized envelope never carries per-ref distill-skipped rows.
58
+ *
59
+ * Non-`distill-skipped` actions are returned verbatim and in order. When there
60
+ * are zero distill-skipped actions the aggregate is omitted (the envelope stays
61
+ * byte-identical to a run that skipped nothing).
62
+ */
63
+ export function foldDistillSkipped(actions) {
64
+ const kept = [];
65
+ const byReason = {};
66
+ const samples = [];
67
+ const sampleCountByReason = {};
68
+ let total = 0;
69
+ for (const action of actions) {
70
+ if (action.mode !== "distill-skipped") {
71
+ kept.push(action);
72
+ continue;
73
+ }
74
+ total += 1;
75
+ const r = action.result;
76
+ const reason = typeof r?.reason === "string" && r.reason.trim() ? r.reason : "unknown";
77
+ byReason[reason] = (byReason[reason] ?? 0) + 1;
78
+ const seen = sampleCountByReason[reason] ?? 0;
79
+ if (seen < DISTILL_SKIPPED_SAMPLE_CAP_PER_REASON) {
80
+ sampleCountByReason[reason] = seen + 1;
81
+ samples.push({ ref: action.ref, reason });
82
+ }
83
+ }
84
+ if (total === 0)
85
+ return { actions: kept };
86
+ return { actions: kept, aggregate: { total, byReason, samples } };
87
+ }
@@ -16,8 +16,10 @@
16
16
  * Log lines are high-volume, append-only, and freely purgeable; state.db rows
17
17
  * (events, proposals, task_history) are durable records. Separating them keeps
18
18
  * state.db small and lets log retention be aggressive without touching durable
19
- * state. Cross-db queries (e.g. "failed task_history row its log lines") use
20
- * SQLite ATTACH see {@link attachStateDatabase}.
19
+ * state. Callers that need to correlate a task_history row with its log lines do
20
+ * an application-side join on the {@link buildTaskRunId} key (e.g. `health` via
21
+ * {@link getLoggedRunIds}) — no SQLite ATTACH, so the split survives a future
22
+ * provider change.
21
23
  *
22
24
  * ## run_id
23
25
  *
@@ -35,12 +37,10 @@
35
37
  *
36
38
  * @module logs-db
37
39
  */
38
- import fs from "node:fs";
39
40
  import path from "node:path";
40
- import { openDatabase } from "../storage/database.js";
41
41
  import { runMigrations as runSqliteMigrations } from "../storage/engines/sqlite-migrations.js";
42
+ import { openManagedDatabase } from "../storage/managed-db.js";
42
43
  import { getDataDir } from "./paths.js";
43
- import { getStateDbPath } from "./state-db.js";
44
44
  // ── Path helper ──────────────────────────────────────────────────────────────
45
45
  /**
46
46
  * Default path: `<dataDir>/logs.db` — alongside state.db so cooperating
@@ -70,16 +70,13 @@ export function getLogsDbPath() {
70
70
  */
71
71
  export function openLogsDatabase(dbPath) {
72
72
  const resolvedPath = dbPath ?? getLogsDbPath();
73
- const dir = path.dirname(resolvedPath);
74
- if (!fs.existsSync(dir)) {
75
- fs.mkdirSync(dir, { recursive: true });
76
- }
77
- const db = openDatabase(resolvedPath);
78
- // PRAGMAs must run before any DDL or DML.
79
- db.exec("PRAGMA journal_mode = WAL");
80
- db.exec("PRAGMA busy_timeout = 30000");
81
- runMigrations(db);
82
- return db;
73
+ // foreignKeys:false preserves this opener's historical behaviour — logs.db
74
+ // has never enforced foreign keys.
75
+ return openManagedDatabase({
76
+ path: resolvedPath,
77
+ pragmas: { dataDir: path.dirname(resolvedPath), foreignKeys: false },
78
+ init: runMigrations,
79
+ });
83
80
  }
84
81
  // ── Migrations ───────────────────────────────────────────────────────────────
85
82
  /**
@@ -145,8 +142,8 @@ export function runMigrations(db) {
145
142
  * Encode a task run's identity — the unique `(task_id, started_at)` pair from
146
143
  * state.db `task_history` — as a single run_id string.
147
144
  *
148
- * The format MUST stay in sync with the SQL expression
149
- * `task_id || '@' || started_at` used by {@link queryFailedRunLogLines}.
145
+ * The format MUST stay in sync with the application-side join key that callers
146
+ * build from a `task_history` row's `task_id` and `started_at`.
150
147
  */
151
148
  export function buildTaskRunId(taskId, startedAtIso) {
152
149
  return `${taskId}@${startedAtIso}`;
@@ -229,64 +226,6 @@ export function getLoggedRunIds(db, runIds) {
229
226
  }
230
227
  return out;
231
228
  }
232
- // ── Cross-db: ATTACH state.db ────────────────────────────────────────────────
233
- /**
234
- * ATTACH state.db to an open logs.db handle under the schema name `state`,
235
- * enabling cross-db joins like task_history × task_logs.
236
- *
237
- * The state.db file must already exist (callers always open state.db first in
238
- * practice); attaching a non-existent path would silently create an empty,
239
- * unmigrated database file, so this throws instead.
240
- */
241
- export function attachStateDatabase(db, stateDbPath) {
242
- const resolved = stateDbPath ?? getStateDbPath();
243
- if (!fs.existsSync(resolved)) {
244
- throw new Error(`Cannot ATTACH state.db: file does not exist at ${resolved}`);
245
- }
246
- // prepare().run() rather than db.run(): both drivers support parameterised
247
- // ATTACH through a prepared statement, and no other call site uses db.run().
248
- db.prepare("ATTACH DATABASE ? AS state").run(resolved);
249
- }
250
- /**
251
- * Convenience: open logs.db with state.db attached as `state`. The returned
252
- * handle supports cross-db queries such as {@link queryFailedRunLogLines}.
253
- * Close it like any other handle (DETACH is implicit on close).
254
- */
255
- export function openLogsDatabaseWithState(logsDbPath, stateDbPath) {
256
- const db = openLogsDatabase(logsDbPath);
257
- try {
258
- attachStateDatabase(db, stateDbPath);
259
- }
260
- catch (err) {
261
- db.close();
262
- throw err;
263
- }
264
- return db;
265
- }
266
- /**
267
- * Cross-db join: every log line belonging to a FAILED task_history run whose
268
- * `started_at` is `>= since` (all failed runs when omitted). Requires a handle
269
- * opened via {@link openLogsDatabaseWithState}.
270
- *
271
- * The join key is the run_id encoding documented on {@link buildTaskRunId}:
272
- * `task_logs.run_id = task_history.task_id || '@' || task_history.started_at`.
273
- */
274
- export function queryFailedRunLogLines(db, options = {}) {
275
- const conditions = ["th.status = 'failed'"];
276
- const params = [];
277
- if (options.since) {
278
- conditions.push("th.started_at >= ?");
279
- params.push(options.since);
280
- }
281
- const limit = options.limit !== undefined && options.limit >= 0 ? ` LIMIT ${Math.floor(options.limit)}` : "";
282
- return db
283
- .prepare(`SELECT th.task_id, l.run_id, th.started_at, th.status, l.ts, l.stream, l.level, l.line
284
- FROM state.task_history th
285
- JOIN task_logs l ON l.run_id = th.task_id || '@' || th.started_at
286
- WHERE ${conditions.join(" AND ")}
287
- ORDER BY th.started_at DESC, l.id ASC${limit}`)
288
- .all(...params);
289
- }
290
229
  // ── Retention ────────────────────────────────────────────────────────────────
291
230
  /**
292
231
  * Delete task_logs rows older than `retentionDays` (default: 90). Mirrors
@@ -98,16 +98,24 @@ export function parseJsonResponse(raw) {
98
98
  * balanced `{ }` or `[ ]` structure in the text and attempts to parse that
99
99
  * substring. Returns `undefined` if no valid JSON structure is found.
100
100
  *
101
- * Non-array results are preferred: if a `{…}` object is found first, it is
102
- * returned immediately. Arrays (`[…]`) are captured as a fallback and
103
- * returned only when no object was found.
101
+ * Shape preference is controlled by {@link ParseEmbeddedJsonOptions.expect}:
102
+ * - `"any"` (default): non-array results are preferred a `{…}` object found
103
+ * first is returned immediately; arrays (`[…]`) are a fallback.
104
+ * - `"array"`: only top-level arrays are returned. The direct parse is
105
+ * accepted only if it is an array, and the scanner returns the first
106
+ * balanced `[…]` while skipping `{…}` openers entirely.
104
107
  */
105
- export function parseEmbeddedJsonResponse(raw) {
108
+ export function parseEmbeddedJsonResponse(raw, options) {
109
+ const expectArray = options?.expect === "array";
106
110
  const direct = parseJsonResponse(raw);
107
- if (direct !== undefined)
111
+ if (direct !== undefined && (!expectArray || Array.isArray(direct)))
108
112
  return direct;
109
113
  const text = escapeJsonStringControls(stripCodeFences(stripThinkBlocks(raw)));
110
114
  let arrayFallback;
115
+ // Scan only *top-level* balanced structures: once a `{…}`/`[…]` is matched we
116
+ // jump `start` past its closing bracket rather than re-scanning its interior.
117
+ // This keeps array mode from salvaging an array *nested inside* a leading
118
+ // object (e.g. the `entities` array of a bare `{entities,relations}` object).
111
119
  for (let start = 0; start < text.length; start++) {
112
120
  const opener = text[start];
113
121
  if (opener !== "{" && opener !== "[")
@@ -116,6 +124,7 @@ export function parseEmbeddedJsonResponse(raw) {
116
124
  let depth = 0;
117
125
  let inString = false;
118
126
  let escaped = false;
127
+ let end = -1;
119
128
  for (let i = start; i < text.length; i++) {
120
129
  const ch = text[i];
121
130
  if (inString) {
@@ -139,20 +148,31 @@ export function parseEmbeddedJsonResponse(raw) {
139
148
  if (ch === closer) {
140
149
  depth -= 1;
141
150
  if (depth === 0) {
142
- try {
143
- const parsed = JSON.parse(text.slice(start, i + 1));
144
- if (!Array.isArray(parsed)) {
145
- return parsed;
146
- }
147
- arrayFallback ??= parsed;
148
- break;
149
- }
150
- catch {
151
- break;
152
- }
151
+ end = i;
152
+ break;
153
153
  }
154
154
  }
155
155
  }
156
+ if (end === -1)
157
+ continue; // never balanced — let the next opener try
158
+ try {
159
+ const parsed = JSON.parse(text.slice(start, end + 1));
160
+ if (Array.isArray(parsed)) {
161
+ // First valid array wins in array mode; in "any" mode it is the
162
+ // fallback returned only if no object is found.
163
+ if (expectArray)
164
+ return parsed;
165
+ arrayFallback ??= parsed;
166
+ }
167
+ else if (!expectArray) {
168
+ return parsed;
169
+ }
170
+ // Skip past this balanced structure so we don't descend into it.
171
+ start = end;
172
+ }
173
+ catch {
174
+ // Malformed candidate — advance one char and try the next opener.
175
+ }
156
176
  }
157
177
  return arrayFallback;
158
178
  }