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
package/src/evals/run.ts
ADDED
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LangSmith Evaluation Runner for Brownian Code
|
|
3
|
+
*
|
|
4
|
+
* Usage:
|
|
5
|
+
* bun run src/evals/run.ts # Run on all questions
|
|
6
|
+
* bun run src/evals/run.ts --sample 10 # Run on random sample of 10 questions
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import 'dotenv/config';
|
|
10
|
+
import React from 'react';
|
|
11
|
+
import { render } from 'ink';
|
|
12
|
+
import { Client } from 'langsmith';
|
|
13
|
+
import type { EvaluationResult } from 'langsmith/evaluation';
|
|
14
|
+
import { ChatOpenAI } from '@langchain/openai';
|
|
15
|
+
import { z } from 'zod';
|
|
16
|
+
import fs from 'fs';
|
|
17
|
+
import path from 'path';
|
|
18
|
+
import { fileURLToPath } from 'url';
|
|
19
|
+
import { Agent } from '../agent/agent.js';
|
|
20
|
+
import { EvalApp, type EvalProgressEvent } from './components/index.js';
|
|
21
|
+
|
|
22
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
23
|
+
const __dirname = path.dirname(__filename);
|
|
24
|
+
|
|
25
|
+
// Types
|
|
26
|
+
interface Example {
|
|
27
|
+
inputs: { question: string };
|
|
28
|
+
outputs: { answer: string };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ============================================================================
|
|
32
|
+
// CSV Parser - handles multi-line quoted fields
|
|
33
|
+
// ============================================================================
|
|
34
|
+
|
|
35
|
+
function parseCSV(csvContent: string): Example[] {
|
|
36
|
+
const examples: Example[] = [];
|
|
37
|
+
const lines = csvContent.split('\n');
|
|
38
|
+
|
|
39
|
+
let i = 1; // Skip header row
|
|
40
|
+
|
|
41
|
+
while (i < lines.length) {
|
|
42
|
+
const result = parseRow(lines, i);
|
|
43
|
+
if (result) {
|
|
44
|
+
const { row, nextIndex } = result;
|
|
45
|
+
if (row.length >= 2 && row[0].trim()) {
|
|
46
|
+
examples.push({
|
|
47
|
+
inputs: { question: row[0] },
|
|
48
|
+
outputs: { answer: row[1] }
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
i = nextIndex;
|
|
52
|
+
} else {
|
|
53
|
+
i++;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return examples;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function parseRow(lines: string[], startIndex: number): { row: string[]; nextIndex: number } | null {
|
|
61
|
+
if (startIndex >= lines.length || !lines[startIndex].trim()) {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const fields: string[] = [];
|
|
66
|
+
let currentField = '';
|
|
67
|
+
let inQuotes = false;
|
|
68
|
+
let lineIndex = startIndex;
|
|
69
|
+
let charIndex = 0;
|
|
70
|
+
|
|
71
|
+
while (lineIndex < lines.length) {
|
|
72
|
+
const line = lines[lineIndex];
|
|
73
|
+
|
|
74
|
+
while (charIndex < line.length) {
|
|
75
|
+
const char = line[charIndex];
|
|
76
|
+
const nextChar = line[charIndex + 1];
|
|
77
|
+
|
|
78
|
+
if (inQuotes) {
|
|
79
|
+
if (char === '"' && nextChar === '"') {
|
|
80
|
+
// Escaped quote
|
|
81
|
+
currentField += '"';
|
|
82
|
+
charIndex += 2;
|
|
83
|
+
} else if (char === '"') {
|
|
84
|
+
// End of quoted field
|
|
85
|
+
inQuotes = false;
|
|
86
|
+
charIndex++;
|
|
87
|
+
} else {
|
|
88
|
+
currentField += char;
|
|
89
|
+
charIndex++;
|
|
90
|
+
}
|
|
91
|
+
} else {
|
|
92
|
+
if (char === '"') {
|
|
93
|
+
// Start of quoted field
|
|
94
|
+
inQuotes = true;
|
|
95
|
+
charIndex++;
|
|
96
|
+
} else if (char === ',') {
|
|
97
|
+
// End of field
|
|
98
|
+
fields.push(currentField);
|
|
99
|
+
currentField = '';
|
|
100
|
+
charIndex++;
|
|
101
|
+
} else {
|
|
102
|
+
currentField += char;
|
|
103
|
+
charIndex++;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (inQuotes) {
|
|
109
|
+
// Continue to next line (multi-line field)
|
|
110
|
+
currentField += '\n';
|
|
111
|
+
lineIndex++;
|
|
112
|
+
charIndex = 0;
|
|
113
|
+
} else {
|
|
114
|
+
// Row complete
|
|
115
|
+
fields.push(currentField);
|
|
116
|
+
return { row: fields, nextIndex: lineIndex + 1 };
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Handle case where file ends while in quotes
|
|
121
|
+
if (currentField) {
|
|
122
|
+
fields.push(currentField);
|
|
123
|
+
}
|
|
124
|
+
return { row: fields, nextIndex: lineIndex };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ============================================================================
|
|
128
|
+
// Sampling utilities
|
|
129
|
+
// ============================================================================
|
|
130
|
+
|
|
131
|
+
function shuffleArray<T>(array: T[]): T[] {
|
|
132
|
+
const shuffled = [...array];
|
|
133
|
+
for (let i = shuffled.length - 1; i > 0; i--) {
|
|
134
|
+
const j = Math.floor(Math.random() * (i + 1));
|
|
135
|
+
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
|
|
136
|
+
}
|
|
137
|
+
return shuffled;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ============================================================================
|
|
141
|
+
// Target function - wraps Brownian Code agent
|
|
142
|
+
// ============================================================================
|
|
143
|
+
|
|
144
|
+
async function target(inputs: { question: string }): Promise<{ answer: string }> {
|
|
145
|
+
const agent = Agent.create({ model: 'claude-sonnet-4-5', maxIterations: 10 });
|
|
146
|
+
let answer = '';
|
|
147
|
+
|
|
148
|
+
for await (const event of agent.run(inputs.question)) {
|
|
149
|
+
if (event.type === 'done') {
|
|
150
|
+
answer = event.answer;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return { answer };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ============================================================================
|
|
158
|
+
// Correctness evaluator - LLM-as-judge using gpt-5.2
|
|
159
|
+
// ============================================================================
|
|
160
|
+
|
|
161
|
+
const EvaluatorOutputSchema = z.object({
|
|
162
|
+
score: z.number().min(0).max(1),
|
|
163
|
+
comment: z.string(),
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
const llm = new ChatOpenAI({
|
|
167
|
+
model: 'gpt-5.2',
|
|
168
|
+
apiKey: process.env.OPENAI_API_KEY,
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
const structuredLlm = llm.withStructuredOutput(EvaluatorOutputSchema);
|
|
172
|
+
|
|
173
|
+
async function correctnessEvaluator({
|
|
174
|
+
outputs,
|
|
175
|
+
referenceOutputs,
|
|
176
|
+
}: {
|
|
177
|
+
inputs: Record<string, unknown>;
|
|
178
|
+
outputs: Record<string, unknown>;
|
|
179
|
+
referenceOutputs?: Record<string, unknown>;
|
|
180
|
+
}): Promise<EvaluationResult> {
|
|
181
|
+
const actualAnswer = (outputs?.answer as string) || '';
|
|
182
|
+
const expectedAnswer = (referenceOutputs?.answer as string) || '';
|
|
183
|
+
|
|
184
|
+
const prompt = `You are evaluating the correctness of an AI assistant's answer to a crypto research question.
|
|
185
|
+
|
|
186
|
+
Compare the actual answer to the expected answer. The actual answer is considered correct if it conveys the same key information as the expected answer. Minor differences in wording, formatting, or additional context are acceptable as long as the core facts are correct.
|
|
187
|
+
|
|
188
|
+
Expected Answer:
|
|
189
|
+
${expectedAnswer}
|
|
190
|
+
|
|
191
|
+
Actual Answer:
|
|
192
|
+
${actualAnswer}
|
|
193
|
+
|
|
194
|
+
Evaluate and provide:
|
|
195
|
+
- score: 1 if the answer is correct (contains the key information), 0 if incorrect
|
|
196
|
+
- comment: brief explanation of why the answer is correct or incorrect`;
|
|
197
|
+
|
|
198
|
+
try {
|
|
199
|
+
const result = await structuredLlm.invoke(prompt);
|
|
200
|
+
return {
|
|
201
|
+
key: 'correctness',
|
|
202
|
+
score: result.score,
|
|
203
|
+
comment: result.comment,
|
|
204
|
+
};
|
|
205
|
+
} catch (error) {
|
|
206
|
+
return {
|
|
207
|
+
key: 'correctness',
|
|
208
|
+
score: 0,
|
|
209
|
+
comment: `Evaluator error: ${error instanceof Error ? error.message : String(error)}`,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ============================================================================
|
|
215
|
+
// Evaluation generator - yields progress events for the UI
|
|
216
|
+
// ============================================================================
|
|
217
|
+
|
|
218
|
+
function createEvaluationRunner(sampleSize?: number) {
|
|
219
|
+
return async function* runEvaluation(): AsyncGenerator<EvalProgressEvent, void, unknown> {
|
|
220
|
+
// Load and parse dataset
|
|
221
|
+
const csvPath = path.join(__dirname, 'dataset', 'crypto_agent.csv');
|
|
222
|
+
const csvContent = fs.readFileSync(csvPath, 'utf-8');
|
|
223
|
+
let examples = parseCSV(csvContent);
|
|
224
|
+
const totalCount = examples.length;
|
|
225
|
+
|
|
226
|
+
// Apply sampling if requested
|
|
227
|
+
if (sampleSize && sampleSize < examples.length) {
|
|
228
|
+
examples = shuffleArray(examples).slice(0, sampleSize);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Create LangSmith client
|
|
232
|
+
const client = new Client();
|
|
233
|
+
|
|
234
|
+
// Create a unique dataset name for this run (sampling creates different datasets)
|
|
235
|
+
const datasetName = sampleSize
|
|
236
|
+
? `brownian-crypto-eval-sample-${sampleSize}-${Date.now()}`
|
|
237
|
+
: 'brownian-crypto-eval';
|
|
238
|
+
|
|
239
|
+
// Yield init event
|
|
240
|
+
yield {
|
|
241
|
+
type: 'init',
|
|
242
|
+
total: examples.length,
|
|
243
|
+
datasetName: sampleSize ? `crypto_agent (sample ${sampleSize}/${totalCount})` : 'crypto_agent',
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
// Check if dataset exists (only for full runs)
|
|
247
|
+
let dataset;
|
|
248
|
+
if (!sampleSize) {
|
|
249
|
+
try {
|
|
250
|
+
dataset = await client.readDataset({ datasetName });
|
|
251
|
+
} catch {
|
|
252
|
+
// Dataset doesn't exist, will create
|
|
253
|
+
dataset = null;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Create dataset if needed
|
|
258
|
+
if (!dataset) {
|
|
259
|
+
dataset = await client.createDataset(datasetName, {
|
|
260
|
+
description: sampleSize
|
|
261
|
+
? `Crypto agent evaluation (sample of ${sampleSize})`
|
|
262
|
+
: 'Crypto agent evaluation dataset',
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
// Upload examples
|
|
266
|
+
await client.createExamples({
|
|
267
|
+
datasetId: dataset.id,
|
|
268
|
+
inputs: examples.map((e) => e.inputs),
|
|
269
|
+
outputs: examples.map((e) => e.outputs),
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Generate experiment name for tracking
|
|
274
|
+
const experimentName = `brownian-eval-${Date.now().toString(36)}`;
|
|
275
|
+
|
|
276
|
+
// Run evaluation manually - process each example one by one
|
|
277
|
+
for (const example of examples) {
|
|
278
|
+
const question = example.inputs.question;
|
|
279
|
+
|
|
280
|
+
// Yield question start - UI shows this immediately
|
|
281
|
+
yield {
|
|
282
|
+
type: 'question_start',
|
|
283
|
+
question,
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
// Run the agent to get an answer
|
|
287
|
+
const startTime = Date.now();
|
|
288
|
+
const outputs = await target(example.inputs);
|
|
289
|
+
const endTime = Date.now();
|
|
290
|
+
|
|
291
|
+
// Run the correctness evaluator
|
|
292
|
+
const evalResult = await correctnessEvaluator({
|
|
293
|
+
inputs: example.inputs,
|
|
294
|
+
outputs,
|
|
295
|
+
referenceOutputs: example.outputs,
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
// Log to LangSmith for tracking
|
|
299
|
+
await client.createRun({
|
|
300
|
+
name: 'brownian-eval-run',
|
|
301
|
+
run_type: 'chain',
|
|
302
|
+
inputs: example.inputs,
|
|
303
|
+
outputs,
|
|
304
|
+
start_time: startTime,
|
|
305
|
+
end_time: endTime,
|
|
306
|
+
project_name: experimentName,
|
|
307
|
+
extra: {
|
|
308
|
+
dataset: datasetName,
|
|
309
|
+
reference_outputs: example.outputs,
|
|
310
|
+
evaluation: {
|
|
311
|
+
score: evalResult.score,
|
|
312
|
+
comment: evalResult.comment,
|
|
313
|
+
},
|
|
314
|
+
},
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
// Yield question end with result - UI updates progress bar
|
|
318
|
+
yield {
|
|
319
|
+
type: 'question_end',
|
|
320
|
+
question,
|
|
321
|
+
score: typeof evalResult.score === 'number' ? evalResult.score : 0,
|
|
322
|
+
comment: evalResult.comment || '',
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Yield complete event
|
|
327
|
+
yield {
|
|
328
|
+
type: 'complete',
|
|
329
|
+
experimentName,
|
|
330
|
+
};
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// ============================================================================
|
|
335
|
+
// Main entry point
|
|
336
|
+
// ============================================================================
|
|
337
|
+
|
|
338
|
+
async function main() {
|
|
339
|
+
// Parse CLI arguments
|
|
340
|
+
const args = process.argv.slice(2);
|
|
341
|
+
const sampleIndex = args.indexOf('--sample');
|
|
342
|
+
const sampleSize = sampleIndex !== -1 ? parseInt(args[sampleIndex + 1]) : undefined;
|
|
343
|
+
|
|
344
|
+
// Create the evaluation runner with the sample size
|
|
345
|
+
const runEvaluation = createEvaluationRunner(sampleSize);
|
|
346
|
+
|
|
347
|
+
// Render the Ink UI
|
|
348
|
+
const { waitUntilExit } = render(
|
|
349
|
+
React.createElement(EvalApp, { runEvaluation })
|
|
350
|
+
);
|
|
351
|
+
|
|
352
|
+
await waitUntilExit();
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
main().catch(console.error);
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WhatsApp credential persistence.
|
|
3
|
+
*
|
|
4
|
+
* Wraps Baileys' useMultiFileAuthState with the ~/.brownian/credentials/ path.
|
|
5
|
+
*/
|
|
6
|
+
import { useMultiFileAuthState } from '@whiskeysockets/baileys';
|
|
7
|
+
import { getCredentialsDir } from '../../config.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Load or create auth state from ~/.brownian/credentials/.
|
|
11
|
+
*/
|
|
12
|
+
export async function loadAuthState() {
|
|
13
|
+
const dir = getCredentialsDir();
|
|
14
|
+
return useMultiFileAuthState(dir);
|
|
15
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Incoming WhatsApp message handler.
|
|
3
|
+
*
|
|
4
|
+
* Routes incoming messages to the Brownian agent and sends responses back.
|
|
5
|
+
* Only processes self-messages (messages from the linked account) that don't
|
|
6
|
+
* start with the [Brownian] prefix.
|
|
7
|
+
*/
|
|
8
|
+
import type { WASocket } from '@whiskeysockets/baileys';
|
|
9
|
+
import { config } from 'dotenv';
|
|
10
|
+
import { Agent } from '../../../agent/agent.js';
|
|
11
|
+
import { sendResponse } from './outbound.js';
|
|
12
|
+
import type { WhatsAppChannelConfig } from '../../config.js';
|
|
13
|
+
|
|
14
|
+
// Load env so the agent has access to API keys
|
|
15
|
+
config({ quiet: true });
|
|
16
|
+
|
|
17
|
+
const BROWNIAN_PREFIX = '[Brownian]';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Extract text content from a WhatsApp message.
|
|
21
|
+
*/
|
|
22
|
+
function extractText(message: Record<string, unknown>): string | null {
|
|
23
|
+
if (typeof message.conversation === 'string') return message.conversation;
|
|
24
|
+
if (
|
|
25
|
+
message.extendedTextMessage &&
|
|
26
|
+
typeof (message.extendedTextMessage as Record<string, unknown>).text === 'string'
|
|
27
|
+
) {
|
|
28
|
+
return (message.extendedTextMessage as Record<string, unknown>).text as string;
|
|
29
|
+
}
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Register the inbound message handler on a WhatsApp socket.
|
|
35
|
+
*/
|
|
36
|
+
export function registerInboundHandler(
|
|
37
|
+
sock: WASocket,
|
|
38
|
+
channelConfig: WhatsAppChannelConfig,
|
|
39
|
+
): void {
|
|
40
|
+
sock.ev.on('messages.upsert', async ({ messages, type }) => {
|
|
41
|
+
if (type !== 'notify') return;
|
|
42
|
+
|
|
43
|
+
for (const msg of messages) {
|
|
44
|
+
if (!msg.message || !msg.key.remoteJid) continue;
|
|
45
|
+
|
|
46
|
+
// Only respond to messages from ourselves (self-message pattern)
|
|
47
|
+
if (!msg.key.fromMe) continue;
|
|
48
|
+
|
|
49
|
+
const text = extractText(msg.message as Record<string, unknown>);
|
|
50
|
+
if (!text) continue;
|
|
51
|
+
|
|
52
|
+
// Skip our own responses
|
|
53
|
+
if (text.startsWith(BROWNIAN_PREFIX)) continue;
|
|
54
|
+
|
|
55
|
+
// Check allowFrom filter
|
|
56
|
+
if (channelConfig.allowFrom.length > 0) {
|
|
57
|
+
const sender = msg.key.remoteJid.replace(/@.*/, '');
|
|
58
|
+
const allowed = channelConfig.allowFrom.some(
|
|
59
|
+
(num) => sender.includes(num.replace(/\D/g, '')),
|
|
60
|
+
);
|
|
61
|
+
if (!allowed) continue;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
console.log(`Received query: ${text.slice(0, 80)}${text.length > 80 ? '...' : ''}`);
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
const agent = Agent.create();
|
|
68
|
+
const events = agent.run(text);
|
|
69
|
+
|
|
70
|
+
let answer = '';
|
|
71
|
+
for await (const event of events) {
|
|
72
|
+
if (event.type === 'done') {
|
|
73
|
+
answer = event.answer || 'No answer generated.';
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
await sendResponse(sock, msg.key.remoteJid, answer);
|
|
78
|
+
console.log(`Responded (${answer.length} chars).`);
|
|
79
|
+
} catch (err) {
|
|
80
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
81
|
+
console.error(`Agent error: ${errMsg}`);
|
|
82
|
+
await sendResponse(sock, msg.key.remoteJid, `Error: ${errMsg}`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* WhatsApp QR code login flow.
|
|
4
|
+
*
|
|
5
|
+
* Run: bun run gateway:login
|
|
6
|
+
*
|
|
7
|
+
* Displays a QR code in the terminal. Scan it with WhatsApp to link.
|
|
8
|
+
* Credentials are saved to ~/.brownian/credentials/ for subsequent use.
|
|
9
|
+
* Exits automatically once connected.
|
|
10
|
+
*/
|
|
11
|
+
import { createWhatsAppSession } from './session.js';
|
|
12
|
+
|
|
13
|
+
console.log('Brownian WhatsApp Gateway — Login');
|
|
14
|
+
console.log('Waiting for QR code...\n');
|
|
15
|
+
|
|
16
|
+
await createWhatsAppSession({
|
|
17
|
+
printQR: true,
|
|
18
|
+
onReady: () => {
|
|
19
|
+
console.log('\nLogin successful! Credentials saved.');
|
|
20
|
+
console.log('You can now run: bun run gateway');
|
|
21
|
+
// Give Baileys a moment to finish writing credentials
|
|
22
|
+
setTimeout(() => process.exit(0), 1000);
|
|
23
|
+
},
|
|
24
|
+
onLoggedOut: () => {
|
|
25
|
+
console.error('Login failed or session was logged out.');
|
|
26
|
+
process.exit(1);
|
|
27
|
+
},
|
|
28
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Send a response back to WhatsApp.
|
|
3
|
+
*
|
|
4
|
+
* Prefixes all outgoing messages with [Brownian] to distinguish agent replies.
|
|
5
|
+
*/
|
|
6
|
+
import type { WASocket } from '@whiskeysockets/baileys';
|
|
7
|
+
|
|
8
|
+
const PREFIX = '[Brownian] ';
|
|
9
|
+
const MAX_MESSAGE_LENGTH = 4096;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Send a text message to a WhatsApp JID.
|
|
13
|
+
* Long messages are truncated with a notice.
|
|
14
|
+
*/
|
|
15
|
+
export async function sendResponse(
|
|
16
|
+
sock: WASocket,
|
|
17
|
+
jid: string,
|
|
18
|
+
text: string,
|
|
19
|
+
): Promise<void> {
|
|
20
|
+
let body = PREFIX + text;
|
|
21
|
+
|
|
22
|
+
if (body.length > MAX_MESSAGE_LENGTH) {
|
|
23
|
+
body = body.slice(0, MAX_MESSAGE_LENGTH - 30) + '\n\n... (truncated)';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
await sock.sendMessage(jid, { text: body });
|
|
27
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WhatsApp session management via Baileys.
|
|
3
|
+
*
|
|
4
|
+
* Handles connection lifecycle, QR auth, reconnection, and credential persistence.
|
|
5
|
+
*/
|
|
6
|
+
import makeWASocket, {
|
|
7
|
+
DisconnectReason,
|
|
8
|
+
makeCacheableSignalKeyStore,
|
|
9
|
+
type WASocket,
|
|
10
|
+
} from '@whiskeysockets/baileys';
|
|
11
|
+
import { Boom } from '@hapi/boom';
|
|
12
|
+
import { loadAuthState } from './auth-store.js';
|
|
13
|
+
|
|
14
|
+
export type MessageHandler = (sock: WASocket) => void;
|
|
15
|
+
|
|
16
|
+
interface SessionOptions {
|
|
17
|
+
/** If true, print QR code in terminal for scanning. */
|
|
18
|
+
printQR?: boolean;
|
|
19
|
+
/** Called once connected successfully. Registers event listeners. */
|
|
20
|
+
onReady?: MessageHandler;
|
|
21
|
+
/** Called when the session is logged out (not a transient disconnect). */
|
|
22
|
+
onLoggedOut?: () => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Create a WhatsApp socket, handle connection lifecycle, and auto-reconnect.
|
|
27
|
+
*/
|
|
28
|
+
export async function createWhatsAppSession(opts: SessionOptions = {}): Promise<WASocket> {
|
|
29
|
+
const { printQR = true, onReady, onLoggedOut } = opts;
|
|
30
|
+
const { state, saveCreds } = await loadAuthState();
|
|
31
|
+
|
|
32
|
+
const sock = makeWASocket({
|
|
33
|
+
auth: {
|
|
34
|
+
creds: state.creds,
|
|
35
|
+
keys: makeCacheableSignalKeyStore(state.keys),
|
|
36
|
+
},
|
|
37
|
+
printQRInTerminal: printQR,
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
sock.ev.on('connection.update', (update) => {
|
|
41
|
+
const { connection, lastDisconnect, qr } = update;
|
|
42
|
+
|
|
43
|
+
if (qr) {
|
|
44
|
+
console.log('\nScan this QR code with WhatsApp to link your account.\n');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (connection === 'close') {
|
|
48
|
+
const statusCode = (lastDisconnect?.error as Boom)?.output?.statusCode;
|
|
49
|
+
const shouldReconnect = statusCode !== DisconnectReason.loggedOut;
|
|
50
|
+
|
|
51
|
+
if (shouldReconnect) {
|
|
52
|
+
console.log('Connection lost, reconnecting...');
|
|
53
|
+
createWhatsAppSession(opts);
|
|
54
|
+
} else {
|
|
55
|
+
console.log('Logged out from WhatsApp.');
|
|
56
|
+
onLoggedOut?.();
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (connection === 'open') {
|
|
61
|
+
console.log('Connected to WhatsApp.');
|
|
62
|
+
onReady?.(sock);
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
sock.ev.on('creds.update', saveCreds);
|
|
67
|
+
|
|
68
|
+
return sock;
|
|
69
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gateway configuration loader.
|
|
3
|
+
*
|
|
4
|
+
* Reads from ~/.brownian/gateway.json if it exists,
|
|
5
|
+
* otherwise returns sensible defaults.
|
|
6
|
+
*/
|
|
7
|
+
import { readFileSync, existsSync, mkdirSync, writeFileSync } from 'fs';
|
|
8
|
+
import { join } from 'path';
|
|
9
|
+
import { homedir } from 'os';
|
|
10
|
+
|
|
11
|
+
export interface WhatsAppChannelConfig {
|
|
12
|
+
enabled: boolean;
|
|
13
|
+
/** Phone numbers allowed to interact (e.g. ["+1234567890"]). Empty = allow all. */
|
|
14
|
+
allowFrom: string[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface GatewayConfig {
|
|
18
|
+
channels: {
|
|
19
|
+
whatsapp: WhatsAppChannelConfig;
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const BROWNIAN_DIR = join(homedir(), '.brownian');
|
|
24
|
+
const CONFIG_PATH = join(BROWNIAN_DIR, 'gateway.json');
|
|
25
|
+
|
|
26
|
+
const DEFAULT_CONFIG: GatewayConfig = {
|
|
27
|
+
channels: {
|
|
28
|
+
whatsapp: {
|
|
29
|
+
enabled: true,
|
|
30
|
+
allowFrom: [],
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Ensure the ~/.brownian directory exists.
|
|
37
|
+
*/
|
|
38
|
+
export function ensureBrownianDir(): string {
|
|
39
|
+
if (!existsSync(BROWNIAN_DIR)) {
|
|
40
|
+
mkdirSync(BROWNIAN_DIR, { recursive: true });
|
|
41
|
+
}
|
|
42
|
+
return BROWNIAN_DIR;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Load gateway config from ~/.brownian/gateway.json.
|
|
47
|
+
* Creates the file with defaults if it doesn't exist.
|
|
48
|
+
*/
|
|
49
|
+
export function loadGatewayConfig(): GatewayConfig {
|
|
50
|
+
ensureBrownianDir();
|
|
51
|
+
|
|
52
|
+
if (!existsSync(CONFIG_PATH)) {
|
|
53
|
+
writeFileSync(CONFIG_PATH, JSON.stringify(DEFAULT_CONFIG, null, 2) + '\n');
|
|
54
|
+
return DEFAULT_CONFIG;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
const raw = readFileSync(CONFIG_PATH, 'utf-8');
|
|
59
|
+
const parsed = JSON.parse(raw) as Partial<GatewayConfig>;
|
|
60
|
+
return {
|
|
61
|
+
channels: {
|
|
62
|
+
whatsapp: {
|
|
63
|
+
...DEFAULT_CONFIG.channels.whatsapp,
|
|
64
|
+
...parsed.channels?.whatsapp,
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
} catch {
|
|
69
|
+
console.warn(`Failed to parse ${CONFIG_PATH}, using defaults.`);
|
|
70
|
+
return DEFAULT_CONFIG;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Path to WhatsApp credential storage. */
|
|
75
|
+
export function getCredentialsDir(): string {
|
|
76
|
+
const dir = join(ensureBrownianDir(), 'credentials');
|
|
77
|
+
if (!existsSync(dir)) {
|
|
78
|
+
mkdirSync(dir, { recursive: true });
|
|
79
|
+
}
|
|
80
|
+
return dir;
|
|
81
|
+
}
|