pi-webfind 0.5.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.
@@ -0,0 +1,662 @@
1
+ /**
2
+ * pi-webfind — the complete free web toolkit for pi.
3
+ * No API keys, no paid services, zero runtime dependencies.
4
+ *
5
+ * Tools (7): web_search, fetch_page, search_stackoverflow, search_wikipedia,
6
+ * search_npm, search_github, search_hn
7
+ * Command: /research <topic> — multi-source research with live progress
8
+ *
9
+ * Claude Code-style UX: compact colored tool headers, live status while
10
+ * running, expanded result views, durations and engine attribution.
11
+ */
12
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
13
+ import { keyHint } from "@earendil-works/pi-coding-agent";
14
+ import { Text } from "@earendil-works/pi-tui";
15
+ import { Type } from "typebox";
16
+ import {
17
+ braveSearch,
18
+ bingRssSearch,
19
+ cacheGet,
20
+ cacheSet,
21
+ ddgSearch,
22
+ multiSearch,
23
+ type Recency,
24
+ type SearchResult,
25
+ } from "../lib/engine.ts";
26
+ import {
27
+ searchGithubRepos,
28
+ searchHackerNews,
29
+ searchNpm,
30
+ searchStackOverflow,
31
+ searchWikipedia,
32
+ } from "../lib/apis.ts";
33
+ import { smartFetch, type FetchOptions } from "../lib/fetcher.ts";
34
+
35
+ const MAX = (n?: number, dflt = 8, cap = 20) => Math.min(Math.max(n ?? dflt, 1), cap);
36
+ const clip = (s: string, n: number) => (s.length > n ? s.slice(0, n - 1) + "…" : s);
37
+ const secs = (ms: number) => `${(ms / 1000).toFixed(1)}s`;
38
+
39
+ interface Row {
40
+ title: string;
41
+ url: string;
42
+ snippet?: string;
43
+ meta?: string;
44
+ /** publication date when the engine provides one */
45
+ date?: string;
46
+ /** deep mode: query-relevant excerpt fetched from the page itself */
47
+ excerpt?: string;
48
+ }
49
+
50
+ function fmtResults(results: Row[]): string {
51
+ if (results.length === 0) return "No results found.";
52
+ return results
53
+ .map((r, i) => {
54
+ const lines = [`${i + 1}. ${r.title}`, ` ${r.url}${r.date ? ` (${r.date})` : ""}`];
55
+ if (r.meta) lines.push(` ${r.meta}`);
56
+ if (r.excerpt) lines.push(` excerpt: ${clip(r.excerpt, 400)}`);
57
+ else if (r.snippet) lines.push(` ${clip(r.snippet, 250)}`);
58
+ return lines.join("\n");
59
+ })
60
+ .join("\n\n");
61
+ }
62
+
63
+ // ------------------------------------------------------------- TUI rendering
64
+
65
+ interface Theme {
66
+ fg(color: string, text: string): string;
67
+ bold(text: string): string;
68
+ dim(text: string): string;
69
+ }
70
+
71
+
72
+ /**
73
+ * Claude Code-style renderers (flat — no background box, no emoji):
74
+ * ⏺ Web Search("query")
75
+ * ⎿ Found 8 results in 4.1s
76
+ * ⎿ via ddg · (ctrl+o to expand)
77
+ * Green dot marks state, live ticking elapsed while running.
78
+ */
79
+ function makeRenderers(
80
+ toolName: string,
81
+ argDetail: (args: any) => string,
82
+ resultSummary: (details: any) => { ok: boolean; line1: string; line2?: string; rows?: Row[]; preview?: string },
83
+ ) {
84
+ return {
85
+ // self shell: no default Box bg — flat like Claude Code
86
+ renderShell: "self" as const,
87
+ renderCall(args: any, theme: any, context: any) {
88
+ const t = theme as Theme;
89
+ if (context.executionStarted && context.state.startedAt === undefined) {
90
+ context.state.startedAt = Date.now();
91
+ }
92
+ const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
93
+ text.setText(
94
+ t.fg("success", "⏺ ") +
95
+ t.fg("toolTitle", t.bold(toolName)) +
96
+ t.fg("dim", `(${JSON.stringify(clip(String(argDetail(args ?? {})), 70))})`),
97
+ );
98
+ return text;
99
+ },
100
+ renderResult(
101
+ result: any,
102
+ options: { expanded?: boolean; isPartial?: boolean },
103
+ theme: any,
104
+ context: any,
105
+ ) {
106
+ const t = theme as Theme;
107
+ const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
108
+ const state = context.state ?? {};
109
+
110
+ // live elapsed ticking while partial (1s interval, like pi's bash renderer)
111
+ if (state.startedAt !== undefined && options.isPartial && !state.interval) {
112
+ state.interval = setInterval(() => context.invalidate?.(), 1000);
113
+ }
114
+ if (!options.isPartial && state.interval) {
115
+ clearInterval(state.interval);
116
+ state.interval = undefined;
117
+ }
118
+
119
+ if (options.isPartial) {
120
+ const step = result?.details?.status ?? "Searching…";
121
+ const elapsed = state.startedAt !== undefined ? secs(Date.now() - state.startedAt) : "";
122
+ text.setText(t.fg("warning", ` ⎿ ${step}${elapsed ? ` · ${elapsed}` : ""}`));
123
+ return text;
124
+ }
125
+
126
+ const isError = result?.isError || result?.details?.error;
127
+ if (isError) {
128
+ const msg = result?.details?.error ?? result?.content?.[0]?.text ?? "failed";
129
+ text.setText(t.fg("error", ` ⎿ ${clip(String(msg), 160)}`));
130
+ return text;
131
+ }
132
+
133
+ const { ok, line1, line2, rows, preview } = resultSummary(result?.details ?? {});
134
+ let out = t.fg("success", " ⎿ ") + t.fg("muted", line1);
135
+ if (line2) out += `\n ⎿ ${t.fg("dim", line2)}`;
136
+ if (options.expanded) {
137
+ if (rows && rows.length > 0) {
138
+ out +=
139
+ "\n" +
140
+ rows
141
+ .map((r, i) => {
142
+ let line = ` ${t.fg("accent", `${i + 1}. ${clip(r.title, 90)}`)}`;
143
+ line += `\n ${t.fg("dim", clip(r.url, 110))}`;
144
+ if (r.meta) line += ` ${t.fg("muted", clip(r.meta, 80))}`;
145
+ if (r.date) line += ` ${t.fg("muted", r.date)}`;
146
+ return line;
147
+ })
148
+ .join("\n");
149
+ } else if (preview) {
150
+ out += "\n" + t.fg("dim", clip(String(preview), 400));
151
+ }
152
+ } else {
153
+ out += t.fg("dim", ` (${keyHint("app.tools.expand", "to expand")})`);
154
+ }
155
+ text.setText(out);
156
+ return text;
157
+ },
158
+ };
159
+ }
160
+
161
+ // ------------------------------------------------------------------ factory
162
+
163
+ function registerSearchTool(
164
+ pi: ExtensionAPI,
165
+ name: string,
166
+ label: string,
167
+ description: string,
168
+ promptSnippet: string,
169
+ run: (query: string, max: number, signal?: AbortSignal) => Promise<Row[]>,
170
+ summary: (details: any) => { ok: boolean; line1: string; line2?: string; rows?: Row[]; preview?: string } = (d) => ({
171
+ ok: true,
172
+ line1: `Found ${d.count ?? 0} results in ${secs(d.durationMs ?? 0)}`,
173
+ rows: d.results,
174
+ }),
175
+ ) {
176
+ pi.registerTool({
177
+ name,
178
+ label,
179
+ description,
180
+ promptSnippet,
181
+ parameters: Type.Object({
182
+ query: Type.String({ description: "Search query" }),
183
+ max: Type.Optional(Type.Number({ description: "Max results (default 8)" })),
184
+ no_cache: Type.Optional(Type.Boolean({ description: "Skip the 10-minute cache" })),
185
+ }),
186
+ async execute(_id, params, signal, onUpdate) {
187
+ const started = Date.now();
188
+ onUpdate?.({
189
+ content: [{ type: "text", text: `${label}…` }],
190
+ details: { status: `Searching ${JSON.stringify(clip(params.query, 50))}…` },
191
+ });
192
+ try {
193
+ const results = await run(params.query, MAX(params.max), signal);
194
+ return {
195
+ content: [{ type: "text", text: fmtResults(results) }],
196
+ details: { results, count: results.length, durationMs: Date.now() - started },
197
+ };
198
+ } catch (err: any) {
199
+ return {
200
+ content: [{ type: "text", text: `${label} error: ${err?.message ?? err}` }],
201
+ details: { error: err?.message ?? String(err), durationMs: Date.now() - started },
202
+ isError: true,
203
+ };
204
+ }
205
+ },
206
+ ...makeRenderers(label, (args) => args.query ?? "", summary),
207
+ });
208
+ }
209
+
210
+ // ------------------------------------------------------------------- setup
211
+
212
+ export default function (pi: ExtensionAPI) {
213
+ // ------------------------------------------------------------- web_search
214
+ pi.registerTool({
215
+ name: "web_search",
216
+ label: "Web Search",
217
+ description:
218
+ "General web search — free, no API key (DuckDuckGo + Brave). Returns titles, URLs, snippets. " +
219
+ "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.",
221
+ promptSnippet: "Free multi-engine web search (DDG + Brave) with recency filter",
222
+ 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.",
227
+ ],
228
+ parameters: Type.Object({
229
+ query: Type.String({ description: "Search query" }),
230
+ max_results: Type.Optional(Type.Number({ description: "Max results, 1-20 (default 8)" })),
231
+ 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)" })),
233
+ refresh: Type.Optional(Type.Boolean({ description: "Skip the 10-minute cache" })),
234
+ deep: Type.Optional(
235
+ Type.Union([Type.Boolean(), Type.Number()], {
236
+ 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.",
238
+ }),
239
+ ),
240
+ }),
241
+ async execute(_id, params, signal, onUpdate) {
242
+ const started = Date.now();
243
+ const maxResults = MAX(params.max_results);
244
+ const recency = (["d", "w", "m", "y"] as const).includes(params.recency as any)
245
+ ? (params.recency as Recency)
246
+ : 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 };
254
+ }
255
+ 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[] };
258
+ }
259
+ 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)] };
270
+ }
271
+ };
272
+ try {
273
+ let cachedHit = false;
274
+ if (!params.refresh) {
275
+ const hit = cacheGet(cacheKey);
276
+ if (hit) {
277
+ cachedHit = true;
278
+ return {
279
+ content: [{ type: "text", text: `[cached]\n${fmtResults(hit)}` }],
280
+ details: { cached: true, results: hit, count: hit.length, durationMs: Date.now() - started },
281
+ };
282
+ }
283
+ }
284
+ const { results, engines, errors } = await run();
285
+ // 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
+ if (deepN > 0 && results.length > 0 && !cachedHit) {
288
+ onUpdate?.({
289
+ content: [{ type: "text", text: "…" }],
290
+ details: { status: `reading top ${Math.min(deepN, results.length)} results for excerpts…` },
291
+ });
292
+ const top = results.slice(0, deepN);
293
+ const deadline = Date.now() + 25_000;
294
+ await Promise.all(
295
+ top.map(async (r) => {
296
+ const budget = Math.max(deadline - Date.now(), 4_000);
297
+ try {
298
+ const page = await smartFetch(r.url, {
299
+ maxChars: 12_000,
300
+ timeoutMs: budget,
301
+ query: params.query,
302
+ noCache: false,
303
+ signal,
304
+ } satisfies FetchOptions);
305
+ 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()));
307
+ if (first && first.trim().length > 80) {
308
+ (r as Row).excerpt = clip(first.trim().replace(/\n+/g, " "), 500);
309
+ }
310
+ } catch {
311
+ /* ship without excerpt */
312
+ }
313
+ }),
314
+ );
315
+ }
316
+ if (results.length > 0) cacheSet(cacheKey, results);
317
+ return {
318
+ content: [
319
+ {
320
+ type: "text",
321
+ text: `[via ${engines.join(" + ")}]${errors.length ? ` (failed: ${errors.join("; ")})` : ""}\n\n${fmtResults(results)}`,
322
+ },
323
+ ],
324
+ details: { results, count: results.length, engines, errors, durationMs: Date.now() - started },
325
+ };
326
+ } 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
+ }
347
+ return {
348
+ content: [
349
+ {
350
+ type: "text",
351
+ text: `Search error: ${err?.message ?? err}. Engines may be rate-limited — wait a minute or retry with engine='multi', refresh=true.`,
352
+ },
353
+ ],
354
+ details: { error: err?.message ?? String(err), durationMs: Date.now() - started },
355
+ isError: true,
356
+ };
357
+ }
358
+ },
359
+ ...makeRenderers(
360
+ "Web Search",
361
+ (args) => String(args.query ?? ""),
362
+ (d) => {
363
+ const eng = (d.engines ?? []).join(" + ") || (d.cached ? "cache" : "ddg");
364
+ const line1 = `Found ${d.count ?? 0} results in ${secs(d.durationMs ?? 0)}`;
365
+ const line2 = d.cached
366
+ ? `via cache · ${eng}`
367
+ : (d.errors ?? []).length > 0
368
+ ? `via ${eng} · failed: ${(d.errors ?? []).join("; ")}`
369
+ : `via ${eng}`;
370
+ return { ok: (d.count ?? 0) > 0, line1, line2, rows: d.results, preview: undefined };
371
+ },
372
+ ),
373
+ });
374
+
375
+ // ------------------------------------------------------------- fetch_page
376
+ pi.registerTool({
377
+ name: "fetch_page",
378
+ label: "Fetch Page",
379
+ description:
380
+ "Fetch a URL and return readable content. Handles HTML (article extraction, nav/ads stripped), " +
381
+ "JSON (pretty-printed), plain text, and PDFs (text extraction). On 401/403/429/503 automatically " +
382
+ "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.",
384
+ promptSnippet: "Fetch a URL → readable text; handles PDFs, JSON, bot-walls (Wayback fallback)",
385
+ parameters: Type.Object({
386
+ url: Type.String({ description: "URL to fetch (http/https only)" }),
387
+ query: Type.Optional(
388
+ Type.String({
389
+ description:
390
+ "What you're looking for on this page. Returns intro + most query-relevant passages instead of the page head. Recommended for long pages.",
391
+ }),
392
+ ),
393
+ max_chars: Type.Optional(Type.Number({ description: "Max text chars (default 8000, max 50000)" })),
394
+ raw: Type.Optional(Type.Boolean({ description: "Return raw HTML instead of extracted text" })),
395
+ timeout: Type.Optional(Type.Number({ description: "Timeout ms (1000-60000, default 15000)" })),
396
+ headers: Type.Optional(
397
+ Type.Record(Type.String(), Type.String(), {
398
+ description: 'Custom headers, e.g. {"Authorization": "Bearer ..."}',
399
+ }),
400
+ ),
401
+ no_cache: Type.Optional(Type.Boolean({ description: "Skip the 1-hour cache" })),
402
+ no_wayback: Type.Optional(Type.Boolean({ description: "Disable Wayback Machine fallback" })),
403
+ allow_http_errors: Type.Optional(
404
+ Type.Boolean({
405
+ description:
406
+ "Return 4xx/5xx responses (with body) instead of throwing. Use for API status checks, e.g. crates.io/npm 404 = name available.",
407
+ }),
408
+ ),
409
+ }),
410
+ async execute(_id, params, signal, onUpdate) {
411
+ const started = Date.now();
412
+ try {
413
+ const u = new URL(params.url);
414
+ onUpdate?.({ content: [{ type: "text", text: "…" }], details: { status: `fetching ${u.host}…` } });
415
+ const r = await smartFetch(params.url, {
416
+ query: (params.query as string | undefined)?.trim() || undefined,
417
+ maxChars: MAX(params.max_chars, 8000, 50_000),
418
+ raw: params.raw,
419
+ timeoutMs: params.timeout ? Math.min(Math.max(params.timeout, 1000), 60_000) : undefined,
420
+ headers: params.headers as Record<string, string> | undefined,
421
+ waybackEnabled: !params.no_wayback,
422
+ allowHttpErrors: params.allow_http_errors,
423
+ signal,
424
+ } satisfies FetchOptions);
425
+ const tags = [
426
+ `HTTP ${r.status}`,
427
+ r.source === "wayback" ? `Wayback ${r.waybackDate}` : null,
428
+ r.fromCache ? "cached" : null,
429
+ ].filter(Boolean).join(" · ");
430
+ return {
431
+ content: [{ type: "text", text: `[${r.finalUrl}]\n[${tags}]\n\n${r.text}` }],
432
+ details: {
433
+ status: r.status,
434
+ source: r.source,
435
+ fromCache: r.fromCache,
436
+ chars: r.text.length,
437
+ truncated: r.truncated,
438
+ host: u.host,
439
+ preview: r.text.slice(0, 300),
440
+ durationMs: Date.now() - started,
441
+ },
442
+ };
443
+ } catch (err: any) {
444
+ return {
445
+ content: [{ type: "text", text: `Fetch error: ${err?.message ?? err}` }],
446
+ details: { error: err?.message ?? String(err), durationMs: Date.now() - started },
447
+ isError: true,
448
+ };
449
+ }
450
+ },
451
+ ...makeRenderers(
452
+ "Fetch Page",
453
+ (args) => String(args.url ?? ""),
454
+ (d) => {
455
+ const line1 = `Read ${d.chars ?? 0} chars in ${secs(d.durationMs ?? 0)}`;
456
+ 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");
459
+ if (d.fromCache) bits.push("cached");
460
+ if (d.truncated) bits.push("truncated");
461
+ return { ok: !d.error, line1, line2: bits.join(" · "), rows: undefined, preview: d.preview };
462
+ },
463
+ ),
464
+ });
465
+
466
+ // ------------------------------------------------------ specialized tools
467
+ registerSearchTool(
468
+ pi,
469
+ "search_stackoverflow",
470
+ "Stack Overflow Search",
471
+ "Programming Q&A via the Stack Exchange API (free, no key). Use for error messages, " +
472
+ "code patterns, debugging. Paste the full error for best results. " +
473
+ "Shows score, answer count, accepted status and tags.",
474
+ "Search Stack Overflow for programming Q&A (errors, debugging)",
475
+ (q, m, s) => searchStackOverflow(q, m, s),
476
+ (d) => ({ ok: (d.count ?? 0) > 0, line1: `Found ${d.count ?? 0} questions in ${secs(d.durationMs ?? 0)}` }),
477
+ );
478
+
479
+ registerSearchTool(
480
+ pi,
481
+ "search_wikipedia",
482
+ "Wikipedia Search",
483
+ "Encyclopedia search via the MediaWiki API (free, no key). Use for definitions, concepts, " +
484
+ "history, people, places. Use short topic names, not full questions.",
485
+ "Search Wikipedia for encyclopedic background",
486
+ (q, m, s) => searchWikipedia(q, m, s),
487
+ (d) => ({ ok: (d.count ?? 0) > 0, line1: `Found ${d.count ?? 0} articles in ${secs(d.durationMs ?? 0)}` }),
488
+ );
489
+
490
+ registerSearchTool(
491
+ pi,
492
+ "search_npm",
493
+ "npm Search",
494
+ "Search the npm registry (free, no key) for JavaScript/TypeScript packages. " +
495
+ "Returns name, version, description, quality and popularity scores.",
496
+ "Search npm registry for JS/TS packages with quality scores",
497
+ (q, m, s) => searchNpm(q, m, s),
498
+ (d) => ({ ok: (d.count ?? 0) > 0, line1: `Found ${d.count ?? 0} packages in ${secs(d.durationMs ?? 0)}` }),
499
+ );
500
+
501
+ registerSearchTool(
502
+ pi,
503
+ "search_hn",
504
+ "Hacker News Search",
505
+ "Search Hacker News via the Algolia API (free, no key). Use for tech community opinion, " +
506
+ "launches, discussions. Returns points, comment counts, dates. Great for 'what do devs think of X'.",
507
+ "Search Hacker News for community discussion and opinion",
508
+ (q, m, s) => searchHackerNews(q, m, s),
509
+ (d) => ({ ok: (d.count ?? 0) > 0, line1: `Found ${d.count ?? 0} stories in ${secs(d.durationMs ?? 0)}` }),
510
+ );
511
+
512
+ // github (separate: honors GITHUB_TOKEN)
513
+ pi.registerTool({
514
+ name: "search_github",
515
+ label: "GitHub Search",
516
+ description:
517
+ "Search GitHub repositories via the API (free, no key; 10 req/min). Returns stars, " +
518
+ "language, last-updated. Honors GITHUB_TOKEN env var if set (higher rate limits).",
519
+ promptSnippet: "Search GitHub repositories (stars, language, activity)",
520
+ parameters: Type.Object({
521
+ query: Type.String({ description: "Repository search query, e.g. 'websocket library language:python'" }),
522
+ max: Type.Optional(Type.Number({ description: "Max results (default 8)" })),
523
+ no_cache: Type.Optional(Type.Boolean({ description: "Skip the 10-minute cache" })),
524
+ }),
525
+ async execute(_id, params, signal, onUpdate) {
526
+ const started = Date.now();
527
+ onUpdate?.({ content: [{ type: "text", text: "…" }], details: { status: "searching github…" } });
528
+ try {
529
+ const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN;
530
+ const results = await searchGithubRepos(params.query, MAX(params.max), signal, token);
531
+ return {
532
+ content: [{ type: "text", text: fmtResults(results) }],
533
+ details: { results, count: results.length, durationMs: Date.now() - started },
534
+ };
535
+ } catch (err: any) {
536
+ return {
537
+ content: [{ type: "text", text: `GitHub search error: ${err?.message ?? err}` }],
538
+ details: { error: err?.message ?? String(err), durationMs: Date.now() - started },
539
+ isError: true,
540
+ };
541
+ }
542
+ },
543
+ ...makeRenderers(
544
+ "GitHub Search",
545
+ (args) => String(args.query ?? ""),
546
+ (d) => ({ ok: (d.count ?? 0) > 0, line1: `Found ${d.count ?? 0} repos in ${secs(d.durationMs ?? 0)}`, rows: d.results }),
547
+ ),
548
+ });
549
+
550
+ // ------------------------------------------------------------ /research
551
+ pi.registerCommand("research", {
552
+ description: "Multi-source research: web + HN + GitHub + Wikipedia, fetches top pages, then synthesizes",
553
+ handler: async (args, ctx) => {
554
+ const topic = (args ?? "").trim();
555
+ if (!topic) {
556
+ ctx.ui.notify("Usage: /research <topic>", "warning");
557
+ return;
558
+ }
559
+ const setStatus = (s: string) => {
560
+ try {
561
+ ctx.ui.setStatus("research", s);
562
+ } catch {
563
+ /* non-tui */
564
+ }
565
+ };
566
+ const setWidget = (lines: string[]) => {
567
+ try {
568
+ ctx.ui.setWidget(
569
+ "research",
570
+ lines.map((l) => clip(l, 100)),
571
+ );
572
+ } catch {
573
+ /* non-tui */
574
+ }
575
+ };
576
+
577
+ setStatus("researching…");
578
+ setWidget([`searching: "${topic}"`]);
579
+
580
+ const sources: Array<{ kind: string; rows: Row[] }> = [];
581
+ const steps: string[] = [];
582
+ const push = async (kind: string, fn: () => Promise<Row[]>) => {
583
+ try {
584
+ const rows = await fn();
585
+ if (rows.length > 0) {
586
+ sources.push({ kind, rows });
587
+ steps.push(`✓ ${kind}: ${rows.length} results`);
588
+ } else steps.push(`– ${kind}: 0 results`);
589
+ } catch (e: any) {
590
+ steps.push(`✗ ${kind}: ${clip(String(e?.message ?? e), 60)}`);
591
+ }
592
+ setWidget([`gathering sources…`, ...steps]);
593
+ };
594
+
595
+ await push("web", () => multiSearch(topic, 6, undefined).then((r) => r.results));
596
+ await push("hn", () => searchHackerNews(topic, 4));
597
+ await push("github", () => searchGithubRepos(topic, 4));
598
+ await push("wikipedia", () => searchWikipedia(topic, 3));
599
+
600
+ // fetch top pages (diverse hosts, skip search-engines/aggregators)
601
+ setStatus("fetching top pages…");
602
+ setWidget([...steps, "fetching top pages…"]);
603
+ const seenHosts = new Set<string>();
604
+ const picked: Array<{ url: string; title: string }> = [];
605
+ for (const s of sources.filter((x) => x.kind === "web")) {
606
+ for (const r of s.rows) {
607
+ try {
608
+ const host = new URL(r.url).host;
609
+ if (seenHosts.has(host) || /duckduckgo|brave.com|reddit.com|medium.com/.test(host)) continue;
610
+ seenHosts.add(host);
611
+ picked.push({ url: r.url, title: r.title });
612
+ if (picked.length >= 3) break;
613
+ } catch {
614
+ /* skip */
615
+ }
616
+ }
617
+ if (picked.length >= 3) break;
618
+ }
619
+ const pages: Array<{ title: string; url: string; text: string }> = [];
620
+ for (const p of picked) {
621
+ try {
622
+ const r = await smartFetch(p.url, { maxChars: 4000, timeoutMs: 15_000 });
623
+ pages.push({ title: p.title, url: p.url, text: r.text });
624
+ steps.push(`✓ fetched: ${clip(new URL(p.url).host, 40)}`);
625
+ } catch (e: any) {
626
+ steps.push(`✗ fetch failed: ${clip(new URL(p.url).host, 30)} (${clip(String(e?.message ?? e), 40)})`);
627
+ }
628
+ setWidget([...steps]);
629
+ }
630
+
631
+ // hand everything to the model for synthesis
632
+ setStatus("synthesizing…");
633
+ let material = `# Research: ${topic}\n\n`;
634
+ for (const s of sources) {
635
+ material += `## ${s.kind} results\n${fmtResults(s.rows)}\n\n`;
636
+ }
637
+ if (pages.length > 0) {
638
+ material += `## Page contents\n`;
639
+ for (const p of pages) {
640
+ material += `### ${p.title}\n${p.url}\n\n${p.text}\n\n`;
641
+ }
642
+ }
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
646
+ - 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.`;
648
+
649
+ pi.sendUserMessage(material);
650
+ setWidget([...steps, "handed to model for synthesis"]);
651
+ setStatus(`done · ${steps.length} steps`);
652
+ setTimeout(() => {
653
+ try {
654
+ ctx.ui.setStatus("research", undefined as never);
655
+ ctx.ui.setWidget("research", []);
656
+ } catch {
657
+ /* cleanup best-effort */
658
+ }
659
+ }, 8000);
660
+ },
661
+ });
662
+ }