pi-webfind 0.5.2 → 0.6.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/README.md +14 -2
- package/extensions/web-search.ts +162 -73
- package/lib/adapters.ts +295 -117
- package/lib/apis.ts +2 -0
- package/lib/cache.ts +42 -16
- package/lib/engine.ts +489 -91
- package/lib/extract.ts +377 -61
- package/lib/fetcher.ts +443 -217
- package/lib/net.ts +93 -0
- package/lib/rank.ts +89 -16
- package/lib/safe.ts +94 -0
- package/lib/version.ts +1 -1
- package/package.json +20 -16
- package/themes/claude-dark.json +80 -0
package/README.md
CHANGED
|
@@ -106,8 +106,20 @@ with it:
|
|
|
106
106
|
- **Never executes or writes fetched content**; it reads URLs and returns text
|
|
107
107
|
- Politeness throttle per host; fake-browser UA only where required, honest
|
|
108
108
|
`pi-webfind/x.y` UA everywhere it matters
|
|
109
|
-
- The only optional
|
|
110
|
-
|
|
109
|
+
- The only optional credentials are listed below. Everything else is keyless by design.
|
|
110
|
+
|
|
111
|
+
## Optional accelerators (still fully free without them)
|
|
112
|
+
|
|
113
|
+
| Env var | Effect when set | Without it |
|
|
114
|
+
| --- | --- | --- |
|
|
115
|
+
| `GITHUB_TOKEN` | Lifts GitHub API from 10 to ~30 req/min in site adapters | 10 req/min anonymous |
|
|
116
|
+
| `JINA_API_KEY` | Reader relay throttle 3.5 s → 300 ms; enables `s.jina.ai` search leg in `engine:"multi"` | 3.5 s gap, no jina search leg |
|
|
117
|
+
| `BRAVE_API_KEY` | Adds Brave's official JSON API as the first `engine:"multi"` attempt | scraped Brave only |
|
|
118
|
+
| `TAVILY_API_KEY` | Adds Tavily as a `engine:"multi"` attempt | no Tavily |
|
|
119
|
+
|
|
120
|
+
Every var is optional; with none set, behavior is identical to the baseline:
|
|
121
|
+
keyless scraping with the fallback ladder. Keys are never required and never
|
|
122
|
+
sent anywhere except their own API host.
|
|
111
123
|
|
|
112
124
|
## Limits (be honest about free)
|
|
113
125
|
|
package/extensions/web-search.ts
CHANGED
|
@@ -20,7 +20,10 @@ import {
|
|
|
20
20
|
cacheSet,
|
|
21
21
|
ddgSearch,
|
|
22
22
|
multiSearch,
|
|
23
|
+
relevanceGate,
|
|
24
|
+
searchRace,
|
|
23
25
|
type Recency,
|
|
26
|
+
type SearchOutcome,
|
|
24
27
|
type SearchResult,
|
|
25
28
|
} from "../lib/engine.ts";
|
|
26
29
|
import {
|
|
@@ -31,11 +34,13 @@ import {
|
|
|
31
34
|
searchWikipedia,
|
|
32
35
|
} from "../lib/apis.ts";
|
|
33
36
|
import { smartFetch, type FetchOptions } from "../lib/fetcher.ts";
|
|
37
|
+
import { isOffline } from "../lib/net.ts";
|
|
34
38
|
|
|
35
39
|
const MAX = (n?: number, dflt = 8, cap = 20) => Math.min(Math.max(n ?? dflt, 1), cap);
|
|
36
40
|
const clip = (s: string, n: number) => (s.length > n ? s.slice(0, n - 1) + "…" : s);
|
|
37
41
|
const secs = (ms: number) => `${(ms / 1000).toFixed(1)}s`;
|
|
38
42
|
|
|
43
|
+
/** Display row — structurally accepts SearchResult (engine rows) and ApiResult (API rows). */
|
|
39
44
|
interface Row {
|
|
40
45
|
title: string;
|
|
41
46
|
url: string;
|
|
@@ -43,6 +48,10 @@ interface Row {
|
|
|
43
48
|
meta?: string;
|
|
44
49
|
/** publication date when the engine provides one */
|
|
45
50
|
date?: string;
|
|
51
|
+
/** "indexed" = crawl stamp (e.g. Bing pubDate), not a publication date */
|
|
52
|
+
dateKind?: "published" | "indexed";
|
|
53
|
+
/** every engine that returned this URL (set by multiSearch fusion) */
|
|
54
|
+
engines?: string[];
|
|
46
55
|
/** deep mode: query-relevant excerpt fetched from the page itself */
|
|
47
56
|
excerpt?: string;
|
|
48
57
|
}
|
|
@@ -51,7 +60,10 @@ function fmtResults(results: Row[]): string {
|
|
|
51
60
|
if (results.length === 0) return "No results found.";
|
|
52
61
|
return results
|
|
53
62
|
.map((r, i) => {
|
|
54
|
-
|
|
63
|
+
// (indexed 2026-09-04) marks a crawl stamp, not a publication date
|
|
64
|
+
const date = r.date ? (r.dateKind === "indexed" ? ` (indexed ${r.date})` : ` (${r.date})`) : "";
|
|
65
|
+
const via = (r.engines ?? []).length > 0 ? ` · ${(r.engines ?? []).join("+")}` : "";
|
|
66
|
+
const lines = [`${i + 1}. ${r.title}`, ` ${r.url}${date}${via}`];
|
|
55
67
|
if (r.meta) lines.push(` ${r.meta}`);
|
|
56
68
|
if (r.excerpt) lines.push(` excerpt: ${clip(r.excerpt, 400)}`);
|
|
57
69
|
else if (r.snippet) lines.push(` ${clip(r.snippet, 250)}`);
|
|
@@ -76,6 +88,22 @@ interface Theme {
|
|
|
76
88
|
* ⎿ via ddg · (ctrl+o to expand)
|
|
77
89
|
* Green dot marks state, live ticking elapsed while running.
|
|
78
90
|
*/
|
|
91
|
+
// Status-aware dot (Claude Code parity): dim while running, red on error,
|
|
92
|
+
// green on success. Truecolor only when the terminal advertises it —
|
|
93
|
+
// theme.fg() throws on raw hex, so non-truecolor uses theme names.
|
|
94
|
+
const TRUECOLOR =
|
|
95
|
+
process.env.COLORTERM === "truecolor" || /256color|truecolor/.test(process.env.TERM ?? "");
|
|
96
|
+
const dot = (t: Theme, phase: string | undefined) =>
|
|
97
|
+
phase === "error"
|
|
98
|
+
? TRUECOLOR
|
|
99
|
+
? "\x1b[38;2;255;107;128m⏺ \x1b[39m"
|
|
100
|
+
: t.fg("error", "⏺ ")
|
|
101
|
+
: phase === "ok"
|
|
102
|
+
? TRUECOLOR
|
|
103
|
+
? "\x1b[38;2;78;186;101m⏺ \x1b[39m"
|
|
104
|
+
: t.fg("success", "⏺ ")
|
|
105
|
+
: t.fg("dim", "⏺ ");
|
|
106
|
+
|
|
79
107
|
function makeRenderers(
|
|
80
108
|
toolName: string,
|
|
81
109
|
argDetail: (args: any) => string,
|
|
@@ -91,9 +119,9 @@ function makeRenderers(
|
|
|
91
119
|
}
|
|
92
120
|
const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
|
|
93
121
|
text.setText(
|
|
94
|
-
t
|
|
122
|
+
dot(t, context.state.phase) +
|
|
95
123
|
t.fg("toolTitle", t.bold(toolName)) +
|
|
96
|
-
t.fg("
|
|
124
|
+
t.fg("text", `(${JSON.stringify(clip(String(argDetail(args ?? {})), 70))})`),
|
|
97
125
|
);
|
|
98
126
|
return text;
|
|
99
127
|
},
|
|
@@ -107,9 +135,19 @@ function makeRenderers(
|
|
|
107
135
|
const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
|
|
108
136
|
const state = context.state ?? {};
|
|
109
137
|
|
|
110
|
-
// live elapsed ticking while partial (1s interval, like pi's bash renderer)
|
|
138
|
+
// live elapsed ticking while partial (1s interval, like pi's bash renderer);
|
|
139
|
+
// unref'd so it can't hold the process, and self-cleaning when the row is
|
|
140
|
+
// dropped by /new or compaction (lastComponent falsy)
|
|
111
141
|
if (state.startedAt !== undefined && options.isPartial && !state.interval) {
|
|
112
|
-
state.interval = setInterval(() =>
|
|
142
|
+
state.interval = setInterval(() => {
|
|
143
|
+
if (!context.lastComponent) {
|
|
144
|
+
clearInterval(state.interval);
|
|
145
|
+
state.interval = undefined;
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
context.invalidate?.();
|
|
149
|
+
}, 1000);
|
|
150
|
+
state.interval.unref?.();
|
|
113
151
|
}
|
|
114
152
|
if (!options.isPartial && state.interval) {
|
|
115
153
|
clearInterval(state.interval);
|
|
@@ -117,13 +155,21 @@ function makeRenderers(
|
|
|
117
155
|
}
|
|
118
156
|
|
|
119
157
|
if (options.isPartial) {
|
|
120
|
-
|
|
158
|
+
if (state.phase !== "running") {
|
|
159
|
+
state.phase = "running";
|
|
160
|
+
context.invalidate?.();
|
|
161
|
+
}
|
|
162
|
+
const step = result?.details?.step ?? "Searching…";
|
|
121
163
|
const elapsed = state.startedAt !== undefined ? secs(Date.now() - state.startedAt) : "";
|
|
122
164
|
text.setText(t.fg("warning", ` ⎿ ${step}${elapsed ? ` · ${elapsed}` : ""}`));
|
|
123
165
|
return text;
|
|
124
166
|
}
|
|
125
167
|
|
|
126
168
|
const isError = result?.isError || result?.details?.error;
|
|
169
|
+
if (state.phase !== (isError ? "error" : "ok")) {
|
|
170
|
+
state.phase = isError ? "error" : "ok";
|
|
171
|
+
context.invalidate?.();
|
|
172
|
+
}
|
|
127
173
|
if (isError) {
|
|
128
174
|
const msg = result?.details?.error ?? result?.content?.[0]?.text ?? "failed";
|
|
129
175
|
text.setText(t.fg("error", ` ⎿ ${clip(String(msg), 160)}`));
|
|
@@ -187,7 +233,7 @@ function registerSearchTool(
|
|
|
187
233
|
const started = Date.now();
|
|
188
234
|
onUpdate?.({
|
|
189
235
|
content: [{ type: "text", text: `${label}…` }],
|
|
190
|
-
details: {
|
|
236
|
+
details: { step: `Searching ${JSON.stringify(clip(params.query, 50))}…` },
|
|
191
237
|
});
|
|
192
238
|
try {
|
|
193
239
|
const results = await run(params.query, MAX(params.max), signal);
|
|
@@ -219,21 +265,26 @@ export default function (pi: ExtensionAPI) {
|
|
|
219
265
|
"Use for news, articles, broad topics. Use fetch_page to read a result in full. " +
|
|
220
266
|
"engine='multi' merges both engines in parallel for best coverage. " +
|
|
221
267
|
"For research questions, run 2-3 queries with varied phrasings (add a year, quote the exact error, add 'docs' or 'github'); " +
|
|
222
|
-
"set deep:true when you need facts rather than links
|
|
268
|
+
"set deep:true when you need facts rather than links — often removes the need for fetch_page; " +
|
|
269
|
+
"prefer recent results for fast-moving topics and cite the date you relied on.",
|
|
223
270
|
promptSnippet: "Free multi-engine web search (DDG + Brave) with recency filter",
|
|
224
271
|
promptGuidelines: [
|
|
225
|
-
"
|
|
272
|
+
"For multi-facet questions (comparisons, 'X vs Y', ecosystem scans), run 2-3 differently-phrased web_search calls in one turn instead of one; dedupe URLs, then fetch_page only the 1-2 results that actually answer the question.",
|
|
273
|
+
"Prefer deep:true over separate fetch_page calls when you need facts from several results; it returns excerpts inline in one round-trip.",
|
|
274
|
+
"Never fetch URLs the user must authenticate to (their email, admin panels, private dashboards) — even private-looking hostnames are rejected by SSRF protection; ask the user to paste the content instead.",
|
|
275
|
+
"Use fetch_page for a specific URL; use web_search first when you'd have to guess the URL.",
|
|
226
276
|
],
|
|
227
277
|
parameters: Type.Object({
|
|
228
278
|
query: Type.String({ description: "Search query" }),
|
|
229
279
|
max_results: Type.Optional(Type.Number({ description: "Max results, 1-20 (default 8)" })),
|
|
230
280
|
recency: Type.Optional(Type.String({ description: "d=day, w=week, m=month, y=year (optional)" })),
|
|
231
|
-
|
|
281
|
+
lang: Type.Optional(Type.String({ description: "BCP-47 language-region, e.g. 'es-ES', 'ja-JP'. Biases results to that locale." })),
|
|
282
|
+
engine: Type.Optional(Type.String({ description: "auto (default: ddg+bing race) | ddg | brave | bing | multi (all in parallel)" })),
|
|
232
283
|
refresh: Type.Optional(Type.Boolean({ description: "Skip the 10-minute cache" })),
|
|
233
284
|
deep: Type.Optional(
|
|
234
285
|
Type.Union([Type.Boolean(), Type.Number()], {
|
|
235
286
|
description:
|
|
236
|
-
"Read the top results and attach a query-relevant excerpt to each. true = 4 results; or a number 1-8. Use when you need facts, not just links
|
|
287
|
+
"Read the top results and attach a query-relevant excerpt to each. true = 4 results; or a number 1-8. Use when you need facts, not just links.",
|
|
237
288
|
}),
|
|
238
289
|
),
|
|
239
290
|
}),
|
|
@@ -243,30 +294,37 @@ export default function (pi: ExtensionAPI) {
|
|
|
243
294
|
const recency = (["d", "w", "m", "y"] as const).includes(params.recency as any)
|
|
244
295
|
? (params.recency as Recency)
|
|
245
296
|
: undefined;
|
|
246
|
-
const engine = params.engine ?? "
|
|
247
|
-
const
|
|
248
|
-
const
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
297
|
+
const engine = params.engine ?? "auto";
|
|
298
|
+
const deepN = params.deep === true ? 4 : typeof params.deep === "number" ? Math.min(Math.max(Math.round(params.deep), 1), 8) : 0;
|
|
299
|
+
const lang = typeof params.lang === "string" && /^[a-z]{2}(-[A-Za-z]{2,4})?$/.test(params.lang.trim()) ? params.lang.trim() : undefined;
|
|
300
|
+
const cacheKey = `s:${engine}:${recency ?? ""}:${lang ?? ""}:${maxResults}:${params.query}:deep${deepN}`;
|
|
301
|
+
const run = async (): Promise<SearchOutcome> => {
|
|
302
|
+
if (engine === "multi" || engine === "auto") {
|
|
303
|
+
onUpdate?.({
|
|
304
|
+
content: [{ type: "text", text: "…" }],
|
|
305
|
+
details: { step: engine === "multi" ? "querying ddg + brave + bing in parallel…" : "querying ddg + bing…" },
|
|
306
|
+
});
|
|
307
|
+
const r = engine === "multi"
|
|
308
|
+
? await multiSearch(params.query, maxResults, recency, signal, lang)
|
|
309
|
+
: await searchRace(params.query, maxResults, recency, signal, lang);
|
|
310
|
+
return r;
|
|
253
311
|
}
|
|
254
312
|
if (engine === "brave") {
|
|
255
|
-
onUpdate?.({ content: [{ type: "text", text: "…" }], details: {
|
|
256
|
-
|
|
313
|
+
onUpdate?.({ content: [{ type: "text", text: "…" }], details: { step: "querying brave…" } });
|
|
314
|
+
const rows = await braveSearch(params.query, maxResults, recency, signal, lang);
|
|
315
|
+
const kept = relevanceGate(params.query, rows) as Row[];
|
|
316
|
+
return { results: kept as unknown as SearchResult[], engines: ["brave"], errors: [], stats: { brave: { got: rows.length, kept: kept.length } } };
|
|
257
317
|
}
|
|
258
318
|
if (engine === "bing") {
|
|
259
|
-
onUpdate?.({ content: [{ type: "text", text: "…" }], details: {
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
try {
|
|
264
|
-
return { results: (await ddgSearch(params.query, maxResults, recency, signal)) as Row[], engines: ["ddg"], errors: [] as string[] };
|
|
265
|
-
} catch (err) {
|
|
266
|
-
// ddg fully failed — structured bing rss before giving up
|
|
267
|
-
onUpdate?.({ content: [{ type: "text", text: "…" }], details: { status: "ddg failed — trying bing rss…" } });
|
|
268
|
-
return { results: (await bingRssSearch(params.query, maxResults, recency, signal)) as Row[], engines: ["bing"], errors: [String((err as Error)?.message ?? err)] };
|
|
319
|
+
onUpdate?.({ content: [{ type: "text", text: "…" }], details: { step: "querying bing rss…" } });
|
|
320
|
+
const rows = await bingRssSearch(params.query, maxResults, recency, signal, lang);
|
|
321
|
+
const kept = relevanceGate(params.query, rows) as Row[];
|
|
322
|
+
return { results: kept as unknown as SearchResult[], engines: ["bing"], errors: [], stats: { bing: { got: rows.length, kept: kept.length } } };
|
|
269
323
|
}
|
|
324
|
+
onUpdate?.({ content: [{ type: "text", text: "…" }], details: { step: "querying duckduckgo…" } });
|
|
325
|
+
const rows = await ddgSearch(params.query, maxResults, recency, signal, lang);
|
|
326
|
+
const kept = relevanceGate(params.query, rows) as Row[];
|
|
327
|
+
return { results: kept as unknown as SearchResult[], engines: ["ddg"], errors: [], stats: { ddg: { got: rows.length, kept: kept.length } } };
|
|
270
328
|
};
|
|
271
329
|
try {
|
|
272
330
|
let cachedHit = false;
|
|
@@ -274,19 +332,26 @@ export default function (pi: ExtensionAPI) {
|
|
|
274
332
|
const hit = cacheGet(cacheKey);
|
|
275
333
|
if (hit) {
|
|
276
334
|
cachedHit = true;
|
|
335
|
+
// rows carry their engine set post-fusion; derive engines from them so the
|
|
336
|
+
// renderer shows "via cache · ddg + bing", not "via cache · cache"
|
|
337
|
+
const hitEngines = hit.engines.length > 0
|
|
338
|
+
? hit.engines
|
|
339
|
+
: [...new Set(hit.results.flatMap((r) => r.engines ?? [r.engine]))];
|
|
277
340
|
return {
|
|
278
|
-
content: [{ type: "text", text: `[cached]\n${fmtResults(hit)}` }],
|
|
279
|
-
details: { cached: true, results: hit, count: hit.length, durationMs: Date.now() - started },
|
|
341
|
+
content: [{ type: "text", text: `[cached]\n${fmtResults(hit.results)}` }],
|
|
342
|
+
details: { cached: true, results: hit.results, engines: hitEngines, count: hit.results.length, durationMs: Date.now() - started },
|
|
280
343
|
};
|
|
281
344
|
}
|
|
282
345
|
}
|
|
283
|
-
const { results, engines, errors } = await run();
|
|
346
|
+
const { results, engines, errors, stats } = await run();
|
|
347
|
+
if (results.length === 0) {
|
|
348
|
+
throw new Error(`all engines failed — ${errors.join("; ") || "no results from any engine"}`);
|
|
349
|
+
}
|
|
284
350
|
// deep mode: read top results in parallel, attach query-relevant excerpts
|
|
285
|
-
const deepN = params.deep === true ? 4 : typeof params.deep === "number" ? Math.min(Math.max(Math.round(params.deep), 1), 8) : 0;
|
|
286
351
|
if (deepN > 0 && results.length > 0 && !cachedHit) {
|
|
287
352
|
onUpdate?.({
|
|
288
353
|
content: [{ type: "text", text: "…" }],
|
|
289
|
-
details: {
|
|
354
|
+
details: { step: `reading top ${Math.min(deepN, results.length)} results for excerpts…` },
|
|
290
355
|
});
|
|
291
356
|
const top = results.slice(0, deepN);
|
|
292
357
|
const deadline = Date.now() + 25_000;
|
|
@@ -302,52 +367,51 @@ export default function (pi: ExtensionAPI) {
|
|
|
302
367
|
signal,
|
|
303
368
|
} satisfies FetchOptions);
|
|
304
369
|
const body = page.text.replace(/\n\n\[\d+ of \d+ passages shown[^\n]*\n?\n?$/, "");
|
|
305
|
-
|
|
370
|
+
// pick the highest-scoring passage (its heading prefix aids the model),
|
|
371
|
+
// falling back to the intro only when nothing scored above zero
|
|
372
|
+
const best = (page.passages ?? [])
|
|
373
|
+
.filter((p) => p.score > 0)
|
|
374
|
+
.sort((a, b) => b.score - a.score)[0];
|
|
375
|
+
const first = best
|
|
376
|
+
? best.heading
|
|
377
|
+
? `${best.heading}\n${best.text}`
|
|
378
|
+
: best.text
|
|
379
|
+
: body
|
|
380
|
+
.split("\n\n")
|
|
381
|
+
.find((p) => !/^(via |\[|\d+\.)/.test(p.trim()));
|
|
306
382
|
if (first && first.trim().length > 80) {
|
|
307
383
|
(r as Row).excerpt = clip(first.trim().replace(/\n+/g, " "), 500);
|
|
308
384
|
}
|
|
385
|
+
if (page.date) (r as Row).date = page.date;
|
|
309
386
|
} catch {
|
|
310
387
|
/* ship without excerpt */
|
|
311
388
|
}
|
|
312
389
|
}),
|
|
313
390
|
);
|
|
314
391
|
}
|
|
315
|
-
if (results.length > 0) cacheSet(cacheKey, results);
|
|
392
|
+
if (results.length > 0) cacheSet(cacheKey, { results: results as SearchResult[], engines });
|
|
393
|
+
// surface gated-out rows in the header so the model knows what was filtered
|
|
394
|
+
const gated = Object.entries(stats)
|
|
395
|
+
.filter(([, s]) => s.kept < s.got)
|
|
396
|
+
.map(([name, s]) => `${name} ${s.got}→${s.kept} kept`);
|
|
316
397
|
return {
|
|
317
398
|
content: [
|
|
318
399
|
{
|
|
319
400
|
type: "text",
|
|
320
|
-
text: `[via ${engines.join(" + ")}]${errors.length ? ` (failed: ${errors.join("; ")})` : ""}\n\n${fmtResults(results)}`,
|
|
401
|
+
text: `[via ${engines.join(" + ")}${gated.length ? ` · ${gated.join(", ")}` : ""}]${errors.length ? ` (failed: ${errors.join("; ")})` : ""}\n\n${fmtResults(results)}`,
|
|
321
402
|
},
|
|
322
403
|
],
|
|
323
|
-
details: { results, count: results.length, engines, errors, durationMs: Date.now() - started },
|
|
404
|
+
details: { results, count: results.length, engines, errors, stats, durationMs: Date.now() - started },
|
|
324
405
|
};
|
|
325
406
|
} catch (err: any) {
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
const r = await multiSearch(params.query, maxResults, recency, signal);
|
|
330
|
-
if (r.results.length > 0) {
|
|
331
|
-
cacheSet(cacheKey, r.results);
|
|
332
|
-
return {
|
|
333
|
-
content: [
|
|
334
|
-
{
|
|
335
|
-
type: "text",
|
|
336
|
-
text: `${fmtResults(r.results)}\n\n[primary engine '${engine}' failed: ${err?.message ?? err}]`,
|
|
337
|
-
},
|
|
338
|
-
],
|
|
339
|
-
details: { results: r.results, count: r.results.length, engines: r.engines, durationMs: Date.now() - started },
|
|
340
|
-
};
|
|
341
|
-
}
|
|
342
|
-
} catch {
|
|
343
|
-
/* fall through */
|
|
344
|
-
}
|
|
345
|
-
}
|
|
407
|
+
const advice = isOffline()
|
|
408
|
+
? "Network appears offline."
|
|
409
|
+
: "Engines may be rate-limited — wait a minute or retry with refresh=true.";
|
|
346
410
|
return {
|
|
347
411
|
content: [
|
|
348
412
|
{
|
|
349
413
|
type: "text",
|
|
350
|
-
text: `Search error: ${err?.message ?? err}.
|
|
414
|
+
text: `Search error: ${err?.message ?? err}. ${advice}`,
|
|
351
415
|
},
|
|
352
416
|
],
|
|
353
417
|
details: { error: err?.message ?? String(err), durationMs: Date.now() - started },
|
|
@@ -359,7 +423,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
359
423
|
"Web Search",
|
|
360
424
|
(args) => String(args.query ?? ""),
|
|
361
425
|
(d) => {
|
|
362
|
-
const eng = (d.engines ?? []).join(" + ") || (d.cached ? "cache" : "
|
|
426
|
+
const eng = (d.engines ?? []).join(" + ") || (d.cached ? "cache" : "");
|
|
363
427
|
const line1 = `Found ${d.count ?? 0} results in ${secs(d.durationMs ?? 0)}`;
|
|
364
428
|
const line2 = d.cached
|
|
365
429
|
? `via cache · ${eng}`
|
|
@@ -379,7 +443,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
379
443
|
"Fetch a URL and return readable content. Handles HTML (article extraction, nav/ads stripped), " +
|
|
380
444
|
"JSON (pretty-printed), plain text, and PDFs (text extraction). On 401/403/429/503 automatically " +
|
|
381
445
|
"retries via the Wayback Machine; thin/SPA pages re-rendered via a reader proxy. SSRF-protected. " +
|
|
382
|
-
|
|
446
|
+
"Pass query to get the most relevant passages of a long page instead of its head. Cached 1h. " +
|
|
447
|
+
"no_jina=true opts out of the reader proxy (raw HTML only, e.g. for JSON/text or when the " +
|
|
448
|
+
"reader proxy would leak custom headers).",
|
|
383
449
|
promptSnippet: "Fetch a URL → readable text; handles PDFs, JSON, bot-walls (Wayback fallback)",
|
|
384
450
|
parameters: Type.Object({
|
|
385
451
|
url: Type.String({ description: "URL to fetch (http/https only)" }),
|
|
@@ -390,6 +456,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
390
456
|
}),
|
|
391
457
|
),
|
|
392
458
|
max_chars: Type.Optional(Type.Number({ description: "Max text chars (default 8000, max 50000)" })),
|
|
459
|
+
offset: Type.Optional(Type.Number({ description: "Char offset into the document for paging (from the truncation footer)" })),
|
|
393
460
|
raw: Type.Optional(Type.Boolean({ description: "Return raw HTML instead of extracted text" })),
|
|
394
461
|
timeout: Type.Optional(Type.Number({ description: "Timeout ms (1000-60000, default 15000)" })),
|
|
395
462
|
headers: Type.Optional(
|
|
@@ -399,6 +466,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
399
466
|
),
|
|
400
467
|
no_cache: Type.Optional(Type.Boolean({ description: "Skip the 1-hour cache" })),
|
|
401
468
|
no_wayback: Type.Optional(Type.Boolean({ description: "Disable Wayback Machine fallback" })),
|
|
469
|
+
no_jina: Type.Optional(Type.Boolean({ description: "Disable the reader-proxy fallback" })),
|
|
402
470
|
allow_http_errors: Type.Optional(
|
|
403
471
|
Type.Boolean({
|
|
404
472
|
description:
|
|
@@ -410,20 +478,29 @@ export default function (pi: ExtensionAPI) {
|
|
|
410
478
|
const started = Date.now();
|
|
411
479
|
try {
|
|
412
480
|
const u = new URL(params.url);
|
|
413
|
-
onUpdate?.({ content: [{ type: "text", text: "…" }], details: {
|
|
481
|
+
onUpdate?.({ content: [{ type: "text", text: "…" }], details: { step: `fetching ${u.host}…` } });
|
|
414
482
|
const r = await smartFetch(params.url, {
|
|
415
483
|
query: (params.query as string | undefined)?.trim() || undefined,
|
|
416
484
|
maxChars: MAX(params.max_chars, 8000, 50_000),
|
|
485
|
+
offset: typeof params.offset === "number" ? Math.max(0, Math.floor(params.offset)) : undefined,
|
|
417
486
|
raw: params.raw,
|
|
418
487
|
timeoutMs: params.timeout ? Math.min(Math.max(params.timeout, 1000), 60_000) : undefined,
|
|
419
488
|
headers: params.headers as Record<string, string> | undefined,
|
|
420
489
|
waybackEnabled: !params.no_wayback,
|
|
421
490
|
allowHttpErrors: params.allow_http_errors,
|
|
491
|
+
jinaEnabled: params.no_jina === true ? false : undefined,
|
|
492
|
+
jinaQuery: (params.query as string | undefined)?.trim() || undefined,
|
|
422
493
|
signal,
|
|
423
494
|
} satisfies FetchOptions);
|
|
424
495
|
const tags = [
|
|
425
496
|
`HTTP ${r.status}`,
|
|
426
|
-
r.source
|
|
497
|
+
r.source !== "direct" ? `via ${r.source}` : null,
|
|
498
|
+
r.source === "wayback" && r.waybackDate ? r.waybackDate : null,
|
|
499
|
+
r.date ?? null,
|
|
500
|
+
typeof r.offset === "number" && r.totalChars !== undefined
|
|
501
|
+
? `${r.offset}\u2013${r.offset + r.text.length} of ${r.totalChars}`
|
|
502
|
+
: null,
|
|
503
|
+
...(r.notes ?? []),
|
|
427
504
|
r.fromCache ? "cached" : null,
|
|
428
505
|
].filter(Boolean).join(" · ");
|
|
429
506
|
return {
|
|
@@ -433,7 +510,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
433
510
|
source: r.source,
|
|
434
511
|
fromCache: r.fromCache,
|
|
435
512
|
chars: r.text.length,
|
|
513
|
+
totalChars: r.totalChars,
|
|
514
|
+
offset: r.offset,
|
|
515
|
+
date: r.date,
|
|
436
516
|
truncated: r.truncated,
|
|
517
|
+
notes: r.notes,
|
|
437
518
|
host: u.host,
|
|
438
519
|
preview: r.text.slice(0, 300),
|
|
439
520
|
durationMs: Date.now() - started,
|
|
@@ -453,10 +534,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
453
534
|
(d) => {
|
|
454
535
|
const line1 = `Read ${d.chars ?? 0} chars in ${secs(d.durationMs ?? 0)}`;
|
|
455
536
|
const bits = [`HTTP ${d.status ?? "?"}`];
|
|
456
|
-
if (d.source
|
|
457
|
-
if (d.
|
|
537
|
+
if (d.source && d.source !== "direct") bits.push(`via ${d.source}`);
|
|
538
|
+
if (d.date) bits.push(d.date);
|
|
539
|
+
if (typeof d.offset === "number" && d.totalChars) bits.push(`${d.offset}\u2013${(d.offset ?? 0) + (d.chars ?? 0)} of ${d.totalChars}`);
|
|
458
540
|
if (d.fromCache) bits.push("cached");
|
|
459
541
|
if (d.truncated) bits.push("truncated");
|
|
542
|
+
if ((d.notes ?? []).length > 0) bits.push(...d.notes);
|
|
460
543
|
return { ok: !d.error, line1, line2: bits.join(" · "), rows: undefined, preview: d.preview };
|
|
461
544
|
},
|
|
462
545
|
),
|
|
@@ -523,7 +606,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
523
606
|
}),
|
|
524
607
|
async execute(_id, params, signal, onUpdate) {
|
|
525
608
|
const started = Date.now();
|
|
526
|
-
onUpdate?.({ content: [{ type: "text", text: "…" }], details: {
|
|
609
|
+
onUpdate?.({ content: [{ type: "text", text: "…" }], details: { step: "searching github…" } });
|
|
527
610
|
try {
|
|
528
611
|
const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN;
|
|
529
612
|
const results = await searchGithubRepos(params.query, MAX(params.max), signal, token);
|
|
@@ -552,7 +635,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
552
635
|
handler: async (args, ctx) => {
|
|
553
636
|
const topic = (args ?? "").trim();
|
|
554
637
|
if (!topic) {
|
|
555
|
-
ctx.ui.notify(
|
|
638
|
+
ctx.ui.notify(
|
|
639
|
+
"Usage: /research <topic> — the model is prompted to answer your actual question, not just summarize the topic",
|
|
640
|
+
"warning",
|
|
641
|
+
);
|
|
556
642
|
return;
|
|
557
643
|
}
|
|
558
644
|
const setStatus = (s: string) => {
|
|
@@ -618,7 +704,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
618
704
|
const pages: Array<{ title: string; url: string; text: string }> = [];
|
|
619
705
|
for (const p of picked) {
|
|
620
706
|
try {
|
|
621
|
-
const r = await smartFetch(p.url, { maxChars:
|
|
707
|
+
const r = await smartFetch(p.url, { maxChars: 12_000, timeoutMs: 30_000 });
|
|
622
708
|
pages.push({ title: p.title, url: p.url, text: r.text });
|
|
623
709
|
steps.push(`✓ fetched: ${clip(new URL(p.url).host, 40)}`);
|
|
624
710
|
} catch (e: any) {
|
|
@@ -639,11 +725,14 @@ export default function (pi: ExtensionAPI) {
|
|
|
639
725
|
material += `### ${p.title}\n${p.url}\n\n${p.text}\n\n`;
|
|
640
726
|
}
|
|
641
727
|
}
|
|
642
|
-
material += `---\
|
|
643
|
-
|
|
644
|
-
|
|
728
|
+
material += `---\nThe user asked: "${topic.replace(/`/g, "'")}"
|
|
729
|
+
|
|
730
|
+
Synthesize the above into a research briefing that ANSWERS THIS QUESTION:
|
|
731
|
+
- Lead with the best current answer supported by the material, with citations
|
|
732
|
+
- Then group findings by sub-topic (like "three-way comparisons", "benchmarks", etc.)
|
|
733
|
+
- For each group, list documents worth reading: [source name](url) — one line on what it covers
|
|
645
734
|
- End with "Recurring conclusions": 3-6 bullets of the consensus/tensions across sources
|
|
646
|
-
- Cite only what appears above; if sources are thin, say so.`;
|
|
735
|
+
- Cite only what appears above; if sources are thin or don't address the question, say so explicitly.`;
|
|
647
736
|
|
|
648
737
|
pi.sendUserMessage(material);
|
|
649
738
|
setWidget([...steps, "handed to model for synthesis"]);
|