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.
- package/LICENSE +662 -0
- package/README.md +737 -0
- package/THIRD_PARTY_NOTICES.md +268 -0
- package/bin/rovecode.ts +21 -0
- package/package.json +56 -0
- package/src/acp/server.ts +374 -0
- package/src/cli/auth-login.ts +122 -0
- package/src/cli/connect.ts +244 -0
- package/src/cli/context-cmd.ts +199 -0
- package/src/cli/dispatch.ts +82 -0
- package/src/cli/doctor.ts +362 -0
- package/src/cli/export.ts +276 -0
- package/src/cli/help.ts +293 -0
- package/src/cli/is-tui-invocation.ts +8 -0
- package/src/cli/main.ts +583 -0
- package/src/cli/market-cmd.ts +658 -0
- package/src/cli/mcp-login.ts +141 -0
- package/src/cli/mcp-market-cmd.ts +302 -0
- package/src/cli/output.ts +382 -0
- package/src/cli/repl.ts +250 -0
- package/src/cli/repomap-root.ts +14 -0
- package/src/cli/resume.ts +57 -0
- package/src/cli/run-flags.ts +43 -0
- package/src/cli/run-limits.ts +78 -0
- package/src/cli/runtime.ts +931 -0
- package/src/cli/session-arg.ts +30 -0
- package/src/cli/sessions-cmd.ts +145 -0
- package/src/cli/setup.ts +153 -0
- package/src/cli/skills-cmd.ts +194 -0
- package/src/cli/start-chat.ts +65 -0
- package/src/cli/trust-cmd.ts +52 -0
- package/src/coding/bash.ts +148 -0
- package/src/coding/checkpoints.ts +327 -0
- package/src/coding/diff.ts +138 -0
- package/src/coding/files.ts +341 -0
- package/src/coding/hashline.ts +274 -0
- package/src/coding/lsp-gate.ts +254 -0
- package/src/coding/lsp-servers.ts +147 -0
- package/src/coding/lsp.ts +283 -0
- package/src/coding/repomap-cache.ts +99 -0
- package/src/coding/repomap-files.ts +192 -0
- package/src/coding/repomap.ts +481 -0
- package/src/core/agents.ts +255 -0
- package/src/core/compaction.ts +259 -0
- package/src/core/config.ts +289 -0
- package/src/core/context-report.ts +228 -0
- package/src/core/context.ts +60 -0
- package/src/core/count-remote.ts +107 -0
- package/src/core/execpolicy-rules.ts +196 -0
- package/src/core/execpolicy.ts +385 -0
- package/src/core/executor.ts +454 -0
- package/src/core/guardrails.ts +400 -0
- package/src/core/hooks.ts +411 -0
- package/src/core/images.ts +230 -0
- package/src/core/intro.ts +266 -0
- package/src/core/loop.ts +567 -0
- package/src/core/modes.ts +372 -0
- package/src/core/orchestrator.ts +245 -0
- package/src/core/proc-group.ts +48 -0
- package/src/core/project-trust.ts +98 -0
- package/src/core/reflection.ts +165 -0
- package/src/core/sandbox-config.ts +186 -0
- package/src/core/session-id.ts +24 -0
- package/src/core/session-images.ts +73 -0
- package/src/core/session-ops.ts +183 -0
- package/src/core/session-text.ts +29 -0
- package/src/core/session.ts +469 -0
- package/src/core/settings.ts +170 -0
- package/src/core/tasks.ts +646 -0
- package/src/core/token-scale.ts +108 -0
- package/src/core/tools.ts +309 -0
- package/src/core/trust.ts +104 -0
- package/src/core/types.ts +330 -0
- package/src/core/update-check.ts +171 -0
- package/src/core/usage.ts +204 -0
- package/src/core/validate.ts +121 -0
- package/src/core/verify-gate.ts +159 -0
- package/src/core/verify.ts +236 -0
- package/src/core/voice.ts +158 -0
- package/src/core/win-job.ts +183 -0
- package/src/core/workspace.ts +184 -0
- package/src/design/audit.ts +797 -0
- package/src/design/direction.ts +190 -0
- package/src/design/rules.ts +157 -0
- package/src/eval/bench.ts +150 -0
- package/src/eval/gauntlet-runner.ts +215 -0
- package/src/eval/gauntlet-support.ts +84 -0
- package/src/eval/gauntlet-wave3.ts +269 -0
- package/src/eval/gauntlet-wave4.ts +217 -0
- package/src/eval/gauntlet.ts +253 -0
- package/src/index.ts +17 -0
- package/src/lanes/agy.ts +95 -0
- package/src/lanes/approval.ts +24 -0
- package/src/lanes/claude.ts +129 -0
- package/src/lanes/codex.ts +127 -0
- package/src/lanes/events.ts +130 -0
- package/src/lanes/job.ts +142 -0
- package/src/lanes/opencode.ts +122 -0
- package/src/lanes/process.ts +184 -0
- package/src/lanes/progress.ts +183 -0
- package/src/lanes/registry.ts +178 -0
- package/src/lanes/runner.ts +124 -0
- package/src/lanes/types.ts +112 -0
- package/src/market/catalogs/mcp-docs.json +111 -0
- package/src/market/catalogs/plugins.json +111 -0
- package/src/market/catalogs/skills.json +478 -0
- package/src/market/clone.ts +72 -0
- package/src/market/context-cost.ts +121 -0
- package/src/market/digest.ts +106 -0
- package/src/market/index.ts +22 -0
- package/src/market/install.ts +578 -0
- package/src/market/manifest.ts +187 -0
- package/src/market/prereq.ts +145 -0
- package/src/market/registry.ts +363 -0
- package/src/market/resolve.ts +111 -0
- package/src/market/types.ts +236 -0
- package/src/market/validate.ts +227 -0
- package/src/mcp/client.ts +449 -0
- package/src/mcp/config.ts +252 -0
- package/src/mcp/local-package.ts +211 -0
- package/src/mcp/market-catalog.ts +84 -0
- package/src/mcp/market-install.ts +289 -0
- package/src/mcp/market.ts +362 -0
- package/src/mcp/oauth.ts +251 -0
- package/src/mcp/prompts-resources.ts +249 -0
- package/src/mcp/shared.ts +149 -0
- package/src/mcp/status.ts +67 -0
- package/src/mcp/tools.ts +275 -0
- package/src/mcp/transport.ts +122 -0
- package/src/mcp/trust.ts +25 -0
- package/src/memory/blocks.ts +278 -0
- package/src/memory/recall.ts +355 -0
- package/src/memory/scope.ts +182 -0
- package/src/memory/store.ts +105 -0
- package/src/memory/tools.ts +99 -0
- package/src/plugins/cli.ts +119 -0
- package/src/plugins/discover.ts +108 -0
- package/src/plugins/index.ts +50 -0
- package/src/plugins/install.ts +184 -0
- package/src/plugins/load.ts +124 -0
- package/src/plugins/manifest.ts +92 -0
- package/src/plugins/state.ts +83 -0
- package/src/providers/auth.ts +408 -0
- package/src/providers/cache.ts +223 -0
- package/src/providers/catalog-local.ts +160 -0
- package/src/providers/catalog.ts +421 -0
- package/src/providers/middleware-context.ts +86 -0
- package/src/providers/middleware.ts +373 -0
- package/src/providers/model-list.ts +23 -0
- package/src/providers/models-index.json +1 -0
- package/src/providers/oauth/common.ts +105 -0
- package/src/providers/oauth/device-code.ts +107 -0
- package/src/providers/oauth/github-copilot.ts +146 -0
- package/src/providers/oauth/loopback.ts +158 -0
- package/src/providers/oauth/openai.ts +163 -0
- package/src/providers/oauth/openrouter.ts +89 -0
- package/src/providers/oauth/pkce.ts +45 -0
- package/src/providers/oauth/registry.ts +39 -0
- package/src/providers/oauth/seam.ts +89 -0
- package/src/providers/profile-glm53.ts +111 -0
- package/src/providers/profile-sonnet5-persona.ts +65 -0
- package/src/providers/profile-sonnet5-voice.ts +23 -0
- package/src/providers/profiles.ts +156 -0
- package/src/providers/provider-config.ts +311 -0
- package/src/providers/registry.ts +333 -0
- package/src/providers/responses.ts +209 -0
- package/src/providers/retry.ts +234 -0
- package/src/providers/router.ts +294 -0
- package/src/providers/sse.ts +26 -0
- package/src/providers/stream-errors.ts +117 -0
- package/src/providers/stream.ts +566 -0
- package/src/providers/thinking.ts +189 -0
- package/src/providers/wire-messages.ts +129 -0
- package/src/providers/wire-responses.ts +79 -0
- package/src/providers/wire-select.ts +53 -0
- package/src/server/http.ts +291 -0
- package/src/server/openapi.ts +246 -0
- package/src/sextant/card-hits.ts +102 -0
- package/src/sextant/card-keys.ts +55 -0
- package/src/sextant/context-source.ts +157 -0
- package/src/sextant/crew-cards.ts +350 -0
- package/src/sextant/draw-agents.ts +273 -0
- package/src/sextant/draw-code.ts +388 -0
- package/src/sextant/draw-context.ts +222 -0
- package/src/sextant/draw-frame.ts +164 -0
- package/src/sextant/draw-market.ts +573 -0
- package/src/sextant/draw-messages.ts +386 -0
- package/src/sextant/draw-pet.ts +230 -0
- package/src/sextant/draw-plan.ts +187 -0
- package/src/sextant/draw-tabs.ts +85 -0
- package/src/sextant/draw-util.ts +65 -0
- package/src/sextant/draw-wizard.ts +378 -0
- package/src/sextant/engine.ts +230 -0
- package/src/sextant/frame-hits.ts +25 -0
- package/src/sextant/frame.ts +101 -0
- package/src/sextant/git-status.ts +197 -0
- package/src/sextant/grid.ts +59 -0
- package/src/sextant/input.ts +119 -0
- package/src/sextant/keys.ts +521 -0
- package/src/sextant/layout.ts +86 -0
- package/src/sextant/local-commands.ts +169 -0
- package/src/sextant/market-source.ts +287 -0
- package/src/sextant/mentions.ts +200 -0
- package/src/sextant/message-hits.ts +26 -0
- package/src/sextant/model.ts +387 -0
- package/src/sextant/overlays.ts +456 -0
- package/src/sextant/panel-hits.ts +38 -0
- package/src/sextant/pet.ts +399 -0
- package/src/sextant/screen.ts +324 -0
- package/src/sextant/scroll-hits.ts +66 -0
- package/src/sextant/scrollbar.ts +82 -0
- package/src/sextant/sextant-bridge.ts +174 -0
- package/src/sextant/sextant-cards.ts +142 -0
- package/src/sextant/sextant-diff-base.ts +63 -0
- package/src/sextant/sextant-files.ts +154 -0
- package/src/sextant/sextant-frame-loop.ts +335 -0
- package/src/sextant/sextant-renderer.ts +574 -0
- package/src/sextant/sextant-repo.ts +140 -0
- package/src/sextant/theme.ts +66 -0
- package/src/sextant/tool-rows.ts +189 -0
- package/src/sextant/types.ts +493 -0
- package/src/skills/index.ts +387 -0
- package/src/skills/pack.ts +220 -0
- package/src/skills/spec.ts +162 -0
- package/src/skills/tools.ts +69 -0
- package/src/skills/versioned.ts +227 -0
- package/src/telemetry/otel-export.ts +122 -0
- package/src/telemetry/otel-lanes.ts +89 -0
- package/src/telemetry/otel-logs.ts +131 -0
- package/src/telemetry/otel-metrics.ts +136 -0
- package/src/telemetry/otel.ts +397 -0
- package/src/telemetry/otlp.ts +76 -0
- package/src/tools/ask-user.ts +156 -0
- package/src/tools/bash-bg.ts +94 -0
- package/src/tools/bash-jobs.ts +237 -0
- package/src/tools/design.ts +151 -0
- package/src/tools/evalcell.ts +338 -0
- package/src/tools/html-text.ts +139 -0
- package/src/tools/provider.ts +149 -0
- package/src/tools/task.ts +250 -0
- package/src/tools/todo.ts +320 -0
- package/src/tools/webfetch.ts +332 -0
- package/src/tools/websearch.ts +359 -0
- package/src/tui/agents-cmd.ts +41 -0
- package/src/tui/app.ts +749 -0
- package/src/tui/attach.ts +127 -0
- package/src/tui/boot-notes.ts +41 -0
- package/src/tui/builtin-prompts.ts +59 -0
- package/src/tui/checkpoints-cmd.ts +70 -0
- package/src/tui/clipboard-image.ts +81 -0
- package/src/tui/clipboard.ts +78 -0
- package/src/tui/commands.ts +283 -0
- package/src/tui/config-view.ts +53 -0
- package/src/tui/context-cmds.ts +282 -0
- package/src/tui/cost.ts +108 -0
- package/src/tui/crash-guard.ts +173 -0
- package/src/tui/focus-terminal.ts +34 -0
- package/src/tui/git-cmds.ts +273 -0
- package/src/tui/git-plain.ts +58 -0
- package/src/tui/info-cmd.ts +150 -0
- package/src/tui/input-plain.ts +76 -0
- package/src/tui/mcp-cmd.ts +128 -0
- package/src/tui/memory-note.ts +77 -0
- package/src/tui/modes-cmd.ts +45 -0
- package/src/tui/notify-seq.ts +100 -0
- package/src/tui/notify.ts +318 -0
- package/src/tui/overlays.ts +97 -0
- package/src/tui/pi-renderer.ts +428 -0
- package/src/tui/providers-cmd.ts +377 -0
- package/src/tui/reasoning-view.ts +56 -0
- package/src/tui/renderer.ts +128 -0
- package/src/tui/replay-marker.ts +29 -0
- package/src/tui/session-cmd.ts +148 -0
- package/src/tui/session-manage.ts +95 -0
- package/src/tui/sextant-attach.ts +102 -0
- package/src/tui/sextant-io.ts +202 -0
- package/src/tui/sextant-smoke.ts +110 -0
- package/src/tui/shell-cmd.ts +158 -0
- package/src/tui/smoke.ts +72 -0
- package/src/tui/staged-terminal.ts +50 -0
- package/src/tui/startup.ts +12 -0
- package/src/tui/theme.ts +59 -0
- package/src/tui/todo-label.ts +7 -0
- package/src/tui/trust-card.ts +107 -0
- package/src/tui/tui-commands.ts +87 -0
- package/tsconfig.json +30 -0
- package/vendor/pi-tui/LICENSE +21 -0
- package/vendor/pi-tui/PATCHES.md +12 -0
- package/vendor/pi-tui/PROVENANCE.md +12 -0
- package/vendor/pi-tui/README.upstream.md +854 -0
- package/vendor/pi-tui/native/win32/prebuilds/win32-arm64/win32-console-mode.node +0 -0
- package/vendor/pi-tui/native/win32/prebuilds/win32-x64/win32-console-mode.node +0 -0
- package/vendor/pi-tui/src/alt-screen-search.ts +158 -0
- package/vendor/pi-tui/src/autocomplete.ts +827 -0
- package/vendor/pi-tui/src/components/alt-screen-flash.ts +52 -0
- package/vendor/pi-tui/src/components/box.ts +138 -0
- package/vendor/pi-tui/src/components/cancellable-loader.ts +41 -0
- package/vendor/pi-tui/src/components/editor.ts +2364 -0
- package/vendor/pi-tui/src/components/h-stack.ts +45 -0
- package/vendor/pi-tui/src/components/image.ts +128 -0
- package/vendor/pi-tui/src/components/input.ts +448 -0
- package/vendor/pi-tui/src/components/loader.ts +93 -0
- package/vendor/pi-tui/src/components/markdown.ts +1016 -0
- package/vendor/pi-tui/src/components/scroll-view.ts +217 -0
- package/vendor/pi-tui/src/components/select-list.ts +230 -0
- package/vendor/pi-tui/src/components/settings-list.ts +277 -0
- package/vendor/pi-tui/src/components/spacer.ts +29 -0
- package/vendor/pi-tui/src/components/stack.ts +155 -0
- package/vendor/pi-tui/src/components/text.ts +108 -0
- package/vendor/pi-tui/src/components/truncated-text.ts +66 -0
- package/vendor/pi-tui/src/components/v-stack.ts +34 -0
- package/vendor/pi-tui/src/editor-component.ts +75 -0
- package/vendor/pi-tui/src/fuzzy.ts +138 -0
- package/vendor/pi-tui/src/index.ts +149 -0
- package/vendor/pi-tui/src/keybindings.ts +321 -0
- package/vendor/pi-tui/src/keys.ts +1402 -0
- package/vendor/pi-tui/src/kill-ring.ts +47 -0
- package/vendor/pi-tui/src/latex.ts +1381 -0
- package/vendor/pi-tui/src/layout-node.ts +52 -0
- package/vendor/pi-tui/src/layout.ts +411 -0
- package/vendor/pi-tui/src/native-modifiers.ts +60 -0
- package/vendor/pi-tui/src/native-module-path.ts +32 -0
- package/vendor/pi-tui/src/stdin-buffer.ts +445 -0
- package/vendor/pi-tui/src/terminal-colors.ts +74 -0
- package/vendor/pi-tui/src/terminal-image.ts +701 -0
- package/vendor/pi-tui/src/terminal.ts +554 -0
- package/vendor/pi-tui/src/tui-alt-screen.ts +1379 -0
- package/vendor/pi-tui/src/tui-main-screen.ts +655 -0
- package/vendor/pi-tui/src/tui.ts +1264 -0
- package/vendor/pi-tui/src/undo-stack.ts +29 -0
- package/vendor/pi-tui/src/utils.ts +1327 -0
- package/vendor/pi-tui/src/word-navigation.ts +118 -0
- package/vendor/pi-tui/test/test-themes.ts +39 -0
- 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
|
+
}
|