feat: add persistent guile REPL sidecar for pigibrack eval
This commit is contained in:
@@ -14,9 +14,10 @@ Tools:
|
||||
- `pigibrack_check_syntax({ path } | { source })`
|
||||
- `pigibrack_eval_expr(expr, module?)`
|
||||
|
||||
Command:
|
||||
Commands:
|
||||
|
||||
- `/pigibrack-status`
|
||||
- `/pigibrack-repl-reset`
|
||||
|
||||
## How to load
|
||||
|
||||
@@ -33,11 +34,11 @@ pi -e ./.pi/extensions/pigibrack/index.ts
|
||||
## Notes
|
||||
|
||||
- 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.
|
||||
- Output is truncated to avoid context blowups.
|
||||
|
||||
## 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.
|
||||
|
||||
104
.pi/extensions/pigibrack/guile/repl_sidecar.scm
Normal file
104
.pi/extensions/pigibrack/guile/repl_sidecar.scm
Normal 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)
|
||||
@@ -8,13 +8,16 @@ import {
|
||||
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)}.`;
|
||||
|
||||
@@ -36,6 +39,21 @@ interface EvalResult {
|
||||
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 {
|
||||
return path.startsWith('@') ? path.slice(1) : path;
|
||||
}
|
||||
@@ -362,6 +380,213 @@ async function writeTempScheme(source: string): Promise<string> {
|
||||
}
|
||||
|
||||
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(
|
||||
input: { path?: string; source?: string },
|
||||
signal?: AbortSignal,
|
||||
@@ -392,7 +617,7 @@ export default function pigibrackExtension(pi: ExtensionAPI) {
|
||||
return { valid: false, errors: [message] };
|
||||
}
|
||||
|
||||
async function guileEval(
|
||||
async function guileEvalDirect(
|
||||
expr: string,
|
||||
moduleSpec: string | undefined,
|
||||
cwd: string,
|
||||
@@ -418,19 +643,72 @@ export default function pigibrackExtension(pi: ExtensionAPI) {
|
||||
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', {
|
||||
description: 'Show pigibrack extension status and guile availability',
|
||||
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 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}`);
|
||||
@@ -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({
|
||||
name: 'pigibrack_read_module',
|
||||
label: 'pigibrack read module',
|
||||
|
||||
Reference in New Issue
Block a user