/**
* 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);
}
},
});
}