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