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,205 @@
1
+ /**
2
+ * TTL-based response cache for Hive MCP tool calls.
3
+ *
4
+ * Different tool categories have different TTLs based on data freshness needs:
5
+ * - Category/schema discovery: 1 hour (static metadata)
6
+ * - Historical data (OHLCV, charts): 5 minutes
7
+ * - Real-time data (prices, balances): 30 seconds
8
+ * - Security checks: 2 minutes
9
+ */
10
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, unlinkSync, statSync } from 'fs';
11
+ import { join, dirname } from 'path';
12
+ import { createHash } from 'crypto';
13
+ import { logger } from '../utils/logger.js';
14
+
15
+ const MCP_CACHE_DIR = '.brownian/mcp-cache';
16
+
17
+ // TTL values in milliseconds
18
+ const TTL = {
19
+ CATEGORY: 60 * 60 * 1000, // 1 hour
20
+ SCHEMA: 60 * 60 * 1000, // 1 hour
21
+ HISTORICAL: 5 * 60 * 1000, // 5 minutes
22
+ REALTIME: 30 * 1000, // 30 seconds
23
+ SECURITY: 2 * 60 * 1000, // 2 minutes
24
+ DEFAULT: 2 * 60 * 1000, // 2 minutes
25
+ } as const;
26
+
27
+ interface MCPCacheEntry {
28
+ toolName: string;
29
+ args: Record<string, unknown>;
30
+ result: string;
31
+ cachedAt: number; // epoch ms
32
+ }
33
+
34
+ /**
35
+ * Determine TTL for a given tool name based on its category.
36
+ */
37
+ function getTTLForTool(toolName: string): number {
38
+ // Category discovery endpoints
39
+ if (toolName.startsWith('get_') && toolName.endsWith('_endpoints')) {
40
+ return TTL.CATEGORY;
41
+ }
42
+
43
+ // Schema endpoints
44
+ if (toolName === 'get_api_endpoint_schema') {
45
+ return TTL.SCHEMA;
46
+ }
47
+
48
+ // For invoke_api_endpoint, look at the endpoint_name arg (handled by caller)
49
+ // Default for invocations
50
+ return TTL.DEFAULT;
51
+ }
52
+
53
+ /**
54
+ * Determine TTL for invoke_api_endpoint based on the endpoint being invoked.
55
+ */
56
+ export function getTTLForEndpoint(endpointName: string): number {
57
+ const lower = endpointName.toLowerCase();
58
+
59
+ // Real-time price data
60
+ if (lower.includes('price') || lower.includes('balance') || lower.includes('portfolio')) {
61
+ return TTL.REALTIME;
62
+ }
63
+
64
+ // Security checks
65
+ if (lower.includes('security') || lower.includes('risk') || lower.includes('honeypot')) {
66
+ return TTL.SECURITY;
67
+ }
68
+
69
+ // Historical/chart data
70
+ if (lower.includes('ohlcv') || lower.includes('chart') || lower.includes('historical')) {
71
+ return TTL.HISTORICAL;
72
+ }
73
+
74
+ return TTL.DEFAULT;
75
+ }
76
+
77
+ /**
78
+ * Build a cache key from tool name and args.
79
+ */
80
+ function buildKey(toolName: string, args: Record<string, unknown>): string {
81
+ const sorted = JSON.stringify(args, Object.keys(args).sort());
82
+ const raw = `${toolName}:${sorted}`;
83
+ return createHash('md5').update(raw).digest('hex').slice(0, 16);
84
+ }
85
+
86
+ /**
87
+ * Read a cached MCP response if it exists and hasn't expired.
88
+ */
89
+ export function readMCPCache(
90
+ toolName: string,
91
+ args: Record<string, unknown>,
92
+ ): string | null {
93
+ const key = buildKey(toolName, args);
94
+ const filepath = join(MCP_CACHE_DIR, `${key}.json`);
95
+
96
+ if (!existsSync(filepath)) return null;
97
+
98
+ try {
99
+ const content = readFileSync(filepath, 'utf-8');
100
+ const entry: MCPCacheEntry = JSON.parse(content);
101
+
102
+ // Determine TTL
103
+ let ttl: number;
104
+ if (toolName === 'invoke_api_endpoint' && typeof args.endpoint_name === 'string') {
105
+ ttl = getTTLForEndpoint(args.endpoint_name);
106
+ } else {
107
+ ttl = getTTLForTool(toolName);
108
+ }
109
+
110
+ // Check expiry
111
+ const age = Date.now() - entry.cachedAt;
112
+ if (age > ttl) {
113
+ // Expired — remove and miss
114
+ try { unlinkSync(filepath); } catch { /* best-effort */ }
115
+ return null;
116
+ }
117
+
118
+ return entry.result;
119
+ } catch {
120
+ // Corrupted — remove
121
+ try { unlinkSync(filepath); } catch { /* best-effort */ }
122
+ return null;
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Write an MCP response to the cache.
128
+ */
129
+ export function writeMCPCache(
130
+ toolName: string,
131
+ args: Record<string, unknown>,
132
+ result: string,
133
+ ): void {
134
+ const key = buildKey(toolName, args);
135
+ const filepath = join(MCP_CACHE_DIR, `${key}.json`);
136
+
137
+ const entry: MCPCacheEntry = {
138
+ toolName,
139
+ args,
140
+ result,
141
+ cachedAt: Date.now(),
142
+ };
143
+
144
+ try {
145
+ const dir = dirname(filepath);
146
+ if (!existsSync(dir)) {
147
+ mkdirSync(dir, { recursive: true });
148
+ }
149
+ writeFileSync(filepath, JSON.stringify(entry));
150
+ } catch (error) {
151
+ const message = error instanceof Error ? error.message : String(error);
152
+ logger.warn(`MCP cache write error: ${message}`);
153
+ }
154
+ }
155
+
156
+ /**
157
+ * Clear ALL cached MCP entries. Use after fixing bugs or clearing stale errors.
158
+ */
159
+ export function clearAllMCPCache(): number {
160
+ if (!existsSync(MCP_CACHE_DIR)) return 0;
161
+
162
+ let cleared = 0;
163
+ try {
164
+ const files = readdirSync(MCP_CACHE_DIR).filter((f) => f.endsWith('.json'));
165
+ for (const file of files) {
166
+ try { unlinkSync(join(MCP_CACHE_DIR, file)); cleared++; } catch { /* best-effort */ }
167
+ }
168
+ } catch { /* directory read error */ }
169
+ return cleared;
170
+ }
171
+
172
+ /**
173
+ * Clean up expired cache entries. Called periodically or on startup.
174
+ */
175
+ export function cleanExpiredMCPCache(): number {
176
+ if (!existsSync(MCP_CACHE_DIR)) return 0;
177
+
178
+ let cleaned = 0;
179
+ try {
180
+ const files = readdirSync(MCP_CACHE_DIR).filter((f) => f.endsWith('.json'));
181
+
182
+ for (const file of files) {
183
+ const filepath = join(MCP_CACHE_DIR, file);
184
+ try {
185
+ const content = readFileSync(filepath, 'utf-8');
186
+ const entry: MCPCacheEntry = JSON.parse(content);
187
+ const ttl = entry.toolName === 'invoke_api_endpoint' && typeof entry.args.endpoint_name === 'string'
188
+ ? getTTLForEndpoint(entry.args.endpoint_name)
189
+ : getTTLForTool(entry.toolName);
190
+
191
+ if (Date.now() - entry.cachedAt > ttl) {
192
+ unlinkSync(filepath);
193
+ cleaned++;
194
+ }
195
+ } catch {
196
+ // Corrupted file — remove
197
+ try { unlinkSync(filepath); cleaned++; } catch { /* */ }
198
+ }
199
+ }
200
+ } catch {
201
+ // Directory read error — skip
202
+ }
203
+
204
+ return cleaned;
205
+ }
@@ -0,0 +1,126 @@
1
+ import { describe, test, expect, beforeEach, mock } from 'bun:test';
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // extractMCPText (tested via callHiveTool internals — we test the export behavior)
5
+ // ---------------------------------------------------------------------------
6
+
7
+ // We test the module's exported functions. Since the module connects to a real
8
+ // server, we mock the MCP Client class at the module level.
9
+
10
+ // Create mock client
11
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
12
+ const mockCallTool = mock(async (): Promise<any> => ({
13
+ content: [
14
+ { type: 'text', text: 'Hello' },
15
+ { type: 'text', text: 'World' },
16
+ ],
17
+ }));
18
+ const mockClose = mock(async () => {});
19
+ const mockConnect = mock(async () => {});
20
+ const mockListTools = mock(async () => ({
21
+ tools: [
22
+ { name: 'tool_a', description: 'Tool A' },
23
+ { name: 'tool_b', description: 'Tool B' },
24
+ ],
25
+ }));
26
+
27
+ // Mock the MCP SDK
28
+ mock.module('@modelcontextprotocol/sdk/client/index.js', () => ({
29
+ Client: class MockClient {
30
+ callTool = mockCallTool;
31
+ close = mockClose;
32
+ connect = mockConnect;
33
+ listTools = mockListTools;
34
+ },
35
+ }));
36
+
37
+ mock.module('@modelcontextprotocol/sdk/client/streamableHttp.js', () => ({
38
+ StreamableHTTPClientTransport: class MockTransport {
39
+ constructor() {}
40
+ },
41
+ }));
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // Tests
45
+ // ---------------------------------------------------------------------------
46
+
47
+ describe('MCP client', () => {
48
+ beforeEach(() => {
49
+ mockCallTool.mockClear();
50
+ mockClose.mockClear();
51
+ mockConnect.mockClear();
52
+ });
53
+
54
+ test('callHiveTool returns joined text content', async () => {
55
+ // Need a fresh import each test to reset singleton
56
+ // but mock.module handles the client constructor
57
+ const { callHiveTool } = await import('./client.js');
58
+ const result = await callHiveTool('test_tool', { arg: 'value' });
59
+ expect(result).toBe('Hello\nWorld');
60
+ });
61
+
62
+ test('callHiveTool passes correct arguments', async () => {
63
+ const { callHiveTool } = await import('./client.js');
64
+ await callHiveTool('my_tool', { key: 'val' });
65
+
66
+ expect(mockCallTool).toHaveBeenCalledWith({
67
+ name: 'my_tool',
68
+ arguments: { key: 'val' },
69
+ });
70
+ });
71
+
72
+ test('callHiveTool handles empty content array', async () => {
73
+ mockCallTool.mockResolvedValueOnce({ content: [] });
74
+ const { callHiveTool } = await import('./client.js');
75
+ const result = await callHiveTool('empty_tool');
76
+ expect(result).toBe('');
77
+ });
78
+
79
+ test('callHiveTool handles missing content field', async () => {
80
+ mockCallTool.mockResolvedValueOnce({ someOther: 'data' });
81
+ const { callHiveTool } = await import('./client.js');
82
+ const result = await callHiveTool('no_content_tool');
83
+ // Should JSON.stringify the result as fallback
84
+ expect(result).toContain('someOther');
85
+ });
86
+
87
+ test('callHiveTool filters non-text blocks', async () => {
88
+ mockCallTool.mockResolvedValueOnce({
89
+ content: [
90
+ { type: 'text', text: 'visible' },
91
+ { type: 'image', data: 'binary' },
92
+ { type: 'text', text: 'also visible' },
93
+ ],
94
+ });
95
+ const { callHiveTool } = await import('./client.js');
96
+ const result = await callHiveTool('mixed_tool');
97
+ expect(result).toBe('visible\nalso visible');
98
+ });
99
+
100
+ test('listHiveTools returns tool list', async () => {
101
+ const { listHiveTools } = await import('./client.js');
102
+ const tools = await listHiveTools();
103
+ expect(tools.length).toBe(2);
104
+ expect(tools[0].name).toBe('tool_a');
105
+ expect(tools[1].description).toBe('Tool B');
106
+ });
107
+
108
+ test('disconnectHiveMCP calls close and resets', async () => {
109
+ const { disconnectHiveMCP, callHiveTool } = await import('./client.js');
110
+ // Ensure client is connected first
111
+ await callHiveTool('setup');
112
+ mockClose.mockClear();
113
+
114
+ await disconnectHiveMCP();
115
+ expect(mockClose).toHaveBeenCalled();
116
+ });
117
+
118
+ test('disconnectHiveMCP is a no-op when not connected', async () => {
119
+ // Import fresh module — but singleton may already exist
120
+ const { disconnectHiveMCP } = await import('./client.js');
121
+ // Call disconnect twice — second should be noop
122
+ await disconnectHiveMCP();
123
+ await disconnectHiveMCP();
124
+ // Should not throw
125
+ });
126
+ });
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Hive MCP Client - connects to Hive Intelligence MCP server over HTTP.
3
+ *
4
+ * Singleton pattern with lazy connection.
5
+ * Uses StreamableHTTPClientTransport for HTTP-based MCP communication.
6
+ */
7
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
8
+ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
9
+ import { logger } from '../utils/logger.js';
10
+
11
+ const HIVE_MCP_URL = process.env.HIVE_MCP_URL ?? 'https://hiveintelligence.xyz/mcp';
12
+ const CONNECT_TIMEOUT_MS = 18_000;
13
+ const CALL_TIMEOUT_MS = 22_000;
14
+ const MAX_RETRIES = 2;
15
+
16
+ let clientInstance: Client | null = null;
17
+ let connectionPromise: Promise<Client> | null = null;
18
+
19
+ /**
20
+ * Create and connect the MCP client to Hive Intelligence.
21
+ */
22
+ async function createClient(): Promise<Client> {
23
+ const transport = new StreamableHTTPClientTransport(new URL(HIVE_MCP_URL));
24
+
25
+ const client = new Client({
26
+ name: 'brownian-code',
27
+ version: '1.0.0',
28
+ });
29
+
30
+ const timeoutSignal = AbortSignal.timeout(CONNECT_TIMEOUT_MS);
31
+
32
+ try {
33
+ await client.connect(transport, { signal: timeoutSignal });
34
+ logger.info(`Connected to Hive MCP at ${HIVE_MCP_URL}`);
35
+ return client;
36
+ } catch (error) {
37
+ const message = error instanceof Error ? error.message : String(error);
38
+ logger.error(`Failed to connect to Hive MCP: ${message}`);
39
+ throw new Error(
40
+ `Could not connect to crypto data service (hiveintelligence.xyz). ` +
41
+ `Check your internet connection. Crypto tools will be unavailable.`
42
+ );
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Get the singleton Hive MCP client.
48
+ * Lazy-connects on first use.
49
+ */
50
+ export async function getHiveMCPClient(): Promise<Client> {
51
+ if (clientInstance) return clientInstance;
52
+
53
+ // Prevent parallel connection attempts
54
+ if (!connectionPromise) {
55
+ connectionPromise = createClient().then(
56
+ (client) => {
57
+ clientInstance = client;
58
+ connectionPromise = null;
59
+ return client;
60
+ },
61
+ (error) => {
62
+ connectionPromise = null;
63
+ throw error;
64
+ },
65
+ );
66
+ }
67
+
68
+ return connectionPromise;
69
+ }
70
+
71
+ /**
72
+ * Extract text content from MCP tool response.
73
+ * MCP responses contain a `content` array of typed blocks.
74
+ */
75
+ function extractMCPText(result: Awaited<ReturnType<Client['callTool']>>): string {
76
+ if (!result.content || !Array.isArray(result.content)) {
77
+ return JSON.stringify(result);
78
+ }
79
+
80
+ const textParts = result.content
81
+ .filter((block: { type: string }) => block.type === 'text')
82
+ .map((block: { type: string; text?: string }) => block.text ?? '');
83
+
84
+ return textParts.join('\n');
85
+ }
86
+
87
+ /**
88
+ * Call a Hive MCP tool by name with arguments.
89
+ * Includes timeout and retry logic.
90
+ */
91
+ export async function callHiveTool(
92
+ name: string,
93
+ args: Record<string, unknown> = {},
94
+ ): Promise<string> {
95
+ let lastError: Error | null = null;
96
+
97
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
98
+ try {
99
+ const client = await getHiveMCPClient();
100
+ const timeoutMs = attempt === 0 ? CONNECT_TIMEOUT_MS : CALL_TIMEOUT_MS;
101
+
102
+ const result = await Promise.race([
103
+ client.callTool({ name, arguments: args }),
104
+ new Promise<never>((_, reject) =>
105
+ setTimeout(() => reject(new Error(`Hive MCP call timed out after ${timeoutMs}ms`)), timeoutMs),
106
+ ),
107
+ ]);
108
+
109
+ return extractMCPText(result);
110
+ } catch (error) {
111
+ lastError = error instanceof Error ? error : new Error(String(error));
112
+ logger.warn(`Hive MCP call '${name}' attempt ${attempt + 1} failed: ${lastError.message}`);
113
+
114
+ if (attempt < MAX_RETRIES) {
115
+ await new Promise((r) => setTimeout(r, 500 * 2 ** attempt));
116
+ }
117
+ }
118
+ }
119
+
120
+ throw lastError ?? new Error(`Hive MCP call '${name}' failed after ${MAX_RETRIES + 1} attempts`);
121
+ }
122
+
123
+ /**
124
+ * List all available Hive MCP tools.
125
+ * Useful for startup validation and discovery.
126
+ */
127
+ export async function listHiveTools(): Promise<{ name: string; description?: string }[]> {
128
+ const client = await getHiveMCPClient();
129
+ const result = await client.listTools();
130
+ return result.tools.map((t) => ({ name: t.name, description: t.description }));
131
+ }
132
+
133
+ /**
134
+ * Disconnect the MCP client. Call during graceful shutdown.
135
+ */
136
+ export async function disconnectHiveMCP(): Promise<void> {
137
+ if (clientInstance) {
138
+ try {
139
+ await clientInstance.close();
140
+ } catch {
141
+ // Best-effort cleanup
142
+ }
143
+ clientInstance = null;
144
+ }
145
+ }
@@ -0,0 +1,2 @@
1
+ export { getHiveMCPClient, callHiveTool, listHiveTools, disconnectHiveMCP } from './client.js';
2
+ export { readMCPCache, writeMCPCache, cleanExpiredMCPCache, clearAllMCPCache, getTTLForEndpoint } from './cache.js';
@@ -0,0 +1,158 @@
1
+ import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
2
+
3
+ // Test provider routing and fast model resolution through the providers module
4
+ // (which is never mocked by other test files), plus the LLM module constants.
5
+ import { resolveProvider, getProviderById, PROVIDERS } from '../providers.js';
6
+
7
+ // ---------------------------------------------------------------------------
8
+ // Provider routing (underlies getChatModel)
9
+ // ---------------------------------------------------------------------------
10
+
11
+ describe('resolveProvider (model routing)', () => {
12
+ test('routes claude- prefix to anthropic', () => {
13
+ expect(resolveProvider('claude-sonnet-4-5').id).toBe('anthropic');
14
+ expect(resolveProvider('claude-opus-4-6').id).toBe('anthropic');
15
+ expect(resolveProvider('claude-haiku-4-5').id).toBe('anthropic');
16
+ });
17
+
18
+ test('routes gemini- prefix to google', () => {
19
+ expect(resolveProvider('gemini-2.0-flash').id).toBe('google');
20
+ expect(resolveProvider('gemini-1.5-pro').id).toBe('google');
21
+ });
22
+
23
+ test('routes grok- prefix to xai', () => {
24
+ expect(resolveProvider('grok-beta').id).toBe('xai');
25
+ });
26
+
27
+ test('routes deepseek- prefix to deepseek', () => {
28
+ expect(resolveProvider('deepseek-chat').id).toBe('deepseek');
29
+ });
30
+
31
+ test('routes ollama: prefix to ollama', () => {
32
+ expect(resolveProvider('ollama:llama3').id).toBe('ollama');
33
+ });
34
+
35
+ test('routes openrouter: prefix to openrouter', () => {
36
+ expect(resolveProvider('openrouter:openai/gpt-4o-mini').id).toBe('openrouter');
37
+ });
38
+
39
+ test('routes kimi- prefix to moonshot', () => {
40
+ expect(resolveProvider('kimi-k2-5').id).toBe('moonshot');
41
+ });
42
+
43
+ test('defaults to anthropic for unknown prefix', () => {
44
+ // The default provider is anthropic (per providers.ts line 76)
45
+ expect(resolveProvider('unknown-model').id).toBe('anthropic');
46
+ });
47
+ });
48
+
49
+ // ---------------------------------------------------------------------------
50
+ // getFastModel (via getProviderById)
51
+ // ---------------------------------------------------------------------------
52
+
53
+ describe('getFastModel (via providers)', () => {
54
+ test('anthropic fast model is claude-haiku-4-5', () => {
55
+ expect(getProviderById('anthropic')?.fastModel).toBe('claude-haiku-4-5');
56
+ });
57
+
58
+ test('openai fast model is gpt-4.1', () => {
59
+ expect(getProviderById('openai')?.fastModel).toBe('gpt-4.1');
60
+ });
61
+
62
+ test('google fast model is gemini-3-flash-preview', () => {
63
+ expect(getProviderById('google')?.fastModel).toBe('gemini-3-flash-preview');
64
+ });
65
+
66
+ test('xai fast model is grok-4-1-fast-reasoning', () => {
67
+ expect(getProviderById('xai')?.fastModel).toBe('grok-4-1-fast-reasoning');
68
+ });
69
+
70
+ test('deepseek fast model is deepseek-chat', () => {
71
+ expect(getProviderById('deepseek')?.fastModel).toBe('deepseek-chat');
72
+ });
73
+
74
+ test('ollama has no fast model', () => {
75
+ expect(getProviderById('ollama')?.fastModel).toBeUndefined();
76
+ });
77
+
78
+ test('unknown provider returns undefined', () => {
79
+ expect(getProviderById('nonexistent')).toBeUndefined();
80
+ });
81
+ });
82
+
83
+ // ---------------------------------------------------------------------------
84
+ // Provider registry completeness
85
+ // ---------------------------------------------------------------------------
86
+
87
+ describe('provider registry', () => {
88
+ test('has all expected providers', () => {
89
+ const ids = PROVIDERS.map(p => p.id);
90
+ expect(ids).toContain('openai');
91
+ expect(ids).toContain('anthropic');
92
+ expect(ids).toContain('google');
93
+ expect(ids).toContain('xai');
94
+ expect(ids).toContain('moonshot');
95
+ expect(ids).toContain('deepseek');
96
+ expect(ids).toContain('openrouter');
97
+ expect(ids).toContain('ollama');
98
+ });
99
+
100
+ test('each provider has required fields', () => {
101
+ for (const provider of PROVIDERS) {
102
+ expect(provider.id).toBeDefined();
103
+ expect(provider.displayName).toBeDefined();
104
+ expect(typeof provider.modelPrefix).toBe('string');
105
+ }
106
+ });
107
+
108
+ test('all providers except ollama have API key env var', () => {
109
+ for (const provider of PROVIDERS) {
110
+ if (provider.id !== 'ollama') {
111
+ expect(provider.apiKeyEnvVar).toBeDefined();
112
+ }
113
+ }
114
+ });
115
+ });
116
+
117
+ // ---------------------------------------------------------------------------
118
+ // API key validation (test the pattern, not getChatModel directly)
119
+ // ---------------------------------------------------------------------------
120
+
121
+ describe('API key validation patterns', () => {
122
+ const originalEnv = { ...process.env };
123
+
124
+ afterEach(() => {
125
+ for (const key of Object.keys(process.env)) {
126
+ if (!(key in originalEnv)) {
127
+ delete process.env[key];
128
+ }
129
+ }
130
+ Object.assign(process.env, originalEnv);
131
+ });
132
+
133
+ test('provider API key env vars are correctly named', () => {
134
+ const anthropic = getProviderById('anthropic')!;
135
+ expect(anthropic.apiKeyEnvVar).toBe('ANTHROPIC_API_KEY');
136
+
137
+ const openai = getProviderById('openai')!;
138
+ expect(openai.apiKeyEnvVar).toBe('OPENAI_API_KEY');
139
+
140
+ const google = getProviderById('google')!;
141
+ expect(google.apiKeyEnvVar).toBe('GOOGLE_API_KEY');
142
+ });
143
+
144
+ test('placeholder keys start with "your-"', () => {
145
+ // This tests the validation pattern used in llm.ts getApiKey()
146
+ const placeholders = ['your-api-key', 'your-key-here', 'your-anthropic-key'];
147
+ for (const key of placeholders) {
148
+ expect(key.trim().startsWith('your-')).toBe(true);
149
+ }
150
+ });
151
+
152
+ test('valid keys do not start with "your-"', () => {
153
+ const validKeys = ['sk-abc123', 'anthropic-key-xyz', 'real-api-key'];
154
+ for (const key of validKeys) {
155
+ expect(key.trim().startsWith('your-')).toBe(false);
156
+ }
157
+ });
158
+ });