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