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.
- package/LICENSE +21 -0
- package/README.md +97 -0
- package/bin/brownian +25 -0
- package/env.example +21 -0
- package/package.json +87 -0
- package/src/agent/agent.test.ts +414 -0
- package/src/agent/agent.ts +385 -0
- package/src/agent/index.ts +27 -0
- package/src/agent/prompts.ts +271 -0
- package/src/agent/scratchpad.test.ts +482 -0
- package/src/agent/scratchpad.ts +526 -0
- package/src/agent/token-counter.test.ts +59 -0
- package/src/agent/token-counter.ts +33 -0
- package/src/agent/types.ts +137 -0
- package/src/cli.tsx +385 -0
- package/src/commands/builtin.test.ts +271 -0
- package/src/commands/builtin.ts +200 -0
- package/src/commands/registry.test.ts +188 -0
- package/src/commands/registry.ts +111 -0
- package/src/commands/types.ts +64 -0
- package/src/components/AgentEventView.tsx +487 -0
- package/src/components/AnswerBox.tsx +81 -0
- package/src/components/ApiKeyPrompt.tsx +75 -0
- package/src/components/CommandMenu.test.tsx +64 -0
- package/src/components/CommandMenu.tsx +38 -0
- package/src/components/CursorText.tsx +43 -0
- package/src/components/DebugPanel.tsx +48 -0
- package/src/components/ErrorBox.test.tsx +58 -0
- package/src/components/ErrorBox.tsx +26 -0
- package/src/components/HelpView.test.tsx +70 -0
- package/src/components/HelpView.tsx +61 -0
- package/src/components/HistoryItemView.tsx +108 -0
- package/src/components/Input.tsx +193 -0
- package/src/components/Intro.test.tsx +59 -0
- package/src/components/Intro.tsx +35 -0
- package/src/components/ModelSelector.tsx +288 -0
- package/src/components/StatusBar.test.tsx +78 -0
- package/src/components/StatusBar.tsx +56 -0
- package/src/components/WorkingIndicator.tsx +133 -0
- package/src/components/index.ts +23 -0
- package/src/e2e/agent-flow.test.ts +378 -0
- package/src/evals/components/EvalApp.tsx +206 -0
- package/src/evals/components/EvalCurrentQuestion.tsx +42 -0
- package/src/evals/components/EvalProgress.tsx +33 -0
- package/src/evals/components/EvalRecentResults.tsx +63 -0
- package/src/evals/components/EvalStats.tsx +49 -0
- package/src/evals/components/index.ts +5 -0
- package/src/evals/dataset/crypto_agent.csv +16 -0
- package/src/evals/run.ts +355 -0
- package/src/gateway/channels/whatsapp/auth-store.ts +15 -0
- package/src/gateway/channels/whatsapp/inbound.ts +86 -0
- package/src/gateway/channels/whatsapp/login.ts +28 -0
- package/src/gateway/channels/whatsapp/outbound.ts +27 -0
- package/src/gateway/channels/whatsapp/session.ts +69 -0
- package/src/gateway/config.ts +81 -0
- package/src/gateway/index.ts +62 -0
- package/src/hooks/useAgentRunner.ts +317 -0
- package/src/hooks/useDebugLogs.ts +22 -0
- package/src/hooks/useInputHistory.ts +106 -0
- package/src/hooks/useModelSelection.ts +249 -0
- package/src/hooks/useTextBuffer.test.ts +121 -0
- package/src/hooks/useTextBuffer.ts +97 -0
- package/src/index.tsx +74 -0
- package/src/mcp/cache.ts +205 -0
- package/src/mcp/client.test.ts +126 -0
- package/src/mcp/client.ts +145 -0
- package/src/mcp/index.ts +2 -0
- package/src/model/llm.test.ts +158 -0
- package/src/model/llm.ts +233 -0
- package/src/providers.ts +94 -0
- package/src/skills/index.ts +17 -0
- package/src/skills/loader.ts +73 -0
- package/src/skills/registry.ts +125 -0
- package/src/skills/types.ts +31 -0
- package/src/test-utils/mocks.ts +110 -0
- package/src/theme.ts +21 -0
- package/src/tools/browser/browser.ts +357 -0
- package/src/tools/browser/index.ts +1 -0
- package/src/tools/crypto/hive-tools.ts +171 -0
- package/src/tools/crypto/index.ts +1 -0
- package/src/tools/descriptions/browser.ts +105 -0
- package/src/tools/descriptions/crypto-search.ts +58 -0
- package/src/tools/descriptions/index.ts +8 -0
- package/src/tools/descriptions/web-fetch.ts +44 -0
- package/src/tools/descriptions/web-search.ts +26 -0
- package/src/tools/fetch/cache.ts +95 -0
- package/src/tools/fetch/external-content.ts +200 -0
- package/src/tools/fetch/index.ts +1 -0
- package/src/tools/fetch/web-fetch-utils.ts +122 -0
- package/src/tools/fetch/web-fetch.ts +371 -0
- package/src/tools/index.ts +12 -0
- package/src/tools/registry.ts +130 -0
- package/src/tools/search/exa.ts +43 -0
- package/src/tools/search/index.ts +2 -0
- package/src/tools/search/tavily.ts +35 -0
- package/src/tools/skill.ts +62 -0
- package/src/tools/types.ts +53 -0
- package/src/utils/ai-message.ts +26 -0
- package/src/utils/config.ts +54 -0
- package/src/utils/cost-calculator.test.ts +101 -0
- package/src/utils/cost-calculator.ts +74 -0
- package/src/utils/env.ts +101 -0
- package/src/utils/error-classifier.test.ts +146 -0
- package/src/utils/error-classifier.ts +91 -0
- package/src/utils/in-memory-chat-history.test.ts +291 -0
- package/src/utils/in-memory-chat-history.ts +224 -0
- package/src/utils/index.ts +19 -0
- package/src/utils/input-key-handlers.test.ts +155 -0
- package/src/utils/input-key-handlers.ts +64 -0
- package/src/utils/logger.ts +67 -0
- package/src/utils/long-term-chat-history.ts +138 -0
- package/src/utils/markdown-table.ts +227 -0
- package/src/utils/ollama.ts +37 -0
- package/src/utils/progress-channel.ts +84 -0
- package/src/utils/text-navigation.test.ts +222 -0
- package/src/utils/text-navigation.ts +81 -0
- package/src/utils/thinking-verbs.ts +29 -0
- package/src/utils/tokens.test.ts +163 -0
- package/src/utils/tokens.ts +67 -0
- package/src/utils/tool-description.ts +88 -0
package/src/mcp/cache.ts
ADDED
|
@@ -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
|
+
}
|
package/src/mcp/index.ts
ADDED
|
@@ -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
|
+
});
|