brownian-code 2026.2.10

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 (120) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +97 -0
  3. package/bin/brownian +25 -0
  4. package/env.example +21 -0
  5. package/package.json +87 -0
  6. package/src/agent/agent.test.ts +414 -0
  7. package/src/agent/agent.ts +385 -0
  8. package/src/agent/index.ts +27 -0
  9. package/src/agent/prompts.ts +271 -0
  10. package/src/agent/scratchpad.test.ts +482 -0
  11. package/src/agent/scratchpad.ts +526 -0
  12. package/src/agent/token-counter.test.ts +59 -0
  13. package/src/agent/token-counter.ts +33 -0
  14. package/src/agent/types.ts +137 -0
  15. package/src/cli.tsx +385 -0
  16. package/src/commands/builtin.test.ts +271 -0
  17. package/src/commands/builtin.ts +200 -0
  18. package/src/commands/registry.test.ts +188 -0
  19. package/src/commands/registry.ts +111 -0
  20. package/src/commands/types.ts +64 -0
  21. package/src/components/AgentEventView.tsx +487 -0
  22. package/src/components/AnswerBox.tsx +81 -0
  23. package/src/components/ApiKeyPrompt.tsx +75 -0
  24. package/src/components/CommandMenu.test.tsx +64 -0
  25. package/src/components/CommandMenu.tsx +38 -0
  26. package/src/components/CursorText.tsx +43 -0
  27. package/src/components/DebugPanel.tsx +48 -0
  28. package/src/components/ErrorBox.test.tsx +58 -0
  29. package/src/components/ErrorBox.tsx +26 -0
  30. package/src/components/HelpView.test.tsx +70 -0
  31. package/src/components/HelpView.tsx +61 -0
  32. package/src/components/HistoryItemView.tsx +108 -0
  33. package/src/components/Input.tsx +193 -0
  34. package/src/components/Intro.test.tsx +59 -0
  35. package/src/components/Intro.tsx +35 -0
  36. package/src/components/ModelSelector.tsx +288 -0
  37. package/src/components/StatusBar.test.tsx +78 -0
  38. package/src/components/StatusBar.tsx +56 -0
  39. package/src/components/WorkingIndicator.tsx +133 -0
  40. package/src/components/index.ts +23 -0
  41. package/src/e2e/agent-flow.test.ts +378 -0
  42. package/src/evals/components/EvalApp.tsx +206 -0
  43. package/src/evals/components/EvalCurrentQuestion.tsx +42 -0
  44. package/src/evals/components/EvalProgress.tsx +33 -0
  45. package/src/evals/components/EvalRecentResults.tsx +63 -0
  46. package/src/evals/components/EvalStats.tsx +49 -0
  47. package/src/evals/components/index.ts +5 -0
  48. package/src/evals/dataset/crypto_agent.csv +16 -0
  49. package/src/evals/run.ts +355 -0
  50. package/src/gateway/channels/whatsapp/auth-store.ts +15 -0
  51. package/src/gateway/channels/whatsapp/inbound.ts +86 -0
  52. package/src/gateway/channels/whatsapp/login.ts +28 -0
  53. package/src/gateway/channels/whatsapp/outbound.ts +27 -0
  54. package/src/gateway/channels/whatsapp/session.ts +69 -0
  55. package/src/gateway/config.ts +81 -0
  56. package/src/gateway/index.ts +62 -0
  57. package/src/hooks/useAgentRunner.ts +317 -0
  58. package/src/hooks/useDebugLogs.ts +22 -0
  59. package/src/hooks/useInputHistory.ts +106 -0
  60. package/src/hooks/useModelSelection.ts +249 -0
  61. package/src/hooks/useTextBuffer.test.ts +121 -0
  62. package/src/hooks/useTextBuffer.ts +97 -0
  63. package/src/index.tsx +74 -0
  64. package/src/mcp/cache.ts +205 -0
  65. package/src/mcp/client.test.ts +126 -0
  66. package/src/mcp/client.ts +145 -0
  67. package/src/mcp/index.ts +2 -0
  68. package/src/model/llm.test.ts +158 -0
  69. package/src/model/llm.ts +233 -0
  70. package/src/providers.ts +94 -0
  71. package/src/skills/index.ts +17 -0
  72. package/src/skills/loader.ts +73 -0
  73. package/src/skills/registry.ts +125 -0
  74. package/src/skills/types.ts +31 -0
  75. package/src/test-utils/mocks.ts +110 -0
  76. package/src/theme.ts +21 -0
  77. package/src/tools/browser/browser.ts +357 -0
  78. package/src/tools/browser/index.ts +1 -0
  79. package/src/tools/crypto/hive-tools.ts +171 -0
  80. package/src/tools/crypto/index.ts +1 -0
  81. package/src/tools/descriptions/browser.ts +105 -0
  82. package/src/tools/descriptions/crypto-search.ts +58 -0
  83. package/src/tools/descriptions/index.ts +8 -0
  84. package/src/tools/descriptions/web-fetch.ts +44 -0
  85. package/src/tools/descriptions/web-search.ts +26 -0
  86. package/src/tools/fetch/cache.ts +95 -0
  87. package/src/tools/fetch/external-content.ts +200 -0
  88. package/src/tools/fetch/index.ts +1 -0
  89. package/src/tools/fetch/web-fetch-utils.ts +122 -0
  90. package/src/tools/fetch/web-fetch.ts +371 -0
  91. package/src/tools/index.ts +12 -0
  92. package/src/tools/registry.ts +130 -0
  93. package/src/tools/search/exa.ts +43 -0
  94. package/src/tools/search/index.ts +2 -0
  95. package/src/tools/search/tavily.ts +35 -0
  96. package/src/tools/skill.ts +62 -0
  97. package/src/tools/types.ts +53 -0
  98. package/src/utils/ai-message.ts +26 -0
  99. package/src/utils/config.ts +54 -0
  100. package/src/utils/cost-calculator.test.ts +101 -0
  101. package/src/utils/cost-calculator.ts +74 -0
  102. package/src/utils/env.ts +101 -0
  103. package/src/utils/error-classifier.test.ts +146 -0
  104. package/src/utils/error-classifier.ts +91 -0
  105. package/src/utils/in-memory-chat-history.test.ts +291 -0
  106. package/src/utils/in-memory-chat-history.ts +224 -0
  107. package/src/utils/index.ts +19 -0
  108. package/src/utils/input-key-handlers.test.ts +155 -0
  109. package/src/utils/input-key-handlers.ts +64 -0
  110. package/src/utils/logger.ts +67 -0
  111. package/src/utils/long-term-chat-history.ts +138 -0
  112. package/src/utils/markdown-table.ts +227 -0
  113. package/src/utils/ollama.ts +37 -0
  114. package/src/utils/progress-channel.ts +84 -0
  115. package/src/utils/text-navigation.test.ts +222 -0
  116. package/src/utils/text-navigation.ts +81 -0
  117. package/src/utils/thinking-verbs.ts +29 -0
  118. package/src/utils/tokens.test.ts +163 -0
  119. package/src/utils/tokens.ts +67 -0
  120. package/src/utils/tool-description.ts +88 -0
@@ -0,0 +1,53 @@
1
+ export interface ToolResult {
2
+ data: unknown;
3
+ sourceUrls?: string[];
4
+ }
5
+
6
+ export function formatToolResult(data: unknown, sourceUrls?: string[]): string {
7
+ const result: ToolResult = { data };
8
+ if (sourceUrls?.length) {
9
+ result.sourceUrls = sourceUrls;
10
+ }
11
+ return JSON.stringify(result);
12
+ }
13
+
14
+ /**
15
+ * Parse search results from a search provider response.
16
+ * Handles both string and object responses, extracting URLs from results.
17
+ * Supports multiple response shapes from different providers.
18
+ */
19
+ export function parseSearchResults(result: unknown): { parsed: unknown; urls: string[] } {
20
+ // Safely parse JSON strings
21
+ let parsed: unknown;
22
+ if (typeof result === 'string') {
23
+ try {
24
+ parsed = JSON.parse(result);
25
+ } catch {
26
+ // If parsing fails, treat the string as the result itself
27
+ parsed = result;
28
+ }
29
+ } else {
30
+ parsed = result;
31
+ }
32
+
33
+ // Extract URLs from multiple possible response shapes
34
+ let urls: string[] = [];
35
+
36
+ // Shape 1: { results: [{ url: string }] } (Exa format)
37
+ if (parsed && typeof parsed === 'object' && 'results' in parsed) {
38
+ const results = (parsed as { results?: unknown[] }).results;
39
+ if (Array.isArray(results)) {
40
+ urls = results
41
+ .map((r) => (r && typeof r === 'object' && 'url' in r ? (r as { url?: string }).url : null))
42
+ .filter((url): url is string => Boolean(url));
43
+ }
44
+ }
45
+ // Shape 2: [{ url: string }] (direct array, Tavily format)
46
+ else if (Array.isArray(parsed)) {
47
+ urls = parsed
48
+ .map((r) => (r && typeof r === 'object' && 'url' in r ? (r as { url?: string }).url : null))
49
+ .filter((url): url is string => Boolean(url));
50
+ }
51
+
52
+ return { parsed, urls };
53
+ }
@@ -0,0 +1,26 @@
1
+ import { AIMessage } from '@langchain/core/messages';
2
+
3
+ /**
4
+ * Extract text content from an AIMessage
5
+ */
6
+ export function extractTextContent(message: AIMessage): string {
7
+ if (typeof message.content === 'string') {
8
+ return message.content;
9
+ }
10
+
11
+ if (Array.isArray(message.content)) {
12
+ return message.content
13
+ .filter(block => typeof block === 'object' && 'type' in block && block.type === 'text')
14
+ .map(block => (block as { text: string }).text)
15
+ .join('\n');
16
+ }
17
+
18
+ return '';
19
+ }
20
+
21
+ /**
22
+ * Check if an AIMessage has tool calls
23
+ */
24
+ export function hasToolCalls(message: AIMessage): boolean {
25
+ return Array.isArray(message.tool_calls) && message.tool_calls.length > 0;
26
+ }
@@ -0,0 +1,54 @@
1
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
2
+ import { dirname } from 'path';
3
+
4
+ const SETTINGS_FILE = '.brownian/settings.json';
5
+
6
+ interface Config {
7
+ provider?: string;
8
+ modelId?: string; // Selected model ID (e.g., "gpt-5.2", "ollama:llama3.1")
9
+ model?: string; // Legacy key, kept for migration
10
+ [key: string]: unknown;
11
+ }
12
+
13
+ export function loadConfig(): Config {
14
+ if (!existsSync(SETTINGS_FILE)) {
15
+ return {};
16
+ }
17
+
18
+ try {
19
+ const content = readFileSync(SETTINGS_FILE, 'utf-8');
20
+ return JSON.parse(content);
21
+ } catch {
22
+ return {};
23
+ }
24
+ }
25
+
26
+ export function saveConfig(config: Config): boolean {
27
+ try {
28
+ const dir = dirname(SETTINGS_FILE);
29
+ if (!existsSync(dir)) {
30
+ mkdirSync(dir, { recursive: true });
31
+ }
32
+ writeFileSync(SETTINGS_FILE, JSON.stringify(config, null, 2));
33
+ return true;
34
+ } catch {
35
+ return false;
36
+ }
37
+ }
38
+
39
+ export function getSetting<T>(key: string, defaultValue: T): T {
40
+ const config = loadConfig();
41
+ return (config[key] as T) ?? defaultValue;
42
+ }
43
+
44
+ export function setSetting(key: string, value: unknown): boolean {
45
+ const config = loadConfig();
46
+ config[key] = value;
47
+
48
+ // If setting provider, remove legacy model key
49
+ if (key === 'provider' && config.model) {
50
+ delete config.model;
51
+ }
52
+
53
+ return saveConfig(config);
54
+ }
@@ -0,0 +1,101 @@
1
+ import { describe, test, expect } from 'bun:test';
2
+ import { estimateCost, formatCost } from './cost-calculator.js';
3
+
4
+ // ---------------------------------------------------------------------------
5
+ // estimateCost
6
+ // ---------------------------------------------------------------------------
7
+
8
+ describe('estimateCost', () => {
9
+ test('calculates cost for a known model (claude-sonnet-4-5)', () => {
10
+ // claude-sonnet-4-5: input=$3/1M, output=$15/1M
11
+ const result = estimateCost('claude-sonnet-4-5', 1_000_000, 1_000_000);
12
+ expect(result.inputCost).toBe(3);
13
+ expect(result.outputCost).toBe(15);
14
+ expect(result.totalCost).toBe(18);
15
+ });
16
+
17
+ test('calculates cost for small token counts', () => {
18
+ // 1000 input tokens, 500 output tokens at claude-sonnet-4-5 rates
19
+ const result = estimateCost('claude-sonnet-4-5', 1000, 500);
20
+ expect(result.inputCost).toBeCloseTo(0.003, 6);
21
+ expect(result.outputCost).toBeCloseTo(0.0075, 6);
22
+ expect(result.totalCost).toBeCloseTo(0.0105, 6);
23
+ });
24
+
25
+ test('calculates cost for gpt-5.2', () => {
26
+ // gpt-5.2: input=$5/1M, output=$15/1M
27
+ const result = estimateCost('gpt-5.2', 100_000, 50_000);
28
+ expect(result.inputCost).toBeCloseTo(0.5, 6);
29
+ expect(result.outputCost).toBeCloseTo(0.75, 6);
30
+ expect(result.totalCost).toBeCloseTo(1.25, 6);
31
+ });
32
+
33
+ test('calculates cost for claude-opus-4-6', () => {
34
+ // claude-opus-4-6: input=$15/1M, output=$75/1M
35
+ const result = estimateCost('claude-opus-4-6', 10_000, 5_000);
36
+ expect(result.inputCost).toBeCloseTo(0.15, 6);
37
+ expect(result.outputCost).toBeCloseTo(0.375, 6);
38
+ });
39
+
40
+ test('calculates cost for claude-haiku-4-5', () => {
41
+ // claude-haiku-4-5: input=$0.8/1M, output=$4/1M
42
+ const result = estimateCost('claude-haiku-4-5', 1_000_000, 1_000_000);
43
+ expect(result.inputCost).toBe(0.8);
44
+ expect(result.outputCost).toBe(4);
45
+ });
46
+
47
+ test('uses default pricing for unknown model', () => {
48
+ // Default: input=$3/1M, output=$15/1M
49
+ const result = estimateCost('unknown-model-xyz', 1_000_000, 1_000_000);
50
+ expect(result.inputCost).toBe(3);
51
+ expect(result.outputCost).toBe(15);
52
+ expect(result.totalCost).toBe(18);
53
+ });
54
+
55
+ test('strips date suffix to match model pricing', () => {
56
+ // 'claude-sonnet-4-5-20250929' should strip '-20250929' → 'claude-sonnet-4-5'
57
+ const result = estimateCost('claude-sonnet-4-5-20250929', 1_000_000, 0);
58
+ expect(result.inputCost).toBe(3);
59
+ });
60
+
61
+ test('returns zero cost for zero tokens', () => {
62
+ const result = estimateCost('claude-sonnet-4-5', 0, 0);
63
+ expect(result.inputCost).toBe(0);
64
+ expect(result.outputCost).toBe(0);
65
+ expect(result.totalCost).toBe(0);
66
+ });
67
+
68
+ test('handles empty model string', () => {
69
+ const result = estimateCost('', 1000, 1000);
70
+ // Should use default pricing without crashing
71
+ expect(result.totalCost).toBeGreaterThan(0);
72
+ });
73
+ });
74
+
75
+ // ---------------------------------------------------------------------------
76
+ // formatCost
77
+ // ---------------------------------------------------------------------------
78
+
79
+ describe('formatCost', () => {
80
+ test('formats very small costs with 4 decimal places', () => {
81
+ expect(formatCost(0.0042)).toBe('$0.0042');
82
+ expect(formatCost(0.001)).toBe('$0.0010');
83
+ expect(formatCost(0.0001)).toBe('$0.0001');
84
+ });
85
+
86
+ test('formats medium costs with 3 decimal places', () => {
87
+ expect(formatCost(0.05)).toBe('$0.050');
88
+ expect(formatCost(0.123)).toBe('$0.123');
89
+ expect(formatCost(0.999)).toBe('$0.999');
90
+ });
91
+
92
+ test('formats large costs with 2 decimal places', () => {
93
+ expect(formatCost(1.5)).toBe('$1.50');
94
+ expect(formatCost(10.123)).toBe('$10.12');
95
+ expect(formatCost(100)).toBe('$100.00');
96
+ });
97
+
98
+ test('formats zero cost', () => {
99
+ expect(formatCost(0)).toBe('$0.0000');
100
+ });
101
+ });
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Simple per-model pricing table for cost estimation.
3
+ * Prices are per 1M tokens in USD.
4
+ */
5
+ const PRICING: Record<string, { input: number; output: number }> = {
6
+ // Anthropic
7
+ 'claude-sonnet-4-5': { input: 3, output: 15 },
8
+ 'claude-sonnet-4-5-20250929': { input: 3, output: 15 },
9
+ 'claude-opus-4-6': { input: 15, output: 75 },
10
+ 'claude-haiku-4-5': { input: 0.8, output: 4 },
11
+ 'claude-haiku-4-5-20251001': { input: 0.8, output: 4 },
12
+ // OpenAI
13
+ 'gpt-5.2': { input: 5, output: 15 },
14
+ 'gpt-4.1': { input: 2, output: 8 },
15
+ 'gpt-4.1-mini': { input: 0.4, output: 1.6 },
16
+ 'gpt-4.1-nano': { input: 0.1, output: 0.4 },
17
+ 'o3': { input: 10, output: 40 },
18
+ 'o4-mini': { input: 1.1, output: 4.4 },
19
+ // Google
20
+ 'gemini-3-flash-preview': { input: 0.15, output: 0.6 },
21
+ 'gemini-2.5-pro-preview': { input: 1.25, output: 10 },
22
+ // xAI
23
+ 'grok-4-1-fast-reasoning': { input: 3, output: 15 },
24
+ // DeepSeek
25
+ 'deepseek-chat': { input: 0.27, output: 1.1 },
26
+ 'deepseek-reasoner': { input: 0.55, output: 2.19 },
27
+ // Moonshot
28
+ 'kimi-k2-5': { input: 0.6, output: 2.4 },
29
+ };
30
+
31
+ /** Default pricing for unknown models */
32
+ const DEFAULT_PRICING = { input: 3, output: 15 };
33
+
34
+ export interface CostEstimate {
35
+ inputCost: number;
36
+ outputCost: number;
37
+ totalCost: number;
38
+ }
39
+
40
+ /**
41
+ * Estimate cost for a given model and token usage.
42
+ */
43
+ export function estimateCost(
44
+ model: string,
45
+ inputTokens: number,
46
+ outputTokens: number
47
+ ): CostEstimate {
48
+ // Try exact match, then try stripping version suffixes
49
+ const pricing = PRICING[model] ??
50
+ PRICING[model.replace(/-\d{8}$/, '')] ??
51
+ DEFAULT_PRICING;
52
+
53
+ const inputCost = (inputTokens / 1_000_000) * pricing.input;
54
+ const outputCost = (outputTokens / 1_000_000) * pricing.output;
55
+
56
+ return {
57
+ inputCost,
58
+ outputCost,
59
+ totalCost: inputCost + outputCost,
60
+ };
61
+ }
62
+
63
+ /**
64
+ * Format a cost value as a string (e.g., "$0.0042").
65
+ */
66
+ export function formatCost(cost: number): string {
67
+ if (cost < 0.01) {
68
+ return `$${cost.toFixed(4)}`;
69
+ }
70
+ if (cost < 1) {
71
+ return `$${cost.toFixed(3)}`;
72
+ }
73
+ return `$${cost.toFixed(2)}`;
74
+ }
@@ -0,0 +1,101 @@
1
+ import { existsSync, readFileSync, writeFileSync } from 'fs';
2
+ import { config } from 'dotenv';
3
+ import { getProviderById } from '@/providers';
4
+
5
+ // Load .env on module import
6
+ config({ quiet: true });
7
+
8
+ export function getApiKeyNameForProvider(providerId: string): string | undefined {
9
+ return getProviderById(providerId)?.apiKeyEnvVar;
10
+ }
11
+
12
+ export function getProviderDisplayName(providerId: string): string {
13
+ return getProviderById(providerId)?.displayName ?? providerId;
14
+ }
15
+
16
+ export function checkApiKeyExistsForProvider(providerId: string): boolean {
17
+ const apiKeyName = getApiKeyNameForProvider(providerId);
18
+ if (!apiKeyName) return true;
19
+ return checkApiKeyExists(apiKeyName);
20
+ }
21
+
22
+ export function checkApiKeyExists(apiKeyName: string): boolean {
23
+ const value = process.env[apiKeyName];
24
+ if (value && value.trim() && !value.trim().startsWith('your-')) {
25
+ return true;
26
+ }
27
+
28
+ // Also check .env file directly
29
+ if (existsSync('.env')) {
30
+ const envContent = readFileSync('.env', 'utf-8');
31
+ const lines = envContent.split('\n');
32
+ for (const line of lines) {
33
+ const trimmed = line.trim();
34
+ if (trimmed && !trimmed.startsWith('#') && trimmed.includes('=')) {
35
+ const [key, ...valueParts] = trimmed.split('=');
36
+ if (key.trim() === apiKeyName) {
37
+ const val = valueParts.join('=').trim();
38
+ if (val && !val.startsWith('your-')) {
39
+ return true;
40
+ }
41
+ }
42
+ }
43
+ }
44
+ }
45
+
46
+ return false;
47
+ }
48
+
49
+ export function saveApiKeyToEnv(apiKeyName: string, apiKeyValue: string): boolean {
50
+ try {
51
+ let lines: string[] = [];
52
+ let keyUpdated = false;
53
+
54
+ if (existsSync('.env')) {
55
+ const existingContent = readFileSync('.env', 'utf-8');
56
+ const existingLines = existingContent.split('\n');
57
+
58
+ for (const line of existingLines) {
59
+ const stripped = line.trim();
60
+ if (!stripped || stripped.startsWith('#')) {
61
+ lines.push(line);
62
+ } else if (stripped.includes('=')) {
63
+ const key = stripped.split('=')[0].trim();
64
+ if (key === apiKeyName) {
65
+ lines.push(`${apiKeyName}=${apiKeyValue}`);
66
+ keyUpdated = true;
67
+ } else {
68
+ lines.push(line);
69
+ }
70
+ } else {
71
+ lines.push(line);
72
+ }
73
+ }
74
+
75
+ if (!keyUpdated) {
76
+ if (lines.length > 0 && !lines[lines.length - 1].endsWith('\n')) {
77
+ lines.push('');
78
+ }
79
+ lines.push(`${apiKeyName}=${apiKeyValue}`);
80
+ }
81
+ } else {
82
+ lines.push('# LLM API Keys');
83
+ lines.push(`${apiKeyName}=${apiKeyValue}`);
84
+ }
85
+
86
+ writeFileSync('.env', lines.join('\n'));
87
+
88
+ // Reload environment variables
89
+ config({ override: true, quiet: true });
90
+
91
+ return true;
92
+ } catch {
93
+ return false;
94
+ }
95
+ }
96
+
97
+ export function saveApiKeyForProvider(providerId: string, apiKey: string): boolean {
98
+ const apiKeyName = getApiKeyNameForProvider(providerId);
99
+ if (!apiKeyName) return false;
100
+ return saveApiKeyToEnv(apiKeyName, apiKey);
101
+ }
@@ -0,0 +1,146 @@
1
+ import { describe, test, expect } from 'bun:test';
2
+ import { classifyError } from './error-classifier.js';
3
+
4
+ describe('classifyError', () => {
5
+ // -------------------------------------------------------------------------
6
+ // Rate limit errors
7
+ // -------------------------------------------------------------------------
8
+ test('classifies rate limit error (429)', () => {
9
+ const result = classifyError('Request failed with status code 429');
10
+ expect(result.category).toBe('Rate Limit');
11
+ expect(result.suggestions.length).toBeGreaterThan(0);
12
+ expect(result.suggestions.some(s => s.toLowerCase().includes('wait'))).toBe(true);
13
+ });
14
+
15
+ test('classifies "rate limit" text', () => {
16
+ const result = classifyError('Rate limit exceeded. Please try again later.');
17
+ expect(result.category).toBe('Rate Limit');
18
+ });
19
+
20
+ test('classifies "too many requests"', () => {
21
+ const result = classifyError('Too many requests, slow down');
22
+ expect(result.category).toBe('Rate Limit');
23
+ });
24
+
25
+ // -------------------------------------------------------------------------
26
+ // Authentication errors
27
+ // -------------------------------------------------------------------------
28
+ test('classifies 401 authentication error', () => {
29
+ const result = classifyError('Request failed with status code 401');
30
+ expect(result.category).toBe('Authentication Error');
31
+ expect(result.suggestions.some(s => s.toLowerCase().includes('api key'))).toBe(true);
32
+ });
33
+
34
+ test('classifies "unauthorized" error', () => {
35
+ const result = classifyError('Unauthorized: invalid API key');
36
+ expect(result.category).toBe('Authentication Error');
37
+ });
38
+
39
+ test('classifies 403 forbidden', () => {
40
+ const result = classifyError('403 Forbidden');
41
+ expect(result.category).toBe('Authentication Error');
42
+ });
43
+
44
+ test('classifies "invalid key" error', () => {
45
+ const result = classifyError('Invalid API key provided');
46
+ expect(result.category).toBe('Authentication Error');
47
+ });
48
+
49
+ // -------------------------------------------------------------------------
50
+ // Context too long errors
51
+ // -------------------------------------------------------------------------
52
+ test('classifies context length error', () => {
53
+ const result = classifyError('This model\'s maximum context length is 128000 tokens');
54
+ expect(result.category).toBe('Context Too Long');
55
+ expect(result.suggestions.some(s => s.includes('/compact'))).toBe(true);
56
+ });
57
+
58
+ test('classifies token limit error', () => {
59
+ const result = classifyError('Token limit exceeded');
60
+ expect(result.category).toBe('Context Too Long');
61
+ });
62
+
63
+ test('classifies "context window" error', () => {
64
+ const result = classifyError('Exceeded context window for this model');
65
+ expect(result.category).toBe('Context Too Long');
66
+ });
67
+
68
+ // -------------------------------------------------------------------------
69
+ // Network errors
70
+ // -------------------------------------------------------------------------
71
+ test('classifies ECONNREFUSED', () => {
72
+ const result = classifyError('connect ECONNREFUSED 127.0.0.1:11434');
73
+ expect(result.category).toBe('Network Error');
74
+ expect(result.suggestions.some(s => s.toLowerCase().includes('connection'))).toBe(true);
75
+ });
76
+
77
+ test('classifies timeout error', () => {
78
+ const result = classifyError('Request timeout after 30000ms');
79
+ expect(result.category).toBe('Network Error');
80
+ });
81
+
82
+ test('classifies ENOTFOUND', () => {
83
+ const result = classifyError('getaddrinfo ENOTFOUND api.openai.com');
84
+ expect(result.category).toBe('Network Error');
85
+ });
86
+
87
+ test('classifies fetch failure', () => {
88
+ const result = classifyError('fetch failed: network error');
89
+ expect(result.category).toBe('Network Error');
90
+ });
91
+
92
+ // -------------------------------------------------------------------------
93
+ // Model not found errors
94
+ // -------------------------------------------------------------------------
95
+ test('classifies model not found', () => {
96
+ const result = classifyError('The model `gpt-99` does not exist');
97
+ expect(result.category).toBe('Model Not Found');
98
+ expect(result.suggestions.some(s => s.includes('/model'))).toBe(true);
99
+ });
100
+
101
+ test('classifies unknown model', () => {
102
+ const result = classifyError('Unknown model: fake-model');
103
+ expect(result.category).toBe('Model Not Found');
104
+ });
105
+
106
+ // -------------------------------------------------------------------------
107
+ // Tool errors
108
+ // -------------------------------------------------------------------------
109
+ test('classifies tool error', () => {
110
+ const result = classifyError('Tool error: web_fetch failed with status 500');
111
+ expect(result.category).toBe('Tool Error');
112
+ });
113
+
114
+ test('classifies tool execution error', () => {
115
+ const result = classifyError('Execution error in tool handler');
116
+ expect(result.category).toBe('Tool Error');
117
+ });
118
+
119
+ // -------------------------------------------------------------------------
120
+ // Billing errors
121
+ // -------------------------------------------------------------------------
122
+ test('classifies insufficient funds', () => {
123
+ const result = classifyError('Insufficient funds in your account');
124
+ expect(result.category).toBe('Billing Error');
125
+ expect(result.suggestions.some(s => s.toLowerCase().includes('billing'))).toBe(true);
126
+ });
127
+
128
+ test('classifies quota exceeded', () => {
129
+ const result = classifyError('You have exceeded your quota');
130
+ expect(result.category).toBe('Billing Error');
131
+ });
132
+
133
+ // -------------------------------------------------------------------------
134
+ // Unknown errors (fallback)
135
+ // -------------------------------------------------------------------------
136
+ test('classifies unknown error with fallback suggestions', () => {
137
+ const result = classifyError('Something completely unexpected happened');
138
+ expect(result.category).toBe('Unknown Error');
139
+ expect(result.suggestions.length).toBeGreaterThan(0);
140
+ });
141
+
142
+ test('classifies empty error string', () => {
143
+ const result = classifyError('');
144
+ expect(result.category).toBe('Unknown Error');
145
+ });
146
+ });
@@ -0,0 +1,91 @@
1
+ export interface ErrorClassification {
2
+ category: string;
3
+ suggestions: string[];
4
+ }
5
+
6
+ const ERROR_PATTERNS: Array<{
7
+ pattern: RegExp;
8
+ category: string;
9
+ suggestions: string[];
10
+ }> = [
11
+ {
12
+ pattern: /rate.?limit|429|too many requests/i,
13
+ category: 'Rate Limit',
14
+ suggestions: [
15
+ 'Wait 30 seconds and try again',
16
+ 'Switch to a different model with /model',
17
+ 'Use /compact to reduce context size',
18
+ ],
19
+ },
20
+ {
21
+ pattern: /auth|401|403|api.?key|unauthorized|forbidden|invalid.*key/i,
22
+ category: 'Authentication Error',
23
+ suggestions: [
24
+ 'Check your API key is set correctly',
25
+ 'Run /model to switch provider or re-enter key',
26
+ 'Verify your API key has the required permissions',
27
+ ],
28
+ },
29
+ {
30
+ pattern: /context.*length|token.*limit|too.*long|maximum.*context|context.*window/i,
31
+ category: 'Context Too Long',
32
+ suggestions: [
33
+ 'Use /compact to summarize and reduce context',
34
+ 'Use /clear to start a fresh conversation',
35
+ 'Try a model with a larger context window',
36
+ ],
37
+ },
38
+ {
39
+ pattern: /tool.*error|tool.*fail|execution.*error/i,
40
+ category: 'Tool Error',
41
+ suggestions: [
42
+ 'Try rephrasing your question',
43
+ 'The tool may be temporarily unavailable',
44
+ ],
45
+ },
46
+ {
47
+ pattern: /network|ECONNREFUSED|ENOTFOUND|timeout|ETIMEDOUT|fetch.*fail|socket/i,
48
+ category: 'Network Error',
49
+ suggestions: [
50
+ 'Check your internet connection',
51
+ 'Try again in a few seconds',
52
+ 'If using Ollama, ensure the server is running',
53
+ ],
54
+ },
55
+ {
56
+ pattern: /model.*not.*found|does not exist|invalid.*model|unknown.*model/i,
57
+ category: 'Model Not Found',
58
+ suggestions: [
59
+ 'Run /model to select a valid model',
60
+ 'Check the model name for typos',
61
+ ],
62
+ },
63
+ {
64
+ pattern: /insufficient.*funds|billing|payment|quota/i,
65
+ category: 'Billing Error',
66
+ suggestions: [
67
+ 'Check your account billing status',
68
+ 'Switch to a free or cheaper model with /model',
69
+ ],
70
+ },
71
+ ];
72
+
73
+ /**
74
+ * Classify an error message and return actionable suggestions.
75
+ */
76
+ export function classifyError(errorMessage: string): ErrorClassification {
77
+ for (const { pattern, category, suggestions } of ERROR_PATTERNS) {
78
+ if (pattern.test(errorMessage)) {
79
+ return { category, suggestions };
80
+ }
81
+ }
82
+
83
+ return {
84
+ category: 'Unknown Error',
85
+ suggestions: [
86
+ 'Try rephrasing your question',
87
+ 'Use /clear to start fresh',
88
+ 'Switch models with /model',
89
+ ],
90
+ };
91
+ }