Files
oh-my-pi/packages/coding-agent/src/tools/read-format.ts
T
roboompandcan1357 a02f88994b fix(tools): preserve workspace-relative path in read hashline headers
formatReadHashlineHeader collapsed every relative in-workspace path to its
basename, so reading a nested file (e.g. src/settings.json) emitted
[settings.json#tag]. When a same-basename file existed at the session cwd,
a verbatim follow-up edit resolved against the cwd file; Patcher.prepare only
runs snapshot-tag path recovery when the authored path is missing, so the
valid edit was deterministically rejected with "hash is not from this
session". Keep the workspace-relative path, which names the file uniquely and
stays directly resolvable against cwd. Out-of-workspace absolute paths remain
shortened; root-level files are unchanged.

Fixes #8482
2026-08-14 00:04:59 +02:00

600 lines
23 KiB
TypeScript

import * as path from "node:path";
import {
formatHashlineHeader,
formatNumberedLine,
formatNumberedLines,
splitAddressableFileLines,
} from "@oh-my-pi/hashline";
import type { AgentToolResult } from "@oh-my-pi/pi-agent-core";
import { canonicalSnapshotKey, getFileSnapshotStore, recordSeenLines } from "../edit/file-snapshot-store";
import { normalizeToLF } from "../edit/normalize";
import { isMarkdownPath } from "../modes/theme/theme";
import type { ToolSession } from "../sdk";
import {
DEFAULT_MAX_BYTES,
noTruncResult,
type TruncationResult,
truncateHead,
truncateHeadBytes,
} from "../session/streaming-output";
import { buildLineEntriesWithBlockContext, type LineEntry, lineEntriesToPlainText } from "../utils/block-context";
import { resolveFileDisplayMode } from "../utils/file-display-mode";
import { formatPathRelativeToCwd, type LineRange } from "./path-utils";
import type { ReadToolDetails } from "./read";
import { formatBytes, shortenPath } from "./render-utils";
import { ToolError } from "./tool-errors";
import { toolResult } from "./tool-result";
function prependLineNumbers(text: string, startNum: number): string {
const textLines = text.split("\n");
return textLines.map((line, i) => `${startNum + i}|${line}`).join("\n");
}
export interface HashlineHeaderContext {
header: string;
tag: string;
fullText?: string;
}
export function formatReadHashlineHeader(displayPath: string, tag: string): string {
// In-workspace reads keep their workspace-relative path (e.g.
// `src/settings.json`), not just the basename: collapsing to the bare name
// made a header ambiguous whenever another same-named file exists at cwd —
// the edit tool would resolve the bare name against cwd, hit the wrong
// file, and reject the valid edit via the snapshot-tag guard (the authored
// path exists, so Patcher's tag-path recovery never runs). The relative
// path stays directly resolvable against cwd and names the file uniquely.
// Out-of-workspace reads use an absolute displayPath; `shortenPath` keeps
// `~/.claude/...` (round-trips through resolveToCwd's ~ expansion) instead
// of leaking the full home path into the read output.
const anchor = path.isAbsolute(displayPath) ? shortenPath(displayPath) : displayPath;
return formatHashlineHeader(anchor, tag);
}
function recordFullHashlineContext(
session: ToolSession,
absolutePath: string | undefined,
displayPath: string,
fullText: string,
): HashlineHeaderContext | undefined {
if (!absolutePath || !path.isAbsolute(absolutePath)) return undefined;
const normalized = normalizeToLF(fullText);
const tag = getFileSnapshotStore(session).record(canonicalSnapshotKey(absolutePath), normalized);
return {
header: formatReadHashlineHeader(displayPath, tag),
tag,
fullText: normalized,
};
}
export async function readHashlineHeaderContext(
session: ToolSession,
absolutePath: string,
cwd: string,
): Promise<HashlineHeaderContext> {
const fullText = await Bun.file(absolutePath).text();
const context = recordFullHashlineContext(
session,
absolutePath,
formatPathRelativeToCwd(absolutePath, cwd),
fullText,
);
if (!context) throw new ToolError(`Cannot record hashline snapshot for non-absolute path: ${absolutePath}`);
return context;
}
export function hashlineHeaderContext(displayPath: string, tag: string): HashlineHeaderContext {
return { header: formatReadHashlineHeader(displayPath, tag), tag };
}
export function prependHashlineHeader(text: string, context: HashlineHeaderContext | undefined): string {
return context ? `${context.header}\n${text}` : text;
}
export function formatTextWithMode(
text: string,
startNum: number,
shouldAddHashLines: boolean,
shouldAddLineNumbers: boolean,
): string {
if (shouldAddHashLines) return formatNumberedLines(text, startNum);
if (shouldAddLineNumbers) return prependLineNumbers(text, startNum);
return text;
}
export const BRACKET_CONTEXT_ELLIPSIS = "…";
function formatLineEntryWithMode(entry: LineEntry, shouldAddHashLines: boolean, shouldAddLineNumbers: boolean): string {
if (entry.kind === "ellipsis") return BRACKET_CONTEXT_ELLIPSIS;
return formatSingleLine(entry.lineNumber, entry.text, shouldAddHashLines, shouldAddLineNumbers);
}
export function formatLineEntriesWithMode(
entries: readonly LineEntry[],
shouldAddHashLines: boolean,
shouldAddLineNumbers: boolean,
): string {
return entries.map(entry => formatLineEntryWithMode(entry, shouldAddHashLines, shouldAddLineNumbers)).join("\n");
}
const BRACE_PAIRS: Record<string, string> = { "{": "}", "(": ")", "[": "]" };
const BRACE_TAIL_TRAILING_RE = /^[;,)\]}]*$/;
/**
* Decide whether the kept lines surrounding an elided range collapse to a
* single brace-pair line in the rendered summary. Returns true when the head
* line ends with `{` / `(` / `[` and the tail line is the matching closer
* (optionally followed by terminating punctuation like `;`, `,`, or further
* closers — e.g. `};`, `})`, `]);`).
*/
export function canMergeBracePair(headLine: string, tailLine: string): boolean {
const head = headLine.trimEnd();
const tail = tailLine.trim();
const opener = head.slice(-1);
const closer = BRACE_PAIRS[opener];
if (!closer) return false;
if (!tail.startsWith(closer)) return false;
return BRACE_TAIL_TRAILING_RE.test(tail.slice(closer.length));
}
export function formatSingleLine(
line: number,
text: string,
shouldAddHashLines: boolean,
shouldAddLineNumbers: boolean,
): string {
if (shouldAddHashLines) return formatNumberedLine(line, text);
if (shouldAddLineNumbers) return `${line}|${text}`;
return text;
}
export function formatMergedBraceLine(
startLine: number,
endLine: number,
headText: string,
tailText: string,
shouldAddHashLines: boolean,
shouldAddLineNumbers: boolean,
): { model: string; display: string } {
const merged = `${headText.trimEnd()} … ${tailText.trim()}`;
if (shouldAddHashLines) {
return { model: `${startLine}-${endLine}:${merged}`, display: merged };
}
if (shouldAddLineNumbers) {
return { model: `${startLine}-${endLine}|${merged}`, display: merged };
}
return { model: merged, display: merged };
}
export function countTextLines(text: string): number {
if (text.length === 0) return 0;
// Count newlines directly instead of allocating an array via split("\n").
// Called on every read of file content; the result is identical (N newlines
// ⇒ N+1 lines for non-empty text).
let lines = 1;
for (let i = 0; i < text.length; i++) {
if (text.charCodeAt(i) === 10) lines++;
}
return lines;
}
export function contiguousLineNumbers(startLine: number, count: number): number[] {
const lines: number[] = [];
for (let offset = 0; offset < count; offset++) lines.push(startLine + offset);
return lines;
}
export function lineNumbersFromSpans(spans: readonly { startLine: number; endLine: number }[]): number[] {
const lines: number[] = [];
for (const span of spans) {
for (let line = span.startLine; line <= span.endLine; line++) lines.push(line);
}
return lines;
}
function recordInMemorySeenLines(
session: ToolSession,
absolutePath: string | undefined,
fullText: string,
seenLines: readonly number[] | undefined,
): void {
if (!absolutePath || !path.isAbsolute(absolutePath) || !seenLines || seenLines.length === 0) return;
getFileSnapshotStore(session).record(canonicalSnapshotKey(absolutePath), normalizeToLF(fullText), seenLines);
}
function lineNumbersFromEntries(entries: readonly LineEntry[]): number[] {
const lines: number[] = [];
for (const entry of entries) {
if (entry.kind === "line") lines.push(entry.lineNumber);
}
return lines;
}
/** Inclusive line range describing one elided span in a structural summary. */
export interface ElidedRange {
start: number;
end: number;
}
/** Sample ranges shown in the footer to demonstrate the multi-range syntax. */
const FOOTER_RANGE_SAMPLES = 2;
/**
* Footer appended to summarized reads telling the model how to recover the
* elided body. Without this hint, agents either ignore the `…`/`{ … }`
* markers or burn a turn guessing the right selector (see issue #1046). The
* footer demonstrates the multi-range selector syntax with concrete sample
* ranges drawn from the actual elision so the model re-reads only what it
* needs instead of falling back to `:raw` or whole-file reads.
*/
export function formatSummaryElisionFooter(
readPath: string,
elidedRanges: ReadonlyArray<ElidedRange>,
elidedLines: number,
): string {
if (elidedRanges.length === 0) return "";
const sampleCount = Math.min(elidedRanges.length, FOOTER_RANGE_SAMPLES);
const selector = elidedRanges
.slice(0, sampleCount)
.map(r => `${r.start}-${r.end}`)
.join(",");
const example = `${readPath}:${selector}`;
const tail = elidedRanges.length > sampleCount ? `, e.g. ${example}` : ` with ${example}`;
return `[…${elidedLines}ln elided; re-read needed ranges${tail}]`;
}
export const READ_CHUNK_SIZE = 8 * 1024;
/**
* Context lines added around an explicit range read. Anchor-stale failures
* cluster on edits whose anchors land just outside the most recent read
* window, but the data (`scripts/session-stats/analyze_selector_reads.py`)
* shows most follow-up reads are disjoint hops, not adjacent extensions —
* so symmetric padding rarely pays for itself.
*
* Leading=1 catches accidental single-line reads where the anchor is the
* line immediately above the requested start. Trailing=3 buffers the
* common case where the agent asks for a narrow range and then needs the
* next few lines to disambiguate an anchor.
*/
export const RANGE_LEADING_CONTEXT_LINES = 1;
export const RANGE_TRAILING_CONTEXT_LINES = 3;
/**
* Expand a [start, end) range with leading/trailing context lines on the
* sides where the user actually constrained the range. A start of 0 (no
* explicit offset) does not get leading context — that's already an
* open-ended read from the top.
*/
function expandRangeWithContext(
requestedStart: number,
requestedEnd: number,
totalLines: number,
expandStart: boolean,
expandEnd: boolean,
): { startLine: number; endLine: number } {
return {
startLine: expandStart ? Math.max(0, requestedStart - RANGE_LEADING_CONTEXT_LINES) : requestedStart,
endLine: expandEnd ? Math.min(totalLines, requestedEnd + RANGE_TRAILING_CONTEXT_LINES) : requestedEnd,
};
}
export function buildInMemoryTextResult(
session: ToolSession,
text: string,
offset: number | undefined,
limit: number | undefined,
options: {
details?: ReadToolDetails;
sourcePath?: string;
sourceUrl?: string;
sourceInternal?: string;
entityLabel: string;
ignoreResultLimits?: boolean;
raw?: boolean;
immutable?: boolean;
},
): AgentToolResult<ReadToolDetails> {
const displayMode = resolveFileDisplayMode(session, { raw: options.raw, immutable: options.immutable });
const details = options.details ?? {};
const allLines = options.raw === true ? text.split("\n") : splitAddressableFileLines(text);
const totalLines = allLines.length;
details.totalLines = totalLines;
// User-requested 0-indexed range start. Lines BEFORE this are leading
// context (added below if offset is explicit).
const requestedStart = offset ? Math.max(0, offset - 1) : 0;
const ignoreResultLimits = options.ignoreResultLimits ?? false;
const requestedEnd = limit !== undefined ? Math.min(requestedStart + limit, allLines.length) : allLines.length;
// Expand only on sides the user actually constrained: leading context
// when offset>1, trailing context when a finite limit was set. Raw mode
// never expands — without line numbers the padding is indistinguishable
// from requested content, so `raw:31-31` must return line 31 and nothing
// else (verbatim-extraction contract).
const rawDisplay = options.raw === true;
const expanded = expandRangeWithContext(
requestedStart,
requestedEnd,
allLines.length,
!rawDisplay && offset !== undefined && offset > 1,
!rawDisplay && limit !== undefined,
);
const startLine = expanded.startLine;
const endLineExpanded = expanded.endLine;
const startLineDisplay = startLine + 1;
const resultBuilder = toolResult(details);
if (options.sourcePath) {
resultBuilder.sourcePath(options.sourcePath);
}
if (options.sourceUrl) {
resultBuilder.sourceUrl(options.sourceUrl);
}
if (options.sourceInternal) {
resultBuilder.sourceInternal(options.sourceInternal);
}
if (requestedStart >= allLines.length) {
const suggestion =
allLines.length === 0
? `The ${options.entityLabel} is empty.`
: `Use :1 to read from the start, or :${allLines.length} to read the last line.`;
return resultBuilder
.text(
`Line ${requestedStart + 1} is beyond end of ${options.entityLabel} (${allLines.length} lines total). ${suggestion}`,
)
.done();
}
const endLine = endLineExpanded;
const selectedContent = allLines.slice(startLine, endLine).join("\n");
const userLimitedLines = limit !== undefined ? endLine - startLine : undefined;
const truncation = ignoreResultLimits ? noTruncResult(selectedContent) : truncateHead(selectedContent);
const shouldAddHashLines = displayMode.hashLines;
const shouldAddLineNumbers = shouldAddHashLines ? false : displayMode.lineNumbers;
const hashContext =
shouldAddHashLines && options.sourcePath
? recordFullHashlineContext(
session,
options.sourcePath,
formatPathRelativeToCwd(options.sourcePath, session.cwd),
text,
)
: undefined;
let emittedHashlineHeader = false;
let seenLines: number[] | undefined;
let rawSeenLines: number[] | undefined;
const formatText = (content: string, startNum: number): string => {
const lineCount = countTextLines(content);
details.displayContent = {
text: content,
startLine: startNum,
lineNumbers: Array.from({ length: lineCount }, (_, i) => startNum + i),
};
if (shouldAddHashLines) seenLines = contiguousLineNumbers(startNum, lineCount);
const formatted = formatTextWithMode(content, startNum, shouldAddHashLines, shouldAddLineNumbers);
if (!hashContext || emittedHashlineHeader) return formatted;
emittedHashlineHeader = true;
return prependHashlineHeader(formatted, hashContext);
};
const formatLineEntries = (entries: readonly LineEntry[], startNum: number): string => {
const firstLine = entries.find(entry => entry.kind === "line");
details.displayContent = {
text: lineEntriesToPlainText(entries, BRACKET_CONTEXT_ELLIPSIS),
startLine: firstLine?.kind === "line" ? firstLine.lineNumber : startNum,
lineNumbers: entries.map(entry => (entry.kind === "line" ? entry.lineNumber : null)),
};
if (shouldAddHashLines) seenLines = lineNumbersFromEntries(entries);
const formatted = formatLineEntriesWithMode(entries, shouldAddHashLines, shouldAddLineNumbers);
if (!hashContext || emittedHashlineHeader) return formatted;
emittedHashlineHeader = true;
return prependHashlineHeader(formatted, hashContext);
};
const buildLineEntries = (endLineDisplay: number): LineEntry[] =>
buildLineEntriesWithBlockContext(allLines, [{ startLine: startLineDisplay, endLine: endLineDisplay }], {
path: options.sourcePath,
});
let outputText: string;
let truncationInfo:
| { result: TruncationResult; options: { direction: "head"; startLine?: number; totalFileLines?: number } }
| undefined;
if (truncation.firstLineExceedsLimit) {
const firstLine = allLines[startLine] ?? "";
const firstLineBytes = Buffer.byteLength(firstLine, "utf-8");
const snippet = truncateHeadBytes(firstLine, DEFAULT_MAX_BYTES);
if (shouldAddHashLines) {
outputText = `[Line ${startLineDisplay} is ${formatBytes(
firstLineBytes,
)}, exceeds ${formatBytes(DEFAULT_MAX_BYTES)} limit. Hashline output requires full lines; cannot emit an editable numbered preview for a truncated line.]`;
} else {
outputText = formatText(snippet.text, startLineDisplay);
}
if (snippet.text.length === 0) {
outputText = `[Line ${startLineDisplay} is ${formatBytes(
firstLineBytes,
)}, exceeds ${formatBytes(DEFAULT_MAX_BYTES)} limit. Unable to display a valid UTF-8 snippet.]`;
}
details.truncation = truncation;
truncationInfo = {
result: truncation,
options: { direction: "head", startLine: startLineDisplay, totalFileLines: totalLines },
};
} else if (truncation.truncated) {
const outputLines = truncation.outputLines ?? countTextLines(truncation.content);
const endLineDisplay = startLineDisplay + Math.max(0, outputLines - 1);
if (options.raw === true) {
rawSeenLines = contiguousLineNumbers(startLineDisplay, outputLines);
outputText = formatText(truncation.content, startLineDisplay);
} else {
outputText = formatLineEntries(buildLineEntries(endLineDisplay), startLineDisplay);
}
details.truncation = truncation;
truncationInfo = {
result: truncation,
options: { direction: "head", startLine: startLineDisplay, totalFileLines: totalLines },
};
} else if (userLimitedLines !== undefined && startLine + userLimitedLines < allLines.length) {
const remaining = allLines.length - (startLine + userLimitedLines);
const nextOffset = startLine + userLimitedLines + 1;
if (options.raw === true) {
rawSeenLines = contiguousLineNumbers(startLineDisplay, userLimitedLines);
outputText = formatText(selectedContent, startLineDisplay);
} else {
outputText = formatLineEntries(buildLineEntries(endLine), startLineDisplay);
}
outputText += `\n\n[${remaining} more lines in ${options.entityLabel}. Use :${nextOffset} to continue]`;
} else {
if (options.raw === true) {
rawSeenLines = contiguousLineNumbers(startLineDisplay, endLine - startLine);
outputText = formatText(truncation.content, startLineDisplay);
} else {
outputText = formatLineEntries(buildLineEntries(endLine), startLineDisplay);
}
}
if (hashContext?.tag && options.sourcePath && seenLines) {
recordSeenLines(session, options.sourcePath, hashContext.tag, seenLines);
}
if (options.raw === true && options.sourcePath && options.immutable !== true && rawSeenLines) {
recordInMemorySeenLines(session, options.sourcePath, text, rawSeenLines);
}
resultBuilder.text(outputText);
if (truncationInfo) {
resultBuilder.truncation(truncationInfo.result, truncationInfo.options);
}
return resultBuilder.done();
}
/**
* Render a multi-range read against in-memory text. Each range emits a
* formatted block with its own anchors / line numbers, blocks are joined
* with an elision separator, and ranges past EOF surface as `[…]` notices
* so the model can correct the next call. No leading/trailing context is
* added — multi-range callers always specify exact bounds.
*/
export function buildInMemoryMultiRangeResult(
session: ToolSession,
text: string,
ranges: readonly LineRange[],
options: {
details?: ReadToolDetails;
sourcePath?: string;
sourceUrl?: string;
sourceInternal?: string;
entityLabel: string;
raw?: boolean;
immutable?: boolean;
},
): AgentToolResult<ReadToolDetails> {
const displayMode = resolveFileDisplayMode(session, { raw: options.raw, immutable: options.immutable });
const details = options.details ?? {};
const allLines = options.raw === true ? text.split("\n") : splitAddressableFileLines(text);
const totalLines = allLines.length;
details.totalLines = totalLines;
const shouldAddHashLines = displayMode.hashLines;
const shouldAddLineNumbers = shouldAddHashLines ? false : displayMode.lineNumbers;
const hashContext =
shouldAddHashLines && options.sourcePath
? recordFullHashlineContext(
session,
options.sourcePath,
formatPathRelativeToCwd(options.sourcePath, session.cwd),
text,
)
: undefined;
let emittedHashlineHeader = false;
let seenLines: number[] | undefined;
const resultBuilder = toolResult(details);
if (options.sourcePath) resultBuilder.sourcePath(options.sourcePath);
if (options.sourceUrl) resultBuilder.sourceUrl(options.sourceUrl);
if (options.sourceInternal) resultBuilder.sourceInternal(options.sourceInternal);
const outOfBounds: LineRange[] = [];
const visibleSpans: Array<{ startLine: number; endLine: number }> = [];
const rawParts: string[] = [];
for (const range of ranges) {
if (range.startLine > totalLines) {
outOfBounds.push(range);
continue;
}
const effectiveEnd = Math.min(range.endLine ?? totalLines, totalLines);
visibleSpans.push({ startLine: range.startLine, endLine: effectiveEnd });
if (options.raw === true) {
rawParts.push(allLines.slice(range.startLine - 1, effectiveEnd).join("\n"));
}
}
let outputText = "";
if (options.raw === true) {
outputText = rawParts.length > 0 ? rawParts.join("\n\n…\n\n") : "";
} else if (visibleSpans.length > 0) {
const entries = buildLineEntriesWithBlockContext(allLines, visibleSpans, { path: options.sourcePath });
if (shouldAddHashLines) seenLines = lineNumbersFromEntries(entries);
const firstLine = entries.find(entry => entry.kind === "line");
if (firstLine?.kind === "line") {
details.displayContent = {
text: lineEntriesToPlainText(entries, BRACKET_CONTEXT_ELLIPSIS),
startLine: firstLine.lineNumber,
lineNumbers: entries.map(entry => (entry.kind === "line" ? entry.lineNumber : null)),
};
}
const formatted = formatLineEntriesWithMode(entries, shouldAddHashLines, shouldAddLineNumbers);
outputText = hashContext && !emittedHashlineHeader ? prependHashlineHeader(formatted, hashContext) : formatted;
if (hashContext) emittedHashlineHeader = true;
}
const notices: string[] = [];
for (const range of outOfBounds) {
const bound = range.endLine !== undefined ? `${range.startLine}-${range.endLine}` : `${range.startLine}`;
notices.push(`[Range ${bound} is beyond end of ${options.entityLabel} (${totalLines} lines total); skipped]`);
}
const finalText =
notices.length > 0 ? (outputText ? `${outputText}\n${notices.join("\n")}` : notices.join("\n")) : outputText;
if (hashContext?.tag && options.sourcePath && seenLines) {
recordSeenLines(session, options.sourcePath, hashContext.tag, seenLines);
}
if (options.raw === true && options.sourcePath && options.immutable !== true && visibleSpans.length > 0) {
recordInMemorySeenLines(session, options.sourcePath, text, lineNumbersFromSpans(visibleSpans));
}
resultBuilder.text(finalText);
return resultBuilder.done();
}
export function decodeUtf8Text(bytes: Uint8Array): string | null {
if (bytes.indexOf(0) !== -1) return null;
try {
return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
} catch {
return null;
}
}
export function prependSuffixResolutionNotice(text: string, suffixResolution?: { from: string; to: string }): string {
if (!suffixResolution) return text;
const notice = `[Path '${suffixResolution.from}' not found; resolved to '${suffixResolution.to}' via suffix match]`;
return text ? `${notice}\n${text}` : notice;
}
/**
* Tag Markdown reads for the TUI's formatted preview, gated on the opt-in
* `read.renderMarkdown` setting. Off by default; when disabled, no local
* read is tagged `text/markdown`, so the renderer output is identical to
* the pre-setting behavior. Internal-URL reads keep their protocol-supplied
* `contentType` and render as Markdown regardless of the setting.
*/
export function markMarkdownContentType(
session: ToolSession,
details: ReadToolDetails,
filePath: string,
): ReadToolDetails {
if (!details.contentType && session.settings.get("read.renderMarkdown") && isMarkdownPath(filePath)) {
details.contentType = "text/markdown";
}
return details;
}