pi-quiver 4.1.0 → 4.2.0
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 +8 -0
- package/README.md +24 -1
- package/dist/bin/pi-quiver.js +578 -0
- package/extensions/fetch.ts +6 -570
- package/extensions/session-name.ts +13 -1
- package/lib/fetch-core.ts +589 -0
- package/package.json +12 -5
package/extensions/fetch.ts
CHANGED
|
@@ -10,448 +10,11 @@
|
|
|
10
10
|
* are capped at 1 MB; binary downloads at 50 MB.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import { mkdirSync, writeFileSync, createWriteStream } from "node:fs";
|
|
14
|
-
import { rm } from "node:fs/promises";
|
|
15
|
-
import { tmpdir } from "node:os";
|
|
16
|
-
import { join } from "node:path";
|
|
17
|
-
import { createHash } from "node:crypto";
|
|
18
|
-
import { execFile } from "node:child_process";
|
|
19
|
-
import { promisify } from "node:util";
|
|
20
13
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
21
14
|
import { formatSize, keyHint } from "@earendil-works/pi-coding-agent";
|
|
22
15
|
import { Text } from "@earendil-works/pi-tui";
|
|
23
16
|
import { Type } from "@sinclair/typebox";
|
|
24
|
-
import {
|
|
25
|
-
import { Readability } from "@mozilla/readability";
|
|
26
|
-
import TurndownService from "turndown";
|
|
27
|
-
import { gfm } from "turndown-plugin-gfm";
|
|
28
|
-
|
|
29
|
-
interface FetchToolDetails {
|
|
30
|
-
url?: string;
|
|
31
|
-
status?: number;
|
|
32
|
-
contentType?: string;
|
|
33
|
-
charset?: string;
|
|
34
|
-
bytes?: number;
|
|
35
|
-
truncated?: boolean;
|
|
36
|
-
category?: "binary" | "markdown" | "json" | "text";
|
|
37
|
-
spilled?: boolean;
|
|
38
|
-
file?: string;
|
|
39
|
-
lines?: number;
|
|
40
|
-
via?: "gh";
|
|
41
|
-
ghCommand?: string;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
type GhTarget =
|
|
45
|
-
| { kind: "issue"; url: string }
|
|
46
|
-
| { kind: "pr"; url: string }
|
|
47
|
-
| { kind: "repo"; slug: string }
|
|
48
|
-
| { kind: "run"; slug: string; runId: string; url: string };
|
|
49
|
-
|
|
50
|
-
const RESERVED_OWNERS = new Set([
|
|
51
|
-
"orgs", "users", "sponsors", "topics", "marketplace", "apps",
|
|
52
|
-
"collections", "stars", "settings", "notifications", "codespaces",
|
|
53
|
-
"features", "trending", "security", "customer-stories",
|
|
54
|
-
]);
|
|
55
|
-
const GH_NAME = /^[A-Za-z0-9._-]+$/;
|
|
56
|
-
|
|
57
|
-
export function classifyGitHubTarget(url: URL): GhTarget | null {
|
|
58
|
-
const host = url.hostname.toLowerCase();
|
|
59
|
-
if (host !== "github.com" && host !== "www.github.com") return null;
|
|
60
|
-
const segs = url.pathname.split("/").filter((s) => s.length > 0);
|
|
61
|
-
if (segs.length < 2) return null;
|
|
62
|
-
const [owner, repo] = segs;
|
|
63
|
-
if (!GH_NAME.test(owner) || !GH_NAME.test(repo)) return null;
|
|
64
|
-
if (RESERVED_OWNERS.has(owner.toLowerCase())) return null;
|
|
65
|
-
if (segs.length === 4 && segs[2] === "issues" && /^\d+$/.test(segs[3])) {
|
|
66
|
-
return { kind: "issue", url: `https://github.com/${owner}/${repo}/issues/${segs[3]}` };
|
|
67
|
-
}
|
|
68
|
-
if (segs.length === 4 && segs[2] === "pull" && /^\d+$/.test(segs[3])) {
|
|
69
|
-
return { kind: "pr", url: `https://github.com/${owner}/${repo}/pull/${segs[3]}` };
|
|
70
|
-
}
|
|
71
|
-
if (segs.length === 5 && segs[2] === "actions" && segs[3] === "runs" && /^\d+$/.test(segs[4])) {
|
|
72
|
-
return { kind: "run", slug: `${owner}/${repo}`, runId: segs[4], url: `https://github.com/${owner}/${repo}/actions/runs/${segs[4]}` };
|
|
73
|
-
}
|
|
74
|
-
if (segs.length === 2) {
|
|
75
|
-
return { kind: "repo", slug: `${owner}/${repo}` };
|
|
76
|
-
}
|
|
77
|
-
return null;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
export function buildGhArgs(target: GhTarget): string[] {
|
|
81
|
-
if (target.kind === "issue") return ["issue", "view", target.url, "--comments"];
|
|
82
|
-
if (target.kind === "pr") return ["pr", "view", target.url, "--comments"];
|
|
83
|
-
if (target.kind === "run") return ["run", "view", target.runId, "--repo", target.slug];
|
|
84
|
-
return ["repo", "view", target.slug];
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
const GH_MAX_BUFFER = 10_000_000; // 10 MB — an order above PARSABLE_MAX_BYTES
|
|
88
|
-
|
|
89
|
-
type GhResult = { ok: true; stdout: string } | { ok: false };
|
|
90
|
-
export type GhRunner = (args: string[], timeoutMs: number, signal?: AbortSignal) => Promise<GhResult>;
|
|
91
|
-
|
|
92
|
-
const execFileAsync = promisify(execFile);
|
|
93
|
-
|
|
94
|
-
export const runGh: GhRunner = async (args, timeoutMs, signal) => {
|
|
95
|
-
try {
|
|
96
|
-
const { stdout } = await execFileAsync("gh", args, {
|
|
97
|
-
timeout: timeoutMs,
|
|
98
|
-
signal,
|
|
99
|
-
maxBuffer: GH_MAX_BUFFER,
|
|
100
|
-
encoding: "utf8",
|
|
101
|
-
});
|
|
102
|
-
if (!stdout.trim()) return { ok: false };
|
|
103
|
-
return { ok: true, stdout };
|
|
104
|
-
} catch {
|
|
105
|
-
return { ok: false };
|
|
106
|
-
}
|
|
107
|
-
};
|
|
108
|
-
|
|
109
|
-
interface GhRoutingParams {
|
|
110
|
-
raw?: boolean;
|
|
111
|
-
method?: string;
|
|
112
|
-
body?: string;
|
|
113
|
-
headers?: Record<string, string>;
|
|
114
|
-
timeoutMs?: number;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
export function planGhRouting(params: GhRoutingParams, url: URL): GhTarget | null {
|
|
118
|
-
if (params.raw) return null;
|
|
119
|
-
if ((params.method ?? "GET") !== "GET") return null;
|
|
120
|
-
if (params.body) return null;
|
|
121
|
-
if (params.headers && Object.keys(params.headers).length > 0) return null;
|
|
122
|
-
return classifyGitHubTarget(url);
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
function ghCommandLabel(target: GhTarget): string {
|
|
126
|
-
if (target.kind === "issue") return "issue view --comments";
|
|
127
|
-
if (target.kind === "pr") return "pr view --comments";
|
|
128
|
-
if (target.kind === "run") return "run view";
|
|
129
|
-
return "repo view";
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
function ghSourceLine(target: GhTarget, ref: string): string {
|
|
133
|
-
if (target.kind === "issue") return `gh issue view ${ref} --comments`;
|
|
134
|
-
if (target.kind === "pr") return `gh pr view ${ref} --comments`;
|
|
135
|
-
if (target.kind === "run") return `gh run view ${target.runId} --repo ${target.slug}`;
|
|
136
|
-
return `gh repo view ${ref}`;
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
function renderGhResult(target: GhTarget, stdout: string): { content: { type: "text"; text: string }[]; details: FetchToolDetails } {
|
|
140
|
-
const body = stdout.trimEnd();
|
|
141
|
-
const ref = target.kind === "repo" ? target.slug : target.url;
|
|
142
|
-
const { spill, bytes, lines } = applyGate(body);
|
|
143
|
-
const baseDetails: FetchToolDetails = {
|
|
144
|
-
url: ref,
|
|
145
|
-
bytes,
|
|
146
|
-
lines,
|
|
147
|
-
category: "markdown",
|
|
148
|
-
via: "gh",
|
|
149
|
-
ghCommand: ghCommandLabel(target),
|
|
150
|
-
};
|
|
151
|
-
const source = `Source: ${ghSourceLine(target, ref)}`;
|
|
152
|
-
if (!spill) {
|
|
153
|
-
return {
|
|
154
|
-
content: [{ type: "text", text: [source, "", body].join("\n") }],
|
|
155
|
-
details: { ...baseDetails, spilled: false },
|
|
156
|
-
};
|
|
157
|
-
}
|
|
158
|
-
const spillUrl = target.kind === "repo" ? `https://github.com/${target.slug}` : target.url;
|
|
159
|
-
const file = spillToFile(spillUrl, body, "md");
|
|
160
|
-
return {
|
|
161
|
-
content: [{
|
|
162
|
-
type: "text",
|
|
163
|
-
text: [
|
|
164
|
-
source,
|
|
165
|
-
`Body: ${formatSize(bytes)} across ${lines} lines — written to file (too large to inline)`,
|
|
166
|
-
`Saved-To: ${file}`,
|
|
167
|
-
"",
|
|
168
|
-
"Read slices of this file with the read tool (offset/limit) or grep it; do not read the whole file unless you must. Markdown is grep-able by heading (^#).",
|
|
169
|
-
"",
|
|
170
|
-
`----- preview (first ${PREVIEW_LINES} lines) -----`,
|
|
171
|
-
buildPreview(body),
|
|
172
|
-
].join("\n"),
|
|
173
|
-
}],
|
|
174
|
-
details: { ...baseDetails, spilled: true, file },
|
|
175
|
-
};
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
export async function executeGhRouting(
|
|
179
|
-
params: GhRoutingParams,
|
|
180
|
-
url: URL,
|
|
181
|
-
signal: AbortSignal | undefined,
|
|
182
|
-
runner: GhRunner = runGh,
|
|
183
|
-
): Promise<{ content: { type: "text"; text: string }[]; details: FetchToolDetails } | null> {
|
|
184
|
-
const target = planGhRouting(params, url);
|
|
185
|
-
if (!target) return null;
|
|
186
|
-
const gh = await runner(buildGhArgs(target), params.timeoutMs ?? DEFAULT_TIMEOUT_MS, signal);
|
|
187
|
-
if (!gh.ok) return null;
|
|
188
|
-
return renderGhResult(target, gh.stdout);
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
const PARSABLE_MAX_BYTES = 1_000_000; // text/markdown/json download ceiling
|
|
192
|
-
const BINARY_MAX_BYTES = 50_000_000; // file-destined download ceiling
|
|
193
|
-
const SNIFF_MAX_BYTES = 64_000; // classification window
|
|
194
|
-
const DEFAULT_TIMEOUT_MS = 20_000;
|
|
195
|
-
const INLINE_MAX_BYTES = 32_000;
|
|
196
|
-
const INLINE_MAX_LINES = 1_000;
|
|
197
|
-
const PREVIEW_LINES = 60;
|
|
198
|
-
const PREVIEW_MAX_BYTES = 4_000;
|
|
199
|
-
const FIREFOX_UA =
|
|
200
|
-
"Mozilla/5.0 (Macintosh; Intel Mac OS X 14.7; rv:135.0) Gecko/20100101 Firefox/135.0";
|
|
201
|
-
const DEFAULT_ACCEPT =
|
|
202
|
-
"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8";
|
|
203
|
-
|
|
204
|
-
function parseCharset(contentType: string): string {
|
|
205
|
-
const m = /charset\s*=\s*"?([^";\s]+)"?/i.exec(contentType);
|
|
206
|
-
return (m?.[1] ?? "utf-8").trim().toLowerCase();
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
function decodeBuffer(buf: Buffer, charset: string): string {
|
|
210
|
-
try {
|
|
211
|
-
return new TextDecoder(charset, { fatal: false }).decode(buf);
|
|
212
|
-
} catch {
|
|
213
|
-
return new TextDecoder("utf-8", { fatal: false }).decode(buf);
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
function buildPreview(body: string): string {
|
|
218
|
-
let preview = body.split("\n").slice(0, PREVIEW_LINES).join("\n");
|
|
219
|
-
if (preview.length > PREVIEW_MAX_BYTES) {
|
|
220
|
-
preview = `${preview.slice(0, PREVIEW_MAX_BYTES)}\n…[preview truncated]`;
|
|
221
|
-
}
|
|
222
|
-
return preview;
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
// --- Turndown singleton ---
|
|
226
|
-
|
|
227
|
-
const turndownService = new TurndownService({
|
|
228
|
-
headingStyle: "atx",
|
|
229
|
-
codeBlockStyle: "fenced",
|
|
230
|
-
bulletListMarker: "-",
|
|
231
|
-
});
|
|
232
|
-
turndownService.use(gfm);
|
|
233
|
-
|
|
234
|
-
// --- Content classification ---
|
|
235
|
-
|
|
236
|
-
function mimeType(contentType: string): string {
|
|
237
|
-
return contentType.split(";")[0].trim().toLowerCase();
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
const TEXT_ALLOWLIST: RegExp[] = [
|
|
241
|
-
/^text\//,
|
|
242
|
-
/^application\/(json|xml|xhtml\+xml|javascript)$/,
|
|
243
|
-
/\+json$/,
|
|
244
|
-
/\+xml$/,
|
|
245
|
-
];
|
|
246
|
-
// octet-stream is intentionally absent — it falls to the NUL-sniff branch.
|
|
247
|
-
const KNOWN_BINARY: RegExp[] = [
|
|
248
|
-
/^audio\//,
|
|
249
|
-
/^video\//,
|
|
250
|
-
/^font\//,
|
|
251
|
-
/^application\/(pdf|zip|gzip|x-tar|x-7z-compressed|x-rar-compressed|wasm)$/,
|
|
252
|
-
];
|
|
253
|
-
|
|
254
|
-
export function categorize(contentType: string, sniff: Buffer, raw: boolean): "binary" | "markdown" | "json" | "text" {
|
|
255
|
-
const mime = mimeType(contentType);
|
|
256
|
-
if (/^image\//.test(mime)) return "binary"; // includes image/svg+xml
|
|
257
|
-
const isText = TEXT_ALLOWLIST.some((re) => re.test(mime));
|
|
258
|
-
const isBinary = KNOWN_BINARY.some((re) => re.test(mime));
|
|
259
|
-
if (!isText && !isBinary) return sniff.includes(0) ? "binary" : "text";
|
|
260
|
-
if (isBinary && !isText) return "binary";
|
|
261
|
-
if (sniff.includes(0)) return "binary"; // NUL downgrade of a text candidate
|
|
262
|
-
if (raw) return "text"; // raw=true skips all transformations (markdown + JSON pretty-print)
|
|
263
|
-
if (mime === "text/html" || mime === "application/xhtml+xml") return "markdown";
|
|
264
|
-
if (mime === "application/json" || /\+json$/.test(mime)) return "json";
|
|
265
|
-
return "text";
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
export function htmlToMarkdown(html: string, url: string): string | null {
|
|
269
|
-
let doc: Document;
|
|
270
|
-
try {
|
|
271
|
-
doc = new JSDOM(html, { url }).window.document;
|
|
272
|
-
} catch {
|
|
273
|
-
return null;
|
|
274
|
-
}
|
|
275
|
-
let article: { title?: string | null; content?: string | null } | null = null;
|
|
276
|
-
try {
|
|
277
|
-
article = new Readability(doc).parse();
|
|
278
|
-
} catch {
|
|
279
|
-
return null;
|
|
280
|
-
}
|
|
281
|
-
if (!article?.content) return null;
|
|
282
|
-
let md: string;
|
|
283
|
-
try {
|
|
284
|
-
md = turndownService.turndown(article.content).trim();
|
|
285
|
-
} catch {
|
|
286
|
-
return null;
|
|
287
|
-
}
|
|
288
|
-
if (!md) return null;
|
|
289
|
-
if (article.title) md = `# ${article.title}\n\n${md}`;
|
|
290
|
-
return md;
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
export function prettyJson(text: string): string {
|
|
294
|
-
try {
|
|
295
|
-
return JSON.stringify(JSON.parse(text), null, 2);
|
|
296
|
-
} catch {
|
|
297
|
-
return text;
|
|
298
|
-
}
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
export function applyGate(body: string): { spill: boolean; bytes: number; lines: number } {
|
|
302
|
-
const bytes = Buffer.byteLength(body, "utf8");
|
|
303
|
-
const lines = body.length ? body.split("\n").length : 0;
|
|
304
|
-
const spill = body.length > 0 && (bytes > INLINE_MAX_BYTES || lines > INLINE_MAX_LINES);
|
|
305
|
-
return { spill, bytes, lines };
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
// --- Temp file helpers ---
|
|
309
|
-
|
|
310
|
-
function tempFilePath(url: string, ext: string): string {
|
|
311
|
-
const dir = join(tmpdir(), "pi-fetch");
|
|
312
|
-
mkdirSync(dir, { recursive: true });
|
|
313
|
-
let host = "page";
|
|
314
|
-
try {
|
|
315
|
-
host = new URL(url).hostname.replace(/[^a-z0-9.-]/gi, "_") || "page";
|
|
316
|
-
} catch {
|
|
317
|
-
// keep default
|
|
318
|
-
}
|
|
319
|
-
const hash = createHash("sha1").update(url).digest("hex").slice(0, 8);
|
|
320
|
-
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
321
|
-
return join(dir, `${stamp}-${host}-${hash}.${ext}`);
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
function spillToFile(url: string, body: string, ext: string): string {
|
|
325
|
-
const file = tempFilePath(url, ext);
|
|
326
|
-
writeFileSync(file, body, "utf8");
|
|
327
|
-
return file;
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
function textExtension(category: "markdown" | "json" | "text", contentType: string): string {
|
|
331
|
-
if (category === "markdown") return "md";
|
|
332
|
-
if (category === "json") return "json";
|
|
333
|
-
return mimeType(contentType).includes("xml") ? "xml" : "txt";
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
const BINARY_EXT: Record<string, string> = {
|
|
337
|
-
"application/pdf": "pdf",
|
|
338
|
-
"application/zip": "zip",
|
|
339
|
-
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": "docx",
|
|
340
|
-
"application/vnd.openxmlformats-officedocument.presentationml.presentation": "pptx",
|
|
341
|
-
"application/gzip": "gz",
|
|
342
|
-
"image/png": "png",
|
|
343
|
-
"image/jpeg": "jpg",
|
|
344
|
-
"image/gif": "gif",
|
|
345
|
-
"image/webp": "webp",
|
|
346
|
-
"image/svg+xml": "svg",
|
|
347
|
-
};
|
|
348
|
-
|
|
349
|
-
export function binaryExtension(contentType: string): string {
|
|
350
|
-
const mime = mimeType(contentType);
|
|
351
|
-
if (BINARY_EXT[mime]) return BINARY_EXT[mime];
|
|
352
|
-
const sub = (mime.split("/")[1] ?? "").replace(/^x-/, "").replace(/[^a-z0-9]+/g, "").slice(0, 8);
|
|
353
|
-
return sub || "bin";
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
// --- Streaming body collection ---
|
|
357
|
-
|
|
358
|
-
type Category = "binary" | "markdown" | "json" | "text";
|
|
359
|
-
|
|
360
|
-
interface CollectedBody {
|
|
361
|
-
category: Category;
|
|
362
|
-
buffer?: Buffer; // text/markdown/json (raw, pre-transform)
|
|
363
|
-
file?: string; // binary
|
|
364
|
-
bytes: number; // bytes kept (post-cap)
|
|
365
|
-
truncated: boolean;
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
function writeChunk(stream: ReturnType<typeof createWriteStream>, b: Buffer): Promise<void> {
|
|
369
|
-
return new Promise((resolve, reject) => {
|
|
370
|
-
stream.write(b, (err) => (err ? reject(err) : resolve()));
|
|
371
|
-
});
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
async function pumpToFile(
|
|
375
|
-
stream: ReturnType<typeof createWriteStream>,
|
|
376
|
-
reader: ReadableStreamDefaultReader<Uint8Array>,
|
|
377
|
-
prefix: Buffer,
|
|
378
|
-
exhausted: boolean,
|
|
379
|
-
): Promise<{ bytes: number; truncated: boolean }> {
|
|
380
|
-
let bytes = 0;
|
|
381
|
-
let truncated = false;
|
|
382
|
-
let head = prefix;
|
|
383
|
-
if (head.length > BINARY_MAX_BYTES) {
|
|
384
|
-
head = head.subarray(0, BINARY_MAX_BYTES);
|
|
385
|
-
truncated = true;
|
|
386
|
-
}
|
|
387
|
-
await writeChunk(stream, head);
|
|
388
|
-
bytes += head.length;
|
|
389
|
-
while (!exhausted && !truncated) {
|
|
390
|
-
const { done, value } = await reader.read();
|
|
391
|
-
if (done) break;
|
|
392
|
-
let chunk = Buffer.from(value);
|
|
393
|
-
if (bytes + chunk.length > BINARY_MAX_BYTES) {
|
|
394
|
-
chunk = chunk.subarray(0, BINARY_MAX_BYTES - bytes);
|
|
395
|
-
truncated = true;
|
|
396
|
-
}
|
|
397
|
-
await writeChunk(stream, chunk);
|
|
398
|
-
bytes += chunk.length;
|
|
399
|
-
}
|
|
400
|
-
await new Promise<void>((resolve, reject) => stream.end((err?: Error | null) => (err ? reject(err) : resolve())));
|
|
401
|
-
return { bytes, truncated };
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
export async function collectBody(res: Response, contentType: string, raw: boolean): Promise<CollectedBody> {
|
|
405
|
-
const reader = res.body!.getReader();
|
|
406
|
-
const prefixParts: Buffer[] = [];
|
|
407
|
-
let prefixLen = 0;
|
|
408
|
-
let exhausted = false;
|
|
409
|
-
while (prefixLen < SNIFF_MAX_BYTES) {
|
|
410
|
-
const { done, value } = await reader.read();
|
|
411
|
-
if (done) {
|
|
412
|
-
exhausted = true;
|
|
413
|
-
break;
|
|
414
|
-
}
|
|
415
|
-
const chunk = Buffer.from(value);
|
|
416
|
-
prefixParts.push(chunk);
|
|
417
|
-
prefixLen += chunk.length;
|
|
418
|
-
}
|
|
419
|
-
const prefix = Buffer.concat(prefixParts);
|
|
420
|
-
const category = categorize(contentType, prefix.subarray(0, SNIFF_MAX_BYTES), raw);
|
|
421
|
-
|
|
422
|
-
if (category === "binary") {
|
|
423
|
-
const file = tempFilePath(res.url, binaryExtension(contentType));
|
|
424
|
-
const stream = createWriteStream(file);
|
|
425
|
-
try {
|
|
426
|
-
const { bytes, truncated } = await pumpToFile(stream, reader, prefix, exhausted);
|
|
427
|
-
if (truncated) await reader.cancel().catch(() => {});
|
|
428
|
-
return { category, file, bytes, truncated };
|
|
429
|
-
} catch (err) {
|
|
430
|
-
stream.destroy();
|
|
431
|
-
await rm(file, { force: true });
|
|
432
|
-
await reader.cancel().catch(() => {});
|
|
433
|
-
throw err;
|
|
434
|
-
}
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
const parts = [prefix];
|
|
438
|
-
let bytes = prefix.length;
|
|
439
|
-
let streamDone = exhausted;
|
|
440
|
-
while (!streamDone && bytes < PARSABLE_MAX_BYTES) {
|
|
441
|
-
const { done, value } = await reader.read();
|
|
442
|
-
if (done) { streamDone = true; break; }
|
|
443
|
-
const chunk = Buffer.from(value);
|
|
444
|
-
parts.push(chunk);
|
|
445
|
-
bytes += chunk.length;
|
|
446
|
-
}
|
|
447
|
-
let buffer = Buffer.concat(parts);
|
|
448
|
-
if (buffer.length > PARSABLE_MAX_BYTES) {
|
|
449
|
-
buffer = buffer.subarray(0, PARSABLE_MAX_BYTES);
|
|
450
|
-
}
|
|
451
|
-
const truncated = !streamDone;
|
|
452
|
-
if (truncated) await reader.cancel().catch(() => {});
|
|
453
|
-
return { category, buffer, bytes: buffer.length, truncated };
|
|
454
|
-
}
|
|
17
|
+
import { fetchUrl, DEFAULT_TIMEOUT_MS, type FetchToolDetails } from "../lib/fetch-core.ts";
|
|
455
18
|
|
|
456
19
|
export default function fetchExtension(pi: ExtensionAPI) {
|
|
457
20
|
pi.registerTool({
|
|
@@ -480,138 +43,11 @@ export default function fetchExtension(pi: ExtensionAPI) {
|
|
|
480
43
|
timeoutMs: Type.Optional(Type.Number({ default: DEFAULT_TIMEOUT_MS })),
|
|
481
44
|
}),
|
|
482
45
|
async execute(_toolCallId, params, signal) {
|
|
483
|
-
const
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
const ghResult = await executeGhRouting(params, url, signal ?? undefined);
|
|
489
|
-
if (ghResult) return ghResult;
|
|
490
|
-
|
|
491
|
-
const headers = new Headers(params.headers ?? {});
|
|
492
|
-
if (!headers.has("user-agent")) headers.set("user-agent", FIREFOX_UA);
|
|
493
|
-
if (!headers.has("accept")) headers.set("accept", DEFAULT_ACCEPT);
|
|
494
|
-
if (!headers.has("accept-language"))
|
|
495
|
-
headers.set("accept-language", "en-US,en;q=0.5");
|
|
496
|
-
|
|
497
|
-
const controller = new AbortController();
|
|
498
|
-
const onAbort = () => controller.abort();
|
|
499
|
-
signal?.addEventListener("abort", onAbort);
|
|
500
|
-
const timer = setTimeout(
|
|
501
|
-
() => controller.abort(new Error("fetch timeout")),
|
|
502
|
-
params.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
503
|
-
);
|
|
504
|
-
|
|
505
|
-
try {
|
|
506
|
-
const res = await fetch(url, {
|
|
507
|
-
method: params.method ?? "GET",
|
|
508
|
-
headers,
|
|
509
|
-
body: params.body,
|
|
510
|
-
signal: controller.signal,
|
|
511
|
-
redirect: "follow",
|
|
512
|
-
});
|
|
513
|
-
|
|
514
|
-
const ct = res.headers.get("content-type") ?? "";
|
|
515
|
-
const charset = parseCharset(ct);
|
|
516
|
-
const header = [
|
|
517
|
-
`HTTP ${res.status} ${res.statusText}`,
|
|
518
|
-
`Content-Type: ${ct}`,
|
|
519
|
-
`Charset: ${charset}`,
|
|
520
|
-
];
|
|
521
|
-
|
|
522
|
-
// HEAD or bodyless response: headers only.
|
|
523
|
-
if (!res.body || (params.method ?? "GET") === "HEAD") {
|
|
524
|
-
return {
|
|
525
|
-
content: [{ type: "text", text: [...header, "Length: 0 (no body)"].join("\n") }],
|
|
526
|
-
details: { url: res.url, status: res.status, contentType: ct, charset, bytes: 0 } as FetchToolDetails,
|
|
527
|
-
};
|
|
528
|
-
}
|
|
529
|
-
|
|
530
|
-
const collected = await collectBody(res, ct, params.raw ?? false);
|
|
531
|
-
const baseDetails: FetchToolDetails = {
|
|
532
|
-
url: res.url,
|
|
533
|
-
status: res.status,
|
|
534
|
-
contentType: ct,
|
|
535
|
-
charset,
|
|
536
|
-
bytes: collected.bytes,
|
|
537
|
-
truncated: collected.truncated,
|
|
538
|
-
category: collected.category,
|
|
539
|
-
};
|
|
540
|
-
|
|
541
|
-
if (collected.category === "binary") {
|
|
542
|
-
const note = collected.truncated ? " (truncated to 50MB)" : "";
|
|
543
|
-
return {
|
|
544
|
-
content: [{
|
|
545
|
-
type: "text",
|
|
546
|
-
text: [
|
|
547
|
-
...header,
|
|
548
|
-
`Body: ${formatSize(collected.bytes)}${note} binary (${mimeType(ct) || "unknown"}) — saved untouched for processing`,
|
|
549
|
-
`Saved-To: ${collected.file}`,
|
|
550
|
-
"",
|
|
551
|
-
"Binary content is not decoded. Use the appropriate tool to process the file at the path above.",
|
|
552
|
-
].join("\n"),
|
|
553
|
-
}],
|
|
554
|
-
details: { ...baseDetails, spilled: true, file: collected.file },
|
|
555
|
-
};
|
|
556
|
-
}
|
|
557
|
-
|
|
558
|
-
const decoded = decodeBuffer(collected.buffer!, charset);
|
|
559
|
-
let body: string;
|
|
560
|
-
let effectiveCategory: "markdown" | "json" | "text" = collected.category;
|
|
561
|
-
if (collected.category === "markdown") {
|
|
562
|
-
const md = htmlToMarkdown(decoded, res.url);
|
|
563
|
-
if (md !== null) {
|
|
564
|
-
body = md;
|
|
565
|
-
} else {
|
|
566
|
-
body = decoded; // raw HTML text fallback
|
|
567
|
-
effectiveCategory = "text";
|
|
568
|
-
}
|
|
569
|
-
} else if (collected.category === "json") {
|
|
570
|
-
body = prettyJson(decoded);
|
|
571
|
-
} else {
|
|
572
|
-
body = decoded;
|
|
573
|
-
}
|
|
574
|
-
|
|
575
|
-
baseDetails.category = effectiveCategory;
|
|
576
|
-
const truncNote = collected.truncated ? "\n[Note: source truncated at 1MB — content may be partial]" : "";
|
|
577
|
-
const lengthLine = `Length: ${collected.bytes}${collected.truncated ? " (truncated to 1MB)" : ""}`;
|
|
578
|
-
const { spill, bytes: bodyBytes, lines: lineCount } = applyGate(body);
|
|
579
|
-
baseDetails.lines = lineCount;
|
|
580
|
-
|
|
581
|
-
if (!spill) {
|
|
582
|
-
return {
|
|
583
|
-
content: [{ type: "text", text: [...header, lengthLine, "", body + truncNote].join("\n") }],
|
|
584
|
-
details: { ...baseDetails, spilled: false },
|
|
585
|
-
};
|
|
586
|
-
}
|
|
587
|
-
|
|
588
|
-
const ext = textExtension(effectiveCategory, ct);
|
|
589
|
-
const file = spillToFile(res.url, body, ext);
|
|
590
|
-
const grepHint = effectiveCategory === "markdown"
|
|
591
|
-
? "Read slices of this file with the read tool (offset/limit) or grep it; do not read the whole file unless you must. Markdown is grep-able by heading (^#)."
|
|
592
|
-
: "Read slices of this file with the read tool (offset/limit) or grep it; do not read the whole file unless you must.";
|
|
593
|
-
return {
|
|
594
|
-
content: [{
|
|
595
|
-
type: "text",
|
|
596
|
-
text: [
|
|
597
|
-
...header,
|
|
598
|
-
lengthLine,
|
|
599
|
-
`Body: ${formatSize(bodyBytes)} across ${lineCount} lines — written to file (too large to inline)`,
|
|
600
|
-
`Saved-To: ${file}`,
|
|
601
|
-
...(collected.truncated ? ["[Note: source truncated at 1MB — content may be partial]"] : []),
|
|
602
|
-
"",
|
|
603
|
-
grepHint,
|
|
604
|
-
"",
|
|
605
|
-
`----- preview (first ${PREVIEW_LINES} lines) -----`,
|
|
606
|
-
buildPreview(body),
|
|
607
|
-
].join("\n"),
|
|
608
|
-
}],
|
|
609
|
-
details: { ...baseDetails, spilled: true, file },
|
|
610
|
-
};
|
|
611
|
-
} finally {
|
|
612
|
-
clearTimeout(timer);
|
|
613
|
-
signal?.removeEventListener("abort", onAbort);
|
|
614
|
-
}
|
|
46
|
+
const result = await fetchUrl({ ...params, signal: signal ?? undefined });
|
|
47
|
+
return {
|
|
48
|
+
content: [{ type: "text" as const, text: result.output }],
|
|
49
|
+
details: result.details,
|
|
50
|
+
};
|
|
615
51
|
},
|
|
616
52
|
|
|
617
53
|
renderCall(args, theme, _context) {
|
|
@@ -347,6 +347,18 @@ export function buildNamingPrompt(conversation: string, opts: PromptOptions = {}
|
|
|
347
347
|
return lines.join("\n");
|
|
348
348
|
}
|
|
349
349
|
|
|
350
|
+
// Copilot business/enterprise credentials pin requests to an account-specific
|
|
351
|
+
// endpoint reported by getApiKeyAndHeaders as auth.baseUrl; the catalog model
|
|
352
|
+
// still carries the individual endpoint, and hitting it with such a token
|
|
353
|
+
// fails with 421 Misdirected Request. Mirror pi's own request path: prefer the
|
|
354
|
+
// credential's endpoint when present.
|
|
355
|
+
export function withAuthBaseUrl<M extends { baseUrl: string }>(
|
|
356
|
+
model: M,
|
|
357
|
+
auth: { baseUrl?: string },
|
|
358
|
+
): M {
|
|
359
|
+
return auth.baseUrl ? { ...model, baseUrl: auth.baseUrl } : model;
|
|
360
|
+
}
|
|
361
|
+
|
|
350
362
|
async function generateName(
|
|
351
363
|
ctx: ExtensionContext,
|
|
352
364
|
opts: PromptOptions = {},
|
|
@@ -368,7 +380,7 @@ async function generateName(
|
|
|
368
380
|
|
|
369
381
|
const complete = await loadComplete();
|
|
370
382
|
const response = await complete(
|
|
371
|
-
model,
|
|
383
|
+
withAuthBaseUrl(model, auth),
|
|
372
384
|
{
|
|
373
385
|
messages: [
|
|
374
386
|
{ role: "user" as const, content: [{ type: "text" as const, text: prompt }], timestamp: Date.now() },
|