char-slop/ai-dots

ai dotfiles

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

Charlotte Somexperiment: code visualization tool call1567399

main
4.8 KiB101 linesraw
1import { spawn } from "node:child_process";
2import { mkdtemp, readFile, rm } from "node:fs/promises";
3import { tmpdir } from "node:os";
4import { join } from "node:path";
5import { fileURLToPath } from "node:url";
6import { Type } from "@earendil-works/pi-ai";
7import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
8import { filePath, hash, readText } from "./hashline.ts";
9
10const binary = fileURLToPath(new URL("../../libexec/visualize-code", import.meta.url));
11const sequence = Type.Array(Type.String({ pattern: "^[A-Za-z]{4}$" }), { minItems: 1 });
12
13function matches(anchors: string[], sequence: string[]): number[] {
14  return anchors.flatMap((_, i) => (sequence.every((anchor, j) => anchor === anchors[i + j]) ? [i] : []));
15}
16
17export default function (pi: ExtensionAPI) {
18  pi.registerTool({
19    name: "visualize_code",
20    label: "visualize_code",
21    description:
22      "Render a range of a UTF-8 text file as a syntax-highlighted image, with each line's hash " +
23      "in the gutter (the same hashes read shows and edit accepts) and ↪ marking soft-wrapped " +
24      "continuations. start is a consecutive hash sequence that must match exactly once; the range " +
25      "begins at its first line. end is a consecutive hash sequence; the range ends at the last line " +
26      "of its first match at or after start. Without start, the range begins at the top of the file; " +
27      "without end, it continues to the end of the file. Ranges that don't fit in one image are cut " +
28      "off at a line boundary, with a start to continue from.",
29    promptSnippet: "Render file ranges, selected by content hashes, as syntax-highlighted images",
30    parameters: Type.Object({
31      path: Type.String(),
32      start: Type.Optional(sequence),
33      end: Type.Optional(sequence),
34    }, { additionalProperties: false }),
35    async execute(_id, args, signal, _onUpdate, ctx) {
36      const path = filePath(args.path, ctx.cwd);
37      const { text, lines } = await readText(path, signal);
38      if (!lines.length) throw new Error("File is empty.");
39      const anchors = lines.map(hash);
40
41      const starts = args.start ? matches(anchors, args.start) : [0];
42      if (starts.length !== 1) {
43        throw new Error(starts.length
44          ? "start is ambiguous; add adjacent hashes."
45          : "start hashes not found (stale reference); read the file again.");
46      }
47      const start = starts[0];
48      let end = lines.length;
49      if (args.end) {
50        const match = matches(anchors, args.end).find((i) => i >= start);
51        if (match === undefined) {
52          throw new Error("end hashes not found at or after start (stale reference?); read the file again.");
53        }
54        end = match + args.end.length;
55      }
56      // Bounds argv; still far more lines than fit in one image.
57      const labels = anchors.slice(start, Math.min(end, start + 2000));
58
59      const dir = await mkdtemp(join(tmpdir(), "visualize-code-"));
60      try {
61        const image = join(dir, "code.png");
62        const shown = await new Promise<number>((resolve, reject) => {
63          const child = spawn(binary, [image, path, String(start + 1), ...labels], { signal });
64          let stdout = "";
65          let stderr = "";
66          child.stdout.on("data", (chunk) => (stdout += chunk));
67          child.stderr.on("data", (chunk) => (stderr += chunk));
68          child.on("error", (error: NodeJS.ErrnoException) => reject(error.code === "ENOENT"
69            ? new Error(`${binary} is missing; build src/visualize-code and copy the binary there.`)
70            : error));
71          child.on("close", (code) => code === 0
72            ? resolve(Number(stdout))
73            : reject(new Error(stderr.trim() || `visualize-code exited with code ${code}`)));
74          // Failures surface through the exit status, so an early exit shouldn't crash us with EPIPE.
75          child.stdin.on("error", () => {});
76          child.stdin.end(text);
77        });
78
79        const next = start + shown;
80        let summary = `Lines ${start + 1}-${next} of ${lines.length}.`;
81        if (next < end) {
82          const continuation = [1, 2, 3, 4, 5, 6, 7, 8]
83            .map((length) => anchors.slice(next, next + length))
84            .find((candidate) => matches(anchors, candidate).length === 1);
85          summary += continuation
86            ? ` Continue with start: ${JSON.stringify(continuation)}${args.end ? " and the same end" : ""}.`
87            : ` No short hash sequence uniquely identifies line ${next + 1}; use read with offset=${next + 1} to continue.`;
88        }
89        return {
90          content: [
91            { type: "text", text: summary },
92            { type: "image", data: (await readFile(image)).toString("base64"), mimeType: "image/png" },
93          ],
94          details: {},
95        };
96      } finally {
97        await rm(dir, { recursive: true, force: true });
98      }
99    },
100  });
101}