/** * @jackice/hashline — pi 扩展入口 * * 将 hashline(行锚定补丁语言)接入 pi coding agent: * 1. `tool_result` 拦截原生 `read`:记录文件内容快照,输出改写为 `[PATH#TAG]` + 全局行号 `N:TEXT`, * 让模型拿到行号锚点与内容哈希标签; * 2. 注册 `hashline` 工具:按行号 + `#TAG` 应用 `PUT`/`CUT`/`REM`/`MV` 补丁, * 陈旧锚点(tag 不匹配)拒绝或 3-way 恢复,多 hunk 原子预检后落地。 * * 原生 `edit` 工具保留作后备;本扩展只新增能力,不修改 pi 源码。 */ import * as path from "node:path"; import * as fs from "node:fs/promises"; import type { ExtensionAPI, ToolResultEvent } from "@earendil-works/pi-coding-agent"; import { isReadToolResult } from "@earendil-works/pi-coding-agent"; import { Type } from "typebox"; import { InMemorySnapshotStore, MismatchError as HashlineMismatchError, NodeFilesystem, Patch, Patcher, normalizeToLF, stripBom, type PatchSectionResult, type WriteResult, } from "./src/index.ts"; /** 超过该字节数的文件不发 tag(对齐上游快照上限,避免整文件哈希驻留内存)。 */ const SNAPSHOT_MAX_BYTES = 4 * 1024 * 1024; /** 会话级快照存储:`read` 记录、`hashline` 校验/恢复,随扩展进程生命周期。 */ const snapshots = new InMemorySnapshotStore(); /** read 输出末尾的截断/继续提示白名单(`\n\n[` 分隔的整行提示)。 */ const READ_TRAILER_RE = /^\s*\[(Showing lines|Truncated|\d+ more lines|remaining|Line \d)/; /** 展示行号区段渲染的上下文行数。 */ const ANCHOR_CONTEXT_LINES = 4; /** * 把补丁内相对路径解析到会话 cwd 的 Filesystem 适配。 * * hashline 的 section path 与 read 拦截记录的快照 key 必须一致: * read 时 key = resolve(ctx.cwd, rawPath),这里同样以 ctx.cwd 为基准, * 保证编辑校验命中同一份快照(进程 cwd 与会话 cwd 可能不同)。 */ class CwdScopedFilesystem extends NodeFilesystem { readonly #base: string; constructor(base: string) { super(); this.#base = base; } #abs(p: string): string { return path.isAbsolute(p) ? p : path.resolve(this.#base, p); } override readText(p: string): Promise { return super.readText(this.#abs(p)); } override readBinary(p: string): Promise { return super.readBinary(this.#abs(p)); } override writeText(p: string, content: string): Promise { return super.writeText(this.#abs(p), content); } override delete(p: string): Promise { return super.delete(this.#abs(p)); } override move(from: string, to: string, content?: string): Promise { return super.move(this.#abs(from), this.#abs(to), content); } override exists(p: string): Promise { return super.exists(this.#abs(p)); } } function resolveAbsolutePath(cwd: string, p: string): string { return path.isAbsolute(p) ? p : path.resolve(cwd, p); } /** 剥离 read 输出末尾的空行与截断/继续提示行,返回正文部分。 */ function stripReadTrailer(text: string): string { const lines = text.split("\n"); let end = lines.length; while (end > 0) { const line = lines[end - 1]; if (line.trim() === "") { end--; continue; } if (READ_TRAILER_RE.test(line.trim())) { end--; continue; } break; } return lines.slice(0, end).join("\n"); } /** 提取 read 输出末尾的提示文本(含分隔空行),无则返回空串。 */ function extractReadTrailer(text: string): string { const body = stripReadTrailer(text); const start = body.length; if (start >= text.length) return ""; return text.slice(start).replace(/^\n+/, ""); } /** * 计算 read 本次实际展示的全局行范围(1-indexed 闭区间)。 * * 优先从 `[Showing lines A-B of N]` 尾注取 B;否则 user 指定了 `limit` 且未截断时 * 用 `offset + limit - 1`;最后退化为「剥尾注后的文本行数」。 */ function computeShownRange( shownText: string, input: ToolResultEvent["input"], totalLines: number, ): { start: number; end: number } { const offset = typeof input.offset === "number" && input.offset >= 1 ? Math.floor(input.offset) : 1; const trailerMatch = shownText.match(/\n\n\[Showing lines (\d+)-(\d+) of \d/); let end: number; if (trailerMatch) { end = Number(trailerMatch[2]); } else if (typeof input.limit === "number" && input.limit >= 1) { end = offset + Math.floor(input.limit) - 1; } else { const body = stripReadTrailer(shownText); const bodyLines = body.length === 0 ? [] : body.split("\n"); end = offset + bodyLines.length - 1; } const start = Math.min(offset, totalLines + 1); return { start, end: Math.max(start - 1, Math.min(end, totalLines)) }; } /** 用 hashline 快照记录展示范围,返回 4-hex tag;超限文件返回 undefined。 */ function recordSnapshot(absolutePath: string, normalized: string, start: number, end: number): string | undefined { if (Buffer.byteLength(normalized, "utf-8") > SNAPSHOT_MAX_BYTES) return undefined; const seen: number[] = []; for (let n = start; n <= end; n++) seen.push(n); return snapshots.record(absolutePath, normalized, seen); } /** 构造 read 改写的 hashline 展示文本。 */ function formatReadOutput(displayPath: string, tag: string | undefined, lines: string[], start: number, end: number, trailer: string): string { const out: string[] = []; if (tag) out.push(`[${displayPath}#${tag}]`); for (let n = start; n <= end; n++) { const line = lines[n - 1]; if (line === undefined) break; out.push(`${n}:${line}`); } if (trailer) out.push("", trailer); if (tag) { out.push( "", "行号来自本次 read;未展示的行不可被补丁引用(如需编辑请先 read 对应范围)。", "编辑后行号与 #TAG 都会变化,请以编辑结果或重新 read 为准。", ); } return out.join("\n"); } /** 行级 diff 摘要(前缀/后缀剥离),供工具结果展示。 */ function summarizeDiff(before: string, after: string): string { const a = before.split("\n"); const b = after.split("\n"); let start = 0; while (start < a.length && start < b.length && a[start] === b[start]) start++; let endA = a.length; let endB = b.length; while (endA > start && endB > start && a[endA - 1] === b[endB - 1]) { endA--; endB--; } const out: string[] = []; const MAX = 12; const removed = a.slice(start, endA); const added = b.slice(start, endB); if (removed.length > MAX) { out.push(`- (${removed.length - 2 * Math.ceil(MAX / 3)} 行省略)`); out.push(...removed.slice(0, Math.ceil(MAX / 3)).map((l) => `- ${l}`)); out.push("..."); out.push(...removed.slice(-Math.ceil(MAX / 3)).map((l) => `- ${l}`)); } else { out.push(...removed.map((l) => `- ${l}`)); } out.push(...added.map((l) => `+ ${l}`)); return out.join("\n"); } /** 描述一个已应用区段:新 header、操作类型、diff 摘要、新文件锚点行号。 */ function describeSection(section: PatchSectionResult): string { const parts: string[] = []; const opLabel = section.op === "delete" ? "已删除" : section.op === "create" ? "已创建" : section.op === "noop" ? "无变化" : "已更新"; parts.push(`${section.header} ${opLabel}`); const beforeLines = section.before.length === 0 ? [] : section.before.split("\n"); const afterLines = section.after.length === 0 ? [] : section.after.split("\n"); parts.push(`变更:${beforeLines.length} 行 → ${afterLines.length} 行`); const diff = summarizeDiff(section.before, section.after); if (diff) parts.push("```diff\n" + diff + "\n```"); if (section.firstChangedLine !== undefined && section.op !== "delete") { const from = Math.max(1, section.firstChangedLine - ANCHOR_CONTEXT_LINES); const to = Math.min(afterLines.length, section.firstChangedLine + ANCHOR_CONTEXT_LINES); const anchor = afterLines.slice(from - 1, to); parts.push(`新文件锚点(${from}-${to} 行):`); anchor.forEach((line, i) => parts.push(`${from + i}:${line}`)); } if (section.warnings.length > 0) parts.push(`警告:${section.warnings.join(";")}`); return parts.join("\n"); } /** hashline 工具的参数与描述。 */ const hashlineParams = Type.Object({ input: Type.String({ description: "hashline 补丁文本。每个文件区块以 [PATH#TAG] 开头(TAG 来自最新 read 输出的内容哈希)," + "内部为 PUT/CUT/REM/MV 操作,`+TEXT` 行承载新内容。", }), }); export default function (pi: ExtensionAPI) { // --------------------------------------------------------------------- // 1. read 输出改写:行号 + [PATH#TAG] // --------------------------------------------------------------------- pi.on("tool_result", async (event, ctx) => { if (!isReadToolResult(event) || event.isError) return; if (event.content.some((c) => c.type === "image")) return; const textBlock = event.content.find((c) => c.type === "text"); if (!textBlock) return; const rawPath = typeof event.input.path === "string" ? event.input.path : undefined; if (!rawPath) return; const absolutePath = resolveAbsolutePath(ctx.cwd, rawPath); let fileText: string; try { fileText = await fs.readFile(absolutePath, "utf-8"); } catch { return; // 文件不可读(已删除/权限),保持原始输出 } const { text: noBom } = stripBom(fileText); const normalized = normalizeToLF(noBom); const lines = normalized.split("\n"); const { start, end } = computeShownRange(textBlock.text, event.input, lines.length); const tag = recordSnapshot(absolutePath, normalized, start, end); const displayPath = rawPath; const trailer = extractReadTrailer(textBlock.text); return { content: [{ type: "text", text: formatReadOutput(displayPath, tag, lines, start, end, trailer) }], }; }); // --------------------------------------------------------------------- // 2. hashline 工具 // --------------------------------------------------------------------- pi.registerTool({ name: "hashline", label: "Hashline", description: "行锚定补丁编辑工具。以 [PATH#TAG] + 行号引用原文件内容,替换/插入/剪切/移动代码。" + "语法要点:PUT N.=M: 替换 N 到 M 行(含端点),PUT N: 在 N 行前/后插入," + "PUT N*: 替换从 N 行开始的语法块,CUT N.=M 剪切(可 @name 命名寄存器)," + "REM 删文件,MV DEST 移动。`+TEXT` 行是最终内容,`+` 单独一行是空行。" + "行号与 TAG 必须来自最近一次 read 的输出(N:TEXT 行与 [PATH#TAG] 头)," + "未展示的行不可引用;一次补丁可跨多文件,全部校验通过才会写盘。" + "编辑成功后行号会重排、TAG 会变化,继续编辑前请以本次结果或重新 read 为准。", promptSnippet: "Apply line-anchored patches to files (PUT/CUT/REM/MV) with content-hash drift protection", promptGuidelines: [ "Use hashline to edit files when you have fresh read output: anchor hunks with the line numbers from the latest read (N:TEXT rows) and include the [PATH#TAG] header from that read.", "Prefer hashline over edit for token efficiency (no oldText retyping) and drift protection (stale tags are rejected); fall back to edit or write when you cannot anchor by line numbers.", ], parameters: hashlineParams, async execute(_toolCallId, params, _signal, _onUpdate, ctx) { try { const patch = Patch.parse(params.input); const patcher = new Patcher({ fs: new CwdScopedFilesystem(ctx.cwd), snapshots }); const result = await patcher.apply(patch); const chunks = result.sections.map((section) => describeSection(section)); return { content: [{ type: "text", text: chunks.join("\n\n") }], details: {} }; } catch (err) { if (err instanceof HashlineMismatchError) { return { content: [{ type: "text", text: err.displayMessage }], isError: true, details: {}, }; } const message = err instanceof Error ? err.message : String(err); return { content: [{ type: "text", text: `hashline 应用失败:${message}` }], isError: true, details: {}, }; } }, }); }