pi-webfind 0.5.1 → 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 +15 -3
- package/extensions/web-search.ts +164 -76
- 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
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
<h1 align="center">pi-webfind</h1>
|
|
6
6
|
|
|
7
7
|
<p align="center">
|
|
8
|
-
<a href="https://www.npmjs.com/package/pi-webfind"><img alt="npm" src="https://img.shields.io/npm/v/pi-webfind?style=flat-square" /></a>
|
|
8
|
+
<a href="https://www.npmjs.com/package/pi-webfind"><img alt="npm version" src="https://img.shields.io/npm/v/pi-webfind?style=flat-square&color=cb3837" /></a>
|
|
9
9
|
<a href="https://github.com/jawwadzafar/pi-webfind/actions/workflows/deploy-docs.yml"><img alt="docs" src="https://img.shields.io/website?url=https%3A%2F%2Fjawwadzafar.github.io%2Fpi-webfind%2F&style=flat-square&label=docs" /></a>
|
|
10
10
|
<a href="https://github.com/jawwadzafar/pi-webfind/blob/main/LICENSE"><img alt="license" src="https://img.shields.io/npm/l/pi-webfind?style=flat-square" /></a>
|
|
11
11
|
<a href="https://github.com/jawwadzafar/pi-webfind/stargazers"><img alt="stars" src="https://img.shields.io/github/stars/jawwadzafar/pi-webfind?style=flat-square&color=b5bd68" /></a>
|
|
@@ -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);
|
|
@@ -217,24 +263,28 @@ export default function (pi: ExtensionAPI) {
|
|
|
217
263
|
description:
|
|
218
264
|
"General web search — free, no API key (DuckDuckGo + Brave). Returns titles, URLs, snippets. " +
|
|
219
265
|
"Use for news, articles, broad topics. Use fetch_page to read a result in full. " +
|
|
220
|
-
"engine='multi' merges both engines in parallel for best coverage."
|
|
266
|
+
"engine='multi' merges both engines in parallel for best coverage. " +
|
|
267
|
+
"For research questions, run 2-3 queries with varied phrasings (add a year, quote the exact error, add 'docs' or 'github'); " +
|
|
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.",
|
|
221
270
|
promptSnippet: "Free multi-engine web search (DDG + Brave) with recency filter",
|
|
222
271
|
promptGuidelines: [
|
|
223
|
-
"For
|
|
224
|
-
"
|
|
225
|
-
"
|
|
226
|
-
"
|
|
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.",
|
|
227
276
|
],
|
|
228
277
|
parameters: Type.Object({
|
|
229
278
|
query: Type.String({ description: "Search query" }),
|
|
230
279
|
max_results: Type.Optional(Type.Number({ description: "Max results, 1-20 (default 8)" })),
|
|
231
280
|
recency: Type.Optional(Type.String({ description: "d=day, w=week, m=month, y=year (optional)" })),
|
|
232
|
-
|
|
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)" })),
|
|
233
283
|
refresh: Type.Optional(Type.Boolean({ description: "Skip the 10-minute cache" })),
|
|
234
284
|
deep: Type.Optional(
|
|
235
285
|
Type.Union([Type.Boolean(), Type.Number()], {
|
|
236
286
|
description:
|
|
237
|
-
"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.",
|
|
238
288
|
}),
|
|
239
289
|
),
|
|
240
290
|
}),
|
|
@@ -244,30 +294,37 @@ export default function (pi: ExtensionAPI) {
|
|
|
244
294
|
const recency = (["d", "w", "m", "y"] as const).includes(params.recency as any)
|
|
245
295
|
? (params.recency as Recency)
|
|
246
296
|
: undefined;
|
|
247
|
-
const engine = params.engine ?? "
|
|
248
|
-
const
|
|
249
|
-
const
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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;
|
|
254
311
|
}
|
|
255
312
|
if (engine === "brave") {
|
|
256
|
-
onUpdate?.({ content: [{ type: "text", text: "…" }], details: {
|
|
257
|
-
|
|
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 } } };
|
|
258
317
|
}
|
|
259
318
|
if (engine === "bing") {
|
|
260
|
-
onUpdate?.({ content: [{ type: "text", text: "…" }], details: {
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
try {
|
|
265
|
-
return { results: (await ddgSearch(params.query, maxResults, recency, signal)) as Row[], engines: ["ddg"], errors: [] as string[] };
|
|
266
|
-
} catch (err) {
|
|
267
|
-
// ddg fully failed — structured bing rss before giving up
|
|
268
|
-
onUpdate?.({ content: [{ type: "text", text: "…" }], details: { status: "ddg failed — trying bing rss…" } });
|
|
269
|
-
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 } } };
|
|
270
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 } } };
|
|
271
328
|
};
|
|
272
329
|
try {
|
|
273
330
|
let cachedHit = false;
|
|
@@ -275,19 +332,26 @@ export default function (pi: ExtensionAPI) {
|
|
|
275
332
|
const hit = cacheGet(cacheKey);
|
|
276
333
|
if (hit) {
|
|
277
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]))];
|
|
278
340
|
return {
|
|
279
|
-
content: [{ type: "text", text: `[cached]\n${fmtResults(hit)}` }],
|
|
280
|
-
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 },
|
|
281
343
|
};
|
|
282
344
|
}
|
|
283
345
|
}
|
|
284
|
-
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
|
+
}
|
|
285
350
|
// deep mode: read top results in parallel, attach query-relevant excerpts
|
|
286
|
-
const deepN = params.deep === true ? 4 : typeof params.deep === "number" ? Math.min(Math.max(Math.round(params.deep), 1), 8) : 0;
|
|
287
351
|
if (deepN > 0 && results.length > 0 && !cachedHit) {
|
|
288
352
|
onUpdate?.({
|
|
289
353
|
content: [{ type: "text", text: "…" }],
|
|
290
|
-
details: {
|
|
354
|
+
details: { step: `reading top ${Math.min(deepN, results.length)} results for excerpts…` },
|
|
291
355
|
});
|
|
292
356
|
const top = results.slice(0, deepN);
|
|
293
357
|
const deadline = Date.now() + 25_000;
|
|
@@ -303,52 +367,51 @@ export default function (pi: ExtensionAPI) {
|
|
|
303
367
|
signal,
|
|
304
368
|
} satisfies FetchOptions);
|
|
305
369
|
const body = page.text.replace(/\n\n\[\d+ of \d+ passages shown[^\n]*\n?\n?$/, "");
|
|
306
|
-
|
|
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()));
|
|
307
382
|
if (first && first.trim().length > 80) {
|
|
308
383
|
(r as Row).excerpt = clip(first.trim().replace(/\n+/g, " "), 500);
|
|
309
384
|
}
|
|
385
|
+
if (page.date) (r as Row).date = page.date;
|
|
310
386
|
} catch {
|
|
311
387
|
/* ship without excerpt */
|
|
312
388
|
}
|
|
313
389
|
}),
|
|
314
390
|
);
|
|
315
391
|
}
|
|
316
|
-
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`);
|
|
317
397
|
return {
|
|
318
398
|
content: [
|
|
319
399
|
{
|
|
320
400
|
type: "text",
|
|
321
|
-
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)}`,
|
|
322
402
|
},
|
|
323
403
|
],
|
|
324
|
-
details: { results, count: results.length, engines, errors, durationMs: Date.now() - started },
|
|
404
|
+
details: { results, count: results.length, engines, errors, stats, durationMs: Date.now() - started },
|
|
325
405
|
};
|
|
326
406
|
} catch (err: any) {
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
const r = await multiSearch(params.query, maxResults, recency, signal);
|
|
331
|
-
if (r.results.length > 0) {
|
|
332
|
-
cacheSet(cacheKey, r.results);
|
|
333
|
-
return {
|
|
334
|
-
content: [
|
|
335
|
-
{
|
|
336
|
-
type: "text",
|
|
337
|
-
text: `${fmtResults(r.results)}\n\n[primary engine '${engine}' failed: ${err?.message ?? err}]`,
|
|
338
|
-
},
|
|
339
|
-
],
|
|
340
|
-
details: { results: r.results, count: r.results.length, engines: r.engines, durationMs: Date.now() - started },
|
|
341
|
-
};
|
|
342
|
-
}
|
|
343
|
-
} catch {
|
|
344
|
-
/* fall through */
|
|
345
|
-
}
|
|
346
|
-
}
|
|
407
|
+
const advice = isOffline()
|
|
408
|
+
? "Network appears offline."
|
|
409
|
+
: "Engines may be rate-limited — wait a minute or retry with refresh=true.";
|
|
347
410
|
return {
|
|
348
411
|
content: [
|
|
349
412
|
{
|
|
350
413
|
type: "text",
|
|
351
|
-
text: `Search error: ${err?.message ?? err}.
|
|
414
|
+
text: `Search error: ${err?.message ?? err}. ${advice}`,
|
|
352
415
|
},
|
|
353
416
|
],
|
|
354
417
|
details: { error: err?.message ?? String(err), durationMs: Date.now() - started },
|
|
@@ -360,7 +423,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
360
423
|
"Web Search",
|
|
361
424
|
(args) => String(args.query ?? ""),
|
|
362
425
|
(d) => {
|
|
363
|
-
const eng = (d.engines ?? []).join(" + ") || (d.cached ? "cache" : "
|
|
426
|
+
const eng = (d.engines ?? []).join(" + ") || (d.cached ? "cache" : "");
|
|
364
427
|
const line1 = `Found ${d.count ?? 0} results in ${secs(d.durationMs ?? 0)}`;
|
|
365
428
|
const line2 = d.cached
|
|
366
429
|
? `via cache · ${eng}`
|
|
@@ -380,7 +443,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
380
443
|
"Fetch a URL and return readable content. Handles HTML (article extraction, nav/ads stripped), " +
|
|
381
444
|
"JSON (pretty-printed), plain text, and PDFs (text extraction). On 401/403/429/503 automatically " +
|
|
382
445
|
"retries via the Wayback Machine; thin/SPA pages re-rendered via a reader proxy. SSRF-protected. " +
|
|
383
|
-
|
|
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).",
|
|
384
449
|
promptSnippet: "Fetch a URL → readable text; handles PDFs, JSON, bot-walls (Wayback fallback)",
|
|
385
450
|
parameters: Type.Object({
|
|
386
451
|
url: Type.String({ description: "URL to fetch (http/https only)" }),
|
|
@@ -391,6 +456,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
391
456
|
}),
|
|
392
457
|
),
|
|
393
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)" })),
|
|
394
460
|
raw: Type.Optional(Type.Boolean({ description: "Return raw HTML instead of extracted text" })),
|
|
395
461
|
timeout: Type.Optional(Type.Number({ description: "Timeout ms (1000-60000, default 15000)" })),
|
|
396
462
|
headers: Type.Optional(
|
|
@@ -400,6 +466,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
400
466
|
),
|
|
401
467
|
no_cache: Type.Optional(Type.Boolean({ description: "Skip the 1-hour cache" })),
|
|
402
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" })),
|
|
403
470
|
allow_http_errors: Type.Optional(
|
|
404
471
|
Type.Boolean({
|
|
405
472
|
description:
|
|
@@ -411,20 +478,29 @@ export default function (pi: ExtensionAPI) {
|
|
|
411
478
|
const started = Date.now();
|
|
412
479
|
try {
|
|
413
480
|
const u = new URL(params.url);
|
|
414
|
-
onUpdate?.({ content: [{ type: "text", text: "…" }], details: {
|
|
481
|
+
onUpdate?.({ content: [{ type: "text", text: "…" }], details: { step: `fetching ${u.host}…` } });
|
|
415
482
|
const r = await smartFetch(params.url, {
|
|
416
483
|
query: (params.query as string | undefined)?.trim() || undefined,
|
|
417
484
|
maxChars: MAX(params.max_chars, 8000, 50_000),
|
|
485
|
+
offset: typeof params.offset === "number" ? Math.max(0, Math.floor(params.offset)) : undefined,
|
|
418
486
|
raw: params.raw,
|
|
419
487
|
timeoutMs: params.timeout ? Math.min(Math.max(params.timeout, 1000), 60_000) : undefined,
|
|
420
488
|
headers: params.headers as Record<string, string> | undefined,
|
|
421
489
|
waybackEnabled: !params.no_wayback,
|
|
422
490
|
allowHttpErrors: params.allow_http_errors,
|
|
491
|
+
jinaEnabled: params.no_jina === true ? false : undefined,
|
|
492
|
+
jinaQuery: (params.query as string | undefined)?.trim() || undefined,
|
|
423
493
|
signal,
|
|
424
494
|
} satisfies FetchOptions);
|
|
425
495
|
const tags = [
|
|
426
496
|
`HTTP ${r.status}`,
|
|
427
|
-
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 ?? []),
|
|
428
504
|
r.fromCache ? "cached" : null,
|
|
429
505
|
].filter(Boolean).join(" · ");
|
|
430
506
|
return {
|
|
@@ -434,7 +510,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
434
510
|
source: r.source,
|
|
435
511
|
fromCache: r.fromCache,
|
|
436
512
|
chars: r.text.length,
|
|
513
|
+
totalChars: r.totalChars,
|
|
514
|
+
offset: r.offset,
|
|
515
|
+
date: r.date,
|
|
437
516
|
truncated: r.truncated,
|
|
517
|
+
notes: r.notes,
|
|
438
518
|
host: u.host,
|
|
439
519
|
preview: r.text.slice(0, 300),
|
|
440
520
|
durationMs: Date.now() - started,
|
|
@@ -454,10 +534,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
454
534
|
(d) => {
|
|
455
535
|
const line1 = `Read ${d.chars ?? 0} chars in ${secs(d.durationMs ?? 0)}`;
|
|
456
536
|
const bits = [`HTTP ${d.status ?? "?"}`];
|
|
457
|
-
if (d.source
|
|
458
|
-
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}`);
|
|
459
540
|
if (d.fromCache) bits.push("cached");
|
|
460
541
|
if (d.truncated) bits.push("truncated");
|
|
542
|
+
if ((d.notes ?? []).length > 0) bits.push(...d.notes);
|
|
461
543
|
return { ok: !d.error, line1, line2: bits.join(" · "), rows: undefined, preview: d.preview };
|
|
462
544
|
},
|
|
463
545
|
),
|
|
@@ -524,7 +606,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
524
606
|
}),
|
|
525
607
|
async execute(_id, params, signal, onUpdate) {
|
|
526
608
|
const started = Date.now();
|
|
527
|
-
onUpdate?.({ content: [{ type: "text", text: "…" }], details: {
|
|
609
|
+
onUpdate?.({ content: [{ type: "text", text: "…" }], details: { step: "searching github…" } });
|
|
528
610
|
try {
|
|
529
611
|
const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN;
|
|
530
612
|
const results = await searchGithubRepos(params.query, MAX(params.max), signal, token);
|
|
@@ -553,7 +635,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
553
635
|
handler: async (args, ctx) => {
|
|
554
636
|
const topic = (args ?? "").trim();
|
|
555
637
|
if (!topic) {
|
|
556
|
-
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
|
+
);
|
|
557
642
|
return;
|
|
558
643
|
}
|
|
559
644
|
const setStatus = (s: string) => {
|
|
@@ -619,7 +704,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
619
704
|
const pages: Array<{ title: string; url: string; text: string }> = [];
|
|
620
705
|
for (const p of picked) {
|
|
621
706
|
try {
|
|
622
|
-
const r = await smartFetch(p.url, { maxChars:
|
|
707
|
+
const r = await smartFetch(p.url, { maxChars: 12_000, timeoutMs: 30_000 });
|
|
623
708
|
pages.push({ title: p.title, url: p.url, text: r.text });
|
|
624
709
|
steps.push(`✓ fetched: ${clip(new URL(p.url).host, 40)}`);
|
|
625
710
|
} catch (e: any) {
|
|
@@ -640,11 +725,14 @@ export default function (pi: ExtensionAPI) {
|
|
|
640
725
|
material += `### ${p.title}\n${p.url}\n\n${p.text}\n\n`;
|
|
641
726
|
}
|
|
642
727
|
}
|
|
643
|
-
material += `---\
|
|
644
|
-
|
|
645
|
-
|
|
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
|
|
646
734
|
- End with "Recurring conclusions": 3-6 bullets of the consensus/tensions across sources
|
|
647
|
-
- 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.`;
|
|
648
736
|
|
|
649
737
|
pi.sendUserMessage(material);
|
|
650
738
|
setWidget([...steps, "handed to model for synthesis"]);
|