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,291 @@
1
+ import { describe, test, expect, beforeEach, mock } from 'bun:test';
2
+
3
+ // Mock callLlm before importing the module under test
4
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
5
+ const mockCallLlm = mock(async (): Promise<any> => ({
6
+ response: 'mock summary',
7
+ usage: undefined,
8
+ }));
9
+
10
+ mock.module('../model/llm.js', () => ({
11
+ callLlm: mockCallLlm,
12
+ DEFAULT_MODEL: 'claude-sonnet-4-5',
13
+ }));
14
+
15
+ // Now import after mock is set up
16
+ const { InMemoryChatHistory } = await import('./in-memory-chat-history.js');
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // saveUserQuery
20
+ // ---------------------------------------------------------------------------
21
+
22
+ describe('saveUserQuery', () => {
23
+ test('adds a message with null answer and summary', () => {
24
+ const history = new InMemoryChatHistory();
25
+ history.saveUserQuery('What is BTC?');
26
+
27
+ const messages = history.getMessages();
28
+ expect(messages.length).toBe(1);
29
+ expect(messages[0].query).toBe('What is BTC?');
30
+ expect(messages[0].answer).toBeNull();
31
+ expect(messages[0].summary).toBeNull();
32
+ });
33
+
34
+ test('assigns sequential IDs', () => {
35
+ const history = new InMemoryChatHistory();
36
+ history.saveUserQuery('Query 1');
37
+ history.saveUserQuery('Query 2');
38
+
39
+ const messages = history.getMessages();
40
+ expect(messages[0].id).toBe(0);
41
+ expect(messages[1].id).toBe(1);
42
+ });
43
+
44
+ test('clears relevance cache on new query', async () => {
45
+ const history = new InMemoryChatHistory();
46
+ history.saveUserQuery('First query');
47
+ // Provide a completed answer so selectRelevantMessages works
48
+ mockCallLlm.mockResolvedValueOnce({ response: 'summary', usage: undefined });
49
+ await history.saveAnswer('Answer 1');
50
+
51
+ // Populate cache
52
+ mockCallLlm.mockResolvedValueOnce({
53
+ response: { message_ids: [0] },
54
+ usage: undefined,
55
+ });
56
+ await history.selectRelevantMessages('relevant?');
57
+
58
+ // New query should clear cache
59
+ history.saveUserQuery('Second query');
60
+
61
+ // Cache should be cleared — next selectRelevantMessages needs fresh LLM call
62
+ mockCallLlm.mockResolvedValueOnce({
63
+ response: { message_ids: [] },
64
+ usage: undefined,
65
+ });
66
+ const result = await history.selectRelevantMessages('relevant?');
67
+ // Won't return cached [0] — should use new LLM response
68
+ expect(mockCallLlm).toHaveBeenCalled();
69
+ });
70
+ });
71
+
72
+ // ---------------------------------------------------------------------------
73
+ // saveAnswer
74
+ // ---------------------------------------------------------------------------
75
+
76
+ describe('saveAnswer', () => {
77
+ beforeEach(() => {
78
+ mockCallLlm.mockClear();
79
+ mockCallLlm.mockResolvedValue({ response: 'generated summary', usage: undefined });
80
+ });
81
+
82
+ test('attaches answer to most recent query', async () => {
83
+ const history = new InMemoryChatHistory();
84
+ history.saveUserQuery('BTC price?');
85
+ await history.saveAnswer('$65,000');
86
+
87
+ const messages = history.getMessages();
88
+ expect(messages[0].answer).toBe('$65,000');
89
+ });
90
+
91
+ test('generates summary via LLM', async () => {
92
+ const history = new InMemoryChatHistory();
93
+ history.saveUserQuery('ETH analysis');
94
+ await history.saveAnswer('ETH is at $3500');
95
+
96
+ expect(mockCallLlm).toHaveBeenCalled();
97
+ const messages = history.getMessages();
98
+ expect(messages[0].summary).toBe('generated summary');
99
+ });
100
+
101
+ test('does nothing if no pending query', async () => {
102
+ const history = new InMemoryChatHistory();
103
+ // No saveUserQuery called
104
+ await history.saveAnswer('orphan answer');
105
+ expect(history.getMessages().length).toBe(0);
106
+ });
107
+
108
+ test('does nothing if answer already exists', async () => {
109
+ const history = new InMemoryChatHistory();
110
+ history.saveUserQuery('test');
111
+ await history.saveAnswer('first');
112
+ mockCallLlm.mockClear();
113
+ await history.saveAnswer('second');
114
+
115
+ // Should not have called LLM again
116
+ expect(mockCallLlm).not.toHaveBeenCalled();
117
+ expect(history.getMessages()[0].answer).toBe('first');
118
+ });
119
+ });
120
+
121
+ // ---------------------------------------------------------------------------
122
+ // selectRelevantMessages
123
+ // ---------------------------------------------------------------------------
124
+
125
+ describe('selectRelevantMessages', () => {
126
+ beforeEach(() => {
127
+ mockCallLlm.mockClear();
128
+ });
129
+
130
+ test('returns empty for no completed messages', async () => {
131
+ const history = new InMemoryChatHistory();
132
+ history.saveUserQuery('pending query');
133
+ // No saveAnswer — query is pending
134
+
135
+ const result = await history.selectRelevantMessages('anything');
136
+ expect(result).toEqual([]);
137
+ });
138
+
139
+ test('returns relevant messages selected by LLM', async () => {
140
+ const history = new InMemoryChatHistory();
141
+ history.saveUserQuery('BTC price');
142
+ mockCallLlm.mockResolvedValueOnce({ response: 'summary1', usage: undefined });
143
+ await history.saveAnswer('$65k');
144
+
145
+ history.saveUserQuery('ETH analysis');
146
+ mockCallLlm.mockResolvedValueOnce({ response: 'summary2', usage: undefined });
147
+ await history.saveAnswer('ETH strong');
148
+
149
+ // Mock the selection LLM call
150
+ mockCallLlm.mockResolvedValueOnce({
151
+ response: { message_ids: [0] },
152
+ usage: undefined,
153
+ });
154
+
155
+ const result = await history.selectRelevantMessages('What about BTC?');
156
+ expect(result.length).toBe(1);
157
+ expect(result[0].query).toBe('BTC price');
158
+ });
159
+
160
+ test('caches by query hash', async () => {
161
+ const history = new InMemoryChatHistory();
162
+ history.saveUserQuery('test');
163
+ mockCallLlm.mockResolvedValueOnce({ response: 'sum', usage: undefined });
164
+ await history.saveAnswer('answer');
165
+
166
+ mockCallLlm.mockResolvedValueOnce({
167
+ response: { message_ids: [0] },
168
+ usage: undefined,
169
+ });
170
+
171
+ await history.selectRelevantMessages('cached query');
172
+ mockCallLlm.mockClear();
173
+ // Second call with same query should use cache
174
+ const result = await history.selectRelevantMessages('cached query');
175
+ expect(mockCallLlm).not.toHaveBeenCalled();
176
+ expect(result.length).toBe(1);
177
+ });
178
+
179
+ test('returns empty on LLM failure', async () => {
180
+ const history = new InMemoryChatHistory();
181
+ history.saveUserQuery('test');
182
+ mockCallLlm.mockResolvedValueOnce({ response: 'sum', usage: undefined });
183
+ await history.saveAnswer('answer');
184
+
185
+ mockCallLlm.mockRejectedValueOnce(new Error('API error'));
186
+
187
+ const result = await history.selectRelevantMessages('failing query');
188
+ expect(result).toEqual([]);
189
+ });
190
+ });
191
+
192
+ // ---------------------------------------------------------------------------
193
+ // formatForPlanning
194
+ // ---------------------------------------------------------------------------
195
+
196
+ describe('formatForPlanning', () => {
197
+ test('formats messages with query + summary', () => {
198
+ const history = new InMemoryChatHistory();
199
+ const messages = [
200
+ { id: 0, query: 'BTC price', answer: '$65k', summary: 'BTC is $65k' },
201
+ { id: 1, query: 'ETH tvl', answer: '$50B', summary: 'ETH TVL is $50B' },
202
+ ];
203
+
204
+ const formatted = history.formatForPlanning(messages);
205
+ expect(formatted).toContain('User: BTC price');
206
+ expect(formatted).toContain('Assistant: BTC is $65k');
207
+ expect(formatted).toContain('User: ETH tvl');
208
+ expect(formatted).toContain('Assistant: ETH TVL is $50B');
209
+ });
210
+
211
+ test('returns empty string for empty array', () => {
212
+ const history = new InMemoryChatHistory();
213
+ expect(history.formatForPlanning([])).toBe('');
214
+ });
215
+ });
216
+
217
+ // ---------------------------------------------------------------------------
218
+ // formatForAnswerGeneration
219
+ // ---------------------------------------------------------------------------
220
+
221
+ describe('formatForAnswerGeneration', () => {
222
+ test('formats messages with query + full answer', () => {
223
+ const history = new InMemoryChatHistory();
224
+ const messages = [
225
+ { id: 0, query: 'What is SOL?', answer: 'SOL is Solana token', summary: 'SOL info' },
226
+ ];
227
+
228
+ const formatted = history.formatForAnswerGeneration(messages);
229
+ expect(formatted).toContain('User: What is SOL?');
230
+ expect(formatted).toContain('Assistant: SOL is Solana token');
231
+ });
232
+
233
+ test('returns empty string for empty array', () => {
234
+ const history = new InMemoryChatHistory();
235
+ expect(history.formatForAnswerGeneration([])).toBe('');
236
+ });
237
+ });
238
+
239
+ // ---------------------------------------------------------------------------
240
+ // Utility methods
241
+ // ---------------------------------------------------------------------------
242
+
243
+ describe('utility methods', () => {
244
+ test('hasMessages returns false when empty', () => {
245
+ const history = new InMemoryChatHistory();
246
+ expect(history.hasMessages()).toBe(false);
247
+ });
248
+
249
+ test('hasMessages returns true when populated', () => {
250
+ const history = new InMemoryChatHistory();
251
+ history.saveUserQuery('test');
252
+ expect(history.hasMessages()).toBe(true);
253
+ });
254
+
255
+ test('getUserMessages returns queries in order', () => {
256
+ const history = new InMemoryChatHistory();
257
+ history.saveUserQuery('Q1');
258
+ history.saveUserQuery('Q2');
259
+ history.saveUserQuery('Q3');
260
+
261
+ expect(history.getUserMessages()).toEqual(['Q1', 'Q2', 'Q3']);
262
+ });
263
+
264
+ test('clear removes all messages and cache', async () => {
265
+ const history = new InMemoryChatHistory();
266
+ history.saveUserQuery('test');
267
+ mockCallLlm.mockResolvedValueOnce({ response: 'sum', usage: undefined });
268
+ await history.saveAnswer('answer');
269
+
270
+ history.clear();
271
+ expect(history.hasMessages()).toBe(false);
272
+ expect(history.getMessages()).toEqual([]);
273
+ });
274
+
275
+ test('setModel updates model', () => {
276
+ const history = new InMemoryChatHistory();
277
+ history.setModel('gpt-4o');
278
+ // No direct way to verify, but should not throw
279
+ expect(history).toBeDefined();
280
+ });
281
+
282
+ test('getMessages returns a copy', () => {
283
+ const history = new InMemoryChatHistory();
284
+ history.saveUserQuery('test');
285
+
286
+ const m1 = history.getMessages();
287
+ const m2 = history.getMessages();
288
+ expect(m1).not.toBe(m2);
289
+ expect(m1).toEqual(m2);
290
+ });
291
+ });
@@ -0,0 +1,224 @@
1
+ import { createHash } from 'crypto';
2
+ import { callLlm, DEFAULT_MODEL } from '../model/llm.js';
3
+ import { z } from 'zod';
4
+
5
+ /**
6
+ * Represents a single conversation turn (query + answer + summary)
7
+ */
8
+ export interface Message {
9
+ id: number;
10
+ query: string;
11
+ answer: string | null; // null until answer completes
12
+ summary: string | null; // LLM-generated summary, null until answer arrives
13
+ }
14
+
15
+ /**
16
+ * Schema for LLM to select relevant messages
17
+ */
18
+ export const SelectedMessagesSchema = z.object({
19
+ message_ids: z.array(z.number()).describe('List of relevant message IDs (0-indexed)'),
20
+ });
21
+
22
+ /**
23
+ * System prompt for generating message summaries
24
+ */
25
+ const MESSAGE_SUMMARY_SYSTEM_PROMPT = `You are a concise summarizer. Generate brief summaries of conversation answers.
26
+ Keep summaries to 1-2 sentences that capture the key information.`;
27
+
28
+ /**
29
+ * System prompt for selecting relevant messages
30
+ */
31
+ const MESSAGE_SELECTION_SYSTEM_PROMPT = `You are a relevance evaluator. Select which previous conversation messages are relevant to the current query.
32
+ Return only message IDs that contain information directly useful for answering the current query.`;
33
+
34
+ /**
35
+ * Manages in-memory conversation history for multi-turn conversations.
36
+ * Stores user queries, final answers, and LLM-generated summaries.
37
+ */
38
+ export class InMemoryChatHistory {
39
+ private messages: Message[] = [];
40
+ private model: string;
41
+ private relevantMessagesByQuery: Map<string, Message[]> = new Map();
42
+
43
+ constructor(model: string = DEFAULT_MODEL) {
44
+ this.model = model;
45
+ }
46
+
47
+ /**
48
+ * Hashes a query string for cache key generation
49
+ */
50
+ private hashQuery(query: string): string {
51
+ return createHash('md5').update(query).digest('hex').slice(0, 12);
52
+ }
53
+
54
+ /**
55
+ * Updates the model used for LLM calls (e.g., when user switches models)
56
+ */
57
+ setModel(model: string): void {
58
+ this.model = model;
59
+ }
60
+
61
+ /**
62
+ * Generates a brief summary of an answer for later relevance matching
63
+ */
64
+ private async generateSummary(query: string, answer: string): Promise<string> {
65
+ const answerPreview = answer.slice(0, 1500); // Limit for prompt size
66
+
67
+ const prompt = `Query: "${query}"
68
+ Answer: "${answerPreview}"
69
+
70
+ Generate a brief 1-2 sentence summary of this answer.`;
71
+
72
+ try {
73
+ const { response } = await callLlm(prompt, {
74
+ systemPrompt: MESSAGE_SUMMARY_SYSTEM_PROMPT,
75
+ model: this.model,
76
+ });
77
+ return typeof response === 'string' ? response.trim() : String(response).trim();
78
+ } catch {
79
+ // Fallback to a simple summary if LLM fails
80
+ return `Answer to: ${query.slice(0, 100)}`;
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Saves a new user query to history immediately (before answer is available).
86
+ * Answer and summary are null until saveAnswer() is called with the answer.
87
+ */
88
+ saveUserQuery(query: string): void {
89
+ // Clear the relevance cache since message history has changed
90
+ this.relevantMessagesByQuery.clear();
91
+
92
+ this.messages.push({
93
+ id: this.messages.length,
94
+ query,
95
+ answer: null,
96
+ summary: null,
97
+ });
98
+ }
99
+
100
+ /**
101
+ * Saves the answer to the most recent message and generates a summary.
102
+ * Should be called when the agent completes answering.
103
+ */
104
+ async saveAnswer(answer: string): Promise<void> {
105
+ const lastMessage = this.messages[this.messages.length - 1];
106
+ if (!lastMessage || lastMessage.answer !== null) {
107
+ return; // No pending query or already has answer
108
+ }
109
+
110
+ lastMessage.answer = answer;
111
+ lastMessage.summary = await this.generateSummary(lastMessage.query, answer);
112
+ }
113
+
114
+ /**
115
+ * Uses LLM to select which messages are relevant to the current query.
116
+ * Results are cached by query hash to avoid redundant LLM calls within the same query.
117
+ * Only considers messages with completed answers for relevance selection.
118
+ */
119
+ async selectRelevantMessages(currentQuery: string): Promise<Message[]> {
120
+ // Only consider messages with completed answers
121
+ const completedMessages = this.messages.filter((m) => m.answer !== null);
122
+ if (completedMessages.length === 0) {
123
+ return [];
124
+ }
125
+
126
+ // Check cache first
127
+ const cacheKey = this.hashQuery(currentQuery);
128
+ const cached = this.relevantMessagesByQuery.get(cacheKey);
129
+ if (cached) {
130
+ return cached;
131
+ }
132
+
133
+ const messagesInfo = completedMessages.map((message) => ({
134
+ id: message.id,
135
+ query: message.query,
136
+ summary: message.summary,
137
+ }));
138
+
139
+ const prompt = `Current user query: "${currentQuery}"
140
+
141
+ Previous conversations:
142
+ ${JSON.stringify(messagesInfo, null, 2)}
143
+
144
+ Select which previous messages are relevant to understanding or answering the current query.`;
145
+
146
+ try {
147
+ const { response } = await callLlm(prompt, {
148
+ systemPrompt: MESSAGE_SELECTION_SYSTEM_PROMPT,
149
+ model: this.model,
150
+ outputSchema: SelectedMessagesSchema,
151
+ });
152
+
153
+ const selectedIds = (response as unknown as { message_ids: number[] }).message_ids || [];
154
+
155
+ const selectedMessages = selectedIds
156
+ .filter((idx) => idx >= 0 && idx < this.messages.length)
157
+ .map((idx) => this.messages[idx])
158
+ .filter((m) => m.answer !== null); // Ensure we only return completed messages
159
+
160
+ // Cache the result
161
+ this.relevantMessagesByQuery.set(cacheKey, selectedMessages);
162
+
163
+ return selectedMessages;
164
+ } catch {
165
+ // On failure, return empty (don't inject potentially irrelevant context)
166
+ return [];
167
+ }
168
+ }
169
+
170
+ /**
171
+ * Formats selected messages for task planning (queries + summaries only, lightweight)
172
+ */
173
+ formatForPlanning(messages: Message[]): string {
174
+ if (messages.length === 0) {
175
+ return '';
176
+ }
177
+
178
+ return messages
179
+ .map((message) => `User: ${message.query}\nAssistant: ${message.summary}`)
180
+ .join('\n\n');
181
+ }
182
+
183
+ /**
184
+ * Formats selected messages for answer generation (queries + full answers)
185
+ */
186
+ formatForAnswerGeneration(messages: Message[]): string {
187
+ if (messages.length === 0) {
188
+ return '';
189
+ }
190
+
191
+ return messages
192
+ .map((message) => `User: ${message.query}\nAssistant: ${message.answer}`)
193
+ .join('\n\n');
194
+ }
195
+
196
+ /**
197
+ * Returns all messages
198
+ */
199
+ getMessages(): Message[] {
200
+ return [...this.messages];
201
+ }
202
+
203
+ /**
204
+ * Returns user queries in chronological order (no LLM call)
205
+ */
206
+ getUserMessages(): string[] {
207
+ return this.messages.map((message) => message.query);
208
+ }
209
+
210
+ /**
211
+ * Returns true if there are any messages
212
+ */
213
+ hasMessages(): boolean {
214
+ return this.messages.length > 0;
215
+ }
216
+
217
+ /**
218
+ * Clears all messages and cache
219
+ */
220
+ clear(): void {
221
+ this.messages = [];
222
+ this.relevantMessagesByQuery.clear();
223
+ }
224
+ }
@@ -0,0 +1,19 @@
1
+ export { loadConfig, saveConfig, getSetting, setSetting } from './config.js';
2
+ export {
3
+ getApiKeyNameForProvider,
4
+ getProviderDisplayName,
5
+ checkApiKeyExistsForProvider,
6
+ saveApiKeyForProvider,
7
+ } from './env.js';
8
+ export { InMemoryChatHistory } from './in-memory-chat-history.js';
9
+ export { logger } from './logger.js';
10
+ export type { LogEntry, LogLevel } from './logger.js';
11
+ export { extractTextContent, hasToolCalls } from './ai-message.js';
12
+ export { LongTermChatHistory } from './long-term-chat-history.js';
13
+ export type { ConversationEntry } from './long-term-chat-history.js';
14
+ export { findPrevWordStart, findNextWordEnd } from './text-navigation.js';
15
+ export { cursorHandlers } from './input-key-handlers.js';
16
+ export type { CursorContext } from './input-key-handlers.js';
17
+ export { getToolDescription } from './tool-description.js';
18
+ export { transformMarkdownTables, formatResponse } from './markdown-table.js';
19
+ export { estimateTokens, TOKEN_BUDGET } from './tokens.js';
@@ -0,0 +1,155 @@
1
+ import { describe, test, expect } from 'bun:test';
2
+ import { cursorHandlers, type CursorContext } from './input-key-handlers.js';
3
+
4
+ // Helper to create cursor context
5
+ function ctx(text: string, cursorPosition: number): CursorContext {
6
+ return { text, cursorPosition };
7
+ }
8
+
9
+ // ---------------------------------------------------------------------------
10
+ // moveLeft / moveRight
11
+ // ---------------------------------------------------------------------------
12
+
13
+ describe('moveLeft', () => {
14
+ test('moves one character left', () => {
15
+ expect(cursorHandlers.moveLeft(ctx('hello', 3))).toBe(2);
16
+ });
17
+
18
+ test('clamps at position 0', () => {
19
+ expect(cursorHandlers.moveLeft(ctx('hello', 0))).toBe(0);
20
+ });
21
+
22
+ test('moves from end of text', () => {
23
+ expect(cursorHandlers.moveLeft(ctx('hello', 5))).toBe(4);
24
+ });
25
+ });
26
+
27
+ describe('moveRight', () => {
28
+ test('moves one character right', () => {
29
+ expect(cursorHandlers.moveRight(ctx('hello', 3))).toBe(4);
30
+ });
31
+
32
+ test('clamps at text length', () => {
33
+ expect(cursorHandlers.moveRight(ctx('hello', 5))).toBe(5);
34
+ });
35
+
36
+ test('moves from position 0', () => {
37
+ expect(cursorHandlers.moveRight(ctx('hello', 0))).toBe(1);
38
+ });
39
+ });
40
+
41
+ // ---------------------------------------------------------------------------
42
+ // moveToLineStart / moveToLineEnd
43
+ // ---------------------------------------------------------------------------
44
+
45
+ describe('moveToLineStart', () => {
46
+ test('moves to start of single line', () => {
47
+ expect(cursorHandlers.moveToLineStart(ctx('hello world', 6))).toBe(0);
48
+ });
49
+
50
+ test('moves to start of second line', () => {
51
+ expect(cursorHandlers.moveToLineStart(ctx('hello\nworld', 8))).toBe(6);
52
+ });
53
+
54
+ test('no-op when already at line start', () => {
55
+ expect(cursorHandlers.moveToLineStart(ctx('hello\nworld', 6))).toBe(6);
56
+ });
57
+ });
58
+
59
+ describe('moveToLineEnd', () => {
60
+ test('moves to end of single line', () => {
61
+ expect(cursorHandlers.moveToLineEnd(ctx('hello world', 3))).toBe(11);
62
+ });
63
+
64
+ test('moves to end of first line (before newline)', () => {
65
+ expect(cursorHandlers.moveToLineEnd(ctx('hello\nworld', 2))).toBe(5);
66
+ });
67
+
68
+ test('moves to end of last line', () => {
69
+ expect(cursorHandlers.moveToLineEnd(ctx('hello\nworld', 8))).toBe(11);
70
+ });
71
+ });
72
+
73
+ // ---------------------------------------------------------------------------
74
+ // moveUp / moveDown
75
+ // ---------------------------------------------------------------------------
76
+
77
+ describe('moveUp', () => {
78
+ test('returns null on single line (signals history navigation)', () => {
79
+ expect(cursorHandlers.moveUp(ctx('hello', 3))).toBeNull();
80
+ });
81
+
82
+ test('returns null on first line of multi-line', () => {
83
+ expect(cursorHandlers.moveUp(ctx('hello\nworld', 3))).toBeNull();
84
+ });
85
+
86
+ test('moves to previous line maintaining column', () => {
87
+ // "hello\nworld", cursor at position 8 (col 2 of line 1)
88
+ // Should move to position 2 (col 2 of line 0)
89
+ const result = cursorHandlers.moveUp(ctx('hello\nworld', 8));
90
+ expect(result).toBe(2);
91
+ });
92
+
93
+ test('clamps column when previous line is shorter', () => {
94
+ // "hi\nworld", cursor at position 7 (col 4 of line 1)
95
+ // Line 0 has length 2, should clamp to position 2
96
+ const result = cursorHandlers.moveUp(ctx('hi\nworld', 7));
97
+ expect(result).toBe(2);
98
+ });
99
+ });
100
+
101
+ describe('moveDown', () => {
102
+ test('returns null on single line', () => {
103
+ expect(cursorHandlers.moveDown(ctx('hello', 3))).toBeNull();
104
+ });
105
+
106
+ test('returns null on last line of multi-line', () => {
107
+ expect(cursorHandlers.moveDown(ctx('hello\nworld', 8))).toBeNull();
108
+ });
109
+
110
+ test('moves to next line maintaining column', () => {
111
+ // "hello\nworld", cursor at position 2 (col 2 of line 0)
112
+ // Should move to position 8 (col 2 of line 1)
113
+ const result = cursorHandlers.moveDown(ctx('hello\nworld', 2));
114
+ expect(result).toBe(8);
115
+ });
116
+
117
+ test('clamps column when next line is shorter', () => {
118
+ // "world\nhi", cursor at position 4 (col 4 of line 0)
119
+ // Line 1 has length 2, should clamp to position 8 (6+2)
120
+ const result = cursorHandlers.moveDown(ctx('world\nhi', 4));
121
+ expect(result).toBe(8);
122
+ });
123
+ });
124
+
125
+ // ---------------------------------------------------------------------------
126
+ // moveWordBackward / moveWordForward
127
+ // ---------------------------------------------------------------------------
128
+
129
+ describe('moveWordBackward', () => {
130
+ test('moves to start of previous word', () => {
131
+ expect(cursorHandlers.moveWordBackward(ctx('hello world', 8))).toBe(6);
132
+ });
133
+
134
+ test('moves from end of text to start of last word', () => {
135
+ expect(cursorHandlers.moveWordBackward(ctx('hello world', 11))).toBe(6);
136
+ });
137
+
138
+ test('returns 0 at beginning', () => {
139
+ expect(cursorHandlers.moveWordBackward(ctx('hello', 0))).toBe(0);
140
+ });
141
+ });
142
+
143
+ describe('moveWordForward', () => {
144
+ test('moves to end of current word', () => {
145
+ expect(cursorHandlers.moveWordForward(ctx('hello world', 0))).toBe(5);
146
+ });
147
+
148
+ test('moves from space to end of next word', () => {
149
+ expect(cursorHandlers.moveWordForward(ctx('hello world', 5))).toBe(11);
150
+ });
151
+
152
+ test('returns text length at end', () => {
153
+ expect(cursorHandlers.moveWordForward(ctx('hello', 5))).toBe(5);
154
+ });
155
+ });