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,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
+ });