@mono-agent/agent-runtime 0.15.3 → 0.16.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.
Files changed (43) hide show
  1. package/MIGRATION.md +41 -13
  2. package/README.md +43 -6
  3. package/package.json +7 -3
  4. package/src/agent/tools/agent-tool.js +894 -0
  5. package/src/agent/tools/bash.js +241 -123
  6. package/src/agent/tools/exec.js +238 -0
  7. package/src/agent/tools/index.js +10 -3
  8. package/src/agent/tools/node-repl.js +231 -95
  9. package/src/agent/tools/pi-bridge.js +115 -24
  10. package/src/agent/tools/shared/process-runner.js +162 -0
  11. package/src/agent/tools/shared/semaphore.js +73 -0
  12. package/src/agent/tools/web-browser-render.js +221 -0
  13. package/src/agent/tools/web-controller.js +160 -0
  14. package/src/agent/tools/web-fetch.js +653 -68
  15. package/src/agent/tools/web-search.js +568 -16
  16. package/src/ai/pi-interop.js +7 -5
  17. package/src/ai/pi-oauth-compat.js +193 -0
  18. package/src/ai/providers/pi-native/stream-subscriber.js +37 -0
  19. package/src/ai/providers/pi-native/turn-runner.js +73 -8
  20. package/src/ai/providers/pi-native.js +67 -7
  21. package/src/ai/runtime/router.js +310 -166
  22. package/src/ai/types.js +54 -2
  23. package/src/pi-auth.js +2 -2
  24. package/src/runtime.js +58 -1
  25. package/types/agent/tools/agent-tool.d.ts +80 -0
  26. package/types/agent/tools/bash.d.ts +55 -7
  27. package/types/agent/tools/exec.d.ts +53 -0
  28. package/types/agent/tools/index.d.ts +5 -3
  29. package/types/agent/tools/node-repl.d.ts +28 -3
  30. package/types/agent/tools/pi-bridge.d.ts +6 -2
  31. package/types/agent/tools/shared/process-runner.d.ts +33 -0
  32. package/types/agent/tools/shared/semaphore.d.ts +29 -0
  33. package/types/agent/tools/web-browser-render.d.ts +16 -0
  34. package/types/agent/tools/web-controller.d.ts +20 -0
  35. package/types/agent/tools/web-fetch.d.ts +74 -5
  36. package/types/agent/tools/web-search.d.ts +81 -5
  37. package/types/ai/pi-oauth-compat.d.ts +57 -0
  38. package/types/ai/providers/pi-native/turn-runner.d.ts +33 -2
  39. package/types/ai/providers/pi-native.d.ts +12 -0
  40. package/types/ai/runtime/router.d.ts +23 -3
  41. package/types/ai/types.d.ts +174 -4
  42. package/types/ai/backend.d.ts +0 -57
  43. package/types/ai/registry.d.ts +0 -1
@@ -1,28 +1,580 @@
1
+ // @ts-check
2
+
3
+ import { parseHTML } from "linkedom";
1
4
  import { passthroughSandbox } from "../sandbox-seam.js";
2
5
  import { readToolRuntime } from "./shared/runtime-context.js";
3
6
  import { resolveSandboxPolicy } from "./shared/tool-context.js";
4
7
 
8
+ const SEARCH_TIMEOUT_MS = 15_000;
9
+ const SEARCH_RESPONSE_MAX_BYTES = 2 * 1024 * 1024;
10
+ const RRF_K = 60;
11
+ const TRACKING_PARAMETERS = new Set([
12
+ "dclid",
13
+ "fbclid",
14
+ "gclid",
15
+ "igshid",
16
+ "mc_cid",
17
+ "mc_eid",
18
+ "mkt_tok",
19
+ "msclkid",
20
+ "ref_src",
21
+ "s_cid",
22
+ "vero_conv",
23
+ "vero_id",
24
+ ]);
25
+
26
+ /**
27
+ * Compatibility wrapper for direct callers.
28
+ *
29
+ * @param {{query: string, limit?: number, alternate_queries?: string[], domains?: string[], exclude_domains?: string[], language?: string, time_range?: string}} params
30
+ * @param {{sandboxPolicy?: any, ctx?: any, signal?: AbortSignal, searchConfig?: any, fetchImpl?: typeof fetch}} [options]
31
+ */
32
+ export async function webSearchToolImpl(params, options = {}) {
33
+ return (await performWebSearch(params, options)).text;
34
+ }
35
+
5
36
  /**
6
- * @param {{query: string, limit?: number}} params
7
- * @param {{sandboxPolicy?: any, ctx?: any}} [options]
37
+ * Search through an operator-owned SearXNG endpoint and/or the keyless HTML
38
+ * fallback chain. Returns a structured internal outcome for the Pi bridge.
39
+ *
40
+ * @param {{query: string, limit?: number, alternate_queries?: string[], domains?: string[], exclude_domains?: string[], language?: string, time_range?: string}} params
41
+ * @param {{sandboxPolicy?: any, ctx?: any, signal?: AbortSignal, searchConfig?: any, fetchImpl?: typeof fetch}} [options]
8
42
  */
9
- export async function webSearchToolImpl({ query, limit = 5 }, { sandboxPolicy, ctx } = {}) {
10
- const max = Math.min(Math.max(Number(limit) || 5, 1), 10);
11
- const url = `https://duckduckgo.com/html/?q=${encodeURIComponent(query)}`;
43
+ export async function performWebSearch(
44
+ {
45
+ query,
46
+ limit = 5,
47
+ alternate_queries = [],
48
+ domains = [],
49
+ exclude_domains = [],
50
+ language,
51
+ time_range,
52
+ },
53
+ {
54
+ sandboxPolicy,
55
+ ctx,
56
+ signal,
57
+ searchConfig,
58
+ fetchImpl = globalThis.fetch,
59
+ } = {},
60
+ ) {
61
+ const startedAt = Date.now();
62
+ const normalizedQuery = typeof query === "string" ? query.trim() : "";
63
+ if (!normalizedQuery) {
64
+ return failure("Error: WebSearch query must not be empty.", "invalid_query", startedAt);
65
+ }
66
+ const max = clampInteger(limit, 1, 10, 5);
67
+ const includeDomains = normalizeDomains(domains);
68
+ const excludeDomains = normalizeDomains(exclude_domains);
69
+ const config = normalizeSearchConfig(searchConfig);
70
+ if (config.error) return failure(`Error: ${config.error}`, "invalid_search_config", startedAt);
71
+
12
72
  const resolvedCtx = ctx ?? readToolRuntime();
13
73
  const sandbox = resolvedCtx.sandbox ?? passthroughSandbox;
14
- if (!sandbox.networkAllowsUrl(resolveSandboxPolicy(resolvedCtx, sandboxPolicy), url)) return "Error: Network access denied by sandbox policy.";
15
- const resp = await fetch(url, {
16
- headers: { "User-Agent": "Mozilla/5.0 AgentRuntime/0.1" },
17
- signal: AbortSignal.timeout(15000),
74
+ const policy = resolveSandboxPolicy(resolvedCtx, sandboxPolicy);
75
+ const initialQueries = uniqueStrings([normalizedQuery, ...alternate_queries], 4);
76
+ /** @type {Array<Array<{title: string, url: string, snippet: string, backend: string}>>} */
77
+ const rankedLists = [];
78
+ const providerFailures = [];
79
+ const providersUsed = new Set();
80
+ let attempts = 0;
81
+ let anyProviderSucceeded = false;
82
+
83
+ const runQuery = async (candidate) => {
84
+ attempts += 1;
85
+ return await searchOneQuery(
86
+ queryWithDomains(candidate, includeDomains),
87
+ {
88
+ config,
89
+ language,
90
+ timeRange: time_range,
91
+ sandbox,
92
+ policy,
93
+ signal,
94
+ fetchImpl,
95
+ },
96
+ );
97
+ };
98
+ const recordResult = (result) => {
99
+ if (result.ok) {
100
+ anyProviderSucceeded = true;
101
+ providersUsed.add(result.backend);
102
+ rankedLists.push(filterByDomains(result.results, includeDomains, excludeDomains));
103
+ } else {
104
+ providerFailures.push(result);
105
+ }
106
+ };
107
+
108
+ const initialResults = await Promise.all(initialQueries.map(runQuery));
109
+ initialResults.forEach(recordResult);
110
+ if (signal?.aborted) {
111
+ return failure("Error: WebSearch was aborted.", "aborted", startedAt, {
112
+ attempts,
113
+ retryable: false,
114
+ });
115
+ }
116
+
117
+ let merged = mergeRankedResults(rankedLists, max);
118
+ if (merged.length < max && initialQueries.length < 4) {
119
+ const relaxed = relaxedQuery(normalizedQuery);
120
+ if (relaxed && !initialQueries.includes(relaxed)) {
121
+ recordResult(await runQuery(relaxed));
122
+ merged = mergeRankedResults(rankedLists, max);
123
+ }
124
+ }
125
+
126
+ if (!anyProviderSucceeded) {
127
+ const reason = providerFailures.map((entry) => entry.message).filter(Boolean).join("; ")
128
+ || "No search backend was available.";
129
+ const networkDenied = providerFailures.length > 0
130
+ && providerFailures.every((entry) => entry.message === "Network access denied by sandbox policy.");
131
+ return failure(networkDenied
132
+ ? "Error: Network access denied by sandbox policy."
133
+ : `Error: WebSearch failed: ${reason}`, networkDenied ? "network_denied" : "backend_unavailable", startedAt, {
134
+ attempts,
135
+ backend: config.backend,
136
+ retryable: providerFailures.some((entry) => entry.retryable),
137
+ });
138
+ }
139
+
140
+ const backend = providersUsed.size === 1 ? [...providersUsed][0] : "mixed";
141
+ const body = merged.length === 0
142
+ ? "No results."
143
+ : merged.map((result, index) => {
144
+ const snippet = result.snippet ? `\n ${collapseWhitespace(result.snippet)}` : "";
145
+ return `${index + 1}. [${escapeMarkdownLabel(result.title || result.url)}](${result.url})${snippet}`;
146
+ }).join("\n\n");
147
+ const text = [
148
+ "[BEGIN UNTRUSTED WEB SEARCH RESULTS]",
149
+ body,
150
+ "[END UNTRUSTED WEB SEARCH RESULTS]",
151
+ ].join("\n");
152
+ return {
153
+ text,
154
+ outcome: {
155
+ status: "ok",
156
+ code: merged.length === 0 ? "no_results" : "ok",
157
+ retryable: false,
158
+ attempts,
159
+ backend,
160
+ cacheHit: false,
161
+ durationMs: Date.now() - startedAt,
162
+ bytes: Buffer.byteLength(text, "utf8"),
163
+ truncated: false,
164
+ resultCount: merged.length,
165
+ providerFailureCount: providerFailures.length,
166
+ },
167
+ error: false,
168
+ };
169
+ }
170
+
171
+ async function searchOneQuery(query, options) {
172
+ const { config } = options;
173
+ const failures = [];
174
+ if (options.signal?.aborted) return abortedSearch(config.backend);
175
+ if (config.backend === "searxng" || (config.backend === "auto" && config.endpoint)) {
176
+ const result = await searchSearxng(query, options);
177
+ if (result.ok || config.backend === "searxng") return result;
178
+ failures.push(result);
179
+ if (options.signal?.aborted) return abortedSearch(result.backend);
180
+ }
181
+ if (config.backend === "keyless" || config.backend === "auto") {
182
+ const duck = await searchDuckDuckGo(query, options);
183
+ if (duck.ok && duck.results.length > 0) return duck;
184
+ if (!duck.ok) failures.push(duck);
185
+ if (options.signal?.aborted) return abortedSearch(duck.backend);
186
+ const startpage = await searchStartpage(query, options);
187
+ if (startpage.ok) return startpage;
188
+ failures.push(startpage);
189
+ if (duck.ok) return duck;
190
+ }
191
+ return failures[failures.length - 1] || {
192
+ ok: false,
193
+ backend: config.backend,
194
+ message: "No configured search backend.",
195
+ retryable: false,
196
+ };
197
+ }
198
+
199
+ function abortedSearch(backend) {
200
+ return {
201
+ ok: false,
202
+ backend,
203
+ message: "WebSearch was aborted.",
204
+ retryable: false,
205
+ };
206
+ }
207
+
208
+ async function searchSearxng(query, options) {
209
+ const endpoint = options.config.endpoint;
210
+ if (!endpoint) {
211
+ return { ok: false, backend: "searxng", message: "SearXNG endpoint is not configured.", retryable: false };
212
+ }
213
+ const url = `${endpoint}/search`;
214
+ if (!options.sandbox.networkAllowsUrl(options.policy, url)) {
215
+ return { ok: false, backend: "searxng", message: "Network access denied by sandbox policy.", retryable: false };
216
+ }
217
+ const body = new URLSearchParams({ q: query, format: "json", categories: "general" });
218
+ if (typeof options.language === "string" && options.language.trim()) {
219
+ body.set("language", options.language.trim());
220
+ }
221
+ if (["day", "month", "year"].includes(options.timeRange)) {
222
+ body.set("time_range", options.timeRange);
223
+ }
224
+ try {
225
+ const response = await options.fetchImpl(url, {
226
+ method: "POST",
227
+ headers: {
228
+ Accept: "application/json",
229
+ "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8",
230
+ "User-Agent": "mono-agent-web/1",
231
+ },
232
+ body,
233
+ signal: requestSignal(options.signal),
234
+ redirect: "error",
235
+ });
236
+ const text = await readLimitedText(response);
237
+ if (!response.ok) {
238
+ return {
239
+ ok: false,
240
+ backend: "searxng",
241
+ message: `SearXNG HTTP ${response.status}`,
242
+ retryable: response.status === 429 || response.status >= 500,
243
+ };
244
+ }
245
+ let data;
246
+ try { data = JSON.parse(text); } catch {
247
+ return { ok: false, backend: "searxng", message: "SearXNG returned invalid JSON.", retryable: false };
248
+ }
249
+ const results = Array.isArray(data?.results)
250
+ ? data.results.flatMap((entry) => normalizedResult(entry, "searxng"))
251
+ : [];
252
+ return { ok: true, backend: "searxng", results };
253
+ } catch (error) {
254
+ return fetchFailure("searxng", error);
255
+ }
256
+ }
257
+
258
+ async function searchDuckDuckGo(query, options) {
259
+ const url = `https://html.duckduckgo.com/html/?q=${encodeURIComponent(query)}`;
260
+ if (!options.sandbox.networkAllowsUrl(options.policy, url)) {
261
+ return { ok: false, backend: "duckduckgo", message: "Network access denied by sandbox policy.", retryable: false };
262
+ }
263
+ try {
264
+ const response = await options.fetchImpl(url, {
265
+ headers: {
266
+ Accept: "text/html,application/xhtml+xml",
267
+ "Accept-Language": options.language || "en-US,en;q=0.8",
268
+ "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) mono-agent-web/1",
269
+ },
270
+ signal: requestSignal(options.signal),
271
+ redirect: "error",
272
+ });
273
+ const html = await readLimitedText(response);
274
+ if (!response.ok) {
275
+ return {
276
+ ok: false,
277
+ backend: "duckduckgo",
278
+ message: `DuckDuckGo HTTP ${response.status}`,
279
+ retryable: response.status === 429 || response.status >= 500,
280
+ };
281
+ }
282
+ return { ok: true, backend: "duckduckgo", results: parseDuckDuckGoResults(html) };
283
+ } catch (error) {
284
+ return fetchFailure("duckduckgo", error);
285
+ }
286
+ }
287
+
288
+ async function searchStartpage(query, options) {
289
+ const url = `https://www.startpage.com/sp/search?query=${encodeURIComponent(query)}`;
290
+ if (!options.sandbox.networkAllowsUrl(options.policy, url)) {
291
+ return { ok: false, backend: "startpage", message: "Network access denied by sandbox policy.", retryable: false };
292
+ }
293
+ try {
294
+ const response = await options.fetchImpl(url, {
295
+ headers: {
296
+ Accept: "text/html,application/xhtml+xml",
297
+ "Accept-Language": options.language || "en-US,en;q=0.8",
298
+ "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) mono-agent-web/1",
299
+ },
300
+ signal: requestSignal(options.signal),
301
+ redirect: "error",
302
+ });
303
+ const html = await readLimitedText(response);
304
+ if (!response.ok) {
305
+ return {
306
+ ok: false,
307
+ backend: "startpage",
308
+ message: `Startpage HTTP ${response.status}`,
309
+ retryable: response.status === 429 || response.status >= 500,
310
+ };
311
+ }
312
+ return { ok: true, backend: "startpage", results: parseStartpageResults(html) };
313
+ } catch (error) {
314
+ return fetchFailure("startpage", error);
315
+ }
316
+ }
317
+
318
+ export function parseDuckDuckGoResults(html) {
319
+ const { document } = parseHTML(String(html || ""));
320
+ const rows = [...document.querySelectorAll(".result")];
321
+ return rows.flatMap((row) => {
322
+ const link = row.querySelector("a.result__a");
323
+ if (!link) return [];
324
+ const url = canonicalizeSearchUrl(link.getAttribute("href"), "https://html.duckduckgo.com/");
325
+ if (!url) return [];
326
+ return [{
327
+ title: collapseWhitespace(link.textContent),
328
+ url,
329
+ snippet: collapseWhitespace(row.querySelector(".result__snippet")?.textContent),
330
+ backend: "duckduckgo",
331
+ }];
18
332
  });
19
- if (!resp.ok) return `Search failed: HTTP ${resp.status}`;
20
- const html = await resp.text();
333
+ }
334
+
335
+ export function parseStartpageResults(html) {
336
+ const { document } = parseHTML(String(html || ""));
337
+ const selectors = [".w-gl__result", ".result", "article"];
338
+ const rows = selectors.flatMap((selector) => [...document.querySelectorAll(selector)]);
339
+ const seen = new Set();
21
340
  const results = [];
22
- const re = /<a[^>]*class="result__a"[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>[\s\S]*?<a[^>]*class="result__snippet"[^>]*>([\s\S]*?)<\/a>/g;
23
- let m;
24
- while ((m = re.exec(html)) && results.length < max) {
25
- results.push(`${m[2].replace(/<[^>]+>/g, "").trim()}\n${m[1]}\n${m[3].replace(/<[^>]+>/g, "").trim()}`);
341
+ for (const row of rows) {
342
+ const link = row.querySelector("a.w-gl__result-title, a.result-link, h2 a, h3 a");
343
+ if (!link) continue;
344
+ const url = canonicalizeSearchUrl(link.getAttribute("href"), "https://www.startpage.com/");
345
+ if (!url || seen.has(url)) continue;
346
+ seen.add(url);
347
+ results.push({
348
+ title: collapseWhitespace(link.textContent),
349
+ url,
350
+ snippet: collapseWhitespace(
351
+ row.querySelector(".w-gl__description, .result-description, p")?.textContent,
352
+ ),
353
+ backend: "startpage",
354
+ });
26
355
  }
27
- return results.length ? results.join("\n\n") : "No results.";
356
+ return results;
357
+ }
358
+
359
+ function normalizedResult(entry, backend) {
360
+ if (!entry || typeof entry !== "object") return [];
361
+ const url = canonicalizeSearchUrl(entry.url);
362
+ if (!url) return [];
363
+ return [{
364
+ title: collapseWhitespace(entry.title) || url,
365
+ url,
366
+ snippet: collapseWhitespace(entry.content || entry.snippet),
367
+ backend,
368
+ }];
369
+ }
370
+
371
+ export function canonicalizeSearchUrl(value, base) {
372
+ if (typeof value !== "string" || value.trim().length === 0) return null;
373
+ let parsed;
374
+ try { parsed = new URL(value, base); } catch { return null; }
375
+ const wrapped = ["uddg", "url", "u", "target"].map((key) => parsed.searchParams.get(key)).find(Boolean);
376
+ if (wrapped && (
377
+ parsed.hostname.endsWith("duckduckgo.com")
378
+ || parsed.hostname.endsWith("startpage.com")
379
+ )) {
380
+ try { parsed = new URL(wrapped); } catch { /* keep the wrapper URL */ }
381
+ }
382
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null;
383
+ parsed.username = "";
384
+ parsed.password = "";
385
+ parsed.hash = "";
386
+ parsed.hostname = parsed.hostname.toLowerCase();
387
+ for (const key of [...parsed.searchParams.keys()]) {
388
+ if (key.toLowerCase().startsWith("utm_") || TRACKING_PARAMETERS.has(key.toLowerCase())) {
389
+ parsed.searchParams.delete(key);
390
+ }
391
+ }
392
+ parsed.searchParams.sort();
393
+ if ((parsed.protocol === "https:" && parsed.port === "443") || (parsed.protocol === "http:" && parsed.port === "80")) {
394
+ parsed.port = "";
395
+ }
396
+ if (parsed.pathname.length > 1) parsed.pathname = parsed.pathname.replace(/\/+$/u, "");
397
+ return parsed.href;
398
+ }
399
+
400
+ export function mergeRankedResults(rankedLists, limit = 10) {
401
+ const merged = new Map();
402
+ for (const list of rankedLists) {
403
+ for (let index = 0; index < list.length; index += 1) {
404
+ const result = list[index];
405
+ const url = canonicalizeSearchUrl(result.url);
406
+ if (!url) continue;
407
+ const existing = merged.get(url);
408
+ const score = 1 / (RRF_K + index + 1);
409
+ if (existing) {
410
+ existing.score += score;
411
+ if (!existing.snippet && result.snippet) existing.snippet = result.snippet;
412
+ } else {
413
+ merged.set(url, { ...result, url, score });
414
+ }
415
+ }
416
+ }
417
+ return [...merged.values()]
418
+ .sort((left, right) => right.score - left.score || left.url.localeCompare(right.url))
419
+ .slice(0, clampInteger(limit, 1, 10, 10))
420
+ .map(({ score: _score, ...result }) => result);
421
+ }
422
+
423
+ function normalizeSearchConfig(input) {
424
+ const backend = input?.backend ?? "auto";
425
+ if (!["auto", "searxng", "keyless"].includes(backend)) {
426
+ return { error: "Web search backend must be auto, searxng, or keyless." };
427
+ }
428
+ let endpoint;
429
+ if (input?.endpoint !== undefined && String(input.endpoint).trim()) {
430
+ try {
431
+ const parsed = new URL(String(input.endpoint));
432
+ if (parsed.protocol !== "http:" || !isLoopbackHost(parsed.hostname) || parsed.username || parsed.password) {
433
+ return { error: "SearXNG endpoint must be an unauthenticated loopback http URL." };
434
+ }
435
+ if (parsed.search || parsed.hash) {
436
+ return { error: "SearXNG endpoint must not contain a query string or fragment." };
437
+ }
438
+ parsed.pathname = parsed.pathname.replace(/\/+$/u, "");
439
+ endpoint = parsed.href.replace(/\/+$/u, "");
440
+ } catch {
441
+ return { error: "SearXNG endpoint must be a valid loopback http URL." };
442
+ }
443
+ }
444
+ if (backend === "searxng" && !endpoint) {
445
+ return { error: "SearXNG backend requires tools.web.search.endpoint." };
446
+ }
447
+ return { backend, endpoint };
448
+ }
449
+
450
+ function isLoopbackHost(hostname) {
451
+ const value = hostname.toLowerCase().replace(/^\[|\]$/gu, "");
452
+ return value === "localhost" || value === "127.0.0.1" || value === "::1";
453
+ }
454
+
455
+ function normalizeDomains(values) {
456
+ if (!Array.isArray(values)) return [];
457
+ const out = [];
458
+ for (const value of values) {
459
+ if (typeof value !== "string") continue;
460
+ const normalized = value.trim().toLowerCase().replace(/^\*\./u, "").replace(/\.$/u, "");
461
+ if (!/^[a-z0-9.-]+$/u.test(normalized) || normalized.includes("..")) continue;
462
+ if (!out.includes(normalized)) out.push(normalized);
463
+ if (out.length >= 10) break;
464
+ }
465
+ return out;
466
+ }
467
+
468
+ function filterByDomains(results, include, exclude) {
469
+ return results.filter((result) => {
470
+ let host;
471
+ try { host = new URL(result.url).hostname.toLowerCase(); } catch { return false; }
472
+ if (exclude.some((domain) => domainMatches(host, domain))) return false;
473
+ return include.length === 0 || include.some((domain) => domainMatches(host, domain));
474
+ });
475
+ }
476
+
477
+ function domainMatches(host, domain) {
478
+ return host === domain || host.endsWith(`.${domain}`);
479
+ }
480
+
481
+ function queryWithDomains(query, domains) {
482
+ if (domains.length === 0) return query;
483
+ return `${query} (${domains.map((domain) => `site:${domain}`).join(" OR ")})`;
484
+ }
485
+
486
+ function relaxedQuery(query) {
487
+ const relaxed = query
488
+ .replace(/"([^"]+)"/gu, "$1")
489
+ .replace(/\bsite:\S+/giu, " ")
490
+ .replace(/\s+/gu, " ")
491
+ .trim();
492
+ return relaxed && relaxed !== query ? relaxed : null;
493
+ }
494
+
495
+ function uniqueStrings(values, limit) {
496
+ const out = [];
497
+ for (const value of Array.isArray(values) ? values : []) {
498
+ if (typeof value !== "string") continue;
499
+ const normalized = value.trim();
500
+ if (!normalized || out.includes(normalized)) continue;
501
+ out.push(normalized);
502
+ if (out.length >= limit) break;
503
+ }
504
+ return out;
505
+ }
506
+
507
+ function collapseWhitespace(value) {
508
+ return String(value || "").replace(/\s+/gu, " ").trim();
509
+ }
510
+
511
+ function escapeMarkdownLabel(value) {
512
+ return collapseWhitespace(value).replace(/[[\]\\]/gu, "\\$&");
513
+ }
514
+
515
+ function requestSignal(signal) {
516
+ const timeout = AbortSignal.timeout(SEARCH_TIMEOUT_MS);
517
+ return signal ? AbortSignal.any([signal, timeout]) : timeout;
518
+ }
519
+
520
+ async function readLimitedText(response) {
521
+ const reader = response.body?.getReader?.();
522
+ if (!reader) {
523
+ const text = await response.text();
524
+ if (Buffer.byteLength(text, "utf8") > SEARCH_RESPONSE_MAX_BYTES) {
525
+ throw new Error(`search response exceeded ${SEARCH_RESPONSE_MAX_BYTES} bytes`);
526
+ }
527
+ return text;
528
+ }
529
+ const chunks = [];
530
+ let bytes = 0;
531
+ while (true) {
532
+ const next = await reader.read();
533
+ if (next.done) break;
534
+ bytes += next.value.byteLength;
535
+ if (bytes > SEARCH_RESPONSE_MAX_BYTES) {
536
+ try { await reader.cancel(); } catch { /* best effort */ }
537
+ throw new Error(`search response exceeded ${SEARCH_RESPONSE_MAX_BYTES} bytes`);
538
+ }
539
+ chunks.push(Buffer.from(next.value));
540
+ }
541
+ return Buffer.concat(chunks).toString("utf8");
542
+ }
543
+
544
+ function fetchFailure(backend, error) {
545
+ const name = error?.name;
546
+ const retryable = name === "AbortError"
547
+ || name === "TimeoutError"
548
+ || ["ECONNRESET", "ECONNREFUSED", "ETIMEDOUT", "EAI_AGAIN"].includes(error?.code ?? error?.cause?.code);
549
+ return {
550
+ ok: false,
551
+ backend,
552
+ message: `${backend} request failed: ${error?.message || String(error)}`,
553
+ retryable,
554
+ };
555
+ }
556
+
557
+ function failure(text, code, startedAt, extra = {}) {
558
+ return {
559
+ text,
560
+ outcome: {
561
+ status: "error",
562
+ code,
563
+ retryable: false,
564
+ attempts: 0,
565
+ backend: "none",
566
+ cacheHit: false,
567
+ durationMs: Date.now() - startedAt,
568
+ bytes: Buffer.byteLength(text, "utf8"),
569
+ truncated: false,
570
+ ...extra,
571
+ },
572
+ error: true,
573
+ };
574
+ }
575
+
576
+ function clampInteger(value, min, max, fallback) {
577
+ const number = Number(value);
578
+ if (!Number.isFinite(number)) return fallback;
579
+ return Math.max(min, Math.min(max, Math.floor(number)));
28
580
  }
@@ -3,7 +3,7 @@
3
3
  // directly so the runtime's known-good Pi version remains authoritative.
4
4
 
5
5
  import { getBuiltinModel, getBuiltinModels } from "@earendil-works/pi-ai/providers/all";
6
- import { getOAuthApiKey, getOAuthProvider } from "@earendil-works/pi-ai/oauth";
6
+ import { getPiOAuthAuth, resolveOAuthApiKey, toAuthInteraction } from "./pi-oauth-compat.js";
7
7
  import { reasoningLevelsForPiModel as resolveReasoningLevels } from "./providers/pi-models.js";
8
8
 
9
9
  /**
@@ -122,7 +122,7 @@ export function reasoningLevelsForPiModel(model) {
122
122
  * @returns {Promise<{apiKey: string, newCredentials: PiOAuthCredentialsSnapshot}|null>}
123
123
  */
124
124
  export async function resolvePiOAuthApiKey(providerId, credentials) {
125
- const result = await getOAuthApiKey(
125
+ const result = await resolveOAuthApiKey(
126
126
  providerId,
127
127
  /** @type {any} */ (cloneInteropValue(credentials)),
128
128
  );
@@ -142,8 +142,8 @@ export async function resolvePiOAuthApiKey(providerId, credentials) {
142
142
  * @returns {Promise<PiOAuthCredentialsSnapshot>}
143
143
  */
144
144
  export async function loginPiOAuth(providerId, callbacks) {
145
- const provider = getOAuthProvider(providerId);
146
- if (!provider || typeof provider.login !== "function") {
145
+ const oauth = getPiOAuthAuth(providerId);
146
+ if (!oauth || typeof oauth.login !== "function") {
147
147
  throw new Error(`Pi OAuth provider is unavailable: ${providerId}`);
148
148
  }
149
149
  for (const callbackName of ["onAuth", "onDeviceCode", "onPrompt", "onSelect"]) {
@@ -151,6 +151,8 @@ export async function loginPiOAuth(providerId, callbacks) {
151
151
  throw new TypeError(`loginPiOAuth requires callbacks.${callbackName}()`);
152
152
  }
153
153
  }
154
- const credentials = await provider.login(/** @type {any} */ ({ ...callbacks }));
154
+ const credentials = await oauth.login(
155
+ toAuthInteraction(/** @type {any} */ ({ ...callbacks })),
156
+ );
155
157
  return cloneInteropValue(credentials);
156
158
  }