diff --git a/.pi/extensions/pigibrack/README.md b/.pi/extensions/pigibrack/README.md index 5d75edd..7c05d55 100644 --- a/.pi/extensions/pigibrack/README.md +++ b/.pi/extensions/pigibrack/README.md @@ -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. diff --git a/.pi/extensions/pigibrack/guile/repl_sidecar.scm b/.pi/extensions/pigibrack/guile/repl_sidecar.scm new file mode 100644 index 0000000..1a020eb --- /dev/null +++ b/.pi/extensions/pigibrack/guile/repl_sidecar.scm @@ -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) diff --git a/.pi/extensions/pigibrack/index.ts b/.pi/extensions/pigibrack/index.ts index 65987c3..9382c7f 100644 --- a/.pi/extensions/pigibrack/index.ts +++ b/.pi/extensions/pigibrack/index.ts @@ -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; + 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 { } 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, @@ -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 { + 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',