privateer-agent 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/README.md +135 -443
  2. package/bin/privateer-tui +86 -0
  3. package/bin/privateer.mjs +17 -6
  4. package/bin/pv +28 -0
  5. package/package.json +26 -23
  6. package/src/auth/privateer.ts +57 -0
  7. package/src/boot.ts +43 -0
  8. package/src/bridge/engineAdapter.ts +182 -0
  9. package/src/cli/chat.ts +243 -0
  10. package/src/config/paths.ts +24 -46
  11. package/src/config/permissionMode.ts +5 -0
  12. package/src/crypto/outboxSeal.ts +61 -0
  13. package/src/daemon/index.ts +204 -121
  14. package/src/daemon/ipc.ts +1 -1
  15. package/src/engine/errors.ts +85 -45
  16. package/src/engine/router.ts +11 -165
  17. package/src/ext/permissionGate.ts +216 -0
  18. package/src/main.ts +32 -0
  19. package/src/permissions/classify.ts +172 -0
  20. package/src/permissions/gate.ts +11 -14
  21. package/src/permissions/mode.ts +6 -2
  22. package/src/permissions/{uiGate.ts → modeGate.ts} +18 -3
  23. package/src/providers/account.ts +170 -0
  24. package/src/providers/catalog.ts +73 -61
  25. package/src/providers/genModelsJson.ts +97 -0
  26. package/src/remote/relayClient.ts +15 -0
  27. package/src/remote/remoteBridge.ts +152 -0
  28. package/src/routines/delivery.ts +118 -9
  29. package/src/routines/schema.ts +24 -5
  30. package/src/routines/store.ts +44 -1
  31. package/src/routines/toolSelect.ts +3 -1
  32. package/src/session.ts +81 -273
  33. package/src/tools/routine.ts +120 -101
  34. package/src/tools/saveAttachment.ts +39 -42
  35. package/src/tools/sendFile.ts +75 -0
  36. package/src/util/attachmentStore.ts +18 -35
  37. package/src/util/redact.ts +33 -3
  38. package/LICENSE +0 -21
  39. package/src/agents/loader.ts +0 -49
  40. package/src/commands/custom.ts +0 -123
  41. package/src/commands/registry.ts +0 -618
  42. package/src/components/AgentGroupView.tsx +0 -104
  43. package/src/components/App.tsx +0 -1626
  44. package/src/components/ApprovalPrompt.tsx +0 -49
  45. package/src/components/Banner.tsx +0 -78
  46. package/src/components/Markdown.tsx +0 -183
  47. package/src/components/ModeHint.tsx +0 -40
  48. package/src/components/ModelPicker.tsx +0 -302
  49. package/src/components/Onboarding.tsx +0 -203
  50. package/src/components/OptionPicker.tsx +0 -134
  51. package/src/components/PlanConfirm.tsx +0 -37
  52. package/src/components/PrivateerLogin.tsx +0 -109
  53. package/src/components/PromptInput.tsx +0 -602
  54. package/src/components/RewindPicker.tsx +0 -69
  55. package/src/components/Root.tsx +0 -116
  56. package/src/components/SessionPicker.tsx +0 -64
  57. package/src/components/StatusBar.tsx +0 -131
  58. package/src/components/TodoPanel.tsx +0 -36
  59. package/src/components/ToolCallView.tsx +0 -113
  60. package/src/components/Transcript.tsx +0 -210
  61. package/src/components/figures.ts +0 -14
  62. package/src/components/promptModel.ts +0 -73
  63. package/src/components/spinnerVerbs.ts +0 -46
  64. package/src/components/theme.ts +0 -57
  65. package/src/components/types.ts +0 -34
  66. package/src/components/useTeeShield.ts +0 -104
  67. package/src/components/useTerminalWidth.ts +0 -24
  68. package/src/components/useZdrShield.ts +0 -126
  69. package/src/config/load.ts +0 -115
  70. package/src/config/schema.ts +0 -94
  71. package/src/context/outputStyles.ts +0 -42
  72. package/src/context/projectInfo.ts +0 -59
  73. package/src/context/systemPrompt.ts +0 -176
  74. package/src/engine/QueryEngine.ts +0 -399
  75. package/src/hooks/engine.ts +0 -155
  76. package/src/main.tsx +0 -209
  77. package/src/mcp/client.ts +0 -251
  78. package/src/mcp/oauth.ts +0 -245
  79. package/src/memory/auto.ts +0 -146
  80. package/src/memory/checkpoints.ts +0 -227
  81. package/src/memory/store.ts +0 -127
  82. package/src/providers/attestation.ts +0 -149
  83. package/src/providers/capabilities.ts +0 -104
  84. package/src/providers/models.ts +0 -183
  85. package/src/providers/registry.ts +0 -71
  86. package/src/providers/resolve.ts +0 -78
  87. package/src/skills/installer.ts +0 -222
  88. package/src/skills/loader.ts +0 -88
  89. package/src/tools/askUser.ts +0 -92
  90. package/src/tools/bash.ts +0 -98
  91. package/src/tools/context.ts +0 -128
  92. package/src/tools/edit.ts +0 -67
  93. package/src/tools/exec.ts +0 -60
  94. package/src/tools/glob.ts +0 -39
  95. package/src/tools/grep.ts +0 -86
  96. package/src/tools/index.ts +0 -83
  97. package/src/tools/memory.ts +0 -53
  98. package/src/tools/processRegistry.ts +0 -77
  99. package/src/tools/read.ts +0 -42
  100. package/src/tools/sendFileToClient.ts +0 -55
  101. package/src/tools/skill.ts +0 -44
  102. package/src/tools/task.ts +0 -52
  103. package/src/tools/todo.ts +0 -36
  104. package/src/tools/todoStore.ts +0 -31
  105. package/src/tools/walk.ts +0 -44
  106. package/src/tools/web.ts +0 -145
  107. package/src/tools/worktree.ts +0 -145
  108. package/src/tools/write.ts +0 -40
  109. package/src/util/images.ts +0 -378
  110. package/src/util/limit.ts +0 -32
  111. package/src/version.ts +0 -13
@@ -1,42 +0,0 @@
1
- import { existsSync, readFileSync } from "node:fs";
2
- import { join } from "node:path";
3
- import { globalPaths, projectPaths } from "../config/paths.ts";
4
- import { walkFiles } from "../tools/walk.ts";
5
- import { parseFrontmatter } from "../commands/custom.ts";
6
-
7
- // A persona/behavior preset loaded from .privateer/output-styles/<name>.md. Its body
8
- // replaces the default tone section of the system prompt while the tool policy,
9
- // security stance, and environment grounding stay intact.
10
- export interface OutputStyle {
11
- name: string;
12
- description?: string;
13
- body: string;
14
- scope: "project" | "user";
15
- }
16
-
17
- function loadFromDir(dir: string, scope: "project" | "user"): OutputStyle[] {
18
- if (!existsSync(dir)) return [];
19
- const out: OutputStyle[] = [];
20
- for (const rel of walkFiles(dir)) {
21
- if (!rel.endsWith(".md")) continue;
22
- const { meta, body } = parseFrontmatter(readFileSync(join(dir, rel), "utf8"));
23
- out.push({
24
- name: rel.replace(/\.md$/, "").split("/").join(":"),
25
- description: meta.description,
26
- body: body.trim(),
27
- scope,
28
- });
29
- }
30
- return out;
31
- }
32
-
33
- export function loadOutputStyles(cwd: string = process.cwd()): OutputStyle[] {
34
- const byName = new Map<string, OutputStyle>();
35
- for (const s of loadFromDir(globalPaths().outputStyles, "user")) byName.set(s.name, s);
36
- for (const s of loadFromDir(projectPaths(cwd).outputStyles, "project")) byName.set(s.name, s);
37
- return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
38
- }
39
-
40
- export function findOutputStyle(name: string, cwd: string = process.cwd()): OutputStyle | undefined {
41
- return loadOutputStyles(cwd).find((s) => s.name === name);
42
- }
@@ -1,59 +0,0 @@
1
- import { execFileSync } from "node:child_process";
2
- import { walkFiles } from "../tools/walk.ts";
3
-
4
- // Lightweight, synchronous environment probes used to enrich the system prompt at
5
- // session start. Everything here fails soft: outside a git repo, or if `git` is
6
- // missing, the git block is simply omitted rather than throwing.
7
-
8
- function git(cwd: string, args: string[]): string | null {
9
- try {
10
- return execFileSync("git", args, {
11
- cwd,
12
- encoding: "utf8",
13
- timeout: 3_000,
14
- stdio: ["ignore", "pipe", "ignore"],
15
- }).trim();
16
- } catch {
17
- return null;
18
- }
19
- }
20
-
21
- export interface GitInfo {
22
- branch: string;
23
- status: string; // short porcelain, possibly truncated
24
- recent: string; // last few commit subjects
25
- }
26
-
27
- // A compact git snapshot, or null when cwd isn't a working tree.
28
- export function gitStatus(cwd: string): GitInfo | null {
29
- const inside = git(cwd, ["rev-parse", "--is-inside-work-tree"]);
30
- if (inside !== "true") return null;
31
-
32
- const branch = git(cwd, ["rev-parse", "--abbrev-ref", "HEAD"]) || "(detached)";
33
- const raw = git(cwd, ["status", "--porcelain"]) ?? "";
34
- const lines = raw ? raw.split("\n") : [];
35
- const status =
36
- lines.length === 0
37
- ? "(clean)"
38
- : lines.slice(0, 20).join("\n") +
39
- (lines.length > 20 ? `\n… (+${lines.length - 20} more)` : "");
40
- const recent = git(cwd, ["log", "--oneline", "-5"]) ?? "";
41
-
42
- return { branch, status, recent };
43
- }
44
-
45
- // A shallow snapshot of the project's files (respecting walk's skip list), capped
46
- // so the prompt stays small. Gives the model a sense of layout before it explores.
47
- export function dirSnapshot(cwd: string, limit = 40): string {
48
- let files: string[];
49
- try {
50
- files = walkFiles(cwd);
51
- } catch {
52
- return "";
53
- }
54
- if (files.length === 0) return "";
55
- files.sort();
56
- const shown = files.slice(0, limit);
57
- const more = files.length > limit ? `\n… (+${files.length - limit} more files)` : "";
58
- return shown.join("\n") + more;
59
- }
@@ -1,176 +0,0 @@
1
- import { existsSync, readFileSync } from "node:fs";
2
- import { join } from "node:path";
3
- import { gitStatus, dirSnapshot } from "./projectInfo.ts";
4
- import { loadMemoryContext } from "../memory/auto.ts";
5
-
6
- // The system prompt is assembled from modular sections: static segments first,
7
- // then a dynamic environment block. Static sections (identity, tone, tool
8
- // policy) come first so they stay byte-stable across turns and cache well; the
9
- // dynamic environment block (cwd, git status, snapshot) comes last. `buildSystemPrompt`
10
- // stays a pure synchronous string builder — the only I/O is reading project files and
11
- // the soft git/dir probes in projectInfo.ts.
12
-
13
- const IDENTITY = `You are Privateer, a provider-agnostic terminal coding agent. You help with software \
14
- engineering tasks directly from the user's terminal, with the same working style as a senior \
15
- engineer pairing over a shared shell.`;
16
-
17
- const TONE = `Tone and style:
18
- - Be concise and direct. Minimize preamble and postamble — no "Sure!", no "Here is what I'll do" \
19
- unless asked. Let your actions and their results speak.
20
- - Prefer doing over explaining. When a task is clear, use your tools to accomplish it rather than \
21
- describing how the user could.
22
- - Keep prose short. Answer the question that was asked; don't volunteer tangents.
23
- - When you finish a task, stop. Don't summarize work the user just watched you do.`;
24
-
25
- const SECURITY = `Security:
26
- - Assist with defensive security, debugging, and legitimate engineering. Refuse to help create or \
27
- improve malware, exploits aimed at systems the user doesn't own, or other clearly malicious uses.
28
- - Never expose or exfiltrate secrets. Don't print API keys or credentials you encounter.`;
29
-
30
- const TOOL_POLICY = `Using your tools:
31
- - Explore before you change: use 'glob' to find files by name and 'grep' to search contents. \
32
- Read a file with 'read' before editing it.
33
- - Prefer 'edit' (exact-string replace) over 'write' for changes to existing files; reserve 'write' \
34
- for new files or full rewrites.
35
- - Batch independent reads/searches rather than going one at a time.
36
- - For multi-step work, call 'todo' to lay out and track the plan; keep exactly one item \
37
- in_progress and mark items completed as you finish them. This keeps the user oriented.
38
- - For broad, open-ended search or investigation, delegate to a 'task' sub-agent so the details \
39
- stay out of the main conversation; it returns just a summary.
40
- - Use 'bash' for builds, tests, git, and other CLI work. Avoid long-running or interactive commands.
41
- - Each tool call is a separate model round-trip, so chain related shell steps into one \
42
- 'bash' call with '&&' rather than firing them one at a time (e.g. \
43
- 'git add -A && git commit -m "…" && git push', not three calls). Keep commands you need \
44
- to inspect the output of (a failing test, a diff you'll act on) separate.
45
- - Scope a commit to how the user asked. An unqualified "commit" / "commit and push" means the \
46
- whole working tree (stage all changes) — not just files you touched this turn. A scoped request \
47
- ("commit the README", "commit the screenshot") means stage only what that names. When an \
48
- unqualified commit would sweep in a lot of unrelated changes, say what you're including in one line \
49
- before doing it.
50
- - When you create a git commit, end the message with a blank line followed by this trailer so the \
51
- work is attributed to Privateer as a co-author:
52
- Co-Authored-By: Privateer <291203302+privateer-first-mate@users.noreply.github.com>
53
- - Use 'web_fetch' to read a known URL when the user provides one or you need current docs.
54
- - When the implementation direction is genuinely ambiguous and the choice materially shapes the \
55
- work — a structural/architectural fork, which library or data model to adopt, the scope of a \
56
- change — don't silently guess. Lay out the realistic approaches and call 'ask_user' to let the \
57
- user pick (2–4 options, most-recommended first, each with its trade-offs). Reserve it for \
58
- consequential forks: for small, reversible choices, just make the call and proceed. After the \
59
- user chooses, build that direction without re-litigating it.
60
- - To try a risky or exploratory approach without disturbing the main tree, use 'worktree' to spin \
61
- up an isolated branch + working copy, do the work there, and let the user compare its diff before \
62
- keeping or discarding it.
63
- - Mutating actions (write/edit/bash) may require user approval; that's expected — proceed and let \
64
- the gate handle it.`;
65
-
66
- const MEMORY = `Memory:
67
- - You have a persistent memory across sessions. When an index of saved memories is \
68
- present below, treat each line as something you already know; read the named .md file \
69
- with 'read' when an entry looks relevant to the current task.
70
- - Use the 'memory' tool to record durable facts worth remembering long-term: stable user \
71
- preferences, project conventions, and feedback on how to work — not transient details \
72
- about the current task. Prefer updating an existing memory (reuse its name) over \
73
- creating a near-duplicate. Default to project scope; use global only for facts that hold \
74
- across every project.`;
75
-
76
- const RECAP = `Recaps:
77
- - End every response with a single final line that begins with "recap: " — one plain-language \
78
- sentence summarizing what the user has accomplished so far this session (their goals and the \
79
- changes they've driven), not a restatement of what you just did. Keep it to one line; if nothing \
80
- has happened yet, recap the user's stated goal.`;
81
-
82
- const PLAN_MODE = `Plan mode is active. Your write, edit, and bash tools are disabled — do not attempt to \
83
- modify files or run commands. Investigate with read, glob, and grep, then present a clear, \
84
- step-by-step implementation plan as your final message. Do not start implementing; wait for the \
85
- user to approve the plan first.`;
86
-
87
- export interface SystemPromptOptions {
88
- cwd: string;
89
- model: string;
90
- // Optional output-style body: replaces the default tone/persona section.
91
- outputStyleBody?: string;
92
- // When true, append the plan-mode mandate (read-only, produce a plan).
93
- planMode?: boolean;
94
- }
95
-
96
- // System prompt for a `task` sub-agent: same environment grounding, but a read-only,
97
- // report-back mandate. It shares the parent's identity/security stance but swaps the
98
- // tool policy for the restricted subset.
99
- export function buildSubAgentPrompt(opts: SystemPromptOptions & { description: string }): string {
100
- return [
101
- IDENTITY,
102
- SECURITY,
103
- `You are running as a read-only sub-agent for the task: "${opts.description}".`,
104
- `You have read, glob, and grep only — you cannot modify files or run commands. Investigate ` +
105
- `thoroughly and efficiently, then return a concise, self-contained summary of your findings ` +
106
- `(reference concrete file paths and line numbers). Do not ask the user questions; you run ` +
107
- `autonomously and your final message is your whole report.`,
108
- `Environment:\n- cwd: ${opts.cwd}\n- platform: ${process.platform}`,
109
- ].join("\n\n");
110
- }
111
-
112
- // System prompt for a user-defined sub-agent: the agent's own instructions plus the
113
- // shared identity/security stance and an autonomous report-back mandate.
114
- export function buildAgentPrompt(
115
- opts: SystemPromptOptions & { description: string; instructions: string },
116
- ): string {
117
- return [
118
- IDENTITY,
119
- opts.instructions,
120
- SECURITY,
121
- `You are running as a sub-agent for the task: "${opts.description}". Work autonomously and ` +
122
- `return a concise, self-contained final report (reference concrete file paths). Do not ask ` +
123
- `the user questions — your final message is your whole report.`,
124
- `Environment:\n- cwd: ${opts.cwd}\n- platform: ${process.platform}`,
125
- ].join("\n\n");
126
- }
127
-
128
- export function buildSystemPrompt(opts: SystemPromptOptions): string {
129
- // An active output style replaces the default tone/persona section.
130
- const persona = opts.outputStyleBody?.trim() || TONE;
131
- const parts: string[] = [IDENTITY, persona, SECURITY, TOOL_POLICY, MEMORY, RECAP];
132
- if (opts.planMode) parts.push(PLAN_MODE);
133
-
134
- // --- Dynamic environment section ---
135
- const env: string[] = [
136
- `Environment:`,
137
- `- cwd: ${opts.cwd}`,
138
- `- model: ${opts.model}`,
139
- `- launched in: ${process.cwd()}`,
140
- `- platform: ${process.platform}`,
141
- `- date: ${new Date().toISOString().slice(0, 10)}`,
142
- `\nTreat cwd as the project scope: interpret relative paths from it and keep your ` +
143
- `exploration, searches, and edits inside it. The file tools are confined to cwd — a path ` +
144
- `that resolves outside it (an absolute path elsewhere, a sibling directory, or a '../' ` +
145
- `escape) is blocked unless the user has explicitly asked you to work there, in which case ` +
146
- `they'll be prompted to approve it. Don't reach outside cwd on your own; if a task seems to ` +
147
- `need a file outside it, ask the user rather than guessing.`,
148
- ];
149
-
150
- const git = gitStatus(opts.cwd);
151
- if (git) {
152
- env.push(`- git branch: ${git.branch}`);
153
- env.push(`\nGit status (porcelain):\n${git.status}`);
154
- if (git.recent) env.push(`\nRecent commits:\n${git.recent}`);
155
- }
156
-
157
- const snapshot = dirSnapshot(opts.cwd);
158
- if (snapshot) env.push(`\nProject files (partial):\n${snapshot}`);
159
-
160
- parts.push(env.join("\n"));
161
-
162
- // Project context file, our CLAUDE.md analog. Loaded last so user-authored
163
- // standing instructions carry the most weight.
164
- const ctxFile = join(opts.cwd, "PRIVATEER.md");
165
- if (existsSync(ctxFile)) {
166
- parts.push(`Project context from PRIVATEER.md:\n${readFileSync(ctxFile, "utf8").trim()}`);
167
- }
168
-
169
- // Recalled memory index, our auto-memory analog. Read a listed .md for full detail.
170
- const memory = loadMemoryContext(opts.cwd);
171
- if (memory) {
172
- parts.push(`Persistent memory (index — read a file for detail):\n${memory}`);
173
- }
174
-
175
- return parts.join("\n\n");
176
- }
@@ -1,399 +0,0 @@
1
- import {
2
- streamText,
3
- generateText,
4
- generateObject,
5
- stepCountIs,
6
- type ModelMessage,
7
- type ToolSet,
8
- } from "ai";
9
- import { z } from "zod";
10
- import { type EngineEvent, type UsageTotals, emptyUsage, addUsage } from "./events.ts";
11
- import { type RouteSet, selectRoute, requiredModalities } from "./router.ts";
12
- import { redactText } from "../util/redact.ts";
13
- import { describeError, type DescribedError } from "./errors.ts";
14
-
15
- // Structured shape for compaction so the summary preserves the parts that matter for
16
- // continuing the work, rather than a free-form blob.
17
- const CompactionSchema = z.object({
18
- goals: z.string().describe("The user's overall goals for this session."),
19
- decisions: z.array(z.string()).describe("Key decisions, approaches, and findings so far."),
20
- filesTouched: z.array(z.string()).describe("File paths created or modified, each with a short note."),
21
- openThreads: z.array(z.string()).describe("Unfinished tasks, next steps, and open questions."),
22
- });
23
-
24
- export function formatCompaction(o: z.infer<typeof CompactionSchema>): string {
25
- const list = (items: string[]) => (items.length ? items.map((i) => `- ${i}`).join("\n") : "- (none)");
26
- return [
27
- `Goals: ${o.goals}`,
28
- `Decisions:\n${list(o.decisions)}`,
29
- `Files touched:\n${list(o.filesTouched)}`,
30
- `Open threads:\n${list(o.openThreads)}`,
31
- ].join("\n\n");
32
- }
33
-
34
- export interface QueryEngineOptions {
35
- // The model routes for this session. `routes.default` is always used unless a
36
- // turn's data/shape selects a specialized route (see src/engine/router.ts).
37
- // Per-route flags (cacheControl/thinkingBudget) travel on each Route. Compaction
38
- // always runs on the default route.
39
- routes: RouteSet;
40
- system: string;
41
- tools: ToolSet;
42
- maxSteps: number;
43
- // Approx token budget; when the estimated context exceeds budget*ratio before a
44
- // turn, older history is summarized away. 0/undefined disables auto-compaction.
45
- contextBudget?: number;
46
- compactRatio?: number;
47
- }
48
-
49
- // Number of most-recent messages kept verbatim when compacting.
50
- const KEEP_RECENT = 6;
51
-
52
- // How many times to auto-retry a turn that failed transiently (rate limit, 5xx,
53
- // network) before any output streamed. Fatal errors (auth/billing/data-policy/bad
54
- // model) are never retried — describeError leaves their `retryable` flag unset.
55
- const MAX_RETRIES = 3;
56
-
57
- // The agent loop. Each `send` streams one user turn through the model, letting the
58
- // AI SDK run the multi-step tool loop internally (executing our tools' execute()),
59
- // while we translate the raw stream into normalized EngineEvents and accumulate usage.
60
- // History persists on the instance so follow-up turns keep context. A turn can be
61
- // interrupted via an AbortSignal; partial output is still persisted to history.
62
- export class QueryEngine {
63
- readonly messages: ModelMessage[] = [];
64
- usage: UsageTotals = emptyUsage();
65
-
66
- constructor(private readonly opts: QueryEngineOptions) {}
67
-
68
- // Current context-window occupancy: estimated tokens in history over the budget
69
- // that triggers compaction. Drives the Claude-Code-style "% of context" readout.
70
- // `budget` is 0 when auto-compaction is disabled.
71
- contextUsage(): { used: number; budget: number } {
72
- return { used: estimateTokens(this.messages), budget: this.opts.contextBudget ?? 0 };
73
- }
74
-
75
- async *send(
76
- userText: string,
77
- signal?: AbortSignal,
78
- attachments?: { data: string; mediaType: string; modality?: string }[],
79
- ): AsyncGenerator<EngineEvent, void, void> {
80
- // Auto-compact before the turn if the context has grown past the budget.
81
- if (this.shouldCompact()) {
82
- const res = await this.compact();
83
- if (res) yield { type: "compacted", before: res.before, after: res.after };
84
- }
85
-
86
- if (attachments && attachments.length > 0) {
87
- this.messages.push({
88
- role: "user",
89
- content: [
90
- { type: "text", text: userText },
91
- // Images go as image parts; documents/audio/video as generic file parts.
92
- ...attachments.map((a) =>
93
- a.modality === "image" || a.mediaType.startsWith("image/")
94
- ? ({ type: "image" as const, image: a.data, mediaType: a.mediaType })
95
- : ({ type: "file" as const, data: a.data, mediaType: a.mediaType })),
96
- ],
97
- });
98
- } else {
99
- this.messages.push({ role: "user", content: userText });
100
- }
101
-
102
- // Pick the model for this turn from its data/shape. Modality requirements are
103
- // sticky over the whole conversation so attachment history never gets replayed to
104
- // a model that can't accept it.
105
- const sel = selectRoute(this.opts.routes, {
106
- modalities: requiredModalities(this.messages),
107
- estTokens: estimateTokens(this.messages),
108
- promptChars: userText.length,
109
- });
110
- if (sel.name !== "default" || (sel.missing && sel.missing.length > 0)) {
111
- yield {
112
- type: "routed",
113
- route: sel.name,
114
- label: sel.route.label,
115
- reason: sel.reason,
116
- missing: sel.missing,
117
- };
118
- }
119
- const route = sel.route;
120
-
121
- let result;
122
- try {
123
- result = streamText({
124
- model: route.model,
125
- system: this.opts.system,
126
- messages: route.cacheControl ? withCacheBreakpoints(this.messages) : this.messages,
127
- tools: this.opts.tools,
128
- stopWhen: stepCountIs(this.opts.maxSteps),
129
- abortSignal: signal,
130
- // Re-place the rolling cache breakpoint on every internal tool-loop step.
131
- // streamText runs the multi-step loop itself, appending tool-call/result
132
- // messages between API calls; without this the breakpoint stays on the
133
- // pre-loop tail, so each step's accumulating tool output is re-sent at full
134
- // price. Marking the new last message each step caches the prefix the
135
- // previous step already sent. (No-op for non-Anthropic routes.)
136
- prepareStep: route.cacheControl
137
- ? ({ messages }) => ({ messages: withCacheBreakpoints(messages) })
138
- : undefined,
139
- providerOptions: route.thinkingBudget
140
- ? { anthropic: { thinking: { type: "enabled", budgetTokens: route.thinkingBudget } } }
141
- : undefined,
142
- });
143
- } catch (err) {
144
- const d = describeError(err);
145
- yield { type: "error", error: d.message, hint: d.hint, retryable: d.retryable };
146
- return;
147
- }
148
-
149
- // The AI SDK exposes several derived promises that reject lazily when the
150
- // stream errors. We await some below in their own try/catch, but any we
151
- // never touch would surface as an unhandled rejection — which Node dumps,
152
- // unredacted, to the terminal (scrambling the TUI and leaking the request
153
- // body). Attach no-op catches so a stream error stays inside our channel.
154
- for (const key of ["text", "steps", "warnings", "sources", "files", "reasoning"] as const) {
155
- const p = (result as unknown as Record<string, unknown>)[key];
156
- if (p && typeof (p as Promise<unknown>).then === "function") {
157
- (p as Promise<unknown>).catch(() => {});
158
- }
159
- }
160
-
161
- let assistantText = "";
162
- let aborted = false;
163
- // Track usage as steps finish so the UI can tick the token count up live,
164
- // instead of jumping only when the whole turn ends. `totalUsage` reconciles
165
- // the authoritative number at finish.
166
- const baseline = this.usage;
167
- let stepsUsage = emptyUsage();
168
-
169
- try {
170
- for await (const part of result.fullStream) {
171
- switch (part.type) {
172
- case "text-delta":
173
- if (part.text) {
174
- assistantText += part.text;
175
- yield { type: "text", text: part.text };
176
- }
177
- break;
178
- case "reasoning-delta":
179
- if (part.text) yield { type: "reasoning", text: part.text };
180
- break;
181
- case "tool-call":
182
- yield { type: "tool-call", id: part.toolCallId, name: part.toolName, input: part.input };
183
- break;
184
- case "tool-result":
185
- yield {
186
- type: "tool-result",
187
- id: part.toolCallId,
188
- name: part.toolName,
189
- output: (part as { output: unknown }).output,
190
- };
191
- break;
192
- case "tool-error":
193
- yield {
194
- type: "tool-error",
195
- id: part.toolCallId,
196
- name: part.toolName,
197
- error: errMsg((part as { error: unknown }).error),
198
- };
199
- break;
200
- case "finish-step": {
201
- const u = (part as { usage?: Partial<UsageTotals> }).usage;
202
- if (u) {
203
- stepsUsage = addUsage(stepsUsage, {
204
- inputTokens: u.inputTokens ?? 0,
205
- outputTokens: u.outputTokens ?? 0,
206
- totalTokens: u.totalTokens ?? 0,
207
- cachedInputTokens: u.cachedInputTokens ?? 0,
208
- });
209
- this.usage = addUsage(baseline, stepsUsage);
210
- yield { type: "usage", usage: this.usage, turn: stepsUsage };
211
- }
212
- yield { type: "step-finish" };
213
- break;
214
- }
215
- case "abort":
216
- aborted = true;
217
- break;
218
- case "error": {
219
- const d = describeError(part.error);
220
- yield { type: "error", error: d.message, hint: d.hint, retryable: d.retryable };
221
- break;
222
- }
223
- }
224
- }
225
- } catch (err) {
226
- if (signal?.aborted || isAbortError(err)) {
227
- aborted = true;
228
- } else {
229
- const d = describeError(err);
230
- yield { type: "error", error: d.message, hint: d.hint, retryable: d.retryable };
231
- return;
232
- }
233
- }
234
-
235
- // Persist the model's response so the next turn keeps context. On a clean finish
236
- // we use the SDK's structured messages; on an interrupt those may be unavailable,
237
- // so we fall back to a synthetic assistant message from the text we streamed.
238
- let persisted = false;
239
- try {
240
- const response = await result.response;
241
- if (response?.messages?.length) {
242
- this.messages.push(...response.messages);
243
- persisted = true;
244
- }
245
- } catch {
246
- /* aborted/errored before a response was assembled */
247
- }
248
- if (!persisted && assistantText.trim()) {
249
- this.messages.push({ role: "assistant", content: assistantText });
250
- }
251
-
252
- if (aborted) {
253
- yield { type: "aborted" };
254
- return;
255
- }
256
-
257
- const turnUsage = await result.totalUsage.catch(() => ({}) as Record<string, number>);
258
- const usage: UsageTotals = {
259
- inputTokens: turnUsage.inputTokens ?? 0,
260
- outputTokens: turnUsage.outputTokens ?? 0,
261
- totalTokens: turnUsage.totalTokens ?? 0,
262
- cachedInputTokens: turnUsage.cachedInputTokens ?? 0,
263
- };
264
- // Reconcile against the authoritative turn total. We already folded per-step
265
- // usage into this.usage live; rebase on the baseline so we don't double-count.
266
- // Fall back to the accumulated step usage if the provider omitted totalUsage.
267
- this.usage = addUsage(baseline, usage.totalTokens > 0 ? usage : stepsUsage);
268
-
269
- const finishReason = await result.finishReason.catch(() => "unknown");
270
- yield { type: "finish", usage, finishReason };
271
- }
272
-
273
- private shouldCompact(): boolean {
274
- const budget = this.opts.contextBudget;
275
- if (!budget) return false;
276
- const ratio = this.opts.compactRatio ?? 0.8;
277
- return this.messages.length > KEEP_RECENT && estimateTokens(this.messages) > budget * ratio;
278
- }
279
-
280
- // Summarize older history into a single briefing message, keeping the most recent
281
- // messages verbatim. Uses a schema-guided summary (goals / decisions / files /
282
- // open threads) so the structure survives, falling back to a plain-text summary if
283
- // structured output fails. The cut always lands on a `user` message so tool-call /
284
- // result pairs are never orphaned. Returns before/after token estimates, or null
285
- // when there's nothing worth compacting. Best-effort: failures leave history intact.
286
- async compact(): Promise<{ before: number; after: number } | null> {
287
- const before = estimateTokens(this.messages);
288
- const cut = safeCutIndex(this.messages, KEEP_RECENT);
289
- if (cut <= 0) return null;
290
-
291
- const older = this.messages.slice(0, cut);
292
- const recent = this.messages.slice(cut);
293
- const transcript = older.map((m) => `${m.role}: ${renderContent(m.content)}`).join("\n\n");
294
- const instruction =
295
- `Summarize the earlier part of this coding session so the work can continue without the ` +
296
- `full history. Be specific and terse.\n\n---\n${transcript}`;
297
-
298
- let summary: string;
299
- try {
300
- const { object } = await generateObject({
301
- model: this.opts.routes.default.model,
302
- schema: CompactionSchema,
303
- prompt: instruction,
304
- });
305
- summary = formatCompaction(object);
306
- } catch {
307
- // Some models/providers handle structured output poorly — fall back to text.
308
- try {
309
- const { text } = await generateText({ model: this.opts.routes.default.model, prompt: instruction });
310
- summary = text.trim();
311
- } catch {
312
- return null; // leave history untouched on failure
313
- }
314
- }
315
- if (!summary) return null;
316
-
317
- this.messages.length = 0;
318
- this.messages.push({ role: "user", content: `[Summary of earlier conversation]\n${summary}` });
319
- this.messages.push(...recent);
320
-
321
- return { before, after: estimateTokens(this.messages) };
322
- }
323
- }
324
-
325
- // Cheap heuristic token estimate (~4 chars/token) over serialized message content.
326
- export function estimateTokens(messages: ModelMessage[]): number {
327
- let chars = 0;
328
- for (const m of messages) chars += renderContent(m.content).length + m.role.length;
329
- return Math.ceil(chars / 4);
330
- }
331
-
332
- // Choose a cut so the kept tail starts on a `user` message — never orphaning a tool
333
- // result from its tool-call. Returns 0 when there's nothing safe to drop.
334
- function safeCutIndex(messages: ModelMessage[], minKeep: number): number {
335
- let cut = messages.length - minKeep;
336
- if (cut <= 0) return 0;
337
- while (cut < messages.length && messages[cut].role !== "user") cut++;
338
- return cut >= messages.length ? 0 : cut;
339
- }
340
-
341
- function renderContent(content: unknown): string {
342
- if (typeof content === "string") return content;
343
- if (Array.isArray(content)) {
344
- return content
345
- .map((p) => {
346
- const part = p as { type?: string; text?: string; toolName?: string };
347
- if (part.type === "text" && part.text) return part.text;
348
- if (part.type === "tool-call") return `[tool-call ${part.toolName ?? ""}]`;
349
- if (part.type === "tool-result") return `[tool-result ${part.toolName ?? ""}]`;
350
- return `[${part.type ?? "part"}]`;
351
- })
352
- .join(" ");
353
- }
354
- return "";
355
- }
356
-
357
- // Attach Anthropic ephemeral cache breakpoints. Anthropic caches the longest prefix
358
- // ending at a breakpoint, so we mark the first message (stable base: system + tools +
359
- // first turn) and the last message (rolling: grows with the conversation). Returns a
360
- // shallow copy so the stored history stays free of provider-specific annotations.
361
- const CACHE = { anthropic: { cacheControl: { type: "ephemeral" } } } as const;
362
-
363
- function withCacheBreakpoints(messages: ModelMessage[]): ModelMessage[] {
364
- if (messages.length === 0) return messages;
365
- const out = messages.slice();
366
- markBreakpoint(out, 0);
367
- if (out.length > 1) markBreakpoint(out, out.length - 1);
368
- return out;
369
- }
370
-
371
- function markBreakpoint(messages: ModelMessage[], i: number): void {
372
- const msg = messages[i] as { role: string; content: unknown };
373
- const parts =
374
- typeof msg.content === "string"
375
- ? [{ type: "text", text: msg.content }]
376
- : (msg.content as unknown[]).slice();
377
- if (parts.length === 0) return;
378
- const last = parts.length - 1;
379
- parts[last] = { ...(parts[last] as object), providerOptions: CACHE };
380
- messages[i] = { ...msg, content: parts } as unknown as ModelMessage;
381
- }
382
-
383
- function isAbortError(err: unknown): boolean {
384
- return err instanceof Error && (err.name === "AbortError" || /abort/i.test(err.message));
385
- }
386
-
387
- function errMsg(err: unknown): string {
388
- return redactText(rawErrMsg(err));
389
- }
390
-
391
- function rawErrMsg(err: unknown): string {
392
- if (err instanceof Error) return err.message;
393
- if (typeof err === "string") return err;
394
- try {
395
- return JSON.stringify(err);
396
- } catch {
397
- return String(err);
398
- }
399
- }