diff --git a/.pi/extensions/pigibrack/README.md b/.pi/extensions/pigibrack/README.md new file mode 100644 index 0000000..5d75edd --- /dev/null +++ b/.pi/extensions/pigibrack/README.md @@ -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. diff --git a/.pi/extensions/pigibrack/guile/check_syntax.scm b/.pi/extensions/pigibrack/guile/check_syntax.scm new file mode 100644 index 0000000..64bd159 --- /dev/null +++ b/.pi/extensions/pigibrack/guile/check_syntax.scm @@ -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 \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)) diff --git a/.pi/extensions/pigibrack/guile/eval_expr.scm b/.pi/extensions/pigibrack/guile/eval_expr.scm new file mode 100644 index 0000000..a440dca --- /dev/null +++ b/.pi/extensions/pigibrack/guile/eval_expr.scm @@ -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 [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)) diff --git a/.pi/extensions/pigibrack/index.ts b/.pi/extensions/pigibrack/index.ts new file mode 100644 index 0000000..65987c3 --- /dev/null +++ b/.pi/extensions/pigibrack/index.ts @@ -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 { + 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 { + 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 { + 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 || ''}`); + + return { + content: [{ type: 'text', text: formatTruncated(outputParts.join('\n\n'), 'tail') }], + details: { + module: params.module, + hasStdout: result.stdout.trim().length > 0, + }, + }; + }, + }); +} diff --git a/playground/calc.scm b/playground/calc.scm new file mode 100644 index 0000000..d7ad42e --- /dev/null +++ b/playground/calc.scm @@ -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") diff --git a/playground/math.scm b/playground/math.scm new file mode 100644 index 0000000..09c694a --- /dev/null +++ b/playground/math.scm @@ -0,0 +1,8 @@ +(define (square x) + (* x x)) +(define (cube x) + (* x x x)) + +(define (add a b) + (+ a b 1)) +