akm-cli 0.6.1 → 0.7.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 (333) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/dist/{cli.js → src/cli.js} +712 -34
  3. package/dist/{commands → src/commands}/config-cli.js +47 -4
  4. package/dist/src/commands/distill.js +283 -0
  5. package/dist/src/commands/events.js +108 -0
  6. package/dist/src/commands/history.js +191 -0
  7. package/dist/{commands → src/commands}/installed-stashes.js +1 -1
  8. package/dist/src/commands/proposal.js +119 -0
  9. package/dist/src/commands/propose.js +171 -0
  10. package/dist/src/commands/reflect.js +193 -0
  11. package/dist/{commands → src/commands}/registry-search.js +71 -7
  12. package/dist/{commands → src/commands}/remember.js +12 -0
  13. package/dist/{commands → src/commands}/search.js +104 -4
  14. package/dist/{commands → src/commands}/self-update.js +4 -3
  15. package/dist/{commands → src/commands}/show.js +73 -0
  16. package/dist/{commands → src/commands}/source-add.js +5 -1
  17. package/dist/{commands → src/commands}/source-manage.js +7 -1
  18. package/dist/{core → src/core}/asset-ref.js +5 -5
  19. package/dist/{core → src/core}/asset-spec.js +12 -0
  20. package/dist/{core → src/core}/common.js +1 -1
  21. package/dist/{core → src/core}/config.js +203 -121
  22. package/dist/{core → src/core}/errors.js +4 -0
  23. package/dist/src/core/events.js +239 -0
  24. package/dist/src/core/lesson-lint.js +86 -0
  25. package/dist/src/core/proposals.js +406 -0
  26. package/dist/src/core/warn.js +72 -0
  27. package/dist/{core → src/core}/write-source.js +80 -5
  28. package/dist/{indexer → src/indexer}/db-search.js +114 -24
  29. package/dist/{indexer → src/indexer}/db.js +76 -23
  30. package/dist/{indexer → src/indexer}/file-context.js +0 -3
  31. package/dist/src/indexer/graph-boost.js +179 -0
  32. package/dist/src/indexer/graph-extraction.js +212 -0
  33. package/dist/{indexer → src/indexer}/indexer.js +88 -7
  34. package/dist/{indexer → src/indexer}/matchers.js +1 -1
  35. package/dist/src/indexer/memory-inference.js +263 -0
  36. package/dist/{indexer → src/indexer}/metadata.js +111 -3
  37. package/dist/{indexer → src/indexer}/search-source.js +4 -2
  38. package/dist/src/integrations/agent/config.js +292 -0
  39. package/dist/src/integrations/agent/detect.js +94 -0
  40. package/dist/src/integrations/agent/index.js +17 -0
  41. package/dist/src/integrations/agent/profiles.js +65 -0
  42. package/dist/src/integrations/agent/prompts.js +167 -0
  43. package/dist/src/integrations/agent/spawn.js +272 -0
  44. package/dist/{integrations → src/integrations}/github.js +9 -3
  45. package/dist/{integrations → src/integrations}/lockfile.js +0 -26
  46. package/dist/{llm → src/llm}/client.js +33 -2
  47. package/dist/{llm → src/llm}/embedders/remote.js +37 -3
  48. package/dist/src/llm/feature-gate.js +108 -0
  49. package/dist/src/llm/graph-extract.js +107 -0
  50. package/dist/src/llm/index-passes.js +35 -0
  51. package/dist/src/llm/memory-infer.js +86 -0
  52. package/dist/{output → src/output}/cli-hints.js +15 -2
  53. package/dist/{output → src/output}/renderers.js +63 -2
  54. package/dist/src/output/shapes.js +523 -0
  55. package/dist/src/output/text.js +1116 -0
  56. package/dist/{registry → src/registry}/build-index.js +19 -8
  57. package/dist/{registry → src/registry}/factory.js +0 -8
  58. package/dist/{registry → src/registry}/providers/static-index.js +6 -3
  59. package/dist/{registry → src/registry}/resolve.js +68 -2
  60. package/dist/{setup → src/setup}/setup.js +52 -5
  61. package/dist/{sources → src/sources}/providers/git.js +7 -15
  62. package/dist/{wiki → src/wiki}/wiki.js +54 -6
  63. package/dist/{workflows → src/workflows}/runs.js +37 -3
  64. package/dist/tests/add-website-source.test.js +119 -0
  65. package/dist/tests/agent/agent-config-loader.test.js +70 -0
  66. package/dist/tests/agent/agent-config.test.js +221 -0
  67. package/dist/tests/agent/agent-detect.test.js +100 -0
  68. package/dist/tests/agent/agent-spawn.test.js +234 -0
  69. package/dist/tests/agent-output.test.js +186 -0
  70. package/dist/tests/architecture/agent-no-llm-sdk-guard.test.js +103 -0
  71. package/dist/tests/architecture/agent-spawn-seam.test.js +193 -0
  72. package/dist/tests/architecture/llm-stateless-seam.test.js +112 -0
  73. package/dist/tests/asset-ref.test.js +192 -0
  74. package/dist/tests/asset-registry.test.js +103 -0
  75. package/dist/tests/asset-spec.test.js +241 -0
  76. package/dist/tests/bench/attribution.test.js +996 -0
  77. package/dist/tests/bench/cleanup-sigint.test.js +83 -0
  78. package/dist/tests/bench/cleanup.js +234 -0
  79. package/dist/tests/bench/cleanup.test.js +166 -0
  80. package/dist/tests/bench/cli.js +1018 -0
  81. package/dist/tests/bench/cli.test.js +445 -0
  82. package/dist/tests/bench/compare.test.js +556 -0
  83. package/dist/tests/bench/corpus.js +317 -0
  84. package/dist/tests/bench/corpus.test.js +258 -0
  85. package/dist/tests/bench/doctor.js +525 -0
  86. package/dist/tests/bench/driver.js +401 -0
  87. package/dist/tests/bench/driver.test.js +584 -0
  88. package/dist/tests/bench/environment.js +233 -0
  89. package/dist/tests/bench/environment.test.js +199 -0
  90. package/dist/tests/bench/evolve-metrics.js +179 -0
  91. package/dist/tests/bench/evolve-metrics.test.js +187 -0
  92. package/dist/tests/bench/evolve.js +647 -0
  93. package/dist/tests/bench/evolve.test.js +624 -0
  94. package/dist/tests/bench/failure-modes.test.js +349 -0
  95. package/dist/tests/bench/feedback-integrity.test.js +457 -0
  96. package/dist/tests/bench/leakage.test.js +228 -0
  97. package/dist/tests/bench/learning-curve.test.js +134 -0
  98. package/dist/tests/bench/metrics.js +2395 -0
  99. package/dist/tests/bench/metrics.test.js +1150 -0
  100. package/dist/tests/bench/no-os-tmpdir-invariant.test.js +43 -0
  101. package/dist/tests/bench/opencode-config.js +194 -0
  102. package/dist/tests/bench/opencode-config.test.js +370 -0
  103. package/dist/tests/bench/report.js +1885 -0
  104. package/dist/tests/bench/report.test.js +1038 -0
  105. package/dist/tests/bench/run-config.js +355 -0
  106. package/dist/tests/bench/run-config.test.js +298 -0
  107. package/dist/tests/bench/run-curate-test.js +32 -0
  108. package/dist/tests/bench/run-failing-tasks.js +56 -0
  109. package/dist/tests/bench/run-full-bench.js +51 -0
  110. package/dist/tests/bench/run-items36-targeted.js +69 -0
  111. package/dist/tests/bench/run-nano-quick.js +42 -0
  112. package/dist/tests/bench/run-waveg-targeted.js +62 -0
  113. package/dist/tests/bench/runner.js +699 -0
  114. package/dist/tests/bench/runner.test.js +958 -0
  115. package/dist/tests/bench/search-bridge.test.js +331 -0
  116. package/dist/tests/bench/tmp.js +131 -0
  117. package/dist/tests/bench/trajectory.js +116 -0
  118. package/dist/tests/bench/trajectory.test.js +127 -0
  119. package/dist/tests/bench/verifier.js +114 -0
  120. package/dist/tests/bench/verifier.test.js +118 -0
  121. package/dist/tests/bench/workflow-evaluator.js +557 -0
  122. package/dist/tests/bench/workflow-evaluator.test.js +421 -0
  123. package/dist/tests/bench/workflow-spec.js +345 -0
  124. package/dist/tests/bench/workflow-spec.test.js +363 -0
  125. package/dist/tests/bench/workflow-trace.js +472 -0
  126. package/dist/tests/bench/workflow-trace.test.js +254 -0
  127. package/dist/tests/benchmark-search-quality.js +536 -0
  128. package/dist/tests/benchmark-suite.js +1441 -0
  129. package/dist/tests/capture-cli.test.js +112 -0
  130. package/dist/tests/cli-errors.test.js +204 -0
  131. package/dist/tests/commands/events.test.js +370 -0
  132. package/dist/tests/commands/history.test.js +418 -0
  133. package/dist/tests/commands/import.test.js +103 -0
  134. package/dist/tests/commands/proposal-cli.test.js +209 -0
  135. package/dist/tests/commands/reflect-propose-cli.test.js +333 -0
  136. package/dist/tests/commands/remember.test.js +97 -0
  137. package/dist/tests/commands/scope-flags.test.js +300 -0
  138. package/dist/tests/commands/search.test.js +537 -0
  139. package/dist/tests/commands/show-indexer-parity.test.js +117 -0
  140. package/dist/tests/commands/show.test.js +294 -0
  141. package/dist/tests/common.test.js +266 -0
  142. package/dist/tests/completions.test.js +142 -0
  143. package/dist/tests/config-cli.test.js +193 -0
  144. package/dist/tests/config-llm-features.test.js +139 -0
  145. package/dist/tests/config.test.js +569 -0
  146. package/dist/tests/contracts/migration-baseline.test.js +43 -0
  147. package/dist/tests/contracts/reflect-propose-envelope.test.js +139 -0
  148. package/dist/tests/contracts/spec-helpers.js +46 -0
  149. package/dist/tests/contracts/v1-spec-section-11-proposal-queue.test.js +228 -0
  150. package/dist/tests/contracts/v1-spec-section-12-agent-config.test.js +56 -0
  151. package/dist/tests/contracts/v1-spec-section-13-lesson-type.test.js +34 -0
  152. package/dist/tests/contracts/v1-spec-section-14-llm-features.test.js +94 -0
  153. package/dist/tests/contracts/v1-spec-section-4-1-asset-types.test.js +39 -0
  154. package/dist/tests/contracts/v1-spec-section-4-2-quality-rules.test.js +44 -0
  155. package/dist/tests/contracts/v1-spec-section-5-configuration.test.js +47 -0
  156. package/dist/tests/contracts/v1-spec-section-6-orchestration.test.js +40 -0
  157. package/dist/tests/contracts/v1-spec-section-7-module-layout.test.js +58 -0
  158. package/dist/tests/contracts/v1-spec-section-8-extension-points.test.js +34 -0
  159. package/dist/tests/contracts/v1-spec-section-9-4-cli-surface.test.js +75 -0
  160. package/dist/tests/contracts/v1-spec-section-9-7-llm-agent-boundary.test.js +36 -0
  161. package/dist/tests/core/write-source.test.js +366 -0
  162. package/dist/tests/curate-command.test.js +87 -0
  163. package/dist/tests/db-scoring.test.js +201 -0
  164. package/dist/tests/db.test.js +654 -0
  165. package/dist/tests/distill-cli-flag.test.js +208 -0
  166. package/dist/tests/distill.test.js +515 -0
  167. package/dist/tests/docker-install.test.js +120 -0
  168. package/dist/tests/e2e.test.js +1419 -0
  169. package/dist/tests/embedder.test.js +340 -0
  170. package/dist/tests/embedding-model-config.test.js +379 -0
  171. package/dist/tests/feedback-command.test.js +172 -0
  172. package/dist/tests/file-context.test.js +552 -0
  173. package/dist/tests/fixtures/scripts/git/summarize-diff.js +9 -0
  174. package/dist/tests/fixtures/scripts/lint/eslint-check.js +7 -0
  175. package/dist/tests/fixtures/stashes/load.js +166 -0
  176. package/dist/tests/fixtures/stashes/load.test.js +97 -0
  177. package/dist/tests/fixtures/stashes/ranking-baseline/scripts/mem0-search.js +12 -0
  178. package/dist/tests/frontmatter.test.js +190 -0
  179. package/dist/tests/fts-field-weighting.test.js +254 -0
  180. package/dist/tests/fuzzy-search.test.js +230 -0
  181. package/dist/tests/git-provider-clone.test.js +45 -0
  182. package/dist/tests/github.test.js +161 -0
  183. package/dist/tests/graph-boost-ranking.test.js +305 -0
  184. package/dist/tests/graph-extraction.test.js +282 -0
  185. package/dist/tests/helpers/usage-events.js +8 -0
  186. package/dist/tests/index-pass-llm.test.js +161 -0
  187. package/dist/tests/indexer.test.js +570 -0
  188. package/dist/tests/info-command.test.js +166 -0
  189. package/dist/tests/init.test.js +69 -0
  190. package/dist/tests/install-script.test.js +246 -0
  191. package/dist/tests/integration/agent-real-profile.test.js +94 -0
  192. package/dist/tests/issue-36-repro.test.js +304 -0
  193. package/dist/tests/issues-191-194.test.js +160 -0
  194. package/dist/tests/lesson-lint.test.js +111 -0
  195. package/dist/tests/llm-client.test.js +115 -0
  196. package/dist/tests/llm-feature-gate.test.js +151 -0
  197. package/dist/tests/llm.test.js +139 -0
  198. package/dist/tests/lockfile.test.js +216 -0
  199. package/dist/tests/manifest.test.js +205 -0
  200. package/dist/tests/markdown.test.js +126 -0
  201. package/dist/tests/matchers-unit.test.js +189 -0
  202. package/dist/tests/memory-inference.test.js +299 -0
  203. package/dist/tests/merge-scoring.test.js +136 -0
  204. package/dist/tests/metadata.test.js +313 -0
  205. package/dist/tests/migration-help.test.js +89 -0
  206. package/dist/tests/origin-resolve.test.js +124 -0
  207. package/dist/tests/output-baseline.test.js +218 -0
  208. package/dist/tests/output-shapes-unit.test.js +478 -0
  209. package/dist/tests/parallel-search.test.js +272 -0
  210. package/dist/tests/parameter-metadata.test.js +365 -0
  211. package/dist/tests/paths.test.js +177 -0
  212. package/dist/tests/progressive-disclosure.test.js +280 -0
  213. package/dist/tests/proposals.test.js +279 -0
  214. package/dist/tests/proposed-quality.test.js +271 -0
  215. package/dist/tests/provider-registry.test.js +32 -0
  216. package/dist/tests/ranking-regression.test.js +548 -0
  217. package/dist/tests/reflect-propose.test.js +455 -0
  218. package/dist/tests/registry-build-index.test.js +394 -0
  219. package/dist/tests/registry-cli.test.js +290 -0
  220. package/dist/tests/registry-index-v2.test.js +430 -0
  221. package/dist/tests/registry-install.test.js +728 -0
  222. package/dist/tests/registry-providers/parity.test.js +189 -0
  223. package/dist/tests/registry-providers/skills-sh.test.js +309 -0
  224. package/dist/tests/registry-providers/static-index.test.js +238 -0
  225. package/dist/tests/registry-resolve.test.js +126 -0
  226. package/dist/tests/registry-search.test.js +923 -0
  227. package/dist/tests/remember-frontmatter.test.js +378 -0
  228. package/dist/tests/remember-unit.test.js +123 -0
  229. package/dist/tests/ripgrep-install.test.js +251 -0
  230. package/dist/tests/ripgrep-resolve.test.js +108 -0
  231. package/dist/tests/ripgrep.test.js +163 -0
  232. package/dist/tests/save-command.test.js +94 -0
  233. package/dist/tests/save-trust-qa-fixes.test.js +270 -0
  234. package/dist/tests/scoring-pipeline.test.js +648 -0
  235. package/dist/tests/search-include-proposed-cli.test.js +118 -0
  236. package/dist/tests/self-update.test.js +442 -0
  237. package/dist/tests/semantic-search-e2e.test.js +512 -0
  238. package/dist/tests/semantic-status.test.js +471 -0
  239. package/dist/tests/setup-run.integration.js +877 -0
  240. package/dist/tests/setup-wizard.test.js +198 -0
  241. package/dist/tests/setup.test.js +131 -0
  242. package/dist/tests/source-add.test.js +11 -0
  243. package/dist/tests/source-clone.test.js +254 -0
  244. package/dist/tests/source-manage.test.js +366 -0
  245. package/dist/tests/source-providers/filesystem.test.js +82 -0
  246. package/dist/tests/source-providers/git.test.js +252 -0
  247. package/dist/tests/source-providers/website.test.js +128 -0
  248. package/dist/tests/source-qa-fixes.test.js +286 -0
  249. package/dist/tests/source-registry.test.js +350 -0
  250. package/dist/tests/source-resolve.test.js +100 -0
  251. package/dist/tests/source-source.test.js +281 -0
  252. package/dist/tests/source.test.js +533 -0
  253. package/dist/tests/tar-utils-scan.test.js +73 -0
  254. package/dist/tests/toggle-components.test.js +73 -0
  255. package/dist/tests/usage-telemetry.test.js +265 -0
  256. package/dist/tests/utility-scoring.test.js +558 -0
  257. package/dist/tests/vault-load-error.test.js +78 -0
  258. package/dist/tests/vault-qa-fixes.test.js +194 -0
  259. package/dist/tests/vault.test.js +429 -0
  260. package/dist/tests/vector-search.test.js +608 -0
  261. package/dist/tests/walker.test.js +252 -0
  262. package/dist/tests/wave2-cluster-bc.test.js +228 -0
  263. package/dist/tests/wave2-cluster-d.test.js +180 -0
  264. package/dist/tests/wave2-cluster-e.test.js +179 -0
  265. package/dist/tests/wiki-qa-fixes.test.js +270 -0
  266. package/dist/tests/wiki.test.js +529 -0
  267. package/dist/tests/workflow-cli.test.js +271 -0
  268. package/dist/tests/workflow-markdown.test.js +171 -0
  269. package/dist/tests/workflow-path-escape.test.js +132 -0
  270. package/dist/tests/workflow-qa-fixes.test.js +395 -0
  271. package/dist/tests/workflows/indexer-rejection.test.js +213 -0
  272. package/docs/README.md +8 -0
  273. package/docs/migration/release-notes/0.7.0.md +244 -0
  274. package/package.json +2 -2
  275. package/dist/core/warn.js +0 -27
  276. package/dist/output/shapes.js +0 -212
  277. package/dist/output/text.js +0 -520
  278. /package/dist/{commands → src/commands}/completions.js +0 -0
  279. /package/dist/{commands → src/commands}/curate.js +0 -0
  280. /package/dist/{commands → src/commands}/info.js +0 -0
  281. /package/dist/{commands → src/commands}/init.js +0 -0
  282. /package/dist/{commands → src/commands}/install-audit.js +0 -0
  283. /package/dist/{commands → src/commands}/migration-help.js +0 -0
  284. /package/dist/{commands → src/commands}/source-clone.js +0 -0
  285. /package/dist/{commands → src/commands}/vault.js +0 -0
  286. /package/dist/{core → src/core}/asset-registry.js +0 -0
  287. /package/dist/{core → src/core}/frontmatter.js +0 -0
  288. /package/dist/{core → src/core}/markdown.js +0 -0
  289. /package/dist/{core → src/core}/paths.js +0 -0
  290. /package/dist/{indexer → src/indexer}/manifest.js +0 -0
  291. /package/dist/{indexer → src/indexer}/search-fields.js +0 -0
  292. /package/dist/{indexer → src/indexer}/semantic-status.js +0 -0
  293. /package/dist/{indexer → src/indexer}/usage-events.js +0 -0
  294. /package/dist/{indexer → src/indexer}/walker.js +0 -0
  295. /package/dist/{llm → src/llm}/embedder.js +0 -0
  296. /package/dist/{llm → src/llm}/embedders/cache.js +0 -0
  297. /package/dist/{llm → src/llm}/embedders/local.js +0 -0
  298. /package/dist/{llm → src/llm}/embedders/types.js +0 -0
  299. /package/dist/{llm → src/llm}/metadata-enhance.js +0 -0
  300. /package/dist/{output → src/output}/context.js +0 -0
  301. /package/dist/{registry → src/registry}/create-provider-registry.js +0 -0
  302. /package/dist/{registry → src/registry}/origin-resolve.js +0 -0
  303. /package/dist/{registry → src/registry}/providers/index.js +0 -0
  304. /package/dist/{registry → src/registry}/providers/skills-sh.js +0 -0
  305. /package/dist/{registry → src/registry}/providers/types.js +0 -0
  306. /package/dist/{registry → src/registry}/types.js +0 -0
  307. /package/dist/{setup → src/setup}/detect.js +0 -0
  308. /package/dist/{setup → src/setup}/ripgrep-install.js +0 -0
  309. /package/dist/{setup → src/setup}/ripgrep-resolve.js +0 -0
  310. /package/dist/{setup → src/setup}/steps.js +0 -0
  311. /package/dist/{sources → src/sources}/include.js +0 -0
  312. /package/dist/{sources → src/sources}/provider-factory.js +0 -0
  313. /package/dist/{sources → src/sources}/provider.js +0 -0
  314. /package/dist/{sources → src/sources}/providers/filesystem.js +0 -0
  315. /package/dist/{sources → src/sources}/providers/index.js +0 -0
  316. /package/dist/{sources → src/sources}/providers/install-types.js +0 -0
  317. /package/dist/{sources → src/sources}/providers/npm.js +0 -0
  318. /package/dist/{sources → src/sources}/providers/provider-utils.js +0 -0
  319. /package/dist/{sources → src/sources}/providers/sync-from-ref.js +0 -0
  320. /package/dist/{sources → src/sources}/providers/tar-utils.js +0 -0
  321. /package/dist/{sources → src/sources}/providers/website.js +0 -0
  322. /package/dist/{sources → src/sources}/resolve.js +0 -0
  323. /package/dist/{sources → src/sources}/types.js +0 -0
  324. /package/dist/{templates → src/templates}/wiki-templates.js +0 -0
  325. /package/dist/{version.js → src/version.js} +0 -0
  326. /package/dist/{workflows → src/workflows}/authoring.js +0 -0
  327. /package/dist/{workflows → src/workflows}/cli.js +0 -0
  328. /package/dist/{workflows → src/workflows}/db.js +0 -0
  329. /package/dist/{workflows → src/workflows}/document-cache.js +0 -0
  330. /package/dist/{workflows → src/workflows}/parser.js +0 -0
  331. /package/dist/{workflows → src/workflows}/renderer.js +0 -0
  332. /package/dist/{workflows → src/workflows}/schema.js +0 -0
  333. /package/dist/{workflows → src/workflows}/validator.js +0 -0
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Shared prompt builders for proposal-producing agent commands (#226).
3
+ *
4
+ * `akm reflect` and `akm propose` both shell out to the configured agent CLI
5
+ * (via {@link runAgent}) and ask it for a structured proposal payload. The
6
+ * prompts are intentionally similar — both ask the agent to return a single
7
+ * JSON object containing `ref`, `content`, and (optionally) `frontmatter` —
8
+ * so we share the construction here. Keeping the prompt builders in
9
+ * `src/integrations/agent/` rather than `src/llm/` is deliberate: these are
10
+ * shell-out prompts targeting an agent CLI, not in-tree LLM API calls.
11
+ *
12
+ * The output the agent must produce is a *strict* JSON object:
13
+ *
14
+ * ```json
15
+ * {
16
+ * "ref": "lesson:my-lesson",
17
+ * "content": "---\ndescription: ...\nwhen_to_use: ...\n---\n\nbody",
18
+ * "frontmatter": { "description": "...", "when_to_use": "..." }
19
+ * }
20
+ * ```
21
+ *
22
+ * `frontmatter` is optional — the proposal queue parses it from `content`
23
+ * during validation. We carry it through if the agent supplies it.
24
+ */
25
+ import { TYPE_DIRS } from "../../core/asset-spec";
26
+ /**
27
+ * Per-asset-type frontmatter / authoring hints surfaced in the prompt so
28
+ * the agent can produce content that passes proposal validation. Kept tiny:
29
+ * full schema docs live in `docs/` — these are nudges, not contracts.
30
+ */
31
+ const TYPE_HINTS = {
32
+ lesson: "lesson assets MUST start with frontmatter containing `description` and `when_to_use` keys (both non-empty). Body should be 1–3 short paragraphs of practical guidance.",
33
+ skill: "skill assets are stored as `skills/<name>/SKILL.md`. Frontmatter typically includes `name`, `description`, and `when_to_use`.",
34
+ command: "command assets are markdown with optional frontmatter (`name`, `description`). The body is the prompt template the user invokes.",
35
+ agent: "agent assets are markdown with frontmatter describing the agent role (`name`, `description`, optional `tools`, `model`).",
36
+ knowledge: "knowledge assets are reference markdown documents. Include a top-level `# Title` and concise sections.",
37
+ memory: "memory assets are short factual notes the user wants persisted across sessions. Frontmatter usually includes `description`.",
38
+ workflow: "workflow assets are markdown describing a multi-step process. Include `# <Title>` and ordered `## Step N` sections.",
39
+ script: "script assets are executable text files. Include a shebang and minimal usage comment.",
40
+ vault: "vault assets store environment variables (KEY=VALUE pairs). Comments use `#`. Never echo secret values back to the user.",
41
+ wiki: "wiki assets are markdown reference pages with `# Title` and structured headings.",
42
+ };
43
+ function hintForType(type) {
44
+ return TYPE_HINTS[type] ?? `assets of type "${type}" — produce sensible markdown with optional frontmatter.`;
45
+ }
46
+ function knownTypeList() {
47
+ return Object.keys(TYPE_DIRS).sort().join(", ");
48
+ }
49
+ /**
50
+ * Common envelope every prompt asks the agent to honour. The wrapper code
51
+ * uses `JSON.parse(stdout)` to extract the payload — anything outside the
52
+ * JSON object will be treated as a parse error.
53
+ */
54
+ const RESPONSE_CONTRACT = [
55
+ "Respond ONLY with a single JSON object. No prose before or after.",
56
+ 'Shape: {"ref": "<type>:<name>", "content": "<full file contents>", "frontmatter": {...}}',
57
+ "`content` is the full file body that will be written if accepted.",
58
+ "`frontmatter` is optional — include it if `content` starts with `---` so reviewers can sanity-check the keys.",
59
+ ].join("\n");
60
+ /**
61
+ * Build the prompt for `akm reflect [ref]`. Asks the agent to review an
62
+ * existing asset (plus any negative feedback / lint findings) and propose
63
+ * an improved version. Returns a single string — the agent runtime will
64
+ * forward it as the trailing positional arg.
65
+ */
66
+ export function buildReflectPrompt(input) {
67
+ const sections = [];
68
+ if (input.ref && input.type && input.name) {
69
+ sections.push(`You are reviewing an akm stash asset (${input.type}) called "${input.name}" and proposing an improved version.`);
70
+ sections.push(`Target ref: ${input.ref}`);
71
+ sections.push(`Asset-type guidance: ${hintForType(input.type)}`);
72
+ }
73
+ else {
74
+ sections.push("You are reviewing recent akm feedback and proposing a single improved asset revision.");
75
+ sections.push("No target ref was supplied. Choose the best target from the feedback below and return it in `ref`.");
76
+ sections.push(`Known asset types: ${knownTypeList()}.`);
77
+ }
78
+ if (input.task?.trim()) {
79
+ sections.push(`Task / focus: ${input.task.trim()}`);
80
+ }
81
+ if (input.assetContent?.trim()) {
82
+ sections.push("Current asset content (verbatim):");
83
+ sections.push("```");
84
+ sections.push(input.assetContent.trimEnd());
85
+ sections.push("```");
86
+ }
87
+ else if (input.ref) {
88
+ sections.push("(No existing content — propose a fresh asset that fits the ref.)");
89
+ }
90
+ else {
91
+ sections.push("(No existing asset content was supplied.)");
92
+ }
93
+ if (input.feedback && input.feedback.length > 0) {
94
+ sections.push("Recent feedback / signals:");
95
+ for (const line of input.feedback)
96
+ sections.push(`- ${line}`);
97
+ }
98
+ else if (!input.ref) {
99
+ sections.push("Recent feedback / signals:");
100
+ sections.push("- (no feedback events recorded)");
101
+ }
102
+ if (input.schemaHints && input.schemaHints.length > 0) {
103
+ sections.push("Schema / lint hints to address:");
104
+ for (const line of input.schemaHints)
105
+ sections.push(`- ${line}`);
106
+ }
107
+ sections.push("Produce a single proposal that addresses the feedback and respects the asset-type contract.");
108
+ sections.push(RESPONSE_CONTRACT);
109
+ return sections.join("\n\n");
110
+ }
111
+ /**
112
+ * Build the prompt for `akm propose <type> <name> --task ...`. Asks the
113
+ * agent to author a brand-new asset of the given type fulfilling `task`.
114
+ */
115
+ export function buildProposePrompt(input) {
116
+ const sections = [];
117
+ sections.push(`Author a new akm stash asset of type "${input.type}" named "${input.name}".`);
118
+ sections.push(`Task: ${input.task}`);
119
+ sections.push(`Asset-type guidance: ${hintForType(input.type)}`);
120
+ sections.push(`(Known asset types: ${knownTypeList()}.)`);
121
+ if (input.schemaHints && input.schemaHints.length > 0) {
122
+ sections.push("Schema / lint hints:");
123
+ for (const line of input.schemaHints)
124
+ sections.push(`- ${line}`);
125
+ }
126
+ sections.push("Produce a single proposal that, if accepted, would land as the asset described above.");
127
+ sections.push(RESPONSE_CONTRACT);
128
+ return sections.join("\n\n");
129
+ }
130
+ /**
131
+ * Parse agent stdout into a proposal payload. The agent contract requires a
132
+ * single JSON object; anything else is reported as a parse error so callers
133
+ * can map to {@link AgentFailureReason} `parse_error`.
134
+ */
135
+ export function parseAgentProposalPayload(stdout) {
136
+ const trimmed = stripJsonFences(stdout).trim();
137
+ if (!trimmed)
138
+ throw new Error("agent produced empty output");
139
+ const parsed = JSON.parse(trimmed);
140
+ if (typeof parsed.ref !== "string" || !parsed.ref.trim()) {
141
+ throw new Error('agent response missing required string field "ref"');
142
+ }
143
+ if (typeof parsed.content !== "string" || !parsed.content.trim()) {
144
+ throw new Error('agent response missing required string field "content"');
145
+ }
146
+ const out = {
147
+ ref: parsed.ref.trim(),
148
+ content: parsed.content,
149
+ };
150
+ if (parsed.frontmatter && typeof parsed.frontmatter === "object" && !Array.isArray(parsed.frontmatter)) {
151
+ out.frontmatter = parsed.frontmatter;
152
+ }
153
+ return out;
154
+ }
155
+ /**
156
+ * Strip `\`\`\`json … \`\`\`` fences if the agent wrapped its JSON output.
157
+ * Mirrors the same helper in `src/llm/client.ts` but kept local here so
158
+ * `agent/` does not import from `llm/` (the boundary is one-way per
159
+ * v1 spec §9.7 — agents are shell-out only).
160
+ */
161
+ export function stripJsonFences(text) {
162
+ const trimmed = text.trim();
163
+ const fenced = trimmed.match(/^```(?:json)?\s*\n([\s\S]*?)\n```$/);
164
+ if (fenced)
165
+ return fenced[1] ?? trimmed;
166
+ return trimmed;
167
+ }
@@ -0,0 +1,272 @@
1
+ /**
2
+ * Agent CLI spawn wrapper (v1 spec §12.2).
3
+ *
4
+ * Single helper that owns:
5
+ * • Process spawn (Bun's subprocess API).
6
+ * • Captured vs interactive stdio.
7
+ * • Hard timeout (per-call override or profile default).
8
+ * • Structured failure reasons — `timeout`, `spawn_failed`,
9
+ * `non_zero_exit`, `parse_error`.
10
+ *
11
+ * NEVER imports an LLM SDK. Agents are reachable only via shell-out;
12
+ * this is a pre-emptive guarantee against the #222 invariant.
13
+ */
14
+ import { DEFAULT_AGENT_TIMEOUT_MS } from "./config";
15
+ /**
16
+ * Kill the process group of `proc` with `signal`, falling back to
17
+ * `proc.kill(signal)` when `proc.pid` is unavailable (e.g. test fakes).
18
+ *
19
+ * Passing a negative PID to `process.kill` targets the entire process
20
+ * group, so opencode's child processes (the .opencode binary, etc.) are
21
+ * reaped alongside the node wrapper. The fallback keeps test fakes working
22
+ * without modification.
23
+ */
24
+ export function killGroup(proc, signal) {
25
+ if (typeof proc.pid === "number") {
26
+ try {
27
+ process.kill(-proc.pid, signal);
28
+ return;
29
+ }
30
+ catch {
31
+ // Process may have already exited; fall through to direct kill.
32
+ }
33
+ }
34
+ try {
35
+ proc.kill(signal);
36
+ }
37
+ catch {
38
+ /* ignore */
39
+ }
40
+ }
41
+ const DEFAULT_TIMEOUT_MS = DEFAULT_AGENT_TIMEOUT_MS;
42
+ function resolveSpawnFn(options) {
43
+ if (options.spawn)
44
+ return options.spawn;
45
+ // Pull from globalThis so tests that swap it out at module level are honoured.
46
+ const bun = globalThis.Bun;
47
+ if (!bun?.spawn) {
48
+ throw new Error("Bun.spawn is unavailable; pass options.spawn for non-Bun environments.");
49
+ }
50
+ return bun.spawn.bind(bun);
51
+ }
52
+ /**
53
+ * Build the child env. Starts empty and copies through:
54
+ * • Every name in `profile.envPassthrough`.
55
+ * • Every entry in `profile.env`.
56
+ * • Every entry in `options.env` (highest precedence).
57
+ */
58
+ function buildChildEnv(profile, options) {
59
+ const source = options.envSource ?? process.env;
60
+ const env = {};
61
+ for (const name of profile.envPassthrough) {
62
+ const value = source[name];
63
+ if (value !== undefined)
64
+ env[name] = value;
65
+ }
66
+ if (profile.env) {
67
+ for (const [k, v] of Object.entries(profile.env))
68
+ env[k] = v;
69
+ }
70
+ if (options.env) {
71
+ for (const [k, v] of Object.entries(options.env))
72
+ env[k] = v;
73
+ }
74
+ return env;
75
+ }
76
+ async function readStream(stream, opts) {
77
+ if (!stream)
78
+ return "";
79
+ const readPromise = new Response(stream).text().catch(() => "");
80
+ if (!opts?.timeoutMs)
81
+ return readPromise;
82
+ // Race the stream read against a timeout so a process that is killed via
83
+ // SIGTERM/SIGKILL but whose pipe endpoints stay open (e.g. background
84
+ // threads still holding the fd) cannot block the caller indefinitely.
85
+ // On timeout we return whatever we received so far (empty string here since
86
+ // `readPromise` is all-or-nothing with `Response.text()`).
87
+ const timeoutPromise = new Promise((resolve) => {
88
+ setTimeout(() => resolve(""), opts.timeoutMs);
89
+ });
90
+ return Promise.race([readPromise, timeoutPromise]);
91
+ }
92
+ /**
93
+ * Spawn the agent CLI described by `profile` with `prompt` (forwarded as
94
+ * the last positional arg by default) and return a structured result.
95
+ *
96
+ * The `prompt` argument is appended to `profile.args` (and `options.args`)
97
+ * unless it is `undefined`. Pass `prompt = ""` to forward an explicit
98
+ * empty positional, or pass extra args via `options.args`.
99
+ *
100
+ * Failure modes (see {@link AgentFailureReason}):
101
+ *
102
+ * • `spawn_failed` — `Bun.spawn` threw synchronously.
103
+ * • `timeout` — exceeded the resolved timeout.
104
+ * • `non_zero_exit` — child exited with a non-zero code.
105
+ * • `parse_error` — `parseOutput === "json"` and stdout was not JSON.
106
+ *
107
+ * `ok === true` requires exit code 0 and (if `parseOutput === "json"`)
108
+ * a successful `JSON.parse`.
109
+ */
110
+ export async function runAgent(profile, prompt, options = {}) {
111
+ const stdioMode = options.stdio ?? profile.stdio;
112
+ const timeoutMs = options.timeoutMs ?? profile.timeoutMs ?? DEFAULT_TIMEOUT_MS;
113
+ const parseOutput = options.parseOutput ?? profile.parseOutput;
114
+ const setTimeoutImpl = options.setTimeoutFn ?? setTimeout;
115
+ const clearTimeoutImpl = options.clearTimeoutFn ?? clearTimeout;
116
+ const args = [...profile.args, ...(options.args ?? [])];
117
+ if (prompt !== undefined)
118
+ args.push(prompt);
119
+ const env = buildChildEnv(profile, options);
120
+ const start = Date.now();
121
+ let proc;
122
+ try {
123
+ const spawnFn = resolveSpawnFn(options);
124
+ proc = spawnFn([profile.bin, ...args], {
125
+ stdin: stdioMode === "captured" ? (options.stdin !== undefined ? "pipe" : "ignore") : "inherit",
126
+ stdout: stdioMode === "captured" ? "pipe" : "inherit",
127
+ stderr: stdioMode === "captured" ? "pipe" : "inherit",
128
+ env,
129
+ ...(options.cwd ? { cwd: options.cwd } : {}),
130
+ // Spawn in its own process group so killGroup(-pid, signal) reaches all
131
+ // descendants (e.g. the .opencode binary that opencode's node wrapper forks).
132
+ // Only applied in captured mode — interactive mode inherits the parent
133
+ // terminal's process group intentionally.
134
+ ...(stdioMode === "captured" ? { detached: true } : {}),
135
+ });
136
+ }
137
+ catch (err) {
138
+ const durationMs = Date.now() - start;
139
+ return {
140
+ ok: false,
141
+ exitCode: null,
142
+ stdout: "",
143
+ stderr: "",
144
+ durationMs,
145
+ reason: "spawn_failed",
146
+ error: err instanceof Error ? err.message : String(err),
147
+ };
148
+ }
149
+ // Hard timeout. We prefer SIGTERM, then SIGKILL if SIGTERM is ignored,
150
+ // but Bun.spawn only exposes a single .kill() — one signal is enough
151
+ // for the structured-failure contract.
152
+ //
153
+ // BUG-M3: only flag `timedOut` when the child has not already exited. A
154
+ // timer firing in the same microtask as `proc.exited` resolving could
155
+ // otherwise label a clean exit as a timeout.
156
+ let timedOut = false;
157
+ const timer = setTimeoutImpl(() => {
158
+ if (proc.exitCode !== null)
159
+ return;
160
+ timedOut = true;
161
+ killGroup(proc, "SIGTERM");
162
+ // Follow up with SIGKILL after 5 s in case the process ignores SIGTERM.
163
+ setTimeoutImpl(() => {
164
+ if (proc.exitCode !== null)
165
+ return;
166
+ killGroup(proc, "SIGKILL");
167
+ }, 5000);
168
+ }, timeoutMs);
169
+ // Stream-drain timeout: the overall wall-clock budget plus a 2 s grace
170
+ // period. When a process is killed via SIGTERM/SIGKILL (from our timeout
171
+ // handler or from outside) some runtimes keep the pipe write-end open in
172
+ // background threads, which would cause `Response.text()` to block forever.
173
+ // Capping stream draining at `timeoutMs + 2 000 ms` ensures the caller
174
+ // never hangs past the wall budget regardless of subprocess pipe behaviour.
175
+ const streamDrainTimeoutMs = timeoutMs + 2_000;
176
+ const stdoutPromise = stdioMode === "captured"
177
+ ? readStream(proc.stdout ?? null, { timeoutMs: streamDrainTimeoutMs })
178
+ : Promise.resolve("");
179
+ const stderrPromise = stdioMode === "captured"
180
+ ? readStream(proc.stderr ?? null, { timeoutMs: streamDrainTimeoutMs })
181
+ : Promise.resolve("");
182
+ // Optional stdin payload (captured mode only).
183
+ //
184
+ // BUG-H1: race the stdin write/close against `proc.exited` and the
185
+ // timeout timer. If the child never drains stdin, an unraced
186
+ // `await writer.write()` would block forever and prevent `runAgent`
187
+ // from ever returning.
188
+ if (options.stdin !== undefined && stdioMode === "captured" && proc.stdin) {
189
+ const stdinPayload = options.stdin;
190
+ const stdinStream = proc.stdin;
191
+ const stdinDone = (async () => {
192
+ try {
193
+ const writer = stdinStream.getWriter();
194
+ const bytes = new TextEncoder().encode(stdinPayload);
195
+ await writer.write(bytes);
196
+ await writer.close();
197
+ }
198
+ catch {
199
+ // Best-effort: ignore stdin write failures, the child will get EOF.
200
+ }
201
+ })();
202
+ // Resolve as soon as either the write completes or the child exits.
203
+ // We don't await the result — only that one of the two has settled —
204
+ // so a stuck writer cannot keep us pinned past the timeout.
205
+ await Promise.race([stdinDone, proc.exited.catch(() => undefined)]);
206
+ }
207
+ let exitCode = null;
208
+ try {
209
+ exitCode = await proc.exited;
210
+ }
211
+ catch (err) {
212
+ clearTimeoutImpl(timer);
213
+ // BUG-H2: drain stream readers before the early return so they don't
214
+ // surface as unhandled rejections after the function resolves.
215
+ // The streams already carry a built-in drain timeout so this allSettled
216
+ // will not block indefinitely.
217
+ await Promise.allSettled([stdoutPromise, stderrPromise]);
218
+ const durationMs = Date.now() - start;
219
+ return {
220
+ ok: false,
221
+ exitCode: null,
222
+ stdout: "",
223
+ stderr: "",
224
+ durationMs,
225
+ reason: "spawn_failed",
226
+ error: err instanceof Error ? err.message : String(err),
227
+ };
228
+ }
229
+ clearTimeoutImpl(timer);
230
+ const [stdout, stderr] = await Promise.all([stdoutPromise, stderrPromise]);
231
+ const durationMs = Date.now() - start;
232
+ if (timedOut) {
233
+ return {
234
+ ok: false,
235
+ exitCode,
236
+ stdout,
237
+ stderr,
238
+ durationMs,
239
+ reason: "timeout",
240
+ error: `agent CLI "${profile.name}" timed out after ${timeoutMs}ms`,
241
+ };
242
+ }
243
+ if (exitCode !== 0) {
244
+ return {
245
+ ok: false,
246
+ exitCode,
247
+ stdout,
248
+ stderr,
249
+ durationMs,
250
+ reason: "non_zero_exit",
251
+ error: `agent CLI "${profile.name}" exited with code ${exitCode}`,
252
+ };
253
+ }
254
+ if (parseOutput === "json" && stdioMode === "captured") {
255
+ try {
256
+ const parsed = JSON.parse(stdout);
257
+ return { ok: true, exitCode, stdout, stderr, durationMs, parsed };
258
+ }
259
+ catch (err) {
260
+ return {
261
+ ok: false,
262
+ exitCode,
263
+ stdout,
264
+ stderr,
265
+ durationMs,
266
+ reason: "parse_error",
267
+ error: err instanceof Error ? err.message : String(err),
268
+ };
269
+ }
270
+ }
271
+ return { ok: true, exitCode, stdout, stderr, durationMs };
272
+ }
@@ -2,8 +2,13 @@ import * as childProcess from "node:child_process";
2
2
  export const GITHUB_API_BASE = "https://api.github.com";
3
3
  const GITHUB_TOKEN_DOMAINS = new Set(["api.github.com", "github.com", "uploads.github.com"]);
4
4
  function readGithubTokenFromEnv() {
5
- const token = process.env.GITHUB_TOKEN?.trim() || process.env.GH_TOKEN?.trim();
6
- return token || undefined;
5
+ if (process.env.GITHUB_TOKEN !== undefined) {
6
+ return process.env.GITHUB_TOKEN.trim();
7
+ }
8
+ if (process.env.GH_TOKEN !== undefined) {
9
+ return process.env.GH_TOKEN.trim();
10
+ }
11
+ return undefined;
7
12
  }
8
13
  function readGithubTokenFromGhCli() {
9
14
  const result = childProcess.spawnSync("gh", ["auth", "token"], {
@@ -17,7 +22,8 @@ function readGithubTokenFromGhCli() {
17
22
  return token || undefined;
18
23
  }
19
24
  function resolveGithubToken() {
20
- return readGithubTokenFromEnv() ?? readGithubTokenFromGhCli();
25
+ const token = readGithubTokenFromEnv();
26
+ return token !== undefined ? token || undefined : readGithubTokenFromGhCli();
21
27
  }
22
28
  /**
23
29
  * Build headers for GitHub API requests.
@@ -3,34 +3,9 @@ import path from "node:path";
3
3
  import { getConfigDir } from "../core/config";
4
4
  // ── Paths ───────────────────────────────────────────────────────────────────
5
5
  const LOCKFILE_NAME = "akm.lock";
6
- const LEGACY_LOCKFILE_NAME = "stash.lock";
7
6
  function getLockfilePath() {
8
7
  return path.join(getConfigDir(), LOCKFILE_NAME);
9
8
  }
10
- function getLegacyLockfilePath() {
11
- return path.join(getConfigDir(), LEGACY_LOCKFILE_NAME);
12
- }
13
- /**
14
- * One-time migration: if the new `akm.lock` does not exist but the legacy
15
- * `stash.lock` does, copy it across so installed-stash tracking survives the
16
- * rename. Best-effort; failures are silent because the lockfile loader treats
17
- * a missing file as an empty lockfile.
18
- */
19
- function migrateLegacyLockfileIfNeeded() {
20
- const newPath = getLockfilePath();
21
- const legacyPath = getLegacyLockfilePath();
22
- try {
23
- if (fs.existsSync(newPath))
24
- return;
25
- if (!fs.existsSync(legacyPath))
26
- return;
27
- fs.mkdirSync(path.dirname(newPath), { recursive: true });
28
- fs.copyFileSync(legacyPath, newPath);
29
- }
30
- catch {
31
- /* best-effort — fall through to empty lockfile */
32
- }
33
- }
34
9
  // ── Lock sentinel ────────────────────────────────────────────────────────────
35
10
  const LOCK_MAX_RETRIES = 3;
36
11
  const LOCK_RETRY_DELAY_MS = 100;
@@ -100,7 +75,6 @@ function releaseLockSentinel() {
100
75
  }
101
76
  // ── Read / Write ────────────────────────────────────────────────────────────
102
77
  export function readLockfile() {
103
- migrateLegacyLockfileIfNeeded();
104
78
  const lockfilePath = getLockfilePath();
105
79
  try {
106
80
  const raw = JSON.parse(fs.readFileSync(lockfilePath, "utf8"));
@@ -8,6 +8,36 @@
8
8
  * `llm.ts` re-exports everything from this module for backward compatibility.
9
9
  */
10
10
  import { fetchWithTimeout } from "../core/common";
11
+ /** Maximum length of an LLM error response body included in thrown errors. */
12
+ const ERROR_BODY_MAX_LEN = 200;
13
+ /**
14
+ * Redact credential-shaped substrings from an upstream error body before
15
+ * including it in a thrown Error. The body is also trimmed to a fixed length
16
+ * so that a verbose provider response cannot leak large amounts of context.
17
+ *
18
+ * Targets:
19
+ * - `Bearer <token>` headers echoed back by the provider
20
+ * - `sk-…` / `sk_…` style API keys (OpenAI / Anthropic-shaped)
21
+ * - `key-…` / `key_…` shorthand keys
22
+ * - `"api_key": "…"` / `"apiKey": "…"` JSON fields
23
+ */
24
+ export function redactErrorBody(input) {
25
+ if (!input)
26
+ return "";
27
+ let out = input
28
+ // Bearer tokens (case-insensitive)
29
+ .replace(/\bBearer\s+[A-Za-z0-9._\-+/=]+/gi, "Bearer [REDACTED]")
30
+ // sk-/sk_ style keys
31
+ .replace(/\bsk[-_][A-Za-z0-9._-]{6,}/g, "[REDACTED]")
32
+ // key-/key_ shorthand keys
33
+ .replace(/\bkey[-_][A-Za-z0-9._-]{6,}/g, "[REDACTED]")
34
+ // JSON-style "api_key": "...", "apiKey": "...", "api-key": "..."
35
+ .replace(/("(?:api[_-]?key|apiKey|authorization|token)"\s*:\s*")([^"]*)(")/gi, "$1[REDACTED]$3");
36
+ if (out.length > ERROR_BODY_MAX_LEN) {
37
+ out = `${out.slice(0, ERROR_BODY_MAX_LEN)}…`;
38
+ }
39
+ return out;
40
+ }
11
41
  export async function chatCompletion(config, messages, options) {
12
42
  const headers = { "Content-Type": "application/json" };
13
43
  if (config.apiKey) {
@@ -24,8 +54,9 @@ export async function chatCompletion(config, messages, options) {
24
54
  }),
25
55
  });
26
56
  if (!response.ok) {
27
- const body = await response.text().catch(() => "");
28
- throw new Error(`LLM request failed (${response.status}): ${body}`);
57
+ const rawBody = await response.text().catch(() => "");
58
+ const safeBody = redactErrorBody(rawBody);
59
+ throw new Error(`LLM request failed (${response.status}) ${config.endpoint}: ${safeBody}`);
29
60
  }
30
61
  const json = (await response.json());
31
62
  return json.choices?.[0]?.message?.content?.trim() ?? "";
@@ -5,7 +5,11 @@
5
5
  * vectors so the scoring pipeline's L2-to-cosine conversion is correct.
6
6
  */
7
7
  import { fetchWithTimeout, isHttpUrl } from "../../core/common";
8
- const REMOTE_BATCH_SIZE = 100;
8
+ const DEFAULT_REMOTE_BATCH_SIZE = 100;
9
+ /** Cheap token estimator: 4 chars ≈ 1 token. Used in verbose logging and error messages. */
10
+ export function estimateTokenCount(text) {
11
+ return Math.round(text.length / 4);
12
+ }
9
13
  export class RemoteEmbedder {
10
14
  config;
11
15
  constructor(config) {
@@ -20,6 +24,10 @@ export class RemoteEmbedder {
20
24
  if (this.config.dimension) {
21
25
  body.dimensions = this.config.dimension;
22
26
  }
27
+ const ollamaOpts = resolveOllamaOptions(this.config);
28
+ if (ollamaOpts) {
29
+ body.options = ollamaOpts;
30
+ }
23
31
  const response = await fetchWithTimeout(normalizeEmbeddingEndpoint(this.config.endpoint), {
24
32
  method: "POST",
25
33
  headers,
@@ -40,8 +48,10 @@ export class RemoteEmbedder {
40
48
  return [];
41
49
  const results = [];
42
50
  const headers = this.buildHeaders();
43
- for (let i = 0; i < texts.length; i += REMOTE_BATCH_SIZE) {
44
- const batch = texts.slice(i, i + REMOTE_BATCH_SIZE);
51
+ const ollamaOpts = resolveOllamaOptions(this.config);
52
+ const batchSize = this.config.batchSize ?? DEFAULT_REMOTE_BATCH_SIZE;
53
+ for (let i = 0; i < texts.length; i += batchSize) {
54
+ const batch = texts.slice(i, i + batchSize);
45
55
  const body = {
46
56
  input: batch,
47
57
  model: this.config.model,
@@ -49,6 +59,9 @@ export class RemoteEmbedder {
49
59
  if (this.config.dimension) {
50
60
  body.dimensions = this.config.dimension;
51
61
  }
62
+ if (ollamaOpts) {
63
+ body.options = ollamaOpts;
64
+ }
52
65
  const response = await fetchWithTimeout(normalizeEmbeddingEndpoint(this.config.endpoint), {
53
66
  method: "POST",
54
67
  headers,
@@ -115,6 +128,27 @@ function embeddingEndpointPathHint(endpoint) {
115
128
  }
116
129
  return "";
117
130
  }
131
+ /**
132
+ * Resolve Ollama-native `options` from the embedding config.
133
+ *
134
+ * Resolution order:
135
+ * 1. `ollamaOptions` — forwarded verbatim (explicit opt-in, takes precedence).
136
+ * 2. `contextLength` — wrapped as `{ num_ctx: contextLength }`.
137
+ * 3. Neither set → returns `undefined` (no `options` field in the request body).
138
+ *
139
+ * These options are only meaningful for Ollama's native `/api/embed` endpoint.
140
+ * OpenAI-compatible endpoints ignore unknown request fields, so passing them to
141
+ * other providers is harmless but has no effect.
142
+ */
143
+ function resolveOllamaOptions(config) {
144
+ if (config.ollamaOptions && Object.keys(config.ollamaOptions).length > 0) {
145
+ return config.ollamaOptions;
146
+ }
147
+ if (config.contextLength) {
148
+ return { num_ctx: config.contextLength };
149
+ }
150
+ return undefined;
151
+ }
118
152
  /** Check whether an EmbeddingConnectionConfig has a valid remote endpoint. */
119
153
  export function hasRemoteEndpoint(config) {
120
154
  return isHttpUrl(config.endpoint);