src / workspace / text.ts
src / workspace / text.ts
import { readFile, stat } from "node:fs/promises";
import { AgenticError } from "../core/errors";
import { sha256Text } from "../core/hash";
export interface TextFileSnapshot {
content: string;
bytes: number;
sha256: string;
lineCount: number;
newline: "lf" | "crlf" | "mixed" | "none";
hasFinalNewline: boolean;
}
export function appearsBinary(buffer: Buffer): boolean {
const sample = buffer.subarray(0, Math.min(buffer.length, 8192));
if (sample.includes(0)) return true;
let suspicious = 0;
for (const byte of sample) {
if (byte < 7 || (byte > 13 && byte < 32)) suspicious++;
}
return sample.length > 0 && suspicious / sample.length > 0.12;
}
/**
* String.split(/\r?\n/) produces a trailing "" element whenever content ends
* in a newline (e.g. "a\nb\n" -> ["a","b",""]), which would otherwise inflate
* the reported line count by one. Drop that phantom trailing empty element so
* "a\nb\n" and "a\nb" both count as 2 lines; "" still counts as 0 (handled by
* callers before this function is reached).
*/
export function countLines(lines: string[]): number {
if (lines.length > 1 && lines[lines.length - 1] === "") {
return lines.length - 1;
}
return lines.length;
}
export function detectNewline(content: string): TextFileSnapshot["newline"] {
const crlf = (content.match(/\r\n/g) ?? []).length;
const lf = (content.match(/(?<!\r)\n/g) ?? []).length;
if (crlf === 0 && lf === 0) return "none";
if (crlf > 0 && lf > 0) return "mixed";
return crlf > 0 ? "crlf" : "lf";
}
export async function readTextSnapshot(
path: string,
maxBytes: number,
): Promise<TextFileSnapshot> {
const info = await stat(path);
if (!info.isFile()) {
throw new AgenticError("INVALID_INPUT", "Expected a regular file.", { path });
}
if (info.size > maxBytes) {
throw new AgenticError(
"FILE_TOO_LARGE",
`File is ${info.size.toLocaleString()} bytes; limit is ${maxBytes.toLocaleString()} bytes.`,
{ path, bytes: info.size, maxBytes },
);
}
const buffer = await readFile(path);
if (buffer.length > maxBytes) {
throw new AgenticError(
"FILE_TOO_LARGE",
`File grew to ${buffer.length.toLocaleString()} bytes while being read; limit is ${maxBytes.toLocaleString()} bytes.`,
{ path, bytes: buffer.length, maxBytes },
);
}
if (appearsBinary(buffer)) {
throw new AgenticError("BINARY_FILE", "Binary files are not editable as text.", {
path,
});
}
const content = buffer.toString("utf8");
if (!Buffer.from(content, "utf8").equals(buffer)) {
throw new AgenticError(
"BINARY_FILE",
"File is not valid UTF-8 text and cannot be edited safely.",
{ path },
);
}
return snapshotFromContent(content);
}
export function snapshotFromContent(content: string): TextFileSnapshot {
return {
content,
bytes: Buffer.byteLength(content),
sha256: sha256Text(content),
lineCount: content.length === 0 ? 0 : countLines(content.split(/\r?\n/)),
newline: detectNewline(content),
hasFinalNewline: /\r?\n$/.test(content),
};
}
/**
* Matches one line of `numberedRange` output: optional leading padding, the
* captured line number, " | ", then the source line (which may be empty, so the
* separator is allowed to end the line).
*/
const NUMBERED_LINE = /^[ \t]*(\d+)[ \t]*\|([ \t]|$)/;
/**
* True when `content` looks like it was pasted back out of `numberedRange`
* (i.e. out of a `workspace_inspect read` result) instead of being real file
* text. Small models routinely copy the `12 | ` gutter into edit content, which
* silently corrupts the file or makes an exact replace miss.
*
* Three conditions, all required. The decisive one is the **run**: read output
* is by construction `n, n+1, n+2`, so the matched prefixes must be adjacent
* lines whose numbers ascend by exactly one, at least twice in a row. Data that
* happens to use pipes almost never does that — measured over 36 inputs, adding
* the run requirement (and only then dropping the count threshold from three
* matched lines to two) moved false positives from 12 to 8 and missed
* detections from 6 to 3.
*
* What it still does *not* catch, deliberately: a genuinely consecutive numeric
* first column — `1 | a`, `2 | b`, `3 | c`, or consecutive years — is
* indistinguishable from read output, and `allow_line_numbers: true` is the
* escape hatch for it. What it reliably leaves alone is a leading-pipe markdown
* table (no line starts with a bare number) and any non-monotonic numeric
* column: descending, sparse, or stepped. A single numbered line is below any
* threshold a majority rule can safely use and is never flagged; the zero-match
* hint on `replace` covers that case after the fact instead.
*/
export function looksLineNumbered(content: string): boolean {
const lines = content.split(/\r?\n/).filter((line) => line.trim() !== "");
if (lines.length < 2) return false;
let numbered = 0;
let longestRun = 0;
let run = 0;
let previous: number | undefined;
for (const line of lines) {
const match = NUMBERED_LINE.exec(line);
if (!match) {
run = 0;
previous = undefined;
continue;
}
numbered++;
const value = Number(match[1]);
run = previous !== undefined && value === previous + 1 ? run + 1 : 1;
if (run > longestRun) longestRun = run;
previous = value;
}
return numbered >= 2 && longestRun >= 2 && numbered * 2 > lines.length;
}
/**
* Removes the `12 | ` gutter `numberedRange` adds, from every line. Used only
* to explain a failure after the fact (see `workspace/transactions.ts`), never
* to rewrite what a caller asked for: silently repairing an edit would let a
* model commit text it never actually looked at.
*
* The trailing separator class matches `NUMBERED_LINE`'s own tolerance: with
* ` ?` alone, a tab-separated `1\t|\ta` was recognised as numbered but stripped
* to `\ta`, so the sentence that quotes the stripped text was wrong by a tab.
*/
export function stripLineNumberGutter(content: string): string {
return content.replace(/^[ \t]*\d+[ \t]*\|[ \t]?/gm, "");
}
export function numberedRange(
content: string,
startLine: number,
endLine: number,
): { text: string; totalLines: number; startLine: number; endLine: number } {
const lines = content.split(/\r?\n/);
const totalLines = content.length === 0 ? 0 : countLines(lines);
if (totalLines === 0) {
return { text: "", totalLines: 0, startLine: 0, endLine: 0 };
}
const start = Math.max(1, Math.min(startLine, totalLines));
const end = Math.max(start, Math.min(endLine, totalLines));
const width = String(end).length;
const selected = lines
.slice(start - 1, end)
.map((line, index) => `${String(start + index).padStart(width, " ")} | ${line}`)
.join("\n");
return { text: selected, totalLines, startLine: start, endLine: end };
}
import { readFile, stat } from "node:fs/promises";
import { AgenticError } from "../core/errors";
import { sha256Text } from "../core/hash";
export interface TextFileSnapshot {
content: string;
bytes: number;
sha256: string;
lineCount: number;
newline: "lf" | "crlf" | "mixed" | "none";
hasFinalNewline: boolean;
}
export function appearsBinary(buffer: Buffer): boolean {
const sample = buffer.subarray(0, Math.min(buffer.length, 8192));
if (sample.includes(0)) return true;
let suspicious = 0;
for (const byte of sample) {
if (byte < 7 || (byte > 13 && byte < 32)) suspicious++;
}
return sample.length > 0 && suspicious / sample.length > 0.12;
}
/**
* String.split(/\r?\n/) produces a trailing "" element whenever content ends
* in a newline (e.g. "a\nb\n" -> ["a","b",""]), which would otherwise inflate
* the reported line count by one. Drop that phantom trailing empty element so
* "a\nb\n" and "a\nb" both count as 2 lines; "" still counts as 0 (handled by
* callers before this function is reached).
*/
export function countLines(lines: string[]): number {
if (lines.length > 1 && lines[lines.length - 1] === "") {
return lines.length - 1;
}
return lines.length;
}
export function detectNewline(content: string): TextFileSnapshot["newline"] {
const crlf = (content.match(/\r\n/g) ?? []).length;
const lf = (content.match(/(?<!\r)\n/g) ?? []).length;
if (crlf === 0 && lf === 0) return "none";
if (crlf > 0 && lf > 0) return "mixed";
return crlf > 0 ? "crlf" : "lf";
}
export async function readTextSnapshot(
path: string,
maxBytes: number,
): Promise<TextFileSnapshot> {
const info = await stat(path);
if (!info.isFile()) {
throw new AgenticError("INVALID_INPUT", "Expected a regular file.", { path });
}
if (info.size > maxBytes) {
throw new AgenticError(
"FILE_TOO_LARGE",
`File is ${info.size.toLocaleString()} bytes; limit is ${maxBytes.toLocaleString()} bytes.`,
{ path, bytes: info.size, maxBytes },
);
}
const buffer = await readFile(path);
if (buffer.length > maxBytes) {
throw new AgenticError(
"FILE_TOO_LARGE",
`File grew to ${buffer.length.toLocaleString()} bytes while being read; limit is ${maxBytes.toLocaleString()} bytes.`,
{ path, bytes: buffer.length, maxBytes },
);
}
if (appearsBinary(buffer)) {
throw new AgenticError("BINARY_FILE", "Binary files are not editable as text.", {
path,
});
}
const content = buffer.toString("utf8");
if (!Buffer.from(content, "utf8").equals(buffer)) {
throw new AgenticError(
"BINARY_FILE",
"File is not valid UTF-8 text and cannot be edited safely.",
{ path },
);
}
return snapshotFromContent(content);
}
export function snapshotFromContent(content: string): TextFileSnapshot {
return {
content,
bytes: Buffer.byteLength(content),
sha256: sha256Text(content),
lineCount: content.length === 0 ? 0 : countLines(content.split(/\r?\n/)),
newline: detectNewline(content),
hasFinalNewline: /\r?\n$/.test(content),
};
}
/**
* Matches one line of `numberedRange` output: optional leading padding, the
* captured line number, " | ", then the source line (which may be empty, so the
* separator is allowed to end the line).
*/
const NUMBERED_LINE = /^[ \t]*(\d+)[ \t]*\|([ \t]|$)/;
/**
* True when `content` looks like it was pasted back out of `numberedRange`
* (i.e. out of a `workspace_inspect read` result) instead of being real file
* text. Small models routinely copy the `12 | ` gutter into edit content, which
* silently corrupts the file or makes an exact replace miss.
*
* Three conditions, all required. The decisive one is the **run**: read output
* is by construction `n, n+1, n+2`, so the matched prefixes must be adjacent
* lines whose numbers ascend by exactly one, at least twice in a row. Data that
* happens to use pipes almost never does that — measured over 36 inputs, adding
* the run requirement (and only then dropping the count threshold from three
* matched lines to two) moved false positives from 12 to 8 and missed
* detections from 6 to 3.
*
* What it still does *not* catch, deliberately: a genuinely consecutive numeric
* first column — `1 | a`, `2 | b`, `3 | c`, or consecutive years — is
* indistinguishable from read output, and `allow_line_numbers: true` is the
* escape hatch for it. What it reliably leaves alone is a leading-pipe markdown
* table (no line starts with a bare number) and any non-monotonic numeric
* column: descending, sparse, or stepped. A single numbered line is below any
* threshold a majority rule can safely use and is never flagged; the zero-match
* hint on `replace` covers that case after the fact instead.
*/
export function looksLineNumbered(content: string): boolean {
const lines = content.split(/\r?\n/).filter((line) => line.trim() !== "");
if (lines.length < 2) return false;
let numbered = 0;
let longestRun = 0;
let run = 0;
let previous: number | undefined;
for (const line of lines) {
const match = NUMBERED_LINE.exec(line);
if (!match) {
run = 0;
previous = undefined;
continue;
}
numbered++;
const value = Number(match[1]);
run = previous !== undefined && value === previous + 1 ? run + 1 : 1;
if (run > longestRun) longestRun = run;
previous = value;
}
return numbered >= 2 && longestRun >= 2 && numbered * 2 > lines.length;
}
/**
* Removes the `12 | ` gutter `numberedRange` adds, from every line. Used only
* to explain a failure after the fact (see `workspace/transactions.ts`), never
* to rewrite what a caller asked for: silently repairing an edit would let a
* model commit text it never actually looked at.
*
* The trailing separator class matches `NUMBERED_LINE`'s own tolerance: with
* ` ?` alone, a tab-separated `1\t|\ta` was recognised as numbered but stripped
* to `\ta`, so the sentence that quotes the stripped text was wrong by a tab.
*/
export function stripLineNumberGutter(content: string): string {
return content.replace(/^[ \t]*\d+[ \t]*\|[ \t]?/gm, "");
}
export function numberedRange(
content: string,
startLine: number,
endLine: number,
): { text: string; totalLines: number; startLine: number; endLine: number } {
const lines = content.split(/\r?\n/);
const totalLines = content.length === 0 ? 0 : countLines(lines);
if (totalLines === 0) {
return { text: "", totalLines: 0, startLine: 0, endLine: 0 };
}
const start = Math.max(1, Math.min(startLine, totalLines));
const end = Math.max(start, Math.min(endLine, totalLines));
const width = String(end).length;
const selected = lines
.slice(start - 1, end)
.map((line, index) => `${String(start + index).padStart(width, " ")} | ${line}`)
.join("\n");
return { text: selected, totalLines, startLine: start, endLine: end };
}