akm-cli 0.9.0-beta.9 → 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 (325) hide show
  1. package/CHANGELOG.md +592 -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 -21
  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 +156 -155
  57. package/dist/commands/graph/graph-cli.js +5 -13
  58. package/dist/commands/graph/graph.js +3 -3
  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 -1091
  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 +1295 -1277
  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 +228 -605
  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 +54 -3
  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 +157 -10
  97. package/dist/commands/improve/improve-cli.js +115 -73
  98. package/dist/commands/improve/improve-profiles.js +28 -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 +485 -2764
  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 +37 -35
  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 +206 -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 +2 -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 -895
  138. package/dist/commands/read/curate.js +410 -111
  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 +19 -39
  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 +382 -62
  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 +18 -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 +132 -1126
  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 +259 -769
  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 +36 -92
  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 +18 -11
  200. package/dist/indexer/index-written-assets.js +105 -0
  201. package/dist/indexer/indexer.js +182 -204
  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 +10 -0
  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 +34 -11
  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 +2661 -2369
  261. package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +883 -596
  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/website.js +9 -5
  286. package/dist/sources/website-ingest.js +187 -29
  287. package/dist/sources/wiki-fetchers/registry.js +53 -0
  288. package/dist/sources/wiki-fetchers/youtube.js +239 -0
  289. package/dist/storage/database.js +45 -10
  290. package/dist/storage/managed-db.js +82 -0
  291. package/dist/storage/repositories/canaries-repository.js +107 -0
  292. package/dist/storage/repositories/consolidation-repository.js +38 -0
  293. package/dist/storage/repositories/embeddings-repository.js +72 -0
  294. package/dist/storage/repositories/events-repository.js +187 -0
  295. package/dist/storage/repositories/extract-sessions-repository.js +96 -0
  296. package/dist/storage/repositories/improve-runs-repository.js +146 -0
  297. package/dist/storage/repositories/index-db.js +14 -8
  298. package/dist/storage/repositories/proposals-repository.js +220 -0
  299. package/dist/storage/repositories/recombine-repository.js +213 -0
  300. package/dist/storage/repositories/registry-cache.js +93 -0
  301. package/dist/storage/repositories/registry-index-cache-repository.js +46 -0
  302. package/dist/storage/repositories/task-history-repository.js +93 -0
  303. package/dist/storage/sqlite-pragmas.js +146 -0
  304. package/dist/tasks/backends/cron.js +1 -1
  305. package/dist/tasks/backends/index.js +9 -0
  306. package/dist/tasks/backends/launchd.js +1 -1
  307. package/dist/tasks/backends/schtasks.js +1 -1
  308. package/dist/tasks/{resolveAkmBin.js → resolve-akm-bin.js} +2 -2
  309. package/dist/tasks/runner.js +15 -13
  310. package/dist/text-import-hook.mjs +0 -0
  311. package/dist/wiki/wiki.js +52 -11
  312. package/dist/workflows/cli.js +1 -0
  313. package/dist/workflows/db.js +3 -4
  314. package/dist/workflows/runtime/runs.js +43 -118
  315. package/dist/workflows/runtime/workflow-asset-loader.js +125 -0
  316. package/dist/workflows/validate-summary.js +2 -7
  317. package/docs/README.md +69 -18
  318. package/docs/data-and-telemetry.md +5 -4
  319. package/docs/migration/release-notes/0.7.0.md +1 -1
  320. package/docs/migration/release-notes/0.9.0.md +39 -0
  321. package/package.json +10 -10
  322. package/dist/assets/tasks/core/update-stashes.yml +0 -4
  323. package/dist/commands/db-cli.js +0 -23
  324. package/dist/indexer/db/db-backup.js +0 -376
  325. package/dist/indexer/passes/staleness-detect.js +0 -488
@@ -4,6 +4,7 @@
4
4
  import fs from "node:fs";
5
5
  import os from "node:os";
6
6
  import path from "node:path";
7
+ import { openDatabase } from "../../../storage/database.js";
7
8
  import { extractInlineRefMentions } from "../../session-logs/inline-refs.js";
8
9
  function getOpenCodeBaseDir() {
9
10
  if (process.platform === "darwin") {
@@ -12,20 +13,49 @@ function getOpenCodeBaseDir() {
12
13
  return path.join(os.homedir(), ".local", "share", "opencode");
13
14
  }
14
15
  /**
15
- * Opencode storage layout (observed 2026-05):
16
- * <base>/storage/session/<projectId>/<sessionId>.json — metadata
17
- * <base>/storage/message/<sessionId>/<messageId>.json one per message
16
+ * Opencode storage layouts:
17
+ *
18
+ * SQLite (current, observed 2026-06): `<base>/opencode.db` a Drizzle-managed
19
+ * database with `session` / `message` / `part` tables. Message text lives in
20
+ * `part` rows (`data` JSON, `type: "text"`); `message.data` holds role/timing.
21
+ * This is the layout current opencode builds write; it is preferred whenever
22
+ * `opencode.db` exists.
23
+ *
24
+ * JSON files (legacy, observed 2026-05): `<base>/storage/session/<projectId>/
25
+ * <sessionId>.json` (metadata) + `<base>/storage/message/<sessionId>/
26
+ * <messageId>.json` (one per message). Read only when `opencode.db` is absent.
18
27
  *
19
28
  * Older builds wrote logs directly into `<base>/log/` and `<base>/*.log`;
20
29
  * those are still scanned by {@link OpenCodeProvider.readEvents} for
21
30
  * backward compatibility with the existing failure-pattern aggregator.
22
31
  */
32
+ /** Filename of opencode's SQLite session store, relative to its base dir. */
33
+ const OPENCODE_DB_FILENAME = "opencode.db";
23
34
  export class OpenCodeProvider {
24
35
  name = "opencode";
25
36
  #baseDir = getOpenCodeBaseDir();
26
37
  isAvailable() {
27
38
  return fs.existsSync(this.#baseDir);
28
39
  }
40
+ /** Absolute path to opencode's SQLite store under `base`. */
41
+ #dbPath(base) {
42
+ return path.join(base, OPENCODE_DB_FILENAME);
43
+ }
44
+ /**
45
+ * Directories/files opencode writes session data under. Returns the base dir
46
+ * when the SQLite store (`opencode.db`) exists, the legacy JSON session root
47
+ * (`<base>/storage/session`) when present, or both during a migration overlap.
48
+ * Empty when neither exists. See {@link SessionLogHarness.watchRoots}.
49
+ */
50
+ watchRoots() {
51
+ const roots = [];
52
+ if (fs.existsSync(this.#dbPath(this.#baseDir)))
53
+ roots.push(this.#baseDir);
54
+ const sessionRoot = path.join(this.#baseDir, "storage", "session");
55
+ if (fs.existsSync(sessionRoot))
56
+ roots.push(sessionRoot);
57
+ return roots;
58
+ }
29
59
  *readEvents(input) {
30
60
  // Legacy behavior: stream raw log lines from the top-level dir and `log/`
31
61
  // subdirectory. Kept to keep `getExecutionLogCandidates` working without
@@ -82,6 +112,9 @@ export class OpenCodeProvider {
82
112
  listSessions(input = {}) {
83
113
  const base = input.location ?? this.#baseDir;
84
114
  const sinceMs = input.sinceMs ?? 0;
115
+ const dbPath = this.#dbPath(base);
116
+ if (fs.existsSync(dbPath))
117
+ return this.#listSessionsFromDb(dbPath, sinceMs);
85
118
  const sessionRoot = path.join(base, "storage", "session");
86
119
  if (!fs.existsSync(sessionRoot))
87
120
  return [];
@@ -142,6 +175,8 @@ export class OpenCodeProvider {
142
175
  return summaries.sort((a, b) => (b.endedAt ?? 0) - (a.endedAt ?? 0));
143
176
  }
144
177
  readSession(ref) {
178
+ if (path.basename(ref.filePath) === OPENCODE_DB_FILENAME)
179
+ return this.#readSessionFromDb(ref);
145
180
  let meta = {};
146
181
  try {
147
182
  meta = JSON.parse(fs.readFileSync(ref.filePath, "utf8"));
@@ -199,6 +234,141 @@ export class OpenCodeProvider {
199
234
  inlineRefs,
200
235
  };
201
236
  }
237
+ /**
238
+ * List sessions from the SQLite store. `filePath` on each summary is the
239
+ * `opencode.db` path so {@link readSession} can route back to the DB reader.
240
+ * Returns `[]` (never throws) when the DB is unreadable or lacks the expected
241
+ * schema — callers treat a missing harness as "no sessions".
242
+ */
243
+ #listSessionsFromDb(dbPath, sinceMs) {
244
+ let db;
245
+ try {
246
+ db = openDatabase(dbPath, { readonly: true, create: false });
247
+ }
248
+ catch {
249
+ return [];
250
+ }
251
+ try {
252
+ const rows = db
253
+ .prepare("SELECT id, title, directory, time_created, time_updated FROM session WHERE time_updated >= ? ORDER BY time_updated DESC")
254
+ .all(sinceMs);
255
+ return rows.map((r) => {
256
+ const startedAt = typeof r.time_created === "number" ? r.time_created : undefined;
257
+ const endedAt = typeof r.time_updated === "number" ? r.time_updated : undefined;
258
+ const title = typeof r.title === "string" && r.title.length > 0 ? r.title : undefined;
259
+ const projectHint = typeof r.directory === "string" && r.directory.length > 0 ? r.directory : undefined;
260
+ return {
261
+ harness: this.name,
262
+ sessionId: r.id,
263
+ filePath: dbPath,
264
+ ...(startedAt !== undefined ? { startedAt } : {}),
265
+ ...(endedAt !== undefined ? { endedAt } : {}),
266
+ ...(projectHint ? { projectHint } : {}),
267
+ ...(title ? { title } : {}),
268
+ };
269
+ });
270
+ }
271
+ catch {
272
+ // Missing `session` table / unexpected schema — treat as no sessions.
273
+ return [];
274
+ }
275
+ finally {
276
+ db.close();
277
+ }
278
+ }
279
+ /**
280
+ * Read one session from the SQLite store. Message text lives in `part` rows
281
+ * (`type: "text"`); `message.data` carries role + timing. One event per
282
+ * message, text-parts concatenated in time order. Returns empty events
283
+ * (never throws) when the DB is unreadable.
284
+ */
285
+ #readSessionFromDb(ref) {
286
+ const emptyRef = { harness: this.name, sessionId: ref.sessionId, filePath: ref.filePath };
287
+ let db;
288
+ try {
289
+ db = openDatabase(ref.filePath, { readonly: true, create: false });
290
+ }
291
+ catch {
292
+ return { ref: emptyRef, events: [], inlineRefs: [] };
293
+ }
294
+ try {
295
+ const meta = db
296
+ .prepare("SELECT title, directory, time_created, time_updated FROM session WHERE id = ?")
297
+ .get(ref.sessionId);
298
+ const startedAt = typeof meta?.time_created === "number" ? meta.time_created : undefined;
299
+ const endedAt = typeof meta?.time_updated === "number" ? meta.time_updated : undefined;
300
+ const title = typeof meta?.title === "string" && meta.title.length > 0 ? meta.title : undefined;
301
+ const projectHint = typeof meta?.directory === "string" && meta.directory.length > 0 ? meta.directory : undefined;
302
+ const messages = db
303
+ .prepare("SELECT id, data, time_created FROM message WHERE session_id = ? ORDER BY time_created ASC, id ASC")
304
+ .all(ref.sessionId);
305
+ const parts = db
306
+ .prepare("SELECT message_id, data FROM part WHERE session_id = ? ORDER BY time_created ASC, id ASC")
307
+ .all(ref.sessionId);
308
+ // Group text-part bodies by their parent message.
309
+ const textByMessage = new Map();
310
+ for (const part of parts) {
311
+ let parsed;
312
+ try {
313
+ parsed = JSON.parse(part.data);
314
+ }
315
+ catch {
316
+ continue;
317
+ }
318
+ if (parsed?.type !== "text")
319
+ continue;
320
+ const text = parsed.text;
321
+ if (typeof text !== "string" || text.length < 1)
322
+ continue;
323
+ const bucket = textByMessage.get(part.message_id) ?? [];
324
+ bucket.push(text);
325
+ textByMessage.set(part.message_id, bucket);
326
+ }
327
+ const events = [];
328
+ const inlineRefs = [];
329
+ for (const message of messages) {
330
+ let mdata = {};
331
+ try {
332
+ mdata = JSON.parse(message.data);
333
+ }
334
+ catch {
335
+ // role/timing unavailable — fall through with defaults
336
+ }
337
+ const role = typeof mdata.role === "string" ? mdata.role : "unknown";
338
+ const mtime = mdata.time?.created;
339
+ const ts = typeof mtime === "number"
340
+ ? mtime
341
+ : typeof message.time_created === "number"
342
+ ? message.time_created
343
+ : undefined;
344
+ const text = (textByMessage.get(message.id) ?? []).join("\n").trim();
345
+ if (text.length < 1)
346
+ continue;
347
+ events.push({ harness: this.name, text, ts, sessionId: ref.sessionId, role, filePath: ref.filePath });
348
+ inlineRefs.push(...extractInlineRefMentions(text, ts));
349
+ }
350
+ events.sort((a, b) => (a.ts ?? 0) - (b.ts ?? 0));
351
+ return {
352
+ ref: {
353
+ harness: this.name,
354
+ sessionId: ref.sessionId,
355
+ filePath: ref.filePath,
356
+ ...(startedAt !== undefined ? { startedAt } : {}),
357
+ ...(endedAt !== undefined ? { endedAt } : {}),
358
+ ...(projectHint ? { projectHint } : {}),
359
+ ...(title ? { title } : {}),
360
+ },
361
+ events,
362
+ inlineRefs,
363
+ };
364
+ }
365
+ catch {
366
+ return { ref: emptyRef, events: [], inlineRefs: [] };
367
+ }
368
+ finally {
369
+ db.close();
370
+ }
371
+ }
202
372
  /**
203
373
  * Derive opencode base dir from a session metadata file path so a caller
204
374
  * passing a custom `--location` can still find the message dir.
@@ -5,7 +5,7 @@
5
5
  * OpenCode SDK harness (#564).
6
6
  *
7
7
  * Per-harness barrel for the SDK-mode dispatch path:
8
- * - agent runner → ./sdk-runner.ts (runOpencodeSdk / runAgentSdk)
8
+ * - agent runner → ./sdk-runner.ts (runOpencodeSdk)
9
9
  *
10
10
  * It also defines {@link OpencodeSdkHarness}, the {@link AkmHarness} descriptor
11
11
  * that `HARNESS_REGISTRY` registers.
@@ -17,7 +17,7 @@
17
17
  * names. Canonical id is `'opencode-sdk'` with no alias.
18
18
  */
19
19
  import { BaseHarness } from "../types.js";
20
- export { closeServer, runAgentSdk, runOpencodeSdk } from "./sdk-runner.js";
20
+ export { closeServer, runOpencodeSdk } from "./sdk-runner.js";
21
21
  function caps(c) {
22
22
  return {
23
23
  sessionLogs: false,
@@ -14,6 +14,7 @@
14
14
  */
15
15
  import { resolveSecret } from "../../../core/config/config.js";
16
16
  import { DEFAULT_AGENT_TIMEOUT_MS } from "../../agent/config.js";
17
+ import { resolveModel } from "../../agent/model-aliases.js";
17
18
  // Singleton server — started once per process, reused across calls
18
19
  let _server = null;
19
20
  /**
@@ -85,16 +86,23 @@ function toolsToSdkAllowlist(tools) {
85
86
  out[n] = true;
86
87
  return out;
87
88
  }
88
- async function getOrStartServer(profile, llmConfig) {
89
- if (_server)
90
- return _server;
91
- const { createOpencode } = await import("@opencode-ai/sdk").catch(() => {
92
- throw new Error("OpenCode SDK not available. Install @opencode-ai/sdk or configure a CLI agent instead.");
93
- });
89
+ /**
90
+ * Assemble the OpenCode SDK server config from the profile + LLM fallback.
91
+ * Pure and exported for tests. `profile.model` is resolved through the model
92
+ * alias tables (platform key `"opencode-sdk"`) so config aliases like
93
+ * `"model": "fast"` work on the SDK path the same way they do for CLI
94
+ * builders. Note there is no built-in alias column for `opencode-sdk` —
95
+ * built-in opus/sonnet/haiku strings are CLI-provider-qualified and would
96
+ * collide with the `akm-custom/` provider prefixing below, so only profile
97
+ * and config-root alias tables apply here.
98
+ */
99
+ export function buildSdkConfig(profile, llmConfig) {
94
100
  // Resolve endpoint and model: profile fields take precedence over config.llm
95
101
  const endpoint = profile.endpoint ?? llmConfig?.endpoint;
96
102
  const apiKey = resolveSecret(profile.apiKey ?? llmConfig?.apiKey);
97
- const model = profile.model;
103
+ const model = profile.model
104
+ ? resolveModel(profile.model, "opencode-sdk", profile.modelAliases, profile.globalModelAliases)
105
+ : undefined;
98
106
  const sdkConfig = {};
99
107
  if (model)
100
108
  sdkConfig.model = model;
@@ -114,6 +122,15 @@ async function getOrStartServer(profile, llmConfig) {
114
122
  sdkConfig.model = `akm-custom/${model}`;
115
123
  }
116
124
  }
125
+ return sdkConfig;
126
+ }
127
+ async function getOrStartServer(profile, llmConfig) {
128
+ if (_server)
129
+ return _server;
130
+ const { createOpencode } = await import("@opencode-ai/sdk").catch(() => {
131
+ throw new Error("OpenCode SDK not available. Install @opencode-ai/sdk or configure a CLI agent instead.");
132
+ });
133
+ const sdkConfig = buildSdkConfig(profile, llmConfig);
117
134
  _server = (await createOpencode(Object.keys(sdkConfig).length > 0 ? { config: sdkConfig } : {}));
118
135
  process.once("exit", () => {
119
136
  closeServer();
@@ -122,8 +139,38 @@ async function getOrStartServer(profile, llmConfig) {
122
139
  throw new Error("Failed to initialise OpenCode SDK server.");
123
140
  return _server;
124
141
  }
142
+ /**
143
+ * Extract best-effort token usage from a prompt response. Only numeric
144
+ * fields the server actually reported are copied; returns undefined when
145
+ * nothing usable is present (older servers, test fakes).
146
+ */
147
+ function extractUsage(info) {
148
+ const tokens = info?.tokens;
149
+ if (!tokens)
150
+ return undefined;
151
+ const usage = {};
152
+ if (typeof tokens.input === "number" && Number.isFinite(tokens.input))
153
+ usage.inputTokens = tokens.input;
154
+ if (typeof tokens.output === "number" && Number.isFinite(tokens.output))
155
+ usage.outputTokens = tokens.output;
156
+ if (typeof tokens.reasoning === "number" && Number.isFinite(tokens.reasoning)) {
157
+ usage.reasoningTokens = tokens.reasoning;
158
+ }
159
+ return Object.keys(usage).length > 0 ? usage : undefined;
160
+ }
125
161
  export async function runOpencodeSdk(profile, prompt, opts = {}, llmConfig) {
126
162
  const start = Date.now();
163
+ if (opts.signal?.aborted) {
164
+ return {
165
+ ok: false,
166
+ stdout: "",
167
+ stderr: "",
168
+ durationMs: 0,
169
+ exitCode: null,
170
+ reason: "aborted",
171
+ error: `opencode-sdk agent "${profile.name}" not started: caller signal already aborted`,
172
+ };
173
+ }
127
174
  let client;
128
175
  try {
129
176
  ({ client } = await getOrStartServer(profile, llmConfig));
@@ -180,16 +227,42 @@ export async function runOpencodeSdk(profile, prompt, opts = {}, llmConfig) {
180
227
  const clearTimeoutImpl = opts.clearTimeoutFn ?? clearTimeout;
181
228
  let timer;
182
229
  const TIMED_OUT = Symbol("opencode-sdk-timeout");
230
+ const ABORTED = Symbol("opencode-sdk-aborted");
231
+ // Cooperative cancel: there is no OS process to signal, so an abort simply
232
+ // wins the race below; the finally block reaps the in-flight session, same
233
+ // as the timeout path.
234
+ let onAbort;
235
+ const abortSignal = opts.signal;
183
236
  try {
184
237
  const promptPromise = client.session.prompt({ path: { id: sessionId }, body });
185
- const result = timeoutMs === null
186
- ? await promptPromise
187
- : await Promise.race([
188
- promptPromise,
189
- new Promise((resolve) => {
190
- timer = setTimeoutImpl(() => resolve(TIMED_OUT), timeoutMs);
191
- }),
192
- ]);
238
+ const racers = [promptPromise];
239
+ if (timeoutMs !== null) {
240
+ racers.push(new Promise((resolve) => {
241
+ timer = setTimeoutImpl(() => resolve(TIMED_OUT), timeoutMs);
242
+ }));
243
+ }
244
+ if (abortSignal) {
245
+ racers.push(new Promise((resolve) => {
246
+ onAbort = () => resolve(ABORTED);
247
+ if (abortSignal.aborted)
248
+ onAbort();
249
+ else
250
+ abortSignal.addEventListener("abort", onAbort, { once: true });
251
+ }));
252
+ }
253
+ const result = racers.length === 1 ? await promptPromise : await Promise.race(racers);
254
+ if (result === ABORTED) {
255
+ return {
256
+ ok: false,
257
+ stdout: "",
258
+ stderr: "",
259
+ durationMs: Date.now() - start,
260
+ exitCode: null,
261
+ reason: "aborted",
262
+ error: `opencode-sdk agent "${profile.name}" aborted by caller signal`,
263
+ sessionId,
264
+ };
265
+ }
193
266
  if (result === TIMED_OUT) {
194
267
  return {
195
268
  ok: false,
@@ -199,17 +272,24 @@ export async function runOpencodeSdk(profile, prompt, opts = {}, llmConfig) {
199
272
  exitCode: null,
200
273
  reason: "timeout",
201
274
  error: `opencode-sdk agent "${profile.name}" timed out after ${timeoutMs}ms`,
275
+ sessionId,
202
276
  };
203
277
  }
204
278
  const parts = result.data?.parts ?? [];
205
279
  const textPart = parts.find((p) => p.type === "text");
206
280
  const stdout = textPart?.text ?? "";
281
+ // Token accounting from the AssistantMessage (previously discarded) —
282
+ // the seam that makes workflow budget.maxTokens meterable on the
283
+ // default sdk runner.
284
+ const usage = extractUsage(result.data?.info);
207
285
  return {
208
286
  ok: true,
209
287
  stdout,
210
288
  stderr: "",
211
289
  durationMs: Date.now() - start,
212
290
  exitCode: 0,
291
+ sessionId,
292
+ ...(usage ? { usage } : {}),
213
293
  };
214
294
  }
215
295
  catch (e) {
@@ -221,14 +301,15 @@ export async function runOpencodeSdk(profile, prompt, opts = {}, llmConfig) {
221
301
  exitCode: 1,
222
302
  reason: "non_zero_exit",
223
303
  error: String(e),
304
+ sessionId,
224
305
  };
225
306
  }
226
307
  finally {
227
308
  if (timer !== undefined)
228
309
  clearTimeoutImpl(timer);
310
+ if (abortSignal && onAbort)
311
+ abortSignal.removeEventListener("abort", onAbort);
229
312
  // Clean up session to prevent disk accumulation in ~/.local/share/opencode/
230
313
  await client.session.delete({ path: { id: sessionId } }).catch(() => { });
231
314
  }
232
315
  }
233
- /** @deprecated Use {@link runOpencodeSdk} instead. */
234
- export const runAgentSdk = runOpencodeSdk;
@@ -13,6 +13,7 @@
13
13
  export class BaseHarness {
14
14
  runtimeId;
15
15
  setupDetectionDir;
16
+ agentBuilder;
16
17
  /**
17
18
  * Lowercase prefixes that a decorated v1 profile name may start with and
18
19
  * still belong to this harness (e.g. `["opencode-sdk"]`). The canonical id
@@ -28,6 +28,22 @@ const ERROR_PATTERNS = /error|failed|exception|cannot|undefined|null pointer|ENO
28
28
  export function getAvailableHarnesses() {
29
29
  return HARNESSES.filter((harness) => harness.isAvailable());
30
30
  }
31
+ /**
32
+ * Map each available harness to its `{ harnessName, roots }` watch target,
33
+ * skipping harnesses that expose no roots (absent `watchRoots()` or an empty
34
+ * result). This is the one stable entry point the watcher uses so it never
35
+ * reaches into providers directly.
36
+ */
37
+ export function getWatchTargets() {
38
+ const targets = [];
39
+ for (const harness of getAvailableHarnesses()) {
40
+ const roots = harness.watchRoots?.() ?? [];
41
+ if (roots.length === 0)
42
+ continue;
43
+ targets.push({ harnessName: harness.name, roots });
44
+ }
45
+ return targets;
46
+ }
31
47
  export function normalizeSessionTopic(text) {
32
48
  const normalized = text.replace(/\s+/g, " ").trim().toLowerCase();
33
49
  if (normalized.length < 10)
@@ -3,7 +3,7 @@
3
3
  // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
4
  import { getDefaultLlmConfig } from "../core/config/config.js";
5
5
  import { warn } from "../core/warn.js";
6
- import { resolveAgentProfile, runAgent } from "../integrations/agent/index.js";
6
+ import { resolveProfileFromConfig, runAgent } from "../integrations/agent/index.js";
7
7
  import { chatCompletion } from "./client.js";
8
8
  /**
9
9
  * Unified AI call: prefers the default agent profile, falls back to the
@@ -14,7 +14,7 @@ export async function callAi(config, prompt, opts = {}) {
14
14
  const defaultAgentName = config.defaults?.agent;
15
15
  if (defaultAgentName) {
16
16
  try {
17
- const profile = resolveAgentProfile(defaultAgentName, config.profiles?.agent?.[defaultAgentName]);
17
+ const profile = resolveProfileFromConfig(defaultAgentName, config);
18
18
  if (!profile) {
19
19
  return {
20
20
  ok: false,
@@ -103,18 +103,29 @@ function retryBackoffMs() {
103
103
  return RETRY_BACKOFF_MIN_MS + Math.random() * (RETRY_BACKOFF_MAX_MS - RETRY_BACKOFF_MIN_MS);
104
104
  }
105
105
  /**
106
- * Detect whether an error message indicates a context-size-exceeded condition.
107
- * Mirrors the heuristic in `graph-extract.ts` retrying a context overflow
108
- * cannot shrink the input, so it must not be retried.
106
+ * Detect whether an error message indicates a context size exceeded condition.
107
+ * Covers common patterns from OpenAI-compatible APIs (LM Studio, Ollama, etc).
108
+ *
109
+ * Requires BOTH a context keyword AND token-count/overflow evidence so that
110
+ * model prose merely mentioning "context size" / "context length" (e.g. gemma
111
+ * narrating about a document) does not get misclassified as a provider
112
+ * context-limit error (#496).
113
+ *
114
+ * Canonical home: `graph-extract.ts` re-exports this so the index-pass
115
+ * graph extractor and the retry classifier (`isRetryable`) share one
116
+ * definition — retrying a context overflow cannot shrink the input, so it
117
+ * must never be retried.
109
118
  */
110
- function looksLikeContextOverflow(message) {
119
+ export function isContextSizeError(message) {
111
120
  const lower = message.toLowerCase();
112
- return (lower.includes("context") &&
113
- (lower.includes("context size") ||
114
- lower.includes("context length") ||
115
- lower.includes("context_window") ||
116
- lower.includes("prompt too long") ||
117
- lower.includes("exceeds")));
121
+ const contextKw = /context (size|length|window)|prompt too long|exceeds.*context/.test(lower);
122
+ if (!contextKw) {
123
+ return false;
124
+ }
125
+ const evidence = /\b\d+\s*(token|tokens|tk)\b/.test(lower) ||
126
+ /max(imum)?\s+(context|token|input)/.test(lower) ||
127
+ /exceeded|over.*limit|too.*long/.test(lower);
128
+ return evidence;
118
129
  }
119
130
  /**
120
131
  * Decide whether a first-attempt {@link LlmCallError} is eligible for a single
@@ -138,7 +149,7 @@ function looksLikeContextOverflow(message) {
138
149
  * failure in the improve/reflect and capability-probe flows.
139
150
  */
140
151
  function isRetryable(err) {
141
- if (looksLikeContextOverflow(err.message))
152
+ if (isContextSizeError(err.message))
142
153
  return false;
143
154
  if (err.code === "provider_error") {
144
155
  return typeof err.statusCode === "number" && err.statusCode >= 500;
@@ -154,7 +165,19 @@ function isRetryable(err) {
154
165
  }
155
166
  return false;
156
167
  }
168
+ // ── Test seam ────────────────────────────────────────────────────────────────
169
+ // Swap-and-restore override. Inert in production; only tests call the setter.
170
+ let chatCompletionOverride;
171
+ /** TEST-ONLY. Swap the implementation of `chatCompletion`; pass undefined to restore. */
172
+ export function _setChatCompletionForTests(fake) {
173
+ chatCompletionOverride = fake;
174
+ }
157
175
  export async function chatCompletion(config, messages, options) {
176
+ if (chatCompletionOverride)
177
+ return chatCompletionOverride(config, messages, options);
178
+ return chatCompletionReal(config, messages, options);
179
+ }
180
+ async function chatCompletionReal(config, messages, options) {
158
181
  const effectiveTimeoutMs = options?.timeoutMs ?? config.timeoutMs ?? 120_000;
159
182
  const started = Date.now();
160
183
  try {
@@ -2,11 +2,27 @@
2
2
  // License, v. 2.0. If a copy of the MPL was not distributed with this
3
3
  // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
4
  import { embedCacheKey, getCachedEmbedding, setCachedEmbedding } from "./embedders/cache.js";
5
- import { isTransformersAvailable, LocalEmbedder } from "./embedders/local.js";
5
+ import { DETERMINISTIC_EMBED_MODEL_ID, deterministicEmbed, isDeterministicEmbedEnabled, } from "./embedders/deterministic.js";
6
+ import { DEFAULT_LOCAL_MODEL, isTransformersAvailable as isTransformersAvailableReal, LocalEmbedder, } from "./embedders/local.js";
6
7
  import { hasRemoteEndpoint, RemoteEmbedder } from "./embedders/remote.js";
7
8
  // ── Re-exports (public API) ─────────────────────────────────────────────────
8
9
  export { clearEmbeddingCache } from "./embedders/cache.js";
9
- export { DEFAULT_LOCAL_MODEL, isTransformersAvailable } from "./embedders/local.js";
10
+ export { _setTransformersLoaderForTests, DEFAULT_LOCAL_MODEL } from "./embedders/local.js";
11
+ let embedderOverrides;
12
+ /** TEST-ONLY. Swap embedder implementations; pass undefined to restore. */
13
+ export function _setEmbedderForTests(fakes) {
14
+ embedderOverrides = fakes;
15
+ }
16
+ /**
17
+ * Check whether the @huggingface/transformers package is importable.
18
+ * Delegating wrapper around `./embedders/local`'s probe so tests can swap it
19
+ * via {@link _setEmbedderForTests}.
20
+ */
21
+ export function isTransformersAvailable() {
22
+ if (embedderOverrides?.isTransformersAvailable)
23
+ return embedderOverrides.isTransformersAvailable();
24
+ return isTransformersAvailableReal();
25
+ }
10
26
  // ── Singleton local embedder ────────────────────────────────────────────────
11
27
  // `_localEmbedder` is an intentional module-level singleton but constructed
12
28
  // lazily on first use. The underlying @huggingface/transformers pipeline is
@@ -39,6 +55,12 @@ export function resetLocalEmbedder() {
39
55
  * and embedding config. Repeated identical queries return the cached vector.
40
56
  */
41
57
  export async function embed(text, embeddingConfig, signal) {
58
+ if (embedderOverrides?.embed)
59
+ return embedderOverrides.embed(text, embeddingConfig, signal);
60
+ // Deterministic mode (env-gated, test/bench only): model-free, stable.
61
+ if (isDeterministicEmbedEnabled()) {
62
+ return deterministicEmbed(text);
63
+ }
42
64
  const key = embedCacheKey(text, embeddingConfig);
43
65
  const cached = getCachedEmbedding(key);
44
66
  if (cached)
@@ -52,16 +74,28 @@ export async function embed(text, embeddingConfig, signal) {
52
74
  /**
53
75
  * Generate embeddings for multiple texts in batch.
54
76
  * Uses the OpenAI-compatible batch API for remote endpoints (batches of 100).
55
- * Falls back to sequential embedding for the local transformer pipeline.
77
+ * Uses the LocalEmbedder.embedBatch path for the local transformer pipeline,
78
+ * which processes texts in chunks of 32 for genuine batched inference.
56
79
  */
57
80
  export async function embedBatch(texts, embeddingConfig, signal) {
81
+ if (embedderOverrides?.embedBatch)
82
+ return embedderOverrides.embedBatch(texts, embeddingConfig, signal);
58
83
  if (texts.length === 0)
59
84
  return [];
85
+ // Deterministic mode (env-gated, test/bench only): model-free, stable.
86
+ if (isDeterministicEmbedEnabled()) {
87
+ return texts.map((t) => deterministicEmbed(t));
88
+ }
60
89
  if (embeddingConfig && hasRemoteEndpoint(embeddingConfig)) {
61
90
  return new RemoteEmbedder(embeddingConfig).embedBatch(texts, signal);
62
91
  }
63
- // Local transformer: process sequentially (pipeline handles one at a time)
92
+ // Local transformer: use the batched path (chunks of 32 via LocalEmbedder).
93
+ // When a localModel override is set we cannot share the singleton (which uses
94
+ // the default model), so fall back to per-text embedWithModel in that case.
64
95
  const localModel = embeddingConfig?.localModel;
96
+ if (!localModel) {
97
+ return getLocalEmbedder().embedBatch(texts, signal);
98
+ }
65
99
  const results = [];
66
100
  for (const text of texts) {
67
101
  if (signal?.aborted) {
@@ -77,11 +111,40 @@ export async function embedBatch(texts, embeddingConfig, signal) {
77
111
  // facade and its `@huggingface/transformers` import chain. Re-export
78
112
  // preserves the existing public API.
79
113
  export { cosineSimilarity } from "./embedders/types.js";
114
+ // ── Model ID resolution ─────────────────────────────────────────────────────
115
+ /**
116
+ * Derive a stable string identifier for the embedding model in use.
117
+ * This is the `model_id` stored in `body_embeddings` (and used for the
118
+ * drop-all-on-mismatch purge when the model changes).
119
+ *
120
+ * Rules:
121
+ * - Remote endpoint: use `config.model` (the API-level model name).
122
+ * - Local transformers: use `config.localModel ?? DEFAULT_LOCAL_MODEL`.
123
+ * - No config: use `DEFAULT_LOCAL_MODEL` (the shared singleton model).
124
+ */
125
+ export function resolveEmbeddingModelId(embeddingConfig) {
126
+ if (embedderOverrides?.resolveEmbeddingModelId)
127
+ return embedderOverrides.resolveEmbeddingModelId(embeddingConfig);
128
+ if (isDeterministicEmbedEnabled())
129
+ return DETERMINISTIC_EMBED_MODEL_ID;
130
+ if (!embeddingConfig)
131
+ return DEFAULT_LOCAL_MODEL;
132
+ if (hasRemoteEndpoint(embeddingConfig))
133
+ return embeddingConfig.model ?? "remote";
134
+ return embeddingConfig.localModel ?? DEFAULT_LOCAL_MODEL;
135
+ }
80
136
  // ── Availability check ──────────────────────────────────────────────────────
81
137
  /**
82
138
  * Check whether embedding is available with a detailed reason on failure.
83
139
  */
84
140
  export async function checkEmbeddingAvailability(embeddingConfig) {
141
+ if (embedderOverrides?.checkEmbeddingAvailability) {
142
+ return embedderOverrides.checkEmbeddingAvailability(embeddingConfig);
143
+ }
144
+ // Deterministic mode (env-gated): always available — no model, no network.
145
+ if (isDeterministicEmbedEnabled()) {
146
+ return { available: true };
147
+ }
85
148
  if (embeddingConfig && hasRemoteEndpoint(embeddingConfig)) {
86
149
  try {
87
150
  await new RemoteEmbedder(embeddingConfig).embed("test");