import { StringEnum, Type } from '@mariozechner/pi-ai'; import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, truncateHead, truncateTail, withFileMutationQueue, type ExtensionAPI, } from '@mariozechner/pi-coding-agent'; import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; import { existsSync } from 'node:fs'; import { mkdtemp, readFile, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { createInterface } from 'node:readline'; const CHECK_SYNTAX_SCRIPT = resolve(__dirname, 'guile/check_syntax.scm'); const EVAL_EXPR_SCRIPT = resolve(__dirname, 'guile/eval_expr.scm'); const REPL_SIDECAR_SCRIPT = resolve(__dirname, 'guile/repl_sidecar.scm'); const TOOL_DESCRIPTION_SUFFIX = `Tool output is truncated to ${DEFAULT_MAX_LINES} lines or ${formatSize(DEFAULT_MAX_BYTES)}.`; const REPLACE_DIFF_PREVIEW_MAX_LINES = 150; const REPLACE_DIFF_PREVIEW_MAX_BYTES = 20 * 1024; const REPLACE_DIFF_CONTEXT_LINES = 3; interface FormSpan { start: number; end: number; line: number; text: string; name?: string; head?: string; } interface SyntaxCheckResult { valid: boolean; errors: string[]; } interface EvalResult { stdout: string; value: string; } interface SidecarWaiter { resolve: (line: string) => void; reject: (error: Error) => void; } interface ReplSidecar { process: ChildProcessWithoutNullStreams; cwd: string; lines: string[]; waiters: SidecarWaiter[]; queue: Promise; stderrTail: string[]; closedError?: Error; } interface ReplaceDiffInfo { mode: 'preview' | 'full'; text: string; truncated: boolean; outputLines: number; totalLines: number; outputBytes: number; totalBytes: number; } function normalizeUserPath(path: string): string { return path.startsWith('@') ? path.slice(1) : path; } function lineNumberAt(source: string, index: number): number { let line = 1; for (let i = 0; i < index && i < source.length; i += 1) { if (source[i] === '\n') line += 1; } return line; } function isWhitespace(ch: string | undefined): boolean { return ch === ' ' || ch === '\n' || ch === '\r' || ch === '\t' || ch === '\f'; } function isDelimiter(ch: string | undefined): boolean { if (ch === undefined) return true; return ( isWhitespace(ch) || ch === '(' || ch === ')' || ch === '[' || ch === ']' || ch === '"' || ch === ';' ); } function parseTopLevelForms(source: string): FormSpan[] { const len = source.length; function skipLineComment(i: number): number { let cursor = i; while (cursor < len && source[cursor] !== '\n') cursor += 1; return cursor; } function skipBlockComment(i: number): number { let cursor = i + 2; let depth = 1; while (cursor < len - 1) { if (source[cursor] === '#' && source[cursor + 1] === '|') { depth += 1; cursor += 2; continue; } if (source[cursor] === '|' && source[cursor + 1] === '#') { depth -= 1; cursor += 2; if (depth === 0) return cursor; continue; } cursor += 1; } throw new Error('Unterminated block comment (#| ... |#).'); } function parseString(i: number): number { let cursor = i + 1; let escaped = false; while (cursor < len) { const ch = source[cursor]; if (escaped) { escaped = false; } else if (ch === '\\') { escaped = true; } else if (ch === '"') { return cursor + 1; } cursor += 1; } throw new Error('Unterminated string literal.'); } function parseAtom(i: number): number { let cursor = i; while (cursor < len) { const ch = source[cursor]; if (isDelimiter(ch)) break; if (ch === '#' && (source[cursor + 1] === '|' || source[cursor + 1] === ';')) break; cursor += 1; } if (cursor === i) throw new Error(`Unexpected token at index ${i}.`); return cursor; } function skipTrivia(i: number): number { let cursor = i; while (cursor < len) { const ch = source[cursor]; if (isWhitespace(ch)) { cursor += 1; continue; } if (ch === ';') { cursor = skipLineComment(cursor); continue; } if (ch === '#' && source[cursor + 1] === '|') { cursor = skipBlockComment(cursor); continue; } if (ch === '#' && source[cursor + 1] === ';') { cursor += 2; cursor = skipTrivia(cursor); cursor = parseDatum(cursor); continue; } break; } return cursor; } function parseList(i: number): number { const open = source[i]; const close = open === '(' ? ')' : ']'; let cursor = i + 1; while (cursor < len) { cursor = skipTrivia(cursor); if (cursor >= len) break; if (source[cursor] === close) return cursor + 1; if (source[cursor] === ')' || source[cursor] === ']') { throw new Error(`Mismatched list delimiter near index ${cursor}.`); } cursor = parseDatum(cursor); } throw new Error('Unterminated list form.'); } function parseDatum(i: number): number { const cursor = skipTrivia(i); if (cursor >= len) throw new Error('Unexpected end of input.'); const ch = source[cursor]; if (ch === '(' || ch === '[') { return parseList(cursor); } if (ch === '"') { return parseString(cursor); } if (ch === "'" || ch === '`') { return parseDatum(cursor + 1); } if (ch === ',') { return parseDatum(source[cursor + 1] === '@' ? cursor + 2 : cursor + 1); } if (ch === '#') { if (source[cursor + 1] === '(' || source[cursor + 1] === '[') { return parseList(cursor + 1); } let maybeVectorCursor = cursor + 1; while (/[a-zA-Z0-9]/.test(source[maybeVectorCursor] ?? '')) maybeVectorCursor += 1; if (source[maybeVectorCursor] === '(' || source[maybeVectorCursor] === '[') { return parseList(maybeVectorCursor); } } return parseAtom(cursor); } const forms: FormSpan[] = []; let cursor = skipTrivia(0); while (cursor < len) { const start = cursor; const end = parseDatum(cursor); const text = source.slice(start, end); forms.push({ start, end, line: lineNumberAt(source, start), text, name: extractFormName(text), head: extractFormHead(text), }); cursor = skipTrivia(end); } return forms; } function extractFormName(formText: string): string | undefined { const trimmed = formText.trim(); if (!trimmed.startsWith('(')) return undefined; const patterns = [ /^\(\s*(?:define|define-public|define\*)\s+\(([^\s()\[\]]+)/, /^\(\s*(?:define|define-public|define\*)\s+([^\s()\[\]]+)/, /^\(\s*(?:define-syntax|define-syntax-rule|define-macro)\s+\(([^\s()\[\]]+)/, /^\(\s*(?:define-syntax|define-syntax-rule|define-macro)\s+([^\s()\[\]]+)/, /^\(\s*define-record-type\s+([^\s()\[\]]+)/, /^\(\s*define-class\s+([^\s()\[\]]+)/, ]; for (const pattern of patterns) { const match = trimmed.match(pattern); if (match?.[1]) return match[1]; } return undefined; } function extractFormHead(formText: string): string | undefined { const trimmed = formText.trim(); const match = trimmed.match(/^\(\s*([^\s()\[\]]+)/); return match?.[1]; } function oneLine(text: string): string { return text.replace(/\s+/g, ' ').trim(); } function formLineCount(formText: string): number { if (formText.length === 0) return 0; return formText.split('\n').length; } function formByteSize(formText: string): number { return Buffer.byteLength(formText, 'utf8'); } function collapseForm(formText: string): string { const trimmed = formText.trim(); const lines = trimmed.split('\n'); let output = lines.length <= 5 ? oneLine(trimmed) : `${oneLine(lines[0] ?? trimmed)} ... [${lines.length} lines]`; if (output.length > 180) { output = `${output.slice(0, 177)}...`; } return output; } function uniqueByName(forms: FormSpan[], name: string): FormSpan { const matches = forms.filter((form) => form.name === name); if (matches.length === 0) { const available = Array.from(new Set(forms.map((form) => form.name).filter(Boolean))).slice( 0, 30, ) as string[]; const suffix = available.length > 0 ? ` Available names: ${available.join(', ')}` : ''; throw new Error(`No top-level form named "${name}" found.${suffix}`); } if (matches.length > 1) { const locations = matches.map((match) => `line ${match.line}`).join(', '); throw new Error( `Multiple forms named "${name}" found (${locations}). Please disambiguate first.`, ); } return matches[0]; } function resolveFormSelector( forms: FormSpan[], selector: { name?: string; index?: number }, selectorLabel: string, ): FormSpan { const hasName = typeof selector.name === 'string'; const hasIndex = typeof selector.index === 'number'; if (hasName === hasIndex) { throw new Error(`Provide exactly one of ${selectorLabel} name or ${selectorLabel} index.`); } if (hasName) { return uniqueByName(forms, selector.name as string); } const index = selector.index as number; if (!Number.isInteger(index) || index < 1 || index > forms.length) { throw new Error( `${selectorLabel} index must be an integer between 1 and ${forms.length}. Received: ${index}`, ); } return forms[index - 1] as FormSpan; } function selectorDisplay(selector: { name?: string; index?: number }): string { if (typeof selector.name === 'string') return `"${selector.name}"`; return `#${selector.index}`; } function ensureSingleTopLevelForm(source: string): string { const trimmed = source.trim(); if (!trimmed) throw new Error('newSource is empty.'); const forms = parseTopLevelForms(trimmed); if (forms.length !== 1 || forms[0].start !== 0 || forms[0].end !== trimmed.length) { throw new Error('newSource must contain exactly one top-level form.'); } return trimmed; } function formatTruncated(text: string, mode: 'head' | 'tail' = 'head'): string { const truncation = mode === 'head' ? truncateHead(text, { maxLines: DEFAULT_MAX_LINES, maxBytes: DEFAULT_MAX_BYTES }) : truncateTail(text, { maxLines: DEFAULT_MAX_LINES, maxBytes: DEFAULT_MAX_BYTES }); let output = truncation.content; if (truncation.truncated) { output += `\n\n[Output truncated: ${truncation.outputLines}/${truncation.totalLines} lines, ${formatSize(truncation.outputBytes)}/${formatSize(truncation.totalBytes)}.]`; } return output; } function formatUnifiedRange(start: number, count: number): string { return `${start},${count}`; } function buildUnifiedDiff(oldText: string, newText: string): string { const oldLines = oldText.split('\n'); const newLines = newText.split('\n'); let prefix = 0; while ( prefix < oldLines.length && prefix < newLines.length && oldLines[prefix] === newLines[prefix] ) { prefix += 1; } let suffix = 0; while ( suffix < oldLines.length - prefix && suffix < newLines.length - prefix && oldLines[oldLines.length - 1 - suffix] === newLines[newLines.length - 1 - suffix] ) { suffix += 1; } const oldChangeStart = prefix; const oldChangeEnd = oldLines.length - suffix; const newChangeStart = prefix; const newChangeEnd = newLines.length - suffix; const oldStart = Math.max(0, oldChangeStart - REPLACE_DIFF_CONTEXT_LINES); const newStart = Math.max(0, newChangeStart - REPLACE_DIFF_CONTEXT_LINES); const oldEnd = Math.min(oldLines.length, oldChangeEnd + REPLACE_DIFF_CONTEXT_LINES); const newEnd = Math.min(newLines.length, newChangeEnd + REPLACE_DIFF_CONTEXT_LINES); const diffLines: string[] = [ '--- before', '+++ after', `@@ -${formatUnifiedRange(oldStart + 1, oldEnd - oldStart)} +${formatUnifiedRange(newStart + 1, newEnd - newStart)} @@`, ]; for (let i = oldStart; i < oldChangeStart; i += 1) { diffLines.push(` ${oldLines[i]}`); } for (let i = oldChangeStart; i < oldChangeEnd; i += 1) { diffLines.push(`-${oldLines[i]}`); } for (let i = newChangeStart; i < newChangeEnd; i += 1) { diffLines.push(`+${newLines[i]}`); } for (let i = oldChangeEnd; i < oldEnd; i += 1) { diffLines.push(` ${oldLines[i]}`); } return diffLines.join('\n'); } function buildReplaceDiffInfo( oldText: string, newText: string, mode: 'preview' | 'full', ): ReplaceDiffInfo { const diff = buildUnifiedDiff(oldText, newText); const totalLines = diff.length === 0 ? 0 : diff.split('\n').length; const totalBytes = Buffer.byteLength(diff, 'utf8'); if (mode === 'full') { return { mode, text: diff, truncated: false, outputLines: totalLines, totalLines, outputBytes: totalBytes, totalBytes, }; } const truncation = truncateHead(diff, { maxLines: REPLACE_DIFF_PREVIEW_MAX_LINES, maxBytes: REPLACE_DIFF_PREVIEW_MAX_BYTES, }); let text = truncation.content; if (truncation.truncated) { text += `\n[Diff preview truncated: ${truncation.outputLines}/${truncation.totalLines} lines, ${formatSize(truncation.outputBytes)}/${formatSize(truncation.totalBytes)}.]`; } return { mode, text, truncated: truncation.truncated, outputLines: truncation.outputLines, totalLines: truncation.totalLines, outputBytes: truncation.outputBytes, totalBytes: truncation.totalBytes, }; } function parseEvalOutput(raw: string): EvalResult { const stdoutStart = '__PIGI_STDOUT_START__\n'; const stdoutEnd = '__PIGI_STDOUT_END__\n'; const resultStart = '__PIGI_RESULT_START__\n'; const resultEnd = '__PIGI_RESULT_END__\n'; const stdoutStartIndex = raw.indexOf(stdoutStart); const stdoutEndIndex = raw.indexOf(stdoutEnd); const resultStartIndex = raw.indexOf(resultStart); const resultEndIndex = raw.indexOf(resultEnd); if ( stdoutStartIndex === -1 || stdoutEndIndex === -1 || resultStartIndex === -1 || resultEndIndex === -1 ) { return { stdout: '', value: raw.trim() }; } const stdout = raw.slice(stdoutStartIndex + stdoutStart.length, stdoutEndIndex); const value = raw.slice(resultStartIndex + resultStart.length, resultEndIndex); return { stdout: stdout.trimEnd(), value: value.trimEnd() }; } async function writeTempScheme(source: string): Promise { const dir = await mkdtemp(join(tmpdir(), 'pigibrack-')); const file = join(dir, 'input.scm'); await writeFile(file, source, 'utf8'); return file; } export default function pigibrackExtension(pi: ExtensionAPI) { let replSidecar: ReplSidecar | undefined; function closeSidecar(sidecar: ReplSidecar, error: Error) { if (sidecar.closedError) return; sidecar.closedError = error; for (const waiter of sidecar.waiters) { waiter.reject(error); } sidecar.waiters = []; if (replSidecar === sidecar) { replSidecar = undefined; } } function readSidecarLine(sidecar: ReplSidecar): Promise { if (sidecar.lines.length > 0) { return Promise.resolve(sidecar.lines.shift() as string); } if (sidecar.closedError) { return Promise.reject(sidecar.closedError); } return new Promise((resolve, reject) => { sidecar.waiters.push({ resolve, reject }); }); } async function waitForSidecarLine( sidecar: ReplSidecar, timeoutMs: number, signal?: AbortSignal, ): Promise { const linePromise = readSidecarLine(sidecar); return await new Promise((resolve, reject) => { let done = false; const finish = (fn: () => void) => { if (done) return; done = true; clearTimeout(timeout); if (signal) { signal.removeEventListener('abort', onAbort); } fn(); }; const timeout = setTimeout(() => { finish(() => reject(new Error(`Timed out waiting for sidecar response after ${timeoutMs}ms.`)), ); }, timeoutMs); const onAbort = () => { finish(() => reject(new Error('Operation cancelled.'))); }; if (signal) { if (signal.aborted) { finish(() => reject(new Error('Operation cancelled.'))); return; } signal.addEventListener('abort', onAbort, { once: true }); } linePromise.then( (line) => finish(() => resolve(line)), (error) => finish(() => reject(error)), ); }); } function enqueueSidecar(sidecar: ReplSidecar, task: () => Promise): Promise { const run = sidecar.queue.then(task, task); sidecar.queue = run.then( () => undefined, () => undefined, ); return run; } function createReplSidecar(cwd: string): ReplSidecar { const process = spawn('guile', ['-L', cwd, REPL_SIDECAR_SCRIPT], { cwd, stdio: ['pipe', 'pipe', 'pipe'], }); const sidecar: ReplSidecar = { process, cwd, lines: [], waiters: [], queue: Promise.resolve(), stderrTail: [], }; const stdoutReader = createInterface({ input: process.stdout }); stdoutReader.on('line', (line) => { if (sidecar.waiters.length > 0) { const waiter = sidecar.waiters.shift() as SidecarWaiter; waiter.resolve(line); } else { sidecar.lines.push(line); } }); process.stderr.on('data', (chunk) => { const text = String(chunk); for (const line of text.split('\n')) { const trimmed = line.trim(); if (!trimmed) continue; sidecar.stderrTail.push(trimmed); if (sidecar.stderrTail.length > 20) { sidecar.stderrTail.shift(); } } }); process.on('error', (error) => { closeSidecar(sidecar, error instanceof Error ? error : new Error(String(error))); }); process.on('close', (code, signal) => { const stderrSummary = sidecar.stderrTail.length > 0 ? ` stderr: ${sidecar.stderrTail.join(' | ')}` : ''; closeSidecar( sidecar, new Error( `Guile sidecar exited (code=${String(code)}, signal=${String(signal)}).${stderrSummary}`, ), ); }); return sidecar; } async function stopReplSidecar() { if (!replSidecar) return; const sidecar = replSidecar; replSidecar = undefined; if (sidecar.closedError) { return; } try { sidecar.process.stdin.write('QUIT\n'); } catch { // ignore } await new Promise((resolve) => { const timer = setTimeout(() => { if (!sidecar.process.killed) { sidecar.process.kill('SIGTERM'); } }, 500); sidecar.process.once('close', () => { clearTimeout(timer); resolve(); }); }); } async function ensureReplSidecar(cwd: string, signal?: AbortSignal): Promise { if (replSidecar && !replSidecar.closedError && replSidecar.cwd !== cwd) { await stopReplSidecar(); } if (!replSidecar || replSidecar.closedError) { const sidecar = createReplSidecar(cwd); const ready = await waitForSidecarLine(sidecar, 5_000, signal); if (ready !== 'READY') { closeSidecar(sidecar, new Error(`Unexpected sidecar handshake: ${ready}`)); throw new Error(`Failed to start pigibrack REPL sidecar: ${ready}`); } replSidecar = sidecar; } return replSidecar; } function sanitizeModuleSpec(moduleSpec: string | undefined): string { if (!moduleSpec) return '-'; return moduleSpec.replace(/[\t\n\r]/g, ' ').trim() || '-'; } async function sidecarCommand( cwd: string, command: string, signal?: AbortSignal, ): Promise { const sidecar = await ensureReplSidecar(cwd, signal); return enqueueSidecar(sidecar, async () => { if (sidecar.closedError) throw sidecar.closedError; sidecar.process.stdin.write(`${command}\n`); return await waitForSidecarLine(sidecar, 30_000, signal); }); } async function guileSyntaxCheck( input: { path?: string; source?: string }, signal?: AbortSignal, ): Promise { let targetPath: string | undefined; if (input.path) { targetPath = input.path; } else if (typeof input.source === 'string') { targetPath = await writeTempScheme(input.source); } if (!targetPath) { return { valid: false, errors: ['Either path or source must be provided.'] }; } const result = await pi.exec('guile', [CHECK_SYNTAX_SCRIPT, targetPath], { signal, timeout: 30_000, }); if (result.code === 0) { return { valid: true, errors: [] }; } const message = [result.stderr, result.stdout].filter(Boolean).join('\n').trim() || 'Unknown syntax error'; return { valid: false, errors: [message] }; } async function guileEvalDirect( expr: string, moduleSpec: string | undefined, cwd: string, signal?: AbortSignal, ): Promise { const exprPath = await writeTempScheme(expr); const args = ['-L', cwd, EVAL_EXPR_SCRIPT, exprPath]; if (moduleSpec) args.push(moduleSpec); const result = await pi.exec('guile', args, { signal, timeout: 30_000, cwd, }); if (result.code !== 0) { const message = [result.stderr, result.stdout].filter(Boolean).join('\n').trim() || 'Guile evaluation failed.'; throw new Error(message); } return parseEvalOutput(result.stdout ?? ''); } async function guileEval( expr: string, moduleSpec: string | undefined, cwd: string, signal?: AbortSignal, ): Promise { const dir = await mkdtemp(join(tmpdir(), 'pigibrack-repl-')); const exprPath = join(dir, 'expr.scm'); const stdoutPath = join(dir, 'stdout.txt'); const valuePath = join(dir, 'value.txt'); const errorPath = join(dir, 'error.txt'); await writeFile(exprPath, expr, 'utf8'); const command = [ 'EVAL', exprPath, sanitizeModuleSpec(moduleSpec), stdoutPath, valuePath, errorPath, ].join('\t'); try { const response = await sidecarCommand(cwd, command, signal); const stdout = (await readFile(stdoutPath, 'utf8').catch(() => '')).trimEnd(); const value = (await readFile(valuePath, 'utf8').catch(() => '')).trimEnd(); const error = (await readFile(errorPath, 'utf8').catch(() => '')).trim(); if (response === 'DONE\tOK') { return { stdout, value }; } if (response === 'DONE\tERR') { throw new Error(error || 'Guile sidecar evaluation failed.'); } throw new Error(`Unexpected sidecar response: ${response}`); } catch (error) { // Fallback to non-persistent eval script so eval still works if sidecar is unavailable. return await guileEvalDirect(expr, moduleSpec, cwd, signal); } } async function guileMacroExpand( expr: string, moduleSpec: string | undefined, cwd: string, signal?: AbortSignal, ): Promise { const dir = await mkdtemp(join(tmpdir(), 'pigibrack-mexp-')); const exprPath = join(dir, 'expr.scm'); const valuePath = join(dir, 'value.txt'); const errorPath = join(dir, 'error.txt'); await writeFile(exprPath, expr, 'utf8'); const command = ['MEXP', exprPath, sanitizeModuleSpec(moduleSpec), valuePath, errorPath].join( '\t', ); try { const response = await sidecarCommand(cwd, command, signal); const value = (await readFile(valuePath, 'utf8').catch(() => '')).trimEnd(); const error = (await readFile(errorPath, 'utf8').catch(() => '')).trim(); if (response === 'DONE\tOK') { return value; } if (response === 'DONE\tERR') { throw new Error(error || 'Guile sidecar macro expansion failed.'); } throw new Error(`Unexpected sidecar response: ${response}`); } catch { const fallbackExpr = `(begin (use-modules (language tree-il)) (tree-il->scheme (macroexpand ${expr})))`; const result = await guileEvalDirect(fallbackExpr, moduleSpec, cwd, signal); return result.value; } } pi.registerCommand('pigibrack-status', { description: 'Show pigibrack extension status and guile availability', handler: async (_args, ctx) => { const scriptsExist = existsSync(CHECK_SYNTAX_SCRIPT) && existsSync(EVAL_EXPR_SCRIPT) && existsSync(REPL_SIDECAR_SCRIPT); const guileVersion = await pi.exec('guile', ['--version'], { timeout: 5_000 }); const lines = [ `scripts: ${scriptsExist ? 'ok' : 'missing'}`, `check_syntax.scm: ${CHECK_SYNTAX_SCRIPT}`, `eval_expr.scm: ${EVAL_EXPR_SCRIPT}`, `repl_sidecar.scm: ${REPL_SIDECAR_SCRIPT}`, `guile available: ${guileVersion.code === 0 ? 'yes' : 'no'}`, `sidecar running: ${replSidecar && !replSidecar.closedError ? 'yes' : 'no'}`, ]; if (replSidecar && !replSidecar.closedError) { lines.push(`sidecar cwd: ${replSidecar.cwd}`); } if (guileVersion.code === 0) { const firstLine = (guileVersion.stdout ?? '').split('\n')[0]?.trim(); if (firstLine) lines.push(`guile: ${firstLine}`); } ctx.ui.notify(lines.join('\n'), scriptsExist && guileVersion.code === 0 ? 'info' : 'warning'); }, }); pi.registerCommand('pigibrack-repl-reset', { description: 'Reset the pigibrack persistent guile REPL sidecar', handler: async (_args, ctx) => { const response = await sidecarCommand(ctx.cwd, 'RESET'); if (response !== 'DONE\tOK') { throw new Error(`Unexpected sidecar response: ${response}`); } ctx.ui.notify('pigibrack REPL sidecar reset.', 'info'); }, }); pi.on('session_shutdown', async () => { await stopReplSidecar(); }); pi.registerTool({ name: 'pigibrack_read_module', label: 'pigibrack read module', description: `Read a Scheme file as collapsed top-level forms (name + one-line summary). ${TOOL_DESCRIPTION_SUFFIX}`, promptSnippet: 'Inspect Scheme modules by top-level forms before editing.', promptGuidelines: [ 'Use pigibrack_read_module before pigibrack_read_form for large Scheme files.', ], parameters: Type.Object({ path: Type.String({ description: 'Path to a Scheme source file' }), }), async execute(_toolCallId, params, _signal, _onUpdate, ctx) { const absolutePath = resolve(ctx.cwd, normalizeUserPath(params.path)); const source = await readFile(absolutePath, 'utf8'); const forms = parseTopLevelForms(source); const lines = forms.map((form, index) => { const id = form.name ? ` name=${form.name}` : form.head ? ` head=${form.head}` : ''; const lineCount = formLineCount(form.text); const bytes = formByteSize(form.text); return `${String(index + 1).padStart(3, ' ')}. line ${form.line}${id} (${lineCount}L ${formatSize(bytes)}) :: ${collapseForm(form.text)}`; }); const output = `Module: ${params.path}\nTop-level forms: ${forms.length}\n\n${lines.join('\n')}`; return { content: [{ type: 'text', text: formatTruncated(output, 'head') }], details: { path: params.path, absolutePath, forms: forms.map((form, index) => ({ index: index + 1, line: form.line, name: form.name, head: form.head, lineCount: formLineCount(form.text), bytes: formByteSize(form.text), summary: collapseForm(form.text), })), }, }; }, }); pi.registerTool({ name: 'pigibrack_read_form', label: 'pigibrack read form', description: `Read one top-level Scheme form by name or index. ${TOOL_DESCRIPTION_SUFFIX}`, promptSnippet: 'Read an individual Scheme top-level form by name or index.', promptGuidelines: ['Use pigibrack_read_form to fetch a form before replacing it.'], parameters: Type.Object({ path: Type.String({ description: 'Path to a Scheme source file' }), name: Type.Optional( Type.String({ description: 'Top-level form name selector (e.g. my-function)' }), ), index: Type.Optional( Type.Number({ description: 'Top-level form index selector (1-based, from read_module output)', }), ), }), async execute(_toolCallId, params, _signal, _onUpdate, ctx) { const absolutePath = resolve(ctx.cwd, normalizeUserPath(params.path)); const source = await readFile(absolutePath, 'utf8'); const forms = parseTopLevelForms(source); const form = resolveFormSelector(forms, { name: params.name, index: params.index }, 'form'); return { content: [{ type: 'text', text: formatTruncated(form.text, 'head') }], details: { path: params.path, absolutePath, selector: { name: params.name, index: params.index, }, line: form.line, name: form.name, head: form.head, }, }; }, }); pi.registerTool({ name: 'pigibrack_read_forms', label: 'pigibrack read forms', description: `Read multiple top-level Scheme forms by selector list (name or index). ${TOOL_DESCRIPTION_SUFFIX}`, promptSnippet: 'Read multiple Scheme top-level forms in one call by name/index selectors.', parameters: Type.Object({ path: Type.String({ description: 'Path to a Scheme source file' }), selectors: Type.Array( Type.Object({ name: Type.Optional(Type.String({ description: 'Top-level form name selector' })), index: Type.Optional( Type.Number({ description: 'Top-level form index selector (1-based)' }), ), }), ), }), async execute(_toolCallId, params, _signal, _onUpdate, ctx) { const absolutePath = resolve(ctx.cwd, normalizeUserPath(params.path)); const source = await readFile(absolutePath, 'utf8'); const forms = parseTopLevelForms(source); if (params.selectors.length === 0) { throw new Error('selectors must contain at least one entry.'); } const resolved = params.selectors.map((selector, i) => { const form = resolveFormSelector(forms, selector, `selector #${i + 1}`); return { selector, form, }; }); const text = resolved .map(({ selector, form }, i) => { const id = form.name ? `name=${form.name}` : form.head ? `head=${form.head}` : 'anonymous'; const selectorText = selectorDisplay(selector); return [`### ${i + 1}. selector=${selectorText} line=${form.line} ${id}`, form.text].join( '\n', ); }) .join('\n\n'); return { content: [{ type: 'text', text: formatTruncated(text, 'head') }], details: { path: params.path, absolutePath, count: resolved.length, forms: resolved.map(({ selector, form }) => ({ selector, line: form.line, name: form.name, head: form.head, lineCount: formLineCount(form.text), bytes: formByteSize(form.text), })), }, }; }, }); pi.registerTool({ name: 'pigibrack_search_forms', label: 'pigibrack search forms', description: `Search top-level forms in a Scheme file by text, name, or head symbol. ${TOOL_DESCRIPTION_SUFFIX}`, promptSnippet: 'Search top-level Scheme forms within a module by query text.', parameters: Type.Object({ path: Type.String({ description: 'Path to a Scheme source file' }), query: Type.String({ description: 'Query text to search for' }), caseSensitive: Type.Optional( Type.Boolean({ description: 'Whether matching is case-sensitive' }), ), }), async execute(_toolCallId, params, _signal, _onUpdate, ctx) { const absolutePath = resolve(ctx.cwd, normalizeUserPath(params.path)); const source = await readFile(absolutePath, 'utf8'); const forms = parseTopLevelForms(source); const caseSensitive = params.caseSensitive ?? false; const needle = caseSensitive ? params.query : params.query.toLowerCase(); const contains = (value: string) => caseSensitive ? value.includes(needle) : value.toLowerCase().includes(needle); const matches = forms.filter((form) => { return ( contains(form.text) || (form.name ? contains(form.name) : false) || (form.head ? contains(form.head) : false) ); }); const lines = matches.map((form, index) => { const id = form.name ? ` name=${form.name}` : form.head ? ` head=${form.head}` : ''; return `${String(index + 1).padStart(3, ' ')}. line ${form.line}${id} :: ${collapseForm(form.text)}`; }); const output = `Search: ${params.query}\n` + `Module: ${params.path}\n` + `Matches: ${matches.length}/${forms.length}\n\n` + (lines.length > 0 ? lines.join('\n') : 'No matching forms.'); return { content: [{ type: 'text', text: formatTruncated(output, 'head') }], details: { path: params.path, absolutePath, query: params.query, caseSensitive, matches: matches.map((form) => ({ line: form.line, name: form.name, head: form.head, summary: collapseForm(form.text), })), }, }; }, }); pi.registerTool({ name: 'pigibrack_replace_form', label: 'pigibrack replace form', description: 'Replace a top-level Scheme form by name or index, with syntax pre-check and optional diff output.', promptSnippet: 'Replace an entire top-level Scheme form by name or index.', promptGuidelines: [ 'Always call pigibrack_read_form first, then replace with a complete new form.', 'newSource must be exactly one top-level form.', ], parameters: Type.Object({ path: Type.String({ description: 'Path to a Scheme source file' }), name: Type.Optional(Type.String({ description: 'Top-level form name selector to replace' })), index: Type.Optional( Type.Number({ description: 'Top-level form index selector to replace (1-based)' }), ), newSource: Type.String({ description: 'The complete new top-level form source' }), includeDiff: Type.Optional(StringEnum(['none', 'preview', 'full'] as const)), }), async execute(_toolCallId, params, signal, _onUpdate, ctx) { const absolutePath = resolve(ctx.cwd, normalizeUserPath(params.path)); const normalizedSource = ensureSingleTopLevelForm(params.newSource); const includeDiff = params.includeDiff ?? 'preview'; const syntax = await guileSyntaxCheck({ source: normalizedSource }, signal); if (!syntax.valid) { throw new Error(`newSource is not valid Scheme: ${syntax.errors.join('; ')}`); } return withFileMutationQueue(absolutePath, async () => { const source = await readFile(absolutePath, 'utf8'); const forms = parseTopLevelForms(source); const selector = { name: params.name, index: params.index }; const form = resolveFormSelector(forms, selector, 'form'); const updated = `${source.slice(0, form.start)}${normalizedSource}${source.slice(form.end)}`; await writeFile(absolutePath, updated, 'utf8'); const diffInfo = includeDiff === 'none' ? undefined : buildReplaceDiffInfo(form.text, normalizedSource, includeDiff); let text = `Replaced form ${selectorDisplay(selector)} at line ${form.line} in ${params.path}.`; if (diffInfo) { text += `\n\nUnified diff (${diffInfo.mode}${diffInfo.truncated ? ', truncated' : ''}):\n\n${diffInfo.text}`; } return { content: [ { type: 'text', text: formatTruncated(text, 'head'), }, ], details: { path: params.path, absolutePath, selector, line: form.line, name: form.name, head: form.head, includeDiff, diff: diffInfo ? { mode: diffInfo.mode, truncated: diffInfo.truncated, outputLines: diffInfo.outputLines, totalLines: diffInfo.totalLines, outputBytes: diffInfo.outputBytes, totalBytes: diffInfo.totalBytes, } : undefined, }, }; }); }, }); pi.registerTool({ name: 'pigibrack_insert_form', label: 'pigibrack insert form', description: 'Insert a new top-level Scheme form before or after an anchor form selected by name or index.', promptSnippet: 'Insert a new top-level Scheme form relative to an existing form.', parameters: Type.Object({ path: Type.String({ description: 'Path to a Scheme source file' }), anchorName: Type.Optional(Type.String({ description: 'Anchor form name selector' })), anchorIndex: Type.Optional( Type.Number({ description: 'Anchor form index selector (1-based, from read_module output)', }), ), position: StringEnum(['before', 'after'] as const), newSource: Type.String({ description: 'New top-level form source' }), }), async execute(_toolCallId, params, signal, _onUpdate, ctx) { const absolutePath = resolve(ctx.cwd, normalizeUserPath(params.path)); const normalizedSource = ensureSingleTopLevelForm(params.newSource); const syntax = await guileSyntaxCheck({ source: normalizedSource }, signal); if (!syntax.valid) { throw new Error(`newSource is not valid Scheme: ${syntax.errors.join('; ')}`); } return withFileMutationQueue(absolutePath, async () => { const source = await readFile(absolutePath, 'utf8'); const forms = parseTopLevelForms(source); const selector = { name: params.anchorName, index: params.anchorIndex }; const anchor = resolveFormSelector(forms, selector, 'anchor form'); const insertionPoint = params.position === 'before' ? anchor.start : anchor.end; const left = source.slice(0, insertionPoint); const right = source.slice(insertionPoint); const needsLeadingNewline = left.length > 0 && !left.endsWith('\n'); const needsTrailingNewline = right.length > 0 && !right.startsWith('\n'); const inserted = `${needsLeadingNewline ? '\n' : ''}${normalizedSource}${needsTrailingNewline ? '\n' : ''}`; const updated = `${left}${inserted}${right}`; await writeFile(absolutePath, updated, 'utf8'); return { content: [ { type: 'text', text: `Inserted form ${params.position} ${selectorDisplay(selector)} (line ${anchor.line}) in ${params.path}.`, }, ], details: { path: params.path, absolutePath, selector, position: params.position, line: anchor.line, name: anchor.name, head: anchor.head, }, }; }); }, }); pi.registerTool({ name: 'pigibrack_delete_form', label: 'pigibrack delete form', description: 'Delete a top-level Scheme form by name or index.', promptSnippet: 'Delete a top-level Scheme form by its name or index.', parameters: Type.Object({ path: Type.String({ description: 'Path to a Scheme source file' }), name: Type.Optional(Type.String({ description: 'Top-level form name selector to delete' })), index: Type.Optional( Type.Number({ description: 'Top-level form index selector to delete (1-based)' }), ), }), async execute(_toolCallId, params, _signal, _onUpdate, ctx) { const absolutePath = resolve(ctx.cwd, normalizeUserPath(params.path)); return withFileMutationQueue(absolutePath, async () => { const source = await readFile(absolutePath, 'utf8'); const forms = parseTopLevelForms(source); const selector = { name: params.name, index: params.index }; const form = resolveFormSelector(forms, selector, 'form'); let start = form.start; let end = form.end; if (source[end] === '\n') end += 1; if (start > 0 && source[start - 1] === '\n' && source[end] === '\n') start -= 1; const updated = `${source.slice(0, start)}${source.slice(end)}`; await writeFile(absolutePath, updated, 'utf8'); return { content: [ { type: 'text', text: `Deleted form ${selectorDisplay(selector)} from ${params.path}.`, }, ], details: { path: params.path, absolutePath, selector, line: form.line, name: form.name, head: form.head, }, }; }); }, }); pi.registerTool({ name: 'pigibrack_check_syntax', label: 'pigibrack check syntax', description: 'Check Scheme syntax via guile reader. Accepts either a file path or inline source.', promptSnippet: 'Validate Scheme syntax before editing or evaluating.', parameters: Type.Object({ path: Type.Optional(Type.String({ description: 'Path to a Scheme source file' })), source: Type.Optional(Type.String({ description: 'Inline Scheme source to validate' })), }), async execute(_toolCallId, params, signal, _onUpdate, ctx) { if (!params.path && typeof params.source !== 'string') { throw new Error('Provide either path or source.'); } if (params.path && typeof params.source === 'string') { throw new Error('Provide path or source, not both.'); } const resolvedPath = params.path ? resolve(ctx.cwd, normalizeUserPath(params.path)) : undefined; const result = await guileSyntaxCheck({ path: resolvedPath, source: params.source }, signal); const output = result.valid ? 'Syntax check: valid ✅' : `Syntax check: invalid ❌\n\n${result.errors.map((error, index) => `${index + 1}. ${error}`).join('\n')}`; return { content: [{ type: 'text', text: formatTruncated(output, 'head') }], details: { valid: result.valid, errors: result.errors, path: params.path, }, }; }, }); pi.registerTool({ name: 'pigibrack_macro_expand', label: 'pigibrack macro expand', description: 'Macro-expand a Scheme expression in guile. Optional module is a module spec, e.g. (my module).', promptSnippet: 'Expand a Scheme form to inspect macro output before debugging runtime behavior.', parameters: Type.Object({ expr: Type.String({ description: 'A Scheme expression to macro-expand' }), module: Type.Optional(Type.String({ description: 'Optional module spec, e.g. (my module)' })), }), async execute(_toolCallId, params, signal, _onUpdate, ctx) { const normalizedExpr = ensureSingleTopLevelForm(params.expr); const expanded = await guileMacroExpand(normalizedExpr, params.module, ctx.cwd, signal); const output = `expanded:\n${expanded || ''}`; return { content: [{ type: 'text', text: formatTruncated(output, 'tail') }], details: { module: params.module, }, }; }, }); pi.registerTool({ name: 'pigibrack_eval_expr', label: 'pigibrack eval expr', description: `Evaluate a Scheme expression in guile. Optional module is a module spec, e.g. (my module). ${TOOL_DESCRIPTION_SUFFIX}`, promptSnippet: 'Evaluate Scheme expressions in guile for fast feedback.', promptGuidelines: [ 'Use pigibrack_eval_expr after structural edits to validate behavior quickly.', ], parameters: Type.Object({ expr: Type.String({ description: 'A Scheme expression to evaluate' }), module: Type.Optional(Type.String({ description: 'Optional module spec, e.g. (my module)' })), }), async execute(_toolCallId, params, signal, _onUpdate, ctx) { const result = await guileEval(params.expr, params.module, ctx.cwd, signal); const outputParts = [] as string[]; if (result.stdout.trim()) { outputParts.push(`stdout:\n${result.stdout}`); } outputParts.push(`result:\n${result.value || ''}`); return { content: [{ type: 'text', text: formatTruncated(outputParts.join('\n\n'), 'tail') }], details: { module: params.module, hasStdout: result.stdout.trim().length > 0, }, }; }, }); }