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,64 @@
1
+ import {
2
+ findPrevWordStart,
3
+ findNextWordEnd,
4
+ getLineAndColumn,
5
+ getCursorPosition,
6
+ getLineStart,
7
+ getLineEnd,
8
+ getLineCount,
9
+ } from './text-navigation.js';
10
+
11
+ /**
12
+ * Context needed for cursor position calculations
13
+ */
14
+ export interface CursorContext {
15
+ text: string;
16
+ cursorPosition: number;
17
+ }
18
+
19
+ /**
20
+ * Pure functions for computing new cursor positions.
21
+ * Each function takes the current context and returns the new cursor position.
22
+ * For vertical movement (moveUp/moveDown), returns null if at boundary to signal
23
+ * that the caller should handle it (e.g., history navigation).
24
+ */
25
+ export const cursorHandlers = {
26
+ /** Move cursor one character left */
27
+ moveLeft: (ctx: CursorContext): number =>
28
+ Math.max(0, ctx.cursorPosition - 1),
29
+
30
+ /** Move cursor one character right */
31
+ moveRight: (ctx: CursorContext): number =>
32
+ Math.min(ctx.text.length, ctx.cursorPosition + 1),
33
+
34
+ /** Move cursor to start of current line */
35
+ moveToLineStart: (ctx: CursorContext): number =>
36
+ getLineStart(ctx.text, ctx.cursorPosition),
37
+
38
+ /** Move cursor to end of current line */
39
+ moveToLineEnd: (ctx: CursorContext): number =>
40
+ getLineEnd(ctx.text, ctx.cursorPosition),
41
+
42
+ /** Move cursor up one line, maintaining column position. Returns null if on first line. */
43
+ moveUp: (ctx: CursorContext): number | null => {
44
+ const { line, column } = getLineAndColumn(ctx.text, ctx.cursorPosition);
45
+ if (line === 0) return null; // At first line, signal to caller
46
+ return getCursorPosition(ctx.text, line - 1, column);
47
+ },
48
+
49
+ /** Move cursor down one line, maintaining column position. Returns null if on last line. */
50
+ moveDown: (ctx: CursorContext): number | null => {
51
+ const { line, column } = getLineAndColumn(ctx.text, ctx.cursorPosition);
52
+ const lineCount = getLineCount(ctx.text);
53
+ if (line >= lineCount - 1) return null; // At last line, signal to caller
54
+ return getCursorPosition(ctx.text, line + 1, column);
55
+ },
56
+
57
+ /** Move cursor to start of previous word */
58
+ moveWordBackward: (ctx: CursorContext): number =>
59
+ findPrevWordStart(ctx.text, ctx.cursorPosition),
60
+
61
+ /** Move cursor to end of next word */
62
+ moveWordForward: (ctx: CursorContext): number =>
63
+ findNextWordEnd(ctx.text, ctx.cursorPosition),
64
+ };
@@ -0,0 +1,67 @@
1
+ type LogLevel = 'debug' | 'info' | 'warn' | 'error';
2
+
3
+ interface LogEntry {
4
+ id: string;
5
+ level: LogLevel;
6
+ message: string;
7
+ timestamp: Date;
8
+ data?: unknown;
9
+ }
10
+
11
+ type LogSubscriber = (logs: LogEntry[]) => void;
12
+
13
+ class DebugLogger {
14
+ private logs: LogEntry[] = [];
15
+ private subscribers: Set<LogSubscriber> = new Set();
16
+ private maxLogs = 50;
17
+
18
+ private emit() {
19
+ this.subscribers.forEach(fn => fn([...this.logs]));
20
+ }
21
+
22
+ private add(level: LogLevel, message: string, data?: unknown) {
23
+ const entry: LogEntry = {
24
+ id: `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
25
+ level,
26
+ message,
27
+ timestamp: new Date(),
28
+ data,
29
+ };
30
+ this.logs.push(entry);
31
+ if (this.logs.length > this.maxLogs) {
32
+ this.logs = this.logs.slice(-this.maxLogs);
33
+ }
34
+ this.emit();
35
+ }
36
+
37
+ debug(message: string, data?: unknown) {
38
+ this.add('debug', message, data);
39
+ }
40
+
41
+ info(message: string, data?: unknown) {
42
+ this.add('info', message, data);
43
+ }
44
+
45
+ warn(message: string, data?: unknown) {
46
+ this.add('warn', message, data);
47
+ }
48
+
49
+ error(message: string, data?: unknown) {
50
+ this.add('error', message, data);
51
+ }
52
+
53
+ subscribe(fn: LogSubscriber): () => void {
54
+ this.subscribers.add(fn);
55
+ fn([...this.logs]); // Send current logs immediately
56
+ return () => this.subscribers.delete(fn);
57
+ }
58
+
59
+ clear() {
60
+ this.logs = [];
61
+ this.emit();
62
+ }
63
+ }
64
+
65
+ // Singleton instance
66
+ export const logger = new DebugLogger();
67
+ export type { LogEntry, LogLevel };
@@ -0,0 +1,138 @@
1
+ import { readFile, writeFile, mkdir } from 'fs/promises';
2
+ import { existsSync } from 'fs';
3
+ import { join, dirname } from 'path';
4
+
5
+ /**
6
+ * Represents a conversation entry (user message + agent response pair)
7
+ * Uses stack ordering: most recent at index 0
8
+ */
9
+ export interface ConversationEntry {
10
+ id: string;
11
+ timestamp: string;
12
+ userMessage: string;
13
+ agentResponse: string | null;
14
+ }
15
+
16
+ interface MessagesFile {
17
+ messages: ConversationEntry[];
18
+ }
19
+
20
+ const BROWNIAN_DIR = '.brownian';
21
+ const MESSAGES_DIR = 'messages';
22
+ const MESSAGES_FILE = 'chat_history.json';
23
+
24
+ /**
25
+ * Manages persistent storage of conversation history for input history navigation.
26
+ * Uses stack ordering (most recent first) for O(1) access to latest entries.
27
+ * Stores messages in .brownian/messages/chat_history.json
28
+ */
29
+ export class LongTermChatHistory {
30
+ private filePath: string;
31
+ private messages: ConversationEntry[] = [];
32
+ private loaded = false;
33
+
34
+ constructor(baseDir: string = process.cwd()) {
35
+ this.filePath = join(baseDir, BROWNIAN_DIR, MESSAGES_DIR, MESSAGES_FILE);
36
+ }
37
+
38
+ /**
39
+ * Loads messages from the JSON file.
40
+ * Creates the file and directories if they don't exist.
41
+ */
42
+ async load(): Promise<void> {
43
+ if (this.loaded) return;
44
+
45
+ try {
46
+ if (existsSync(this.filePath)) {
47
+ const content = await readFile(this.filePath, 'utf-8');
48
+ const data: MessagesFile = JSON.parse(content);
49
+ this.messages = data.messages || [];
50
+ } else {
51
+ // File doesn't exist, initialize with empty messages
52
+ this.messages = [];
53
+ await this.save();
54
+ }
55
+ } catch {
56
+ // If there's any error reading/parsing, start fresh
57
+ this.messages = [];
58
+ }
59
+
60
+ this.loaded = true;
61
+ }
62
+
63
+ /**
64
+ * Saves the current messages to the JSON file.
65
+ * Creates directories if they don't exist.
66
+ */
67
+ private async save(): Promise<void> {
68
+ const dir = dirname(this.filePath);
69
+
70
+ if (!existsSync(dir)) {
71
+ await mkdir(dir, { recursive: true });
72
+ }
73
+
74
+ const data: MessagesFile = { messages: this.messages };
75
+ await writeFile(this.filePath, JSON.stringify(data, null, 2), 'utf-8');
76
+ }
77
+
78
+ /**
79
+ * Adds a new user message to the history (prepends to stack).
80
+ * Agent response is null until updateAgentResponse is called.
81
+ */
82
+ async addUserMessage(message: string): Promise<void> {
83
+ if (!this.loaded) {
84
+ await this.load();
85
+ }
86
+
87
+ const entry: ConversationEntry = {
88
+ id: Date.now().toString(),
89
+ timestamp: new Date().toISOString(),
90
+ userMessage: message,
91
+ agentResponse: null,
92
+ };
93
+
94
+ // Prepend to stack (most recent first)
95
+ this.messages.unshift(entry);
96
+ await this.save();
97
+ }
98
+
99
+ /**
100
+ * Updates the agent response for the most recent conversation entry.
101
+ * O(1) lookup since most recent is at index 0.
102
+ */
103
+ async updateAgentResponse(response: string): Promise<void> {
104
+ if (!this.loaded) {
105
+ await this.load();
106
+ }
107
+
108
+ if (this.messages.length > 0) {
109
+ this.messages[0].agentResponse = response;
110
+ await this.save();
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Returns all conversation entries in stack order (newest first).
116
+ */
117
+ getMessages(): ConversationEntry[] {
118
+ return [...this.messages];
119
+ }
120
+
121
+ /**
122
+ * Returns user message strings in stack order (newest first).
123
+ * Deduplicates consecutive duplicates only (like shell HISTCONTROL=ignoredups).
124
+ * Used for input history navigation.
125
+ */
126
+ getMessageStrings(): string[] {
127
+ const result: string[] = [];
128
+
129
+ for (const m of this.messages) {
130
+ const lastMessage = result[result.length - 1];
131
+ if (lastMessage !== m.userMessage) {
132
+ result.push(m.userMessage);
133
+ }
134
+ }
135
+
136
+ return result;
137
+ }
138
+ }
@@ -0,0 +1,227 @@
1
+ /**
2
+ * Markdown table parsing and box-drawing rendering utilities.
3
+ *
4
+ * Converts markdown tables to properly-aligned Unicode box-drawing tables.
5
+ * Also handles bold text formatting.
6
+ */
7
+
8
+ import chalk from 'chalk';
9
+
10
+ // Box-drawing characters
11
+ const BOX = {
12
+ topLeft: '┌',
13
+ topRight: '┐',
14
+ bottomLeft: '└',
15
+ bottomRight: '┘',
16
+ horizontal: '─',
17
+ vertical: '│',
18
+ topT: '┬',
19
+ bottomT: '┴',
20
+ leftT: '├',
21
+ rightT: '┤',
22
+ cross: '┼',
23
+ };
24
+
25
+ /**
26
+ * Check if a string looks like a number (for right-alignment).
27
+ */
28
+ function isNumeric(value: string): boolean {
29
+ const trimmed = value.trim();
30
+ // Match numbers with optional $, %, B/M/K suffixes
31
+ return /^[$]?[-+]?[\d,]+\.?\d*[%BMK]?$/.test(trimmed);
32
+ }
33
+
34
+ /**
35
+ * Parse a markdown table into headers and rows.
36
+ */
37
+ export function parseMarkdownTable(tableText: string): { headers: string[]; rows: string[][] } | null {
38
+ const lines = tableText.trim().split('\n').map(line => line.trim());
39
+
40
+ if (lines.length < 2) return null;
41
+
42
+ // Parse header line
43
+ const headerLine = lines[0];
44
+ if (!headerLine.includes('|')) return null;
45
+
46
+ const headers = headerLine
47
+ .split('|')
48
+ .map(cell => cell.trim())
49
+ .filter((_, i, arr) => i > 0 && i < arr.length - 1 || arr.length === 1);
50
+
51
+ // Handle edge case where there's no leading/trailing pipe
52
+ if (headers.length === 0) {
53
+ const rawHeaders = headerLine.split('|').map(cell => cell.trim());
54
+ if (rawHeaders.length > 0) {
55
+ headers.push(...rawHeaders);
56
+ }
57
+ }
58
+
59
+ if (headers.length === 0) return null;
60
+
61
+ // Check for separator line (---|---|---)
62
+ const separatorLine = lines[1];
63
+ if (!separatorLine || !/^[\s|:-]+$/.test(separatorLine)) return null;
64
+
65
+ // Parse data rows
66
+ const rows: string[][] = [];
67
+ for (let i = 2; i < lines.length; i++) {
68
+ const line = lines[i];
69
+ if (!line.includes('|')) continue;
70
+
71
+ const cells = line
72
+ .split('|')
73
+ .map(cell => cell.trim());
74
+
75
+ // Remove empty first/last cells from pipes at start/end
76
+ if (cells[0] === '') cells.shift();
77
+ if (cells[cells.length - 1] === '') cells.pop();
78
+
79
+ if (cells.length > 0) {
80
+ rows.push(cells);
81
+ }
82
+ }
83
+
84
+ return { headers, rows };
85
+ }
86
+
87
+ /**
88
+ * Render a parsed table as a Unicode box-drawing table.
89
+ */
90
+ export function renderBoxTable(headers: string[], rows: string[][]): string {
91
+ // Calculate column widths
92
+ const colWidths: number[] = headers.map(h => h.length);
93
+
94
+ for (const row of rows) {
95
+ for (let i = 0; i < row.length; i++) {
96
+ if (i < colWidths.length) {
97
+ colWidths[i] = Math.max(colWidths[i], row[i].length);
98
+ }
99
+ }
100
+ }
101
+
102
+ // Determine alignment for each column (right for numeric, left for text)
103
+ const alignRight: boolean[] = headers.map((_, colIndex) => {
104
+ // Check if most values in this column are numeric
105
+ let numericCount = 0;
106
+ for (const row of rows) {
107
+ if (row[colIndex] && isNumeric(row[colIndex])) {
108
+ numericCount++;
109
+ }
110
+ }
111
+ return numericCount > rows.length / 2;
112
+ });
113
+
114
+ // Helper to pad a cell
115
+ const padCell = (value: string, width: number, rightAlign: boolean): string => {
116
+ if (rightAlign) {
117
+ return value.padStart(width);
118
+ }
119
+ return value.padEnd(width);
120
+ };
121
+
122
+ // Build the table
123
+ const lines: string[] = [];
124
+
125
+ // Top border
126
+ const topBorder = BOX.topLeft +
127
+ colWidths.map(w => BOX.horizontal.repeat(w + 2)).join(BOX.topT) +
128
+ BOX.topRight;
129
+ lines.push(topBorder);
130
+
131
+ // Header row
132
+ const headerRow = BOX.vertical +
133
+ headers.map((h, i) => ` ${padCell(h, colWidths[i], false)} `).join(BOX.vertical) +
134
+ BOX.vertical;
135
+ lines.push(headerRow);
136
+
137
+ // Header separator
138
+ const headerSep = BOX.leftT +
139
+ colWidths.map(w => BOX.horizontal.repeat(w + 2)).join(BOX.cross) +
140
+ BOX.rightT;
141
+ lines.push(headerSep);
142
+
143
+ // Data rows
144
+ for (const row of rows) {
145
+ const dataRow = BOX.vertical +
146
+ colWidths.map((w, i) => {
147
+ const value = row[i] || '';
148
+ return ` ${padCell(value, w, alignRight[i])} `;
149
+ }).join(BOX.vertical) +
150
+ BOX.vertical;
151
+ lines.push(dataRow);
152
+ }
153
+
154
+ // Bottom border
155
+ const bottomBorder = BOX.bottomLeft +
156
+ colWidths.map(w => BOX.horizontal.repeat(w + 2)).join(BOX.bottomT) +
157
+ BOX.bottomRight;
158
+ lines.push(bottomBorder);
159
+
160
+ return lines.join('\n');
161
+ }
162
+
163
+ /**
164
+ * Find and transform all markdown tables in content to box-drawing tables.
165
+ */
166
+ export function transformMarkdownTables(content: string): string {
167
+ // Normalize line endings: convert \r\n to \n, then trim trailing whitespace from each line
168
+ const normalized = content
169
+ .replace(/\r\n/g, '\n')
170
+ .split('\n')
171
+ .map(line => line.trimEnd())
172
+ .join('\n');
173
+
174
+ // Regex to match markdown tables:
175
+ // - Starts with a line containing pipes
176
+ // - Followed by a separator line (---|---|---)
177
+ // - Followed by zero or more data rows with pipes
178
+ // IMPORTANT: Use [ \t] instead of \s in separator to avoid matching newlines
179
+ const tableRegex = /^(\|[^\n]+\|\n\|[-:| \t]+\|(?:\n\|[^\n]+\|)*)/gm;
180
+
181
+ // Also match tables without leading/trailing pipes on each line
182
+ const tableRegex2 = /^([^\n|]*\|[^\n]+\n[-:| \t]+(?:\n[^\n|]*\|[^\n]+)*)/gm;
183
+
184
+ let result = normalized;
185
+
186
+ // Process tables with pipes at start/end
187
+ result = result.replace(tableRegex, (match) => {
188
+ const parsed = parseMarkdownTable(match);
189
+ if (parsed && parsed.headers.length > 0 && parsed.rows.length > 0) {
190
+ return renderBoxTable(parsed.headers, parsed.rows);
191
+ }
192
+ return match;
193
+ });
194
+
195
+ // Process tables that might not have leading pipes
196
+ result = result.replace(tableRegex2, (match) => {
197
+ // Skip if already transformed (contains box-drawing chars)
198
+ if (match.includes(BOX.topLeft)) return match;
199
+
200
+ const parsed = parseMarkdownTable(match);
201
+ if (parsed && parsed.headers.length > 0 && parsed.rows.length > 0) {
202
+ return renderBoxTable(parsed.headers, parsed.rows);
203
+ }
204
+ return match;
205
+ });
206
+
207
+ return result;
208
+ }
209
+
210
+ /**
211
+ * Transform markdown bold (**text**) to ANSI bold.
212
+ */
213
+ export function transformBold(content: string): string {
214
+ return content.replace(/\*\*([^*]+)\*\*/g, (_, text) => chalk.bold(text));
215
+ }
216
+
217
+ /**
218
+ * Apply all pre-render formatting to response content.
219
+ * - Converts markdown tables to unicode box-drawing tables
220
+ * - Converts **bold** to ANSI bold
221
+ */
222
+ export function formatResponse(content: string): string {
223
+ let result = content;
224
+ result = transformMarkdownTables(result);
225
+ result = transformBold(result);
226
+ return result;
227
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Ollama API utilities
3
+ */
4
+
5
+ interface OllamaModel {
6
+ name: string;
7
+ modified_at: string;
8
+ size: number;
9
+ }
10
+
11
+ interface OllamaTagsResponse {
12
+ models: OllamaModel[];
13
+ }
14
+
15
+ /**
16
+ * Fetches locally downloaded models from the Ollama API
17
+ */
18
+ export async function getOllamaModels(): Promise<string[]> {
19
+ const baseUrl = process.env.OLLAMA_BASE_URL || 'http://localhost:11434';
20
+
21
+ try {
22
+ const response = await fetch(`${baseUrl}/api/tags`);
23
+
24
+ if (!response.ok) {
25
+ return [];
26
+ }
27
+
28
+ const data = (await response.json()) as OllamaTagsResponse;
29
+ return (data?.models ?? [])
30
+ .map((m) => m?.name)
31
+ .filter((n): n is string => typeof n === 'string');
32
+ } catch {
33
+ // Ollama not running or unreachable
34
+ return [];
35
+ }
36
+ }
37
+
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Lightweight async-iterable queue for streaming progress messages
3
+ * from subagent tools back to the agent event loop in real-time.
4
+ *
5
+ * Tools call `emit()` to push messages; the agent drains the channel
6
+ * via `for await...of` and yields ToolProgressEvents to the UI.
7
+ */
8
+ export interface ProgressChannel {
9
+ /** Push a progress message into the channel */
10
+ emit: (message: string) => void;
11
+ /** Signal that no more messages will be emitted */
12
+ close: () => void;
13
+ /** Async iterable interface for draining messages */
14
+ [Symbol.asyncIterator](): AsyncIterator<string>;
15
+ }
16
+
17
+ /**
18
+ * Create a progress channel that bridges synchronous `emit()` calls
19
+ * from inside a tool to the async generator that the agent drains.
20
+ *
21
+ * Uses a simple buffer + pending-resolver pattern:
22
+ * - If the consumer is waiting (no buffered items), `emit()` resolves
23
+ * the pending promise immediately.
24
+ * - If the consumer is busy, messages buffer until the next `next()` call.
25
+ * - `close()` marks the channel as done; the iterator terminates after
26
+ * draining any remaining buffered items.
27
+ */
28
+ export function createProgressChannel(): ProgressChannel {
29
+ const buffer: string[] = [];
30
+ let closed = false;
31
+ let pendingResolve: ((value: IteratorResult<string>) => void) | null = null;
32
+
33
+ const emit = (message: string) => {
34
+ if (closed) return;
35
+
36
+ if (pendingResolve) {
37
+ // Consumer is waiting -- deliver immediately
38
+ const resolve = pendingResolve;
39
+ pendingResolve = null;
40
+ resolve({ value: message, done: false });
41
+ } else {
42
+ // Consumer is busy -- buffer for later
43
+ buffer.push(message);
44
+ }
45
+ };
46
+
47
+ const close = () => {
48
+ closed = true;
49
+
50
+ // If the consumer is waiting and nothing is buffered, signal done
51
+ if (pendingResolve && buffer.length === 0) {
52
+ const resolve = pendingResolve;
53
+ pendingResolve = null;
54
+ resolve({ value: undefined as unknown as string, done: true });
55
+ }
56
+ };
57
+
58
+ const asyncIterator: AsyncIterator<string> = {
59
+ next(): Promise<IteratorResult<string>> {
60
+ // Drain buffered items first
61
+ if (buffer.length > 0) {
62
+ return Promise.resolve({ value: buffer.shift()!, done: false });
63
+ }
64
+
65
+ // Nothing buffered and channel is closed -- we're done
66
+ if (closed) {
67
+ return Promise.resolve({ value: undefined as unknown as string, done: true });
68
+ }
69
+
70
+ // Nothing buffered, channel still open -- wait for next emit or close
71
+ return new Promise<IteratorResult<string>>((resolve) => {
72
+ pendingResolve = resolve;
73
+ });
74
+ },
75
+ };
76
+
77
+ return {
78
+ emit,
79
+ close,
80
+ [Symbol.asyncIterator]() {
81
+ return asyncIterator;
82
+ },
83
+ };
84
+ }