open-tag 0.0.5 → 0.1.1

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.
@@ -0,0 +1,1082 @@
1
+ import { join, sep } from "node:path";
2
+ import { constants } from "node:fs";
3
+ import { createHash } from "node:crypto";
4
+ import { lstat, mkdir, open, readdir, realpath, rename, rm, writeFile } from "node:fs/promises";
5
+ import http from "node:http";
6
+ //#region src/pi-extensions/web-tools.ts
7
+ /**
8
+ * OpenTag web tools Pi extension (trusted built artifact).
9
+ *
10
+ * Loaded explicitly with `pi -e <this file>` for actual execution only; `--no-extensions` stays
11
+ * set so implicit discovery never loads it. The extension registers `web_search`/`web_fetch`,
12
+ * forwards validated calls over the per-execution endpoint named by the nonsecret
13
+ * OPENTAG_WEB_TOOLS_SOCKET descriptor, and saves bounded extracted content under the Session
14
+ * workspace `.opentag/web/<stable-tool-id>/`.
15
+ *
16
+ * This module is intentionally self-contained (Node builtins only, no package imports): it runs
17
+ * inside the Pi process and must never resolve workspace dependencies. The wire authority is
18
+ * `@opentag/shared` web-tools; the gateway and Server re-validate everything authoritatively.
19
+ *
20
+ * Wire/artifact identity: the wire toolCallId is derived deterministically (UUID shape) from the
21
+ * per-execution endpoint descriptor plus the actual Pi runtime tool call ID. Same-execution
22
+ * retries reuse it (stable idempotency); a distinct execution can never collide with or
23
+ * overwrite another execution's artifacts. Failures throw bounded redacted errors so Pi records
24
+ * toolResult.isError=true; a normal return is never used for failed operations.
25
+ */
26
+ /** Named export so Client tests can pin these against the shared wire authority. */
27
+ const WEB_EXTENSION_LIMITS = {
28
+ requestMaxBytes: 16384,
29
+ searchResponseMaxBytes: 1048576,
30
+ fetchResponseMaxBytes: 3145728,
31
+ previewPageMaxBytes: 12288,
32
+ toolResultMaxBytes: 49152,
33
+ pageBodyMaxBytes: 1048576,
34
+ queryMaxCodepoints: 400,
35
+ searchLimitMax: 10,
36
+ domainsMax: 10,
37
+ fetchUrlsMax: 3,
38
+ urlMaxBytes: 4096,
39
+ /** End-to-end operation budgets, equal to the shared hard caps; the chain only decreases them. */
40
+ searchTimeoutMs: 15e3,
41
+ fetchTimeoutMs: 45e3,
42
+ protocolVersion: 1,
43
+ /** Remaining-budget hop header (hop metadata, never part of the semantic digest). */
44
+ remainingHeader: "x-web-remaining-ms"
45
+ };
46
+ const TRUNCATION_MARKER = "…[truncated]";
47
+ const ENDPOINT_ENV = "OPENTAG_WEB_TOOLS_SOCKET";
48
+ /** UUID-shaped stable id derivation namespace; version/variant bits are set below. */
49
+ const TOOL_ID_NAMESPACE = "opentag.web.tool.v1";
50
+ /** Exact UUID-v5 shape produced by `deriveWebToolIdentity`; the only id ever used as a directory. */
51
+ const TOOL_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-5[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
52
+ /** The deadline controller aborts with this exact reason so callers keep distinct timeout wording. */
53
+ const WEB_CALL_TIMED_OUT = "The web call timed out";
54
+ /** Plain JSON Schema (no TypeBox dependency); the gateway re-validates with the shared Zod authority. */
55
+ const WEB_SEARCH_PARAMETERS = {
56
+ type: "object",
57
+ properties: {
58
+ query: {
59
+ type: "string",
60
+ minLength: 1,
61
+ maxLength: WEB_EXTENSION_LIMITS.queryMaxCodepoints,
62
+ description: "Search query (1–400 characters), kept verbatim including case."
63
+ },
64
+ limit: {
65
+ type: "integer",
66
+ minimum: 1,
67
+ maximum: WEB_EXTENSION_LIMITS.searchLimitMax,
68
+ default: 5,
69
+ description: "Maximum number of sources to return (default 5)."
70
+ },
71
+ domains: {
72
+ type: "array",
73
+ items: { type: "string" },
74
+ maxItems: WEB_EXTENSION_LIMITS.domainsMax,
75
+ description: "Optional public domain names to scope the search (max 10)."
76
+ },
77
+ timeRange: {
78
+ type: "string",
79
+ enum: [
80
+ "day",
81
+ "week",
82
+ "month",
83
+ "year"
84
+ ],
85
+ description: "Optional recency window for results."
86
+ },
87
+ language: {
88
+ type: "string",
89
+ description: "Optional language preference (e.g. en, zh-CN, portuguese); not a strict filter."
90
+ },
91
+ depth: {
92
+ type: "string",
93
+ enum: ["basic", "advanced"],
94
+ default: "basic",
95
+ description: "Search depth; advanced costs more and must be chosen explicitly."
96
+ }
97
+ },
98
+ required: ["query"],
99
+ additionalProperties: false
100
+ };
101
+ const WEB_FETCH_PARAMETERS = {
102
+ type: "object",
103
+ properties: {
104
+ urls: {
105
+ type: "array",
106
+ items: { type: "string" },
107
+ minItems: 1,
108
+ maxItems: WEB_EXTENSION_LIMITS.fetchUrlsMax,
109
+ description: "1–3 public http(s) URLs to extract page content from."
110
+ },
111
+ depth: {
112
+ type: "string",
113
+ enum: ["basic", "advanced"],
114
+ default: "basic",
115
+ description: "Extraction depth; advanced handles complex pages and must be chosen explicitly."
116
+ }
117
+ },
118
+ required: ["urls"],
119
+ additionalProperties: false
120
+ };
121
+ /**
122
+ * Deterministic UUID-shaped tool identity: SHA-256 over the derivation namespace, the
123
+ * per-execution endpoint descriptor, and the actual Pi runtime tool call ID, formatted with
124
+ * version-5/variant bits. Same endpoint + same call ID ⇒ same id (stable retry); a different
125
+ * execution endpoint ⇒ a different id (no cross-execution artifact collision).
126
+ */
127
+ function deriveWebToolIdentity(endpointDescriptor, runtimeToolCallId) {
128
+ if (typeof runtimeToolCallId !== "string" || runtimeToolCallId.length === 0) throw new Error("The runtime tool call id is missing");
129
+ const hash = createHash("sha256").update(TOOL_ID_NAMESPACE, "utf8").update("", "utf8").update(endpointDescriptor, "utf8").update("", "utf8").update(runtimeToolCallId, "utf8").digest();
130
+ hash[6] = (hash[6] ?? 0) & 15 | 80;
131
+ hash[8] = (hash[8] ?? 0) & 63 | 128;
132
+ const hex = hash.subarray(0, 16).toString("hex");
133
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
134
+ }
135
+ /** Truncate to a UTF-8 byte budget on codepoint boundaries, appending a marker when it fits. */
136
+ function truncateToByteBudget(text, budgetBytes) {
137
+ if (Buffer.byteLength(text, "utf8") <= budgetBytes) return {
138
+ text,
139
+ truncated: false
140
+ };
141
+ if (budgetBytes <= 0) return {
142
+ text: "",
143
+ truncated: true
144
+ };
145
+ const markerBytes = Buffer.byteLength(TRUNCATION_MARKER, "utf8");
146
+ const buffer = Buffer.from(text, "utf8");
147
+ if (budgetBytes < markerBytes) {
148
+ let cut = budgetBytes;
149
+ while (cut > 0 && ((buffer[cut] ?? 0) & 192) === 128) cut -= 1;
150
+ return {
151
+ text: buffer.subarray(0, cut).toString("utf8"),
152
+ truncated: true
153
+ };
154
+ }
155
+ let cut = budgetBytes - markerBytes;
156
+ while (cut > 0 && ((buffer[cut] ?? 0) & 192) === 128) cut -= 1;
157
+ return {
158
+ text: `${buffer.subarray(0, cut).toString("utf8")}${TRUNCATION_MARKER}`,
159
+ truncated: true
160
+ };
161
+ }
162
+ /**
163
+ * Distinguish the operation deadline from a caller abort while keeping the established wording:
164
+ * `The web call was aborted` (never leaks a caller-provided reason), `The web call timed out` when
165
+ * the end-to-end deadline fired. Everything downstream fails closed on either.
166
+ */
167
+ function webCallAbortError(signal) {
168
+ const reason = signal?.reason;
169
+ if (reason instanceof Error && reason.message === WEB_CALL_TIMED_OUT) return /* @__PURE__ */ new Error(WEB_CALL_TIMED_OUT);
170
+ return /* @__PURE__ */ new Error("The web call was aborted");
171
+ }
172
+ /**
173
+ * End-to-end operation deadline (original 15s/45s budget): mirrors the caller abort signal and
174
+ * additionally aborts when the remaining budget expires. The returned signal stays live through
175
+ * the HTTP hop AND every artifact write/publication, so filesystem work can never outlive the
176
+ * budget; callers must keep checking it after each await and dispose it in a `finally`.
177
+ */
178
+ function startOperationDeadline(timeoutMs, externalSignal) {
179
+ const deadlineAtMs = Date.now() + timeoutMs;
180
+ const controller = new AbortController();
181
+ const onExternalAbort = () => controller.abort(externalSignal?.reason);
182
+ if (externalSignal?.aborted) controller.abort(externalSignal.reason);
183
+ else externalSignal?.addEventListener("abort", onExternalAbort, { once: true });
184
+ const timer = setTimeout(() => controller.abort(/* @__PURE__ */ new Error(WEB_CALL_TIMED_OUT)), timeoutMs);
185
+ timer.unref?.();
186
+ return {
187
+ signal: controller.signal,
188
+ deadlineAtMs,
189
+ dispose() {
190
+ clearTimeout(timer);
191
+ externalSignal?.removeEventListener("abort", onExternalAbort);
192
+ }
193
+ };
194
+ }
195
+ function postToGateway(socketPath, path, payload, options) {
196
+ return new Promise((resolvePromise, rejectPromise) => {
197
+ const fail = (message) => {
198
+ finish(new Error(message));
199
+ };
200
+ const remainingAtDispatch = options.deadlineAtMs - Date.now();
201
+ if (options.signal?.aborted) {
202
+ rejectPromise(webCallAbortError(options.signal));
203
+ return;
204
+ }
205
+ if (remainingAtDispatch <= 0) {
206
+ rejectPromise(/* @__PURE__ */ new Error("The web call budget was exhausted before dispatch"));
207
+ return;
208
+ }
209
+ const body = Buffer.from(JSON.stringify(payload), "utf8");
210
+ if (body.byteLength > WEB_EXTENSION_LIMITS.requestMaxBytes) {
211
+ rejectPromise(/* @__PURE__ */ new Error("The web request exceeds the 16 KiB request bound"));
212
+ return;
213
+ }
214
+ let settled = false;
215
+ let timer;
216
+ let request;
217
+ const onAbort = () => {
218
+ finish(webCallAbortError(options.signal));
219
+ request.destroy();
220
+ };
221
+ const finish = (error, reply) => {
222
+ if (settled) return;
223
+ settled = true;
224
+ if (timer) clearTimeout(timer);
225
+ options.signal?.removeEventListener("abort", onAbort);
226
+ if (error) rejectPromise(error);
227
+ else if (reply) resolvePromise(reply);
228
+ };
229
+ request = http.request({
230
+ socketPath,
231
+ path,
232
+ method: "POST",
233
+ headers: {
234
+ "content-type": "application/json",
235
+ "content-length": String(body.byteLength),
236
+ accept: "application/json",
237
+ [WEB_EXTENSION_LIMITS.remainingHeader]: String(remainingAtDispatch)
238
+ }
239
+ }, (response) => {
240
+ const chunks = [];
241
+ let total = 0;
242
+ response.on("data", (chunk) => {
243
+ total += chunk.byteLength;
244
+ if (total > options.maxResponseBytes) {
245
+ fail("The web gateway response exceeds the bound");
246
+ request.destroy();
247
+ return;
248
+ }
249
+ chunks.push(chunk);
250
+ });
251
+ response.on("end", () => finish(void 0, {
252
+ status: response.statusCode ?? 500,
253
+ body: Buffer.concat(chunks)
254
+ }));
255
+ response.on("error", () => fail("The web gateway response failed"));
256
+ });
257
+ timer = setTimeout(() => {
258
+ fail("The web call timed out");
259
+ request.destroy();
260
+ }, remainingAtDispatch);
261
+ timer.unref?.();
262
+ request.on("error", (error) => {
263
+ const code = error.code;
264
+ if (options.signal?.aborted) fail(webCallAbortError(options.signal).message);
265
+ else if (settled) return;
266
+ else fail(`The web gateway is unavailable: ${code ?? "unreachable"}`);
267
+ });
268
+ options.signal?.addEventListener("abort", onAbort, { once: true });
269
+ request.end(body);
270
+ });
271
+ }
272
+ /** Create-if-missing without following symlinks, then prove the result is a real directory. */
273
+ async function ensureOwnedDirectory(path) {
274
+ try {
275
+ await mkdir(path, { mode: 448 });
276
+ } catch (error) {
277
+ if (error.code !== "EEXIST") throw error;
278
+ }
279
+ await assertRealDirectory(path);
280
+ }
281
+ async function assertRealDirectory(path) {
282
+ const stat = await lstat(path);
283
+ if (stat.isSymbolicLink() || !stat.isDirectory()) throw new Error("The web artifact path is not a real directory");
284
+ }
285
+ /**
286
+ * Open (or reuse) the artifact directory for one stable tool call id. Every parent component is
287
+ * validated BEFORE anything is created through it, so a hostile symlink can never cause writes
288
+ * outside the Session store; a second page under the same id reuses the verified directory.
289
+ */
290
+ async function openArtifactDirectory(cwd, toolCallId, signal) {
291
+ if (!TOOL_ID_PATTERN.test(toolCallId)) throw new Error("Unsafe web artifact tool id");
292
+ signal?.throwIfAborted();
293
+ const root = await realpath(cwd);
294
+ await ensureOwnedDirectory(join(root, ".opentag"));
295
+ await ensureOwnedDirectory(join(root, ".opentag", "web"));
296
+ const baseReal = await realpath(join(root, ".opentag", "web"));
297
+ if (!baseReal.startsWith(`${root}${sep}`)) throw new Error("The web artifact store escapes the workspace");
298
+ const directory = join(baseReal, toolCallId);
299
+ await ensureOwnedDirectory(directory);
300
+ const resolvedDirectory = await realpath(directory);
301
+ if (!resolvedDirectory.startsWith(`${baseReal}${sep}`)) throw new Error("The web artifact directory escapes the Session store");
302
+ signal?.throwIfAborted();
303
+ return resolvedDirectory;
304
+ }
305
+ /**
306
+ * Save one page: bounded content, temp file + atomic rename, post-write regular-file proof.
307
+ * The operation deadline stays authoritative after every await (including after publication), so
308
+ * an aborted/timed-out attempt never reports a fabricated success. The observer is notified of the
309
+ * temp name before the first write and of the published name right after the rename, which is the
310
+ * only ownership evidence later cleanup is allowed to trust.
311
+ * Throws on any failure — callers must mark the artifact unsaved and never fabricate a path.
312
+ */
313
+ async function saveWebArtifact(input) {
314
+ if (!/^[a-z0-9][a-z0-9.-]{0,63}$/.test(input.fileName)) throw new Error("Unsafe web artifact file name");
315
+ input.signal?.throwIfAborted();
316
+ const directory = await openArtifactDirectory(input.cwd, input.toolCallId, input.signal);
317
+ const truncated = truncateToByteBudget(input.content, WEB_EXTENSION_LIMITS.pageBodyMaxBytes);
318
+ const bytes = Buffer.byteLength(truncated.text, "utf8");
319
+ const sha256 = createHash("sha256").update(truncated.text, "utf8").digest("hex");
320
+ const finalPath = join(directory, input.fileName);
321
+ const existing = await readBoundedRegularFile(finalPath, WEB_EXTENSION_LIMITS.pageBodyMaxBytes, input.signal);
322
+ if (existing.kind === "oversize" || existing.kind === "unsafe") throw new Error("The existing web artifact is not a bounded regular file");
323
+ if (existing.kind === "regular" && existing.bytes.length === bytes && sha256Hex(existing.bytes) === sha256) return {
324
+ relativePath: [
325
+ ".opentag",
326
+ "web",
327
+ input.toolCallId,
328
+ input.fileName
329
+ ].join("/"),
330
+ bytes,
331
+ sha256,
332
+ artifactTruncated: truncated.truncated
333
+ };
334
+ const tempName = `.${input.fileName}.${randomSuffix()}.tmp`;
335
+ const tempPath = join(directory, tempName);
336
+ input.observer?.onTempFile?.(tempName);
337
+ try {
338
+ await writeFile(tempPath, truncated.text, {
339
+ encoding: "utf8",
340
+ mode: 384,
341
+ flag: "wx",
342
+ signal: input.signal
343
+ });
344
+ input.signal?.throwIfAborted();
345
+ await rename(tempPath, finalPath);
346
+ input.observer?.onPublished?.(input.fileName);
347
+ input.signal?.throwIfAborted();
348
+ const stat = await lstat(finalPath);
349
+ if (!stat.isFile() || stat.isSymbolicLink()) throw new Error("The web artifact failed verification");
350
+ input.signal?.throwIfAborted();
351
+ } catch (error) {
352
+ await rm(tempPath, { force: true }).catch(() => void 0);
353
+ throw error instanceof Error ? error : /* @__PURE__ */ new Error("The web artifact write failed");
354
+ }
355
+ return {
356
+ relativePath: [
357
+ ".opentag",
358
+ "web",
359
+ input.toolCallId,
360
+ input.fileName
361
+ ].join("/"),
362
+ bytes,
363
+ sha256,
364
+ artifactTruncated: truncated.truncated
365
+ };
366
+ }
367
+ function randomSuffix() {
368
+ return createHash("sha256").update(`${Date.now()}:${Math.random()}`).digest("hex").slice(0, 16);
369
+ }
370
+ function sha256Hex(data) {
371
+ return createHash("sha256").update(data).digest("hex");
372
+ }
373
+ /**
374
+ * Read cap for the attempt index: ample for three URLs plus failure records (URLs are bounded at
375
+ * 4 KiB each), far below any allocation risk. Page content gets its own 1 MiB cap.
376
+ */
377
+ const METADATA_READ_MAX_BYTES = 65536;
378
+ /** Fixed read chunk: the loop stops as soon as the cap is crossed, never trusting fstat size. */
379
+ const READ_CHUNK_BYTES = 65536;
380
+ /** Read-only, non-following, non-blocking open flags (POSIX bits fall back to 0 where absent). */
381
+ function boundedReadFlags() {
382
+ const noFollow = constants.O_NOFOLLOW ?? 0;
383
+ const nonBlocking = constants.O_NONBLOCK ?? 0;
384
+ return constants.O_RDONLY | noFollow | nonBlocking;
385
+ }
386
+ /**
387
+ * Bounded, symlink-safe read of a workspace-controlled entry. The handle is opened with
388
+ * O_NOFOLLOW and O_NONBLOCK so a symlinked entry can never be followed and a FIFO can never block
389
+ * the open waiting for a writer; fstat must prove a regular file, and the read proceeds in fixed
390
+ * chunks until the cap is crossed — a file growing between fstat and read therefore returns
391
+ * `oversize` instead of allocating past the cap. Callers decide whether a non-regular result is a
392
+ * fall-through (cleanup) or a bounded unsaved failure (writes).
393
+ */
394
+ async function readBoundedRegularFile(path, maxBytes, signal) {
395
+ signal?.throwIfAborted();
396
+ let handle;
397
+ try {
398
+ handle = await open(path, boundedReadFlags());
399
+ } catch (error) {
400
+ const code = error.code;
401
+ if (code === "ENOENT") return { kind: "absent" };
402
+ if (code === "ELOOP" || code === "EMLINK") return { kind: "unsafe" };
403
+ throw error;
404
+ }
405
+ try {
406
+ const stat = await handle.stat();
407
+ if (!stat.isFile()) return { kind: "unsafe" };
408
+ if (stat.size > maxBytes) return { kind: "oversize" };
409
+ const chunks = [];
410
+ let total = 0;
411
+ while (total <= maxBytes) {
412
+ signal?.throwIfAborted();
413
+ const chunk = Buffer.allocUnsafe(Math.min(READ_CHUNK_BYTES, maxBytes + 1 - total));
414
+ const { bytesRead } = await handle.read(chunk, 0, chunk.length, total);
415
+ if (bytesRead === 0) break;
416
+ total += bytesRead;
417
+ if (total > maxBytes) return { kind: "oversize" };
418
+ chunks.push(chunk.subarray(0, bytesRead));
419
+ }
420
+ signal?.throwIfAborted();
421
+ return {
422
+ kind: "regular",
423
+ bytes: Buffer.concat(chunks, total)
424
+ };
425
+ } finally {
426
+ await handle.close().catch(() => void 0);
427
+ }
428
+ }
429
+ /** Files a finalized index references; malformed/absent metadata references nothing. */
430
+ function finalizedPagesFromBytes(bytes) {
431
+ try {
432
+ const parsed = JSON.parse(bytes.toString("utf8"));
433
+ if (!parsed || !Array.isArray(parsed.pages)) return /* @__PURE__ */ new Set();
434
+ return new Set(parsed.pages.map((page) => page.file).filter((file) => typeof file === "string"));
435
+ } catch {
436
+ return /* @__PURE__ */ new Set();
437
+ }
438
+ }
439
+ async function finalizedFileSet(directory) {
440
+ const existing = await readBoundedRegularFile(join(directory, "metadata.json"), METADATA_READ_MAX_BYTES);
441
+ if (existing.kind !== "regular") return /* @__PURE__ */ new Set();
442
+ return finalizedPagesFromBytes(existing.bytes);
443
+ }
444
+ /**
445
+ * Restore a prior finalized index atomically, and only over a regular file inside the validated
446
+ * attempt directory. Best-effort: a restore failure never masks the original operation error.
447
+ */
448
+ async function restoreMetadataBytes(directory, content) {
449
+ const target = join(directory, "metadata.json");
450
+ const stat = await lstat(target).catch(() => void 0);
451
+ if (!stat || stat.isSymbolicLink() || !stat.isFile()) return;
452
+ const tempPath = join(directory, `.metadata.restore.${randomSuffix()}.tmp`);
453
+ try {
454
+ await writeFile(tempPath, content, {
455
+ mode: 384,
456
+ flag: "wx"
457
+ });
458
+ await rename(tempPath, target);
459
+ } catch {
460
+ await rm(tempPath, { force: true }).catch(() => void 0);
461
+ }
462
+ }
463
+ /**
464
+ * Which page names a surviving index protects. When this attempt published its own index and no
465
+ * prior index is restored, its pages are its own to remove; otherwise pages referenced by the
466
+ * surviving (prior or foreign) index must never be deleted.
467
+ */
468
+ async function protectionSet(directory, input) {
469
+ if (input.publishedMetadata === true && input.priorMetadata !== void 0) return finalizedPagesFromBytes(input.priorMetadata);
470
+ if (input.publishedMetadata === true) return /* @__PURE__ */ new Set();
471
+ return finalizedFileSet(directory);
472
+ }
473
+ /**
474
+ * Decide the fate of the index this attempt published: put the prior finalized bytes back when
475
+ * they are provably the ones we replaced, withdraw a fresh index of our own, and never clobber
476
+ * an index that another attempt published after ours.
477
+ */
478
+ async function settlePublishedMetadata(directory, input) {
479
+ const digest = input.publishedMetadataDigest;
480
+ if (digest === void 0) {
481
+ await removeOwnedEntry(directory, "metadata.json");
482
+ return;
483
+ }
484
+ const current = await readBoundedRegularFile(join(directory, "metadata.json"), METADATA_READ_MAX_BYTES);
485
+ if (current.kind !== "regular" || sha256Hex(current.bytes) !== digest) return;
486
+ if (input.priorMetadata !== void 0) {
487
+ await restoreMetadataBytes(directory, input.priorMetadata);
488
+ return;
489
+ }
490
+ await removeOwnedEntry(directory, "metadata.json");
491
+ }
492
+ /** A relative entry name that can only address one file directly inside the attempt directory. */
493
+ function isSafeEntryName(name) {
494
+ return name.length > 0 && name !== "." && name !== ".." && !name.includes("/") && !name.includes("\\");
495
+ }
496
+ /**
497
+ * Resolve the attempt directory for cleanup without ever traversing a symlink: every component
498
+ * (`.opentag`, `web`, the stable id) must be a real directory. A symlinked parent means this is
499
+ * not the Session store this execution opened, so cleanup does nothing at all.
500
+ */
501
+ async function resolveCleanupDirectory(cwd, toolCallId) {
502
+ if (!TOOL_ID_PATTERN.test(toolCallId)) return void 0;
503
+ let root;
504
+ try {
505
+ root = await realpath(cwd);
506
+ } catch {
507
+ return;
508
+ }
509
+ const directory = join(root, ".opentag", "web", toolCallId);
510
+ for (const component of [
511
+ join(root, ".opentag"),
512
+ join(root, ".opentag", "web"),
513
+ directory
514
+ ]) try {
515
+ const stat = await lstat(component);
516
+ if (stat.isSymbolicLink() || !stat.isDirectory()) return void 0;
517
+ } catch {
518
+ return;
519
+ }
520
+ try {
521
+ const resolved = await realpath(directory);
522
+ if (!resolved.startsWith(`${root}${sep}`)) return void 0;
523
+ return resolved;
524
+ } catch {
525
+ return;
526
+ }
527
+ }
528
+ /** Remove one explicitly owned entry, and only when it is still a regular file (never a link). */
529
+ async function removeOwnedEntry(directory, name) {
530
+ const target = join(directory, name);
531
+ try {
532
+ const stat = await lstat(target);
533
+ if (stat.isSymbolicLink() || !stat.isFile()) return;
534
+ } catch {
535
+ return;
536
+ }
537
+ await rm(target, { force: true }).catch(() => void 0);
538
+ }
539
+ /**
540
+ * Aborted/failed attempt cleanup: remove ONLY the entries this attempt explicitly registered —
541
+ * the temp file names it reported before writing and the page files it published — and never a
542
+ * wildcard `*.tmp` sweep. Anything else in the directory (another attempt's temp/publication
543
+ * files, earlier successful artifacts, and pages protected by a surviving index) is preserved.
544
+ * When this attempt published an index over a prior finalized one, the prior bytes are restored
545
+ * unless another attempt has since published its own (proven by digest); a fresh index with no
546
+ * prior is withdrawn. The whole path is validated first, so a symlinked intermediate component
547
+ * cannot redirect the deletion outside the Session store.
548
+ */
549
+ async function cleanupWebArtifacts(input) {
550
+ const directory = await resolveCleanupDirectory(input.cwd, input.toolCallId);
551
+ if (!directory) return;
552
+ const finalized = await protectionSet(directory, input);
553
+ const owned = /* @__PURE__ */ new Set();
554
+ for (const name of input.tempFiles ?? []) if (isSafeEntryName(name)) owned.add(name);
555
+ for (const name of input.writtenThisAttempt) {
556
+ if (!isSafeEntryName(name)) continue;
557
+ if (input.preExisting.has(name)) continue;
558
+ if (finalized.has(name)) continue;
559
+ owned.add(name);
560
+ }
561
+ if (input.publishedMetadata) await settlePublishedMetadata(directory, input);
562
+ await Promise.all([...owned].map((name) => removeOwnedEntry(directory, name)));
563
+ }
564
+ /**
565
+ * Publish the attempt index. Like page writes, every await is followed by a deadline check:
566
+ * cancellation while the temp file is being written must never rename metadata into place, and an
567
+ * abort right after the rename is still surfaced to the caller. The caller then withdraws its own
568
+ * index and restores the prior finalized bytes it captured before this rename.
569
+ */
570
+ async function writeFetchMetadata(directory, serialized, signal, observer) {
571
+ const finalPath = join(directory, "metadata.json");
572
+ const tempName = `.metadata.json.${randomSuffix()}.tmp`;
573
+ const tempPath = join(directory, tempName);
574
+ signal?.throwIfAborted();
575
+ observer?.onTempFile?.(tempName);
576
+ try {
577
+ await writeFile(tempPath, serialized, {
578
+ encoding: "utf8",
579
+ mode: 384,
580
+ flag: "wx",
581
+ signal
582
+ });
583
+ signal?.throwIfAborted();
584
+ await rename(tempPath, finalPath);
585
+ observer?.onPublished?.("metadata.json");
586
+ signal?.throwIfAborted();
587
+ const stat = await lstat(finalPath);
588
+ if (!stat.isFile() || stat.isSymbolicLink()) throw new Error("The web metadata failed verification");
589
+ signal?.throwIfAborted();
590
+ } catch (error) {
591
+ await rm(tempPath, { force: true }).catch(() => void 0);
592
+ throw error instanceof Error ? error : /* @__PURE__ */ new Error("The web metadata write failed");
593
+ }
594
+ }
595
+ function searchOutput(result) {
596
+ const results = result.results;
597
+ const lines = [`web_search ok — ${results.length} source(s); requestId ${String(result.requestId)}; retrievedAt ${String(result.retrievedAt)}; depth ${String(result.effectiveDepth)}.`, "Snippets are provider search snippets for source selection, not page content."];
598
+ results.forEach((item, index) => {
599
+ const published = typeof item.publishedAt === "string" ? `; published ${item.publishedAt}` : "";
600
+ lines.push(`\n[${index + 1}] ${typeof item.title === "string" && item.title ? item.title : "(untitled)"}`, `URL: ${String(item.url)}${published}`, String(item.snippet ?? ""));
601
+ });
602
+ return truncateToByteBudget(lines.join("\n"), WEB_EXTENSION_LIMITS.toolResultMaxBytes);
603
+ }
604
+ /** Details artifact records returned to Pi: same combined truncation flag as the metadata. */
605
+ function artifactDetails(artifacts) {
606
+ return artifacts.map((artifact) => ({
607
+ path: artifact.relativePath,
608
+ bytes: artifact.bytes,
609
+ sha256: artifact.sha256,
610
+ artifactTruncated: artifact.artifactTruncated
611
+ }));
612
+ }
613
+ /** Every requested URL failed: the batch is an operation failure, not a partial success. */
614
+ function allFailedError(items) {
615
+ const codes = items.map((item) => `${item.url}: ${String(item.code)}${item.retryable ? " (retryable)" : ""}`);
616
+ return /* @__PURE__ */ new Error(`web_fetch failed for every requested URL — ${truncateToByteBudget(codes.join("; "), 512).text}`);
617
+ }
618
+ /** One page's report section: metadata lines, flags, and the bounded preview. */
619
+ function pageSection(index, item, content, saved) {
620
+ const preview = truncateToByteBudget(content, WEB_EXTENSION_LIMITS.previewPageMaxBytes);
621
+ const flags = ["completeness unknown (extracted content, not raw HTML)"];
622
+ if (preview.truncated || item.previewTruncated === true) flags.push("preview truncated");
623
+ if (saved?.artifactTruncated || item.artifactTruncated === true) flags.push("stored content truncated");
624
+ if (item.upstreamTruncated === true) flags.push("upstream truncated");
625
+ if (item.upstreamTruncated === null || item.upstreamTruncated === void 0) flags.push("upstream truncation unknown");
626
+ return [
627
+ `\n[${index + 1}] ${item.url} — ${item.status}`,
628
+ `finalUrl: ${item.finalUrl ?? "unknown"}; sourceFetchedAt: ${item.sourceFetchedAt ?? "unknown"}`,
629
+ saved ? `saved: ${saved.relativePath} (${saved.bytes} bytes, sha256:${saved.sha256})` : "saved: none (artifact write failed; content exists only in this preview)",
630
+ `flags: ${flags.join("; ")}`,
631
+ "preview:",
632
+ preview.text
633
+ ];
634
+ }
635
+ /** The attempt index, including the combined upstream + local truncation flag per page. */
636
+ function buildFetchMetadata(result, toolCallId, pages, results) {
637
+ return {
638
+ version: 1,
639
+ toolCallId,
640
+ requestId: String(result.requestId),
641
+ retrievedAt: String(result.retrievedAt),
642
+ effectiveDepth: String(result.effectiveDepth),
643
+ pages: pages.map((page) => ({
644
+ index: page.index,
645
+ url: page.item.url,
646
+ file: page.artifact.relativePath.split("/").pop() ?? "",
647
+ bytes: page.artifact.bytes,
648
+ sha256: page.artifact.sha256,
649
+ artifactTruncated: page.artifact.artifactTruncated,
650
+ upstreamTruncated: page.item.upstreamTruncated ?? null,
651
+ finalUrl: page.item.finalUrl ?? null,
652
+ sourceFetchedAt: page.item.sourceFetchedAt ?? null,
653
+ completeness: "unknown"
654
+ })),
655
+ failures: results.filter((item) => item.status === "failed").map((item) => ({
656
+ url: item.url,
657
+ code: String(item.code),
658
+ retryable: item.retryable === true
659
+ }))
660
+ };
661
+ }
662
+ /** Save one non-failed page with the combined truncation flag; abort always propagates. */
663
+ async function saveOnePage(input) {
664
+ try {
665
+ const saved = await saveWebArtifact({
666
+ cwd: input.cwd,
667
+ toolCallId: input.toolCallId,
668
+ fileName: `page-${input.index}.md`,
669
+ content: input.content,
670
+ signal: input.signal,
671
+ observer: input.observer
672
+ });
673
+ return {
674
+ saved: {
675
+ ...saved,
676
+ artifactTruncated: saved.artifactTruncated || input.item.artifactTruncated === true
677
+ },
678
+ writeFailed: false
679
+ };
680
+ } catch (error) {
681
+ if (input.signal.aborted) throw error;
682
+ return { writeFailed: true };
683
+ }
684
+ }
685
+ function createAttemptLedger() {
686
+ const tempFiles = /* @__PURE__ */ new Set();
687
+ const writtenThisAttempt = /* @__PURE__ */ new Set();
688
+ return {
689
+ tempFiles,
690
+ writtenThisAttempt,
691
+ metadataPublished: false,
692
+ publishedMetadataDigest: void 0,
693
+ priorMetadata: void 0,
694
+ observer: {
695
+ onTempFile: (name) => {
696
+ tempFiles.add(name);
697
+ },
698
+ onPublished: (name) => {
699
+ writtenThisAttempt.add(name);
700
+ }
701
+ }
702
+ };
703
+ }
704
+ function fetchHeader(result, results) {
705
+ return `web_fetch ${String(result.status)} — ${results.length} URL result(s); requestId ${String(result.requestId)}; retrievedAt ${String(result.retrievedAt)}; depth ${String(result.effectiveDepth)}.`;
706
+ }
707
+ /** Open the attempt store up front so pre-existing files are known before any write. */
708
+ async function openAttemptStore(cwd, toolCallId, signal) {
709
+ const directory = await openArtifactDirectory(cwd, toolCallId, signal);
710
+ return {
711
+ directory,
712
+ preExisting: new Set(await readdir(directory).catch(() => []))
713
+ };
714
+ }
715
+ function failedPageSection(index, item) {
716
+ return `\n[${index + 1}] ${item.url} — FAILED: ${String(item.code)}${item.retryable ? " (retryable with a new call)" : ""}`;
717
+ }
718
+ /**
719
+ * Save every non-failed page for this attempt and build its report sections. A genuine write
720
+ * failure is counted and reported; an abort always propagates so the caller never sees success.
721
+ */
722
+ async function saveResultPages(results, toolCallId, cwd, signal, ledger) {
723
+ const artifacts = [];
724
+ const pages = [];
725
+ const sections = [];
726
+ let artifactWriteFailures = 0;
727
+ for (const [index, item] of results.entries()) {
728
+ signal.throwIfAborted();
729
+ if (item.status === "failed") {
730
+ sections.push(failedPageSection(index, item));
731
+ continue;
732
+ }
733
+ const content = typeof item.content === "string" ? item.content : "";
734
+ const { saved, writeFailed } = await saveOnePage({
735
+ cwd,
736
+ toolCallId,
737
+ index,
738
+ item,
739
+ content,
740
+ signal,
741
+ observer: ledger.observer
742
+ });
743
+ if (writeFailed) artifactWriteFailures += 1;
744
+ if (saved) {
745
+ artifacts.push(saved);
746
+ pages.push({
747
+ index,
748
+ item,
749
+ artifact: saved
750
+ });
751
+ }
752
+ sections.push(...pageSection(index, item, content, saved));
753
+ }
754
+ return {
755
+ artifacts,
756
+ pages,
757
+ sections,
758
+ artifactWriteFailures
759
+ };
760
+ }
761
+ /**
762
+ * Publish the attempt index. A cancellation during the temp write is rethrown (never a degraded
763
+ * success); only a genuine non-abort write failure returns `failed: true`.
764
+ */
765
+ async function publishAttemptMetadata(directory, result, toolCallId, pages, results, signal, ledger) {
766
+ const metadata = buildFetchMetadata(result, toolCallId, pages, results);
767
+ const serialized = `${JSON.stringify(metadata, void 0, 2)}\n`;
768
+ const digest = sha256Hex(Buffer.from(serialized, "utf8"));
769
+ const prior = await readBoundedRegularFile(join(directory, "metadata.json"), METADATA_READ_MAX_BYTES, signal);
770
+ if (prior.kind === "oversize" || prior.kind === "unsafe") throw new Error("The existing web metadata is not a bounded regular file");
771
+ ledger.priorMetadata = prior.kind === "regular" ? prior.bytes : void 0;
772
+ try {
773
+ await writeFetchMetadata(directory, serialized, signal, {
774
+ onTempFile: (name) => {
775
+ ledger.tempFiles.add(name);
776
+ },
777
+ onPublished: () => {
778
+ ledger.metadataPublished = true;
779
+ ledger.publishedMetadataDigest = digest;
780
+ }
781
+ });
782
+ return {
783
+ written: true,
784
+ failed: false
785
+ };
786
+ } catch (error) {
787
+ if (signal.aborted) throw error;
788
+ return {
789
+ written: false,
790
+ failed: true
791
+ };
792
+ }
793
+ }
794
+ async function cleanupAttempt(cwd, toolCallId, preExisting, ledger) {
795
+ await cleanupWebArtifacts({
796
+ cwd,
797
+ toolCallId,
798
+ preExisting,
799
+ writtenThisAttempt: ledger.writtenThisAttempt,
800
+ tempFiles: ledger.tempFiles,
801
+ publishedMetadata: ledger.metadataPublished,
802
+ priorMetadata: ledger.priorMetadata,
803
+ publishedMetadataDigest: ledger.publishedMetadataDigest
804
+ });
805
+ }
806
+ /** One final truncation covers framing AND suffix: the total never exceeds the tool bound. */
807
+ function assembleFetchText(sections, artifactWriteFailures) {
808
+ const suffix = artifactWriteFailures > 0 ? `\n${artifactWriteFailures} artifact write(s) failed; no fabricated paths are reported.` : "";
809
+ return truncateToByteBudget(`${sections.join("\n")}${suffix}`, WEB_EXTENSION_LIMITS.toolResultMaxBytes).text;
810
+ }
811
+ async function fetchOutput(result, toolCallId, cwd, signal) {
812
+ const results = result.results;
813
+ const ledger = createAttemptLedger();
814
+ const sections = [fetchHeader(result, results)];
815
+ let directory;
816
+ let preExisting = /* @__PURE__ */ new Set();
817
+ try {
818
+ signal.throwIfAborted();
819
+ if (results.some((item) => item.status !== "failed")) {
820
+ const store = await openAttemptStore(cwd, toolCallId, signal);
821
+ directory = store.directory;
822
+ preExisting = store.preExisting;
823
+ }
824
+ const pageOutcome = await saveResultPages(results, toolCallId, cwd, signal, ledger);
825
+ sections.push(...pageOutcome.sections);
826
+ signal.throwIfAborted();
827
+ let artifactWriteFailures = pageOutcome.artifactWriteFailures;
828
+ let metadataWritten = false;
829
+ if (directory && pageOutcome.pages.length > 0) {
830
+ const published = await publishAttemptMetadata(directory, result, toolCallId, pageOutcome.pages, results, signal, ledger);
831
+ metadataWritten = published.written;
832
+ if (published.written) sections.push(`\nmetadata: .opentag/web/${toolCallId}/metadata.json`);
833
+ else if (published.failed) {
834
+ artifactWriteFailures += 1;
835
+ sections.push("\nmetadata: write failed (page files above remain saved)");
836
+ }
837
+ }
838
+ signal.throwIfAborted();
839
+ return {
840
+ text: assembleFetchText(sections, artifactWriteFailures),
841
+ artifacts: pageOutcome.artifacts,
842
+ artifactWriteFailures,
843
+ metadataWritten
844
+ };
845
+ } catch (error) {
846
+ await cleanupAttempt(cwd, toolCallId, preExisting, ledger);
847
+ throw error;
848
+ }
849
+ }
850
+ function errorEnvelope(body) {
851
+ try {
852
+ const parsed = JSON.parse(body.toString("utf8"));
853
+ if (typeof parsed?.error?.code !== "string") return void 0;
854
+ return {
855
+ code: parsed.error.code,
856
+ retryable: parsed.error.retryable === true
857
+ };
858
+ } catch {
859
+ return;
860
+ }
861
+ }
862
+ function boundedString(value, maxBytes) {
863
+ return typeof value === "string" && Buffer.byteLength(value, "utf8") <= maxBytes;
864
+ }
865
+ const INVALID_WIRE_PAYLOAD = "The web gateway returned an invalid payload";
866
+ function invalidWirePayload() {
867
+ return /* @__PURE__ */ new Error(INVALID_WIRE_PAYLOAD);
868
+ }
869
+ function isPlainRecord(value) {
870
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
871
+ }
872
+ function requireBoundedString(value, maxBytes) {
873
+ if (!boundedString(value, maxBytes)) throw invalidWirePayload();
874
+ return value;
875
+ }
876
+ function requireNonEmptyBoundedString(value, maxBytes) {
877
+ const text = requireBoundedString(value, maxBytes);
878
+ if (text.length === 0) throw invalidWirePayload();
879
+ return text;
880
+ }
881
+ /** Shared envelope: request id, retrieval time, depth, and the results array shape. */
882
+ function assertWireEnvelope(record) {
883
+ requireNonEmptyBoundedString(record.requestId, 128);
884
+ requireBoundedString(record.retrievedAt, 64);
885
+ if (record.effectiveDepth !== "basic" && record.effectiveDepth !== "advanced") throw invalidWirePayload();
886
+ if (!Array.isArray(record.results)) throw invalidWirePayload();
887
+ }
888
+ function assertSearchWireItem(value) {
889
+ if (!isPlainRecord(value)) throw invalidWirePayload();
890
+ requireNonEmptyBoundedString(value.sourceId, 128);
891
+ requireBoundedString(value.title, 2048);
892
+ requireNonEmptyBoundedString(value.url, WEB_EXTENSION_LIMITS.urlMaxBytes);
893
+ requireBoundedString(value.snippet, 8192);
894
+ if (value.publishedAt !== void 0 && !boundedString(value.publishedAt, 128)) throw invalidWirePayload();
895
+ }
896
+ function assertFailedFetchWireItem(item) {
897
+ requireNonEmptyBoundedString(item.code, 64);
898
+ if (typeof item.retryable !== "boolean") throw invalidWirePayload();
899
+ }
900
+ function assertContentFetchWireItem(item) {
901
+ if (item.status !== "ok" && item.status !== "partial") throw invalidWirePayload();
902
+ if (item.contentKind !== "extracted" || item.completeness !== "unknown") throw invalidWirePayload();
903
+ requireBoundedString(item.content, WEB_EXTENSION_LIMITS.pageBodyMaxBytes);
904
+ if (item.finalUrl !== null && !boundedString(item.finalUrl, WEB_EXTENSION_LIMITS.urlMaxBytes)) throw invalidWirePayload();
905
+ if (item.sourceFetchedAt !== null && !boundedString(item.sourceFetchedAt, 64)) throw invalidWirePayload();
906
+ if (item.upstreamTruncated !== null && typeof item.upstreamTruncated !== "boolean") throw invalidWirePayload();
907
+ if (typeof item.previewTruncated !== "boolean" || typeof item.artifactTruncated !== "boolean") throw invalidWirePayload();
908
+ }
909
+ function assertFetchWireItem(value) {
910
+ if (!isPlainRecord(value)) throw invalidWirePayload();
911
+ requireNonEmptyBoundedString(value.url, WEB_EXTENSION_LIMITS.urlMaxBytes);
912
+ if (value.status === "failed") {
913
+ assertFailedFetchWireItem(value);
914
+ return;
915
+ }
916
+ assertContentFetchWireItem(value);
917
+ }
918
+ function assertSearchWireResult(record, results) {
919
+ if (record.status !== "ok") throw invalidWirePayload();
920
+ if (results.length > 25) throw invalidWirePayload();
921
+ for (const item of results) assertSearchWireItem(item);
922
+ }
923
+ function assertFetchWireResult(record, results) {
924
+ if (![
925
+ "ok",
926
+ "partial",
927
+ "failed"
928
+ ].includes(String(record.status))) throw invalidWirePayload();
929
+ if (results.length > WEB_EXTENSION_LIMITS.fetchUrlsMax) throw invalidWirePayload();
930
+ for (const item of results) assertFetchWireItem(item);
931
+ }
932
+ /**
933
+ * Defensive wire validation (the trusted gateway/client hold the authoritative shared schemas).
934
+ * Enough shape and bounds are checked here to prevent mislabeled or synthetic successes.
935
+ */
936
+ function assertWireResult(value, kind) {
937
+ if (!isPlainRecord(value)) throw invalidWirePayload();
938
+ assertWireEnvelope(value);
939
+ const results = value.results;
940
+ if (kind === "search") assertSearchWireResult(value, results);
941
+ else assertFetchWireResult(value, results);
942
+ return value;
943
+ }
944
+ function normalizeUrl(raw) {
945
+ try {
946
+ const url = new URL(raw);
947
+ url.hash = "";
948
+ if (url.protocol === "http:" && url.port === "80" || url.protocol === "https:" && url.port === "443") url.port = "";
949
+ return url.toString();
950
+ } catch {
951
+ return;
952
+ }
953
+ }
954
+ /**
955
+ * Results must correspond exactly to the requested URLs after documented normalization/dedup
956
+ * (fragment removed, host case/default ports normalized): every item matches one unclaimed
957
+ * request, none are missing, and duplicates never borrow another URL's content.
958
+ */
959
+ function requestedUrlsMatch(urls, results) {
960
+ const expected = /* @__PURE__ */ new Map();
961
+ for (const url of urls) {
962
+ const normalized = normalizeUrl(url);
963
+ if (normalized) expected.set(normalized, (expected.get(normalized) ?? 0) + 1);
964
+ }
965
+ const seen = /* @__PURE__ */ new Set();
966
+ for (const item of results) {
967
+ const normalized = normalizeUrl(item.url);
968
+ if (!normalized || !expected.has(normalized) || seen.has(normalized)) throw new Error("The web gateway returned a result for an unrequested or duplicated URL");
969
+ seen.add(normalized);
970
+ }
971
+ if (seen.size !== expected.size) throw new Error("The web gateway omitted a requested URL from the results");
972
+ }
973
+ /** Validate the HTTP status and parse a successful JSON reply; failures stay bounded/redacted. */
974
+ function requireWebSuccessReply(operation, reply) {
975
+ if (reply.status !== 200) {
976
+ const failure = errorEnvelope(reply.body);
977
+ throw new Error(`${operation} failed: ${failure?.code ?? `http_${reply.status}`}${failure?.retryable ? " (retryable)" : ""}`);
978
+ }
979
+ return JSON.parse(reply.body.toString("utf8"));
980
+ }
981
+ /** Optional search fields are included only when the caller supplied them. */
982
+ function searchRequestBody(toolCallId, params) {
983
+ return {
984
+ protocolVersion: WEB_EXTENSION_LIMITS.protocolVersion,
985
+ toolCallId,
986
+ query: params.query,
987
+ ...params.limit !== void 0 ? { limit: params.limit } : {},
988
+ ...params.domains ? { domains: params.domains } : {},
989
+ ...params.timeRange ? { timeRange: params.timeRange } : {},
990
+ ...params.language ? { language: params.language } : {},
991
+ depth: params.depth === "advanced" ? "advanced" : "basic"
992
+ };
993
+ }
994
+ function openTagWebTools(pi) {
995
+ const socketPath = process.env[ENDPOINT_ENV];
996
+ if (!socketPath) return;
997
+ pi.registerTool({
998
+ name: "web_search",
999
+ label: "Web Search",
1000
+ description: "Search the public web through OpenTag's managed provider. Returns bounded source snippets for selection; use web_fetch on chosen URLs for page content. depth=advanced costs more and must be chosen explicitly. No automatic retries; a failed call may be retried as a new explicit call when marked retryable.",
1001
+ parameters: WEB_SEARCH_PARAMETERS,
1002
+ async execute(runtimeToolCallId, rawParams, signal, _onUpdate, _ctx) {
1003
+ const params = rawParams;
1004
+ const toolCallId = deriveWebToolIdentity(socketPath, runtimeToolCallId);
1005
+ const deadline = startOperationDeadline(WEB_EXTENSION_LIMITS.searchTimeoutMs, signal);
1006
+ try {
1007
+ const reply = await postToGateway(socketPath, "/web/search", searchRequestBody(toolCallId, params), {
1008
+ deadlineAtMs: deadline.deadlineAtMs,
1009
+ signal: deadline.signal,
1010
+ maxResponseBytes: WEB_EXTENSION_LIMITS.searchResponseMaxBytes
1011
+ });
1012
+ deadline.signal.throwIfAborted();
1013
+ const result = assertWireResult(requireWebSuccessReply("web_search", reply), "search");
1014
+ const output = searchOutput(result);
1015
+ deadline.signal.throwIfAborted();
1016
+ return {
1017
+ content: [{
1018
+ type: "text",
1019
+ text: output.text
1020
+ }],
1021
+ details: {
1022
+ requestId: String(result.requestId),
1023
+ status: "ok",
1024
+ sourceCount: result.results.length,
1025
+ truncated: output.truncated
1026
+ }
1027
+ };
1028
+ } finally {
1029
+ deadline.dispose();
1030
+ }
1031
+ }
1032
+ });
1033
+ pi.registerTool({
1034
+ name: "web_fetch",
1035
+ label: "Web Fetch",
1036
+ description: "Extract page content for 1–3 public http(s) URLs through OpenTag's managed provider. Content is the provider's extraction (not raw HTML; completeness unknown). Full pages are saved under the Session workspace .opentag/web/ with sha256 metadata; previews are bounded. HTTP 200 can still contain per-URL failures — read every item. depth=advanced costs more and must be chosen explicitly.",
1037
+ parameters: WEB_FETCH_PARAMETERS,
1038
+ async execute(runtimeToolCallId, rawParams, signal, _onUpdate, ctx) {
1039
+ const params = rawParams;
1040
+ const toolCallId = deriveWebToolIdentity(socketPath, runtimeToolCallId);
1041
+ const deadline = startOperationDeadline(WEB_EXTENSION_LIMITS.fetchTimeoutMs, signal);
1042
+ try {
1043
+ const reply = await postToGateway(socketPath, "/web/fetch", {
1044
+ protocolVersion: WEB_EXTENSION_LIMITS.protocolVersion,
1045
+ toolCallId,
1046
+ urls: params.urls,
1047
+ depth: params.depth === "advanced" ? "advanced" : "basic"
1048
+ }, {
1049
+ deadlineAtMs: deadline.deadlineAtMs,
1050
+ signal: deadline.signal,
1051
+ maxResponseBytes: WEB_EXTENSION_LIMITS.fetchResponseMaxBytes
1052
+ });
1053
+ deadline.signal.throwIfAborted();
1054
+ const result = assertWireResult(requireWebSuccessReply("web_fetch", reply), "fetch");
1055
+ const items = result.results;
1056
+ requestedUrlsMatch(params.urls, items);
1057
+ if (items.length === 0 || items.every((item) => item.status === "failed")) throw allFailedError(items);
1058
+ const output = await fetchOutput(result, toolCallId, ctx.cwd, deadline.signal);
1059
+ deadline.signal.throwIfAborted();
1060
+ return {
1061
+ content: [{
1062
+ type: "text",
1063
+ text: output.text
1064
+ }],
1065
+ details: {
1066
+ requestId: String(result.requestId),
1067
+ status: String(result.status),
1068
+ artifacts: artifactDetails(output.artifacts),
1069
+ artifactWriteFailures: output.artifactWriteFailures,
1070
+ metadataWritten: output.metadataWritten
1071
+ }
1072
+ };
1073
+ } finally {
1074
+ deadline.dispose();
1075
+ }
1076
+ }
1077
+ });
1078
+ }
1079
+ //#endregion
1080
+ export { WEB_EXTENSION_LIMITS, WEB_FETCH_PARAMETERS, WEB_SEARCH_PARAMETERS, cleanupWebArtifacts, openTagWebTools as default, deriveWebToolIdentity, saveWebArtifact, truncateToByteBudget };
1081
+
1082
+ //# sourceMappingURL=web-tools.mjs.map