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 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 credential is `GITHUB_TOKEN` (lifts GitHub's 10 req/min
110
- anonymous limit). Everything else is keyless by design.
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
 
@@ -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
- const lines = [`${i + 1}. ${r.title}`, ` ${r.url}${r.date ? ` (${r.date})` : ""}`];
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.fg("success", "⏺ ") +
122
+ dot(t, context.state.phase) +
95
123
  t.fg("toolTitle", t.bold(toolName)) +
96
- t.fg("dim", `(${JSON.stringify(clip(String(argDetail(args ?? {})), 70))})`),
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(() => context.invalidate?.(), 1000);
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
- const step = result?.details?.status ?? "Searching…";
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: { status: `Searching ${JSON.stringify(clip(params.query, 50))}…` },
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 anything factual or current, search before answering. Run 2-3 searches with different phrasings (add a year, quote the exact error, add 'docs' or 'github') one query is rarely enough.",
224
- "Set deep:true on web_search when you need facts rather than links; use fetch_page with query when you need one page in depth. Cite URLs inline.",
225
- "Read at least two independent sources before stating a conclusion. Prefer primary sources (official docs, repos, papers) over aggregators, and say when sources disagree or are thin.",
226
- "Results include dates when available prefer recent ones for fast-moving topics and mention which date you relied on.",
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
- engine: Type.Optional(Type.String({ description: "ddg (default) | brave | bing | multi (parallel merge)" })),
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 — often removes the need for fetch_page.",
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 ?? "ddg";
248
- const cacheKey = `s:${engine}:${recency ?? ""}:${maxResults}:${params.query}`;
249
- const run = async () => {
250
- if (engine === "multi") {
251
- onUpdate?.({ content: [{ type: "text", text: "…" }], details: { status: "querying ddg + brave in parallel…" } });
252
- const r = await multiSearch(params.query, maxResults, recency, signal);
253
- return { results: r.results as Row[], engines: r.engines, errors: r.errors };
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: { status: "querying brave…" } });
257
- return { results: (await braveSearch(params.query, maxResults, recency, signal)) as Row[], engines: ["brave"], errors: [] as string[] };
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: { status: "querying bing rss…" } });
261
- return { results: (await bingRssSearch(params.query, maxResults, recency, signal)) as Row[], engines: ["bing"], errors: [] as string[] };
262
- }
263
- onUpdate?.({ content: [{ type: "text", text: "…" }], details: { status: "querying duckduckgo…" } });
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: { status: `reading top ${Math.min(deepN, results.length)} results for excerpts…` },
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
- const first = body.split("\n\n").find((p) => !/^(via |\[|\d+\.)/.test(p.trim()));
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
- if (engine !== "multi") {
328
- onUpdate?.({ content: [{ type: "text", text: "…" }], details: { status: "primary engine failed — trying multi…" } });
329
- try {
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}. Engines may be rate-limited — wait a minute or retry with engine='multi', refresh=true.`,
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" : "ddg");
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
- "Pass query to get the most relevant passages of a long page instead of its head. Cached 1h.",
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: { status: `fetching ${u.host}…` } });
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 === "wayback" ? `Wayback ${r.waybackDate}` : null,
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 === "wayback") bits.push("via Wayback");
458
- if (d.source === "jina") bits.push("via r.jina.ai");
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: { status: "searching github…" } });
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("Usage: /research <topic>", "warning");
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: 4000, timeoutMs: 15_000 });
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 += `---\nSynthesize the above into a research briefing:
644
- - Group findings by sub-topic (like "three-way comparisons", "benchmarks", etc.)
645
- - For each group, list the documents worth reading: [source name](url) one line on what it covers
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"]);