rovecode 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (334) hide show
  1. package/LICENSE +662 -0
  2. package/README.md +737 -0
  3. package/THIRD_PARTY_NOTICES.md +268 -0
  4. package/bin/rovecode.ts +21 -0
  5. package/package.json +56 -0
  6. package/src/acp/server.ts +374 -0
  7. package/src/cli/auth-login.ts +122 -0
  8. package/src/cli/connect.ts +244 -0
  9. package/src/cli/context-cmd.ts +199 -0
  10. package/src/cli/dispatch.ts +82 -0
  11. package/src/cli/doctor.ts +362 -0
  12. package/src/cli/export.ts +276 -0
  13. package/src/cli/help.ts +293 -0
  14. package/src/cli/is-tui-invocation.ts +8 -0
  15. package/src/cli/main.ts +583 -0
  16. package/src/cli/market-cmd.ts +658 -0
  17. package/src/cli/mcp-login.ts +141 -0
  18. package/src/cli/mcp-market-cmd.ts +302 -0
  19. package/src/cli/output.ts +382 -0
  20. package/src/cli/repl.ts +250 -0
  21. package/src/cli/repomap-root.ts +14 -0
  22. package/src/cli/resume.ts +57 -0
  23. package/src/cli/run-flags.ts +43 -0
  24. package/src/cli/run-limits.ts +78 -0
  25. package/src/cli/runtime.ts +931 -0
  26. package/src/cli/session-arg.ts +30 -0
  27. package/src/cli/sessions-cmd.ts +145 -0
  28. package/src/cli/setup.ts +153 -0
  29. package/src/cli/skills-cmd.ts +194 -0
  30. package/src/cli/start-chat.ts +65 -0
  31. package/src/cli/trust-cmd.ts +52 -0
  32. package/src/coding/bash.ts +148 -0
  33. package/src/coding/checkpoints.ts +327 -0
  34. package/src/coding/diff.ts +138 -0
  35. package/src/coding/files.ts +341 -0
  36. package/src/coding/hashline.ts +274 -0
  37. package/src/coding/lsp-gate.ts +254 -0
  38. package/src/coding/lsp-servers.ts +147 -0
  39. package/src/coding/lsp.ts +283 -0
  40. package/src/coding/repomap-cache.ts +99 -0
  41. package/src/coding/repomap-files.ts +192 -0
  42. package/src/coding/repomap.ts +481 -0
  43. package/src/core/agents.ts +255 -0
  44. package/src/core/compaction.ts +259 -0
  45. package/src/core/config.ts +289 -0
  46. package/src/core/context-report.ts +228 -0
  47. package/src/core/context.ts +60 -0
  48. package/src/core/count-remote.ts +107 -0
  49. package/src/core/execpolicy-rules.ts +196 -0
  50. package/src/core/execpolicy.ts +385 -0
  51. package/src/core/executor.ts +454 -0
  52. package/src/core/guardrails.ts +400 -0
  53. package/src/core/hooks.ts +411 -0
  54. package/src/core/images.ts +230 -0
  55. package/src/core/intro.ts +266 -0
  56. package/src/core/loop.ts +567 -0
  57. package/src/core/modes.ts +372 -0
  58. package/src/core/orchestrator.ts +245 -0
  59. package/src/core/proc-group.ts +48 -0
  60. package/src/core/project-trust.ts +98 -0
  61. package/src/core/reflection.ts +165 -0
  62. package/src/core/sandbox-config.ts +186 -0
  63. package/src/core/session-id.ts +24 -0
  64. package/src/core/session-images.ts +73 -0
  65. package/src/core/session-ops.ts +183 -0
  66. package/src/core/session-text.ts +29 -0
  67. package/src/core/session.ts +469 -0
  68. package/src/core/settings.ts +170 -0
  69. package/src/core/tasks.ts +646 -0
  70. package/src/core/token-scale.ts +108 -0
  71. package/src/core/tools.ts +309 -0
  72. package/src/core/trust.ts +104 -0
  73. package/src/core/types.ts +330 -0
  74. package/src/core/update-check.ts +171 -0
  75. package/src/core/usage.ts +204 -0
  76. package/src/core/validate.ts +121 -0
  77. package/src/core/verify-gate.ts +159 -0
  78. package/src/core/verify.ts +236 -0
  79. package/src/core/voice.ts +158 -0
  80. package/src/core/win-job.ts +183 -0
  81. package/src/core/workspace.ts +184 -0
  82. package/src/design/audit.ts +797 -0
  83. package/src/design/direction.ts +190 -0
  84. package/src/design/rules.ts +157 -0
  85. package/src/eval/bench.ts +150 -0
  86. package/src/eval/gauntlet-runner.ts +215 -0
  87. package/src/eval/gauntlet-support.ts +84 -0
  88. package/src/eval/gauntlet-wave3.ts +269 -0
  89. package/src/eval/gauntlet-wave4.ts +217 -0
  90. package/src/eval/gauntlet.ts +253 -0
  91. package/src/index.ts +17 -0
  92. package/src/lanes/agy.ts +95 -0
  93. package/src/lanes/approval.ts +24 -0
  94. package/src/lanes/claude.ts +129 -0
  95. package/src/lanes/codex.ts +127 -0
  96. package/src/lanes/events.ts +130 -0
  97. package/src/lanes/job.ts +142 -0
  98. package/src/lanes/opencode.ts +122 -0
  99. package/src/lanes/process.ts +184 -0
  100. package/src/lanes/progress.ts +183 -0
  101. package/src/lanes/registry.ts +178 -0
  102. package/src/lanes/runner.ts +124 -0
  103. package/src/lanes/types.ts +112 -0
  104. package/src/market/catalogs/mcp-docs.json +111 -0
  105. package/src/market/catalogs/plugins.json +111 -0
  106. package/src/market/catalogs/skills.json +478 -0
  107. package/src/market/clone.ts +72 -0
  108. package/src/market/context-cost.ts +121 -0
  109. package/src/market/digest.ts +106 -0
  110. package/src/market/index.ts +22 -0
  111. package/src/market/install.ts +578 -0
  112. package/src/market/manifest.ts +187 -0
  113. package/src/market/prereq.ts +145 -0
  114. package/src/market/registry.ts +363 -0
  115. package/src/market/resolve.ts +111 -0
  116. package/src/market/types.ts +236 -0
  117. package/src/market/validate.ts +227 -0
  118. package/src/mcp/client.ts +449 -0
  119. package/src/mcp/config.ts +252 -0
  120. package/src/mcp/local-package.ts +211 -0
  121. package/src/mcp/market-catalog.ts +84 -0
  122. package/src/mcp/market-install.ts +289 -0
  123. package/src/mcp/market.ts +362 -0
  124. package/src/mcp/oauth.ts +251 -0
  125. package/src/mcp/prompts-resources.ts +249 -0
  126. package/src/mcp/shared.ts +149 -0
  127. package/src/mcp/status.ts +67 -0
  128. package/src/mcp/tools.ts +275 -0
  129. package/src/mcp/transport.ts +122 -0
  130. package/src/mcp/trust.ts +25 -0
  131. package/src/memory/blocks.ts +278 -0
  132. package/src/memory/recall.ts +355 -0
  133. package/src/memory/scope.ts +182 -0
  134. package/src/memory/store.ts +105 -0
  135. package/src/memory/tools.ts +99 -0
  136. package/src/plugins/cli.ts +119 -0
  137. package/src/plugins/discover.ts +108 -0
  138. package/src/plugins/index.ts +50 -0
  139. package/src/plugins/install.ts +184 -0
  140. package/src/plugins/load.ts +124 -0
  141. package/src/plugins/manifest.ts +92 -0
  142. package/src/plugins/state.ts +83 -0
  143. package/src/providers/auth.ts +408 -0
  144. package/src/providers/cache.ts +223 -0
  145. package/src/providers/catalog-local.ts +160 -0
  146. package/src/providers/catalog.ts +421 -0
  147. package/src/providers/middleware-context.ts +86 -0
  148. package/src/providers/middleware.ts +373 -0
  149. package/src/providers/model-list.ts +23 -0
  150. package/src/providers/models-index.json +1 -0
  151. package/src/providers/oauth/common.ts +105 -0
  152. package/src/providers/oauth/device-code.ts +107 -0
  153. package/src/providers/oauth/github-copilot.ts +146 -0
  154. package/src/providers/oauth/loopback.ts +158 -0
  155. package/src/providers/oauth/openai.ts +163 -0
  156. package/src/providers/oauth/openrouter.ts +89 -0
  157. package/src/providers/oauth/pkce.ts +45 -0
  158. package/src/providers/oauth/registry.ts +39 -0
  159. package/src/providers/oauth/seam.ts +89 -0
  160. package/src/providers/profile-glm53.ts +111 -0
  161. package/src/providers/profile-sonnet5-persona.ts +65 -0
  162. package/src/providers/profile-sonnet5-voice.ts +23 -0
  163. package/src/providers/profiles.ts +156 -0
  164. package/src/providers/provider-config.ts +311 -0
  165. package/src/providers/registry.ts +333 -0
  166. package/src/providers/responses.ts +209 -0
  167. package/src/providers/retry.ts +234 -0
  168. package/src/providers/router.ts +294 -0
  169. package/src/providers/sse.ts +26 -0
  170. package/src/providers/stream-errors.ts +117 -0
  171. package/src/providers/stream.ts +566 -0
  172. package/src/providers/thinking.ts +189 -0
  173. package/src/providers/wire-messages.ts +129 -0
  174. package/src/providers/wire-responses.ts +79 -0
  175. package/src/providers/wire-select.ts +53 -0
  176. package/src/server/http.ts +291 -0
  177. package/src/server/openapi.ts +246 -0
  178. package/src/sextant/card-hits.ts +102 -0
  179. package/src/sextant/card-keys.ts +55 -0
  180. package/src/sextant/context-source.ts +157 -0
  181. package/src/sextant/crew-cards.ts +350 -0
  182. package/src/sextant/draw-agents.ts +273 -0
  183. package/src/sextant/draw-code.ts +388 -0
  184. package/src/sextant/draw-context.ts +222 -0
  185. package/src/sextant/draw-frame.ts +164 -0
  186. package/src/sextant/draw-market.ts +573 -0
  187. package/src/sextant/draw-messages.ts +386 -0
  188. package/src/sextant/draw-pet.ts +230 -0
  189. package/src/sextant/draw-plan.ts +187 -0
  190. package/src/sextant/draw-tabs.ts +85 -0
  191. package/src/sextant/draw-util.ts +65 -0
  192. package/src/sextant/draw-wizard.ts +378 -0
  193. package/src/sextant/engine.ts +230 -0
  194. package/src/sextant/frame-hits.ts +25 -0
  195. package/src/sextant/frame.ts +101 -0
  196. package/src/sextant/git-status.ts +197 -0
  197. package/src/sextant/grid.ts +59 -0
  198. package/src/sextant/input.ts +119 -0
  199. package/src/sextant/keys.ts +521 -0
  200. package/src/sextant/layout.ts +86 -0
  201. package/src/sextant/local-commands.ts +169 -0
  202. package/src/sextant/market-source.ts +287 -0
  203. package/src/sextant/mentions.ts +200 -0
  204. package/src/sextant/message-hits.ts +26 -0
  205. package/src/sextant/model.ts +387 -0
  206. package/src/sextant/overlays.ts +456 -0
  207. package/src/sextant/panel-hits.ts +38 -0
  208. package/src/sextant/pet.ts +399 -0
  209. package/src/sextant/screen.ts +324 -0
  210. package/src/sextant/scroll-hits.ts +66 -0
  211. package/src/sextant/scrollbar.ts +82 -0
  212. package/src/sextant/sextant-bridge.ts +174 -0
  213. package/src/sextant/sextant-cards.ts +142 -0
  214. package/src/sextant/sextant-diff-base.ts +63 -0
  215. package/src/sextant/sextant-files.ts +154 -0
  216. package/src/sextant/sextant-frame-loop.ts +335 -0
  217. package/src/sextant/sextant-renderer.ts +574 -0
  218. package/src/sextant/sextant-repo.ts +140 -0
  219. package/src/sextant/theme.ts +66 -0
  220. package/src/sextant/tool-rows.ts +189 -0
  221. package/src/sextant/types.ts +493 -0
  222. package/src/skills/index.ts +387 -0
  223. package/src/skills/pack.ts +220 -0
  224. package/src/skills/spec.ts +162 -0
  225. package/src/skills/tools.ts +69 -0
  226. package/src/skills/versioned.ts +227 -0
  227. package/src/telemetry/otel-export.ts +122 -0
  228. package/src/telemetry/otel-lanes.ts +89 -0
  229. package/src/telemetry/otel-logs.ts +131 -0
  230. package/src/telemetry/otel-metrics.ts +136 -0
  231. package/src/telemetry/otel.ts +397 -0
  232. package/src/telemetry/otlp.ts +76 -0
  233. package/src/tools/ask-user.ts +156 -0
  234. package/src/tools/bash-bg.ts +94 -0
  235. package/src/tools/bash-jobs.ts +237 -0
  236. package/src/tools/design.ts +151 -0
  237. package/src/tools/evalcell.ts +338 -0
  238. package/src/tools/html-text.ts +139 -0
  239. package/src/tools/provider.ts +149 -0
  240. package/src/tools/task.ts +250 -0
  241. package/src/tools/todo.ts +320 -0
  242. package/src/tools/webfetch.ts +332 -0
  243. package/src/tools/websearch.ts +359 -0
  244. package/src/tui/agents-cmd.ts +41 -0
  245. package/src/tui/app.ts +749 -0
  246. package/src/tui/attach.ts +127 -0
  247. package/src/tui/boot-notes.ts +41 -0
  248. package/src/tui/builtin-prompts.ts +59 -0
  249. package/src/tui/checkpoints-cmd.ts +70 -0
  250. package/src/tui/clipboard-image.ts +81 -0
  251. package/src/tui/clipboard.ts +78 -0
  252. package/src/tui/commands.ts +283 -0
  253. package/src/tui/config-view.ts +53 -0
  254. package/src/tui/context-cmds.ts +282 -0
  255. package/src/tui/cost.ts +108 -0
  256. package/src/tui/crash-guard.ts +173 -0
  257. package/src/tui/focus-terminal.ts +34 -0
  258. package/src/tui/git-cmds.ts +273 -0
  259. package/src/tui/git-plain.ts +58 -0
  260. package/src/tui/info-cmd.ts +150 -0
  261. package/src/tui/input-plain.ts +76 -0
  262. package/src/tui/mcp-cmd.ts +128 -0
  263. package/src/tui/memory-note.ts +77 -0
  264. package/src/tui/modes-cmd.ts +45 -0
  265. package/src/tui/notify-seq.ts +100 -0
  266. package/src/tui/notify.ts +318 -0
  267. package/src/tui/overlays.ts +97 -0
  268. package/src/tui/pi-renderer.ts +428 -0
  269. package/src/tui/providers-cmd.ts +377 -0
  270. package/src/tui/reasoning-view.ts +56 -0
  271. package/src/tui/renderer.ts +128 -0
  272. package/src/tui/replay-marker.ts +29 -0
  273. package/src/tui/session-cmd.ts +148 -0
  274. package/src/tui/session-manage.ts +95 -0
  275. package/src/tui/sextant-attach.ts +102 -0
  276. package/src/tui/sextant-io.ts +202 -0
  277. package/src/tui/sextant-smoke.ts +110 -0
  278. package/src/tui/shell-cmd.ts +158 -0
  279. package/src/tui/smoke.ts +72 -0
  280. package/src/tui/staged-terminal.ts +50 -0
  281. package/src/tui/startup.ts +12 -0
  282. package/src/tui/theme.ts +59 -0
  283. package/src/tui/todo-label.ts +7 -0
  284. package/src/tui/trust-card.ts +107 -0
  285. package/src/tui/tui-commands.ts +87 -0
  286. package/tsconfig.json +30 -0
  287. package/vendor/pi-tui/LICENSE +21 -0
  288. package/vendor/pi-tui/PATCHES.md +12 -0
  289. package/vendor/pi-tui/PROVENANCE.md +12 -0
  290. package/vendor/pi-tui/README.upstream.md +854 -0
  291. package/vendor/pi-tui/native/win32/prebuilds/win32-arm64/win32-console-mode.node +0 -0
  292. package/vendor/pi-tui/native/win32/prebuilds/win32-x64/win32-console-mode.node +0 -0
  293. package/vendor/pi-tui/src/alt-screen-search.ts +158 -0
  294. package/vendor/pi-tui/src/autocomplete.ts +827 -0
  295. package/vendor/pi-tui/src/components/alt-screen-flash.ts +52 -0
  296. package/vendor/pi-tui/src/components/box.ts +138 -0
  297. package/vendor/pi-tui/src/components/cancellable-loader.ts +41 -0
  298. package/vendor/pi-tui/src/components/editor.ts +2364 -0
  299. package/vendor/pi-tui/src/components/h-stack.ts +45 -0
  300. package/vendor/pi-tui/src/components/image.ts +128 -0
  301. package/vendor/pi-tui/src/components/input.ts +448 -0
  302. package/vendor/pi-tui/src/components/loader.ts +93 -0
  303. package/vendor/pi-tui/src/components/markdown.ts +1016 -0
  304. package/vendor/pi-tui/src/components/scroll-view.ts +217 -0
  305. package/vendor/pi-tui/src/components/select-list.ts +230 -0
  306. package/vendor/pi-tui/src/components/settings-list.ts +277 -0
  307. package/vendor/pi-tui/src/components/spacer.ts +29 -0
  308. package/vendor/pi-tui/src/components/stack.ts +155 -0
  309. package/vendor/pi-tui/src/components/text.ts +108 -0
  310. package/vendor/pi-tui/src/components/truncated-text.ts +66 -0
  311. package/vendor/pi-tui/src/components/v-stack.ts +34 -0
  312. package/vendor/pi-tui/src/editor-component.ts +75 -0
  313. package/vendor/pi-tui/src/fuzzy.ts +138 -0
  314. package/vendor/pi-tui/src/index.ts +149 -0
  315. package/vendor/pi-tui/src/keybindings.ts +321 -0
  316. package/vendor/pi-tui/src/keys.ts +1402 -0
  317. package/vendor/pi-tui/src/kill-ring.ts +47 -0
  318. package/vendor/pi-tui/src/latex.ts +1381 -0
  319. package/vendor/pi-tui/src/layout-node.ts +52 -0
  320. package/vendor/pi-tui/src/layout.ts +411 -0
  321. package/vendor/pi-tui/src/native-modifiers.ts +60 -0
  322. package/vendor/pi-tui/src/native-module-path.ts +32 -0
  323. package/vendor/pi-tui/src/stdin-buffer.ts +445 -0
  324. package/vendor/pi-tui/src/terminal-colors.ts +74 -0
  325. package/vendor/pi-tui/src/terminal-image.ts +701 -0
  326. package/vendor/pi-tui/src/terminal.ts +554 -0
  327. package/vendor/pi-tui/src/tui-alt-screen.ts +1379 -0
  328. package/vendor/pi-tui/src/tui-main-screen.ts +655 -0
  329. package/vendor/pi-tui/src/tui.ts +1264 -0
  330. package/vendor/pi-tui/src/undo-stack.ts +29 -0
  331. package/vendor/pi-tui/src/utils.ts +1327 -0
  332. package/vendor/pi-tui/src/word-navigation.ts +118 -0
  333. package/vendor/pi-tui/test/test-themes.ts +39 -0
  334. package/vendor/pi-tui/test/virtual-terminal.ts +219 -0
@@ -0,0 +1,408 @@
1
+ /** Provider credential store (port #37): backing for `rovecode auth set/list/remove`.
2
+ *
3
+ * Ported from opencode packages/opencode/src/auth/index.ts @ ebece6e (MIT):
4
+ * - one JSON object keyed by provider id, entries discriminated by `type` (index.ts:14-36)
5
+ * - the "api" variant carries the secret in `key` (index.ts:23-27); the provider loader
6
+ * consumes it as the apiKey (provider.ts:1596-1601)
7
+ * - file written with mode 0o600 (index.ts:79,88)
8
+ * - a missing/corrupt file reads as {} and malformed entries are dropped per-entry, never
9
+ * fatal (index.ts:65-66, Record.filterMap over the schema decode)
10
+ * Deviations: the file lives at ~/.rovecode/credentials.json (bar) instead of opencode's
11
+ * <data>/auth.json; entries carry an rovecode-only optional `keyName` (which env var the
12
+ * secret stands in for); the "api" variant plus, since the OAuth port (aion port #66, brought over
13
+ * 2026-09-07), the "oauth" variant (`rovecode auth login`): `access` (the bearer the provider seam
14
+ * sends), `refresh` (what mints a new access token; "" when the provider issues a permanent key),
15
+ * `expires` (ms since the epoch; Number.MAX_SAFE_INTEGER = never) — pi's OAuthCredential shape
16
+ * (packages/ai/src/auth/types.ts). The `type` field is the record's kind discriminator. Entries with
17
+ * any other `type` round-trip through save/remove unharmed but are not listed or resolved.
18
+ *
19
+ * Also home to readSecret (the `rovecode auth set` prompt) so its TTY path is unit-testable
20
+ * in-process — main.ts cannot be imported by tests (it dispatches on load).
21
+ *
22
+ * SECRETS ARE NEVER LOGGED from this module: the only terminal output is readSecret's
23
+ * prompt text plus cursor-control sequences, error messages never embed the credential
24
+ * value, and the store itself prints nothing. Rendering (redacted) is the caller's job
25
+ * via listProviders()/redactSecret().
26
+ */
27
+
28
+ import { readFileSync, writeFileSync, mkdirSync, chmodSync, cpSync, existsSync, rmSync } from "node:fs";
29
+ import { createInterface } from "node:readline";
30
+ import { Writable } from "node:stream";
31
+ import { join } from "node:path";
32
+ import { homedir } from "node:os";
33
+ import { createRequire } from "node:module";
34
+
35
+ /** The provider index, loaded ONLY when something asks for a key name.
36
+ *
37
+ * Two things were wrong here and they compounded. The import was at the top of the module, and what it
38
+ * imported was the full models.dev snapshot: 63 MB resident, for one call site (`keyNameFor`). Meanwhile
39
+ * `rovecodeHome`, three lines of path arithmetic in this same file, is imported by hooks.ts, settings.ts,
40
+ * runtime.ts, the plugin loader and half the TUI — so all of them paid 63 MB to learn where `~/.rovecode`
41
+ * is, and the TUI paid it at startup before drawing a frame.
42
+ *
43
+ * Now it is deferred, and it reads the trimmed index (src/providers/models-index.json, the same file the
44
+ * catalog reads) rather than the upstream snapshot. Only `env[0]` is ever used from it.
45
+ *
46
+ * `require` rather than `await import` because `keyNameFor` is synchronous and called from synchronous
47
+ * code; making it async would push the change through a dozen call sites to save nothing extra. */
48
+ let snapshotCache: Record<string, { env?: string[] } | undefined> | null = null;
49
+ function snapshotProvidersLazy(): Record<string, { env?: string[] } | undefined> {
50
+ if (snapshotCache === null) {
51
+ const req = createRequire(import.meta.url);
52
+ const m = req("./models-index.json") as { providers?: Record<string, { env?: string[] }> };
53
+ snapshotCache = m.providers ?? {};
54
+ }
55
+ return snapshotCache;
56
+ }
57
+
58
+ export interface StoredCredential {
59
+ type: "api";
60
+ /** the secret itself (upstream field name — auth/index.ts:24) */
61
+ key: string;
62
+ /** rovecode extension: env var name this secret stands in for (e.g. ANTHROPIC_API_KEY) */
63
+ keyName?: string;
64
+ }
65
+
66
+ /** An OAuth login (`rovecode auth login <provider>`), pi's OAuthCredential shape. */
67
+ export interface StoredOAuthCredential {
68
+ type: "oauth";
69
+ /** the bearer the provider seam sends (short-lived where the provider issues one) */
70
+ access: string;
71
+ /** what refresh() presents to mint a new access token (github-copilot: the GitHub OAuth token;
72
+ * openai: the refresh_token; "" when the provider issued a permanent key — openrouter) */
73
+ refresh: string;
74
+ /** access-token expiry, ms since the epoch; Number.MAX_SAFE_INTEGER = never expires */
75
+ expires: number;
76
+ /** openai: the ChatGPT account id from the access token's JWT claim (request header) */
77
+ accountId?: string;
78
+ }
79
+
80
+ /** Port #76: an MCP server's OAuth token (`rovecode mcp login <name>`, stored under `mcp:<name>`) — the SDK-driven
81
+ * flow's whole session, so a connect needs no discovery or registration round trip: `url` (a record is attached
82
+ * only to the server whose configured url EQUALS it), `access` ("" once invalidated — the record then still carries
83
+ * the client + discovery), `refresh` ("" when the server issued none), `expires` (ms since the epoch;
84
+ * Number.MAX_SAFE_INTEGER = never), the registered client (`client_id`, plus a DCR-issued secret when the server
85
+ * insisted on one — never a configured one) and the SDK's discovery state (opaque here; mcp/oauth.ts reads it).
86
+ * Listed by `auth list` (kind oauth, key name `mcp server token`), removable, NEVER resolved as a chat provider. */
87
+ export interface StoredMcpOAuthRecord {
88
+ type: "mcp-oauth";
89
+ url: string;
90
+ access: string;
91
+ refresh: string;
92
+ expires: number;
93
+ clientInformation?: { client_id: string; client_secret?: string; client_id_issued_at?: number; client_secret_expires_at?: number };
94
+ discovery?: Record<string, unknown>;
95
+ }
96
+
97
+ /** The mode the store is written with (create AND re-asserted on rewrite) — the single source
98
+ * the tests pin; see the Windows note on writeStore. */
99
+ export const CREDENTIALS_FILE_MODE = 0o600;
100
+
101
+ /** User-scope rovecode dir: ROVECODE_HOME overrides ~/.rovecode wholesale (tests point it at a temp
102
+ * dir). homedir() already respects HOME on POSIX and USERPROFILE on Windows. Mirrors the
103
+ * skills store's user-scope default (skills/index.ts: join(homedir(), ".rovecode", ...)). */
104
+ /** what node's homedir() promises, spelled out so every runtime agrees on every host: HOME on POSIX,
105
+ * USERPROFILE on Windows, the OS account's directory only when neither is set (tests point HOME at a
106
+ * scratch dir and expect the home to follow) */
107
+ const userHome = (): string => (process.platform === "win32" ? process.env.USERPROFILE : process.env.HOME) || homedir();
108
+
109
+ export function rovecodeHome(): string {
110
+ const explicit = process.env.ROVECODE_HOME;
111
+ const home = explicit ?? join(userHome(), ".rovecode");
112
+ migrateLegacyHome(home, { explicit: explicit !== undefined });
113
+ return home;
114
+ }
115
+
116
+ /** The config directory used to be `~/.cumulus` (the project was called nimbus). A rename must not
117
+ * cost anyone their stored API keys and providers, so the first call that resolves the new home
118
+ * COPIES the old one into it — copy, not move: the old directory is left exactly as it was, so
119
+ * downgrading to an older build keeps working and nothing is destroyed if this goes wrong.
120
+ *
121
+ * Runs once per process, only when the new home does not exist yet and the old one does, and ONLY for
122
+ * the default home. An explicit ROVECODE_HOME used to be migrated the same way "which is what makes it
123
+ * testable", and that was a real hazard rather than a convenience: pointing ROVECODE_HOME at a fresh
124
+ * path — the ordinary way to get an isolated home for a test, a script or a clean-room check — silently
125
+ * filled it with a copy of the old credentials. It billed two real API calls during this repository's
126
+ * own release verification (2026-09-06) before anyone noticed the scratch home was not scratch. Tests
127
+ * that need to exercise the migration pass `legacyDir`; nothing else copies a user's keys into a path
128
+ * they chose for isolation.
129
+ *
130
+ * Any failure is swallowed: a migration that cannot run must not stop the agent from starting — the
131
+ * user simply sees "no provider configured" and runs `rovecode connect`. */
132
+ let migrated = false;
133
+ export function migrateLegacyHome(home: string, opts: { explicit?: boolean; legacyDir?: string; note?: (line: string) => void } = {}): void {
134
+ if (migrated && opts.legacyDir === undefined) return;
135
+ if (opts.legacyDir === undefined) migrated = true;
136
+ try {
137
+ if (existsSync(home)) return; // already living in the new place
138
+ const legacy = opts.legacyDir ?? join(userHome(), ".cumulus");
139
+ // an explicit ROVECODE_HOME is a request for THIS directory, not for a copy of another one. The
140
+ // injected legacyDir does not override it: a test that wants the explicit case must SEE the refusal.
141
+ if (opts.explicit === true) return;
142
+ if (home === legacy || !existsSync(legacy)) return;
143
+ cpSync(legacy, home, { recursive: true });
144
+ // and it says so: a copy of someone's credentials appearing in a new directory is not a silent event
145
+ (opts.note ?? ((l: string) => console.error(l)))(`migrated ${legacy} → ${home} (the config directory was renamed; the old one is untouched)`);
146
+ // the credentials file carries the 0600 the old one had only on POSIX; re-assert it here
147
+ const creds = join(home, "credentials.json");
148
+ if (existsSync(creds) && process.platform !== "win32") chmodSync(creds, CREDENTIALS_FILE_MODE);
149
+ } catch { /* best effort: a failed migration is a fresh config, never a crash */ }
150
+ }
151
+
152
+ export function credentialsPath(): string {
153
+ return join(rovecodeHome(), "credentials.json");
154
+ }
155
+
156
+ /** Raw file contents: every entry as stored, including unknown `type`s. Missing file,
157
+ * unreadable file, or non-object JSON -> {} (upstream orElseSucceed idiom). */
158
+ function readRaw(): Record<string, unknown> {
159
+ let parsed: unknown;
160
+ try {
161
+ parsed = JSON.parse(readFileSync(credentialsPath(), "utf8"));
162
+ } catch {
163
+ return {};
164
+ }
165
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return {};
166
+ return parsed as Record<string, unknown>;
167
+ }
168
+
169
+ function isApiCredential(value: unknown): value is StoredCredential {
170
+ if (typeof value !== "object" || value === null) return false;
171
+ const v = value as { type?: unknown; key?: unknown; keyName?: unknown };
172
+ // whitespace-only counts as empty: a hand-edited `"key": " "` must not shadow a valid env key
173
+ if (v.type !== "api" || typeof v.key !== "string" || v.key.trim().length === 0) return false;
174
+ return v.keyName === undefined || typeof v.keyName === "string";
175
+ }
176
+
177
+ /** Valid "api" credentials only, keyed by provider id (malformed entries dropped). */
178
+ export function loadCredentials(): Record<string, StoredCredential> {
179
+ const out: Record<string, StoredCredential> = {};
180
+ for (const [id, value] of Object.entries(readRaw())) {
181
+ if (isApiCredential(value)) out[id] = value;
182
+ }
183
+ return out;
184
+ }
185
+
186
+ function isOAuthCredential(value: unknown): value is StoredOAuthCredential {
187
+ if (typeof value !== "object" || value === null) return false;
188
+ const v = value as { type?: unknown; access?: unknown; refresh?: unknown; expires?: unknown; accountId?: unknown };
189
+ if (v.type !== "oauth" || typeof v.access !== "string" || v.access.trim().length === 0) return false;
190
+ if (typeof v.refresh !== "string" || typeof v.expires !== "number" || !Number.isFinite(v.expires)) return false;
191
+ // `expires` must sit inside Date's range (±8.64e15 ms) or be the "never" sentinel — `auth list` renders it
192
+ // through toISOString, which throws RangeError beyond that, and a hand-edited entry is never fatal
193
+ if (Math.abs(v.expires) > 8.64e15 && v.expires !== Number.MAX_SAFE_INTEGER) return false;
194
+ return v.accountId === undefined || typeof v.accountId === "string";
195
+ }
196
+
197
+ /** Valid "oauth" credentials only, keyed by provider id (malformed entries dropped). */
198
+ export function loadOAuthCredentials(): Record<string, StoredOAuthCredential> {
199
+ const out: Record<string, StoredOAuthCredential> = {};
200
+ for (const [id, value] of Object.entries(readRaw())) {
201
+ if (isOAuthCredential(value)) out[id] = value;
202
+ }
203
+ return out;
204
+ }
205
+
206
+ /** Port #76: the mcp-oauth variant — same range rule on `expires` as isOAuthCredential; `access` may be "" (invalidated). */
207
+ export function isMcpOAuthRecord(value: unknown): value is StoredMcpOAuthRecord {
208
+ if (typeof value !== "object" || value === null) return false;
209
+ const v = value as Record<string, unknown>;
210
+ if (v.type !== "mcp-oauth" || typeof v.url !== "string" || v.url.length === 0) return false;
211
+ if (typeof v.access !== "string" || typeof v.refresh !== "string" || typeof v.expires !== "number" || !Number.isFinite(v.expires)) return false;
212
+ if (Math.abs(v.expires) > 8.64e15 && v.expires !== Number.MAX_SAFE_INTEGER) return false;
213
+ const ci = v.clientInformation;
214
+ if (ci !== undefined && (typeof ci !== "object" || ci === null || typeof (ci as { client_id?: unknown }).client_id !== "string")) return false;
215
+ const d = v.discovery;
216
+ return d === undefined || (typeof d === "object" && d !== null && !Array.isArray(d));
217
+ }
218
+
219
+ /** Port #76: one raw entry as stored (any `type`), or undefined. The MCP module validates it (isMcpOAuthRecord). */
220
+ export function readEntry(id: string): unknown {
221
+ return readRaw()[id];
222
+ }
223
+
224
+ /** Port #76: store/replace one entry verbatim (0600 on create and rewrite), or delete it (`undefined` — the file goes
225
+ * when it was the last entry, like removeCredential). The value is never echoed or thrown. */
226
+ export function writeEntry(id: string, value: object | undefined): void {
227
+ if (id.trim().length === 0) throw new Error("credential id must not be empty");
228
+ if (value === undefined) {
229
+ removeCredential(id);
230
+ return;
231
+ }
232
+ const data = readRaw();
233
+ data[id] = value;
234
+ writeStore(data);
235
+ }
236
+
237
+ /** Store/replace a provider's OAuth credential (an "api" entry under the same id is replaced —
238
+ * one entry per provider). Tokens are never echoed or thrown. */
239
+ export function saveOAuthCredential(provider: string, cred: StoredOAuthCredential): void {
240
+ const id = provider.trim();
241
+ if (id.length === 0) throw new Error("provider id must not be empty");
242
+ if (!isOAuthCredential(cred)) throw new Error(`refusing to store a malformed OAuth credential for ${id}`);
243
+ const data = readRaw();
244
+ data[id] = { type: "oauth", access: cred.access, refresh: cred.refresh, expires: cred.expires, ...(cred.accountId !== undefined ? { accountId: cred.accountId } : {}) };
245
+ writeStore(data);
246
+ }
247
+
248
+ /** Write the store with restrictive permissions.
249
+ *
250
+ * Windows honesty note: fs mode bits on win32 map only onto the FILE_ATTRIBUTE_READONLY
251
+ * flag — 0o600 does NOT create owner-only protection there. Real isolation on Windows
252
+ * comes from the NTFS ACL on %USERPROFILE% (inherited by ~/.rovecode), which by default
253
+ * denies other non-admin users. So this is best-effort hardening on POSIX (where the
254
+ * 0o600/0o700 bits are enforced) and effectively a no-op on Windows beyond the profile
255
+ * ACL it inherits — we do not claim otherwise. */
256
+ function writeStore(data: Record<string, unknown>): void {
257
+ const path = credentialsPath();
258
+ mkdirSync(rovecodeHome(), { recursive: true, mode: 0o700 });
259
+ writeFileSync(path, JSON.stringify(data, null, 2) + "\n", { mode: CREDENTIALS_FILE_MODE });
260
+ try {
261
+ chmodSync(path, CREDENTIALS_FILE_MODE); // `mode` above only applies on create; re-assert on rewrites
262
+ } catch {
263
+ // best-effort (see Windows note) — a failed chmod must not lose the write
264
+ }
265
+ }
266
+
267
+ /** Store/replace the credential for a provider. `secret` is never echoed or thrown. */
268
+ export function saveCredential(provider: string, secret: string, keyName?: string): void {
269
+ const id = provider.trim();
270
+ if (id.length === 0) throw new Error("provider id must not be empty");
271
+ if (secret.trim().length === 0) throw new Error(`refusing to store an empty secret for ${id}`);
272
+ const data = readRaw();
273
+ const entry: StoredCredential = { type: "api", key: secret, keyName: keyName ?? keyNameFor(id) };
274
+ data[id] = entry;
275
+ writeStore(data);
276
+ }
277
+
278
+ /** Remove a provider's entry. Returns false (and leaves the file alone) when absent.
279
+ * Removing the last entry deletes the file rather than leaving an empty {} around. */
280
+ export function removeCredential(provider: string): boolean {
281
+ const data = readRaw();
282
+ if (!(provider in data)) return false;
283
+ delete data[provider];
284
+ if (Object.keys(data).length === 0) {
285
+ try {
286
+ rmSync(credentialsPath());
287
+ } catch {
288
+ writeStore(data);
289
+ }
290
+ return true;
291
+ }
292
+ writeStore(data);
293
+ return true;
294
+ }
295
+
296
+ /** First 4 chars + "…" (bar wording). Secrets of 8 chars or fewer collapse to "…" alone —
297
+ * half of a short key is most of the key, so nothing of it is shown. */
298
+ export function redactSecret(secret: string): string {
299
+ return secret.length > 8 ? secret.slice(0, 4) + "…" : "…";
300
+ }
301
+
302
+ export interface ProviderListing {
303
+ provider: string;
304
+ /** the record's `type` — api (`auth set`) or oauth (`auth login`; also the mcp-oauth records of port #76) */
305
+ kind: "api" | "oauth";
306
+ /** api: the env var the key stands in for; oauth: the literal "oauth token"; mcp-oauth: "mcp server token" */
307
+ keyName: string;
308
+ redacted: string;
309
+ /** oauth only: access-token expiry, ms since the epoch (Number.MAX_SAFE_INTEGER = never) */
310
+ expires?: number;
311
+ }
312
+
313
+ /** Redacted listing for `rovecode auth list`: provider + kind + key NAME + redacted prefix (+ expiry
314
+ * for OAuth entries). No secret value — key, access token or refresh token — appears in the records. */
315
+ export function listProviders(): ProviderListing[] {
316
+ const api: ProviderListing[] = Object.entries(loadCredentials()).map(([provider, cred]) => ({
317
+ provider, kind: "api", keyName: cred.keyName ?? keyNameFor(provider), redacted: redactSecret(cred.key),
318
+ }));
319
+ const oauth: ProviderListing[] = Object.entries(loadOAuthCredentials()).map(([provider, cred]) => ({
320
+ provider, kind: "oauth", keyName: "oauth token", redacted: redactSecret(cred.access), expires: cred.expires,
321
+ }));
322
+ // port #76: MCP server tokens (`mcp:<name>`) — listed and removable like any entry, never a chat provider
323
+ const mcp: ProviderListing[] = Object.entries(readRaw()).flatMap(([provider, value]) => isMcpOAuthRecord(value)
324
+ ? [{ provider, kind: "oauth" as const, keyName: "mcp server token", redacted: redactSecret(value.access), expires: value.expires }]
325
+ : []);
326
+ return [...api, ...oauth, ...mcp].sort((a, b) => a.provider.localeCompare(b.provider));
327
+ }
328
+
329
+ /** rovecode provider id -> models.dev provider key, mirroring catalog.ts PROVIDER_MAP for the
330
+ * non-identity ids (together -> "togetherai", fireworks -> "fireworks-ai") plus an
331
+ * auth-only alias: moonshot -> "moonshotai" (models.dev has no bare "moonshot" key — the
332
+ * catalog reaches it via VENDOR_PREFIX_MAP on model ids instead, which auth cannot use).
333
+ * Identity ids (anthropic/openai/deepseek/openrouter/...) need no entry: keyNameFor tries
334
+ * the id itself against the snapshot first. */
335
+ const AUTH_PROVIDER_MAP: Record<string, string> = {
336
+ together: "togetherai",
337
+ fireworks: "fireworks-ai",
338
+ moonshot: "moonshotai",
339
+ };
340
+
341
+ /** Which env var / key name a provider expects. models.dev drives this (snapshot
342
+ * Provider.env, e.g. anthropic -> ANTHROPIC_API_KEY — same source opencode's provider
343
+ * loader reads at provider.ts:1583 @ ebece6e); providers absent from models.dev (kaesra,
344
+ * ollama, moondream, vllm) fall back to <ID>_API_KEY, which matches every envKey in
345
+ * stream.ts builtinProviders by construction. */
346
+ export function keyNameFor(providerId: string): string {
347
+ const key = AUTH_PROVIDER_MAP[providerId] ?? providerId;
348
+ const env = snapshotProvidersLazy()[key]?.env;
349
+ if (env !== undefined && env.length > 0 && env[0]) return env[0];
350
+ return providerId.toUpperCase().replace(/[^A-Z0-9]+/g, "_") + "_API_KEY";
351
+ }
352
+
353
+ // ---------- secret prompt (`rovecode auth set`) ----------
354
+
355
+ type SecretInput = NodeJS.ReadableStream & { isTTY?: boolean; isRaw?: boolean };
356
+ type SecretOutput = NodeJS.WritableStream & { columns?: number };
357
+ /** Streams readSecret talks to — injectable so tests can drive a fake TTY in-process. */
358
+ export interface SecretPromptIO { input?: SecretInput; output?: SecretOutput }
359
+
360
+ /** Cursor-control sequence for the cooked-mode fallback: after Enter the terminal has echoed
361
+ * prompt+line and moved to the next row, so erase ONE row per wrapped row the echo occupied
362
+ * (cursor-up + erase-line each), then "\r". A single row is not enough — a 33-col prompt plus
363
+ * a 108-char Anthropic key wraps on any terminal narrower than 141 columns. Pure: pinned by
364
+ * the headless-xterm test at 80 and 120 columns. Unknown/zero width assumes 80. */
365
+ export function echoScrubSequence(promptLen: number, lineLen: number, columns: number): string {
366
+ const cols = columns > 0 ? Math.floor(columns) : 80;
367
+ const rows = Math.max(1, Math.ceil((promptLen + lineLen) / cols));
368
+ return "\x1b[1A\x1b[2K".repeat(rows) + "\r";
369
+ }
370
+
371
+ /** Read one secret line for `rovecode auth set`. What is guaranteed:
372
+ * - TTY stdin: the prompt goes to stderr and readline runs in terminal mode, which calls
373
+ * setRawMode(true): the terminal driver's echo is OFF and readline's own echo goes to a
374
+ * sink, so the keystrokes are never written to the terminal at all. Verified on a real
375
+ * Windows console under Bun 1.3.14 — the console input mode drops ENABLE_ECHO_INPUT and
376
+ * ENABLE_LINE_INPUT, the screen buffer stays clean, and the mode is restored on close.
377
+ * - Fallback, only if raw mode did not take (no/failing setRawMode, input.isRaw stays false):
378
+ * the driver echoed, so after Enter every row the prompt+echo wrapped onto is erased
379
+ * (echoScrubSequence). A terminal that ignores VT cursor sequences keeps that echo.
380
+ * - Piped stdin (scripts, tests): one line, no prompt, nothing written to any stream.
381
+ * The value is returned trimmed and is never logged or embedded in an error. */
382
+ export function readSecret(promptText: string, io: SecretPromptIO = {}): Promise<string> {
383
+ const input = io.input ?? process.stdin;
384
+ const output = io.output ?? process.stderr;
385
+ const tty = input.isTTY === true;
386
+ if (tty) output.write(promptText);
387
+ const rl = tty
388
+ ? createInterface({ input, output: new Writable({ write: (_c, _e, cb) => cb() }), terminal: true })
389
+ : createInterface({ input });
390
+ const raw = tty && input.isRaw === true;
391
+ return new Promise((resolve) => {
392
+ let settled = false; // rl.close() emits "close" SYNCHRONOUSLY — guard the race
393
+ rl.once("line", (line) => {
394
+ if (settled) return;
395
+ settled = true;
396
+ // raw: nothing was echoed, just end the prompt line; not raw: erase the echoed rows
397
+ if (tty) output.write(raw ? "\n" : echoScrubSequence(promptText.length, line.length, output.columns ?? 80));
398
+ rl.close();
399
+ resolve(line.trim());
400
+ });
401
+ rl.once("close", () => { // EOF / Ctrl+C / Ctrl+D without a line
402
+ if (settled) return;
403
+ settled = true;
404
+ if (tty) output.write("\n");
405
+ resolve("");
406
+ });
407
+ });
408
+ }
@@ -0,0 +1,223 @@
1
+ /** Anthropic prompt-cache boundary placement — pure transform for the /messages request body.
2
+ *
3
+ * Prompt caching is a PREFIX match: the API render order is tools → system → messages, and a
4
+ * `cache_control: {type:"ephemeral"}` marker on a content block caches everything from the start
5
+ * of the request up to and including that block. Any byte change before a marker invalidates it.
6
+ * Therefore boundaries only pay off on STABLE prefixes — a boundary after content that changes
7
+ * every turn is a wasted breakpoint (Anthropic allows at most 4 per request).
8
+ *
9
+ * Pattern reference (hermes-agent snapshot, research/source_snapshots/hermes-agent):
10
+ * - agent/prompt_caching.py:1-8 — "The default layout uses 4 cache_control breakpoints: the static
11
+ * system prefix, the end of the system prompt, and the last 2 non-system messages."
12
+ * - agent/prompt_caching.py:157-162 (_build_marker) — the marker is `{"type": "ephemeral"}`.
13
+ * - agent/prompt_caching.py:113-121 (_apply_cache_marker) — string content is converted to a
14
+ * single text block carrying the marker; list content gets the marker on its LAST block.
15
+ * - agent/prompt_caching.py:99-103, 331-333 — volatile tails ride UNMARKED ("a changed ticket ID
16
+ * or timestamp no longer invalidates the whole skill body"), and empty text blocks are never
17
+ * emitted (HTTP 400 on native Anthropic).
18
+ * - agent/prompt_caching.py:567-573 (apply_anthropic_cache_control) — idempotence contract:
19
+ * re-application "can never accumulate past 4 markers".
20
+ * - agent/system_prompt.py:911-924 and agent/turn_context.py:214-217 — the stability principle:
21
+ * volatile per-turn facts are kept OUT of / AFTER the cached band so the prefix stays
22
+ * byte-stable turn-over-turn.
23
+ *
24
+ * Placement policy implemented here (deliberately simpler than Hermes' 4-marker layout):
25
+ * (1) SYSTEM boundary — `system: string` is converted to
26
+ * `[{type:"text", text, cache_control:{type:"ephemeral"}}]` when it is ≥ minChunkChars.
27
+ * A system-block array already provided by the caller gets the marker on its last block
28
+ * (unless any block is already marked). Because caching is prefix-based, this single marker
29
+ * also covers the `tools` array rendered before `system` — no separate tools marker needed.
30
+ * (2) CONVERSATION-PREFIX boundary — the marker goes on the LAST content block of the most
31
+ * recent message that has ≥ 2 messages after it (index length-3): an agent turn appends
32
+ * the trailing exchange (assistant tool_use + tool_result, or user + assistant), so the
33
+ * tail churns every turn while the prefix up to there is CANONICALLY stable — not
34
+ * byte-identical. Two rewrites make consecutive requests differ in raw bytes: the marker
35
+ * moves forward each turn (dropped from the old anchor, added at the new one), and the
36
+ * marked message's string content is rewritten to [{type:"text",...}] by
37
+ * withMarkerOnLastBlock below. Anthropic's cache key normalizes exactly those two
38
+ * (cache_control is placement metadata, and string content is equivalent to a single
39
+ * text block), so the previous request's prefix still cache-hits. Pinned by the
40
+ * consecutive-turn stability test in test/unit/cache.test.ts.
41
+ * If the anchor message cannot carry a marker (empty content — cf. Hermes _can_carry_marker,
42
+ * prompt_caching.py:127-140, "so the breakpoints land on messages that count"), the
43
+ * boundary reallocates to the nearest earlier eligible message. String content is
44
+ * converted to a one-block array, preserving the text byte-for-byte.
45
+ * (3) BUDGET — never exceed maxBreakpoints total, counting markers already present in
46
+ * system/messages/tools before placing new ones. System wins over conversation prefix.
47
+ * (4) IDEMPOTENT — a position that already carries a marker is left untouched, existing markers
48
+ * count toward the budget, and untouched inputs are returned by reference, so
49
+ * apply(apply(body)) deep-equals apply(body).
50
+ * (5) Bodies without messages/system pass through untouched (same reference), as does anything
51
+ * whose shapes don't match — this is a best-effort decorator, never a validator.
52
+ *
53
+ * minChunkChars default: 4096 chars ≈ 1024 tokens (~4 chars/token) — the documented minimum
54
+ * cacheable prefix for Sonnet/Opus-class models (model-dependent, 512–4096 tokens per the API
55
+ * docs). Prefixes below the model minimum are silently NOT cached, so spending a breakpoint on
56
+ * them is pure waste. For the message boundary the gate compares the CUMULATIVE prefix
57
+ * (system + messages[0..anchor]) against minChunkChars, since that whole span is what the
58
+ * marker would cache (tools text is not counted — a conservative undercount).
59
+ *
60
+ * Pure: the input body is never mutated; modified paths are copied copy-on-write.
61
+ */
62
+
63
+ export interface CacheOptions {
64
+ /** Maximum cache_control markers allowed in the request. Anthropic's limit — and the default — is 4. */
65
+ maxBreakpoints?: number;
66
+ /** Don't spend a breakpoint on a prefix shorter than this many characters. Default 4096 (≈1024 tokens). */
67
+ minChunkChars?: number;
68
+ }
69
+
70
+ export interface CacheControl { type: "ephemeral" }
71
+
72
+ /** Loose ("-ish") view of the body anthropicStream builds (src/providers/stream.ts:152-160):
73
+ * `{ model, max_tokens, system?: string, messages: [...], tools?: [...] }`. Every field is
74
+ * unknown-typed so the adapter's `Record<string, unknown>` is directly assignable; this module
75
+ * narrows at runtime and passes through anything it does not recognize. */
76
+ export interface AnthropicishBody {
77
+ system?: unknown;
78
+ messages?: unknown;
79
+ tools?: unknown;
80
+ [key: string]: unknown;
81
+ }
82
+
83
+ export const DEFAULT_MAX_BREAKPOINTS = 4;
84
+ export const DEFAULT_MIN_CHUNK_CHARS = 4096;
85
+
86
+ const EPHEMERAL: CacheControl = { type: "ephemeral" };
87
+
88
+ type Rec = Record<string, unknown>;
89
+
90
+ function isRec(v: unknown): v is Rec {
91
+ return typeof v === "object" && v !== null && !Array.isArray(v);
92
+ }
93
+
94
+ /** Marker presence on a content block / tool entry (block-level only — the shapes this module emits). */
95
+ function hasMarker(block: unknown): boolean {
96
+ return isRec(block) && block["cache_control"] !== undefined;
97
+ }
98
+
99
+ function blockText(block: unknown): string {
100
+ if (!isRec(block)) return "";
101
+ return typeof block["text"] === "string" ? block["text"] : "";
102
+ }
103
+
104
+ function systemChars(system: unknown): number {
105
+ if (typeof system === "string") return system.length;
106
+ if (Array.isArray(system)) return system.reduce((n: number, b) => n + blockText(b).length, 0);
107
+ return 0;
108
+ }
109
+
110
+ function contentChars(content: unknown): number {
111
+ if (typeof content === "string") return content.length;
112
+ if (!Array.isArray(content)) return 0;
113
+ let n = 0;
114
+ for (const b of content) {
115
+ n += blockText(b).length;
116
+ // tool_result blocks carry their payload under "content" (string form only; arrays undercount)
117
+ if (isRec(b) && typeof b["content"] === "string") n += b["content"].length;
118
+ }
119
+ return n;
120
+ }
121
+
122
+ function messageMarkerCount(msg: unknown): number {
123
+ if (!isRec(msg)) return 0;
124
+ const content = msg["content"];
125
+ return Array.isArray(content) ? content.filter(hasMarker).length : 0;
126
+ }
127
+
128
+ function countExistingMarkers(system: unknown, messages: readonly unknown[] | null, tools: unknown): number {
129
+ let n = 0;
130
+ if (Array.isArray(system)) n += system.filter(hasMarker).length;
131
+ for (const m of messages ?? []) n += messageMarkerCount(m);
132
+ if (Array.isArray(tools)) n += tools.filter(hasMarker).length;
133
+ return n;
134
+ }
135
+
136
+ /** A marker on this message would actually land on the wire: non-empty string content (an empty
137
+ * string would convert to an empty text block — HTTP 400), or a non-empty block array whose last
138
+ * element is an object. Mirrors Hermes _can_carry_marker (prompt_caching.py:127-154). */
139
+ function canCarryMarker(msg: unknown): boolean {
140
+ if (!isRec(msg)) return false;
141
+ const content = msg["content"];
142
+ if (typeof content === "string") return content.length > 0;
143
+ if (Array.isArray(content) && content.length > 0) return isRec(content[content.length - 1]);
144
+ return false;
145
+ }
146
+
147
+ /** Copy of `msg` with the ephemeral marker on its last content block; string content becomes a
148
+ * one-block array with the text preserved byte-for-byte (Hermes prompt_caching.py:113-121). */
149
+ function withMarkerOnLastBlock(msg: Rec): Rec {
150
+ const content = msg["content"];
151
+ if (typeof content === "string") {
152
+ return { ...msg, content: [{ type: "text", text: content, cache_control: EPHEMERAL }] };
153
+ }
154
+ if (Array.isArray(content) && content.length > 0) {
155
+ const last = content[content.length - 1];
156
+ if (isRec(last)) {
157
+ const blocks = content.slice();
158
+ blocks[blocks.length - 1] = { ...last, cache_control: EPHEMERAL };
159
+ return { ...msg, content: blocks };
160
+ }
161
+ }
162
+ return msg; // callers gate on canCarryMarker(); unreachable in practice
163
+ }
164
+
165
+ export function applyAnthropicCacheBoundaries(body: AnthropicishBody, opts?: CacheOptions): AnthropicishBody {
166
+ const maxBreakpoints = opts?.maxBreakpoints ?? DEFAULT_MAX_BREAKPOINTS;
167
+ const minChunkChars = opts?.minChunkChars ?? DEFAULT_MIN_CHUNK_CHARS;
168
+
169
+ const system = body.system;
170
+ const messages: readonly unknown[] | null = Array.isArray(body.messages) ? body.messages : null;
171
+
172
+ const hasSystem = (typeof system === "string" && system.length > 0) || (Array.isArray(system) && system.length > 0);
173
+ const hasMessages = messages !== null && messages.length > 0;
174
+ if (!hasSystem && !hasMessages) return body; // policy (5): pass through untouched
175
+
176
+ let used = countExistingMarkers(system, messages, body.tools);
177
+ let out = body;
178
+ const copyOnWrite = (): AnthropicishBody => (out === body ? (out = { ...body }) : out);
179
+
180
+ // ---- policy (1): system boundary ------------------------------------------------------------
181
+ if (used < maxBreakpoints && hasSystem) {
182
+ if (typeof system === "string") {
183
+ if (system.length >= minChunkChars) {
184
+ copyOnWrite().system = [{ type: "text", text: system, cache_control: EPHEMERAL }];
185
+ used += 1;
186
+ }
187
+ } else if (Array.isArray(system) && !system.some(hasMarker)) {
188
+ const last = system[system.length - 1];
189
+ if (isRec(last) && systemChars(system) >= minChunkChars) {
190
+ const blocks = system.slice();
191
+ blocks[blocks.length - 1] = { ...last, cache_control: EPHEMERAL };
192
+ copyOnWrite().system = blocks;
193
+ used += 1;
194
+ }
195
+ }
196
+ }
197
+
198
+ // ---- policy (2): conversation-prefix boundary ------------------------------------------------
199
+ // Anchor = most recent message with ≥2 messages after it (index length-3); the trailing two
200
+ // messages are this turn's churn. Reallocate backwards past marker-incapable messages.
201
+ if (used < maxBreakpoints && messages !== null && messages.length >= 3) {
202
+ let anchor = -1;
203
+ for (let i = messages.length - 3; i >= 0; i -= 1) {
204
+ if (canCarryMarker(messages[i])) { anchor = i; break; }
205
+ }
206
+ const msg = anchor >= 0 ? messages[anchor] : undefined;
207
+ if (isRec(msg) && messageMarkerCount(msg) === 0) {
208
+ let prefixChars = systemChars(system);
209
+ for (let i = 0; i <= anchor; i += 1) {
210
+ const m = messages[i];
211
+ if (isRec(m)) prefixChars += contentChars(m["content"]);
212
+ }
213
+ if (prefixChars >= minChunkChars) {
214
+ const next = messages.slice();
215
+ next[anchor] = withMarkerOnLastBlock(msg);
216
+ copyOnWrite().messages = next;
217
+ used += 1;
218
+ }
219
+ }
220
+ }
221
+
222
+ return out;
223
+ }