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