privateer-agent 0.1.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 (86) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +474 -0
  3. package/bin/privateer.mjs +11 -0
  4. package/package.json +74 -0
  5. package/src/agents/loader.ts +49 -0
  6. package/src/auth/privateer.ts +393 -0
  7. package/src/commands/custom.ts +75 -0
  8. package/src/commands/registry.ts +499 -0
  9. package/src/components/AgentGroupView.tsx +104 -0
  10. package/src/components/App.tsx +1376 -0
  11. package/src/components/ApprovalPrompt.tsx +38 -0
  12. package/src/components/Banner.tsx +58 -0
  13. package/src/components/Markdown.tsx +183 -0
  14. package/src/components/ModeHint.tsx +40 -0
  15. package/src/components/ModelPicker.tsx +269 -0
  16. package/src/components/Onboarding.tsx +203 -0
  17. package/src/components/PlanConfirm.tsx +37 -0
  18. package/src/components/PrivateerLogin.tsx +109 -0
  19. package/src/components/PromptInput.tsx +602 -0
  20. package/src/components/RewindPicker.tsx +69 -0
  21. package/src/components/Root.tsx +95 -0
  22. package/src/components/SessionPicker.tsx +64 -0
  23. package/src/components/StatusBar.tsx +121 -0
  24. package/src/components/TodoPanel.tsx +36 -0
  25. package/src/components/ToolCallView.tsx +109 -0
  26. package/src/components/Transcript.tsx +203 -0
  27. package/src/components/figures.ts +13 -0
  28. package/src/components/promptModel.ts +73 -0
  29. package/src/components/spinnerVerbs.ts +46 -0
  30. package/src/components/theme.ts +55 -0
  31. package/src/components/types.ts +34 -0
  32. package/src/components/useTeeShield.ts +104 -0
  33. package/src/components/useTerminalWidth.ts +24 -0
  34. package/src/components/useZdrShield.ts +126 -0
  35. package/src/config/load.ts +115 -0
  36. package/src/config/paths.ts +61 -0
  37. package/src/config/schema.ts +94 -0
  38. package/src/context/outputStyles.ts +42 -0
  39. package/src/context/projectInfo.ts +59 -0
  40. package/src/context/systemPrompt.ts +167 -0
  41. package/src/engine/QueryEngine.ts +399 -0
  42. package/src/engine/errors.ts +197 -0
  43. package/src/engine/events.ts +74 -0
  44. package/src/engine/router.ts +165 -0
  45. package/src/hooks/engine.ts +155 -0
  46. package/src/main.tsx +167 -0
  47. package/src/mcp/client.ts +236 -0
  48. package/src/mcp/oauth.ts +245 -0
  49. package/src/memory/auto.ts +146 -0
  50. package/src/memory/checkpoints.ts +227 -0
  51. package/src/memory/store.ts +127 -0
  52. package/src/permissions/danger.ts +56 -0
  53. package/src/permissions/gate.ts +38 -0
  54. package/src/permissions/mode.ts +39 -0
  55. package/src/permissions/protected.ts +29 -0
  56. package/src/permissions/uiGate.ts +73 -0
  57. package/src/providers/attestation.ts +149 -0
  58. package/src/providers/capabilities.ts +104 -0
  59. package/src/providers/catalog.ts +66 -0
  60. package/src/providers/models.ts +183 -0
  61. package/src/providers/registry.ts +71 -0
  62. package/src/providers/resolve.ts +78 -0
  63. package/src/remote/relayClient.ts +283 -0
  64. package/src/session.ts +264 -0
  65. package/src/tools/bash.ts +98 -0
  66. package/src/tools/context.ts +114 -0
  67. package/src/tools/edit.ts +67 -0
  68. package/src/tools/exec.ts +60 -0
  69. package/src/tools/glob.ts +39 -0
  70. package/src/tools/grep.ts +86 -0
  71. package/src/tools/index.ts +69 -0
  72. package/src/tools/memory.ts +53 -0
  73. package/src/tools/processRegistry.ts +77 -0
  74. package/src/tools/read.ts +42 -0
  75. package/src/tools/saveAttachment.ts +53 -0
  76. package/src/tools/task.ts +52 -0
  77. package/src/tools/todo.ts +36 -0
  78. package/src/tools/todoStore.ts +31 -0
  79. package/src/tools/walk.ts +44 -0
  80. package/src/tools/web.ts +145 -0
  81. package/src/tools/write.ts +40 -0
  82. package/src/util/attachmentStore.ts +72 -0
  83. package/src/util/images.ts +343 -0
  84. package/src/util/limit.ts +32 -0
  85. package/src/util/redact.ts +44 -0
  86. package/src/version.ts +13 -0
@@ -0,0 +1,399 @@
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
+ }
@@ -0,0 +1,197 @@
1
+ // Turn provider / AI-SDK errors into something a human can act on. These errors
2
+ // (e.g. `AI_APICallError`) carry structured fields well beyond `.message` —
3
+ // statusCode, responseBody, the request's model — but Node's default printer
4
+ // would dump the whole object (request messages and all) to the terminal,
5
+ // unredacted. We read the useful fields defensively and emit a short message
6
+ // plus an actionable hint, both run through secret redaction.
7
+
8
+ import { redactText } from "../util/redact.ts";
9
+
10
+ export interface DescribedError {
11
+ message: string; // short, user-facing, redacted
12
+ hint?: string; // actionable next step, rendered dim below the message
13
+ retryable?: boolean;
14
+ }
15
+
16
+ interface ErrorFacts {
17
+ statusCode?: number;
18
+ providerMessage?: string;
19
+ code?: string; // provider's machine-readable error code, e.g. "DAILY_CAP_HIT"
20
+ model?: string;
21
+ provider?: string;
22
+ }
23
+
24
+ const HOST_LABELS: Record<string, string> = {
25
+ "openrouter.ai": "OpenRouter",
26
+ "api.anthropic.com": "Anthropic",
27
+ "api.openai.com": "OpenAI",
28
+ "cloud-api.near.ai": "NEAR AI",
29
+ };
30
+
31
+ // The AI SDK wraps the real provider error: a retry sequence that exhausts its
32
+ // attempts throws AI_RetryError, whose `.lastError` is the APICallError that
33
+ // actually carries statusCode / responseBody / requestBodyValues. Without peeling
34
+ // that off we'd read undefined for every field and fall back to the wrapper's bare
35
+ // "Too Many Requests". Follow `.lastError` (and a `.cause` that looks like a
36
+ // richer error) until we reach the one with the useful fields.
37
+ function unwrap(err: unknown): unknown {
38
+ let cur = err;
39
+ for (let i = 0; i < 5; i++) {
40
+ if (!cur || typeof cur !== "object") break;
41
+ const e = cur as Record<string, unknown>;
42
+ const richer = e.statusCode == null && e.responseBody == null;
43
+ const inner = e.lastError ?? (richer ? e.cause : undefined);
44
+ if (!inner || inner === cur) break;
45
+ cur = inner;
46
+ }
47
+ return cur;
48
+ }
49
+
50
+ // Pull structured fields off an unknown error without trusting any one shape.
51
+ function extract(err: unknown): ErrorFacts {
52
+ const e = (err ?? {}) as Record<string, unknown>;
53
+ const statusCode = typeof e.statusCode === "number" ? e.statusCode : undefined;
54
+
55
+ // The provider's own message + machine code, preferred over the SDK's wrapper
56
+ // text. Providers disagree on shape: OpenAI/OpenRouter nest under `error`, while
57
+ // the Privateer account backend returns a flat `{ message, code }` (e.g. a daily
58
+ // usage cap). Read both shapes; keep the first message/code we find.
59
+ let providerMessage: string | undefined;
60
+ let code: string | undefined;
61
+ const readBody = (body: unknown) => {
62
+ const b = body as
63
+ | { error?: { message?: unknown; code?: unknown }; message?: unknown; code?: unknown }
64
+ | undefined;
65
+ if (!b || typeof b !== "object") return;
66
+ const msg = b.error?.message ?? b.message;
67
+ if (providerMessage == null && typeof msg === "string") providerMessage = msg;
68
+ const c = b.error?.code ?? b.code;
69
+ if (code == null && typeof c === "string") code = c;
70
+ };
71
+ readBody(e.data);
72
+ if (typeof e.responseBody === "string") {
73
+ try {
74
+ readBody(JSON.parse(e.responseBody));
75
+ } catch {
76
+ /* responseBody wasn't JSON — fall back to the wrapper message */
77
+ }
78
+ }
79
+
80
+ const reqBody = e.requestBodyValues as { model?: unknown } | undefined;
81
+ const model = typeof reqBody?.model === "string" ? reqBody.model : undefined;
82
+
83
+ let provider: string | undefined;
84
+ if (typeof e.url === "string") {
85
+ try {
86
+ provider = HOST_LABELS[new URL(e.url).host];
87
+ } catch {
88
+ /* not a URL */
89
+ }
90
+ }
91
+
92
+ return { statusCode, providerMessage, code, model, provider };
93
+ }
94
+
95
+ // Machine codes the Privateer backend returns for a hard account cap (daily /
96
+ // monthly message or token limit, or an empty balance). Exported so the provider
97
+ // fetch wrapper can recognise the same condition and rewrite the 429 to a
98
+ // non-retryable status — otherwise the AI SDK burns its full retry budget on a
99
+ // limit that won't clear by retrying. Keep this the single source of truth.
100
+ const CAP_CODE = /CAP|QUOTA|LIMIT_REACHED|INSUFFICIENT|TOP_?UP/i;
101
+
102
+ export function isAccountCapCode(code: string | null | undefined): boolean {
103
+ return typeof code === "string" && CAP_CODE.test(code);
104
+ }
105
+
106
+ function rawMessage(err: unknown): string {
107
+ if (err instanceof Error) return err.message;
108
+ if (typeof err === "string") return err;
109
+ try {
110
+ return JSON.stringify(err);
111
+ } catch {
112
+ return String(err);
113
+ }
114
+ }
115
+
116
+ // Map a provider error to a friendly message + hint. Falls back to the raw
117
+ // (redacted) message for anything we don't recognize, so nothing is swallowed.
118
+ export function describeError(err: unknown): DescribedError {
119
+ const inner = unwrap(err);
120
+ const facts = extract(inner);
121
+ const status = facts.statusCode;
122
+ const text = facts.providerMessage ?? rawMessage(inner);
123
+ const forModel = facts.model ? ` for ${facts.model}` : "";
124
+ const forProvider = facts.provider ? ` for ${facts.provider}` : "";
125
+
126
+ const out = (d: DescribedError): DescribedError => ({
127
+ message: redactText(d.message),
128
+ hint: d.hint ? redactText(d.hint) : undefined,
129
+ retryable: d.retryable,
130
+ });
131
+
132
+ // OpenRouter: the account's data-policy / guardrail settings exclude every
133
+ // provider that could serve this model. The phrasing is distinctive, so match on
134
+ // it regardless of status — OpenRouter returns this as a 404 *or* a 403, and the
135
+ // 403 must not fall through to the generic "authentication failed" branch below.
136
+ // This is a "you must act" error: never retried (no `retryable` flag).
137
+ if (/data[- ]?policy|guardrail|no endpoints?\b/i.test(text)) {
138
+ return out({
139
+ message: `No provider endpoint matches your data-policy settings${forModel}.`,
140
+ hint: "Enable providers at https://openrouter.ai/settings/privacy, or pick a different model with /model.",
141
+ });
142
+ }
143
+ // Privateer account usage cap (daily/monthly message or token limit). The
144
+ // backend returns this as a 429 with a machine `code` like DAILY_CAP_HIT and a
145
+ // ready-to-show message ("Daily message limit of 25 reached. Upgrade or top up
146
+ // to continue."). Unlike a transient rate limit, retrying never helps — the user
147
+ // must upgrade, top up, or switch providers — so surface the backend's own
148
+ // message verbatim and do NOT mark it retryable (which would invite a retry).
149
+ const capText = /limit of .* reached|upgrade or top ?up|usage limit reached/i.test(text);
150
+ if (isAccountCapCode(facts.code) || (status === 429 && capText)) {
151
+ return out({
152
+ message: facts.providerMessage ?? text,
153
+ hint: "Upgrade or top up your Privateer account, or run /provider to use your own API key.",
154
+ });
155
+ }
156
+ if (status === 401 || status === 403) {
157
+ return out({
158
+ message: `Authentication failed${forProvider} (${status}).`,
159
+ hint: "Check the API key — run /provider, or set the provider's API key env var.",
160
+ });
161
+ }
162
+ if (status === 402) {
163
+ return out({
164
+ message: `Request rejected for billing reasons${forProvider} (402).`,
165
+ hint: "Check your account credits or billing.",
166
+ });
167
+ }
168
+ if (status === 404) {
169
+ return out({
170
+ message: `Model not found${forModel} (404).`,
171
+ hint: "Check the model id — run /model to switch.",
172
+ });
173
+ }
174
+ if (status === 429) {
175
+ return out({
176
+ message: `Rate limited${forProvider} (429).`,
177
+ hint: "Wait a moment and try again.",
178
+ retryable: true,
179
+ });
180
+ }
181
+ if (status != null && status >= 500) {
182
+ return out({
183
+ message: `Provider error${forProvider} (${status}).`,
184
+ hint: "Usually transient — retry shortly.",
185
+ retryable: true,
186
+ });
187
+ }
188
+ if (/fetch failed|ENOTFOUND|ECONNREFUSED|ETIMEDOUT|EAI_AGAIN|network/i.test(text)) {
189
+ return out({
190
+ message: "Network error reaching the provider.",
191
+ hint: "Check your connection and try again.",
192
+ retryable: true,
193
+ });
194
+ }
195
+
196
+ return out({ message: text });
197
+ }
@@ -0,0 +1,74 @@
1
+ import type { RouteName, Modality } from "./router.ts";
2
+
3
+ // Normalized events the engine emits while streaming a turn. The UI (and the
4
+ // headless print path) consume these without knowing anything about the provider
5
+ // or the AI SDK's internal stream-part shapes.
6
+
7
+ export interface UsageTotals {
8
+ inputTokens: number;
9
+ outputTokens: number;
10
+ totalTokens: number;
11
+ // Cache-read input tokens (a subset of input, billed at a fraction of the base
12
+ // rate). Providers differ on whether `inputTokens` already includes these — see
13
+ // `effectiveTokens`, which handles both conventions.
14
+ cachedInputTokens: number;
15
+ }
16
+
17
+ export type EngineEvent =
18
+ | { type: "text"; text: string }
19
+ | { type: "reasoning"; text: string }
20
+ | { type: "tool-call"; id: string; name: string; input: unknown }
21
+ | { type: "tool-result"; id: string; name: string; output: unknown }
22
+ | { type: "tool-error"; id: string; name: string; error: string }
23
+ | { type: "step-finish" }
24
+ // `usage` is the cumulative session total; `turn` is just this turn's accumulation
25
+ // so far. Both emitted live as steps finish so the UI can show either.
26
+ | { type: "usage"; usage: UsageTotals; turn: UsageTotals }
27
+ | { type: "aborted" }
28
+ | { type: "compacted"; before: number; after: number }
29
+ // The router switched this turn to a non-default model. `missing` lists modalities
30
+ // the chosen model can't accept (set when no configured model fully covers the turn).
31
+ | { type: "routed"; route: RouteName; label: string; reason?: string; missing?: Modality[] }
32
+ | { type: "finish"; usage: UsageTotals; finishReason: string }
33
+ // A transient failure (rate limit, 5xx, network) is being retried automatically
34
+ // before any output streamed. `attempt`/`max` are 1-based for display; `reason` is
35
+ // the redacted error message that triggered the retry.
36
+ | { type: "retrying"; attempt: number; max: number; delayMs: number; reason: string }
37
+ // `error` is the short user-facing message; `hint` is an optional actionable
38
+ // next step rendered dim beneath it. Both are already secret-redacted.
39
+ | { type: "error"; error: string; hint?: string; retryable?: boolean };
40
+
41
+ export const emptyUsage = (): UsageTotals => ({
42
+ inputTokens: 0,
43
+ outputTokens: 0,
44
+ totalTokens: 0,
45
+ cachedInputTokens: 0,
46
+ });
47
+
48
+ export function addUsage(a: UsageTotals, b: Partial<UsageTotals>): UsageTotals {
49
+ return {
50
+ inputTokens: (a.inputTokens ?? 0) + (b.inputTokens ?? 0),
51
+ outputTokens: (a.outputTokens ?? 0) + (b.outputTokens ?? 0),
52
+ totalTokens: (a.totalTokens ?? 0) + (b.totalTokens ?? 0),
53
+ cachedInputTokens: (a.cachedInputTokens ?? 0) + (b.cachedInputTokens ?? 0),
54
+ };
55
+ }
56
+
57
+ // Anthropic bills a cache read at ~10% of the base input rate. Re-price the cached
58
+ // portion at that rate to estimate what was actually billed, rather than the raw
59
+ // `totalTokens` which counts every re-sent cached token at full weight.
60
+ const CACHE_READ_RATE = 0.1;
61
+
62
+ // Effective (billed-weighted) token estimate. Providers disagree on whether
63
+ // `inputTokens` already includes `cachedInputTokens`: OpenRouter's `prompt_tokens`
64
+ // includes them (cached ≤ input), Anthropic's `input_tokens` counts only fresh
65
+ // tokens (cached reported separately, so cached may exceed input). We detect which
66
+ // convention applies and discount the cached portion either way. With no cache
67
+ // hits this collapses to `inputTokens + outputTokens` (== totalTokens).
68
+ export function effectiveTokens(u: UsageTotals): number {
69
+ const cached = u.cachedInputTokens ?? 0;
70
+ const input = u.inputTokens ?? 0;
71
+ const inputIncludesCached = cached <= input;
72
+ const fullPriceInput = inputIncludesCached ? input - cached : input;
73
+ return Math.round(fullPriceInput + cached * CACHE_READ_RATE + (u.outputTokens ?? 0));
74
+ }