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
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { existsSync, rmSync, mkdirSync } from 'fs';
|
|
4
|
+
import { join } from 'path';
|
|
5
|
+
import { registerBuiltinCommands } from './builtin.js';
|
|
6
|
+
import { commandRegistry, executeCommand } from './registry.js';
|
|
7
|
+
import type { CommandContext } from './types.js';
|
|
8
|
+
import type { HistoryItem } from '../components/HistoryItemView.js';
|
|
9
|
+
|
|
10
|
+
// Register commands once
|
|
11
|
+
registerBuiltinCommands();
|
|
12
|
+
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// Test helpers
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
function makeContext(overrides: Partial<CommandContext> = {}): CommandContext {
|
|
18
|
+
return {
|
|
19
|
+
provider: 'anthropic',
|
|
20
|
+
model: 'claude-sonnet-4-5',
|
|
21
|
+
history: [],
|
|
22
|
+
inMemoryChatHistoryRef: { current: null } as any,
|
|
23
|
+
setError: () => {},
|
|
24
|
+
clearHistory: () => {},
|
|
25
|
+
startSelection: () => {},
|
|
26
|
+
toggleDebug: () => {},
|
|
27
|
+
showDebug: false,
|
|
28
|
+
cumulativeTokens: 0,
|
|
29
|
+
cumulativeCost: 0,
|
|
30
|
+
turnCount: 0,
|
|
31
|
+
...overrides,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function makeHistoryItem(query: string, answer: string): HistoryItem {
|
|
36
|
+
return {
|
|
37
|
+
id: Date.now().toString(),
|
|
38
|
+
query,
|
|
39
|
+
events: [],
|
|
40
|
+
answer,
|
|
41
|
+
status: 'complete',
|
|
42
|
+
tokenUsage: { inputTokens: 100, outputTokens: 50, totalTokens: 150 },
|
|
43
|
+
duration: 1500,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
// Command registration
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
describe('builtin command registration', () => {
|
|
52
|
+
test('all expected commands are registered', () => {
|
|
53
|
+
const expectedCommands = ['help', 'clear', 'model', 'debug', 'cost', 'compact', 'history', 'export', 'verbose', 'shortcuts'];
|
|
54
|
+
for (const name of expectedCommands) {
|
|
55
|
+
expect(commandRegistry.get(name)).toBeDefined();
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('aliases are registered (new → clear)', () => {
|
|
60
|
+
const newCmd = commandRegistry.get('new');
|
|
61
|
+
expect(newCmd).toBeDefined();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('visible commands exclude hidden ones', () => {
|
|
65
|
+
const visible = commandRegistry.getVisible();
|
|
66
|
+
const names = visible.map(c => c.name);
|
|
67
|
+
// 'new' is hidden (alias for clear)
|
|
68
|
+
// The hidden 'new' in builtin has isHidden: true
|
|
69
|
+
// But note that 'clear' has aliases: ['new'], so 'new' maps to the 'clear' command object
|
|
70
|
+
// which is NOT hidden. The separate hidden 'new' command definition is also registered.
|
|
71
|
+
// The visible list should contain 'clear' but not the standalone hidden 'new'.
|
|
72
|
+
expect(names).toContain('clear');
|
|
73
|
+
expect(names).toContain('help');
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
// /clear command
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
describe('/clear command', () => {
|
|
82
|
+
test('calls clearHistory and returns confirmation', async () => {
|
|
83
|
+
let cleared = false;
|
|
84
|
+
const ctx = makeContext({ clearHistory: () => { cleared = true; } });
|
|
85
|
+
const result = await executeCommand('clear', [], ctx);
|
|
86
|
+
expect(cleared).toBe(true);
|
|
87
|
+
expect(result).toBe('Conversation cleared.');
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
// /model command
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
describe('/model command', () => {
|
|
96
|
+
test('calls startSelection', async () => {
|
|
97
|
+
let started = false;
|
|
98
|
+
const ctx = makeContext({ startSelection: () => { started = true; } });
|
|
99
|
+
await executeCommand('model', [], ctx);
|
|
100
|
+
expect(started).toBe(true);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
// /debug command
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
describe('/debug command', () => {
|
|
109
|
+
test('calls toggleDebug and returns message when debug was off', async () => {
|
|
110
|
+
let toggled = false;
|
|
111
|
+
const ctx = makeContext({
|
|
112
|
+
showDebug: false,
|
|
113
|
+
toggleDebug: () => { toggled = true; },
|
|
114
|
+
});
|
|
115
|
+
const result = await executeCommand('debug', [], ctx);
|
|
116
|
+
expect(toggled).toBe(true);
|
|
117
|
+
// showDebug is false → message says "Debug panel visible." (will become visible)
|
|
118
|
+
expect(result).toBe('Debug panel visible.');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('returns "hidden" message when debug was on', async () => {
|
|
122
|
+
const ctx = makeContext({ showDebug: true, toggleDebug: () => {} });
|
|
123
|
+
const result = await executeCommand('debug', [], ctx);
|
|
124
|
+
expect(result).toBe('Debug panel hidden.');
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
// /cost command
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
describe('/cost command', () => {
|
|
133
|
+
test('returns "no queries" when turnCount is 0', async () => {
|
|
134
|
+
const ctx = makeContext({ turnCount: 0 });
|
|
135
|
+
const result = await executeCommand('cost', [], ctx);
|
|
136
|
+
expect(result).toBe('No queries yet this session.');
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test('returns session stats when there are turns', async () => {
|
|
140
|
+
const ctx = makeContext({
|
|
141
|
+
turnCount: 3,
|
|
142
|
+
cumulativeTokens: 15000,
|
|
143
|
+
cumulativeCost: 0.045,
|
|
144
|
+
model: 'claude-sonnet-4-5',
|
|
145
|
+
});
|
|
146
|
+
const result = await executeCommand('cost', [], ctx) as string;
|
|
147
|
+
expect(result).toContain('Turns: 3');
|
|
148
|
+
expect(result).toContain('15,000');
|
|
149
|
+
expect(result).toContain('claude-sonnet-4-5');
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// ---------------------------------------------------------------------------
|
|
154
|
+
// /verbose command
|
|
155
|
+
// ---------------------------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
describe('/verbose command', () => {
|
|
158
|
+
test('toggles verbose setting and returns message', async () => {
|
|
159
|
+
const ctx = makeContext();
|
|
160
|
+
const result = await executeCommand('verbose', [], ctx) as string;
|
|
161
|
+
// First toggle: was false → now true
|
|
162
|
+
expect(typeof result).toBe('string');
|
|
163
|
+
expect(result).toMatch(/verbose mode/i);
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
// ---------------------------------------------------------------------------
|
|
168
|
+
// /help command
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
describe('/help command', () => {
|
|
172
|
+
test('returns a React element', async () => {
|
|
173
|
+
const ctx = makeContext();
|
|
174
|
+
const result = await executeCommand('help', [], ctx);
|
|
175
|
+
expect(React.isValidElement(result)).toBe(true);
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
// /shortcuts command
|
|
181
|
+
// ---------------------------------------------------------------------------
|
|
182
|
+
|
|
183
|
+
describe('/shortcuts command', () => {
|
|
184
|
+
test('returns a React element', async () => {
|
|
185
|
+
const ctx = makeContext();
|
|
186
|
+
const result = await executeCommand('shortcuts', [], ctx);
|
|
187
|
+
expect(React.isValidElement(result)).toBe(true);
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
// ---------------------------------------------------------------------------
|
|
192
|
+
// /export command
|
|
193
|
+
// ---------------------------------------------------------------------------
|
|
194
|
+
|
|
195
|
+
describe('/export command', () => {
|
|
196
|
+
const exportDir = join('.brownian', 'exports');
|
|
197
|
+
|
|
198
|
+
afterAll(() => {
|
|
199
|
+
// Clean up export files
|
|
200
|
+
if (existsSync(exportDir)) {
|
|
201
|
+
rmSync(exportDir, { recursive: true });
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test('exports markdown by default', async () => {
|
|
206
|
+
const historyItems = [makeHistoryItem('What is Bitcoin?', 'Bitcoin is a cryptocurrency.')];
|
|
207
|
+
const ctx = makeContext({ history: historyItems, model: 'claude-sonnet-4-5' });
|
|
208
|
+
const result = await executeCommand('export', [], ctx) as string;
|
|
209
|
+
|
|
210
|
+
expect(result).toContain('Exported 1 messages to');
|
|
211
|
+
expect(result).toContain('.md');
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test('exports json when arg is "json"', async () => {
|
|
215
|
+
const historyItems = [makeHistoryItem('What is ETH?', 'Ethereum is a blockchain platform.')];
|
|
216
|
+
const ctx = makeContext({ history: historyItems });
|
|
217
|
+
const result = await executeCommand('export', ['json'], ctx) as string;
|
|
218
|
+
|
|
219
|
+
expect(result).toContain('Exported 1 messages to');
|
|
220
|
+
expect(result).toContain('.json');
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test('handles empty history', async () => {
|
|
224
|
+
const ctx = makeContext({ history: [] });
|
|
225
|
+
const result = await executeCommand('export', [], ctx) as string;
|
|
226
|
+
|
|
227
|
+
expect(result).toContain('Exported 0 messages to');
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
// ---------------------------------------------------------------------------
|
|
232
|
+
// Unknown command
|
|
233
|
+
// ---------------------------------------------------------------------------
|
|
234
|
+
|
|
235
|
+
describe('unknown command', () => {
|
|
236
|
+
test('executeCommand returns undefined for unregistered command', async () => {
|
|
237
|
+
const ctx = makeContext();
|
|
238
|
+
const result = await executeCommand('nonexistent_xyz', [], ctx);
|
|
239
|
+
expect(result).toBeUndefined();
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
// ---------------------------------------------------------------------------
|
|
244
|
+
// /compact command
|
|
245
|
+
// ---------------------------------------------------------------------------
|
|
246
|
+
|
|
247
|
+
describe('/compact command', () => {
|
|
248
|
+
test('returns "not available" when runCompactQuery is not provided', async () => {
|
|
249
|
+
const ctx = makeContext();
|
|
250
|
+
const result = await executeCommand('compact', [], ctx);
|
|
251
|
+
expect(result).toBe('Compact not available.');
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test('calls runCompactQuery with instructions when provided', async () => {
|
|
255
|
+
let receivedInstructions: string | undefined;
|
|
256
|
+
const ctx = makeContext({
|
|
257
|
+
runCompactQuery: async (instructions) => { receivedInstructions = instructions; },
|
|
258
|
+
});
|
|
259
|
+
await executeCommand('compact', ['focus', 'on', 'DeFi'], ctx);
|
|
260
|
+
expect(receivedInstructions).toBe('focus on DeFi');
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
test('calls runCompactQuery with undefined when no instructions', async () => {
|
|
264
|
+
let receivedInstructions: string | undefined = 'should-be-overwritten';
|
|
265
|
+
const ctx = makeContext({
|
|
266
|
+
runCompactQuery: async (instructions) => { receivedInstructions = instructions; },
|
|
267
|
+
});
|
|
268
|
+
await executeCommand('compact', [], ctx);
|
|
269
|
+
expect(receivedInstructions).toBeUndefined();
|
|
270
|
+
});
|
|
271
|
+
});
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { writeFile, mkdir } from 'fs/promises';
|
|
3
|
+
import { existsSync } from 'fs';
|
|
4
|
+
import { join } from 'path';
|
|
5
|
+
import type { CommandDef, CommandContext } from './types.js';
|
|
6
|
+
import { commandRegistry } from './registry.js';
|
|
7
|
+
import { HelpView, ShortcutsView } from '../components/HelpView.js';
|
|
8
|
+
import { formatCost } from '../utils/cost-calculator.js';
|
|
9
|
+
import { LongTermChatHistory } from '../utils/long-term-chat-history.js';
|
|
10
|
+
import { getSetting, setSetting } from '../utils/config.js';
|
|
11
|
+
|
|
12
|
+
const builtinCommands: CommandDef[] = [
|
|
13
|
+
{
|
|
14
|
+
name: 'help',
|
|
15
|
+
description: 'Show commands & keyboard shortcuts',
|
|
16
|
+
type: 'local-jsx',
|
|
17
|
+
execute: () => React.createElement(HelpView),
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
name: 'clear',
|
|
21
|
+
description: 'Clear conversation history',
|
|
22
|
+
aliases: ['new'],
|
|
23
|
+
type: 'local',
|
|
24
|
+
execute: (_args, ctx) => {
|
|
25
|
+
ctx.clearHistory();
|
|
26
|
+
return 'Conversation cleared.';
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
name: 'model',
|
|
31
|
+
description: 'Switch model/provider',
|
|
32
|
+
type: 'local',
|
|
33
|
+
execute: (_args, ctx) => {
|
|
34
|
+
ctx.startSelection();
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
name: 'debug',
|
|
39
|
+
description: 'Toggle debug panel',
|
|
40
|
+
type: 'local',
|
|
41
|
+
execute: (_args, ctx) => {
|
|
42
|
+
ctx.toggleDebug();
|
|
43
|
+
return ctx.showDebug ? 'Debug panel hidden.' : 'Debug panel visible.';
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
name: 'cost',
|
|
48
|
+
description: 'Show session token/cost stats',
|
|
49
|
+
type: 'local',
|
|
50
|
+
execute: (_args, ctx) => {
|
|
51
|
+
if (ctx.turnCount === 0) {
|
|
52
|
+
return 'No queries yet this session.';
|
|
53
|
+
}
|
|
54
|
+
const lines = [
|
|
55
|
+
`Session Stats:`,
|
|
56
|
+
` Turns: ${ctx.turnCount}`,
|
|
57
|
+
` Tokens: ${ctx.cumulativeTokens.toLocaleString()}`,
|
|
58
|
+
` Cost: ${formatCost(ctx.cumulativeCost)}`,
|
|
59
|
+
` Model: ${ctx.model}`,
|
|
60
|
+
];
|
|
61
|
+
return lines.join('\n');
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
name: 'compact',
|
|
66
|
+
description: 'Summarize & clear old context',
|
|
67
|
+
argHint: '[instructions]',
|
|
68
|
+
type: 'local',
|
|
69
|
+
execute: async (args, ctx) => {
|
|
70
|
+
if (ctx.runCompactQuery) {
|
|
71
|
+
const instructions = args.join(' ') || undefined;
|
|
72
|
+
await ctx.runCompactQuery(instructions);
|
|
73
|
+
return; // runCompactQuery handles the message
|
|
74
|
+
}
|
|
75
|
+
return 'Compact not available.';
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
name: 'history',
|
|
80
|
+
description: 'Show past queries',
|
|
81
|
+
argHint: '[search term]',
|
|
82
|
+
type: 'local',
|
|
83
|
+
execute: async (args) => {
|
|
84
|
+
const longTermHistory = new LongTermChatHistory();
|
|
85
|
+
await longTermHistory.load();
|
|
86
|
+
const entries = longTermHistory.getMessages();
|
|
87
|
+
|
|
88
|
+
if (entries.length === 0) {
|
|
89
|
+
return 'No history found.';
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const searchTerm = args.join(' ').toLowerCase();
|
|
93
|
+
const filtered = searchTerm
|
|
94
|
+
? entries.filter(e => e.userMessage.toLowerCase().includes(searchTerm))
|
|
95
|
+
: entries.slice(0, 10);
|
|
96
|
+
|
|
97
|
+
if (filtered.length === 0) {
|
|
98
|
+
return `No history matching "${searchTerm}".`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const lines = filtered.map(e => {
|
|
102
|
+
const date = new Date(e.timestamp).toLocaleDateString('en-US', {
|
|
103
|
+
month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit',
|
|
104
|
+
});
|
|
105
|
+
const msg = e.userMessage.length > 60
|
|
106
|
+
? e.userMessage.slice(0, 57) + '...'
|
|
107
|
+
: e.userMessage;
|
|
108
|
+
return ` ${date} ${msg}`;
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const header = searchTerm
|
|
112
|
+
? `History matching "${searchTerm}" (${filtered.length}):`
|
|
113
|
+
: `Recent queries (${filtered.length}):`;
|
|
114
|
+
|
|
115
|
+
return [header, ...lines].join('\n');
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
name: 'export',
|
|
120
|
+
description: 'Export conversation to file',
|
|
121
|
+
argHint: '[json]',
|
|
122
|
+
type: 'local',
|
|
123
|
+
execute: async (args, ctx) => {
|
|
124
|
+
const format = args[0]?.toLowerCase() === 'json' ? 'json' : 'md';
|
|
125
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
|
126
|
+
const dir = join('.brownian', 'exports');
|
|
127
|
+
const filename = `${timestamp}.${format}`;
|
|
128
|
+
const filepath = join(dir, filename);
|
|
129
|
+
|
|
130
|
+
if (!existsSync(dir)) {
|
|
131
|
+
await mkdir(dir, { recursive: true });
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
let content: string;
|
|
135
|
+
if (format === 'json') {
|
|
136
|
+
content = JSON.stringify(ctx.history.map(h => ({
|
|
137
|
+
query: h.query,
|
|
138
|
+
answer: h.answer,
|
|
139
|
+
status: h.status,
|
|
140
|
+
tokenUsage: h.tokenUsage,
|
|
141
|
+
duration: h.duration,
|
|
142
|
+
})), null, 2);
|
|
143
|
+
} else {
|
|
144
|
+
const sections = ctx.history.map(h => {
|
|
145
|
+
const lines = [`## ${h.query}\n`];
|
|
146
|
+
if (h.events.length > 0) {
|
|
147
|
+
const toolCalls = h.events.filter(e => e.event.type === 'tool_start');
|
|
148
|
+
if (toolCalls.length > 0) {
|
|
149
|
+
lines.push('**Tools used:** ' + toolCalls.map(e =>
|
|
150
|
+
(e.event as { tool: string }).tool
|
|
151
|
+
).join(', ') + '\n');
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
if (h.answer) {
|
|
155
|
+
lines.push(h.answer + '\n');
|
|
156
|
+
}
|
|
157
|
+
return lines.join('\n');
|
|
158
|
+
});
|
|
159
|
+
content = `# Brownian Code Export\n\nDate: ${new Date().toISOString()}\nModel: ${ctx.model}\n\n---\n\n${sections.join('\n---\n\n')}`;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
await writeFile(filepath, content, 'utf-8');
|
|
163
|
+
return `Exported ${ctx.history.length} messages to ${filepath}`;
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
name: 'verbose',
|
|
168
|
+
description: 'Toggle verbose tool output',
|
|
169
|
+
type: 'local',
|
|
170
|
+
execute: () => {
|
|
171
|
+
const current = getSetting('verbose', false);
|
|
172
|
+
setSetting('verbose', !current);
|
|
173
|
+
return current ? 'Verbose mode off.' : 'Verbose mode on.';
|
|
174
|
+
},
|
|
175
|
+
},
|
|
176
|
+
{
|
|
177
|
+
name: 'shortcuts',
|
|
178
|
+
description: 'Show keyboard shortcuts',
|
|
179
|
+
type: 'local-jsx',
|
|
180
|
+
execute: () => React.createElement(ShortcutsView),
|
|
181
|
+
},
|
|
182
|
+
// Hidden aliases
|
|
183
|
+
{
|
|
184
|
+
name: 'new',
|
|
185
|
+
description: 'Start new conversation',
|
|
186
|
+
type: 'local',
|
|
187
|
+
isHidden: true,
|
|
188
|
+
execute: (_args, ctx) => {
|
|
189
|
+
ctx.clearHistory();
|
|
190
|
+
return 'Conversation cleared.';
|
|
191
|
+
},
|
|
192
|
+
},
|
|
193
|
+
];
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Register all builtin commands with the global registry.
|
|
197
|
+
*/
|
|
198
|
+
export function registerBuiltinCommands(): void {
|
|
199
|
+
commandRegistry.registerAll(builtinCommands);
|
|
200
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { describe, test, expect, beforeEach } from 'bun:test';
|
|
2
|
+
import { parseCommand, commandRegistry } from './registry.js';
|
|
3
|
+
import type { CommandDef } from './types.js';
|
|
4
|
+
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// parseCommand
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
|
|
9
|
+
describe('parseCommand', () => {
|
|
10
|
+
test('parses simple command', () => {
|
|
11
|
+
const result = parseCommand('/help');
|
|
12
|
+
expect(result).toEqual({ name: 'help', args: [] });
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test('parses command with single arg', () => {
|
|
16
|
+
const result = parseCommand('/history search');
|
|
17
|
+
expect(result).toEqual({ name: 'history', args: ['search'] });
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test('parses command with multiple args', () => {
|
|
21
|
+
const result = parseCommand('/history search bitcoin defi');
|
|
22
|
+
expect(result).toEqual({ name: 'history', args: ['search', 'bitcoin', 'defi'] });
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test('normalizes command name to lowercase', () => {
|
|
26
|
+
const result = parseCommand('/HELP');
|
|
27
|
+
expect(result).toEqual({ name: 'help', args: [] });
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('handles leading/trailing whitespace', () => {
|
|
31
|
+
const result = parseCommand(' /help ');
|
|
32
|
+
expect(result).toEqual({ name: 'help', args: [] });
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('returns null for non-command input', () => {
|
|
36
|
+
expect(parseCommand('hello world')).toBeNull();
|
|
37
|
+
expect(parseCommand('what is bitcoin')).toBeNull();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('returns null for empty string', () => {
|
|
41
|
+
expect(parseCommand('')).toBeNull();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('returns null for just a slash', () => {
|
|
45
|
+
expect(parseCommand('/')).toBeNull();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('returns null for whitespace-only string', () => {
|
|
49
|
+
expect(parseCommand(' ')).toBeNull();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('handles extra spaces between args', () => {
|
|
53
|
+
const result = parseCommand('/export json');
|
|
54
|
+
expect(result).toEqual({ name: 'export', args: ['json'] });
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
// CommandRegistry
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
describe('CommandRegistry', () => {
|
|
63
|
+
// Register test commands before each test
|
|
64
|
+
const testCmd: CommandDef = {
|
|
65
|
+
name: 'testcmd',
|
|
66
|
+
description: 'A test command',
|
|
67
|
+
type: 'local',
|
|
68
|
+
execute: () => 'test result',
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const aliasedCmd: CommandDef = {
|
|
72
|
+
name: 'aliased',
|
|
73
|
+
description: 'A command with aliases',
|
|
74
|
+
aliases: ['al', 'als'],
|
|
75
|
+
type: 'local',
|
|
76
|
+
execute: () => 'aliased result',
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const hiddenCmd: CommandDef = {
|
|
80
|
+
name: 'hiddencmd',
|
|
81
|
+
description: 'A hidden command',
|
|
82
|
+
type: 'local',
|
|
83
|
+
isHidden: true,
|
|
84
|
+
execute: () => 'hidden result',
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
// -------------------------------------------------------------------------
|
|
88
|
+
// get
|
|
89
|
+
// -------------------------------------------------------------------------
|
|
90
|
+
describe('get', () => {
|
|
91
|
+
test('retrieves a registered command by name', () => {
|
|
92
|
+
commandRegistry.register(testCmd);
|
|
93
|
+
expect(commandRegistry.get('testcmd')).toBe(testCmd);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('retrieves a command by alias', () => {
|
|
97
|
+
commandRegistry.register(aliasedCmd);
|
|
98
|
+
expect(commandRegistry.get('al')).toBe(aliasedCmd);
|
|
99
|
+
expect(commandRegistry.get('als')).toBe(aliasedCmd);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('returns undefined for unregistered command', () => {
|
|
103
|
+
expect(commandRegistry.get('nonexistent')).toBeUndefined();
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// -------------------------------------------------------------------------
|
|
108
|
+
// getVisible
|
|
109
|
+
// -------------------------------------------------------------------------
|
|
110
|
+
describe('getVisible', () => {
|
|
111
|
+
test('excludes hidden commands', () => {
|
|
112
|
+
commandRegistry.register(hiddenCmd);
|
|
113
|
+
const visible = commandRegistry.getVisible();
|
|
114
|
+
const names = visible.map(c => c.name);
|
|
115
|
+
expect(names).not.toContain('hiddencmd');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('returns unique commands (no duplicates from aliases)', () => {
|
|
119
|
+
commandRegistry.register(aliasedCmd);
|
|
120
|
+
const visible = commandRegistry.getVisible();
|
|
121
|
+
const aliasedCount = visible.filter(c => c.name === 'aliased').length;
|
|
122
|
+
expect(aliasedCount).toBe(1);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// -------------------------------------------------------------------------
|
|
127
|
+
// getAll
|
|
128
|
+
// -------------------------------------------------------------------------
|
|
129
|
+
describe('getAll', () => {
|
|
130
|
+
test('includes hidden commands', () => {
|
|
131
|
+
commandRegistry.register(hiddenCmd);
|
|
132
|
+
const all = commandRegistry.getAll();
|
|
133
|
+
const names = all.map(c => c.name);
|
|
134
|
+
expect(names).toContain('hiddencmd');
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// -------------------------------------------------------------------------
|
|
139
|
+
// search
|
|
140
|
+
// -------------------------------------------------------------------------
|
|
141
|
+
describe('search', () => {
|
|
142
|
+
test('returns all visible commands for empty prefix', () => {
|
|
143
|
+
const results = commandRegistry.search('');
|
|
144
|
+
expect(results.length).toBeGreaterThan(0);
|
|
145
|
+
// Should not include hidden commands
|
|
146
|
+
const names = results.map(c => c.name);
|
|
147
|
+
expect(names).not.toContain('hiddencmd');
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test('returns matching commands by prefix', () => {
|
|
151
|
+
// 'help' should be registered from builtin commands
|
|
152
|
+
const results = commandRegistry.search('hel');
|
|
153
|
+
const names = results.map(c => c.name);
|
|
154
|
+
expect(names).toContain('help');
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test('returns matching commands by substring', () => {
|
|
158
|
+
const results = commandRegistry.search('port');
|
|
159
|
+
const names = results.map(c => c.name);
|
|
160
|
+
expect(names).toContain('export');
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test('prioritizes prefix matches over substring matches', () => {
|
|
164
|
+
// 'he' should put 'help' before anything with 'he' only as substring
|
|
165
|
+
const results = commandRegistry.search('he');
|
|
166
|
+
expect(results.length).toBeGreaterThan(0);
|
|
167
|
+
expect(results[0].name).toBe('help');
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test('matches against aliases', () => {
|
|
171
|
+
commandRegistry.register(aliasedCmd);
|
|
172
|
+
const results = commandRegistry.search('al');
|
|
173
|
+
const names = results.map(c => c.name);
|
|
174
|
+
expect(names).toContain('aliased');
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test('is case-insensitive', () => {
|
|
178
|
+
const results = commandRegistry.search('HELP');
|
|
179
|
+
const names = results.map(c => c.name);
|
|
180
|
+
expect(names).toContain('help');
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test('returns empty array for no matches', () => {
|
|
184
|
+
const results = commandRegistry.search('zzzzz');
|
|
185
|
+
expect(results).toEqual([]);
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
});
|