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