feat: add pigibrack Scheme structural editing extension

This commit is contained in:
2026-04-04 11:00:37 +02:00
parent 2b79dd0eef
commit a301d20c92
6 changed files with 892 additions and 0 deletions

View File

@@ -0,0 +1,43 @@
# pigibrack
**pi guile bracket extension** for structural Scheme/Guile editing.
## What it provides
Tools:
- `pigibrack_read_module(path)`
- `pigibrack_read_form(path, name)`
- `pigibrack_replace_form(path, name, newSource)`
- `pigibrack_insert_form(path, anchorName, position, newSource)`
- `pigibrack_delete_form(path, name)`
- `pigibrack_check_syntax({ path } | { source })`
- `pigibrack_eval_expr(expr, module?)`
Command:
- `/pigibrack-status`
## How to load
This extension is project-local at:
- `.pi/extensions/pigibrack/index.ts`
Pi auto-discovers this path. You can also start explicitly:
```bash
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/`.
- 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.

View File

@@ -0,0 +1,41 @@
(use-modules (ice-9 textual-ports))
(define (->string obj)
(call-with-output-string
(lambda (port)
(write obj port))))
(define (read-all path)
(call-with-input-file path get-string-all))
(define (read-all-datums source)
(let ((port (open-input-string source)))
(let loop ()
(let ((datum (read port)))
(unless (eof-object? datum)
(loop))))))
(define (main argv)
(if (< (length argv) 2)
(begin
(display "usage: guile check_syntax.scm <file>\n")
(primitive-exit 2))
(let* ((path (list-ref argv 1))
(ok?
(catch #t
(lambda ()
(read-all-datums (read-all path))
#t)
(lambda (key . args)
(display (symbol->string key))
(display " ")
(display (->string args))
(newline)
#f))))
(if ok?
(begin
(display "OK\n")
(primitive-exit 0))
(primitive-exit 1)))))
(main (command-line))

View File

@@ -0,0 +1,71 @@
(use-modules (ice-9 textual-ports)
(ice-9 pretty-print))
(define stdout-start "__PIGI_STDOUT_START__")
(define stdout-end "__PIGI_STDOUT_END__")
(define result-start "__PIGI_RESULT_START__")
(define result-end "__PIGI_RESULT_END__")
(define (->string obj)
(call-with-output-string
(lambda (port)
(write obj port))))
(define (read-all path)
(call-with-input-file path get-string-all))
(define (parse-module-spec spec)
(call-with-input-string spec read))
(define (eval-expression expr-source module-spec)
(when module-spec
(set-current-module (resolve-interface module-spec)))
(let* ((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)
(values stdout (get-output-string value-port))))
(define (main argv)
(if (< (length argv) 2)
(begin
(display "usage: guile eval_expr.scm <expr-file> [module-spec]\n")
(primitive-exit 2))
(let* ((expr-path (list-ref argv 1))
(module-spec (if (> (length argv) 2)
(parse-module-spec (list-ref argv 2))
#f))
(expr-source (read-all expr-path))
(ok?
(catch #t
(lambda ()
(call-with-values
(lambda ()
(eval-expression expr-source module-spec))
(lambda (stdout value)
(display stdout-start)
(newline)
(display stdout)
(display stdout-end)
(newline)
(display result-start)
(newline)
(display value)
(display result-end)
(newline)))
#t)
(lambda (key . args)
(display (symbol->string key))
(display " ")
(display (->string args))
(newline)
#f))))
(if ok?
(primitive-exit 0)
(primitive-exit 1)))))
(main (command-line))

View File

@@ -0,0 +1,719 @@
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 { existsSync } from 'node:fs';
import { mkdtemp, readFile, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
const CHECK_SYNTAX_SCRIPT = resolve(__dirname, 'guile/check_syntax.scm');
const EVAL_EXPR_SCRIPT = resolve(__dirname, 'guile/eval_expr.scm');
const TOOL_DESCRIPTION_SUFFIX = `Tool output is truncated to ${DEFAULT_MAX_LINES} lines or ${formatSize(DEFAULT_MAX_BYTES)}.`;
interface FormSpan {
start: number;
end: number;
line: number;
text: string;
name?: string;
}
interface SyntaxCheckResult {
valid: boolean;
errors: string[];
}
interface EvalResult {
stdout: string;
value: string;
}
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),
});
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+\(([\w!$%&*+\-./:<=>?@^~]+(?:[\w!$%&*+\-./:<=>?@^~]|#[^\s()\[\]]*)?)\b/,
/^\(\s*(?:define|define-public|define\*)\s+([^\s()\[\]]+)\b/,
/^\(\s*(?:define-syntax|define-syntax-rule|define-macro)\s+\(([^\s()\[\]]+)\b/,
/^\(\s*(?:define-syntax|define-syntax-rule|define-macro)\s+([^\s()\[\]]+)\b/,
/^\(\s*define-record-type\s+([^\s()\[\]]+)\b/,
/^\(\s*define-class\s+([^\s()\[\]]+)\b/,
];
for (const pattern of patterns) {
const match = trimmed.match(pattern);
if (match?.[1]) return match[1];
}
return undefined;
}
function oneLine(text: string): string {
return text.replace(/\s+/g, ' ').trim();
}
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)} ...`;
if (output.length > 160) {
output = `${output.slice(0, 157)}...`;
}
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 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 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<string> {
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) {
async function guileSyntaxCheck(
input: { path?: string; source?: string },
signal?: AbortSignal,
): Promise<SyntaxCheckResult> {
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 guileEval(
expr: string,
moduleSpec: string | undefined,
cwd: string,
signal?: AbortSignal,
): Promise<EvalResult> {
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 ?? '');
}
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 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}`,
`guile available: ${guileVersion.code === 0 ? 'yes' : 'no'}`,
];
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.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 name = form.name ? ` name=${form.name}` : '';
return `${String(index + 1).padStart(3, ' ')}. line ${form.line}${name} :: ${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.length,
},
};
},
});
pi.registerTool({
name: 'pigibrack_read_form',
label: 'pigibrack read form',
description: `Read one top-level Scheme form by defined name. ${TOOL_DESCRIPTION_SUFFIX}`,
promptSnippet: 'Read an individual Scheme top-level form by name.',
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.String({ description: 'Top-level form name (e.g. my-function)' }),
}),
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 = uniqueByName(forms, params.name);
return {
content: [{ type: 'text', text: formatTruncated(form.text, 'head') }],
details: {
path: params.path,
absolutePath,
name: params.name,
line: form.line,
},
};
},
});
pi.registerTool({
name: 'pigibrack_replace_form',
label: 'pigibrack replace form',
description: 'Replace a top-level Scheme form by name, with syntax pre-check via guile reader.',
promptSnippet: 'Replace an entire top-level Scheme form by name.',
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.String({ description: 'Top-level form name to replace' }),
newSource: Type.String({ description: 'The complete 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 form = uniqueByName(forms, params.name);
const updated = `${source.slice(0, form.start)}${normalizedSource}${source.slice(form.end)}`;
await writeFile(absolutePath, updated, 'utf8');
return {
content: [
{
type: 'text',
text: `Replaced form "${params.name}" at line ${form.line} in ${params.path}.`,
},
],
details: {
path: params.path,
absolutePath,
name: params.name,
line: form.line,
},
};
});
},
});
pi.registerTool({
name: 'pigibrack_insert_form',
label: 'pigibrack insert form',
description: 'Insert a new top-level Scheme form before or after an anchor form by name.',
promptSnippet: 'Insert a new top-level Scheme form relative to an existing named form.',
parameters: Type.Object({
path: Type.String({ description: 'Path to a Scheme source file' }),
anchorName: Type.String({ description: 'Anchor form name for insertion' }),
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 anchor = uniqueByName(forms, params.anchorName);
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} "${params.anchorName}" (line ${anchor.line}) in ${params.path}.`,
},
],
details: {
path: params.path,
absolutePath,
anchorName: params.anchorName,
position: params.position,
line: anchor.line,
},
};
});
},
});
pi.registerTool({
name: 'pigibrack_delete_form',
label: 'pigibrack delete form',
description: 'Delete a top-level Scheme form by name.',
promptSnippet: 'Delete a top-level Scheme form by its name.',
parameters: Type.Object({
path: Type.String({ description: 'Path to a Scheme source file' }),
name: Type.String({ description: 'Top-level form name to delete' }),
}),
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 form = uniqueByName(forms, params.name);
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 "${params.name}" from ${params.path}.` }],
details: {
path: params.path,
absolutePath,
name: params.name,
line: form.line,
},
};
});
},
});
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_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 || '<no value>'}`);
return {
content: [{ type: 'text', text: formatTruncated(outputParts.join('\n\n'), 'tail') }],
details: {
module: params.module,
hasStdout: result.stdout.trim().length > 0,
},
};
},
});
}

10
playground/calc.scm Normal file
View File

@@ -0,0 +1,10 @@
(define-module (playground calc)
#:export (triple twice greeting))
(define (triple x)
(* 3 x))
(define (twice x)
(* 2 x))
(define greeting "hello from module")

8
playground/math.scm Normal file
View File

@@ -0,0 +1,8 @@
(define (square x)
(* x x))
(define (cube x)
(* x x x))
(define (add a b)
(+ a b 1))