char-slop/ai-dots

ai dotfiles

git clone https://git.t4t.associates/char-slop/ai-dots

Charlotte Somexperiment: add hashline9720b9d

main
10.0 KiB213 linesraw
1import { Type } from "@earendil-works/pi-ai";
2import {
3  createReadTool,
4  type ExtensionAPI,
5  keyHint,
6  truncateHead,
7  withFileMutationQueue,
8} from "@earendil-works/pi-coding-agent";
9import { Text } from "@earendil-works/pi-tui";
10import { isUtf8 } from "node:buffer";
11import { createHash } from "node:crypto";
12import { readFile, stat, writeFile } from "node:fs/promises";
13import { homedir } from "node:os";
14import { resolve } from "node:path";
15
16import anchorData from "./anchor-pieces.json" with { type: "json" };
17
18function filePath(path: string, cwd: string): string {
19  return resolve(cwd, path === "~" ? homedir() : path.replace(/^~\//, `${homedir()}/`));
20}
21
22function hash(line: string): string {
23  const value = createHash("sha256").update(line).digest().readUInt32BE(0);
24  const prefixCount = anchorData.prefixes.length / 2;
25  const prefix = (value % prefixCount) * 2;
26  const suffix = (Math.floor(value / prefixCount) % (anchorData.suffixes.length / 2)) * 2;
27  return anchorData.prefixes.slice(prefix, prefix + 2) + anchorData.suffixes.slice(suffix, suffix + 2);
28}
29
30async function readText(path: string, signal?: AbortSignal) {
31  signal?.throwIfAborted();
32  const info = await stat(path);
33  if (!info.isFile()) throw new Error("Not a regular file.");
34  if (info.size > 50 * 1024 * 1024) throw new Error("File exceeds the 50 MiB edit/read limit.");
35  const bytes = await readFile(path, { signal });
36  if (!isUtf8(bytes) || bytes.includes(0)) {
37    throw new Error("Hashline requires UTF-8 text without NUL bytes.");
38  }
39  const raw = bytes.toString("utf8");
40  const bom = raw.startsWith("\uFEFF") ? "\uFEFF" : "";
41  const text = raw.slice(bom.length);
42  const rows = text.match(/[^\n]*\n|[^\n]+$/g) ?? [];
43  return { raw, bom, text, rows, lines: rows.map((row) => row.replace(/\r?\n$/, "")) };
44}
45
46const hashes = Type.Array(Type.String({ pattern: "^[A-Za-z]{4}$" }));
47
48export default function (pi: ExtensionAPI) {
49  pi.registerTool({
50    name: "read",
51    label: "read",
52    description:
53      "Read UTF-8 text as hash│content, using four-letter tokenizer-friendly hashes of exact line " +
54      "content, independent of position. Duplicate lines and hash collisions share anchors; " +
55      "use adjacent context with edit to disambiguate them. Supports images with jpg, jpeg, " +
56      "png, gif, webp, or bmp extensions. Text output is capped at 2000 lines / 50 KiB; " +
57      "use offset/limit to page. Text files over 50 MiB are rejected.",
58    promptSnippet: "Read file contents with position-independent content hashes; supports images",
59    parameters: Type.Object({
60      path: Type.String(),
61      offset: Type.Optional(Type.Integer({ minimum: 1, description: "First line (1-indexed)." })),
62      limit: Type.Optional(Type.Integer({ minimum: 1, description: "Maximum lines to return." })),
63    }),
64    async execute(id, args, signal, onUpdate, ctx) {
65      const path = filePath(args.path, ctx.cwd);
66      if (/\.(jpe?g|png|gif|webp|bmp)$/i.test(path)) {
67        return createReadTool(ctx.cwd).execute(id, { ...args, path }, signal, onUpdate);
68      }
69      const { lines } = await readText(path, signal);
70      const start = (args.offset ?? 1) - 1;
71      if (start > 0 && start >= lines.length) throw new Error("Offset is beyond end of file.");
72      if (!lines.length) {
73        return { content: [{ type: "text", text: "[Empty file; edit with old: [] to insert.]" }], details: {} };
74      }
75      const selected = lines.slice(start, start + Math.min(args.limit ?? 2000, 2000));
76      const output = truncateHead(selected.map((line) => `${hash(line)}${line}`).join("\n"));
77      if (output.firstLineExceedsLimit) {
78        throw new Error(`Line ${start + 1} exceeds 50 KiB; use bash to inspect it.`);
79      }
80      const next = start + output.outputLines;
81      const continuation = next < lines.length
82        ? `\n\n[Showing lines ${start + 1}-${next} of ${lines.length}. Use offset=${next + 1} to continue.]`
83        : "";
84      return {
85        content: [{ type: "text", text: output.content + continuation }],
86        details: { truncation: output, nextOffset: next < lines.length ? next + 1 : undefined },
87      };
88    },
89  });
90
91  pi.registerTool({
92    name: "edit",
93    label: "edit",
94    description:
95      "Edit a file using content hashes from read. Each edit matches the consecutive sequence " +
96      "before + old + after exactly once in the original file, then replaces only old with new. " +
97      "Include every removed line's hash in old. Add adjacent before/after hashes to disambiguate " +
98      "duplicate lines or hash collisions; context is preserved. old: [] inserts; new: [] deletes. An empty selector " +
99      "is allowed only for an empty file. New lines are literal, without hash prefixes or embedded " +
100      "CR/LF/NUL. All edits are validated before writing; ambiguous, stale, or overlapping targets fail. " +
101      "Batch separate edits to the same file in one call. In results, + rows carry new hashes; - rows are removed lines.",
102    promptSnippet: "Edit files by content hashes; supports batched replacements, insertions, and deletions",
103    renderShell: "default",
104    renderCall(args, theme) {
105      return new Text(
106        theme.fg("toolTitle", theme.bold("edit")) + " " + theme.fg("accent", args.path ?? "..."),
107        0, 0,
108      );
109    },
110    renderResult(result, { expanded }, theme, context) {
111      const lines = result.content.filter((part) => part.type === "text")
112        .map((part) => part.text).join("\n").split("\n");
113      const visible = expanded ? lines : lines.slice(0, 10);
114      let output = visible.map((line) => theme.fg(
115        context.isError ? "error" : line.startsWith("+") ? "toolDiffAdded" :
116          line.startsWith("-") ? "toolDiffRemoved" : "toolOutput",
117        context.isError ? line : line.replace(/^([+-])(?:[A-Za-z]{4}|[0-9a-f]{16})│/, "$1"),
118      )).join("\n");
119      if (visible.length < lines.length) {
120        output += theme.fg("muted", `\n... (${lines.length - visible.length} more lines, ${keyHint("app.tools.expand", "to expand")})`);
121      }
122      return new Text(output, 0, 0);
123    },
124    parameters: Type.Object({
125      path: Type.String(),
126      edits: Type.Array(Type.Object({
127        before: Type.Optional(hashes),
128        old: hashes,
129        after: Type.Optional(hashes),
130        new: Type.Array(Type.String({ pattern: "^[^\r\n\u0000]*$" })),
131      }, { additionalProperties: false }), { minItems: 1 }),
132    }, { additionalProperties: false }),
133    async execute(_id, { path: inputPath, edits }, signal, _onUpdate, ctx) {
134      const path = filePath(inputPath, ctx.cwd);
135      return withFileMutationQueue(path, async () => {
136        const { raw, bom, text, rows, lines } = await readText(path, signal);
137        const anchors = lines.map(hash);
138        const eol = text.match(/\r?\n/)?.[0] ?? "\n";
139        const changes = edits.map((edit, index) => {
140          const before = edit.before ?? [];
141          const sequence = [...before, ...edit.old, ...(edit.after ?? [])];
142          if (!sequence.length && lines.length) {
143            throw new Error(`Edit ${index + 1}: supply old or adjacent context hashes.`);
144          }
145          if (edit.new.some((line) => /[\r\n\0]/.test(line))) {
146            throw new Error(`Edit ${index + 1}: new must contain individual lines, without CR/LF/NUL.`);
147          }
148          let match = -1;
149          for (let i = 0; i <= anchors.length - sequence.length; i++) {
150            if (!sequence.every((anchor, j) => anchor === anchors[i + j])) continue;
151            if (match !== -1) {
152              throw new Error(`Edit ${index + 1}: ambiguous hashes; add adjacent before/after context.`);
153            }
154            match = i;
155          }
156          if (match === -1) {
157            throw new Error(`Edit ${index + 1}: hashes not found (stale reference); read the file again.`);
158          }
159          const start = match + before.length;
160          return { start, end: start + edit.old.length, replacement: edit.new };
161        }).sort((a, b) => a.start - b.start);
162
163        for (let i = 1; i < changes.length; i++) {
164          if (changes[i].start < changes[i - 1].end || changes[i].start === changes[i - 1].start) {
165            throw new Error("Overlapping edits (or insertions at the same position); merge them.");
166          }
167        }
168
169        const updated: string[] = [];
170        const diff: string[] = [];
171        let cursor = 0;
172        for (const { start, end, replacement } of changes) {
173          for (let i = cursor; i < start; i++) updated.push(rows[i]);
174          for (const [i, line] of replacement.entries()) {
175            updated.push(start + i < end && line === lines[start + i] ? rows[start + i] : line + eol);
176          }
177          diff.push(`@@ original line ${start + 1} @@`);
178          for (let i = start; i < end; i++) diff.push(`-${anchors[i]}${lines[i]}`);
179          for (const line of replacement) diff.push(`+${hash(line)}${line}`);
180          cursor = end;
181        }
182        for (let i = cursor; i < rows.length; i++) updated.push(rows[i]);
183        // Inserting after an unterminated last line needs a separator, not a joined line.
184        let result = updated.map((row, i) =>
185          i < updated.length - 1 && !row.endsWith("\n") ? row + eol : row,
186        ).join("");
187        if (text && !text.endsWith("\n") && updated.at(-1) !== eol) {
188          result = result.replace(/\r?\n$/, "");
189        }
190        const content = bom + result;
191        if (content === raw) {
192          return { content: [{ type: "text", text: "No changes made." }], details: {} };
193        }
194        signal?.throwIfAborted();
195        // The shared queue also serializes this against Pi's built-in write tool.
196        if (!(await readFile(path)).equals(Buffer.from(raw))) {
197          throw new Error("File changed while preparing the edit; read it again.");
198        }
199        signal?.throwIfAborted();
200        await writeFile(path, content, "utf8");
201        const preview = truncateHead(diff.join("\n"));
202        return {
203          content: [{
204            type: "text",
205            text: `Applied ${changes.length} edit(s).\n\n` +
206              preview.content + (preview.truncated ? "\n[Diff truncated; read for more context.]" : ""),
207          }],
208          details: {},
209        };
210      });
211    },
212  });
213}