char-slop/berry

multipurpose self-modifiable agentic harness

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

Charlotte SomConfigure code editing as naive, hashline, or both per conversation8744a75

main
7.4 KiB208 linesraw
1import { Buffer, isUtf8 } from "node:buffer";
2import { createHash } from "node:crypto";
3import { structuredPatch } from "diff";
4import anchorData from "./anchor-pieces.json" with { type: "json" };
5
6export const MAX_LINES = 2000;
7export const MAX_BYTES = 50 * 1024;
8
9export function hash(line: string): string {
10  const value = createHash("sha256").update(line).digest().readUInt32BE(0);
11  const prefixCount = anchorData.prefixes.length / 2;
12  const prefix = (value % prefixCount) * 2;
13  const suffix = (Math.floor(value / prefixCount) % (anchorData.suffixes.length / 2)) * 2;
14  return anchorData.prefixes.slice(prefix, prefix + 2) +
15    anchorData.suffixes.slice(suffix, suffix + 2);
16}
17
18export type TextFile = {
19  readonly raw: string;
20  readonly bom: string;
21  readonly text: string;
22  /** Lines with their line endings. */
23  readonly rows: readonly string[];
24  readonly lines: readonly string[];
25};
26
27/** The most text read or edit takes in. */
28export const MAX_TEXT_BYTES = 50 * 1024 * 1024;
29
30export function decodeText(content: Uint8Array): TextFile {
31  const bytes = Buffer.from(content.buffer, content.byteOffset, content.byteLength);
32  if (!isUtf8(bytes) || bytes.includes(0)) {
33    throw new Error("Read and edit require UTF-8 text without NUL bytes.");
34  }
35  const raw = bytes.toString("utf8");
36  const bom = raw.startsWith("\uFEFF") ? "\uFEFF" : "";
37  const text = raw.slice(bom.length);
38  const rows = text.match(/[^\n]*\n|[^\n]+$/g) ?? [];
39  return { raw, bom, text, rows, lines: rows.map((row) => row.replace(/\r?\n$/, "")) };
40}
41
42/** How many leading rows fit in `maxBytes`, newlines between them included. */
43export function fitting(rows: readonly string[], maxBytes = MAX_BYTES, separatorBytes = 1): number {
44  let bytes = -separatorBytes;
45  for (const [i, row] of rows.entries()) {
46    bytes += Buffer.byteLength(row) + separatorBytes;
47    if (bytes > maxBytes) return i;
48  }
49  return rows.length;
50}
51
52export function renderRead(
53  file: TextFile,
54  offset = 1,
55  limit = MAX_LINES,
56  hashes = true,
57): string {
58  const { lines } = file;
59  const start = offset - 1;
60  if (start > 0 && start >= lines.length) throw new Error("Offset is beyond end of file.");
61  if (!lines.length) {
62    return hashes
63      ? "[Empty file; edit with old: [] to insert.]"
64      : '[Empty file; edit with oldText: "" to insert.]';
65  }
66  const end = start + Math.min(limit, MAX_LINES);
67  const rows = hashes
68    ? lines.slice(start, end).map((line) => `${hash(line)}│${line}`)
69    : file.rows.slice(start, end);
70  const count = fitting(rows, MAX_BYTES, hashes ? 1 : 0);
71  if (count === 0) throw new Error(`Line ${start + 1} exceeds 50 KiB; use bash to inspect it.`);
72  const next = start + count;
73  const continuation = next < lines.length
74    ? `\n\n[Showing lines ${start + 1}-${next} of ${lines.length}. Use offset=${
75      next + 1
76    } to continue.]`
77    : "";
78  return rows.slice(0, count).join(hashes ? "\n" : "") + continuation;
79}
80
81export type HashlineEdit = {
82  readonly before?: readonly string[];
83  readonly old: readonly string[];
84  readonly after?: readonly string[];
85  readonly new: readonly string[];
86};
87
88/** The edited file contents and a diff; `content === file.raw` when nothing changes. */
89export function planEdit(
90  file: TextFile,
91  edits: readonly (HashlineEdit | { readonly oldText: string; readonly newText: string })[],
92  hashes = true,
93): { content: string; diff: string[] } {
94  const { bom, text, rows, lines } = file;
95  const anchors = edits.some((edit) => "old" in edit) ? lines.map(hash) : [];
96  const eol = text.match(/\r?\n/)?.[0] ?? "\n";
97  const offsets = [0];
98  for (const row of rows) offsets.push(offsets.at(-1)! + row.length);
99
100  const changes = edits.map((edit, index) => {
101    if ("oldText" in edit) {
102      if (/[\0]/.test(edit.oldText + edit.newText)) {
103        throw new Error(`Edit ${index + 1}: text must not contain NUL.`);
104      }
105      if (!edit.oldText && text) {
106        throw new Error(`Edit ${index + 1}: empty oldText is allowed only for an empty file.`);
107      }
108      const start = text.indexOf(edit.oldText);
109      if (start === -1) {
110        throw new Error(
111          `Edit ${index + 1}: oldText not found (stale reference); read the file again.`,
112        );
113      }
114      if (edit.oldText && text.indexOf(edit.oldText, start + 1) !== -1) {
115        throw new Error(`Edit ${index + 1}: ambiguous oldText; include more surrounding text.`);
116      }
117      return {
118        start,
119        end: start + edit.oldText.length,
120        replacement: edit.newText,
121        hashline: false,
122        separate: false,
123      };
124    }
125
126    const before = edit.before ?? [];
127    const sequence = [...before, ...edit.old, ...(edit.after ?? [])];
128    if (!sequence.length && lines.length) {
129      throw new Error(`Edit ${index + 1}: supply old or adjacent context hashes.`);
130    }
131    if (edit.new.some((line) => /[\r\n\0]/.test(line))) {
132      throw new Error(`Edit ${index + 1}: new must contain individual lines, without CR/LF/NUL.`);
133    }
134    let match = -1;
135    for (let i = 0; i <= anchors.length - sequence.length; i++) {
136      if (!sequence.every((anchor, j) => anchor === anchors[i + j])) continue;
137      if (match !== -1) {
138        throw new Error(`Edit ${index + 1}: ambiguous hashes; add adjacent before/after context.`);
139      }
140      match = i;
141    }
142    if (match === -1) {
143      throw new Error(
144        `Edit ${index + 1}: hashes not found (stale reference); read the file again.`,
145      );
146    }
147    const start = match + before.length;
148    const end = start + edit.old.length;
149    const replacement = edit.new.map((line, i) => {
150      const row = start + i < end && line === lines[start + i] ? rows[start + i] : line + eol;
151      return i < edit.new.length - 1 && !row.endsWith("\n") ? row + eol : row;
152    }).join("");
153    return {
154      start: offsets[start],
155      end: offsets[end],
156      replacement,
157      hashline: true,
158      separate: start === rows.length,
159    };
160  }).sort((a, b) => a.start - b.start);
161
162  for (let i = 1; i < changes.length; i++) {
163    if (changes[i].start < changes[i - 1].end || changes[i].start === changes[i - 1].start) {
164      throw new Error("Overlapping edits (or insertions at the same position); merge them.");
165    }
166  }
167
168  const updated: string[] = [];
169  let cursor = 0;
170  for (const { start, end, replacement, separate } of changes) {
171    updated.push(text.slice(cursor, start));
172    // Inserting after an unterminated last line needs a separator, not a joined line.
173    if (
174      separate && replacement && updated.findLast((part) => part.length)?.endsWith("\n") === false
175    ) {
176      updated.push(eol);
177    }
178    updated.push(replacement);
179    cursor = end;
180  }
181  updated.push(text.slice(cursor));
182  let result = updated.join("");
183  const last = changes.at(-1);
184  if (
185    last?.hashline && last.end === text.length && text && !text.endsWith("\n") &&
186    result !== eol && !result.endsWith("\n" + eol)
187  ) {
188    result = result.replace(/\r?\n$/, "");
189  }
190
191  const diff = structuredPatch("", "", text, result, "", "", { context: 3 }).hunks.flatMap((
192    hunk,
193  ) => [
194    `@@ original line ${hunk.oldStart} @@`,
195    ...hunk.lines.flatMap((row, i) => {
196      if (row.startsWith("\\")) return [];
197      const line = hunk.lines[i + 1]?.startsWith("\\")
198        ? row.slice(1)
199        : row.slice(1).replace(/\r$/, "");
200      return [
201        row.startsWith("+") || row.startsWith("-")
202          ? `${row[0]}${hashes ? `${hash(line)}│` : ""}${line}`
203          : ` ${line}`,
204      ];
205    }),
206  ]);
207  return { content: bom + result, diff };
208}