feat: add pigibrack Scheme structural editing extension
This commit is contained in:
43
.pi/extensions/pigibrack/README.md
Normal file
43
.pi/extensions/pigibrack/README.md
Normal 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.
|
||||
41
.pi/extensions/pigibrack/guile/check_syntax.scm
Normal file
41
.pi/extensions/pigibrack/guile/check_syntax.scm
Normal 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))
|
||||
71
.pi/extensions/pigibrack/guile/eval_expr.scm
Normal file
71
.pi/extensions/pigibrack/guile/eval_expr.scm
Normal 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))
|
||||
719
.pi/extensions/pigibrack/index.ts
Normal file
719
.pi/extensions/pigibrack/index.ts
Normal 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
10
playground/calc.scm
Normal 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
8
playground/math.scm
Normal file
@@ -0,0 +1,8 @@
|
||||
(define (square x)
|
||||
(* x x))
|
||||
(define (cube x)
|
||||
(* x x x))
|
||||
|
||||
(define (add a b)
|
||||
(+ a b 1))
|
||||
|
||||
Reference in New Issue
Block a user