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 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 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);
@@ -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; prefer recent results for fast-moving topics and cite the date you relied on.",
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
- "Use web tools when the question depends on current or external information the user hasn't provided; don't search for stable knowledge you already know.",
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
- 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)" })),
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 — 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.",
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 ?? "ddg";
247
- const cacheKey = `s:${engine}:${recency ?? ""}:${maxResults}:${params.query}`;
248
- const run = async () => {
249
- if (engine === "multi") {
250
- onUpdate?.({ content: [{ type: "text", text: "…" }], details: { status: "querying ddg + brave in parallel…" } });
251
- const r = await multiSearch(params.query, maxResults, recency, signal);
252
- 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;
253
311
  }
254
312
  if (engine === "brave") {
255
- onUpdate?.({ content: [{ type: "text", text: "…" }], details: { status: "querying brave…" } });
256
- 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 } } };
257
317
  }
258
318
  if (engine === "bing") {
259
- onUpdate?.({ content: [{ type: "text", text: "…" }], details: { status: "querying bing rss…" } });
260
- return { results: (await bingRssSearch(params.query, maxResults, recency, signal)) as Row[], engines: ["bing"], errors: [] as string[] };
261
- }
262
- onUpdate?.({ content: [{ type: "text", text: "…" }], details: { status: "querying duckduckgo…" } });
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: { status: `reading top ${Math.min(deepN, results.length)} results for excerpts…` },
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
- 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()));
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
- if (engine !== "multi") {
327
- onUpdate?.({ content: [{ type: "text", text: "…" }], details: { status: "primary engine failed — trying multi…" } });
328
- try {
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}. Engines may be rate-limited — wait a minute or retry with engine='multi', refresh=true.`,
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" : "ddg");
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
- "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).",
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: { status: `fetching ${u.host}…` } });
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 === "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 ?? []),
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 === "wayback") bits.push("via Wayback");
457
- 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}`);
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: { status: "searching github…" } });
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("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
+ );
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: 4000, timeoutMs: 15_000 });
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 += `---\nSynthesize the above into a research briefing:
643
- - Group findings by sub-topic (like "three-way comparisons", "benchmarks", etc.)
644
- - 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
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"]);