feat: add persistent guile REPL sidecar for pigibrack eval

This commit is contained in:
2026-04-04 11:04:00 +02:00
parent a301d20c92
commit b03b2e0eca
3 changed files with 403 additions and 5 deletions

View File

@@ -14,9 +14,10 @@ Tools:
- `pigibrack_check_syntax({ path } | { source })` - `pigibrack_check_syntax({ path } | { source })`
- `pigibrack_eval_expr(expr, module?)` - `pigibrack_eval_expr(expr, module?)`
Command: Commands:
- `/pigibrack-status` - `/pigibrack-status`
- `/pigibrack-repl-reset`
## How to load ## How to load
@@ -33,11 +34,11 @@ pi -e ./.pi/extensions/pigibrack/index.ts
## Notes ## Notes
- Structural operations are top-level form by **name**. - Structural operations are top-level form by **name**.
- Syntax checks and eval run through **guile** scripts in `guile/`. - Syntax checks run through **guile** reader scripts in `guile/`.
- Eval uses a persistent Guile REPL sidecar (`repl_sidecar.scm`) with fallback to one-shot eval if sidecar startup fails.
- File mutations use pi's `withFileMutationQueue()` to avoid race conditions in parallel tool mode. - File mutations use pi's `withFileMutationQueue()` to avoid race conditions in parallel tool mode.
- Output is truncated to avoid context blowups. - Output is truncated to avoid context blowups.
## Current limitations ## Current limitations
- `pigibrack_eval_expr` currently runs a fresh Guile process per call (not yet a persistent REPL session).
- Name extraction targets common `define*` patterns; unusual macro forms may be unnamed by the tool. - Name extraction targets common `define*` patterns; unusual macro forms may be unnamed by the tool.

View File

@@ -0,0 +1,104 @@
(use-modules (ice-9 pretty-print)
(ice-9 rdelim)
(ice-9 textual-ports))
(define (->string obj)
(call-with-output-string
(lambda (port)
(write obj port))))
(define (read-text path)
(call-with-input-file path get-string-all))
(define (write-text path text)
(call-with-output-file path
(lambda (port)
(display text port))))
(define (split-tabs line)
(let ((n (string-length line)))
(let loop ((i 0) (start 0) (parts '()))
(if (= i n)
(reverse (cons (substring line start i) parts))
(if (char=? (string-ref line i) #\tab)
(loop (+ i 1) (+ i 1) (cons (substring line start i) parts))
(loop (+ i 1) start parts))))))
(define (module-spec-from-string raw)
(if (or (string=? raw "-") (string-null? raw))
#f
(call-with-input-string raw read)))
(define (reply text)
(display text)
(newline)
(force-output))
(define (handle-eval parts)
(if (< (length parts) 6)
(reply "DONE\tERR")
(let* ((expr-path (list-ref parts 1))
(module-raw (list-ref parts 2))
(stdout-path (list-ref parts 3))
(value-path (list-ref parts 4))
(error-path (list-ref parts 5)))
(catch #t
(lambda ()
(let ((module-spec (module-spec-from-string module-raw)))
(when module-spec
(set-current-module (resolve-interface module-spec))))
(let* ((expr-source (read-text expr-path))
(expr (call-with-input-string expr-source read))
(stdout-port (open-output-string))
(value (parameterize ((current-output-port stdout-port))
(eval expr (current-module))))
(stdout (get-output-string stdout-port))
(value-port (open-output-string)))
(pretty-print value value-port)
(write-text stdout-path stdout)
(write-text value-path (get-output-string value-port))
(write-text error-path "")
(reply "DONE\tOK")))
(lambda (key . args)
(write-text stdout-path "")
(write-text value-path "")
(write-text error-path (string-append (symbol->string key) " " (->string args) "\n"))
(reply "DONE\tERR"))))))
(define (handle-reset)
(set-current-module (resolve-module '(guile-user) #:ensure #t))
(reply "DONE\tOK"))
(define (dispatch line)
(let* ((parts (split-tabs line))
(command (if (null? parts) "" (car parts))))
(cond
((string=? command "EVAL")
(handle-eval parts)
#t)
((string=? command "RESET")
(handle-reset)
#t)
((string=? command "PING")
(reply "PONG")
#t)
((string=? command "QUIT")
(reply "BYE")
#f)
(else
(reply "DONE\tERR")
#t))))
(define (main)
(set-current-module (resolve-module '(guile-user) #:ensure #t))
(reply "READY")
(let loop ()
(let ((line (read-line)))
(if (eof-object? line)
(primitive-exit 0)
(when (dispatch line)
(loop))))))
(main)

View File

@@ -8,13 +8,16 @@ import {
withFileMutationQueue, withFileMutationQueue,
type ExtensionAPI, type ExtensionAPI,
} from '@mariozechner/pi-coding-agent'; } from '@mariozechner/pi-coding-agent';
import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process';
import { existsSync } from 'node:fs'; import { existsSync } from 'node:fs';
import { mkdtemp, readFile, writeFile } from 'node:fs/promises'; import { mkdtemp, readFile, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path'; import { join, resolve } from 'node:path';
import { createInterface } from 'node:readline';
const CHECK_SYNTAX_SCRIPT = resolve(__dirname, 'guile/check_syntax.scm'); const CHECK_SYNTAX_SCRIPT = resolve(__dirname, 'guile/check_syntax.scm');
const EVAL_EXPR_SCRIPT = resolve(__dirname, 'guile/eval_expr.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 TOOL_DESCRIPTION_SUFFIX = `Tool output is truncated to ${DEFAULT_MAX_LINES} lines or ${formatSize(DEFAULT_MAX_BYTES)}.`;
@@ -36,6 +39,21 @@ interface EvalResult {
value: 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<void>;
stderrTail: string[];
closedError?: Error;
}
function normalizeUserPath(path: string): string { function normalizeUserPath(path: string): string {
return path.startsWith('@') ? path.slice(1) : path; return path.startsWith('@') ? path.slice(1) : path;
} }
@@ -362,6 +380,213 @@ async function writeTempScheme(source: string): Promise<string> {
} }
export default function pigibrackExtension(pi: ExtensionAPI) { 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<string> {
if (sidecar.lines.length > 0) {
return Promise.resolve(sidecar.lines.shift() as string);
}
if (sidecar.closedError) {
return Promise.reject(sidecar.closedError);
}
return new Promise<string>((resolve, reject) => {
sidecar.waiters.push({ resolve, reject });
});
}
async function waitForSidecarLine(
sidecar: ReplSidecar,
timeoutMs: number,
signal?: AbortSignal,
): Promise<string> {
const linePromise = readSidecarLine(sidecar);
return await new Promise<string>((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<T>(sidecar: ReplSidecar, task: () => Promise<T>): Promise<T> {
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<void>((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<ReplSidecar> {
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<string> {
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( async function guileSyntaxCheck(
input: { path?: string; source?: string }, input: { path?: string; source?: string },
signal?: AbortSignal, signal?: AbortSignal,
@@ -392,7 +617,7 @@ export default function pigibrackExtension(pi: ExtensionAPI) {
return { valid: false, errors: [message] }; return { valid: false, errors: [message] };
} }
async function guileEval( async function guileEvalDirect(
expr: string, expr: string,
moduleSpec: string | undefined, moduleSpec: string | undefined,
cwd: string, cwd: string,
@@ -418,19 +643,72 @@ export default function pigibrackExtension(pi: ExtensionAPI) {
return parseEvalOutput(result.stdout ?? ''); return parseEvalOutput(result.stdout ?? '');
} }
async function guileEval(
expr: string,
moduleSpec: string | undefined,
cwd: string,
signal?: AbortSignal,
): Promise<EvalResult> {
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);
}
}
pi.registerCommand('pigibrack-status', { pi.registerCommand('pigibrack-status', {
description: 'Show pigibrack extension status and guile availability', description: 'Show pigibrack extension status and guile availability',
handler: async (_args, ctx) => { handler: async (_args, ctx) => {
const scriptsExist = existsSync(CHECK_SYNTAX_SCRIPT) && existsSync(EVAL_EXPR_SCRIPT); 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 guileVersion = await pi.exec('guile', ['--version'], { timeout: 5_000 });
const lines = [ const lines = [
`scripts: ${scriptsExist ? 'ok' : 'missing'}`, `scripts: ${scriptsExist ? 'ok' : 'missing'}`,
`check_syntax.scm: ${CHECK_SYNTAX_SCRIPT}`, `check_syntax.scm: ${CHECK_SYNTAX_SCRIPT}`,
`eval_expr.scm: ${EVAL_EXPR_SCRIPT}`, `eval_expr.scm: ${EVAL_EXPR_SCRIPT}`,
`repl_sidecar.scm: ${REPL_SIDECAR_SCRIPT}`,
`guile available: ${guileVersion.code === 0 ? 'yes' : 'no'}`, `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) { if (guileVersion.code === 0) {
const firstLine = (guileVersion.stdout ?? '').split('\n')[0]?.trim(); const firstLine = (guileVersion.stdout ?? '').split('\n')[0]?.trim();
if (firstLine) lines.push(`guile: ${firstLine}`); if (firstLine) lines.push(`guile: ${firstLine}`);
@@ -440,6 +718,21 @@ export default function pigibrackExtension(pi: ExtensionAPI) {
}, },
}); });
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({ pi.registerTool({
name: 'pigibrack_read_module', name: 'pigibrack_read_module',
label: 'pigibrack read module', label: 'pigibrack read module',