@alexion42/pi-web-search 0.1.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/index.ts ADDED
@@ -0,0 +1,885 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
2
+ import { Text, truncateToWidth } from "@mariozechner/pi-tui";
3
+ import { Type } from "typebox";
4
+ import { StringEnum } from "@mariozechner/pi-ai";
5
+ import { fetchAllContent, type ExtractedContent } from "./extract.js";
6
+ import { clearCloneCache } from "./github-extract.js";
7
+ import { search } from "./search.js";
8
+ import { executeCodeSearch } from "./code-search.js";
9
+ import type { SearchResult } from "./types.js";
10
+ import {
11
+ clearResults,
12
+ deleteResult,
13
+ generateId,
14
+ getAllResults,
15
+ getResult,
16
+ restoreFromSession,
17
+ storeResult,
18
+ type QueryResultData,
19
+ type StoredSearchData,
20
+ } from "./storage.js";
21
+ import { activityMonitor, type ActivityEntry } from "./activity.js";
22
+ import { homedir } from "node:os";
23
+ import { existsSync, readFileSync } from "node:fs";
24
+ import { join } from "node:path";
25
+ import { hasExaApiKey } from "./exa.js";
26
+
27
+ const WEB_SEARCH_CONFIG_PATH = join(homedir(), ".pi", "web-search.json");
28
+
29
+ interface WebSearchConfig {
30
+ shortcuts?: {
31
+ activity?: string;
32
+ };
33
+ }
34
+
35
+ const DEFAULT_SHORTCUTS = { activity: "ctrl+shift+w" };
36
+ const MAX_INLINE_CONTENT = 30000;
37
+
38
+ const pendingFetches = new Map<string, AbortController>();
39
+ let sessionActive = false;
40
+ let widgetVisible = false;
41
+ let widgetUnsubscribe: (() => void) | null = null;
42
+
43
+ function loadConfig(): WebSearchConfig {
44
+ if (!existsSync(WEB_SEARCH_CONFIG_PATH)) return {};
45
+ const raw = readFileSync(WEB_SEARCH_CONFIG_PATH, "utf-8");
46
+ try {
47
+ return JSON.parse(raw) as WebSearchConfig;
48
+ } catch (err) {
49
+ const message = err instanceof Error ? err.message : String(err);
50
+ throw new Error(`Failed to parse ${WEB_SEARCH_CONFIG_PATH}: ${message}`);
51
+ }
52
+ }
53
+
54
+ function loadConfigForExtensionInit(): WebSearchConfig {
55
+ try {
56
+ return loadConfig();
57
+ } catch (err) {
58
+ const message = err instanceof Error ? err.message : String(err);
59
+ console.error(`[pi-web-search] ${message}`);
60
+ return {};
61
+ }
62
+ }
63
+
64
+ function normalizeQueryList(queryList: unknown[]): string[] {
65
+ const normalized: string[] = [];
66
+ for (const query of queryList) {
67
+ if (typeof query !== "string") continue;
68
+ const trimmed = query.trim();
69
+ if (trimmed.length > 0) normalized.push(trimmed);
70
+ }
71
+ return normalized;
72
+ }
73
+
74
+ function formatSearchSummary(results: SearchResult[], answer: string): string {
75
+ let output = answer ? `${answer}\n\n---\n\n**Sources:**\n` : "";
76
+ output += results.map((r, i) => `${i + 1}. ${r.title}\n ${r.url}`).join("\n\n");
77
+ return output;
78
+ }
79
+
80
+ function formatFullResults(queryData: QueryResultData): string {
81
+ let output = `## Results for: "${queryData.query}"\n\n`;
82
+ if (queryData.answer) {
83
+ output += `${queryData.answer}\n\n---\n\n`;
84
+ }
85
+ for (const r of queryData.results) {
86
+ output += `### ${r.title}\n${r.url}\n\n`;
87
+ }
88
+ return output;
89
+ }
90
+
91
+ function abortPendingFetches(): void {
92
+ for (const controller of pendingFetches.values()) {
93
+ controller.abort();
94
+ }
95
+ pendingFetches.clear();
96
+ }
97
+
98
+ function updateWidget(ctx: ExtensionContext): void {
99
+ const theme = ctx.ui.theme;
100
+ const entries = activityMonitor.getEntries();
101
+ const lines: string[] = [];
102
+
103
+ lines.push(theme.fg("accent", "─── Web Search Activity " + "─".repeat(36)));
104
+
105
+ if (entries.length === 0) {
106
+ lines.push(theme.fg("muted", " No activity yet"));
107
+ } else {
108
+ for (const e of entries) {
109
+ lines.push(" " + formatEntryLine(e, theme));
110
+ }
111
+ }
112
+
113
+ lines.push(theme.fg("accent", "─".repeat(60)));
114
+
115
+ const rateInfo = activityMonitor.getRateLimitInfo();
116
+ const resetMs = rateInfo.oldestTimestamp ? Math.max(0, rateInfo.oldestTimestamp + rateInfo.windowMs - Date.now()) : 0;
117
+ const resetSec = Math.ceil(resetMs / 1000);
118
+ lines.push(
119
+ theme.fg("muted", `Rate: ${rateInfo.used}/${rateInfo.max}`) +
120
+ (resetMs > 0 ? theme.fg("dim", ` (resets in ${resetSec}s)`) : ""),
121
+ );
122
+
123
+ ctx.ui.setWidget("web-activity", new Text(lines.join("\n"), 0, 0));
124
+ }
125
+
126
+ function formatEntryLine(
127
+ entry: ActivityEntry,
128
+ theme: { fg: (color: string, text: string) => string },
129
+ ): string {
130
+ const typeStr = entry.type === "api" ? "API" : "GET";
131
+ const target =
132
+ entry.type === "api"
133
+ ? `"${truncateToWidth(entry.query || "", 28, "")}"`
134
+ : truncateToWidth(entry.url?.replace(/^https?:\/\//, "") || "", 30, "");
135
+
136
+ const duration = entry.endTime
137
+ ? `${((entry.endTime - entry.startTime) / 1000).toFixed(1)}s`
138
+ : `${((Date.now() - entry.startTime) / 1000).toFixed(1)}s`;
139
+
140
+ let statusStr: string;
141
+ let indicator: string;
142
+ if (entry.error) {
143
+ statusStr = "err";
144
+ indicator = theme.fg("error", "✗");
145
+ } else if (entry.status === null) {
146
+ statusStr = "...";
147
+ indicator = theme.fg("warning", "⋯");
148
+ } else if (entry.status === 0) {
149
+ statusStr = "abort";
150
+ indicator = theme.fg("muted", "○");
151
+ } else {
152
+ statusStr = String(entry.status);
153
+ indicator = entry.status >= 200 && entry.status < 300 ? theme.fg("success", "✓") : theme.fg("error", "✗");
154
+ }
155
+
156
+ return `${typeStr.padEnd(4)} ${target.padEnd(32)} ${statusStr.padStart(5)} ${duration.padStart(5)} ${indicator}`;
157
+ }
158
+
159
+ function handleSessionChange(ctx: ExtensionContext): void {
160
+ abortPendingFetches();
161
+ clearCloneCache();
162
+ sessionActive = true;
163
+ restoreFromSession(ctx);
164
+ widgetUnsubscribe?.();
165
+ widgetUnsubscribe = null;
166
+ activityMonitor.clear();
167
+ if (widgetVisible) {
168
+ widgetUnsubscribe = activityMonitor.onUpdate(() => updateWidget(ctx));
169
+ updateWidget(ctx);
170
+ }
171
+ }
172
+
173
+ function startBackgroundFetch(urls: string[]): string | null {
174
+ if (urls.length === 0) return null;
175
+ const fetchId = generateId();
176
+ const controller = new AbortController();
177
+ pendingFetches.set(fetchId, controller);
178
+ fetchAllContent(urls, controller.signal)
179
+ .then((fetched) => {
180
+ if (!sessionActive || !pendingFetches.has(fetchId)) return;
181
+ const data: StoredSearchData = {
182
+ id: fetchId,
183
+ type: "fetch",
184
+ timestamp: Date.now(),
185
+ urls: fetched,
186
+ };
187
+ storeResult(fetchId, data);
188
+ pi.appendEntry("web-search-results", data);
189
+ const ok = fetched.filter(f => !f.error).length;
190
+ pi.sendMessage(
191
+ {
192
+ customType: "web-search-content-ready",
193
+ content: `Content fetched for ${ok}/${fetched.length} URLs [${fetchId}]. Full page content now available.`,
194
+ display: true,
195
+ },
196
+ { triggerTurn: true },
197
+ );
198
+ })
199
+ .catch((err) => {
200
+ if (!sessionActive || !pendingFetches.has(fetchId)) return;
201
+ const message = err instanceof Error ? err.message : String(err);
202
+ const isAbort = (err instanceof Error && err.name === "AbortError") || message.toLowerCase().includes("abort");
203
+ if (!isAbort) {
204
+ pi.sendMessage(
205
+ {
206
+ customType: "web-search-error",
207
+ content: `Content fetch failed [${fetchId}]: ${message}`,
208
+ display: true,
209
+ },
210
+ { triggerTurn: false },
211
+ );
212
+ }
213
+ })
214
+ .finally(() => { pendingFetches.delete(fetchId); });
215
+ return fetchId;
216
+ }
217
+
218
+ export default function (pi: ExtensionAPI) {
219
+ const initConfig = loadConfigForExtensionInit();
220
+ const activityKey = initConfig.shortcuts?.activity || DEFAULT_SHORTCUTS.activity;
221
+
222
+ if (!hasExaApiKey()) {
223
+ console.log("[pi-web-search] No Exa API key set. Search uses Exa MCP (zero-config). Set EXA_API_KEY for direct API access.");
224
+ }
225
+
226
+ pi.registerShortcut(activityKey, {
227
+ description: "Toggle web search activity",
228
+ handler: async (ctx) => {
229
+ widgetVisible = !widgetVisible;
230
+ if (widgetVisible) {
231
+ widgetUnsubscribe = activityMonitor.onUpdate(() => updateWidget(ctx));
232
+ updateWidget(ctx);
233
+ } else {
234
+ widgetUnsubscribe?.();
235
+ widgetUnsubscribe = null;
236
+ ctx.ui.setWidget("web-activity", null);
237
+ }
238
+ },
239
+ });
240
+
241
+ pi.on("session_start", async (_event, ctx) => handleSessionChange(ctx));
242
+ pi.on("session_tree", async (_event, ctx) => handleSessionChange(ctx));
243
+
244
+ pi.on("session_shutdown", () => {
245
+ sessionActive = false;
246
+ abortPendingFetches();
247
+ clearResults();
248
+ widgetUnsubscribe?.();
249
+ widgetUnsubscribe = null;
250
+ activityMonitor.clear();
251
+ widgetVisible = false;
252
+ });
253
+
254
+ pi.registerTool({
255
+ name: "web_search",
256
+ label: "Web Search",
257
+ description:
258
+ `Search the web using Exa. Returns an AI-synthesized answer with source citations. For comprehensive research, prefer queries (plural) with 2-4 varied angles over a single query. When includeContent is true, full page content is fetched in the background.`,
259
+ promptSnippet:
260
+ "Use for web research questions. Prefer {queries:[...]} with 2-4 varied angles over a single query for broader coverage.",
261
+ parameters: Type.Object({
262
+ query: Type.Optional(Type.String({ description: "Single search query. For research tasks, prefer 'queries' with multiple varied angles instead." })),
263
+ queries: Type.Optional(Type.Array(Type.String(), { description: "Multiple queries searched in sequence, each returning its own synthesized answer." })),
264
+ numResults: Type.Optional(Type.Number({ description: "Results per query (default: 5, max: 20)" })),
265
+ includeContent: Type.Optional(Type.Boolean({ description: "Fetch full page content (async)" })),
266
+ recencyFilter: Type.Optional(
267
+ StringEnum(["day", "week", "month", "year"], { description: "Filter by recency" }),
268
+ ),
269
+ domainFilter: Type.Optional(Type.Array(Type.String(), { description: "Limit to domains (prefix with - to exclude)" })),
270
+ }),
271
+
272
+ async execute(_toolCallId, params, signal, onUpdate) {
273
+ const rawQueryList: unknown[] = Array.isArray(params.queries)
274
+ ? params.queries
275
+ : (params.query !== undefined ? [params.query] : []);
276
+ const queryList = normalizeQueryList(rawQueryList);
277
+
278
+ if (queryList.length === 0) {
279
+ return {
280
+ content: [{ type: "text", text: "Error: No query provided. Use 'query' or 'queries' parameter." }],
281
+ details: { error: "No query provided" },
282
+ };
283
+ }
284
+
285
+ const numResults = params.numResults != null ? Math.min(Math.max(1, Math.floor(params.numResults)), 20) : undefined;
286
+
287
+ const searchResults: QueryResultData[] = [];
288
+ const allUrls: string[] = [];
289
+ const allInlineContent: ExtractedContent[] = [];
290
+
291
+ for (let i = 0; i < queryList.length; i++) {
292
+ const query = queryList[i];
293
+
294
+ onUpdate?.({
295
+ content: [{ type: "text", text: `Searching ${i + 1}/${queryList.length}: "${query}"...` }],
296
+ details: { phase: "search", progress: i / queryList.length, currentQuery: query },
297
+ });
298
+
299
+ if (signal?.aborted) break;
300
+
301
+ try {
302
+ const { answer, results, inlineContent } = await search(query, {
303
+ numResults: numResults,
304
+ recencyFilter: params.recencyFilter,
305
+ domainFilter: params.domainFilter,
306
+ includeContent: params.includeContent,
307
+ signal,
308
+ });
309
+
310
+ searchResults.push({ query, answer, results, error: null });
311
+ for (const r of results) {
312
+ if (!allUrls.includes(r.url)) {
313
+ allUrls.push(r.url);
314
+ }
315
+ }
316
+ if (inlineContent) allInlineContent.push(...inlineContent);
317
+ } catch (err) {
318
+ const message = err instanceof Error ? err.message : String(err);
319
+ searchResults.push({ query, answer: "", results: [], error: message });
320
+ }
321
+ }
322
+
323
+ // Build output
324
+ const sc = searchResults.filter(r => !r.error).length;
325
+ let output = "";
326
+ for (const { query, answer, results, error } of searchResults) {
327
+ if (queryList.length > 1) {
328
+ output += `## Query: "${query}"\n\n`;
329
+ }
330
+ if (error) output += `Error: ${error}\n\n`;
331
+ else if (results.length === 0) output += "No results found.\n\n";
332
+ else output += formatSearchSummary(results, answer) + "\n\n";
333
+ }
334
+
335
+ // Handle content fetching
336
+ const includeContent = params.includeContent ?? false;
337
+ let fetchId: string | null = null;
338
+ const hasInlineReady = allInlineContent.length > 0 && allInlineContent.every(c => allUrls.includes(c.url));
339
+ if (hasInlineReady) {
340
+ fetchId = generateId();
341
+ const data: StoredSearchData = {
342
+ id: fetchId,
343
+ type: "fetch",
344
+ timestamp: Date.now(),
345
+ urls: allInlineContent,
346
+ };
347
+ storeResult(fetchId, data);
348
+ pi.appendEntry("web-search-results", data);
349
+ output += `---\nFull content for ${allInlineContent.length} sources available [${fetchId}].`;
350
+ } else if (includeContent) {
351
+ fetchId = startBackgroundFetch(allUrls);
352
+ if (fetchId) {
353
+ output += `---\nContent fetching in background [${fetchId}]. Will notify when ready.`;
354
+ }
355
+ }
356
+
357
+ // Store search results
358
+ const searchId = generateId();
359
+ const searchData: StoredSearchData = {
360
+ id: searchId,
361
+ type: "search",
362
+ timestamp: Date.now(),
363
+ queries: searchResults,
364
+ };
365
+ storeResult(searchId, searchData);
366
+ pi.appendEntry("web-search-results", searchData);
367
+
368
+ return {
369
+ content: [{ type: "text", text: output.trim() }],
370
+ details: {
371
+ queries: queryList,
372
+ queryCount: queryList.length,
373
+ successfulQueries: sc,
374
+ totalResults: searchResults.reduce((sum, r) => sum + r.results.length, 0),
375
+ includeContent,
376
+ fetchId,
377
+ fetchUrls: fetchId !== null && !hasInlineReady ? allUrls : undefined,
378
+ searchId,
379
+ },
380
+ };
381
+ },
382
+
383
+ renderCall(args, theme) {
384
+ const input = args as { query?: unknown; queries?: unknown };
385
+ const rawQueryList: unknown[] = Array.isArray(input.queries)
386
+ ? input.queries
387
+ : (input.query !== undefined ? [input.query] : []);
388
+ const queryList = normalizeQueryList(rawQueryList);
389
+ if (queryList.length === 0) {
390
+ return new Text(theme.fg("toolTitle", theme.bold("search ")) + theme.fg("error", "(no query)"), 0, 0);
391
+ }
392
+ if (queryList.length === 1) {
393
+ const q = queryList[0];
394
+ const display = q.length > 60 ? q.slice(0, 57) + "..." : q;
395
+ return new Text(theme.fg("toolTitle", theme.bold("search ")) + theme.fg("accent", `"${display}"`), 0, 0);
396
+ }
397
+ const lines = [theme.fg("toolTitle", theme.bold("search ")) + theme.fg("accent", `${queryList.length} queries`)];
398
+ for (const q of queryList.slice(0, 5)) {
399
+ const display = q.length > 50 ? q.slice(0, 47) + "..." : q;
400
+ lines.push(theme.fg("muted", ` "${display}"`));
401
+ }
402
+ if (queryList.length > 5) {
403
+ lines.push(theme.fg("muted", ` ... and ${queryList.length - 5} more`));
404
+ }
405
+ return new Text(lines.join("\n"), 0, 0);
406
+ },
407
+
408
+ renderResult(result, { expanded, isPartial }, theme) {
409
+ const details = result.details as {
410
+ queryCount?: number;
411
+ successfulQueries?: number;
412
+ totalResults?: number;
413
+ error?: string;
414
+ fetchId?: string;
415
+ fetchUrls?: string[];
416
+ phase?: string;
417
+ progress?: number;
418
+ currentQuery?: string;
419
+ };
420
+
421
+ if (isPartial) {
422
+ const progress = details?.progress ?? 0;
423
+ const bar = "\u2588".repeat(Math.floor(progress * 10)) + "\u2591".repeat(10 - Math.floor(progress * 10));
424
+ const query = details?.currentQuery || "";
425
+ const display = query.length > 40 ? query.slice(0, 37) + "..." : query;
426
+ return new Text(theme.fg("accent", `[${bar}] ${display}`), 0, 0);
427
+ }
428
+
429
+ if (details?.error) {
430
+ return new Text(theme.fg("error", `Error: ${details.error}`), 0, 0);
431
+ }
432
+
433
+ let statusLine: string;
434
+ const queryInfo = details?.queryCount === 1 ? "" : `${details?.successfulQueries}/${details?.queryCount} queries, `;
435
+ statusLine = theme.fg("success", `${queryInfo}${details?.totalResults ?? 0} sources`);
436
+ if (details?.fetchId && details?.fetchUrls) {
437
+ statusLine += theme.fg("muted", ` (fetching ${details.fetchUrls.length} URLs)`);
438
+ } else if (details?.fetchId) {
439
+ statusLine += theme.fg("muted", " (content ready)");
440
+ }
441
+
442
+ if (!expanded) {
443
+ return new Text(statusLine, 0, 0);
444
+ }
445
+
446
+ const textContent = result.content.find((c) => c.type === "text")?.text || "";
447
+ const preview = textContent.length > 500 ? textContent.slice(0, 500) + "..." : textContent;
448
+ return new Text(statusLine + "\n" + theme.fg("dim", preview), 0, 0);
449
+ },
450
+ });
451
+
452
+ pi.registerTool({
453
+ name: "code_search",
454
+ label: "Code Search",
455
+ description: "Search for code examples, documentation, and API references via Exa MCP. No API key required.",
456
+ promptSnippet:
457
+ "Use for programming/API/library questions to retrieve concrete examples and docs.",
458
+ parameters: Type.Object({
459
+ query: Type.String({ description: "Programming question, API, library, or debugging topic to search for" }),
460
+ maxTokens: Type.Optional(Type.Integer({
461
+ minimum: 1000,
462
+ maximum: 50000,
463
+ description: "Maximum tokens of code/documentation context to return (default: 5000)",
464
+ })),
465
+ }),
466
+
467
+ async execute(toolCallId, params, signal) {
468
+ return executeCodeSearch(toolCallId, params, signal);
469
+ },
470
+
471
+ renderCall(args, theme) {
472
+ const { query } = args as { query?: string };
473
+ const display = !query
474
+ ? "(no query)"
475
+ : query.length > 70 ? query.slice(0, 67) + "..." : query;
476
+ return new Text(theme.fg("toolTitle", theme.bold("code_search ")) + theme.fg("accent", display), 0, 0);
477
+ },
478
+
479
+ renderResult(result, { expanded }, theme) {
480
+ const details = result.details as { query?: string; maxTokens?: number; error?: string };
481
+ if (details?.error) {
482
+ return new Text(theme.fg("error", `Error: ${details.error}`), 0, 0);
483
+ }
484
+
485
+ const summary = theme.fg("success", "code context returned") +
486
+ theme.fg("muted", ` (${details?.maxTokens ?? 5000} tokens max)`);
487
+ if (!expanded) return new Text(summary, 0, 0);
488
+
489
+ const textContent = result.content.find((c) => c.type === "text")?.text || "";
490
+ const preview = textContent.length > 500 ? textContent.slice(0, 500) + "..." : textContent;
491
+ return new Text(summary + "\n" + theme.fg("dim", preview), 0, 0);
492
+ },
493
+ });
494
+
495
+ pi.registerTool({
496
+ name: "fetch_content",
497
+ label: "Fetch Content",
498
+ description: "Fetch URL(s) and extract readable content as markdown. Supports GitHub repository contents and regular web pages. Uses Readability extraction with Jina Reader fallback for JS-rendered pages.",
499
+ promptSnippet:
500
+ "Use to extract readable content from URL(s) or GitHub repos.",
501
+ parameters: Type.Object({
502
+ url: Type.Optional(Type.String({ description: "Single URL to fetch" })),
503
+ urls: Type.Optional(Type.Array(Type.String(), { description: "Multiple URLs (parallel)" })),
504
+ forceClone: Type.Optional(Type.Boolean({
505
+ description: "Force cloning large GitHub repositories that exceed the size threshold",
506
+ })),
507
+ }),
508
+
509
+ async execute(_toolCallId, params, signal, onUpdate) {
510
+ const urlList = (params.urls ?? (params.url ? [params.url] : []))
511
+ .filter((u: unknown) => typeof u === "string" && u.trim().length > 0) as string[];
512
+ if (urlList.length === 0) {
513
+ return {
514
+ content: [{ type: "text", text: "Error: No URL provided." }],
515
+ details: { error: "No URL provided" },
516
+ };
517
+ }
518
+
519
+ onUpdate?.({
520
+ content: [{ type: "text", text: `Fetching ${urlList.length} URL(s)...` }],
521
+ details: { phase: "fetch", progress: 0 },
522
+ });
523
+
524
+ const fetchResults = await fetchAllContent(urlList, signal, {
525
+ forceClone: params.forceClone,
526
+ });
527
+ const successful = fetchResults.filter((r) => !r.error).length;
528
+ const totalChars = fetchResults.reduce((sum, r) => sum + r.content.length, 0);
529
+
530
+ const responseId = generateId();
531
+ const data: StoredSearchData = {
532
+ id: responseId,
533
+ type: "fetch",
534
+ timestamp: Date.now(),
535
+ urls: fetchResults,
536
+ };
537
+ storeResult(responseId, data);
538
+ pi.appendEntry("web-search-results", data);
539
+
540
+ if (urlList.length === 1) {
541
+ const result = fetchResults[0];
542
+ if (result.error) {
543
+ return {
544
+ content: [{ type: "text", text: `Error: ${result.error}` }],
545
+ details: { urls: urlList, urlCount: 1, successful: 0, error: result.error, responseId },
546
+ };
547
+ }
548
+
549
+ const fullLength = result.content.length;
550
+ const truncated = fullLength > MAX_INLINE_CONTENT;
551
+ let output = truncated
552
+ ? result.content.slice(0, MAX_INLINE_CONTENT) + "\n\n[Content truncated...]"
553
+ : result.content;
554
+
555
+ if (truncated) {
556
+ output += `\n\n---\nShowing ${MAX_INLINE_CONTENT} of ${fullLength} chars. ` +
557
+ `Use get_search_content({ responseId: "${responseId}", urlIndex: 0 }) for full content.`;
558
+ }
559
+
560
+ return {
561
+ content: [{ type: "text", text: output }],
562
+ details: {
563
+ urls: urlList,
564
+ urlCount: 1,
565
+ successful: 1,
566
+ totalChars: fullLength,
567
+ title: result.title,
568
+ responseId,
569
+ truncated,
570
+ },
571
+ };
572
+ }
573
+
574
+ let output = "## Fetched URLs\n\n";
575
+ for (const { url, title, content, error } of fetchResults) {
576
+ if (error) {
577
+ output += `- ${url}: Error - ${error}\n`;
578
+ } else {
579
+ output += `- ${title || url} (${content.length} chars)\n`;
580
+ }
581
+ }
582
+ output += `\n---\nUse get_search_content({ responseId: "${responseId}", urlIndex: 0 }) to retrieve full content.`;
583
+
584
+ return {
585
+ content: [{ type: "text", text: output }],
586
+ details: { urls: urlList, urlCount: urlList.length, successful, totalChars, responseId },
587
+ };
588
+ },
589
+
590
+ renderCall(args, theme) {
591
+ const { url, urls } = args as { url?: string; urls?: string[] };
592
+ const urlList = urls ?? (url ? [url] : []);
593
+ if (urlList.length === 0) {
594
+ return new Text(theme.fg("toolTitle", theme.bold("fetch ")) + theme.fg("error", "(no URL)"), 0, 0);
595
+ }
596
+ const lines: string[] = [];
597
+ if (urlList.length === 1) {
598
+ const display = urlList[0].length > 60 ? urlList[0].slice(0, 57) + "..." : urlList[0];
599
+ lines.push(theme.fg("toolTitle", theme.bold("fetch ")) + theme.fg("accent", display));
600
+ } else {
601
+ lines.push(theme.fg("toolTitle", theme.bold("fetch ")) + theme.fg("accent", `${urlList.length} URLs`));
602
+ for (const u of urlList.slice(0, 5)) {
603
+ const display = u.length > 60 ? u.slice(0, 57) + "..." : u;
604
+ lines.push(theme.fg("muted", " " + display));
605
+ }
606
+ if (urlList.length > 5) {
607
+ lines.push(theme.fg("muted", ` ... and ${urlList.length - 5} more`));
608
+ }
609
+ }
610
+ return new Text(lines.join("\n"), 0, 0);
611
+ },
612
+
613
+ renderResult(result, { expanded, isPartial }, theme) {
614
+ const details = result.details as {
615
+ urlCount?: number;
616
+ successful?: number;
617
+ totalChars?: number;
618
+ error?: string;
619
+ title?: string;
620
+ truncated?: boolean;
621
+ responseId?: string;
622
+ phase?: string;
623
+ progress?: number;
624
+ };
625
+
626
+ if (isPartial) {
627
+ const progress = details?.progress ?? 0;
628
+ const bar = "\u2588".repeat(Math.floor(progress * 10)) + "\u2591".repeat(10 - Math.floor(progress * 10));
629
+ return new Text(theme.fg("accent", `[${bar}] ${details?.phase || "fetching"}`), 0, 0);
630
+ }
631
+
632
+ if (details?.error) {
633
+ return new Text(theme.fg("error", `Error: ${details.error}`), 0, 0);
634
+ }
635
+
636
+ if (details?.urlCount === 1) {
637
+ const title = details?.title || "Untitled";
638
+ let statusLine = theme.fg("success", title) + theme.fg("muted", ` (${details?.totalChars ?? 0} chars)`);
639
+ if (details?.truncated) {
640
+ statusLine += theme.fg("warning", " [truncated]");
641
+ }
642
+ const textContent = result.content.find((c) => c.type === "text")?.text || "";
643
+ if (!expanded) {
644
+ const brief = textContent.length > 200 ? textContent.slice(0, 200) + "..." : textContent;
645
+ return new Text(statusLine + "\n" + theme.fg("dim", brief), 0, 0);
646
+ }
647
+ const preview = textContent.length > 500 ? textContent.slice(0, 500) + "..." : textContent;
648
+ return new Text(statusLine + "\n" + theme.fg("dim", preview), 0, 0);
649
+ }
650
+
651
+ const countColor = (details?.successful ?? 0) > 0 ? "success" : "error";
652
+ const statusLine = theme.fg(countColor, `${details?.successful}/${details?.urlCount} URLs`) + theme.fg("muted", " (content stored)");
653
+ if (!expanded) {
654
+ return new Text(statusLine, 0, 0);
655
+ }
656
+ const textContent = result.content.find((c) => c.type === "text")?.text || "";
657
+ const preview = textContent.length > 500 ? textContent.slice(0, 500) + "..." : textContent;
658
+ return new Text(statusLine + "\n" + theme.fg("dim", preview), 0, 0);
659
+ },
660
+ });
661
+
662
+ pi.registerTool({
663
+ name: "get_search_content",
664
+ label: "Get Search Content",
665
+ description: "Retrieve full content from a previous web_search or fetch_content call.",
666
+ promptSnippet:
667
+ "Use after web_search/fetch_content when full stored content is needed via responseId.",
668
+ parameters: Type.Object({
669
+ responseId: Type.String({ description: "The responseId from web_search or fetch_content" }),
670
+ query: Type.Optional(Type.String({ description: "Get content for this query (web_search)" })),
671
+ queryIndex: Type.Optional(Type.Number({ description: "Get content for query at index" })),
672
+ url: Type.Optional(Type.String({ description: "Get content for this URL" })),
673
+ urlIndex: Type.Optional(Type.Number({ description: "Get content for URL at index" })),
674
+ }),
675
+
676
+ async execute(_toolCallId, params) {
677
+ const data = getResult(params.responseId);
678
+ if (!data) {
679
+ return {
680
+ content: [{ type: "text", text: `Error: No stored results for "${params.responseId}"` }],
681
+ details: { error: "Not found", responseId: params.responseId },
682
+ };
683
+ }
684
+
685
+ if (data.type === "search" && data.queries) {
686
+ let queryData: QueryResultData | undefined;
687
+
688
+ if (params.query !== undefined) {
689
+ queryData = data.queries.find((q) => q.query === params.query);
690
+ if (!queryData) {
691
+ const available = data.queries.map((q) => `"${q.query}"`).join(", ");
692
+ return {
693
+ content: [{ type: "text", text: `Query "${params.query}" not found. Available: ${available}` }],
694
+ details: { error: "Query not found" },
695
+ };
696
+ }
697
+ } else if (params.queryIndex !== undefined) {
698
+ queryData = data.queries[params.queryIndex];
699
+ if (!queryData) {
700
+ return {
701
+ content: [{ type: "text", text: `Index ${params.queryIndex} out of range (0-${data.queries.length - 1})` }],
702
+ details: { error: "Index out of range" },
703
+ };
704
+ }
705
+ } else {
706
+ const available = data.queries.map((q, i) => `${i}: "${q.query}"`).join(", ");
707
+ return {
708
+ content: [{ type: "text", text: `Specify query or queryIndex. Available: ${available}` }],
709
+ details: { error: "No query specified" },
710
+ };
711
+ }
712
+
713
+ if (queryData.error) {
714
+ return {
715
+ content: [{ type: "text", text: `Error for "${queryData.query}": ${queryData.error}` }],
716
+ details: { error: queryData.error, query: queryData.query },
717
+ };
718
+ }
719
+
720
+ return {
721
+ content: [{ type: "text", text: formatFullResults(queryData) }],
722
+ details: { query: queryData.query, resultCount: queryData.results.length },
723
+ };
724
+ }
725
+
726
+ if (data.type === "fetch" && data.urls) {
727
+ let urlData: ExtractedContent | undefined;
728
+
729
+ if (params.url !== undefined) {
730
+ urlData = data.urls.find((u) => u.url === params.url);
731
+ if (!urlData) {
732
+ const available = data.urls.map((u) => u.url).join("\n ");
733
+ return {
734
+ content: [{ type: "text", text: `URL not found. Available:\n ${available}` }],
735
+ details: { error: "URL not found" },
736
+ };
737
+ }
738
+ } else if (params.urlIndex !== undefined) {
739
+ urlData = data.urls[params.urlIndex];
740
+ if (!urlData) {
741
+ return {
742
+ content: [{ type: "text", text: `Index ${params.urlIndex} out of range (0-${data.urls.length - 1})` }],
743
+ details: { error: "Index out of range" },
744
+ };
745
+ }
746
+ } else {
747
+ const available = data.urls.map((u, i) => `${i}: ${u.url}`).join("\n ");
748
+ return {
749
+ content: [{ type: "text", text: `Specify url or urlIndex. Available:\n ${available}` }],
750
+ details: { error: "No URL specified" },
751
+ };
752
+ }
753
+
754
+ if (urlData.error) {
755
+ return {
756
+ content: [{ type: "text", text: `Error for ${urlData.url}: ${urlData.error}` }],
757
+ details: { error: urlData.error, url: urlData.url },
758
+ };
759
+ }
760
+
761
+ return {
762
+ content: [{ type: "text", text: `# ${urlData.title}\n\n${urlData.content}` }],
763
+ details: { url: urlData.url, title: urlData.title, contentLength: urlData.content.length },
764
+ };
765
+ }
766
+
767
+ return {
768
+ content: [{ type: "text", text: "Invalid stored data format" }],
769
+ details: { error: "Invalid data" },
770
+ };
771
+ },
772
+
773
+ renderCall(args, theme) {
774
+ const { responseId, query, queryIndex, url, urlIndex } = args as {
775
+ responseId: string;
776
+ query?: string;
777
+ queryIndex?: number;
778
+ url?: string;
779
+ urlIndex?: number;
780
+ };
781
+ let target = "";
782
+ if (query) target = `query="${query}"`;
783
+ else if (queryIndex !== undefined) target = `queryIndex=${queryIndex}`;
784
+ else if (url) target = url.length > 30 ? url.slice(0, 27) + "..." : url;
785
+ else if (urlIndex !== undefined) target = `urlIndex=${urlIndex}`;
786
+ return new Text(theme.fg("toolTitle", theme.bold("get_content ")) + theme.fg("accent", target || responseId.slice(0, 8)), 0, 0);
787
+ },
788
+
789
+ renderResult(result, { expanded }, theme) {
790
+ const details = result.details as {
791
+ error?: string;
792
+ query?: string;
793
+ url?: string;
794
+ title?: string;
795
+ resultCount?: number;
796
+ contentLength?: number;
797
+ };
798
+
799
+ if (details?.error) {
800
+ return new Text(theme.fg("error", `Error: ${details.error}`), 0, 0);
801
+ }
802
+
803
+ let statusLine: string;
804
+ if (details?.query) {
805
+ statusLine = theme.fg("success", `"${details.query}"`) + theme.fg("muted", ` (${details.resultCount} results)`);
806
+ } else {
807
+ statusLine = theme.fg("success", details?.title || "Content") + theme.fg("muted", ` (${details?.contentLength ?? 0} chars)`);
808
+ }
809
+
810
+ if (!expanded) {
811
+ return new Text(statusLine, 0, 0);
812
+ }
813
+
814
+ const textContent = result.content.find((c) => c.type === "text")?.text || "";
815
+ const preview = textContent.length > 500 ? textContent.slice(0, 500) + "..." : textContent;
816
+ return new Text(statusLine + "\n" + theme.fg("dim", preview), 0, 0);
817
+ },
818
+ });
819
+
820
+ pi.registerCommand("search", {
821
+ description: "Browse stored web search results",
822
+ handler: async (_args, ctx) => {
823
+ const results = getAllResults();
824
+
825
+ if (results.length === 0) {
826
+ ctx.ui.notify("No stored search results", "info");
827
+ return;
828
+ }
829
+
830
+ const options = results.map((r) => {
831
+ const age = Math.floor((Date.now() - r.timestamp) / 60000);
832
+ const ageStr = age < 60 ? `${age}m ago` : `${Math.floor(age / 60)}h ago`;
833
+ if (r.type === "search" && r.queries) {
834
+ const query = r.queries[0]?.query || "unknown";
835
+ return `[${r.id.slice(0, 6)}] "${query}" (${r.queries.length} queries) - ${ageStr}`;
836
+ }
837
+ if (r.type === "fetch" && r.urls) {
838
+ return `[${r.id.slice(0, 6)}] ${r.urls.length} URLs fetched - ${ageStr}`;
839
+ }
840
+ return `[${r.id.slice(0, 6)}] ${r.type} - ${ageStr}`;
841
+ });
842
+
843
+ const choice = await ctx.ui.select("Stored Search Results", options);
844
+ if (!choice) return;
845
+
846
+ const match = choice.match(/^\[([a-z0-9]+)\]/);
847
+ if (!match) return;
848
+
849
+ const selected = results.find((r) => r.id.startsWith(match[1]));
850
+ if (!selected) return;
851
+
852
+ const actions = ["View details", "Delete"];
853
+ const action = await ctx.ui.select(`Result ${selected.id.slice(0, 6)}`, actions);
854
+
855
+ if (action === "Delete") {
856
+ deleteResult(selected.id);
857
+ ctx.ui.notify(`Deleted ${selected.id.slice(0, 6)}`, "info");
858
+ } else if (action === "View details") {
859
+ let info = `ID: ${selected.id}\nType: ${selected.type}\nAge: ${Math.floor((Date.now() - selected.timestamp) / 60000)}m\n\n`;
860
+ if (selected.type === "search" && selected.queries) {
861
+ info += "Queries:\n";
862
+ const queries = selected.queries.slice(0, 10);
863
+ for (const q of queries) {
864
+ info += `- "${q.query}" (${q.results.length} results)\n`;
865
+ }
866
+ if (selected.queries.length > 10) {
867
+ info += `... and ${selected.queries.length - 10} more\n`;
868
+ }
869
+ }
870
+ if (selected.type === "fetch" && selected.urls) {
871
+ info += "URLs:\n";
872
+ const urls = selected.urls.slice(0, 10);
873
+ for (const u of urls) {
874
+ const urlDisplay = u.url.length > 50 ? u.url.slice(0, 47) + "..." : u.url;
875
+ info += `- ${urlDisplay} (${u.error || `${u.content.length} chars`})\n`;
876
+ }
877
+ if (selected.urls.length > 10) {
878
+ info += `... and ${selected.urls.length - 10} more\n`;
879
+ }
880
+ }
881
+ ctx.ui.notify(info, "info");
882
+ }
883
+ },
884
+ });
885
+ }