7454b6e78f
- 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.
210 lines
7.0 KiB
TypeScript
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;
|
|
}
|