import { Type } from "@earendil-works/pi-ai"; import { createReadTool, type ExtensionAPI, keyHint, truncateHead, withFileMutationQueue, } from "@earendil-works/pi-coding-agent"; import { Text } from "@earendil-works/pi-tui"; import { isUtf8 } from "node:buffer"; import { createHash } from "node:crypto"; import { readFile, stat, writeFile } from "node:fs/promises"; import { homedir } from "node:os"; import { resolve } from "node:path"; import anchorData from "./anchor-pieces.json" with { type: "json" }; function filePath(path: string, cwd: string): string { return resolve(cwd, path === "~" ? homedir() : path.replace(/^~\//, `${homedir()}/`)); } function hash(line: string): string { const value = createHash("sha256").update(line).digest().readUInt32BE(0); const prefixCount = anchorData.prefixes.length / 2; const prefix = (value % prefixCount) * 2; const suffix = (Math.floor(value / prefixCount) % (anchorData.suffixes.length / 2)) * 2; return anchorData.prefixes.slice(prefix, prefix + 2) + anchorData.suffixes.slice(suffix, suffix + 2); } async function readText(path: string, signal?: AbortSignal) { signal?.throwIfAborted(); const info = await stat(path); if (!info.isFile()) throw new Error("Not a regular file."); if (info.size > 50 * 1024 * 1024) throw new Error("File exceeds the 50 MiB edit/read limit."); const bytes = await readFile(path, { signal }); if (!isUtf8(bytes) || bytes.includes(0)) { throw new Error("Hashline requires UTF-8 text without NUL bytes."); } const raw = bytes.toString("utf8"); const bom = raw.startsWith("\uFEFF") ? "\uFEFF" : ""; const text = raw.slice(bom.length); const rows = text.match(/[^\n]*\n|[^\n]+$/g) ?? []; return { raw, bom, text, rows, lines: rows.map((row) => row.replace(/\r?\n$/, "")) }; } const hashes = Type.Array(Type.String({ pattern: "^[A-Za-z]{4}$" })); export default function (pi: ExtensionAPI) { pi.registerTool({ name: "read", label: "read", description: "Read UTF-8 text as hash│content, using four-letter tokenizer-friendly hashes of exact line " + "content, independent of position. Duplicate lines and hash collisions share anchors; " + "use adjacent context with edit to disambiguate them. Supports images with jpg, jpeg, " + "png, gif, webp, or bmp extensions. Text output is capped at 2000 lines / 50 KiB; " + "use offset/limit to page. Text files over 50 MiB are rejected.", promptSnippet: "Read file contents with position-independent content hashes; supports images", parameters: Type.Object({ path: Type.String(), offset: Type.Optional(Type.Integer({ minimum: 1, description: "First line (1-indexed)." })), limit: Type.Optional(Type.Integer({ minimum: 1, description: "Maximum lines to return." })), }), async execute(id, args, signal, onUpdate, ctx) { const path = filePath(args.path, ctx.cwd); if (/\.(jpe?g|png|gif|webp|bmp)$/i.test(path)) { return createReadTool(ctx.cwd).execute(id, { ...args, path }, signal, onUpdate); } const { lines } = await readText(path, signal); const start = (args.offset ?? 1) - 1; if (start > 0 && start >= lines.length) throw new Error("Offset is beyond end of file."); if (!lines.length) { return { content: [{ type: "text", text: "[Empty file; edit with old: [] to insert.]" }], details: {} }; } const selected = lines.slice(start, start + Math.min(args.limit ?? 2000, 2000)); const output = truncateHead(selected.map((line) => `${hash(line)}│${line}`).join("\n")); if (output.firstLineExceedsLimit) { throw new Error(`Line ${start + 1} exceeds 50 KiB; use bash to inspect it.`); } const next = start + output.outputLines; const continuation = next < lines.length ? `\n\n[Showing lines ${start + 1}-${next} of ${lines.length}. Use offset=${next + 1} to continue.]` : ""; return { content: [{ type: "text", text: output.content + continuation }], details: { truncation: output, nextOffset: next < lines.length ? next + 1 : undefined }, }; }, }); pi.registerTool({ name: "edit", label: "edit", description: "Edit a file using content hashes from read. Each edit matches the consecutive sequence " + "before + old + after exactly once in the original file, then replaces only old with new. " + "Include every removed line's hash in old. Add adjacent before/after hashes to disambiguate " + "duplicate lines or hash collisions; context is preserved. old: [] inserts; new: [] deletes. An empty selector " + "is allowed only for an empty file. New lines are literal, without hash prefixes or embedded " + "CR/LF/NUL. All edits are validated before writing; ambiguous, stale, or overlapping targets fail. " + "Batch separate edits to the same file in one call. In results, + rows carry new hashes; - rows are removed lines.", promptSnippet: "Edit files by content hashes; supports batched replacements, insertions, and deletions", renderShell: "default", renderCall(args, theme) { return new Text( theme.fg("toolTitle", theme.bold("edit")) + " " + theme.fg("accent", args.path ?? "..."), 0, 0, ); }, renderResult(result, { expanded }, theme, context) { const lines = result.content.filter((part) => part.type === "text") .map((part) => part.text).join("\n").split("\n"); const visible = expanded ? lines : lines.slice(0, 10); let output = visible.map((line) => theme.fg( context.isError ? "error" : line.startsWith("+") ? "toolDiffAdded" : line.startsWith("-") ? "toolDiffRemoved" : "toolOutput", context.isError ? line : line.replace(/^([+-])(?:[A-Za-z]{4}|[0-9a-f]{16})│/, "$1"), )).join("\n"); if (visible.length < lines.length) { output += theme.fg("muted", `\n... (${lines.length - visible.length} more lines, ${keyHint("app.tools.expand", "to expand")})`); } return new Text(output, 0, 0); }, parameters: Type.Object({ path: Type.String(), edits: Type.Array(Type.Object({ before: Type.Optional(hashes), old: hashes, after: Type.Optional(hashes), new: Type.Array(Type.String({ pattern: "^[^\r\n\u0000]*$" })), }, { additionalProperties: false }), { minItems: 1 }), }, { additionalProperties: false }), async execute(_id, { path: inputPath, edits }, signal, _onUpdate, ctx) { const path = filePath(inputPath, ctx.cwd); return withFileMutationQueue(path, async () => { const { raw, bom, text, rows, lines } = await readText(path, signal); const anchors = lines.map(hash); const eol = text.match(/\r?\n/)?.[0] ?? "\n"; const changes = edits.map((edit, index) => { const before = edit.before ?? []; const sequence = [...before, ...edit.old, ...(edit.after ?? [])]; if (!sequence.length && lines.length) { throw new Error(`Edit ${index + 1}: supply old or adjacent context hashes.`); } if (edit.new.some((line) => /[\r\n\0]/.test(line))) { throw new Error(`Edit ${index + 1}: new must contain individual lines, without CR/LF/NUL.`); } let match = -1; for (let i = 0; i <= anchors.length - sequence.length; i++) { if (!sequence.every((anchor, j) => anchor === anchors[i + j])) continue; if (match !== -1) { throw new Error(`Edit ${index + 1}: ambiguous hashes; add adjacent before/after context.`); } match = i; } if (match === -1) { throw new Error(`Edit ${index + 1}: hashes not found (stale reference); read the file again.`); } const start = match + before.length; return { start, end: start + edit.old.length, replacement: edit.new }; }).sort((a, b) => a.start - b.start); for (let i = 1; i < changes.length; i++) { if (changes[i].start < changes[i - 1].end || changes[i].start === changes[i - 1].start) { throw new Error("Overlapping edits (or insertions at the same position); merge them."); } } const updated: string[] = []; const diff: string[] = []; let cursor = 0; for (const { start, end, replacement } of changes) { for (let i = cursor; i < start; i++) updated.push(rows[i]); for (const [i, line] of replacement.entries()) { updated.push(start + i < end && line === lines[start + i] ? rows[start + i] : line + eol); } diff.push(`@@ original line ${start + 1} @@`); for (let i = start; i < end; i++) diff.push(`-${anchors[i]}│${lines[i]}`); for (const line of replacement) diff.push(`+${hash(line)}│${line}`); cursor = end; } for (let i = cursor; i < rows.length; i++) updated.push(rows[i]); // Inserting after an unterminated last line needs a separator, not a joined line. let result = updated.map((row, i) => i < updated.length - 1 && !row.endsWith("\n") ? row + eol : row, ).join(""); if (text && !text.endsWith("\n") && updated.at(-1) !== eol) { result = result.replace(/\r?\n$/, ""); } const content = bom + result; if (content === raw) { return { content: [{ type: "text", text: "No changes made." }], details: {} }; } signal?.throwIfAborted(); // The shared queue also serializes this against Pi's built-in write tool. if (!(await readFile(path)).equals(Buffer.from(raw))) { throw new Error("File changed while preparing the edit; read it again."); } signal?.throwIfAborted(); await writeFile(path, content, "utf8"); const preview = truncateHead(diff.join("\n")); return { content: [{ type: "text", text: `Applied ${changes.length} edit(s).\n\n` + preview.content + (preview.truncated ? "\n[Diff truncated; read for more context.]" : ""), }], details: {}, }; }); }, }); }