pi-web-access 0.4.2
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/CHANGELOG.md +96 -0
- package/README.md +179 -0
- package/activity.ts +102 -0
- package/banner.png +0 -0
- package/extract.ts +189 -0
- package/index.ts +761 -0
- package/package.json +16 -0
- package/pdf-extract.ts +184 -0
- package/perplexity.ts +181 -0
- package/rsc-extract.ts +338 -0
- package/storage.ts +71 -0
package/index.ts
ADDED
|
@@ -0,0 +1,761 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
import { Key, Text, truncateToWidth } from "@mariozechner/pi-tui";
|
|
3
|
+
import { Type } from "@sinclair/typebox";
|
|
4
|
+
import { fetchAllContent, type ExtractedContent } from "./extract.js";
|
|
5
|
+
import { searchWithPerplexity, type SearchResult } from "./perplexity.js";
|
|
6
|
+
import {
|
|
7
|
+
clearResults,
|
|
8
|
+
deleteResult,
|
|
9
|
+
generateId,
|
|
10
|
+
getAllResults,
|
|
11
|
+
getResult,
|
|
12
|
+
restoreFromSession,
|
|
13
|
+
storeResult,
|
|
14
|
+
type QueryResultData,
|
|
15
|
+
type StoredSearchData,
|
|
16
|
+
} from "./storage.js";
|
|
17
|
+
import { activityMonitor, type ActivityEntry } from "./activity.js";
|
|
18
|
+
|
|
19
|
+
const pendingFetches = new Map<string, AbortController>();
|
|
20
|
+
let sessionActive = false;
|
|
21
|
+
let widgetVisible = false;
|
|
22
|
+
let widgetUnsubscribe: (() => void) | null = null;
|
|
23
|
+
|
|
24
|
+
const MAX_INLINE_CONTENT = 30000; // Content returned directly to agent
|
|
25
|
+
|
|
26
|
+
function formatSearchSummary(results: SearchResult[], answer: string): string {
|
|
27
|
+
let output = answer ? `${answer}\n\n---\n\n**Sources:**\n` : "";
|
|
28
|
+
output += results.map((r, i) => `${i + 1}. ${r.title}\n ${r.url}`).join("\n\n");
|
|
29
|
+
return output;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function formatFullResults(queryData: QueryResultData): string {
|
|
33
|
+
let output = `## Results for: "${queryData.query}"\n\n`;
|
|
34
|
+
if (queryData.answer) {
|
|
35
|
+
output += `${queryData.answer}\n\n---\n\n`;
|
|
36
|
+
}
|
|
37
|
+
for (const r of queryData.results) {
|
|
38
|
+
output += `### ${r.title}\n${r.url}\n\n`;
|
|
39
|
+
}
|
|
40
|
+
return output;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function abortPendingFetches(): void {
|
|
44
|
+
for (const controller of pendingFetches.values()) {
|
|
45
|
+
controller.abort();
|
|
46
|
+
}
|
|
47
|
+
pendingFetches.clear();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function updateWidget(ctx: ExtensionContext): void {
|
|
51
|
+
const theme = ctx.ui.theme;
|
|
52
|
+
const entries = activityMonitor.getEntries();
|
|
53
|
+
const lines: string[] = [];
|
|
54
|
+
|
|
55
|
+
lines.push(theme.fg("accent", "─── Web Search Activity " + "─".repeat(36)));
|
|
56
|
+
|
|
57
|
+
if (entries.length === 0) {
|
|
58
|
+
lines.push(theme.fg("muted", " No activity yet"));
|
|
59
|
+
} else {
|
|
60
|
+
for (const e of entries) {
|
|
61
|
+
lines.push(" " + formatEntryLine(e, theme));
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
lines.push(theme.fg("accent", "─".repeat(60)));
|
|
66
|
+
|
|
67
|
+
const rateInfo = activityMonitor.getRateLimitInfo();
|
|
68
|
+
const resetMs = rateInfo.oldestTimestamp ? Math.max(0, rateInfo.oldestTimestamp + rateInfo.windowMs - Date.now()) : 0;
|
|
69
|
+
const resetSec = Math.ceil(resetMs / 1000);
|
|
70
|
+
lines.push(
|
|
71
|
+
theme.fg("muted", `Rate: ${rateInfo.used}/${rateInfo.max}`) +
|
|
72
|
+
(resetMs > 0 ? theme.fg("dim", ` (resets in ${resetSec}s)`) : ""),
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
ctx.ui.setWidget("web-activity", new Text(lines.join("\n"), 0, 0));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function formatEntryLine(
|
|
79
|
+
entry: ActivityEntry,
|
|
80
|
+
theme: { fg: (color: string, text: string) => string },
|
|
81
|
+
): string {
|
|
82
|
+
const typeStr = entry.type === "api" ? "API" : "GET";
|
|
83
|
+
const target =
|
|
84
|
+
entry.type === "api"
|
|
85
|
+
? `"${truncateToWidth(entry.query || "", 28, "")}"`
|
|
86
|
+
: truncateToWidth(entry.url?.replace(/^https?:\/\//, "") || "", 30, "");
|
|
87
|
+
|
|
88
|
+
const duration = entry.endTime
|
|
89
|
+
? `${((entry.endTime - entry.startTime) / 1000).toFixed(1)}s`
|
|
90
|
+
: `${((Date.now() - entry.startTime) / 1000).toFixed(1)}s`;
|
|
91
|
+
|
|
92
|
+
let statusStr: string;
|
|
93
|
+
let indicator: string;
|
|
94
|
+
if (entry.error) {
|
|
95
|
+
statusStr = "err";
|
|
96
|
+
indicator = theme.fg("error", "✗");
|
|
97
|
+
} else if (entry.status === null) {
|
|
98
|
+
statusStr = "...";
|
|
99
|
+
indicator = theme.fg("warning", "⋯");
|
|
100
|
+
} else if (entry.status === 0) {
|
|
101
|
+
statusStr = "abort";
|
|
102
|
+
indicator = theme.fg("muted", "○");
|
|
103
|
+
} else {
|
|
104
|
+
statusStr = String(entry.status);
|
|
105
|
+
indicator = entry.status >= 200 && entry.status < 300 ? theme.fg("success", "✓") : theme.fg("error", "✗");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return `${typeStr.padEnd(4)} ${target.padEnd(32)} ${statusStr.padStart(5)} ${duration.padStart(5)} ${indicator}`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function handleSessionChange(ctx: ExtensionContext): void {
|
|
112
|
+
abortPendingFetches();
|
|
113
|
+
sessionActive = true;
|
|
114
|
+
restoreFromSession(ctx);
|
|
115
|
+
// Unsubscribe before clear() to avoid callback with stale ctx
|
|
116
|
+
widgetUnsubscribe?.();
|
|
117
|
+
widgetUnsubscribe = null;
|
|
118
|
+
activityMonitor.clear();
|
|
119
|
+
if (widgetVisible) {
|
|
120
|
+
// Re-subscribe with new ctx
|
|
121
|
+
widgetUnsubscribe = activityMonitor.onUpdate(() => updateWidget(ctx));
|
|
122
|
+
updateWidget(ctx);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export default function (pi: ExtensionAPI) {
|
|
127
|
+
pi.registerShortcut(Key.ctrlShift("w"), {
|
|
128
|
+
description: "Toggle web search activity",
|
|
129
|
+
handler: async (ctx) => {
|
|
130
|
+
widgetVisible = !widgetVisible;
|
|
131
|
+
if (widgetVisible) {
|
|
132
|
+
widgetUnsubscribe = activityMonitor.onUpdate(() => updateWidget(ctx));
|
|
133
|
+
updateWidget(ctx);
|
|
134
|
+
} else {
|
|
135
|
+
widgetUnsubscribe?.();
|
|
136
|
+
widgetUnsubscribe = null;
|
|
137
|
+
ctx.ui.setWidget("web-activity", null);
|
|
138
|
+
}
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
pi.on("session_start", async (_event, ctx) => handleSessionChange(ctx));
|
|
143
|
+
pi.on("session_switch", async (_event, ctx) => handleSessionChange(ctx));
|
|
144
|
+
pi.on("session_branch", async (_event, ctx) => handleSessionChange(ctx));
|
|
145
|
+
pi.on("session_tree", async (_event, ctx) => handleSessionChange(ctx));
|
|
146
|
+
|
|
147
|
+
pi.on("session_shutdown", () => {
|
|
148
|
+
sessionActive = false;
|
|
149
|
+
abortPendingFetches();
|
|
150
|
+
clearResults();
|
|
151
|
+
// Unsubscribe before clear() to avoid callback with stale ctx
|
|
152
|
+
widgetUnsubscribe?.();
|
|
153
|
+
widgetUnsubscribe = null;
|
|
154
|
+
activityMonitor.clear();
|
|
155
|
+
widgetVisible = false;
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
pi.registerTool({
|
|
159
|
+
name: "web_search",
|
|
160
|
+
label: "Web Search",
|
|
161
|
+
description:
|
|
162
|
+
"Search the web using Perplexity AI. Returns an AI-synthesized answer with source citations. Supports batch searching with multiple queries. When includeContent is true, full page content is fetched in the background.",
|
|
163
|
+
parameters: Type.Object({
|
|
164
|
+
query: Type.Optional(Type.String({ description: "Single search query" })),
|
|
165
|
+
queries: Type.Optional(Type.Array(Type.String(), { description: "Multiple queries (parallel)" })),
|
|
166
|
+
numResults: Type.Optional(Type.Number({ description: "Results per query (default: 5, max: 20)" })),
|
|
167
|
+
includeContent: Type.Optional(Type.Boolean({ description: "Fetch full page content (async)" })),
|
|
168
|
+
recencyFilter: Type.Optional(
|
|
169
|
+
Type.Union([Type.Literal("day"), Type.Literal("week"), Type.Literal("month"), Type.Literal("year")], {
|
|
170
|
+
description: "Filter by recency",
|
|
171
|
+
}),
|
|
172
|
+
),
|
|
173
|
+
domainFilter: Type.Optional(Type.Array(Type.String(), { description: "Limit to domains (prefix with - to exclude)" })),
|
|
174
|
+
}),
|
|
175
|
+
|
|
176
|
+
async execute(_toolCallId, params, onUpdate, _ctx, signal) {
|
|
177
|
+
const queryList = params.queries ?? (params.query ? [params.query] : []);
|
|
178
|
+
if (queryList.length === 0) {
|
|
179
|
+
return {
|
|
180
|
+
content: [{ type: "text", text: "Error: No query provided. Use 'query' or 'queries' parameter." }],
|
|
181
|
+
details: { error: "No query provided" },
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const searchResults: QueryResultData[] = [];
|
|
186
|
+
const allUrls: string[] = [];
|
|
187
|
+
|
|
188
|
+
for (let i = 0; i < queryList.length; i++) {
|
|
189
|
+
const query = queryList[i];
|
|
190
|
+
|
|
191
|
+
onUpdate?.({
|
|
192
|
+
content: [{ type: "text", text: `Searching ${i + 1}/${queryList.length}: "${query}"...` }],
|
|
193
|
+
details: { phase: "search", progress: i / queryList.length, currentQuery: query },
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
try {
|
|
197
|
+
const { answer, results } = await searchWithPerplexity(query, {
|
|
198
|
+
numResults: params.numResults,
|
|
199
|
+
recencyFilter: params.recencyFilter,
|
|
200
|
+
domainFilter: params.domainFilter,
|
|
201
|
+
signal,
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
searchResults.push({ query, answer, results, error: null });
|
|
205
|
+
for (const r of results) {
|
|
206
|
+
if (!allUrls.includes(r.url)) {
|
|
207
|
+
allUrls.push(r.url);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
} catch (err) {
|
|
211
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
212
|
+
searchResults.push({ query, answer: "", results: [], error: message });
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const successCount = searchResults.filter((r) => !r.error).length;
|
|
217
|
+
const totalResults = searchResults.reduce((sum, r) => sum + r.results.length, 0);
|
|
218
|
+
|
|
219
|
+
let output = "";
|
|
220
|
+
for (const { query, answer, results, error } of searchResults) {
|
|
221
|
+
if (queryList.length > 1) {
|
|
222
|
+
output += `## Query: "${query}"\n\n`;
|
|
223
|
+
}
|
|
224
|
+
if (error) {
|
|
225
|
+
output += `Error: ${error}\n\n`;
|
|
226
|
+
} else if (results.length === 0) {
|
|
227
|
+
output += "No results found.\n\n";
|
|
228
|
+
} else {
|
|
229
|
+
output += formatSearchSummary(results, answer) + "\n\n";
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
let fetchId: string | null = null;
|
|
234
|
+
if (params.includeContent && allUrls.length > 0) {
|
|
235
|
+
fetchId = generateId();
|
|
236
|
+
const controller = new AbortController();
|
|
237
|
+
pendingFetches.set(fetchId, controller);
|
|
238
|
+
|
|
239
|
+
const capturedFetchId = fetchId;
|
|
240
|
+
fetchAllContent(allUrls, controller.signal)
|
|
241
|
+
.then((fetched) => {
|
|
242
|
+
if (!sessionActive || !pendingFetches.has(capturedFetchId)) {
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const data: StoredSearchData = {
|
|
247
|
+
id: capturedFetchId,
|
|
248
|
+
type: "fetch",
|
|
249
|
+
timestamp: Date.now(),
|
|
250
|
+
urls: fetched,
|
|
251
|
+
};
|
|
252
|
+
storeResult(capturedFetchId, data);
|
|
253
|
+
pi.appendEntry("web-search-results", data);
|
|
254
|
+
|
|
255
|
+
const successfulFetches = fetched.filter((f) => !f.error).length;
|
|
256
|
+
pi.sendMessage(
|
|
257
|
+
{
|
|
258
|
+
customType: "web-search-content-ready",
|
|
259
|
+
content: `Content fetched for ${successfulFetches}/${fetched.length} URLs [${capturedFetchId}]. Full page content now available.`,
|
|
260
|
+
display: true,
|
|
261
|
+
},
|
|
262
|
+
{ triggerTurn: true },
|
|
263
|
+
);
|
|
264
|
+
})
|
|
265
|
+
.catch((err) => {
|
|
266
|
+
if (!sessionActive || !pendingFetches.has(capturedFetchId)) {
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
271
|
+
const isAbort = err.name === "AbortError" || message.toLowerCase().includes("abort");
|
|
272
|
+
if (!isAbort) {
|
|
273
|
+
pi.sendMessage(
|
|
274
|
+
{
|
|
275
|
+
customType: "web-search-error",
|
|
276
|
+
content: `Content fetch failed [${capturedFetchId}]: ${message}`,
|
|
277
|
+
display: true,
|
|
278
|
+
},
|
|
279
|
+
{ triggerTurn: false },
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
})
|
|
283
|
+
.finally(() => {
|
|
284
|
+
pendingFetches.delete(capturedFetchId);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
output += `---\nContent fetching in background [${fetchId}]. Will notify when ready.`;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const searchId = generateId();
|
|
291
|
+
const searchData: StoredSearchData = {
|
|
292
|
+
id: searchId,
|
|
293
|
+
type: "search",
|
|
294
|
+
timestamp: Date.now(),
|
|
295
|
+
queries: searchResults,
|
|
296
|
+
};
|
|
297
|
+
storeResult(searchId, searchData);
|
|
298
|
+
pi.appendEntry("web-search-results", searchData);
|
|
299
|
+
|
|
300
|
+
return {
|
|
301
|
+
content: [{ type: "text", text: output.trim() }],
|
|
302
|
+
details: {
|
|
303
|
+
queries: queryList,
|
|
304
|
+
queryCount: queryList.length,
|
|
305
|
+
successfulQueries: successCount,
|
|
306
|
+
totalResults,
|
|
307
|
+
includeContent: params.includeContent ?? false,
|
|
308
|
+
fetchId,
|
|
309
|
+
fetchUrls: fetchId ? allUrls : undefined,
|
|
310
|
+
searchId,
|
|
311
|
+
},
|
|
312
|
+
};
|
|
313
|
+
},
|
|
314
|
+
|
|
315
|
+
renderCall(args, theme) {
|
|
316
|
+
const { query, queries } = args as { query?: string; queries?: string[] };
|
|
317
|
+
const queryList = queries ?? (query ? [query] : []);
|
|
318
|
+
if (queryList.length === 0) {
|
|
319
|
+
return new Text(theme.fg("toolTitle", theme.bold("search ")) + theme.fg("error", "(no query)"), 0, 0);
|
|
320
|
+
}
|
|
321
|
+
if (queryList.length === 1) {
|
|
322
|
+
const q = queryList[0];
|
|
323
|
+
const display = q.length > 60 ? q.slice(0, 57) + "..." : q;
|
|
324
|
+
return new Text(theme.fg("toolTitle", theme.bold("search ")) + theme.fg("accent", `"${display}"`), 0, 0);
|
|
325
|
+
}
|
|
326
|
+
const lines = [theme.fg("toolTitle", theme.bold("search ")) + theme.fg("accent", `${queryList.length} queries`)];
|
|
327
|
+
for (const q of queryList.slice(0, 5)) {
|
|
328
|
+
const display = q.length > 50 ? q.slice(0, 47) + "..." : q;
|
|
329
|
+
lines.push(theme.fg("muted", ` "${display}"`));
|
|
330
|
+
}
|
|
331
|
+
if (queryList.length > 5) {
|
|
332
|
+
lines.push(theme.fg("muted", ` ... and ${queryList.length - 5} more`));
|
|
333
|
+
}
|
|
334
|
+
return new Text(lines.join("\n"), 0, 0);
|
|
335
|
+
},
|
|
336
|
+
|
|
337
|
+
renderResult(result, { expanded, isPartial }, theme) {
|
|
338
|
+
const details = result.details as {
|
|
339
|
+
queryCount?: number;
|
|
340
|
+
successfulQueries?: number;
|
|
341
|
+
totalResults?: number;
|
|
342
|
+
error?: string;
|
|
343
|
+
fetchId?: string;
|
|
344
|
+
fetchUrls?: string[];
|
|
345
|
+
phase?: string;
|
|
346
|
+
progress?: number;
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
if (isPartial) {
|
|
350
|
+
const progress = details?.progress ?? 0;
|
|
351
|
+
const bar = "\u2588".repeat(Math.floor(progress * 10)) + "\u2591".repeat(10 - Math.floor(progress * 10));
|
|
352
|
+
return new Text(theme.fg("accent", `[${bar}] ${details?.phase || "searching"}`), 0, 0);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
if (details?.error) {
|
|
356
|
+
return new Text(theme.fg("error", `Error: ${details.error}`), 0, 0);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const queryInfo = details?.queryCount === 1 ? "" : `${details?.successfulQueries}/${details?.queryCount} queries, `;
|
|
360
|
+
let statusLine = theme.fg("success", `${queryInfo}${details?.totalResults ?? 0} sources`);
|
|
361
|
+
if (details?.fetchId && details?.fetchUrls) {
|
|
362
|
+
statusLine += theme.fg("muted", ` (fetching ${details.fetchUrls.length} URLs)`);
|
|
363
|
+
} else if (details?.fetchId) {
|
|
364
|
+
statusLine += theme.fg("muted", " (content fetching)");
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
if (!expanded) {
|
|
368
|
+
return new Text(statusLine, 0, 0);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const lines = [statusLine];
|
|
372
|
+
if (details?.fetchUrls && details.fetchUrls.length > 0) {
|
|
373
|
+
lines.push(theme.fg("muted", "Fetching:"));
|
|
374
|
+
for (const u of details.fetchUrls.slice(0, 5)) {
|
|
375
|
+
const display = u.length > 60 ? u.slice(0, 57) + "..." : u;
|
|
376
|
+
lines.push(theme.fg("dim", " " + display));
|
|
377
|
+
}
|
|
378
|
+
if (details.fetchUrls.length > 5) {
|
|
379
|
+
lines.push(theme.fg("dim", ` ... and ${details.fetchUrls.length - 5} more`));
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const textContent = result.content.find((c) => c.type === "text")?.text || "";
|
|
384
|
+
const preview = textContent.length > 500 ? textContent.slice(0, 500) + "..." : textContent;
|
|
385
|
+
lines.push(theme.fg("dim", preview));
|
|
386
|
+
return new Text(lines.join("\n"), 0, 0);
|
|
387
|
+
},
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
pi.registerTool({
|
|
391
|
+
name: "fetch_content",
|
|
392
|
+
label: "Fetch Content",
|
|
393
|
+
description: "Fetch URL(s) and extract readable content as markdown. Content is always stored and can be retrieved with get_search_content.",
|
|
394
|
+
parameters: Type.Object({
|
|
395
|
+
url: Type.Optional(Type.String({ description: "Single URL to fetch" })),
|
|
396
|
+
urls: Type.Optional(Type.Array(Type.String(), { description: "Multiple URLs (parallel)" })),
|
|
397
|
+
}),
|
|
398
|
+
|
|
399
|
+
async execute(_toolCallId, params, onUpdate, _ctx, signal) {
|
|
400
|
+
const urlList = params.urls ?? (params.url ? [params.url] : []);
|
|
401
|
+
if (urlList.length === 0) {
|
|
402
|
+
return {
|
|
403
|
+
content: [{ type: "text", text: "Error: No URL provided." }],
|
|
404
|
+
details: { error: "No URL provided" },
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
onUpdate?.({
|
|
409
|
+
content: [{ type: "text", text: `Fetching ${urlList.length} URL(s)...` }],
|
|
410
|
+
details: { phase: "fetch", progress: 0 },
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
const fetchResults = await fetchAllContent(urlList, signal);
|
|
414
|
+
const successful = fetchResults.filter((r) => !r.error).length;
|
|
415
|
+
const totalChars = fetchResults.reduce((sum, r) => sum + r.content.length, 0);
|
|
416
|
+
|
|
417
|
+
// ALWAYS store results (even for single URL)
|
|
418
|
+
const responseId = generateId();
|
|
419
|
+
const data: StoredSearchData = {
|
|
420
|
+
id: responseId,
|
|
421
|
+
type: "fetch",
|
|
422
|
+
timestamp: Date.now(),
|
|
423
|
+
urls: fetchResults,
|
|
424
|
+
};
|
|
425
|
+
storeResult(responseId, data);
|
|
426
|
+
pi.appendEntry("web-search-results", data);
|
|
427
|
+
|
|
428
|
+
// Single URL: return content directly (possibly truncated) with responseId
|
|
429
|
+
if (urlList.length === 1) {
|
|
430
|
+
const result = fetchResults[0];
|
|
431
|
+
if (result.error) {
|
|
432
|
+
return {
|
|
433
|
+
content: [{ type: "text", text: `Error: ${result.error}` }],
|
|
434
|
+
details: { urls: urlList, urlCount: 1, successful: 0, error: result.error, responseId },
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const fullLength = result.content.length;
|
|
439
|
+
const truncated = fullLength > MAX_INLINE_CONTENT;
|
|
440
|
+
let output = truncated
|
|
441
|
+
? result.content.slice(0, MAX_INLINE_CONTENT) + "\n\n[Content truncated...]"
|
|
442
|
+
: result.content;
|
|
443
|
+
|
|
444
|
+
if (truncated) {
|
|
445
|
+
output += `\n\n---\nShowing ${MAX_INLINE_CONTENT} of ${fullLength} chars. ` +
|
|
446
|
+
`Use get_search_content({ responseId: "${responseId}", urlIndex: 0 }) for full content.`;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
return {
|
|
450
|
+
content: [{ type: "text", text: output }],
|
|
451
|
+
details: {
|
|
452
|
+
urls: urlList,
|
|
453
|
+
urlCount: 1,
|
|
454
|
+
successful: 1,
|
|
455
|
+
totalChars: fullLength,
|
|
456
|
+
title: result.title,
|
|
457
|
+
responseId,
|
|
458
|
+
truncated,
|
|
459
|
+
},
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// Multi-URL: existing behavior (summary + responseId)
|
|
464
|
+
let output = "## Fetched URLs\n\n";
|
|
465
|
+
for (const { url, title, content, error } of fetchResults) {
|
|
466
|
+
if (error) {
|
|
467
|
+
output += `- ${url}: Error - ${error}\n`;
|
|
468
|
+
} else {
|
|
469
|
+
output += `- ${title || url} (${content.length} chars)\n`;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
output += `\n---\nUse get_search_content({ responseId: "${responseId}", urlIndex: 0 }) to retrieve full content.`;
|
|
473
|
+
|
|
474
|
+
return {
|
|
475
|
+
content: [{ type: "text", text: output }],
|
|
476
|
+
details: { urls: urlList, urlCount: urlList.length, successful, totalChars, responseId },
|
|
477
|
+
};
|
|
478
|
+
},
|
|
479
|
+
|
|
480
|
+
renderCall(args, theme) {
|
|
481
|
+
const { url, urls } = args as { url?: string; urls?: string[] };
|
|
482
|
+
const urlList = urls ?? (url ? [url] : []);
|
|
483
|
+
if (urlList.length === 0) {
|
|
484
|
+
return new Text(theme.fg("toolTitle", theme.bold("fetch ")) + theme.fg("error", "(no URL)"), 0, 0);
|
|
485
|
+
}
|
|
486
|
+
if (urlList.length === 1) {
|
|
487
|
+
const display = urlList[0].length > 50 ? urlList[0].slice(0, 47) + "..." : urlList[0];
|
|
488
|
+
return new Text(theme.fg("toolTitle", theme.bold("fetch ")) + theme.fg("accent", display), 0, 0);
|
|
489
|
+
}
|
|
490
|
+
const lines = [theme.fg("toolTitle", theme.bold("fetch ")) + theme.fg("accent", `${urlList.length} URLs`)];
|
|
491
|
+
for (const u of urlList.slice(0, 5)) {
|
|
492
|
+
const display = u.length > 60 ? u.slice(0, 57) + "..." : u;
|
|
493
|
+
lines.push(theme.fg("muted", " " + display));
|
|
494
|
+
}
|
|
495
|
+
if (urlList.length > 5) {
|
|
496
|
+
lines.push(theme.fg("muted", ` ... and ${urlList.length - 5} more`));
|
|
497
|
+
}
|
|
498
|
+
return new Text(lines.join("\n"), 0, 0);
|
|
499
|
+
},
|
|
500
|
+
|
|
501
|
+
renderResult(result, { expanded }, theme) {
|
|
502
|
+
const details = result.details as {
|
|
503
|
+
urlCount?: number;
|
|
504
|
+
successful?: number;
|
|
505
|
+
totalChars?: number;
|
|
506
|
+
error?: string;
|
|
507
|
+
title?: string;
|
|
508
|
+
truncated?: boolean;
|
|
509
|
+
responseId?: string;
|
|
510
|
+
};
|
|
511
|
+
|
|
512
|
+
if (details?.error) {
|
|
513
|
+
return new Text(theme.fg("error", `Error: ${details.error}`), 0, 0);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
if (details?.urlCount === 1) {
|
|
517
|
+
const title = details?.title || "Untitled";
|
|
518
|
+
let statusLine = theme.fg("success", title) + theme.fg("muted", ` (${details?.totalChars ?? 0} chars)`);
|
|
519
|
+
if (details?.truncated) {
|
|
520
|
+
statusLine += theme.fg("warning", " [truncated]");
|
|
521
|
+
}
|
|
522
|
+
if (!expanded) {
|
|
523
|
+
return new Text(statusLine, 0, 0);
|
|
524
|
+
}
|
|
525
|
+
const textContent = result.content.find((c) => c.type === "text")?.text || "";
|
|
526
|
+
const preview = textContent.length > 500 ? textContent.slice(0, 500) + "..." : textContent;
|
|
527
|
+
return new Text(statusLine + "\n" + theme.fg("dim", preview), 0, 0);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
const statusLine = theme.fg("success", `${details?.successful}/${details?.urlCount} URLs`) + theme.fg("muted", " (content stored)");
|
|
531
|
+
if (!expanded) {
|
|
532
|
+
return new Text(statusLine, 0, 0);
|
|
533
|
+
}
|
|
534
|
+
const textContent = result.content.find((c) => c.type === "text")?.text || "";
|
|
535
|
+
const preview = textContent.length > 500 ? textContent.slice(0, 500) + "..." : textContent;
|
|
536
|
+
return new Text(statusLine + "\n" + theme.fg("dim", preview), 0, 0);
|
|
537
|
+
},
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
pi.registerTool({
|
|
541
|
+
name: "get_search_content",
|
|
542
|
+
label: "Get Search Content",
|
|
543
|
+
description: "Retrieve full content from a previous web_search or fetch_content call.",
|
|
544
|
+
parameters: Type.Object({
|
|
545
|
+
responseId: Type.String({ description: "The responseId from web_search or fetch_content" }),
|
|
546
|
+
query: Type.Optional(Type.String({ description: "Get content for this query (web_search)" })),
|
|
547
|
+
queryIndex: Type.Optional(Type.Number({ description: "Get content for query at index" })),
|
|
548
|
+
url: Type.Optional(Type.String({ description: "Get content for this URL" })),
|
|
549
|
+
urlIndex: Type.Optional(Type.Number({ description: "Get content for URL at index" })),
|
|
550
|
+
}),
|
|
551
|
+
|
|
552
|
+
async execute(_toolCallId, params, _onUpdate, _ctx, _signal) {
|
|
553
|
+
const data = getResult(params.responseId);
|
|
554
|
+
if (!data) {
|
|
555
|
+
return {
|
|
556
|
+
content: [{ type: "text", text: `Error: No stored results for "${params.responseId}"` }],
|
|
557
|
+
details: { error: "Not found", responseId: params.responseId },
|
|
558
|
+
};
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
if (data.type === "search" && data.queries) {
|
|
562
|
+
let queryData: QueryResultData | undefined;
|
|
563
|
+
|
|
564
|
+
if (params.query !== undefined) {
|
|
565
|
+
queryData = data.queries.find((q) => q.query === params.query);
|
|
566
|
+
if (!queryData) {
|
|
567
|
+
const available = data.queries.map((q) => `"${q.query}"`).join(", ");
|
|
568
|
+
return {
|
|
569
|
+
content: [{ type: "text", text: `Query "${params.query}" not found. Available: ${available}` }],
|
|
570
|
+
details: { error: "Query not found" },
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
} else if (params.queryIndex !== undefined) {
|
|
574
|
+
queryData = data.queries[params.queryIndex];
|
|
575
|
+
if (!queryData) {
|
|
576
|
+
return {
|
|
577
|
+
content: [{ type: "text", text: `Index ${params.queryIndex} out of range (0-${data.queries.length - 1})` }],
|
|
578
|
+
details: { error: "Index out of range" },
|
|
579
|
+
};
|
|
580
|
+
}
|
|
581
|
+
} else {
|
|
582
|
+
const available = data.queries.map((q, i) => `${i}: "${q.query}"`).join(", ");
|
|
583
|
+
return {
|
|
584
|
+
content: [{ type: "text", text: `Specify query or queryIndex. Available: ${available}` }],
|
|
585
|
+
details: { error: "No query specified" },
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
if (queryData.error) {
|
|
590
|
+
return {
|
|
591
|
+
content: [{ type: "text", text: `Error for "${queryData.query}": ${queryData.error}` }],
|
|
592
|
+
details: { error: queryData.error, query: queryData.query },
|
|
593
|
+
};
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
return {
|
|
597
|
+
content: [{ type: "text", text: formatFullResults(queryData) }],
|
|
598
|
+
details: { query: queryData.query, resultCount: queryData.results.length },
|
|
599
|
+
};
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
if (data.type === "fetch" && data.urls) {
|
|
603
|
+
let urlData: ExtractedContent | undefined;
|
|
604
|
+
|
|
605
|
+
if (params.url !== undefined) {
|
|
606
|
+
urlData = data.urls.find((u) => u.url === params.url);
|
|
607
|
+
if (!urlData) {
|
|
608
|
+
const available = data.urls.map((u) => u.url).join("\n ");
|
|
609
|
+
return {
|
|
610
|
+
content: [{ type: "text", text: `URL not found. Available:\n ${available}` }],
|
|
611
|
+
details: { error: "URL not found" },
|
|
612
|
+
};
|
|
613
|
+
}
|
|
614
|
+
} else if (params.urlIndex !== undefined) {
|
|
615
|
+
urlData = data.urls[params.urlIndex];
|
|
616
|
+
if (!urlData) {
|
|
617
|
+
return {
|
|
618
|
+
content: [{ type: "text", text: `Index ${params.urlIndex} out of range (0-${data.urls.length - 1})` }],
|
|
619
|
+
details: { error: "Index out of range" },
|
|
620
|
+
};
|
|
621
|
+
}
|
|
622
|
+
} else {
|
|
623
|
+
const available = data.urls.map((u, i) => `${i}: ${u.url}`).join("\n ");
|
|
624
|
+
return {
|
|
625
|
+
content: [{ type: "text", text: `Specify url or urlIndex. Available:\n ${available}` }],
|
|
626
|
+
details: { error: "No URL specified" },
|
|
627
|
+
};
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
if (urlData.error) {
|
|
631
|
+
return {
|
|
632
|
+
content: [{ type: "text", text: `Error for ${urlData.url}: ${urlData.error}` }],
|
|
633
|
+
details: { error: urlData.error, url: urlData.url },
|
|
634
|
+
};
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
return {
|
|
638
|
+
content: [{ type: "text", text: `# ${urlData.title}\n\n${urlData.content}` }],
|
|
639
|
+
details: { url: urlData.url, title: urlData.title, contentLength: urlData.content.length },
|
|
640
|
+
};
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
return {
|
|
644
|
+
content: [{ type: "text", text: "Invalid stored data format" }],
|
|
645
|
+
details: { error: "Invalid data" },
|
|
646
|
+
};
|
|
647
|
+
},
|
|
648
|
+
|
|
649
|
+
renderCall(args, theme) {
|
|
650
|
+
const { responseId, query, queryIndex, url, urlIndex } = args as {
|
|
651
|
+
responseId: string;
|
|
652
|
+
query?: string;
|
|
653
|
+
queryIndex?: number;
|
|
654
|
+
url?: string;
|
|
655
|
+
urlIndex?: number;
|
|
656
|
+
};
|
|
657
|
+
let target = "";
|
|
658
|
+
if (query) target = `query="${query}"`;
|
|
659
|
+
else if (queryIndex !== undefined) target = `queryIndex=${queryIndex}`;
|
|
660
|
+
else if (url) target = url.length > 30 ? url.slice(0, 27) + "..." : url;
|
|
661
|
+
else if (urlIndex !== undefined) target = `urlIndex=${urlIndex}`;
|
|
662
|
+
return new Text(theme.fg("toolTitle", theme.bold("get_content ")) + theme.fg("accent", target || responseId.slice(0, 8)), 0, 0);
|
|
663
|
+
},
|
|
664
|
+
|
|
665
|
+
renderResult(result, { expanded }, theme) {
|
|
666
|
+
const details = result.details as {
|
|
667
|
+
error?: string;
|
|
668
|
+
query?: string;
|
|
669
|
+
url?: string;
|
|
670
|
+
title?: string;
|
|
671
|
+
resultCount?: number;
|
|
672
|
+
contentLength?: number;
|
|
673
|
+
};
|
|
674
|
+
|
|
675
|
+
if (details?.error) {
|
|
676
|
+
return new Text(theme.fg("error", `Error: ${details.error}`), 0, 0);
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
let statusLine: string;
|
|
680
|
+
if (details?.query) {
|
|
681
|
+
statusLine = theme.fg("success", `"${details.query}"`) + theme.fg("muted", ` (${details.resultCount} results)`);
|
|
682
|
+
} else {
|
|
683
|
+
statusLine = theme.fg("success", details?.title || "Content") + theme.fg("muted", ` (${details?.contentLength ?? 0} chars)`);
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
if (!expanded) {
|
|
687
|
+
return new Text(statusLine, 0, 0);
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
const textContent = result.content.find((c) => c.type === "text")?.text || "";
|
|
691
|
+
const preview = textContent.length > 500 ? textContent.slice(0, 500) + "..." : textContent;
|
|
692
|
+
return new Text(statusLine + "\n" + theme.fg("dim", preview), 0, 0);
|
|
693
|
+
},
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
pi.registerCommand("search", {
|
|
697
|
+
description: "Browse stored web search results",
|
|
698
|
+
handler: async (_args, ctx) => {
|
|
699
|
+
const results = getAllResults();
|
|
700
|
+
|
|
701
|
+
if (results.length === 0) {
|
|
702
|
+
ctx.ui.notify("No stored search results", "info");
|
|
703
|
+
return;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
const options = results.map((r) => {
|
|
707
|
+
const age = Math.floor((Date.now() - r.timestamp) / 60000);
|
|
708
|
+
const ageStr = age < 60 ? `${age}m ago` : `${Math.floor(age / 60)}h ago`;
|
|
709
|
+
if (r.type === "search" && r.queries) {
|
|
710
|
+
const query = r.queries[0]?.query || "unknown";
|
|
711
|
+
return `[${r.id.slice(0, 6)}] "${query}" (${r.queries.length} queries) - ${ageStr}`;
|
|
712
|
+
}
|
|
713
|
+
if (r.type === "fetch" && r.urls) {
|
|
714
|
+
return `[${r.id.slice(0, 6)}] ${r.urls.length} URLs fetched - ${ageStr}`;
|
|
715
|
+
}
|
|
716
|
+
return `[${r.id.slice(0, 6)}] ${r.type} - ${ageStr}`;
|
|
717
|
+
});
|
|
718
|
+
|
|
719
|
+
const choice = await ctx.ui.select("Stored Search Results", options);
|
|
720
|
+
if (!choice) return;
|
|
721
|
+
|
|
722
|
+
const match = choice.match(/^\[([a-z0-9]+)\]/);
|
|
723
|
+
if (!match) return;
|
|
724
|
+
|
|
725
|
+
const selected = results.find((r) => r.id.startsWith(match[1]));
|
|
726
|
+
if (!selected) return;
|
|
727
|
+
|
|
728
|
+
const actions = ["View details", "Delete"];
|
|
729
|
+
const action = await ctx.ui.select(`Result ${selected.id.slice(0, 6)}`, actions);
|
|
730
|
+
|
|
731
|
+
if (action === "Delete") {
|
|
732
|
+
deleteResult(selected.id);
|
|
733
|
+
ctx.ui.notify(`Deleted ${selected.id.slice(0, 6)}`, "info");
|
|
734
|
+
} else if (action === "View details") {
|
|
735
|
+
let info = `ID: ${selected.id}\nType: ${selected.type}\nAge: ${Math.floor((Date.now() - selected.timestamp) / 60000)}m\n\n`;
|
|
736
|
+
if (selected.type === "search" && selected.queries) {
|
|
737
|
+
info += "Queries:\n";
|
|
738
|
+
const queries = selected.queries.slice(0, 10);
|
|
739
|
+
for (const q of queries) {
|
|
740
|
+
info += `- "${q.query}" (${q.results.length} results)\n`;
|
|
741
|
+
}
|
|
742
|
+
if (selected.queries.length > 10) {
|
|
743
|
+
info += `... and ${selected.queries.length - 10} more\n`;
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
if (selected.type === "fetch" && selected.urls) {
|
|
747
|
+
info += "URLs:\n";
|
|
748
|
+
const urls = selected.urls.slice(0, 10);
|
|
749
|
+
for (const u of urls) {
|
|
750
|
+
const urlDisplay = u.url.length > 50 ? u.url.slice(0, 47) + "..." : u.url;
|
|
751
|
+
info += `- ${urlDisplay} (${u.error || `${u.content.length} chars`})\n`;
|
|
752
|
+
}
|
|
753
|
+
if (selected.urls.length > 10) {
|
|
754
|
+
info += `... and ${selected.urls.length - 10} more\n`;
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
ctx.ui.notify(info, "info");
|
|
758
|
+
}
|
|
759
|
+
},
|
|
760
|
+
});
|
|
761
|
+
}
|