@oh-my-pi/pi-coding-agent 17.2.11 → 17.2.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -0
- package/dist/{CHANGELOG-d9xenpn9.md → CHANGELOG-k9ghy5sn.md} +13 -0
- package/dist/cli.js +10795 -10791
- package/dist/types/advisor/runtime.d.ts +1 -1
- package/dist/types/config/custom-models.d.ts +31 -0
- package/dist/types/config/model-config-values.d.ts +19 -0
- package/dist/types/config/model-patch.d.ts +93 -0
- package/dist/types/config/model-provider-discovery.d.ts +51 -0
- package/dist/types/config/model-registry.d.ts +5 -52
- package/dist/types/config/settings.d.ts +5 -3
- package/dist/types/debug/raw-sse-buffer.d.ts +9 -0
- package/dist/types/discovery/agent-plugin-format.d.ts +6 -13
- package/dist/types/discovery/contained-path.d.ts +2 -3
- package/dist/types/eval/executor-base.d.ts +8 -2
- package/dist/types/eval/kernel-session-registry.d.ts +60 -0
- package/dist/types/export/share.d.ts +1 -1
- package/dist/types/lsp/diagnostics.d.ts +96 -0
- package/dist/types/lsp/index.d.ts +7 -128
- package/dist/types/lsp/servers.d.ts +72 -0
- package/dist/types/lsp/tool.d.ts +42 -0
- package/dist/types/lsp/workspace-diagnostics.d.ts +12 -0
- package/dist/types/lsp/writethrough.d.ts +33 -0
- package/dist/types/mcp/transports/header-policy.d.ts +2 -1
- package/dist/types/modes/components/agent-hub-projection.d.ts +2 -7
- package/dist/types/modes/components/agent-hub-renderer.d.ts +0 -7
- package/dist/types/modes/theme/color.d.ts +19 -0
- package/dist/types/modes/theme/loader.d.ts +19 -0
- package/dist/types/modes/theme/schema.d.ts +175 -0
- package/dist/types/modes/theme/symbols.d.ts +28 -0
- package/dist/types/modes/theme/theme-class.d.ts +244 -0
- package/dist/types/modes/theme/theme.d.ts +9 -280
- package/dist/types/modes/theme/tui-adapters.d.ts +13 -0
- package/dist/types/registry/agent-registry.d.ts +1 -3
- package/dist/types/secrets/index.d.ts +3 -1
- package/dist/types/secrets/message-transform.d.ts +40 -0
- package/dist/types/secrets/obfuscator.d.ts +0 -108
- package/dist/types/secrets/placeholder-scan.d.ts +95 -0
- package/dist/types/secrets/placeholder.d.ts +94 -0
- package/dist/types/secrets/replacement.d.ts +82 -0
- package/dist/types/session/agent-session-types.d.ts +6 -0
- package/dist/types/session/agent-session.d.ts +1 -1
- package/dist/types/session/session-handoff.d.ts +3 -3
- package/dist/types/session/session-manager.d.ts +32 -0
- package/dist/types/session/session-provider-boundary.d.ts +1 -1
- package/dist/types/session/turn-recovery.d.ts +2 -2
- package/dist/types/slash-commands/builtin-collaboration.d.ts +2 -0
- package/dist/types/slash-commands/builtin-completions.d.ts +36 -0
- package/dist/types/slash-commands/builtin-control.d.ts +2 -0
- package/dist/types/slash-commands/builtin-lifecycle.d.ts +3 -0
- package/dist/types/slash-commands/builtin-marketplace.d.ts +11 -0
- package/dist/types/slash-commands/builtin-modes.d.ts +5 -0
- package/dist/types/slash-commands/builtin-registry.d.ts +1 -1
- package/dist/types/slash-commands/builtin-session.d.ts +2 -0
- package/dist/types/tools/gh-common.d.ts +69 -0
- package/dist/types/tools/gh-pr-checkout.d.ts +84 -0
- package/dist/types/tools/gh-pr-diff.d.ts +102 -0
- package/dist/types/tools/gh-run-watch.d.ts +78 -0
- package/dist/types/tools/gh-search.d.ts +61 -0
- package/dist/types/tools/gh-types.d.ts +340 -0
- package/dist/types/tools/gh-view.d.ts +88 -0
- package/dist/types/tools/gh.d.ts +4 -201
- package/dist/types/tools/read-archive.d.ts +16 -0
- package/dist/types/tools/read-format.d.ts +104 -0
- package/dist/types/tools/read-path-resolution.d.ts +19 -0
- package/dist/types/tools/read-pdf-images.d.ts +12 -0
- package/dist/types/tools/read-renderer.d.ts +24 -0
- package/dist/types/tools/read-selector.d.ts +27 -0
- package/dist/types/tools/read-sqlite.d.ts +16 -0
- package/dist/types/tools/read-summary.d.ts +19 -0
- package/dist/types/tools/read.d.ts +1 -23
- package/dist/types/tools/shell-tokenize.d.ts +2 -1
- package/dist/types/utils/changelog.d.ts +0 -5
- package/package.json +13 -13
- package/src/advisor/runtime.ts +12 -7
- package/src/config/custom-models.ts +188 -0
- package/src/config/model-config-values.ts +128 -0
- package/src/config/model-patch.ts +252 -0
- package/src/config/model-provider-discovery.ts +132 -0
- package/src/config/model-registry.ts +64 -704
- package/src/config/settings.ts +7 -3
- package/src/debug/raw-sse-buffer.ts +20 -0
- package/src/discovery/agent-plugin-format.ts +7 -7
- package/src/discovery/contained-path.ts +2 -5
- package/src/edit/hashline/diff.ts +5 -1
- package/src/eval/executor-base.ts +39 -6
- package/src/eval/jl/executor.ts +82 -371
- package/src/eval/kernel-session-registry.ts +398 -0
- package/src/eval/py/executor.ts +53 -261
- package/src/eval/rb/executor.ts +36 -279
- package/src/export/share.ts +2 -1
- package/src/lsp/diagnostics.ts +516 -0
- package/src/lsp/index.ts +20 -2819
- package/src/lsp/servers.ts +296 -0
- package/src/lsp/tool.ts +1352 -0
- package/src/lsp/workspace-diagnostics.ts +170 -0
- package/src/lsp/writethrough.ts +561 -0
- package/src/mcp/transports/header-policy.ts +1 -1
- package/src/modes/components/agent-hub-projection.ts +2 -2
- package/src/modes/components/agent-hub-renderer.ts +3 -7
- package/src/modes/controllers/command-controller.ts +4 -1
- package/src/modes/theme/color.ts +133 -0
- package/src/modes/theme/loader.ts +178 -0
- package/src/modes/theme/schema.ts +263 -0
- package/src/modes/theme/symbols.ts +1000 -0
- package/src/modes/theme/theme-class.ts +611 -0
- package/src/modes/theme/theme.ts +27 -2453
- package/src/modes/theme/tui-adapters.ts +279 -0
- package/src/registry/agent-registry.ts +2 -2
- package/src/secrets/index.ts +6 -12
- package/src/secrets/message-transform.ts +287 -0
- package/src/secrets/obfuscator.ts +39 -1303
- package/src/secrets/placeholder-scan.ts +506 -0
- package/src/secrets/placeholder.ts +309 -0
- package/src/secrets/replacement.ts +216 -0
- package/src/session/agent-session-types.ts +6 -0
- package/src/session/agent-session.ts +113 -7
- package/src/session/indexed-session-storage.ts +7 -2
- package/src/session/session-advisors.ts +32 -34
- package/src/session/session-handoff.ts +39 -20
- package/src/session/session-manager.ts +67 -3
- package/src/session/session-provider-boundary.ts +3 -6
- package/src/session/turn-recovery.ts +21 -12
- package/src/slash-commands/builtin-collaboration.ts +504 -0
- package/src/slash-commands/builtin-completions.ts +291 -0
- package/src/slash-commands/builtin-control.ts +78 -0
- package/src/slash-commands/builtin-lifecycle.ts +506 -0
- package/src/slash-commands/builtin-marketplace.ts +567 -0
- package/src/slash-commands/builtin-modes.ts +518 -0
- package/src/slash-commands/builtin-registry.ts +21 -3000
- package/src/slash-commands/builtin-session.ts +592 -0
- package/src/task/executor.ts +17 -14
- package/src/tools/gh-common.ts +288 -0
- package/src/tools/gh-pr-checkout.ts +679 -0
- package/src/tools/gh-pr-diff.ts +473 -0
- package/src/tools/gh-run-watch.ts +1015 -0
- package/src/tools/gh-search.ts +500 -0
- package/src/tools/gh-types.ts +379 -0
- package/src/tools/gh-view.ts +612 -0
- package/src/tools/gh.ts +109 -3821
- package/src/tools/read-archive.ts +209 -0
- package/src/tools/read-format.ts +593 -0
- package/src/tools/read-path-resolution.ts +36 -0
- package/src/tools/read-pdf-images.ts +250 -0
- package/src/tools/read-renderer.ts +289 -0
- package/src/tools/read-selector.ts +84 -0
- package/src/tools/read-sqlite.ts +215 -0
- package/src/tools/read-summary.ts +199 -0
- package/src/tools/read.ts +77 -1820
- package/src/tools/shell-tokenize.ts +1 -1
- package/src/utils/changelog.ts +1 -1
- package/src/utils/title-generator.ts +3 -1
- package/src/web/search/providers/zai.ts +12 -3
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import * as fs from "node:fs/promises";
|
|
2
|
+
import * as os from "node:os";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import type { AgentToolResult } from "@oh-my-pi/pi-agent-core";
|
|
5
|
+
import { isEexist, isEnotempty, readImageMetadata, untilAborted } from "@oh-my-pi/pi-utils";
|
|
6
|
+
import type { ToolSession } from "../sdk";
|
|
7
|
+
import { loadImageInput, MAX_IMAGE_INPUT_BYTES, webpExclusionForModel } from "../utils/image-loading";
|
|
8
|
+
import { convertFileWithMarkit } from "../utils/markit";
|
|
9
|
+
import type { ReadToolDetails } from "./read";
|
|
10
|
+
import { prependSuffixResolutionNotice } from "./read-format";
|
|
11
|
+
import { isNotFoundError } from "./read-path-resolution";
|
|
12
|
+
import { formatBytes } from "./render-utils";
|
|
13
|
+
import { ToolError } from "./tool-errors";
|
|
14
|
+
import { toolResult } from "./tool-result";
|
|
15
|
+
|
|
16
|
+
const MAX_IMAGE_SIZE = MAX_IMAGE_INPUT_BYTES;
|
|
17
|
+
|
|
18
|
+
const PDF_IMAGE_PLACEHOLDER_RE = /<!--\s*image:\s*([^\s<>]+)(.*?)-->/g;
|
|
19
|
+
const PDF_IMAGE_MEMBER_RE = /^(.*\.pdf):(.*)$/i;
|
|
20
|
+
const PDF_IMAGE_MEMBER_EXTENSION_RE = /\.png$/i;
|
|
21
|
+
const PDF_IMAGE_CACHE_BASENAME_MAX_LENGTH = 96;
|
|
22
|
+
|
|
23
|
+
interface PdfImageSnapshot {
|
|
24
|
+
directory: string;
|
|
25
|
+
filePath: string;
|
|
26
|
+
digest: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface PdfImageExtraction {
|
|
30
|
+
controller: AbortController;
|
|
31
|
+
promise: Promise<string>;
|
|
32
|
+
settled: boolean;
|
|
33
|
+
waiters: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const pdfImageExtractions = new Map<string, PdfImageExtraction>();
|
|
37
|
+
|
|
38
|
+
function pdfImageMemberPath(pdfPath: string, imageId: string): string {
|
|
39
|
+
const member = PDF_IMAGE_MEMBER_EXTENSION_RE.test(imageId) ? imageId : `${imageId}.png`;
|
|
40
|
+
return `${pdfPath}:${member}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function rewritePdfImagePlaceholders(markdown: string, pdfPath: string): string {
|
|
44
|
+
return markdown.replace(PDF_IMAGE_PLACEHOLDER_RE, (_match: string, imageId: string, metadataText: string) => {
|
|
45
|
+
const metadata = metadataText.trim();
|
|
46
|
+
const suffix = metadata.length > 0 ? ` (${metadata})` : "";
|
|
47
|
+
return `Image ${imageId}${suffix}: read \`${pdfImageMemberPath(pdfPath, imageId)}\``;
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function splitPdfImageMemberReadPath(readPath: string): { pdfPath: string; member: string } | null {
|
|
52
|
+
const match = PDF_IMAGE_MEMBER_RE.exec(readPath);
|
|
53
|
+
if (!match) return null;
|
|
54
|
+
const pdfPath = match[1];
|
|
55
|
+
const member = match[2];
|
|
56
|
+
if (pdfPath === undefined || member === undefined) return null;
|
|
57
|
+
if (member.length !== 0 && !PDF_IMAGE_MEMBER_EXTENSION_RE.test(member)) return null;
|
|
58
|
+
return { pdfPath, member };
|
|
59
|
+
}
|
|
60
|
+
function pdfImageCacheDir(session: ToolSession, absolutePdfPath: string, contentDigest: string): string {
|
|
61
|
+
const artifactsDir = session.getArtifactsDir?.();
|
|
62
|
+
let root = artifactsDir ?? undefined;
|
|
63
|
+
if (root === undefined) {
|
|
64
|
+
const sessionFile = session.getSessionFile();
|
|
65
|
+
root = sessionFile?.endsWith(".jsonl") ? sessionFile.slice(0, -6) : path.join(os.tmpdir(), "omp-read-pdf-images");
|
|
66
|
+
}
|
|
67
|
+
const basename = path
|
|
68
|
+
.basename(absolutePdfPath)
|
|
69
|
+
.replace(/[^A-Za-z0-9._-]/g, "_")
|
|
70
|
+
.slice(0, PDF_IMAGE_CACHE_BASENAME_MAX_LENGTH);
|
|
71
|
+
const pathDigest = Bun.hash(absolutePdfPath).toString(36);
|
|
72
|
+
return path.join(root, "read-pdf-images", `${basename}-${pathDigest}-${contentDigest}`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function snapshotPdfSource(absolutePdfPath: string, signal?: AbortSignal): Promise<PdfImageSnapshot> {
|
|
76
|
+
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "omp-read-pdf-"));
|
|
77
|
+
try {
|
|
78
|
+
const bytes = await untilAborted(signal, () => Bun.file(absolutePdfPath).bytes());
|
|
79
|
+
signal?.throwIfAborted();
|
|
80
|
+
const digest = new Bun.CryptoHasher("sha256").update(bytes).digest("hex");
|
|
81
|
+
const filePath = path.join(directory, "source.pdf");
|
|
82
|
+
await Bun.write(filePath, bytes);
|
|
83
|
+
signal?.throwIfAborted();
|
|
84
|
+
return { directory, filePath, digest };
|
|
85
|
+
} catch (error) {
|
|
86
|
+
await fs.rm(directory, { recursive: true, force: true });
|
|
87
|
+
throw error;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function listPdfImageMembers(imageDir: string): Promise<string[]> {
|
|
92
|
+
try {
|
|
93
|
+
const entries = await fs.readdir(imageDir, { withFileTypes: true });
|
|
94
|
+
const members: string[] = [];
|
|
95
|
+
for (const entry of entries) {
|
|
96
|
+
if (entry.isFile() && PDF_IMAGE_MEMBER_EXTENSION_RE.test(entry.name)) members.push(entry.name);
|
|
97
|
+
}
|
|
98
|
+
return members.sort();
|
|
99
|
+
} catch (error) {
|
|
100
|
+
if (isNotFoundError(error)) return [];
|
|
101
|
+
throw error;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function extractPdfImages(snapshot: PdfImageSnapshot, imageDir: string, signal: AbortSignal): Promise<string> {
|
|
106
|
+
const markerPath = path.join(imageDir, ".extracted");
|
|
107
|
+
try {
|
|
108
|
+
await fs.stat(markerPath);
|
|
109
|
+
return imageDir;
|
|
110
|
+
} catch (error) {
|
|
111
|
+
if (!isNotFoundError(error)) throw error;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
await fs.mkdir(path.dirname(imageDir), { recursive: true });
|
|
115
|
+
const stagingDir = await fs.mkdtemp(`${imageDir}.tmp-`);
|
|
116
|
+
let published = false;
|
|
117
|
+
try {
|
|
118
|
+
const result = await convertFileWithMarkit(snapshot.filePath, signal, { imageDir: stagingDir });
|
|
119
|
+
if (!result.ok) {
|
|
120
|
+
throw new ToolError(`Cannot extract images from PDF: ${result.error ?? "conversion failed"}`);
|
|
121
|
+
}
|
|
122
|
+
await Bun.write(path.join(stagingDir, ".extracted"), "ok");
|
|
123
|
+
try {
|
|
124
|
+
await fs.rename(stagingDir, imageDir);
|
|
125
|
+
published = true;
|
|
126
|
+
} catch (error) {
|
|
127
|
+
if (!isEexist(error) && !isEnotempty(error)) throw error;
|
|
128
|
+
try {
|
|
129
|
+
await fs.stat(markerPath);
|
|
130
|
+
} catch (markerError) {
|
|
131
|
+
if (isNotFoundError(markerError)) throw error;
|
|
132
|
+
throw markerError;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return imageDir;
|
|
136
|
+
} finally {
|
|
137
|
+
if (!published) await fs.rm(stagingDir, { recursive: true, force: true });
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function createPdfImageExtraction(snapshot: PdfImageSnapshot, imageDir: string): PdfImageExtraction {
|
|
142
|
+
const controller = new AbortController();
|
|
143
|
+
const promise = extractPdfImages(snapshot, imageDir, controller.signal).finally(() =>
|
|
144
|
+
fs.rm(snapshot.directory, { recursive: true, force: true }),
|
|
145
|
+
);
|
|
146
|
+
const extraction: PdfImageExtraction = { controller, promise, settled: false, waiters: 0 };
|
|
147
|
+
const settle = () => {
|
|
148
|
+
extraction.settled = true;
|
|
149
|
+
if (pdfImageExtractions.get(imageDir) === extraction) pdfImageExtractions.delete(imageDir);
|
|
150
|
+
};
|
|
151
|
+
void promise.then(settle, settle);
|
|
152
|
+
return extraction;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function waitForPdfImageExtraction(
|
|
156
|
+
extraction: PdfImageExtraction,
|
|
157
|
+
signal: AbortSignal | undefined,
|
|
158
|
+
): Promise<string> {
|
|
159
|
+
extraction.waiters++;
|
|
160
|
+
try {
|
|
161
|
+
return await untilAborted(signal, extraction.promise);
|
|
162
|
+
} finally {
|
|
163
|
+
extraction.waiters--;
|
|
164
|
+
if (extraction.waiters === 0 && !extraction.settled) {
|
|
165
|
+
extraction.controller.abort();
|
|
166
|
+
try {
|
|
167
|
+
await extraction.promise;
|
|
168
|
+
} catch {}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function ensurePdfImageCache(
|
|
174
|
+
session: ToolSession,
|
|
175
|
+
absolutePdfPath: string,
|
|
176
|
+
signal?: AbortSignal,
|
|
177
|
+
): Promise<string> {
|
|
178
|
+
const snapshot = await snapshotPdfSource(absolutePdfPath, signal);
|
|
179
|
+
const imageDir = pdfImageCacheDir(session, absolutePdfPath, snapshot.digest);
|
|
180
|
+
const existing = pdfImageExtractions.get(imageDir);
|
|
181
|
+
if (existing && !existing.settled && !existing.controller.signal.aborted) {
|
|
182
|
+
await fs.rm(snapshot.directory, { recursive: true, force: true });
|
|
183
|
+
return waitForPdfImageExtraction(existing, signal);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const extraction = createPdfImageExtraction(snapshot, imageDir);
|
|
187
|
+
pdfImageExtractions.set(imageDir, extraction);
|
|
188
|
+
return waitForPdfImageExtraction(extraction, signal);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export async function readPdfImageMember(
|
|
192
|
+
session: ToolSession,
|
|
193
|
+
autoResizeImages: boolean,
|
|
194
|
+
absolutePdfPath: string,
|
|
195
|
+
pdfDisplayPath: string,
|
|
196
|
+
member: string,
|
|
197
|
+
suffixResolution: { from: string; to: string } | undefined,
|
|
198
|
+
signal?: AbortSignal,
|
|
199
|
+
): Promise<AgentToolResult<ReadToolDetails>> {
|
|
200
|
+
const imageDir = await ensurePdfImageCache(session, absolutePdfPath, signal);
|
|
201
|
+
const members = await listPdfImageMembers(imageDir);
|
|
202
|
+
if (member.length === 0) {
|
|
203
|
+
const text =
|
|
204
|
+
members.length === 0
|
|
205
|
+
? "No extractable PDF image members found."
|
|
206
|
+
: `Extractable PDF image members:\n${members
|
|
207
|
+
.map(imageMember => `- read \`${pdfDisplayPath}:${imageMember}\``)
|
|
208
|
+
.join("\n")}`;
|
|
209
|
+
return toolResult<ReadToolDetails>({ resolvedPath: absolutePdfPath, suffixResolution })
|
|
210
|
+
.text(prependSuffixResolutionNotice(text, suffixResolution))
|
|
211
|
+
.sourcePath(absolutePdfPath)
|
|
212
|
+
.done();
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (!members.includes(member)) {
|
|
216
|
+
const available = members.length === 0 ? "(none)" : members.join(", ");
|
|
217
|
+
throw new ToolError(`PDF image member '${member}' not found. Available members: ${available}`);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const imagePath = path.join(imageDir, member);
|
|
221
|
+
const imageStat = await Bun.file(imagePath).stat();
|
|
222
|
+
if (imageStat.size > MAX_IMAGE_SIZE) {
|
|
223
|
+
const sizeStr = formatBytes(imageStat.size);
|
|
224
|
+
const maxStr = formatBytes(MAX_IMAGE_SIZE);
|
|
225
|
+
throw new ToolError(`Image file too large: ${sizeStr} exceeds ${maxStr} limit.`);
|
|
226
|
+
}
|
|
227
|
+
const metadata = await readImageMetadata(imagePath);
|
|
228
|
+
const mimeType = metadata?.mimeType;
|
|
229
|
+
if (!mimeType) throw new ToolError(`PDF image member '${member}' is not a supported image.`);
|
|
230
|
+
const imageInput = await loadImageInput({
|
|
231
|
+
path: `${pdfDisplayPath}:${member}`,
|
|
232
|
+
cwd: session.cwd,
|
|
233
|
+
autoResize: autoResizeImages,
|
|
234
|
+
maxBytes: MAX_IMAGE_SIZE,
|
|
235
|
+
resolvedPath: imagePath,
|
|
236
|
+
detectedMimeType: mimeType,
|
|
237
|
+
excludeWebP: webpExclusionForModel(session.getActiveModel?.()),
|
|
238
|
+
});
|
|
239
|
+
if (!imageInput) {
|
|
240
|
+
throw new ToolError(`Read image file [${mimeType}] failed: unsupported image format.`);
|
|
241
|
+
}
|
|
242
|
+
const textNote = prependSuffixResolutionNotice(imageInput.textNote, suffixResolution);
|
|
243
|
+
return toolResult<ReadToolDetails>({ resolvedPath: absolutePdfPath, suffixResolution })
|
|
244
|
+
.content([
|
|
245
|
+
{ type: "text", text: textNote },
|
|
246
|
+
{ type: "image", data: imageInput.data, mimeType: imageInput.mimeType },
|
|
247
|
+
])
|
|
248
|
+
.sourcePath(imageInput.resolvedPath)
|
|
249
|
+
.done();
|
|
250
|
+
}
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
import * as path from "node:path";
|
|
2
|
+
import type { Component } from "@oh-my-pi/pi-tui";
|
|
3
|
+
import { Text } from "@oh-my-pi/pi-tui";
|
|
4
|
+
import type { RenderResultOptions } from "../extensibility/custom-tools/types";
|
|
5
|
+
import { getLanguageFromPath, type Theme } from "../modes/theme/theme";
|
|
6
|
+
import { fileHyperlink, renderCodeCell, renderMarkdownCell, renderStatusLine, tryResolveInternalUrlSync } from "../tui";
|
|
7
|
+
import { CachedOutputBlock, markFramedBlockComponent } from "../tui/output-block";
|
|
8
|
+
import { type ReadUrlToolDetails, renderReadUrlCall, renderReadUrlResult } from "./fetch";
|
|
9
|
+
import { formatFullOutputReference, formatStyledTruncationWarning, stripOutputNotice } from "./output-meta";
|
|
10
|
+
import { isReadableUrlPath, splitInternalUrlSel, splitPathAndSel } from "./path-utils";
|
|
11
|
+
import type { ReadToolDetails } from "./read";
|
|
12
|
+
import { isRawSelector, parseSel } from "./read-selector";
|
|
13
|
+
import { formatBytes, replaceTabs, shortenPath, wrapBrackets } from "./render-utils";
|
|
14
|
+
|
|
15
|
+
// =============================================================================
|
|
16
|
+
// TUI Renderer
|
|
17
|
+
// =============================================================================
|
|
18
|
+
|
|
19
|
+
interface ReadRenderArgs {
|
|
20
|
+
path?: unknown;
|
|
21
|
+
file_path?: unknown;
|
|
22
|
+
// Legacy fields from old schema — tolerated for in-flight tool calls during transition
|
|
23
|
+
offset?: number;
|
|
24
|
+
limit?: number;
|
|
25
|
+
raw?: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const INTERNAL_URL_LIKE_RE = /^[a-z][a-z0-9+.-]*:\/\//i;
|
|
29
|
+
|
|
30
|
+
function splitReadRenderPath(rawPath: string): { path: string; sel?: string } {
|
|
31
|
+
if (INTERNAL_URL_LIKE_RE.test(rawPath)) {
|
|
32
|
+
const internal = splitInternalUrlSel(rawPath);
|
|
33
|
+
if (internal.sel) return internal;
|
|
34
|
+
}
|
|
35
|
+
return splitPathAndSel(rawPath);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function firstReadSelectorLine(sel: string | undefined): number | undefined {
|
|
39
|
+
if (!sel) return undefined;
|
|
40
|
+
try {
|
|
41
|
+
const parsed = parseSel(sel);
|
|
42
|
+
if (parsed.kind !== "lines") return undefined;
|
|
43
|
+
return parsed.ranges[0].startLine;
|
|
44
|
+
} catch {
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Absolute fs path the read result actually resolved to, used as the OSC 8 link
|
|
50
|
+
* target when the structured `resolvedPath` isn't set (the common plain-file and
|
|
51
|
+
* image reads only record the path in `meta.source`). URL/internal sources are
|
|
52
|
+
* not fs paths, so only `type: "path"` qualifies. */
|
|
53
|
+
function readSourceFsPath(details: ReadToolDetails | undefined): string | undefined {
|
|
54
|
+
const source = details?.meta?.source;
|
|
55
|
+
return source?.type === "path" ? source.value : undefined;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function formatReadPathLink(
|
|
59
|
+
rawPath: string,
|
|
60
|
+
options: {
|
|
61
|
+
resolvedPath?: string;
|
|
62
|
+
sourcePath?: string;
|
|
63
|
+
suffixResolution?: { from: string; to: string };
|
|
64
|
+
offset?: number;
|
|
65
|
+
fallbackLabel?: string;
|
|
66
|
+
},
|
|
67
|
+
): string {
|
|
68
|
+
const split = splitReadRenderPath(rawPath);
|
|
69
|
+
const basePath = split.path || rawPath;
|
|
70
|
+
const selectorSuffix = split.sel ? `:${split.sel}` : "";
|
|
71
|
+
const plainDisplayPath = options.suffixResolution
|
|
72
|
+
? shortenPath(options.suffixResolution.to)
|
|
73
|
+
: shortenPath(basePath || options.resolvedPath || options.fallbackLabel || rawPath);
|
|
74
|
+
const absoluteInputPath = path.isAbsolute(basePath) ? basePath : undefined;
|
|
75
|
+
const target =
|
|
76
|
+
options.resolvedPath ?? options.sourcePath ?? tryResolveInternalUrlSync(basePath) ?? absoluteInputPath;
|
|
77
|
+
const line = firstReadSelectorLine(split.sel) ?? options.offset;
|
|
78
|
+
const linkOptions = line !== undefined ? { line } : undefined;
|
|
79
|
+
const linkedPath = target ? fileHyperlink(target, plainDisplayPath, linkOptions) : plainDisplayPath;
|
|
80
|
+
return `${linkedPath}${selectorSuffix}`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export const readToolRenderer = {
|
|
84
|
+
renderCall(args: ReadRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component {
|
|
85
|
+
const rawPath =
|
|
86
|
+
typeof args.file_path === "string" ? args.file_path : typeof args.path === "string" ? args.path : "";
|
|
87
|
+
if (isReadableUrlPath(rawPath)) {
|
|
88
|
+
return renderReadUrlCall({ path: rawPath, raw: args.raw }, _options, uiTheme);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const offset = args.offset;
|
|
92
|
+
const limit = args.limit;
|
|
93
|
+
|
|
94
|
+
let pathDisplay = formatReadPathLink(rawPath, { offset, fallbackLabel: "…" }) || "…";
|
|
95
|
+
if (offset !== undefined || limit !== undefined) {
|
|
96
|
+
const startLine = offset ?? 1;
|
|
97
|
+
const endLine = limit !== undefined ? startLine + limit - 1 : "";
|
|
98
|
+
pathDisplay += `:${startLine}${endLine ? `-${endLine}` : ""}`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const text = renderStatusLine({ icon: "pending", title: "Read", description: pathDisplay }, uiTheme);
|
|
102
|
+
return new Text(text, 0, 0);
|
|
103
|
+
},
|
|
104
|
+
|
|
105
|
+
renderResult(
|
|
106
|
+
result: { content: Array<{ type: string; text?: string }>; details?: ReadToolDetails; isError?: boolean },
|
|
107
|
+
options: RenderResultOptions,
|
|
108
|
+
uiTheme: Theme,
|
|
109
|
+
args?: ReadRenderArgs,
|
|
110
|
+
): Component {
|
|
111
|
+
const urlDetails = result.details as ReadUrlToolDetails | undefined;
|
|
112
|
+
const baseRawPathForKind =
|
|
113
|
+
typeof args?.file_path === "string" ? args.file_path : typeof args?.path === "string" ? args.path : "";
|
|
114
|
+
if (urlDetails?.kind === "url" || isReadableUrlPath(baseRawPathForKind)) {
|
|
115
|
+
return renderReadUrlResult(
|
|
116
|
+
result as {
|
|
117
|
+
content: Array<{ type: string; text?: string }>;
|
|
118
|
+
details?: ReadUrlToolDetails;
|
|
119
|
+
isError?: boolean;
|
|
120
|
+
},
|
|
121
|
+
options,
|
|
122
|
+
uiTheme,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (result.isError) {
|
|
127
|
+
const rawErrorText = result.content?.find(c => c.type === "text")?.text ?? "";
|
|
128
|
+
const errorText = (rawErrorText || "Unknown error").replace(/^Error:\s*/, "");
|
|
129
|
+
const rawPath =
|
|
130
|
+
typeof args?.file_path === "string" ? args.file_path : typeof args?.path === "string" ? args.path : "";
|
|
131
|
+
const filePath =
|
|
132
|
+
formatReadPathLink(rawPath, { offset: args?.offset, sourcePath: readSourceFsPath(result.details) }) ||
|
|
133
|
+
shortenPath(rawPath);
|
|
134
|
+
let title = filePath ? `Read ${filePath}` : "Read";
|
|
135
|
+
if (args?.offset !== undefined || args?.limit !== undefined) {
|
|
136
|
+
const startLine = args.offset ?? 1;
|
|
137
|
+
const endLine = args.limit !== undefined ? startLine + args.limit - 1 : "";
|
|
138
|
+
title += `:${startLine}${endLine ? `-${endLine}` : ""}`;
|
|
139
|
+
}
|
|
140
|
+
const header = renderStatusLine({ icon: "error", title }, uiTheme);
|
|
141
|
+
const errorLines = errorText.split("\n").map(line => uiTheme.fg("error", replaceTabs(line)));
|
|
142
|
+
const outputBlock = new CachedOutputBlock();
|
|
143
|
+
return markFramedBlockComponent({
|
|
144
|
+
render: (width: number) =>
|
|
145
|
+
outputBlock.render({ header, state: "error", sections: [{ lines: errorLines }], width }, uiTheme),
|
|
146
|
+
invalidate: () => outputBlock.invalidate(),
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
const details = result.details;
|
|
150
|
+
const rawText = result.content?.find(c => c.type === "text")?.text ?? "";
|
|
151
|
+
// Prefer structured `displayContent` from details when available so the TUI
|
|
152
|
+
// shows clean file content (no model-only hashline anchors) without parsing the formatted text.
|
|
153
|
+
// Fall back to the raw text, but strip the LLM-facing notice so it doesn't
|
|
154
|
+
// echo next to the styled warning line below.
|
|
155
|
+
const contentText = details?.displayContent?.text ?? stripOutputNotice(rawText, details?.meta);
|
|
156
|
+
const imageContent = result.content?.find(c => c.type === "image");
|
|
157
|
+
const rawPath =
|
|
158
|
+
typeof args?.file_path === "string" ? args.file_path : typeof args?.path === "string" ? args.path : "";
|
|
159
|
+
const renderPath = splitReadRenderPath(rawPath);
|
|
160
|
+
const lang = getLanguageFromPath(renderPath.path);
|
|
161
|
+
|
|
162
|
+
const warningLines: string[] = [];
|
|
163
|
+
const truncation = details?.meta?.truncation;
|
|
164
|
+
const fallback = details?.truncation;
|
|
165
|
+
if (details?.resolvedPath) {
|
|
166
|
+
warningLines.push(uiTheme.fg("dim", wrapBrackets(`Resolved path: ${details.resolvedPath}`, uiTheme)));
|
|
167
|
+
}
|
|
168
|
+
if (truncation) {
|
|
169
|
+
if (fallback?.firstLineExceedsLimit) {
|
|
170
|
+
let warning = `First line exceeds ${formatBytes(fallback.outputBytes ?? fallback.totalBytes)} limit`;
|
|
171
|
+
if (truncation.artifactId) {
|
|
172
|
+
warning += `. ${formatFullOutputReference(truncation.artifactId)}`;
|
|
173
|
+
}
|
|
174
|
+
warningLines.push(uiTheme.fg("warning", wrapBrackets(warning, uiTheme)));
|
|
175
|
+
} else {
|
|
176
|
+
const warning = formatStyledTruncationWarning(details?.meta, uiTheme);
|
|
177
|
+
if (warning) warningLines.push(warning);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (imageContent) {
|
|
182
|
+
const suffix = details?.suffixResolution;
|
|
183
|
+
const displayPath = formatReadPathLink(rawPath, {
|
|
184
|
+
resolvedPath: details?.resolvedPath,
|
|
185
|
+
sourcePath: readSourceFsPath(details),
|
|
186
|
+
suffixResolution: suffix,
|
|
187
|
+
fallbackLabel: "image",
|
|
188
|
+
});
|
|
189
|
+
const correction = suffix ? ` ${uiTheme.fg("dim", `(corrected from ${shortenPath(suffix.from)})`)}` : "";
|
|
190
|
+
const header = renderStatusLine(
|
|
191
|
+
{ icon: suffix ? "warning" : "success", title: "Read", description: `${displayPath}${correction}` },
|
|
192
|
+
uiTheme,
|
|
193
|
+
);
|
|
194
|
+
const detailLines = contentText ? contentText.split("\n").map(line => uiTheme.fg("toolOutput", line)) : [];
|
|
195
|
+
const lines = [...detailLines, ...warningLines];
|
|
196
|
+
const outputBlock = new CachedOutputBlock();
|
|
197
|
+
return markFramedBlockComponent({
|
|
198
|
+
render: (width: number) =>
|
|
199
|
+
outputBlock.render(
|
|
200
|
+
{
|
|
201
|
+
header,
|
|
202
|
+
state: "success",
|
|
203
|
+
sections: [
|
|
204
|
+
{
|
|
205
|
+
label: uiTheme.fg("toolTitle", "Details"),
|
|
206
|
+
lines: lines.length > 0 ? lines : [uiTheme.fg("dim", "(image)")],
|
|
207
|
+
},
|
|
208
|
+
],
|
|
209
|
+
width,
|
|
210
|
+
},
|
|
211
|
+
uiTheme,
|
|
212
|
+
),
|
|
213
|
+
invalidate: () => outputBlock.invalidate(),
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const suffix = details?.suffixResolution;
|
|
218
|
+
// resolvedPath is the absolute fs path when a read resolved/corrected the
|
|
219
|
+
// input (suffix match, internal URL, archive/sqlite/notebook); plain file
|
|
220
|
+
// reads only record the absolute path in meta.source, so fall back to that
|
|
221
|
+
// (and then to a sync internal-URL resolver) to keep the title clickable.
|
|
222
|
+
const displayPath = formatReadPathLink(rawPath, {
|
|
223
|
+
resolvedPath: details?.resolvedPath,
|
|
224
|
+
sourcePath: readSourceFsPath(details),
|
|
225
|
+
suffixResolution: suffix,
|
|
226
|
+
offset: args?.offset,
|
|
227
|
+
});
|
|
228
|
+
const correction = suffix ? ` ${uiTheme.fg("dim", `(corrected from ${shortenPath(suffix.from)})`)}` : "";
|
|
229
|
+
let title = displayPath ? `Read ${displayPath}${correction}` : "Read";
|
|
230
|
+
if (args?.offset !== undefined || args?.limit !== undefined) {
|
|
231
|
+
const startLine = args.offset ?? 1;
|
|
232
|
+
const endLine = args.limit !== undefined ? startLine + args.limit - 1 : "";
|
|
233
|
+
title += `:${startLine}${endLine ? `-${endLine}` : ""}`;
|
|
234
|
+
}
|
|
235
|
+
if (details?.summary) {
|
|
236
|
+
title += ` (summary: ${details.summary.elidedSpans} elided span${details.summary.elidedSpans === 1 ? "" : "s"})`;
|
|
237
|
+
}
|
|
238
|
+
if (details?.conflictCount && details.conflictCount > 0) {
|
|
239
|
+
const n = details.conflictCount;
|
|
240
|
+
title += ` ${uiTheme.fg("warning", `(⚠ ${n} conflict${n === 1 ? "" : "s"})`)}`;
|
|
241
|
+
}
|
|
242
|
+
const rawRequested = args?.raw === true || isRawSelector(parseSel(renderPath.sel));
|
|
243
|
+
const isMarkdown = details?.contentType === "text/markdown" && !rawRequested;
|
|
244
|
+
let cachedWidth: number | undefined;
|
|
245
|
+
let cachedExpanded: boolean | undefined;
|
|
246
|
+
let cachedLines: string[] | undefined;
|
|
247
|
+
return markFramedBlockComponent({
|
|
248
|
+
render: (width: number) => {
|
|
249
|
+
const expanded = options.expanded;
|
|
250
|
+
if (cachedLines && cachedWidth === width && cachedExpanded === expanded) return cachedLines;
|
|
251
|
+
cachedLines = isMarkdown
|
|
252
|
+
? renderMarkdownCell(
|
|
253
|
+
{
|
|
254
|
+
content: contentText,
|
|
255
|
+
title,
|
|
256
|
+
status: "complete",
|
|
257
|
+
output: warningLines.length > 0 ? warningLines.join("\n") : undefined,
|
|
258
|
+
expanded,
|
|
259
|
+
width,
|
|
260
|
+
},
|
|
261
|
+
uiTheme,
|
|
262
|
+
)
|
|
263
|
+
: renderCodeCell(
|
|
264
|
+
{
|
|
265
|
+
code: contentText,
|
|
266
|
+
language: lang,
|
|
267
|
+
title,
|
|
268
|
+
status: "complete",
|
|
269
|
+
output: warningLines.length > 0 ? warningLines.join("\n") : undefined,
|
|
270
|
+
expanded,
|
|
271
|
+
codeStartLine: details?.displayContent?.startLine,
|
|
272
|
+
codeLineNumbers: details?.displayContent?.lineNumbers,
|
|
273
|
+
width,
|
|
274
|
+
},
|
|
275
|
+
uiTheme,
|
|
276
|
+
);
|
|
277
|
+
cachedWidth = width;
|
|
278
|
+
cachedExpanded = expanded;
|
|
279
|
+
return cachedLines;
|
|
280
|
+
},
|
|
281
|
+
invalidate: () => {
|
|
282
|
+
cachedWidth = undefined;
|
|
283
|
+
cachedExpanded = undefined;
|
|
284
|
+
cachedLines = undefined;
|
|
285
|
+
},
|
|
286
|
+
});
|
|
287
|
+
},
|
|
288
|
+
mergeCallAndResult: true,
|
|
289
|
+
};
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import type { LineRange } from "./path-utils";
|
|
2
|
+
import { parseLineRanges } from "./path-utils";
|
|
3
|
+
import { ToolError } from "./tool-errors";
|
|
4
|
+
/** Parsed representation of a path-embedded selector. */
|
|
5
|
+
export type ParsedSelector =
|
|
6
|
+
| { kind: "none" }
|
|
7
|
+
| { kind: "raw" }
|
|
8
|
+
| { kind: "conflicts" }
|
|
9
|
+
| { kind: "lines"; ranges: [LineRange, ...LineRange[]]; raw?: boolean };
|
|
10
|
+
|
|
11
|
+
/** Returns true when the selector requested verbatim/raw output (alone or combined with a range). */
|
|
12
|
+
export function isRawSelector(parsed: ParsedSelector): boolean {
|
|
13
|
+
return parsed.kind === "raw" || (parsed.kind === "lines" && parsed.raw === true);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Returns true when the selector requested multiple line ranges. */
|
|
17
|
+
export function isMultiRange(parsed: ParsedSelector): boolean {
|
|
18
|
+
return parsed.kind === "lines" && parsed.ranges.length > 1;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function selectorChunkLooksReadLike(chunk: string): boolean {
|
|
22
|
+
const lower = chunk.toLowerCase();
|
|
23
|
+
return (
|
|
24
|
+
lower === "raw" || lower === "conflicts" || /^-\d+(?:[-+]\d+)?$/.test(chunk) || parseLineRanges(chunk) !== null
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function invalidSelector(sel: string): ToolError {
|
|
29
|
+
return new ToolError(
|
|
30
|
+
`Invalid selector ':${sel}'. Use :N, :N-M, :N+K, :N- (open-ended), a comma-separated list of ranges, :raw, or a range combined with raw (e.g. :raw:50-100).`,
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function parseSel(sel: string | undefined): ParsedSelector {
|
|
35
|
+
if (!sel || sel.length === 0) return { kind: "none" };
|
|
36
|
+
|
|
37
|
+
// Compound selector: `1-50:raw` or `raw:1-50`. Split into chunks and accept
|
|
38
|
+
// exactly one line range (possibly multi) plus the literal `raw`. Selector-like
|
|
39
|
+
// compounds that are not in that accepted set are invalid rather than "none";
|
|
40
|
+
// otherwise `read` can silently widen a malformed selector like
|
|
41
|
+
// `artifact://5:conflicts:1-1` while `grep` rejects it.
|
|
42
|
+
if (sel.includes(":")) {
|
|
43
|
+
const chunks = sel.split(":");
|
|
44
|
+
if (chunks.length === 2) {
|
|
45
|
+
const [a, b] = chunks as [string, string];
|
|
46
|
+
const aIsRaw = a.toLowerCase() === "raw";
|
|
47
|
+
const bIsRaw = b.toLowerCase() === "raw";
|
|
48
|
+
const rangeChunk = aIsRaw ? b : bIsRaw ? a : null;
|
|
49
|
+
const rawChunk = aIsRaw ? a : bIsRaw ? b : null;
|
|
50
|
+
if (rangeChunk !== null && rawChunk !== null) {
|
|
51
|
+
const ranges = parseLineRanges(rangeChunk);
|
|
52
|
+
if (ranges) {
|
|
53
|
+
return { kind: "lines", ranges, raw: true };
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
if (chunks.every(selectorChunkLooksReadLike)) throw invalidSelector(sel);
|
|
58
|
+
// Unrecognized compound — fall through (sqlite/archive/url consume their own colon syntax).
|
|
59
|
+
return { kind: "none" };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (sel.toLowerCase() === "raw") return { kind: "raw" };
|
|
63
|
+
if (sel.toLowerCase() === "conflicts") return { kind: "conflicts" };
|
|
64
|
+
const ranges = parseLineRanges(sel);
|
|
65
|
+
if (ranges) {
|
|
66
|
+
return { kind: "lines", ranges };
|
|
67
|
+
}
|
|
68
|
+
// Unrecognized selectors fall through; sqlite/archive/url readers consume their own colon syntax.
|
|
69
|
+
return { kind: "none" };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Convert a single-range selector to the offset/limit pair used by internal pagination.
|
|
74
|
+
* Returns the FIRST range only — multi-range callers MUST branch on `isMultiRange` before
|
|
75
|
+
* calling this helper.
|
|
76
|
+
*/
|
|
77
|
+
export function selToOffsetLimit(parsed: ParsedSelector): { offset?: number; limit?: number } {
|
|
78
|
+
if (parsed.kind === "lines") {
|
|
79
|
+
const first = parsed.ranges[0];
|
|
80
|
+
const limit = first.endLine !== undefined ? first.endLine - first.startLine + 1 : undefined;
|
|
81
|
+
return { offset: first.startLine, limit };
|
|
82
|
+
}
|
|
83
|
+
return {};
|
|
84
|
+
}
|