char-slop/berry

multipurpose self-modifiable agentic harness

git clone https://git.t4t.associates/char-slop/berry

Charlotte Somallow .berry/AGENTS.md to override AGENTS.mdb2cc381

main
18.5 KiB459 linesraw
1import { Buffer } from "node:buffer";
2import { extname } from "node:path";
3import type { Context } from "@earendil-works/chord";
4import { BACKGROUND_CONTEXT, withAbortSignal } from "@earendil-works/chord/context";
5import { Type } from "@earendil-works/pi-ai";
6import { type FileError, getOrThrow } from "@earendil-works/pi-durable/env";
7import ignore, { type Ignore } from "ignore";
8import { defineExtension, type Environment, type Tool } from "@berry/api";
9import {
10  decodeText,
11  fitting,
12  MAX_BYTES,
13  MAX_LINES,
14  MAX_TEXT_BYTES,
15  planEdit,
16  renderRead,
17  type TextFile,
18} from "./hashline.ts";
19import { hasWorkspace, workspace } from "./workspace.ts";
20import { hasCommand, powershellCommand } from "./commands.ts";
21
22type EditMode = "naive" | "hashline" | "both";
23type State = { editMode: EditMode };
24
25/** Only old history has these: the harness keeps the working directory now. */
26type Cd = { type: "cd"; cwd: string };
27export type Event = Cd | { type: "editMode"; editMode: EditMode };
28
29const IMAGE_TYPES: Record<string, string> = {
30  ".png": "image/png",
31  ".jpg": "image/jpeg",
32  ".jpeg": "image/jpeg",
33  ".gif": "image/gif",
34  ".webp": "image/webp",
35  ".bmp": "image/bmp",
36};
37const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
38
39class TooLarge extends Error {}
40
41/** The whole of the regular file at `path`, which must be no larger than `limit`. */
42async function readBytes(
43  env: Environment,
44  path: string,
45  limit: number,
46  context: Context,
47): Promise<Uint8Array> {
48  const reader = getOrThrow(await env.openBinaryReader(path, undefined, context));
49  try {
50    const { size } = getOrThrow(await reader.info(context));
51    if (size > limit) throw new TooLarge(`${path} is larger than ${limit} bytes`);
52    return getOrThrow(await reader.read(0, size, context));
53  } finally {
54    await reader.close(context);
55  }
56}
57
58async function readText(env: Environment, path: string, context: Context): Promise<TextFile> {
59  try {
60    return decodeText(await readBytes(env, path, MAX_TEXT_BYTES, context));
61  } catch (error) {
62    if (error instanceof TooLarge) throw new Error("File exceeds the 50 MiB edit/read limit.");
63    throw error;
64  }
65}
66
67const base64 = (bytes: Uint8Array) =>
68  Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).toString("base64");
69
70const mutations = new Map<string, Promise<unknown>>();
71
72/** Serialize changes to one file, whatever path (or symlink) names it on whichever machine. */
73async function mutating<T>(env: Environment, path: string, change: () => Promise<T>): Promise<T> {
74  const canonical = await env.canonicalPath(path, BACKGROUND_CONTEXT);
75  const key = `${env.id}\0${canonical.ok ? canonical.value : path}`;
76  const result = (mutations.get(key) ?? Promise.resolve()).then(change);
77  const settled = result.catch(() => {});
78  mutations.set(key, settled);
79  void settled.then(() => mutations.get(key) === settled && mutations.delete(key));
80  return await result;
81}
82
83/** The last `MAX_LINES` lines and `MAX_BYTES` of output, keeping at least the final line. */
84function tail(output: string): string {
85  const ending = output.endsWith("\n") ? "\n" : "";
86  const lines = output.slice(0, output.length - ending.length).split("\n").slice(-MAX_LINES);
87  const kept = Math.max(1, fitting(lines.toReversed()));
88  return lines.slice(-kept).join("\n") + ending;
89}
90
91const LIST_LIMIT = 1000;
92const OPAQUE_DIRS = new Set([".git", ".jj", "node_modules"]);
93
94function unavailable(error: FileError): boolean {
95  return ["not_found", "not_directory", "permission_denied", "invalid"].includes(error.code);
96}
97
98async function list(env: Environment, path: string, depth: number, context: Context) {
99  const found: string[] = [];
100  type Rule = { prefix: string; matcher: Ignore };
101  async function visit(
102    dir: string,
103    prefix: string,
104    level: number,
105    inherited: Rule[],
106  ): Promise<void> {
107    const entries = getOrThrow(await env.listDir(dir, context)).sort((a, b) =>
108      a.name.localeCompare(b.name)
109    );
110    let rules = inherited;
111    const gitignore = entries.find((entry) => entry.name === ".gitignore");
112    if (gitignore) {
113      const source = await env.readTextFile(gitignore.path, context);
114      if (source.ok) rules = [...rules, { prefix, matcher: ignore().add(source.value) }];
115      else if (!unavailable(source.error)) throw source.error;
116    }
117    for (const entry of entries) {
118      if (found.length >= LIST_LIMIT) return;
119      const directory = entry.kind === "directory";
120      const shown = prefix + entry.name;
121      const candidate = directory ? `${shown}/` : shown;
122      let ignored = false;
123      for (const rule of rules) {
124        const result = rule.matcher.test(candidate.slice(rule.prefix.length));
125        if (result.ignored) ignored = true;
126        else if (result.unignored) ignored = false;
127      }
128      if (ignored) continue;
129      found.push(directory ? `${shown}/` : entry.kind === "symlink" ? `${shown}@` : shown);
130      if (directory && level < depth && !OPAQUE_DIRS.has(entry.name)) {
131        await visit(entry.path, `${shown}/`, level + 1, rules);
132      }
133    }
134  }
135  await visit(getOrThrow(await env.absolutePath(path, context)), "", 1, []);
136  if (found.length >= LIST_LIMIT) {
137    found.push(`[Stopped at ${LIST_LIMIT} entries; list a subdirectory or lower depth.]`);
138  }
139  return found.join("\n");
140}
141
142/** `AGENTS.md` of the working directory and its ancestors, outermost first. */
143async function projectContext(env: Environment, context: Context) {
144  const files: { path: string; content: string }[] = [];
145  for (let dir = env.cwd;;) {
146    for (const parts of [[dir, ".berry", "AGENTS.md"], [dir, "AGENTS.md"]]) {
147      const path = getOrThrow(await env.joinPath(parts, context));
148      const content = await env.readTextFile(path, context);
149      if (content.ok) {
150        files.unshift({ path, content: content.value.trim() });
151        break;
152      }
153    }
154    const parent = getOrThrow(await env.joinPath([dir, ".."], context));
155    if (parent === dir) return files;
156    dir = parent;
157  }
158}
159
160const hashes = Type.Array(Type.String({ pattern: "^[A-Za-z]{4}$" }));
161
162const modes = Type.Union([
163  Type.Literal("naive"),
164  Type.Literal("hashline"),
165  Type.Literal("both"),
166], {
167  title: "Editing mode",
168  description:
169    "Naive uses exact text replacements; hashline uses line anchors; both offers either.",
170  default: "both",
171});
172const hashEdit = Type.Object({
173  before: Type.Optional(hashes),
174  old: hashes,
175  after: Type.Optional(hashes),
176  new: Type.Array(Type.String({ pattern: "^[^\\r\\n\\u0000]*$" })),
177}, { additionalProperties: false });
178const naiveEdit = Type.Object({
179  oldText: Type.String({ pattern: "^[^\\u0000]*$" }),
180  newText: Type.String({ pattern: "^[^\\u0000]*$" }),
181}, { additionalProperties: false });
182const editForms = { naive: naiveEdit, hashline: hashEdit, both: Type.Union([hashEdit, naiveEdit]) };
183
184export default defineExtension<State, Event>({
185  name: "code",
186  sortOrder: 10,
187  defaultEnabled: true,
188  formerly: ["coding"],
189  description: "edit files and run shell commands",
190  initial: () => ({ editMode: "both" }),
191  apply: (state, event) => event.type === "editMode" ? { editMode: event.editMode } : state,
192
193  setup(ext) {
194    ext.promptProse({
195      default: "guidelines:\n" + [
196        "when changing multiple separate locations in one file, use one edit call with multiple entries in edits[] instead of multiple edit calls",
197        "if you need to start a background process, use tmux",
198        "when a command is missing, you can get it via `nix shell` or `nix run`",
199        "when producing source code, avoid comments that explain the plain mechanics of the code, " +
200        "but preserve terse comments that explain subtle motivation. " +
201        "(e.g. '// work around a layout bug in firefox' and not '// set the style X to Y')",
202        "we will prioritise elegance, simplicity, and long-term maintainability of code. " +
203        "we have license to _work slower_ in order to do it right the first time",
204        "additionally wrt simplicity: don't outline simple functions if they're only going to be used in one place",
205        "avoid tautological tests: tests should not need to change when details of their implementation changes",
206        "$USER is likely using jj instead of git. pass --git flags to jj diff to work around the lack of terminal color",
207      ].map((g) => `- ${g}`).join("\n"),
208    });
209    ext.configuration({
210      parameters: Type.Object({ editMode: modes }),
211      values: (state) => state,
212      events: ({ editMode }, state) =>
213        editMode === state.editMode ? [] : [{ type: "editMode", editMode }],
214    });
215    ext.prompt("project", async ({ env }) => {
216      if (env === undefined) return undefined;
217      const context = await projectContext(env, BACKGROUND_CONTEXT);
218      if (context.length === 0) return undefined;
219      return "# project context\n\nproject-specific instructions and guidelines:" +
220        context.map(({ path, content }) => `\n\n## ${path}\n\n${content}`).join("");
221    });
222
223    ext.tool({
224      name: "write",
225      expose: "both",
226      description: "Create or overwrite a file, creating parent directories.",
227      parameters: Type.Object({ path: Type.String(), content: Type.String() }),
228      returns: Type.String(),
229      async execute({ path, content }, ctx) {
230        const { env } = ctx;
231        const context = withAbortSignal(ctx.signal, BACKGROUND_CONTEXT);
232        const absolute = getOrThrow(await env.absolutePath(path, context));
233        return await mutating(env, absolute, async () => {
234          getOrThrow(await env.writeFile(absolute, content, context));
235          return `Wrote ${content.split("\n").length} lines to ${absolute}.`;
236        });
237      },
238    });
239
240    ext.tool({
241      name: "list",
242      expose: "both",
243      description:
244        "List a directory (default: the working directory) as relative paths, one per line, with / after " +
245        "directories and @ after symlinks. Skips paths ignored by .gitignore. depth (default 1) descends " +
246        "except into .git, .jj, and " +
247        `node_modules. Stops at ${LIST_LIMIT} entries.`,
248      parameters: Type.Object({
249        path: Type.Optional(Type.String()),
250        depth: Type.Optional(Type.Integer({ minimum: 1, maximum: 10 })),
251      }),
252      returns: Type.String(),
253      execute: ({ path, depth }, ctx) =>
254        list(ctx.env, path ?? ".", depth ?? 1, withAbortSignal(ctx.signal, BACKGROUND_CONTEXT)),
255    });
256
257    const shellParameters = Type.Object({
258      command: Type.String(),
259      timeout: Type.Number({ exclusiveMinimum: 0, description: "Seconds." }),
260    });
261    const shell = (name: "bash" | "powershell"): Tool<State, Event, typeof shellParameters, {
262      output: string;
263      exitCode: number;
264      fullOutputPath?: string;
265    }> => ({
266      name,
267      expose: "both",
268      description:
269        `Run a ${name === "bash" ? "bash" : "PowerShell"} command in the working directory. ` +
270        (name === "powershell"
271          ? "Uses PS7 when available, otherwise PS5; no profile or interactive input. "
272          : "") +
273        `Returns the exit code and the last ${MAX_LINES} lines / ${
274          MAX_BYTES / 1024
275        } KiB of combined ` +
276        "stdout and stderr, with fullOutputPath holding all of a longer output. A nonzero exit code " +
277        "does not reject; a timeout does.",
278      parameters: shellParameters,
279      returns: Type.Object({
280        output: Type.String(),
281        exitCode: Type.Integer(),
282        fullOutputPath: Type.Optional(Type.String()),
283      }),
284      async execute({ command, timeout }, ctx) {
285        let output = "";
286        const invocation = name === "powershell"
287          ? await powershellCommand(ctx.env, command)
288          : command;
289        const result = await ctx.env.exec(invocation, {
290          timeout,
291          onOutput: (text) => {
292            ctx.output(text);
293            output += text;
294            if (output.length > 4 * MAX_BYTES) output = output.slice(-2 * MAX_BYTES);
295            // direct-call output loses escape characters in the harness's display sanitization
296            ctx.progress({ rawOutput: tail(output) });
297          },
298          spill: { afterBytes: MAX_BYTES, afterLines: MAX_LINES },
299        }, withAbortSignal(ctx.signal, BACKGROUND_CONTEXT));
300        if (!result.ok) {
301          if (result.error.code === "timeout") {
302            throw new Error(`Command timed out after ${timeout} seconds.`);
303          }
304          if (result.error.code === "aborted") throw new Error("Command aborted.");
305          throw result.error;
306        }
307        const { exitCode, spillPath } = result.value;
308        return {
309          output: tail(output),
310          exitCode,
311          ...(spillPath === undefined ? {} : { fullOutputPath: spillPath }),
312        };
313      },
314      text: ({ output, exitCode, fullOutputPath }) =>
315        [
316          output.trimEnd() || "(no output)",
317          `[exit code ${exitCode}${fullOutputPath ? `; full output: ${fullOutputPath}` : ""}]`,
318        ].join("\n\n"),
319    });
320    ext.tool(shell("bash"));
321
322    return (conv) => {
323      const { editMode } = conv.state;
324      const anchored = editMode !== "naive";
325
326      conv.tool({
327        name: "read",
328        expose: "both",
329        description: (anchored
330          ? "Read UTF-8 text as hash│content, each hash four letters of the exact line content, " +
331            "independent of position; equal lines share a hash. "
332          : "Read UTF-8 text. ") +
333          `At most ${MAX_LINES} lines / ${
334            MAX_BYTES / 1024
335          } KiB; page with offset (1-indexed line) and ` +
336          "limit (line count). Images (png, jpeg, gif, webp, bmp) resolve to an image for image().",
337        parameters: Type.Object({
338          path: Type.String(),
339          offset: Type.Optional(Type.Integer({ minimum: 1 })),
340          limit: Type.Optional(Type.Integer({ minimum: 1 })),
341        }),
342        returns: Type.Union([
343          Type.String(),
344          Type.Object({
345            type: Type.Literal("image"),
346            data: Type.String(),
347            mimeType: Type.String(),
348          }),
349        ]),
350        async execute({ path, offset, limit }, ctx) {
351          ctx.progress({ editMode });
352          const context = withAbortSignal(ctx.signal, BACKGROUND_CONTEXT);
353          const absolute = getOrThrow(await ctx.env.absolutePath(path, context));
354          const mimeType = IMAGE_TYPES[extname(absolute).toLowerCase()];
355          if (mimeType !== undefined) {
356            const bytes = await readBytes(ctx.env, absolute, MAX_IMAGE_BYTES, context).catch(
357              (error) => {
358                if (error instanceof TooLarge) {
359                  throw new Error("Image exceeds 5 MiB; scale it down with bash first.");
360                }
361                throw error;
362              },
363            );
364            return { type: "image", data: base64(bytes), mimeType };
365          }
366          return renderRead(
367            await readText(ctx.env, absolute, context),
368            offset,
369            limit,
370            anchored,
371          );
372        },
373      });
374
375      conv.tool({
376        name: "edit",
377        expose: "both",
378        description: "Edit a file. Each of edits[] is " +
379          (editMode === "both" ? "either " : "") +
380          (editMode !== "hashline"
381            ? "{ oldText, newText }, replacing literal text that must occur exactly once"
382            : "") +
383          (editMode === "both" ? ", or " : "") +
384          (anchored
385            ? "{ before?, old, after?, new } with line hashes from read, where before + old + after " +
386              "must match exactly once and only old is replaced (old: [] inserts, new: [] deletes; " +
387              "list every removed line's hash; new lines can't contain CR/LF/NUL)"
388            : "") +
389          ". Add adjacent context to disambiguate; an empty match is allowed only for an empty file. " +
390          "All edits match the original file and are validated before writing; ambiguous, stale, or " +
391          "overlapping ones fail. Batch a file's edits in one call. Returns a diff" +
392          (anchored ? " with hashes on changed rows." : "."),
393        parameters: Type.Object({
394          path: Type.String(),
395          edits: Type.Array(editForms[editMode], { minItems: 1 }),
396        }, { additionalProperties: false }),
397        returns: Type.String(),
398        async execute({ path, edits }, ctx) {
399          ctx.progress({ editMode });
400          const { env } = ctx;
401          const context = withAbortSignal(ctx.signal, BACKGROUND_CONTEXT);
402          const absolute = getOrThrow(await env.absolutePath(path, context));
403          return await mutating(env, absolute, async () => {
404            const file = await readText(env, absolute, context);
405            const { content, diff } = planEdit(file, edits, anchored);
406            if (content === file.raw) return "No changes made.";
407            ctx.signal.throwIfAborted();
408            const current = await readBytes(env, absolute, MAX_TEXT_BYTES, context);
409            if (!Buffer.from(current).equals(Buffer.from(file.raw))) {
410              throw new Error("File changed while preparing the edit; read it again.");
411            }
412            ctx.signal.throwIfAborted();
413            getOrThrow(await env.writeFile(absolute, content, context));
414            const shown = diff.slice(0, Math.min(MAX_LINES, fitting(diff)));
415            const truncated = shown.length < diff.length
416              ? "\n[Diff truncated; read for more context.]"
417              : "";
418            return `Applied ${edits.length} edit(s).\n\n${shown.join("\n")}${truncated}`;
419          });
420        },
421      });
422
423      if (conv.env?.platform === "win32") {
424        conv.tool(shell("powershell"));
425      }
426
427      conv.command({
428        name: "workspace",
429        enabled: ({ env }) => env ? hasWorkspace(env) : false,
430        description: "create and switch to a jj workspace (under $BERRY_WORKSPACES_DIR)",
431        async execute(args, ctx) {
432          if (args.trim()) throw new Error("Usage: /workspace");
433          return await ctx.cd(await workspace(ctx.env, ctx.tid, BACKGROUND_CONTEXT), {
434            inheritProject: true,
435          });
436        },
437      });
438
439      conv.prompt(async ({ env }) => {
440        const commands = env
441          ? (await Promise.all(
442            ["tmux", "nix", "jj"].map(async (name) =>
443              await hasCommand(env, name) ? name : undefined
444            ),
445          )).filter((name) => name !== undefined)
446          : [];
447        return [
448          ...(env ? [`current working directory: ${env.cwd}`] : []),
449          ...(env?.platform === "win32"
450            ? ["use powershell for commands in this Windows environment"]
451            : []),
452          "@-references to files (e.g. @path/to/file.ts) are relative to the working directory unless " +
453          "absolute: read them directly, without the @, rather than searching for them.",
454          ...(env ? [`available workflow commands: ${commands.join(", ") || "none"}`] : []),
455        ].join("\n\n");
456      });
457    };
458  },
459});