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,222 @@
|
|
|
1
|
+
import { describe, test, expect } from 'bun:test';
|
|
2
|
+
import {
|
|
3
|
+
findPrevWordStart,
|
|
4
|
+
findNextWordEnd,
|
|
5
|
+
getLineAndColumn,
|
|
6
|
+
getCursorPosition,
|
|
7
|
+
getLineStart,
|
|
8
|
+
getLineEnd,
|
|
9
|
+
getLineCount,
|
|
10
|
+
} from './text-navigation.js';
|
|
11
|
+
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// findPrevWordStart
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
describe('findPrevWordStart', () => {
|
|
17
|
+
test('moves to start of current word', () => {
|
|
18
|
+
// "hello world" with cursor at position 8 (middle of "world")
|
|
19
|
+
expect(findPrevWordStart('hello world', 8)).toBe(6);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test('skips whitespace to previous word', () => {
|
|
23
|
+
// "hello world" with cursor at position 6 (start of "world")
|
|
24
|
+
expect(findPrevWordStart('hello world', 6)).toBe(0);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('returns 0 at beginning of text', () => {
|
|
28
|
+
expect(findPrevWordStart('hello', 0)).toBe(0);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('returns 0 from position 1', () => {
|
|
32
|
+
expect(findPrevWordStart('hello', 1)).toBe(0);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('handles empty string', () => {
|
|
36
|
+
expect(findPrevWordStart('', 0)).toBe(0);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('handles punctuation', () => {
|
|
40
|
+
// "foo.bar" cursor at end → should go to "bar" start (position 4)
|
|
41
|
+
expect(findPrevWordStart('foo.bar', 7)).toBe(4);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('handles multiple spaces', () => {
|
|
45
|
+
// "hello world" cursor at 10 (in "world")
|
|
46
|
+
expect(findPrevWordStart('hello world', 10)).toBe(8);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('handles cursor at end of single word', () => {
|
|
50
|
+
expect(findPrevWordStart('hello', 5)).toBe(0);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
// findNextWordEnd
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
describe('findNextWordEnd', () => {
|
|
59
|
+
test('moves to end of current word', () => {
|
|
60
|
+
// "hello world" cursor at 0 → end of "hello" = position 5
|
|
61
|
+
expect(findNextWordEnd('hello world', 0)).toBe(5);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('skips whitespace to next word end', () => {
|
|
65
|
+
// "hello world" cursor at 5 → end of "world" = position 11
|
|
66
|
+
expect(findNextWordEnd('hello world', 5)).toBe(11);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('returns text length at end', () => {
|
|
70
|
+
expect(findNextWordEnd('hello', 5)).toBe(5);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('handles empty string', () => {
|
|
74
|
+
expect(findNextWordEnd('', 0)).toBe(0);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('handles punctuation', () => {
|
|
78
|
+
// "foo.bar" cursor at 0 → end of "foo" = position 3
|
|
79
|
+
expect(findNextWordEnd('foo.bar', 0)).toBe(3);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('handles cursor beyond text length', () => {
|
|
83
|
+
expect(findNextWordEnd('hello', 10)).toBe(5);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
// getLineAndColumn
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
describe('getLineAndColumn', () => {
|
|
92
|
+
test('returns line 0 col 0 for position 0', () => {
|
|
93
|
+
expect(getLineAndColumn('hello\nworld', 0)).toEqual({ line: 0, column: 0 });
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('returns correct position within first line', () => {
|
|
97
|
+
expect(getLineAndColumn('hello\nworld', 3)).toEqual({ line: 0, column: 3 });
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('returns start of second line', () => {
|
|
101
|
+
// Position 6 is 'w' in "world"
|
|
102
|
+
expect(getLineAndColumn('hello\nworld', 6)).toEqual({ line: 1, column: 0 });
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('handles single-line text', () => {
|
|
106
|
+
expect(getLineAndColumn('hello', 3)).toEqual({ line: 0, column: 3 });
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('handles empty string', () => {
|
|
110
|
+
expect(getLineAndColumn('', 0)).toEqual({ line: 0, column: 0 });
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('handles cursor at newline character', () => {
|
|
114
|
+
// Position 5 is the '\n' in "hello\nworld"
|
|
115
|
+
expect(getLineAndColumn('hello\nworld', 5)).toEqual({ line: 0, column: 5 });
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('handles three lines', () => {
|
|
119
|
+
const text = 'line1\nline2\nline3';
|
|
120
|
+
// Position 12 is 'l' of "line3"
|
|
121
|
+
expect(getLineAndColumn(text, 12)).toEqual({ line: 2, column: 0 });
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
// getCursorPosition
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
describe('getCursorPosition', () => {
|
|
130
|
+
test('returns position for line 0 col 0', () => {
|
|
131
|
+
expect(getCursorPosition('hello\nworld', 0, 0)).toBe(0);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test('returns position for line 1 col 0', () => {
|
|
135
|
+
expect(getCursorPosition('hello\nworld', 1, 0)).toBe(6);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('clamps column to line length', () => {
|
|
139
|
+
// "hi\nworld" → line 0 has length 2, asking for col 10 should clamp to 2
|
|
140
|
+
expect(getCursorPosition('hi\nworld', 0, 10)).toBe(2);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('handles single-line text', () => {
|
|
144
|
+
expect(getCursorPosition('hello', 0, 3)).toBe(3);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test('handles three lines', () => {
|
|
148
|
+
const text = 'abc\ndef\nghi';
|
|
149
|
+
expect(getCursorPosition(text, 2, 1)).toBe(9); // 'abc\n' (4) + 'def\n' (4) + 1 = 9
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// ---------------------------------------------------------------------------
|
|
154
|
+
// getLineStart
|
|
155
|
+
// ---------------------------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
describe('getLineStart', () => {
|
|
158
|
+
test('returns 0 for first line', () => {
|
|
159
|
+
expect(getLineStart('hello\nworld', 3)).toBe(0);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test('returns start of second line', () => {
|
|
163
|
+
// Position 8 is in "world", line starts at 6
|
|
164
|
+
expect(getLineStart('hello\nworld', 8)).toBe(6);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test('handles single-line text', () => {
|
|
168
|
+
expect(getLineStart('hello', 3)).toBe(0);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test('handles cursor at position 0', () => {
|
|
172
|
+
expect(getLineStart('hello\nworld', 0)).toBe(0);
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
// ---------------------------------------------------------------------------
|
|
177
|
+
// getLineEnd
|
|
178
|
+
// ---------------------------------------------------------------------------
|
|
179
|
+
|
|
180
|
+
describe('getLineEnd', () => {
|
|
181
|
+
test('returns position of newline for first line', () => {
|
|
182
|
+
expect(getLineEnd('hello\nworld', 3)).toBe(5);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test('returns text length for last line', () => {
|
|
186
|
+
expect(getLineEnd('hello\nworld', 8)).toBe(11);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test('handles single-line text', () => {
|
|
190
|
+
expect(getLineEnd('hello', 3)).toBe(5);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test('handles cursor at newline', () => {
|
|
194
|
+
expect(getLineEnd('hello\nworld', 5)).toBe(5);
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
// ---------------------------------------------------------------------------
|
|
199
|
+
// getLineCount
|
|
200
|
+
// ---------------------------------------------------------------------------
|
|
201
|
+
|
|
202
|
+
describe('getLineCount', () => {
|
|
203
|
+
test('returns 1 for single line', () => {
|
|
204
|
+
expect(getLineCount('hello')).toBe(1);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test('returns 2 for two lines', () => {
|
|
208
|
+
expect(getLineCount('hello\nworld')).toBe(2);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test('returns 3 for three lines', () => {
|
|
212
|
+
expect(getLineCount('a\nb\nc')).toBe(3);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test('returns 1 for empty string', () => {
|
|
216
|
+
expect(getLineCount('')).toBe(1);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test('counts trailing newline as extra line', () => {
|
|
220
|
+
expect(getLineCount('hello\n')).toBe(2);
|
|
221
|
+
});
|
|
222
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Find the start position of the previous word from cursor position.
|
|
3
|
+
* Used for Option+Left (Mac) / Ctrl+Left (Windows) navigation.
|
|
4
|
+
*/
|
|
5
|
+
export function findPrevWordStart(text: string, pos: number): number {
|
|
6
|
+
if (pos <= 0) return 0;
|
|
7
|
+
let i = pos - 1;
|
|
8
|
+
// Skip non-word chars
|
|
9
|
+
while (i > 0 && !/\w/.test(text[i])) i--;
|
|
10
|
+
// Move to word start
|
|
11
|
+
while (i > 0 && /\w/.test(text[i - 1])) i--;
|
|
12
|
+
return i;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Find the end position of the next word from cursor position.
|
|
17
|
+
* Used for Option+Right (Mac) / Ctrl+Right (Windows) navigation.
|
|
18
|
+
*/
|
|
19
|
+
export function findNextWordEnd(text: string, pos: number): number {
|
|
20
|
+
const len = text.length;
|
|
21
|
+
if (pos >= len) return len;
|
|
22
|
+
let i = pos;
|
|
23
|
+
// Skip non-word chars
|
|
24
|
+
while (i < len && !/\w/.test(text[i])) i++;
|
|
25
|
+
// Move to word end
|
|
26
|
+
while (i < len && /\w/.test(text[i])) i++;
|
|
27
|
+
return i;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ============================================================================
|
|
31
|
+
// Multi-line cursor navigation utilities
|
|
32
|
+
// ============================================================================
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Get the line number (0-indexed) and column from a cursor position.
|
|
36
|
+
*/
|
|
37
|
+
export function getLineAndColumn(text: string, pos: number): { line: number; column: number } {
|
|
38
|
+
const beforeCursor = text.slice(0, pos);
|
|
39
|
+
const lines = beforeCursor.split('\n');
|
|
40
|
+
return {
|
|
41
|
+
line: lines.length - 1,
|
|
42
|
+
column: lines[lines.length - 1].length,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Get cursor position from line number and column.
|
|
48
|
+
* Clamps column to the actual line length if it exceeds.
|
|
49
|
+
*/
|
|
50
|
+
export function getCursorPosition(text: string, line: number, column: number): number {
|
|
51
|
+
const lines = text.split('\n');
|
|
52
|
+
let pos = 0;
|
|
53
|
+
for (let i = 0; i < line && i < lines.length; i++) {
|
|
54
|
+
pos += lines[i].length + 1; // +1 for newline
|
|
55
|
+
}
|
|
56
|
+
const targetLine = lines[line] || '';
|
|
57
|
+
return pos + Math.min(column, targetLine.length);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Get the start position of the line containing the cursor.
|
|
62
|
+
*/
|
|
63
|
+
export function getLineStart(text: string, pos: number): number {
|
|
64
|
+
const lastNewline = text.lastIndexOf('\n', pos - 1);
|
|
65
|
+
return lastNewline + 1; // -1 + 1 = 0 if no newline found
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Get the end position of the line containing the cursor (before the newline).
|
|
70
|
+
*/
|
|
71
|
+
export function getLineEnd(text: string, pos: number): number {
|
|
72
|
+
const nextNewline = text.indexOf('\n', pos);
|
|
73
|
+
return nextNewline === -1 ? text.length : nextNewline;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Get the total number of lines in the text.
|
|
78
|
+
*/
|
|
79
|
+
export function getLineCount(text: string): number {
|
|
80
|
+
return text.split('\n').length;
|
|
81
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export const THINKING_VERBS = [
|
|
2
|
+
'Alchemizing', 'Ambling', 'Baking', 'Boogieing',
|
|
3
|
+
'Brainstorming', 'Brewing', 'Buffering', 'Buzzing',
|
|
4
|
+
'Cerebrating', 'Chugging', 'Chugging along', 'Churning',
|
|
5
|
+
'Clauding', 'Cogitating', 'Concocting', 'Conjuring',
|
|
6
|
+
'Cooking', 'Crafting', 'Cruising', 'Daydreaming',
|
|
7
|
+
'Defragmenting', 'Deliberating', 'Dillydallying', 'Divining',
|
|
8
|
+
'Enchanting', 'Fermenting', 'Fiddling', 'Finagling',
|
|
9
|
+
'Finessing', 'Forging', 'Futzing', 'Gallivanting',
|
|
10
|
+
'Gliding', 'Grooving', 'Hatching', 'Hemming and hawing',
|
|
11
|
+
'Humming', 'Hustling', 'Ideating', 'Incanting',
|
|
12
|
+
'Invoking', 'Juggling', 'Kneading', 'Manifesting',
|
|
13
|
+
'Marinating', 'Moseying', 'Mulling', 'Musing',
|
|
14
|
+
'Noodling', 'Percolating', 'Plotting', 'Pondering',
|
|
15
|
+
'Pottering', 'Prancing', 'Purring', 'Puttering',
|
|
16
|
+
'Puzzling', 'Reticulating', 'Revving', 'Riffing',
|
|
17
|
+
'Ruminating', 'Sashaying', 'Sautéing', 'Scampering',
|
|
18
|
+
'Scheming', 'Scribbling', 'Sculpting', 'Seasoning',
|
|
19
|
+
'Shimmying', 'Simmering', 'Sketching', 'Sorcering',
|
|
20
|
+
'Spellcasting', 'Stewing', 'Summoning', 'Swooshing',
|
|
21
|
+
'Thrumming', 'Tinkering', 'Transmuting', 'Trotting',
|
|
22
|
+
'Vibing', 'Waddling', 'Warming up', 'Whipping up',
|
|
23
|
+
'Whirring', 'Whittling', 'Wizarding', 'Woolgathering',
|
|
24
|
+
'Wrangling', 'Zipping', 'Zooming',
|
|
25
|
+
] as const;
|
|
26
|
+
|
|
27
|
+
export function getRandomThinkingVerb(): string {
|
|
28
|
+
return THINKING_VERBS[Math.floor(Math.random() * THINKING_VERBS.length)];
|
|
29
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { describe, test, expect } from 'bun:test';
|
|
2
|
+
import {
|
|
3
|
+
estimateTokens,
|
|
4
|
+
getContextWindow,
|
|
5
|
+
getContextThreshold,
|
|
6
|
+
TOKEN_BUDGET,
|
|
7
|
+
KEEP_TOOL_USES,
|
|
8
|
+
} from './tokens.js';
|
|
9
|
+
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// Constants
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
describe('token constants', () => {
|
|
15
|
+
test('TOKEN_BUDGET is 150_000', () => {
|
|
16
|
+
expect(TOKEN_BUDGET).toBe(150_000);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test('KEEP_TOOL_USES is 5', () => {
|
|
20
|
+
expect(KEEP_TOOL_USES).toBe(5);
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// estimateTokens
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
describe('estimateTokens', () => {
|
|
29
|
+
test('empty string returns 0', () => {
|
|
30
|
+
expect(estimateTokens('')).toBe(0);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('returns ceil(length / 3.5)', () => {
|
|
34
|
+
// 7 chars / 3.5 = 2.0 → 2
|
|
35
|
+
expect(estimateTokens('abcdefg')).toBe(2);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('rounds up for non-integer result', () => {
|
|
39
|
+
// 10 chars / 3.5 = 2.857… → 3
|
|
40
|
+
expect(estimateTokens('0123456789')).toBe(3);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('handles single character', () => {
|
|
44
|
+
// 1 / 3.5 = 0.285… → 1
|
|
45
|
+
expect(estimateTokens('a')).toBe(1);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('handles long string', () => {
|
|
49
|
+
const text = 'x'.repeat(35_000);
|
|
50
|
+
expect(estimateTokens(text)).toBe(10_000);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('handles very long string (100K chars)', () => {
|
|
54
|
+
const text = 'a'.repeat(100_000);
|
|
55
|
+
// 100_000 / 3.5 = 28571.4… → 28572
|
|
56
|
+
expect(estimateTokens(text)).toBe(Math.ceil(100_000 / 3.5));
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
// getContextWindow
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
describe('getContextWindow', () => {
|
|
65
|
+
test('claude models return 200K', () => {
|
|
66
|
+
expect(getContextWindow('claude-sonnet-4-5')).toBe(200_000);
|
|
67
|
+
expect(getContextWindow('claude-opus-4-6')).toBe(200_000);
|
|
68
|
+
expect(getContextWindow('claude-haiku-4-5')).toBe(200_000);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('gpt-4o returns 128K', () => {
|
|
72
|
+
expect(getContextWindow('gpt-4o')).toBe(128_000);
|
|
73
|
+
expect(getContextWindow('gpt-4o-mini')).toBe(128_000);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('gpt-4 returns 128K', () => {
|
|
77
|
+
expect(getContextWindow('gpt-4')).toBe(128_000);
|
|
78
|
+
expect(getContextWindow('gpt-4-turbo')).toBe(128_000);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('gpt-3.5 returns 16K', () => {
|
|
82
|
+
expect(getContextWindow('gpt-3.5-turbo')).toBe(16_000);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('gemini returns 1M', () => {
|
|
86
|
+
expect(getContextWindow('gemini-2.0-flash')).toBe(1_000_000);
|
|
87
|
+
expect(getContextWindow('gemini-1.5-pro')).toBe(1_000_000);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('grok returns 131072', () => {
|
|
91
|
+
expect(getContextWindow('grok-beta')).toBe(131_072);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('deepseek returns 64K', () => {
|
|
95
|
+
expect(getContextWindow('deepseek-chat')).toBe(64_000);
|
|
96
|
+
expect(getContextWindow('deepseek-coder')).toBe(64_000);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('moonshot returns 128K', () => {
|
|
100
|
+
expect(getContextWindow('moonshot-v1-8k')).toBe(128_000);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('o1 returns 128K', () => {
|
|
104
|
+
expect(getContextWindow('o1')).toBe(128_000);
|
|
105
|
+
expect(getContextWindow('o1-preview')).toBe(128_000);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('o3 returns 200K', () => {
|
|
109
|
+
expect(getContextWindow('o3')).toBe(200_000);
|
|
110
|
+
expect(getContextWindow('o3-mini')).toBe(200_000);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('unknown model returns default 128K', () => {
|
|
114
|
+
expect(getContextWindow('some-unknown-model')).toBe(128_000);
|
|
115
|
+
expect(getContextWindow('llama-3.1')).toBe(128_000);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('is case-insensitive', () => {
|
|
119
|
+
expect(getContextWindow('Claude-Sonnet-4-5')).toBe(200_000);
|
|
120
|
+
expect(getContextWindow('GPT-4O')).toBe(128_000);
|
|
121
|
+
expect(getContextWindow('GEMINI-2.0')).toBe(1_000_000);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test('empty string returns default', () => {
|
|
125
|
+
expect(getContextWindow('')).toBe(128_000);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
// getContextThreshold
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
describe('getContextThreshold', () => {
|
|
134
|
+
test('returns 75% of context window', () => {
|
|
135
|
+
// Claude: 200K * 0.75 = 150K
|
|
136
|
+
expect(getContextThreshold('claude-sonnet-4-5')).toBe(150_000);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test('returns floor of 75%', () => {
|
|
140
|
+
// grok: 131072 * 0.75 = 98304
|
|
141
|
+
expect(getContextThreshold('grok-beta')).toBe(98_304);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('returns 75% for gpt-4o', () => {
|
|
145
|
+
// 128K * 0.75 = 96K
|
|
146
|
+
expect(getContextThreshold('gpt-4o')).toBe(96_000);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test('returns 75% for gemini', () => {
|
|
150
|
+
// 1M * 0.75 = 750K
|
|
151
|
+
expect(getContextThreshold('gemini-2.0-flash')).toBe(750_000);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test('returns 75% for deepseek', () => {
|
|
155
|
+
// 64K * 0.75 = 48K
|
|
156
|
+
expect(getContextThreshold('deepseek-chat')).toBe(48_000);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test('returns 75% for unknown model (default window)', () => {
|
|
160
|
+
// 128K * 0.75 = 96K
|
|
161
|
+
expect(getContextThreshold('unknown-model')).toBe(96_000);
|
|
162
|
+
});
|
|
163
|
+
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Token estimation utilities for context management.
|
|
3
|
+
* Used to prevent exceeding LLM context window limits.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Rough token estimation based on character count.
|
|
8
|
+
* JSON is denser than prose, so we use ~3.5 chars per token.
|
|
9
|
+
* This is conservative - better to underestimate available space.
|
|
10
|
+
*/
|
|
11
|
+
export function estimateTokens(text: string): number {
|
|
12
|
+
return Math.ceil(text.length / 3.5);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Maximum token budget for context data in final answer generation.
|
|
17
|
+
* Conservative limit that leaves room for system prompt, query, and response.
|
|
18
|
+
*/
|
|
19
|
+
export const TOKEN_BUDGET = 150_000;
|
|
20
|
+
|
|
21
|
+
// ============================================================================
|
|
22
|
+
// Model Context Windows & Thresholds
|
|
23
|
+
// ============================================================================
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Known context window sizes (in tokens) by model prefix.
|
|
27
|
+
* Checked in order — first match wins.
|
|
28
|
+
*/
|
|
29
|
+
const MODEL_CONTEXT_WINDOWS: [prefix: string, tokens: number][] = [
|
|
30
|
+
['claude-', 200_000],
|
|
31
|
+
['gpt-4o', 128_000],
|
|
32
|
+
['gpt-4', 128_000],
|
|
33
|
+
['gpt-3.5', 16_000],
|
|
34
|
+
['gemini-', 1_000_000],
|
|
35
|
+
['grok-', 131_072],
|
|
36
|
+
['deepseek-', 64_000],
|
|
37
|
+
['moonshot-', 128_000],
|
|
38
|
+
['o1', 128_000],
|
|
39
|
+
['o3', 200_000],
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
const DEFAULT_CONTEXT_WINDOW = 128_000;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Returns the context window size (in tokens) for the given model.
|
|
46
|
+
*/
|
|
47
|
+
export function getContextWindow(model: string): number {
|
|
48
|
+
const lower = model.toLowerCase();
|
|
49
|
+
for (const [prefix, tokens] of MODEL_CONTEXT_WINDOWS) {
|
|
50
|
+
if (lower.startsWith(prefix)) return tokens;
|
|
51
|
+
}
|
|
52
|
+
return DEFAULT_CONTEXT_WINDOW;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Returns the token threshold at which context clearing should trigger.
|
|
57
|
+
* Set at 75% of the model's context window to leave room for system prompt + response.
|
|
58
|
+
*/
|
|
59
|
+
export function getContextThreshold(model: string): number {
|
|
60
|
+
return Math.floor(getContextWindow(model) * 0.75);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Number of most recent tool results to keep when clearing.
|
|
65
|
+
* Anthropic's default is 3, but we use 5 for slightly more context.
|
|
66
|
+
*/
|
|
67
|
+
export const KEEP_TOOL_USES = 5;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generate a deterministic human-readable description of a tool call.
|
|
3
|
+
* Used for context compaction during the agent loop.
|
|
4
|
+
*
|
|
5
|
+
* Examples:
|
|
6
|
+
* - "Get Bitcoin Price"
|
|
7
|
+
* - "Wallet Analysis (0xd8dA...6045)"
|
|
8
|
+
* - '"trending tokens" web search'
|
|
9
|
+
*/
|
|
10
|
+
export function getToolDescription(toolName: string, args: Record<string, unknown>): string {
|
|
11
|
+
const parts: string[] = [];
|
|
12
|
+
const usedKeys = new Set<string>();
|
|
13
|
+
|
|
14
|
+
// Crypto-specific: invoke_api_endpoint gets special formatting
|
|
15
|
+
if (toolName === 'invoke_api_endpoint' && args.endpoint_name) {
|
|
16
|
+
const endpoint = String(args.endpoint_name);
|
|
17
|
+
const formatted = endpoint.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
|
18
|
+
parts.push(formatted);
|
|
19
|
+
usedKeys.add('endpoint_name');
|
|
20
|
+
|
|
21
|
+
// Add key identifiers from arguments
|
|
22
|
+
const innerArgs = (args.arguments ?? args) as Record<string, unknown>;
|
|
23
|
+
if (innerArgs.ids) {
|
|
24
|
+
parts.push(`(${String(innerArgs.ids)})`);
|
|
25
|
+
usedKeys.add('arguments');
|
|
26
|
+
} else if (innerArgs.address) {
|
|
27
|
+
const addr = String(innerArgs.address);
|
|
28
|
+
const short = addr.length > 12 ? `${addr.slice(0, 6)}...${addr.slice(-4)}` : addr;
|
|
29
|
+
parts.push(`(${short})`);
|
|
30
|
+
usedKeys.add('arguments');
|
|
31
|
+
}
|
|
32
|
+
return parts.join(' ');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Schema lookup
|
|
36
|
+
if (toolName === 'get_api_endpoint_schema' && args.endpoint_name) {
|
|
37
|
+
return `Schema: ${String(args.endpoint_name)}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Category discovery tools
|
|
41
|
+
if (toolName.startsWith('get_') && toolName.endsWith('_endpoints')) {
|
|
42
|
+
const category = toolName
|
|
43
|
+
.replace(/^get_/, '')
|
|
44
|
+
.replace(/_endpoints$/, '')
|
|
45
|
+
.replace(/_/g, ' ')
|
|
46
|
+
.replace(/\b\w/g, (c) => c.toUpperCase());
|
|
47
|
+
return `Discover ${category} Endpoints`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Add search query if present
|
|
51
|
+
if (args.query) {
|
|
52
|
+
parts.push(`"${truncate(String(args.query), 50)}"`);
|
|
53
|
+
usedKeys.add('query');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Add address if present
|
|
57
|
+
if (args.address) {
|
|
58
|
+
const addr = String(args.address);
|
|
59
|
+
const short = addr.length > 12 ? `${addr.slice(0, 6)}...${addr.slice(-4)}` : addr;
|
|
60
|
+
parts.push(short);
|
|
61
|
+
usedKeys.add('address');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Format tool name: get_income_statements -> income statements
|
|
65
|
+
const formattedToolName = toolName
|
|
66
|
+
.replace(/^get_/, '')
|
|
67
|
+
.replace(/^search_/, '')
|
|
68
|
+
.replace(/_/g, ' ');
|
|
69
|
+
parts.push(formattedToolName);
|
|
70
|
+
|
|
71
|
+
// Add period qualifier if present
|
|
72
|
+
if (args.period) {
|
|
73
|
+
parts.push(`(${args.period})`);
|
|
74
|
+
usedKeys.add('period');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Add limit if present
|
|
78
|
+
if (args.limit && typeof args.limit === 'number') {
|
|
79
|
+
parts.push(`- ${args.limit} items`);
|
|
80
|
+
usedKeys.add('limit');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return parts.join(' ');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function truncate(str: string, max: number): string {
|
|
87
|
+
return str.length <= max ? str : str.slice(0, max) + '...';
|
|
88
|
+
}
|