/** * Hashline Tools — Pi package for hash-anchored file reading and editing. * * Provides `read_hashed` and `hashline_edit` tools for reliable, stale-line-free * file editing. Inspired by Oh-My-OpenAgent's hash-anchored edit system. * * Workflow: * 1. Call `read_hashed` to get LINE#ID tagged content. * 2. Call `hashline_edit` with exact LINE#ID references. * 3. If hashes mismatch (file changed), get a detailed error with updated tags. * * Package-local: no dependency on ~/.pi/agent/extensions/shared/*. */ import * as fs from "node:fs"; import * as path from "node:path"; import { fileURLToPath } from "node:url"; import { StringEnum } from "@earendil-works/pi-ai"; import type { ExtensionAPI, ExtensionContext, } from "@earendil-works/pi-coding-agent"; import { Type } from "typebox"; import { applyHashlineEditsWithReport, buildConservativeRemapSuggestions, formatHashLines, HashlineMismatchError, normalizeHashlineEdits, } from "./hashline-utils.js"; import { normalizePath } from "./path-utils.js"; // ── Package metadata ─────────────────────────────────────────────────── interface PackageMetadata { name: string; version: string; packageRoot: string; sourcePath: string; } const sourcePath = fileURLToPath(import.meta.url); const packageRoot = path.resolve(path.dirname(sourcePath), ".."); let cachedPackageMetadata: PackageMetadata | null = null; function getPackageMetadata(): PackageMetadata { if (cachedPackageMetadata) return cachedPackageMetadata; let name = "pi-hashline-tools"; let version = "0.1.0"; try { const packageJson = JSON.parse( fs.readFileSync(path.join(packageRoot, "package.json"), "utf8"), ) as { name?: string; version?: string }; name = packageJson.name ?? name; version = packageJson.version ?? version; } catch { // best-effort metadata only } cachedPackageMetadata = { name, version, packageRoot, sourcePath }; return cachedPackageMetadata; } // ── read_hashed tool ─────────────────────────────────────────────────── const readHashedDescription = `Read a file and return its content with hash-anchored line identifiers. Each line is prefixed with \`LINE#HASH|content\` so that subsequent edits can reference exact lines by their content hash, preventing stale-line errors when the file changes. Blank or whitespace-only lines are shown as \`LINE|content\` without a hash and cannot be used as edit anchors. Use this tool BEFORE calling \`hashline_edit\` on a file.`; const readHashedPromptSnippet = "Use read_hashed before hashline_edit to get stable LINE#ID references for safe editing."; // ── hashline_edit tool ───────────────────────────────────────────────── const hashlineEditDescription = `Edit files using LINE#ID format for precise, safe modifications. WORKFLOW: 1. Read target file with \`read_hashed\` and copy exact LINE#ID tags. 2. Pick the smallest operation per logical mutation site. 3. Submit one edit call per file with all related operations. 4. If same file needs another call, re-read first. 5. Use anchors as "LINE#ID" only (never include trailing "|content"). - SNAPSHOT: All edits in one call reference the ORIGINAL file state. Do NOT adjust line numbers for prior edits in the same call — the system applies them bottom-up automatically. - replace removes lines pos..end (inclusive) and inserts lines in their place. Lines BEFORE pos and AFTER end are UNTOUCHED — do NOT include them in lines. If you do, they will appear twice. - lines must contain ONLY the content that belongs inside the consumed range. Content after end survives unchanged. - Tags MUST be copied exactly from read_hashed output or >>> mismatch output. NEVER guess tags. - Batch = multiple operations in edits[], NOT one big replace covering everything. Each operation targets the smallest possible change. - lines must contain plain replacement text only (no LINE#ID prefixes, no diff +/- markers). OPERATION CHOICE: replace with pos only -> replace one line at pos replace with pos+end -> replace range pos..end inclusive as a block (ranges MUST NOT overlap) append with pos anchor -> insert after that anchor prepend with pos anchor -> insert before that anchor append/prepend without anchor -> EOF/BOF insertion (also creates missing files) CONTENT FORMAT: lines can be a string (single line) or string[] (multi-line, preferred). If you pass a multi-line string, it is split by real newline characters. lines: null or lines: [] with replace -> delete those lines. FILE MODES: delete=true deletes file and requires edits=[] with no rename rename moves final content to a new path and removes old path Given this file content after read_hashed: 10#VK|function hello() { 11#XJ| console.log("hi"); 12#MB| console.log("bye"); 13#QR|} Single-line replace (change line 11): { op: "replace", pos: "11#XJ", lines: [" console.log(\\"hello\\");"] } Range replace (rewrite lines 11-12): { op: "replace", pos: "11#XJ", end: "12#MB", lines: [" return \\"hello world\\";"] } Delete a line: { op: "replace", pos: "12#MB", lines: [] } Insert after line 13: { op: "append", pos: "13#QR", lines: ["", "function added() {"] } RECOVERY (when >>> mismatch error appears): Copy the updated LINE#ID tags shown in the error output directly. Re-read only if the needed tags are missing from the error snippet.`; const hashlineEditPromptSnippet = "Use hashline_edit for surgical file changes with hash validation. Always call read_hashed first."; const hashlineEditPromptGuidelines = [ "Before editing any file with hashline_edit, you MUST call read_hashed on that file first.", "hashline_edit rejects plain line numbers. Every anchor must be in LINE#ID format copied from read_hashed output.", "If hashline_edit returns a mismatch error with >>> markers, copy the updated LINE#ID tags from the error and retry.", "For files you do not intend to edit, use the standard read tool (no hash overhead).", ]; // ── Helpers ──────────────────────────────────────────────────────────── async function readFileContent(filePath: string): Promise { const resolved = path.resolve(filePath); const data = await fs.promises.readFile(resolved, { encoding: "utf-8" }); return data; } async function writeFileContent( filePath: string, content: string, ): Promise { const resolved = path.resolve(filePath); await fs.promises.writeFile(resolved, content, { encoding: "utf-8" }); } async function fileExists(filePath: string): Promise { try { await fs.promises.access(path.resolve(filePath)); return true; } catch { return false; } } function fail(message: string): never { throw new Error(message); } function applyLineRange( content: string, offset?: number, limit?: number, ): { text: string; startLine: number } { if (offset === undefined && limit === undefined) { return { text: content, startLine: 1 }; } const lines = content.split("\n"); const start = Math.max(0, (offset ?? 1) - 1); const end = limit !== undefined ? start + limit : lines.length; return { text: lines.slice(start, end).join("\n"), startLine: start + 1 }; } function generateUnifiedDiff( before: string, after: string, filePath: string, ): string { const beforeLines = before.split("\n"); const afterLines = after.split("\n"); const diff: string[] = []; const maxLen = Math.max(beforeLines.length, afterLines.length); for (let i = 0; i < maxLen; i++) { const b = beforeLines[i]; const a = afterLines[i]; if (b !== a) { if (b !== undefined) diff.push(`- ${b}`); if (a !== undefined) diff.push(`+ ${a}`); } } if (diff.length === 0) return "(no changes)"; return [`--- ${filePath}`, `+++ ${filePath}`, ...diff].join("\n"); } // ── Extension ────────────────────────────────────────────────────────── export default function hashlineToolsExtension(pi: ExtensionAPI) { function sendVisibleMessage( content: string, details?: Record, ) { pi.sendMessage({ customType: "hashline-tools-status", content, details, display: true, }); } pi.registerCommand("hashline-status", { description: "Show hashline-tools package status", handler: async (_args, _ctx: ExtensionContext) => { const metadata = getPackageMetadata(); sendVisibleMessage( [ `${metadata.name} v${metadata.version}`, `source: ${metadata.sourcePath}`, `package root: ${metadata.packageRoot}`, ].join("\n"), metadata as unknown as Record, ); }, }); pi.registerTool({ name: "read_hashed", label: "Read Hashed", description: readHashedDescription, promptSnippet: readHashedPromptSnippet, promptGuidelines: [ "For large files (>100 lines), always use offset and limit to read only the target section plus ~20 lines of surrounding context.", "For files you do not intend to edit, use the standard read tool instead (no hash overhead).", "Blank or whitespace-only lines are shown without a hash and cannot be used as edit anchors.", ], parameters: Type.Object({ path: Type.String({ description: "Absolute or relative path to the file to read", }), offset: Type.Optional( Type.Number({ description: "1-based line number to start from", minimum: 1, }), ), limit: Type.Optional( Type.Number({ description: "Maximum number of lines to read", minimum: 1, }), ), }), // eslint-disable-next-line @typescript-eslint/no-explicit-any async execute( _toolCallId: string, params: { path: string; offset?: number; limit?: number }, _signal: AbortSignal, _onUpdate: any, ctx: ExtensionContext, ) { const filePath = path.resolve(ctx.cwd, params.path); if (!(await fileExists(filePath))) { fail(`File not found: ${filePath}`); } try { const rawContent = await readFileContent(filePath); const { text, startLine } = applyLineRange( rawContent, params.offset, params.limit, ); const hashed = formatHashLines(text, startLine); return { content: [ { type: "text", text: hashed, }, ], details: { filePath: normalizePath(filePath), startLine, totalLines: rawContent.split("\n").length, }, }; } catch (err) { fail( `Error reading file: ${err instanceof Error ? err.message : String(err)}`, ); } }, }); pi.registerTool({ name: "hashline_edit", label: "Hashline Edit", description: hashlineEditDescription, promptSnippet: hashlineEditPromptSnippet, promptGuidelines: hashlineEditPromptGuidelines, parameters: Type.Object({ filePath: Type.String({ description: "Absolute or relative path to the file to edit", }), edits: Type.Array( Type.Object({ op: StringEnum(["replace", "append", "prepend"] as const, { description: "Edit operation type", }), pos: Type.Optional( Type.String({ description: "Primary anchor in LINE#ID format (e.g. '15#AB'). Required for replace; optional for append/prepend (defaults to EOF/BOF).", }), ), end: Type.Optional( Type.String({ description: "Range end anchor in LINE#ID format. Only for replace range operations.", }), ), lines: Type.Optional( Type.Union( [Type.Array(Type.String()), Type.String(), Type.Null()], { description: "Replacement or inserted lines. string[] preferred. null or [] deletes for replace.", }, ), ), }), { description: "Array of edit operations to apply", }, ), delete: Type.Optional( Type.Boolean({ description: "Delete the file instead of editing. Requires edits=[].", }), ), rename: Type.Optional( Type.String({ description: "Rename output file path after edits", }), ), }), // @ts-expect-error Pi execute signature has stricter inference than extension code needs async execute( _toolCallId: string, params: { filePath: string; edits: Array<{ op?: "replace" | "append" | "prepend"; pos?: string; end?: string; lines?: string | string[] | null; }>; delete?: boolean; rename?: string; }, _signal: AbortSignal, _onUpdate: any, ctx: ExtensionContext, ) { const filePath = path.resolve(ctx.cwd, params.filePath); // Validate delete mode if (params.delete && params.rename) { fail("delete and rename cannot be used together"); } if (params.delete && params.edits.length > 0) { fail("delete mode requires edits to be an empty array"); } if (!params.delete && !params.rename && params.edits.length === 0) { fail( "edits parameter must be a non-empty array (or use delete: true or rename)", ); } // Handle delete if (params.delete) { if (!(await fileExists(filePath))) { fail(`File not found: ${filePath}`); } await fs.promises.unlink(filePath); return { content: [ { type: "text", text: `Successfully deleted ${filePath}`, }, ], details: { filePath: normalizePath(filePath), deleted: true, modifiedFiles: [normalizePath(filePath)], }, }; } // Read existing content (or empty for new file) const exists = await fileExists(filePath); let rawContent = ""; if (exists) { rawContent = await readFileContent(filePath); } try { const normalizedEdits = normalizeHashlineEdits(params.edits); const beforeContent = rawContent; const result = applyHashlineEditsWithReport( beforeContent, normalizedEdits, ); // Check for no-op if (result.content === beforeContent && !params.rename) { return { content: [ { type: "text", text: `No changes made to ${filePath}. The edits produced identical content.${result.noopEdits > 0 ? ` No-op edits: ${result.noopEdits}.` : ""}`, }, ], details: { filePath: normalizePath(filePath), noopEdits: result.noopEdits, diff: "(no changes)", modifiedFiles: [], }, }; } // Write content const effectivePath = params.rename ? path.resolve(ctx.cwd, params.rename) : filePath; await writeFileContent(effectivePath, result.content); // Handle rename if (params.rename && effectivePath !== filePath) { await fs.promises.unlink(filePath); return { content: [ { type: "text", text: `Moved ${filePath} to ${effectivePath}`, }, ], details: { filePath: normalizePath(effectivePath), from: normalizePath(filePath), to: normalizePath(effectivePath), diff: generateUnifiedDiff( beforeContent, result.content, normalizePath(effectivePath), ), modifiedFiles: [normalizePath(effectivePath)], }, }; } return { content: [ { type: "text", text: `Updated ${effectivePath}`, }, ], details: { filePath: normalizePath(effectivePath), diff: generateUnifiedDiff( beforeContent, result.content, normalizePath(effectivePath), ), noopEdits: result.noopEdits, modifiedFiles: [normalizePath(effectivePath)], }, }; } catch (error) { const message = error instanceof Error ? error.message : String(error); if (error instanceof HashlineMismatchError) { // Build conservative remap suggestions (unique hashes only) let suggestionText = ""; try { const freshContent = await readFileContent(filePath); const freshLines = freshContent.split("\n"); // Collect all refs from edits const allRefs: string[] = []; for (const edit of params.edits) { if (typeof edit.pos === "string") allRefs.push(edit.pos); if (typeof edit.end === "string") allRefs.push(edit.end); } const { suggestions, ambiguous } = buildConservativeRemapSuggestions(allRefs, freshLines); const parts: string[] = []; // 1. Same-line content changes (from error.remaps) — safe only if not ambiguous for (const [oldRef, newRef] of error.remaps) { if (!ambiguous.has(oldRef)) { parts.push(` ${oldRef} → ${newRef}`); } } // 2. Unique hash-location matches (content moved to a new line) for (const [oldRef, newRef] of suggestions) { if (!error.remaps.has(oldRef)) { parts.push(` ${oldRef} → ${newRef}`); } } // 3. Ambiguous refs — cannot safely remap if (ambiguous.size > 0) { parts.push(""); parts.push( ` \u26a0 Ambiguous (hash on multiple lines): ${[...ambiguous].join(", ")}`, ); parts.push( ` \u2192 Re-run read_hashed to get updated tags for these lines.`, ); } if (parts.length > 0) { suggestionText = `\n\n${parts.join("\n")}`; } } catch { // Ignore suggestion build failures } fail( `hash mismatch\n${message}${suggestionText}\n\nTip: Re-run read_hashed on this file to get updated LINE#ID tags, or copy the >>> marked tags above.`, ); } fail(message); } }, }); }