Files
oh-my-pi/packages/coding-agent/src/tools/read-archive.ts
T
can1357 7454b6e78f refactor(coding-agent): split read tool into per-source modules
- ReadTool mixed plain-file reading with archive, sqlite, pdf-image, summary,
  selector, formatting and renderer concerns in one 3763-line module.
- Each now owns a sibling module; read.ts drops to 2020 lines and keeps its
  public exports, including the readToolRenderer re-export required because
  tools/index.ts star-exports ./read through the explicit ./tools entry.
- The pdfImageExtractions map and summaryParseCaches WeakMap stay single
  instances; execute() was deliberately left intact.
2026-08-08 06:32:01 +02:00

210 lines
7.0 KiB
TypeScript

import type { AgentToolResult } from "@oh-my-pi/pi-agent-core";
import type { TextContent } from "@oh-my-pi/pi-ai";
import type { ToolSession } from "../sdk";
import { truncateHead } from "../session/streaming-output";
import { type ArchiveReader, formatArchiveEntryLines, openArchive, parseArchivePathCandidates } from "../utils/zip";
import { applyListLimit } from "./list-limit";
import { resolveReadPath } from "./path-utils";
import type { ReadToolDetails } from "./read";
import {
buildInMemoryMultiRangeResult,
buildInMemoryTextResult,
decodeUtf8Text,
markMarkdownContentType,
prependSuffixResolutionNotice,
} from "./read-format";
import {
findSuffixMatchCached,
isNotFoundError,
isRemoteMountPath,
type SuffixMatchCache,
} from "./read-path-resolution";
import { isMultiRange, isRawSelector, type ParsedSelector, parseSel, selToOffsetLimit } from "./read-selector";
import { formatBytes } from "./render-utils";
import { ToolError, throwIfAborted } from "./tool-errors";
import { toolResult } from "./tool-result";
interface ResolvedArchiveReadPath {
absolutePath: string;
archiveSubPath: string;
suffixResolution?: { from: string; to: string };
}
export async function resolveArchiveReadPath(
session: ToolSession,
readPath: string,
suffixCache: SuffixMatchCache,
signal?: AbortSignal,
): Promise<ResolvedArchiveReadPath | null> {
const candidates = parseArchivePathCandidates(readPath);
for (const candidate of candidates) {
let absolutePath = resolveReadPath(candidate.archivePath, session.cwd);
let suffixResolution: { from: string; to: string } | undefined;
try {
const stat = await Bun.file(absolutePath).stat();
if (stat.isDirectory()) continue;
return {
absolutePath,
archiveSubPath: candidate.archivePath === readPath ? "" : candidate.subPath,
suffixResolution,
};
} catch (error) {
if (!isNotFoundError(error) || isRemoteMountPath(absolutePath)) continue;
const suffixMatch = await findSuffixMatchCached(session, suffixCache, candidate.archivePath, signal);
if (!suffixMatch) continue;
try {
const retryStat = await Bun.file(suffixMatch.absolutePath).stat();
if (retryStat.isDirectory()) continue;
absolutePath = suffixMatch.absolutePath;
suffixResolution = { from: candidate.archivePath, to: suffixMatch.displayPath };
return {
absolutePath,
archiveSubPath: candidate.archivePath === readPath ? "" : candidate.subPath,
suffixResolution,
};
} catch (retryError) {
if (!isNotFoundError(retryError)) {
throw retryError;
}
}
}
}
return null;
}
async function readArchiveDirectory(
archive: ArchiveReader,
archivePath: string,
subPath: string,
offset: number | undefined,
limit: number | undefined,
details: ReadToolDetails,
signal?: AbortSignal,
): Promise<AgentToolResult<ReadToolDetails>> {
const DEFAULT_LIMIT = 500;
const effectiveLimit = limit ?? DEFAULT_LIMIT;
const allEntries = archive.listDirectory(subPath);
// `offset` is 1-indexed (line-selector semantics): `a.zip:dir:50` starts
// the listing at the 50th entry instead of being silently ignored.
const entries = offset !== undefined && offset > 1 ? allEntries.slice(offset - 1) : allEntries;
const listLimit = applyListLimit(entries, { limit: effectiveLimit });
const limitedEntries = listLimit.items;
const limitMeta = listLimit.meta;
for (let index = 0; index < limitedEntries.length; index++) {
throwIfAborted(signal);
}
const results = formatArchiveEntryLines(limitedEntries);
const output = results.length > 0 ? results.join("\n") : "(empty archive directory)";
const text = prependSuffixResolutionNotice(output, details.suffixResolution);
const truncation = truncateHead(text, { maxLines: Number.MAX_SAFE_INTEGER });
const directoryDetails: ReadToolDetails = { ...details, isDirectory: true };
const resultBuilder = toolResult<ReadToolDetails>(directoryDetails).text(truncation.content);
resultBuilder.sourcePath(archivePath).limits({ resultLimit: limitMeta.resultLimit?.reached });
if (truncation.truncated) {
directoryDetails.truncation = truncation;
resultBuilder.truncation(truncation, { direction: "head" });
}
return resultBuilder.done();
}
export async function readArchive(
session: ToolSession,
readPath: string,
parsedSel: ParsedSelector,
resolvedArchivePath: ResolvedArchiveReadPath,
signal?: AbortSignal,
): Promise<AgentToolResult<ReadToolDetails>> {
throwIfAborted(signal);
const archive = await openArchive(resolvedArchivePath.absolutePath);
throwIfAborted(signal);
const details: ReadToolDetails = markMarkdownContentType(
session,
{
resolvedPath: resolvedArchivePath.absolutePath,
suffixResolution: resolvedArchivePath.suffixResolution,
},
resolvedArchivePath.archiveSubPath,
);
let archiveSubPath = resolvedArchivePath.archiveSubPath;
let sel = parsedSel;
let node = archive.getNode(archiveSubPath);
if (!node && archiveSubPath) {
// `archive.zip:500` / `archive.zip:raw`: the whole subPath is a
// selector on the archive root, not a member name. Member names take
// precedence (getNode above); fall back to root + selector.
const wholeSel = parseSel(archiveSubPath);
if (wholeSel.kind !== "none") {
node = archive.getNode("");
archiveSubPath = "";
sel = wholeSel;
}
}
if (!node) {
throw new ToolError(`Path '${readPath}' not found inside archive`);
}
if (node.isDirectory) {
if (isMultiRange(sel)) {
throw new ToolError("Multi-range line selectors are not supported for archive directory listings.");
}
const { offset, limit } = selToOffsetLimit(sel);
return readArchiveDirectory(
archive,
resolvedArchivePath.absolutePath,
archiveSubPath,
offset,
limit,
details,
signal,
);
}
const entry = await archive.readFile(archiveSubPath);
const text = decodeUtf8Text(entry.bytes);
if (text === null) {
return toolResult<ReadToolDetails>(details)
.text(
prependSuffixResolutionNotice(
`[Cannot read binary archive entry '${entry.path}' (${formatBytes(entry.size)})]`,
resolvedArchivePath.suffixResolution,
),
)
.sourcePath(resolvedArchivePath.absolutePath)
.done();
}
// Archive members are immutable: there is no edit path for bytes inside
// an archive, and a hashline tag keyed to the archive file would invite
// (and fail) edits while clobbering sibling members' snapshots.
const raw = isRawSelector(sel);
const result =
isMultiRange(sel) && sel.kind === "lines"
? buildInMemoryMultiRangeResult(session, text, sel.ranges, {
details,
sourcePath: resolvedArchivePath.absolutePath,
entityLabel: "archive entry",
raw,
immutable: true,
})
: buildInMemoryTextResult(session, text, selToOffsetLimit(sel).offset, selToOffsetLimit(sel).limit, {
details,
sourcePath: resolvedArchivePath.absolutePath,
entityLabel: "archive entry",
raw,
immutable: true,
});
const firstText = result.content.find((content): content is TextContent => content.type === "text");
if (firstText) {
firstText.text = prependSuffixResolutionNotice(firstText.text, resolvedArchivePath.suffixResolution);
}
return result;
}