ashlrcode 1.0.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 (133) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +295 -0
  3. package/package.json +46 -0
  4. package/src/__tests__/branded-types.test.ts +47 -0
  5. package/src/__tests__/context.test.ts +163 -0
  6. package/src/__tests__/cost-tracker.test.ts +274 -0
  7. package/src/__tests__/cron.test.ts +197 -0
  8. package/src/__tests__/dream.test.ts +204 -0
  9. package/src/__tests__/error-handler.test.ts +192 -0
  10. package/src/__tests__/features.test.ts +69 -0
  11. package/src/__tests__/file-history.test.ts +177 -0
  12. package/src/__tests__/hooks.test.ts +145 -0
  13. package/src/__tests__/keybindings.test.ts +159 -0
  14. package/src/__tests__/model-patches.test.ts +82 -0
  15. package/src/__tests__/permissions-rules.test.ts +121 -0
  16. package/src/__tests__/permissions.test.ts +108 -0
  17. package/src/__tests__/project-config.test.ts +63 -0
  18. package/src/__tests__/retry.test.ts +321 -0
  19. package/src/__tests__/router.test.ts +158 -0
  20. package/src/__tests__/session-compact.test.ts +191 -0
  21. package/src/__tests__/session.test.ts +145 -0
  22. package/src/__tests__/skill-registry.test.ts +130 -0
  23. package/src/__tests__/speculation.test.ts +196 -0
  24. package/src/__tests__/tasks-v2.test.ts +267 -0
  25. package/src/__tests__/telemetry.test.ts +149 -0
  26. package/src/__tests__/tool-executor.test.ts +141 -0
  27. package/src/__tests__/tool-registry.test.ts +166 -0
  28. package/src/__tests__/undercover.test.ts +93 -0
  29. package/src/__tests__/workflow.test.ts +195 -0
  30. package/src/agent/async-context.ts +64 -0
  31. package/src/agent/context.ts +245 -0
  32. package/src/agent/cron.ts +189 -0
  33. package/src/agent/dream.ts +165 -0
  34. package/src/agent/error-handler.ts +108 -0
  35. package/src/agent/ipc.ts +256 -0
  36. package/src/agent/kairos.ts +207 -0
  37. package/src/agent/loop.ts +314 -0
  38. package/src/agent/model-patches.ts +68 -0
  39. package/src/agent/speculation.ts +219 -0
  40. package/src/agent/sub-agent.ts +125 -0
  41. package/src/agent/system-prompt.ts +231 -0
  42. package/src/agent/team.ts +220 -0
  43. package/src/agent/tool-executor.ts +162 -0
  44. package/src/agent/workflow.ts +189 -0
  45. package/src/agent/worktree-manager.ts +86 -0
  46. package/src/autopilot/queue.ts +186 -0
  47. package/src/autopilot/scanner.ts +245 -0
  48. package/src/autopilot/types.ts +58 -0
  49. package/src/bridge/bridge-client.ts +57 -0
  50. package/src/bridge/bridge-server.ts +81 -0
  51. package/src/cli.ts +1120 -0
  52. package/src/config/features.ts +51 -0
  53. package/src/config/git.ts +137 -0
  54. package/src/config/hooks.ts +201 -0
  55. package/src/config/permissions.ts +251 -0
  56. package/src/config/project-config.ts +63 -0
  57. package/src/config/remote-settings.ts +163 -0
  58. package/src/config/settings-sync.ts +170 -0
  59. package/src/config/settings.ts +113 -0
  60. package/src/config/undercover.ts +76 -0
  61. package/src/config/upgrade-notice.ts +65 -0
  62. package/src/mcp/client.ts +197 -0
  63. package/src/mcp/manager.ts +125 -0
  64. package/src/mcp/oauth.ts +252 -0
  65. package/src/mcp/types.ts +61 -0
  66. package/src/persistence/memory.ts +129 -0
  67. package/src/persistence/session.ts +289 -0
  68. package/src/planning/plan-mode.ts +128 -0
  69. package/src/planning/plan-tools.ts +138 -0
  70. package/src/providers/anthropic.ts +177 -0
  71. package/src/providers/cost-tracker.ts +184 -0
  72. package/src/providers/retry.ts +264 -0
  73. package/src/providers/router.ts +159 -0
  74. package/src/providers/types.ts +79 -0
  75. package/src/providers/xai.ts +217 -0
  76. package/src/repl.tsx +1384 -0
  77. package/src/setup.ts +119 -0
  78. package/src/skills/loader.ts +78 -0
  79. package/src/skills/registry.ts +78 -0
  80. package/src/skills/types.ts +11 -0
  81. package/src/state/file-history.ts +264 -0
  82. package/src/telemetry/event-log.ts +116 -0
  83. package/src/tools/agent.ts +133 -0
  84. package/src/tools/ask-user.ts +229 -0
  85. package/src/tools/bash.ts +146 -0
  86. package/src/tools/config.ts +147 -0
  87. package/src/tools/diff.ts +137 -0
  88. package/src/tools/file-edit.ts +123 -0
  89. package/src/tools/file-read.ts +82 -0
  90. package/src/tools/file-write.ts +82 -0
  91. package/src/tools/glob.ts +76 -0
  92. package/src/tools/grep.ts +187 -0
  93. package/src/tools/ls.ts +77 -0
  94. package/src/tools/lsp.ts +375 -0
  95. package/src/tools/mcp-resources.ts +83 -0
  96. package/src/tools/mcp-tool.ts +47 -0
  97. package/src/tools/memory.ts +148 -0
  98. package/src/tools/notebook-edit.ts +133 -0
  99. package/src/tools/peers.ts +113 -0
  100. package/src/tools/powershell.ts +83 -0
  101. package/src/tools/registry.ts +114 -0
  102. package/src/tools/send-message.ts +75 -0
  103. package/src/tools/sleep.ts +50 -0
  104. package/src/tools/snip.ts +143 -0
  105. package/src/tools/tasks.ts +349 -0
  106. package/src/tools/team.ts +309 -0
  107. package/src/tools/todo-write.ts +93 -0
  108. package/src/tools/tool-search.ts +83 -0
  109. package/src/tools/types.ts +52 -0
  110. package/src/tools/web-browser.ts +263 -0
  111. package/src/tools/web-fetch.ts +118 -0
  112. package/src/tools/web-search.ts +107 -0
  113. package/src/tools/workflow.ts +188 -0
  114. package/src/tools/worktree.ts +143 -0
  115. package/src/types/branded.ts +22 -0
  116. package/src/ui/App.tsx +184 -0
  117. package/src/ui/BuddyPanel.tsx +52 -0
  118. package/src/ui/PermissionPrompt.tsx +29 -0
  119. package/src/ui/banner.ts +217 -0
  120. package/src/ui/buddy-ai.ts +108 -0
  121. package/src/ui/buddy.ts +466 -0
  122. package/src/ui/context-bar.ts +60 -0
  123. package/src/ui/effort.ts +65 -0
  124. package/src/ui/keybindings.ts +143 -0
  125. package/src/ui/markdown.ts +271 -0
  126. package/src/ui/message-renderer.ts +73 -0
  127. package/src/ui/mode.ts +80 -0
  128. package/src/ui/notifications.ts +57 -0
  129. package/src/ui/speech-bubble.ts +95 -0
  130. package/src/ui/spinner.ts +116 -0
  131. package/src/ui/theme.ts +98 -0
  132. package/src/version.ts +5 -0
  133. package/src/voice/voice-mode.ts +169 -0
@@ -0,0 +1,177 @@
1
+ /**
2
+ * Anthropic Claude provider — uses the official Anthropic SDK.
3
+ * Fallback provider for when Claude API access is available.
4
+ */
5
+
6
+ import Anthropic from "@anthropic-ai/sdk";
7
+ import type {
8
+ Provider,
9
+ ProviderConfig,
10
+ ProviderRequest,
11
+ StreamEvent,
12
+ } from "./types.ts";
13
+ import { withRetry } from "./retry.ts";
14
+
15
+ export function createAnthropicProvider(config: ProviderConfig): Provider {
16
+ const client = new Anthropic({
17
+ apiKey: config.apiKey,
18
+ ...(config.baseURL ? { baseURL: config.baseURL } : {}),
19
+ });
20
+
21
+ return {
22
+ name: "anthropic",
23
+ config,
24
+ pricing: [3.0, 15.0], // Sonnet 4.6 per million tokens
25
+
26
+ async *stream(request: ProviderRequest): AsyncGenerator<StreamEvent> {
27
+ const tools: Anthropic.Tool[] = request.tools.map((t) => ({
28
+ name: t.name,
29
+ description: t.description,
30
+ input_schema: t.input_schema as Anthropic.Tool.InputSchema,
31
+ }));
32
+
33
+ const messages: Anthropic.MessageParam[] = request.messages.map(
34
+ convertMessage
35
+ );
36
+
37
+ // Use withRetry to create the stream, then iterate it.
38
+ // Anthropic SDK errors can surface during both creation and iteration,
39
+ // so we wrap the entire stream lifecycle in a retry-able unit.
40
+ const createAndConsumeStream = async (): Promise<StreamEvent[]> => {
41
+ const events: StreamEvent[] = [];
42
+ const stream = client.messages.stream({
43
+ model: config.model,
44
+ system: request.systemPrompt,
45
+ messages,
46
+ tools: tools.length > 0 ? tools : undefined,
47
+ max_tokens: request.maxTokens ?? config.maxTokens ?? 8192,
48
+ });
49
+
50
+ for await (const event of stream) {
51
+ switch (event.type) {
52
+ case "content_block_delta": {
53
+ const delta = event.delta;
54
+ if (delta.type === "text_delta") {
55
+ events.push({ type: "text_delta", text: delta.text });
56
+ } else if (delta.type === "input_json_delta") {
57
+ events.push({
58
+ type: "tool_call_delta",
59
+ text: delta.partial_json,
60
+ });
61
+ }
62
+ break;
63
+ }
64
+
65
+ case "content_block_start": {
66
+ const block = event.content_block;
67
+ if (block.type === "tool_use") {
68
+ events.push({
69
+ type: "tool_call_start",
70
+ toolCall: { id: block.id, name: block.name },
71
+ });
72
+ }
73
+ break;
74
+ }
75
+
76
+ case "content_block_stop":
77
+ break;
78
+
79
+ case "message_delta": {
80
+ const delta = event.delta;
81
+ events.push({
82
+ type: "message_end",
83
+ stopReason:
84
+ delta.stop_reason === "tool_use"
85
+ ? "tool_use"
86
+ : delta.stop_reason === "max_tokens"
87
+ ? "max_tokens"
88
+ : "end_turn",
89
+ usage: {
90
+ inputTokens: 0,
91
+ outputTokens: event.usage?.output_tokens ?? 0,
92
+ },
93
+ });
94
+ break;
95
+ }
96
+
97
+ case "message_start": {
98
+ if (event.message.usage) {
99
+ events.push({
100
+ type: "usage",
101
+ usage: {
102
+ inputTokens: event.message.usage.input_tokens,
103
+ outputTokens: event.message.usage.output_tokens,
104
+ },
105
+ });
106
+ }
107
+ break;
108
+ }
109
+ }
110
+ }
111
+
112
+ // Extract completed tool calls from the final message
113
+ const finalMessage = await stream.finalMessage();
114
+ for (const block of finalMessage.content) {
115
+ if (block.type === "tool_use") {
116
+ events.push({
117
+ type: "tool_call_end",
118
+ toolCall: {
119
+ id: block.id,
120
+ name: block.name,
121
+ input: block.input as Record<string, unknown>,
122
+ },
123
+ });
124
+ }
125
+ }
126
+
127
+ return events;
128
+ };
129
+
130
+ const events = await withRetry(createAndConsumeStream, {
131
+ providerName: "anthropic",
132
+ });
133
+
134
+ for (const event of events) {
135
+ yield event;
136
+ }
137
+ },
138
+ };
139
+ }
140
+
141
+ function convertMessage(
142
+ msg: ProviderRequest["messages"][number]
143
+ ): Anthropic.MessageParam {
144
+ if (typeof msg.content === "string") {
145
+ return { role: msg.role === "tool" ? "user" : msg.role, content: msg.content };
146
+ }
147
+
148
+ const blocks: Anthropic.ContentBlockParam[] = msg.content
149
+ .filter((b) => b.type !== "image_url") // Anthropic uses different image format
150
+ .map((b) => {
151
+ switch (b.type) {
152
+ case "text":
153
+ return { type: "text" as const, text: b.text };
154
+ case "tool_use":
155
+ return {
156
+ type: "tool_use" as const,
157
+ id: b.id,
158
+ name: b.name,
159
+ input: b.input,
160
+ };
161
+ case "tool_result":
162
+ return {
163
+ type: "tool_result" as const,
164
+ tool_use_id: b.tool_use_id,
165
+ content: b.content,
166
+ is_error: b.is_error,
167
+ };
168
+ default:
169
+ return { type: "text" as const, text: "" };
170
+ }
171
+ }).filter(b => b.type !== "text" || b.text !== "");
172
+
173
+ return {
174
+ role: msg.role === "tool" ? "user" : msg.role,
175
+ content: blocks,
176
+ };
177
+ }
@@ -0,0 +1,184 @@
1
+ /**
2
+ * Detailed cost tracking — per-provider, per-model pricing with reasoning tokens.
3
+ */
4
+
5
+ interface ModelPricing {
6
+ inputPerMillion: number;
7
+ outputPerMillion: number;
8
+ reasoningPerMillion?: number; // Some models charge extra for reasoning tokens
9
+ }
10
+
11
+ // Pricing tables (USD per million tokens)
12
+ const PRICING: Record<string, ModelPricing> = {
13
+ // xAI
14
+ "grok-4-1-fast-reasoning": {
15
+ inputPerMillion: 0.2,
16
+ outputPerMillion: 0.5,
17
+ },
18
+ "grok-4-0314": { inputPerMillion: 2.0, outputPerMillion: 4.0 },
19
+ "grok-3-fast": { inputPerMillion: 0.1, outputPerMillion: 0.3 },
20
+ // Anthropic
21
+ "claude-opus-4-6-20250514": {
22
+ inputPerMillion: 15.0,
23
+ outputPerMillion: 75.0,
24
+ },
25
+ "claude-sonnet-4-6-20250514": {
26
+ inputPerMillion: 3.0,
27
+ outputPerMillion: 15.0,
28
+ },
29
+ "claude-haiku-4-5-20251001": {
30
+ inputPerMillion: 0.8,
31
+ outputPerMillion: 4.0,
32
+ },
33
+ // OpenAI
34
+ "gpt-4o": { inputPerMillion: 2.5, outputPerMillion: 10.0 },
35
+ "gpt-4o-mini": { inputPerMillion: 0.15, outputPerMillion: 0.6 },
36
+ o1: {
37
+ inputPerMillion: 15.0,
38
+ outputPerMillion: 60.0,
39
+ reasoningPerMillion: 60.0,
40
+ },
41
+ "o1-mini": {
42
+ inputPerMillion: 3.0,
43
+ outputPerMillion: 12.0,
44
+ reasoningPerMillion: 12.0,
45
+ },
46
+ // DeepSeek
47
+ "deepseek-chat": { inputPerMillion: 0.14, outputPerMillion: 0.28 },
48
+ "deepseek-reasoner": {
49
+ inputPerMillion: 0.55,
50
+ outputPerMillion: 2.19,
51
+ reasoningPerMillion: 2.19,
52
+ },
53
+ // Groq (free tier, nominal costs)
54
+ "llama-3.3-70b-versatile": { inputPerMillion: 0.0, outputPerMillion: 0.0 },
55
+ // Local (Ollama)
56
+ "llama3.2": { inputPerMillion: 0.0, outputPerMillion: 0.0 },
57
+ };
58
+
59
+ function getPricing(model: string): ModelPricing {
60
+ // Exact match first
61
+ if (PRICING[model]) return PRICING[model];
62
+ // Partial match (model might have date suffix)
63
+ for (const [key, pricing] of Object.entries(PRICING)) {
64
+ if (model.startsWith(key) || key.startsWith(model)) return pricing;
65
+ }
66
+ // Unknown model — assume moderate pricing
67
+ return { inputPerMillion: 1.0, outputPerMillion: 3.0 };
68
+ }
69
+
70
+ export interface TokenUsageTotals {
71
+ inputTokens: number;
72
+ outputTokens: number;
73
+ reasoningTokens: number;
74
+ }
75
+
76
+ export interface ProviderCostEntry {
77
+ provider: string;
78
+ model: string;
79
+ usage: TokenUsageTotals;
80
+ costUSD: number;
81
+ calls: number;
82
+ }
83
+
84
+ export class CostTracker {
85
+ private entries = new Map<string, ProviderCostEntry>();
86
+
87
+ /** Record token usage for a single API call */
88
+ record(
89
+ provider: string,
90
+ model: string,
91
+ usage: Partial<TokenUsageTotals>
92
+ ): void {
93
+ const key = `${provider}:${model}`;
94
+ const existing = this.entries.get(key) ?? {
95
+ provider,
96
+ model,
97
+ usage: { inputTokens: 0, outputTokens: 0, reasoningTokens: 0 },
98
+ costUSD: 0,
99
+ calls: 0,
100
+ };
101
+
102
+ existing.usage.inputTokens += usage.inputTokens ?? 0;
103
+ existing.usage.outputTokens += usage.outputTokens ?? 0;
104
+ existing.usage.reasoningTokens += usage.reasoningTokens ?? 0;
105
+ existing.calls++;
106
+
107
+ // Recalculate cost from totals to avoid floating-point drift
108
+ const pricing = getPricing(model);
109
+ existing.costUSD =
110
+ (existing.usage.inputTokens / 1_000_000) * pricing.inputPerMillion +
111
+ (existing.usage.outputTokens / 1_000_000) * pricing.outputPerMillion +
112
+ (existing.usage.reasoningTokens / 1_000_000) *
113
+ (pricing.reasoningPerMillion ?? pricing.outputPerMillion);
114
+
115
+ this.entries.set(key, existing);
116
+ }
117
+
118
+ /** Get total cost across all providers */
119
+ get totalCostUSD(): number {
120
+ let total = 0;
121
+ for (const entry of this.entries.values()) total += entry.costUSD;
122
+ return total;
123
+ }
124
+
125
+ /** Get total tokens */
126
+ get totalTokens(): TokenUsageTotals {
127
+ const total: TokenUsageTotals = {
128
+ inputTokens: 0,
129
+ outputTokens: 0,
130
+ reasoningTokens: 0,
131
+ };
132
+ for (const entry of this.entries.values()) {
133
+ total.inputTokens += entry.usage.inputTokens;
134
+ total.outputTokens += entry.usage.outputTokens;
135
+ total.reasoningTokens += entry.usage.reasoningTokens;
136
+ }
137
+ return total;
138
+ }
139
+
140
+ /** Get per-provider breakdown */
141
+ getBreakdown(): ProviderCostEntry[] {
142
+ return Array.from(this.entries.values());
143
+ }
144
+
145
+ /** Format a detailed cost summary */
146
+ formatSummary(): string {
147
+ const total = this.totalTokens;
148
+ const entries = this.getBreakdown();
149
+
150
+ const lines: string[] = [`Cost: $${this.totalCostUSD.toFixed(4)}`];
151
+ lines.push(
152
+ `Tokens: ${formatTokens(total.inputTokens)} in / ${formatTokens(total.outputTokens)} out${total.reasoningTokens > 0 ? ` / ${formatTokens(total.reasoningTokens)} reasoning` : ""}`
153
+ );
154
+
155
+ if (entries.length > 1) {
156
+ lines.push("Per provider:");
157
+ for (const e of entries) {
158
+ lines.push(
159
+ ` ${e.provider}:${e.model} — $${e.costUSD.toFixed(4)} (${e.calls} calls)`
160
+ );
161
+ }
162
+ }
163
+
164
+ return lines.join("\n");
165
+ }
166
+
167
+ // ── Legacy compat: expose the same shape the old CostTracker interface had ──
168
+
169
+ get totalInputTokens(): number {
170
+ return this.totalTokens.inputTokens;
171
+ }
172
+ get totalOutputTokens(): number {
173
+ return this.totalTokens.outputTokens;
174
+ }
175
+ get totalReasoningTokens(): number {
176
+ return this.totalTokens.reasoningTokens;
177
+ }
178
+ }
179
+
180
+ function formatTokens(n: number): string {
181
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
182
+ if (n >= 1_000) return `${(n / 1_000).toFixed(0)}K`;
183
+ return `${n}`;
184
+ }
@@ -0,0 +1,264 @@
1
+ /**
2
+ * Retry logic — exponential backoff with jitter for API calls.
3
+ * Circuit breaker pattern to stop hammering a failing provider.
4
+ *
5
+ * Centralizes retry behavior previously duplicated across xai.ts and anthropic.ts.
6
+ */
7
+
8
+ import { categorizeError, type CategorizedError } from "../agent/error-handler.ts";
9
+
10
+ // ── Retry Config ──────────────────────────────────────────────────────
11
+
12
+ export interface RetryConfig {
13
+ /** Max retry attempts per error category */
14
+ maxRetriesRateLimit: number;
15
+ maxRetriesNetwork: number;
16
+ maxRetriesServer: number;
17
+ /** Base delay in ms before first retry (doubles each attempt) */
18
+ baseDelayMs: number;
19
+ /** Absolute max delay cap */
20
+ maxDelayMs: number;
21
+ /** Provider name for log messages */
22
+ providerName: string;
23
+ }
24
+
25
+ const DEFAULT_CONFIG: RetryConfig = {
26
+ maxRetriesRateLimit: 3,
27
+ maxRetriesNetwork: 2,
28
+ maxRetriesServer: 2,
29
+ baseDelayMs: 1000,
30
+ maxDelayMs: 30_000,
31
+ providerName: "provider",
32
+ };
33
+
34
+ // ── Retry Error ───────────────────────────────────────────────────────
35
+
36
+ export class RetryError extends Error {
37
+ constructor(
38
+ message: string,
39
+ public readonly attempts: number,
40
+ public readonly lastCategory?: string,
41
+ public readonly lastStatus?: number,
42
+ ) {
43
+ super(message);
44
+ this.name = "RetryError";
45
+ }
46
+ }
47
+
48
+ // ── Core retry function ───────────────────────────────────────────────
49
+
50
+ /**
51
+ * Execute a function with category-aware exponential backoff and jitter.
52
+ *
53
+ * - Rate limit (429): up to maxRetriesRateLimit retries
54
+ * - Network errors: up to maxRetriesNetwork retries
55
+ * - Server errors (500/502/503/504/529): up to maxRetriesServer retries
56
+ * - Auth errors (401/403): fail immediately
57
+ * - Unknown non-retryable errors: fail immediately
58
+ */
59
+ export async function withRetry<T>(
60
+ fn: () => Promise<T>,
61
+ config: Partial<RetryConfig> = {},
62
+ ): Promise<T> {
63
+ const cfg = { ...DEFAULT_CONFIG, ...config };
64
+ let lastError: Error | null = null;
65
+ let lastCategorized: CategorizedError | null = null;
66
+
67
+ for (let attempt = 0; ; attempt++) {
68
+ try {
69
+ return await fn();
70
+ } catch (err) {
71
+ lastError = err instanceof Error ? err : new Error(String(err));
72
+ lastCategorized = categorizeError(lastError);
73
+
74
+ // Auth errors: fail immediately with clear message
75
+ if (lastCategorized.category === "auth") {
76
+ throw new Error(
77
+ `[${cfg.providerName}] Authentication failed — check your API key. (${lastError.message})`,
78
+ );
79
+ }
80
+
81
+ // Determine max retries for this error category
82
+ const maxRetries = getMaxRetries(lastCategorized, cfg);
83
+
84
+ if (!lastCategorized.retryable || attempt >= maxRetries) {
85
+ throw lastError;
86
+ }
87
+
88
+ // Calculate delay with exponential backoff + jitter
89
+ const delay = calculateDelay(attempt, lastCategorized, cfg);
90
+
91
+ process.stderr.write(
92
+ `[${cfg.providerName}] ${lastCategorized.category} error, retrying in ${delay}ms (attempt ${attempt + 1}/${maxRetries})...\n`,
93
+ );
94
+
95
+ await sleep(delay);
96
+ }
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Wrap an async generator with retry — recreates the generator on failure.
102
+ * Used for streaming API calls where errors can surface mid-iteration.
103
+ */
104
+ export async function* withStreamRetry<T>(
105
+ createStream: () => AsyncGenerator<T>,
106
+ config: Partial<RetryConfig> = {},
107
+ ): AsyncGenerator<T> {
108
+ const cfg = { ...DEFAULT_CONFIG, ...config };
109
+ let lastError: Error | null = null;
110
+
111
+ for (let attempt = 0; ; attempt++) {
112
+ const stream = createStream();
113
+ try {
114
+ for await (const event of stream) {
115
+ yield event;
116
+ }
117
+ return; // Stream completed successfully
118
+ } catch (err) {
119
+ lastError = err instanceof Error ? err : new Error(String(err));
120
+ const categorized = categorizeError(lastError);
121
+
122
+ // Auth: fail fast
123
+ if (categorized.category === "auth") {
124
+ throw new Error(
125
+ `[${cfg.providerName}] Authentication failed — check your API key. (${lastError.message})`,
126
+ );
127
+ }
128
+
129
+ const maxRetries = getMaxRetries(categorized, cfg);
130
+
131
+ if (!categorized.retryable || attempt >= maxRetries) {
132
+ throw lastError;
133
+ }
134
+
135
+ const delay = calculateDelay(attempt, categorized, cfg);
136
+ process.stderr.write(
137
+ `[${cfg.providerName}] ${categorized.category} error mid-stream, retrying in ${delay}ms (attempt ${attempt + 1}/${maxRetries})...\n`,
138
+ );
139
+
140
+ await sleep(delay);
141
+ // Loop re-creates the stream from scratch
142
+ }
143
+ }
144
+ }
145
+
146
+ // ── Circuit Breaker ───────────────────────────────────────────────────
147
+
148
+ export type CircuitState = "closed" | "open" | "half-open";
149
+
150
+ /**
151
+ * Circuit breaker — stop hammering a provider after consecutive failures.
152
+ *
153
+ * States:
154
+ * - closed: requests flow normally
155
+ * - open: requests are blocked (too many failures)
156
+ * - half-open: one probe request allowed to test recovery
157
+ */
158
+ export class CircuitBreaker {
159
+ private failures = 0;
160
+ private lastFailureTime = 0;
161
+ private state: CircuitState = "closed";
162
+ private probeInFlight = false;
163
+
164
+ constructor(
165
+ /** Number of consecutive failures before opening the circuit */
166
+ private readonly threshold: number = 5,
167
+ /** Time in ms before an open circuit transitions to half-open */
168
+ private readonly resetTimeMs: number = 60_000,
169
+ ) {}
170
+
171
+ /** Check if a request should be allowed through */
172
+ canRequest(): boolean {
173
+ if (this.state === "closed") return true;
174
+
175
+ if (this.state === "open") {
176
+ // Check if enough time has passed to try again
177
+ if (Date.now() - this.lastFailureTime >= this.resetTimeMs) {
178
+ this.state = "half-open";
179
+ this.probeInFlight = true;
180
+ return true;
181
+ }
182
+ return false;
183
+ }
184
+
185
+ // half-open: allow only one probe request at a time
186
+ if (this.probeInFlight) return false;
187
+ this.probeInFlight = true;
188
+ return true;
189
+ }
190
+
191
+ /** Record a successful request — resets failure count */
192
+ recordSuccess(): void {
193
+ this.failures = 0;
194
+ this.state = "closed";
195
+ this.probeInFlight = false;
196
+ }
197
+
198
+ /** Record a failed request — may trip the breaker */
199
+ recordFailure(): void {
200
+ this.probeInFlight = false;
201
+ this.failures++;
202
+ this.lastFailureTime = Date.now();
203
+ if (this.failures >= this.threshold) {
204
+ this.state = "open";
205
+ }
206
+ }
207
+
208
+ getState(): CircuitState {
209
+ // Re-check for time-based transition
210
+ if (this.state === "open" && Date.now() - this.lastFailureTime >= this.resetTimeMs) {
211
+ this.state = "half-open";
212
+ }
213
+ return this.state;
214
+ }
215
+
216
+ getStatus(): string {
217
+ return `${this.getState()} (${this.failures}/${this.threshold} failures)`;
218
+ }
219
+ }
220
+
221
+ // ── Helpers ───────────────────────────────────────────────────────────
222
+
223
+ function getMaxRetries(categorized: CategorizedError, cfg: RetryConfig): number {
224
+ switch (categorized.category) {
225
+ case "rate_limit":
226
+ return cfg.maxRetriesRateLimit;
227
+ case "network":
228
+ return cfg.maxRetriesNetwork;
229
+ default:
230
+ // Server errors (5xx) are retryable if categorized as such
231
+ return categorized.retryable ? cfg.maxRetriesServer : 0;
232
+ }
233
+ }
234
+
235
+ function calculateDelay(
236
+ attempt: number,
237
+ categorized: CategorizedError,
238
+ cfg: RetryConfig,
239
+ ): number {
240
+ // If the error includes a Retry-After hint, use it
241
+ if (categorized.retryAfterMs && categorized.retryAfterMs > 0) {
242
+ return Math.min(categorized.retryAfterMs, cfg.maxDelayMs);
243
+ }
244
+
245
+ // Rate limits get a higher base delay
246
+ const baseDelay =
247
+ categorized.category === "rate_limit"
248
+ ? cfg.baseDelayMs
249
+ : categorized.category === "network"
250
+ ? cfg.baseDelayMs * 2
251
+ : cfg.baseDelayMs;
252
+
253
+ // Exponential backoff: base * 2^attempt
254
+ const exponential = baseDelay * Math.pow(2, attempt);
255
+
256
+ // Add jitter: ±25% randomization to prevent thundering herd
257
+ const jitter = exponential * 0.25 * (Math.random() * 2 - 1);
258
+
259
+ return Math.min(Math.round(exponential + jitter), cfg.maxDelayMs);
260
+ }
261
+
262
+ function sleep(ms: number): Promise<void> {
263
+ return new Promise((resolve) => setTimeout(resolve, ms));
264
+ }