Tauri + Svelte 5 + Rust application for orchestrating multiple AI coding agents. Includes Claude, Aider, Codex, and Ollama provider support, multi-agent communication (btmsg/bttask), session anchors, plugin sandbox, FTS5 search, Landlock sandboxing, and 507 vitest + 110 cargo tests.
243 lines
7.4 KiB
TypeScript
243 lines
7.4 KiB
TypeScript
// aider-parser.ts — Pure parsing functions extracted from aider-runner.ts
|
|
// Exported for unit testing. aider-runner.ts imports from here.
|
|
|
|
import { execSync } from 'child_process';
|
|
|
|
// --- Types ---
|
|
|
|
export interface TurnBlock {
|
|
type: 'thinking' | 'text' | 'shell' | 'cost';
|
|
content: string;
|
|
}
|
|
|
|
// --- Constants ---
|
|
|
|
// Prompt detection: Aider with --no-pretty --no-fancy-input shows prompts like:
|
|
// > or aider> or repo-name>
|
|
export const PROMPT_RE = /^[a-zA-Z0-9._-]*> $/;
|
|
|
|
// Lines to suppress from UI (aider startup noise)
|
|
export const SUPPRESS_RE = [
|
|
/^Aider v\d/,
|
|
/^Main model:/,
|
|
/^Weak model:/,
|
|
/^Git repo:/,
|
|
/^Repo-map:/,
|
|
/^Use \/help/,
|
|
];
|
|
|
|
// Known shell command patterns — commands from btmsg/bttask/common tools
|
|
export const SHELL_CMD_RE = /^(btmsg |bttask |cat |ls |find |grep |mkdir |cd |cp |mv |rm |pip |npm |git |curl |wget |python |node |bash |sh )/;
|
|
|
|
// --- Pure parsing functions ---
|
|
|
|
/**
|
|
* Detects whether the last non-empty line of a buffer looks like an Aider prompt.
|
|
* Aider with --no-pretty --no-fancy-input shows prompts like: `> `, `aider> `, `repo-name> `
|
|
*/
|
|
export function looksLikePrompt(buffer: string): boolean {
|
|
const lines = buffer.split('\n');
|
|
for (let i = lines.length - 1; i >= 0; i--) {
|
|
const l = lines[i];
|
|
if (l.trim() === '') continue;
|
|
return PROMPT_RE.test(l);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Returns true for lines that should be suppressed from the UI output.
|
|
* Covers Aider startup noise and empty lines.
|
|
*/
|
|
export function shouldSuppress(line: string): boolean {
|
|
const t = line.trim();
|
|
return t === '' || SUPPRESS_RE.some(p => p.test(t));
|
|
}
|
|
|
|
/**
|
|
* Parses complete Aider turn output into structured blocks.
|
|
* Handles thinking sections, text, shell commands extracted from code blocks
|
|
* or inline, cost lines, and suppresses startup noise.
|
|
*/
|
|
export function parseTurnOutput(buffer: string): TurnBlock[] {
|
|
const blocks: TurnBlock[] = [];
|
|
const lines = buffer.split('\n');
|
|
|
|
let thinkingLines: string[] = [];
|
|
let answerLines: string[] = [];
|
|
let inThinking = false;
|
|
let inAnswer = false;
|
|
let inCodeBlock = false;
|
|
let codeBlockLang = '';
|
|
let codeBlockLines: string[] = [];
|
|
|
|
for (const line of lines) {
|
|
const t = line.trim();
|
|
|
|
// Skip suppressed lines
|
|
if (shouldSuppress(line) && !inCodeBlock) continue;
|
|
|
|
// Prompt markers — skip
|
|
if (PROMPT_RE.test(t)) continue;
|
|
|
|
// Thinking block markers (handle various unicode arrows and spacing)
|
|
if (/^[►▶⯈❯>]\s*THINKING$/i.test(t)) {
|
|
inThinking = true;
|
|
inAnswer = false;
|
|
continue;
|
|
}
|
|
if (/^[►▶⯈❯>]\s*ANSWER$/i.test(t)) {
|
|
if (thinkingLines.length > 0) {
|
|
blocks.push({ type: 'thinking', content: thinkingLines.join('\n') });
|
|
thinkingLines = [];
|
|
}
|
|
inThinking = false;
|
|
inAnswer = true;
|
|
continue;
|
|
}
|
|
|
|
// Code block detection (```bash, ```shell, ```)
|
|
if (t.startsWith('```') && !inCodeBlock) {
|
|
inCodeBlock = true;
|
|
codeBlockLang = t.slice(3).trim().toLowerCase();
|
|
codeBlockLines = [];
|
|
continue;
|
|
}
|
|
if (t === '```' && inCodeBlock) {
|
|
inCodeBlock = false;
|
|
// If this was a bash/shell code block, extract commands
|
|
if (['bash', 'shell', 'sh', ''].includes(codeBlockLang)) {
|
|
for (const cmdLine of codeBlockLines) {
|
|
const cmd = cmdLine.trim().replace(/^\$ /, '');
|
|
if (cmd && SHELL_CMD_RE.test(cmd)) {
|
|
if (answerLines.length > 0) {
|
|
blocks.push({ type: 'text', content: answerLines.join('\n') });
|
|
answerLines = [];
|
|
}
|
|
blocks.push({ type: 'shell', content: cmd });
|
|
}
|
|
}
|
|
}
|
|
codeBlockLines = [];
|
|
continue;
|
|
}
|
|
if (inCodeBlock) {
|
|
codeBlockLines.push(line);
|
|
continue;
|
|
}
|
|
|
|
// Cost line
|
|
if (/^Tokens: .+Cost:/.test(t)) {
|
|
blocks.push({ type: 'cost', content: t });
|
|
continue;
|
|
}
|
|
|
|
// Shell command ($ prefix or Running prefix)
|
|
if (t.startsWith('$ ') || t.startsWith('Running ')) {
|
|
if (answerLines.length > 0) {
|
|
blocks.push({ type: 'text', content: answerLines.join('\n') });
|
|
answerLines = [];
|
|
}
|
|
blocks.push({ type: 'shell', content: t.replace(/^(Running |\$ )/, '') });
|
|
continue;
|
|
}
|
|
|
|
// Detect bare btmsg/bttask commands in answer text
|
|
if (inAnswer && SHELL_CMD_RE.test(t) && !t.includes('`') && !t.startsWith('#')) {
|
|
if (answerLines.length > 0) {
|
|
blocks.push({ type: 'text', content: answerLines.join('\n') });
|
|
answerLines = [];
|
|
}
|
|
blocks.push({ type: 'shell', content: t });
|
|
continue;
|
|
}
|
|
|
|
// Aider's "Applied edit" / flake8 output — suppress from answer text
|
|
if (/^Applied edit to |^Fix any errors|^Running: /.test(t)) continue;
|
|
|
|
// Accumulate into thinking or answer
|
|
if (inThinking) {
|
|
thinkingLines.push(line);
|
|
} else {
|
|
answerLines.push(line);
|
|
}
|
|
}
|
|
|
|
// Flush remaining
|
|
if (thinkingLines.length > 0) {
|
|
blocks.push({ type: 'thinking', content: thinkingLines.join('\n') });
|
|
}
|
|
if (answerLines.length > 0) {
|
|
blocks.push({ type: 'text', content: answerLines.join('\n').trim() });
|
|
}
|
|
|
|
return blocks;
|
|
}
|
|
|
|
/**
|
|
* Extracts session cost from a raw turn buffer.
|
|
* Returns 0 when no cost line is present.
|
|
*/
|
|
export function extractSessionCost(buffer: string): number {
|
|
const match = buffer.match(/Cost: \$([0-9.]+) message, \$([0-9.]+) session/);
|
|
return match ? parseFloat(match[2]) : 0;
|
|
}
|
|
|
|
// --- I/O helpers (require real child_process; mock in tests) ---
|
|
|
|
function log(message: string) {
|
|
process.stderr.write(`[aider-parser] ${message}\n`);
|
|
}
|
|
|
|
/**
|
|
* Runs a CLI command and returns its trimmed stdout, or null on failure/empty.
|
|
*/
|
|
export function runCmd(cmd: string, env: Record<string, string>, cwd: string): string | null {
|
|
try {
|
|
const result = execSync(cmd, { env, cwd, timeout: 5000, encoding: 'utf-8' }).trim();
|
|
log(`[prefetch] ${cmd} → ${result.length} chars`);
|
|
return result || null;
|
|
} catch (e: unknown) {
|
|
log(`[prefetch] ${cmd} FAILED: ${e instanceof Error ? e.message : String(e)}`);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Pre-fetches btmsg inbox and bttask board context.
|
|
* Returns formatted markdown with both sections.
|
|
*/
|
|
export function prefetchContext(env: Record<string, string>, cwd: string): string {
|
|
log(`[prefetch] BTMSG_AGENT_ID=${env.BTMSG_AGENT_ID ?? 'NOT SET'}, cwd=${cwd}`);
|
|
const parts: string[] = [];
|
|
|
|
const inbox = runCmd('btmsg inbox', env, cwd);
|
|
if (inbox) {
|
|
parts.push(`## Your Inbox\n\`\`\`\n${inbox}\n\`\`\``);
|
|
} else {
|
|
parts.push('## Your Inbox\nNo messages (or btmsg unavailable).');
|
|
}
|
|
|
|
const board = runCmd('bttask board', env, cwd);
|
|
if (board) {
|
|
parts.push(`## Task Board\n\`\`\`\n${board}\n\`\`\``);
|
|
} else {
|
|
parts.push('## Task Board\nNo tasks (or bttask unavailable).');
|
|
}
|
|
|
|
return parts.join('\n\n');
|
|
}
|
|
|
|
/**
|
|
* Executes a shell command and returns stdout + exit code.
|
|
* On failure, returns stderr/error message with a non-zero exit code.
|
|
*/
|
|
export function execShell(cmd: string, env: Record<string, string>, cwd: string): { stdout: string; exitCode: number } {
|
|
try {
|
|
const result = execSync(cmd, { env, cwd, timeout: 30000, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
|
|
return { stdout: result.trim(), exitCode: 0 };
|
|
} catch (e: unknown) {
|
|
const err = e as { stdout?: string; stderr?: string; status?: number };
|
|
return { stdout: (err.stdout ?? err.stderr ?? String(e)).trim(), exitCode: err.status ?? 1 };
|
|
}
|
|
}
|