pi-agentic-search 0.1.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/README.md +136 -0
- package/extensions/default-system-prompt.md +46 -0
- package/extensions/index.ts +1778 -0
- package/package.json +28 -0
|
@@ -0,0 +1,1778 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-agentic-search — Deep research agent extension for pi
|
|
3
|
+
*
|
|
4
|
+
* Spawns a cheap research agent ("Search") that autonomously searches,
|
|
5
|
+
* fetches, and synthesizes information on a given topic. Runs as a
|
|
6
|
+
* background process with a live progress widget.
|
|
7
|
+
*
|
|
8
|
+
* Features:
|
|
9
|
+
* - Real-time tool progress tracking
|
|
10
|
+
* - Throttled UI updates
|
|
11
|
+
* - Context window usage tracking
|
|
12
|
+
* - Long task handling via temp files
|
|
13
|
+
* - Auto-retry on transient errors (429, 5xx, network)
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { spawn } from "node:child_process";
|
|
17
|
+
import * as fs from "node:fs";
|
|
18
|
+
import * as os from "node:os";
|
|
19
|
+
import * as path from "node:path";
|
|
20
|
+
|
|
21
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
22
|
+
import {
|
|
23
|
+
getMarkdownTheme,
|
|
24
|
+
truncateHead,
|
|
25
|
+
DEFAULT_MAX_BYTES,
|
|
26
|
+
DEFAULT_MAX_LINES,
|
|
27
|
+
} from "@earendil-works/pi-coding-agent";
|
|
28
|
+
import { Box, Container, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
|
|
29
|
+
import { Type } from "typebox";
|
|
30
|
+
|
|
31
|
+
// ── Constants ──────────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
const UPDATE_THROTTLE_MS = 150;
|
|
34
|
+
const TASK_LIMIT = 8000; // Write to file if task exceeds this length
|
|
35
|
+
|
|
36
|
+
// Retry config for transient errors (429, 5xx, network)
|
|
37
|
+
const MAX_RETRIES = 3;
|
|
38
|
+
const INITIAL_RETRY_DELAY_MS = 5000; // 5s, then 10s, 20s
|
|
39
|
+
|
|
40
|
+
// Activity timeout — if no events for this long, consider the process stuck
|
|
41
|
+
const ACTIVITY_TIMEOUT_MS = 120_000; // 2 minutes
|
|
42
|
+
|
|
43
|
+
// ── Types ──────────────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
interface UsageStats {
|
|
46
|
+
input: number;
|
|
47
|
+
output: number;
|
|
48
|
+
cacheRead: number;
|
|
49
|
+
cacheWrite: number;
|
|
50
|
+
cost: number;
|
|
51
|
+
contextTokens: number;
|
|
52
|
+
contextWindow?: number;
|
|
53
|
+
turns: number;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface ToolEvent {
|
|
57
|
+
tool: string;
|
|
58
|
+
args: string;
|
|
59
|
+
toolCallId?: string;
|
|
60
|
+
status: "running" | "done";
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
interface SearchProgress {
|
|
64
|
+
status: "pending" | "running" | "completed" | "failed";
|
|
65
|
+
recentTools: ToolEvent[];
|
|
66
|
+
toolCount: number;
|
|
67
|
+
tokens: number;
|
|
68
|
+
contextWindow?: number;
|
|
69
|
+
durationMs: number;
|
|
70
|
+
lastMessage: string;
|
|
71
|
+
error?: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
interface SearchResult {
|
|
75
|
+
task: string;
|
|
76
|
+
exitCode: number;
|
|
77
|
+
messages: any[];
|
|
78
|
+
stderr: string;
|
|
79
|
+
usage: UsageStats;
|
|
80
|
+
model?: string;
|
|
81
|
+
stopReason?: string;
|
|
82
|
+
errorMessage?: string;
|
|
83
|
+
progress: SearchProgress;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
interface SearchDetails {
|
|
87
|
+
mode: "widget";
|
|
88
|
+
task: string;
|
|
89
|
+
goal: string;
|
|
90
|
+
result?: SearchResult;
|
|
91
|
+
status?: "started" | "running" | "completed" | "failed";
|
|
92
|
+
error?: string;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
interface SearchSettings {
|
|
96
|
+
model?: string;
|
|
97
|
+
keybinding?: number; // 1-9, default: 2
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ── Agent Registry ────────────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
type AgentStatus = "running" | "stopped" | "completed" | "failed";
|
|
103
|
+
|
|
104
|
+
interface RegisteredAgent {
|
|
105
|
+
id: string;
|
|
106
|
+
widgetId: string;
|
|
107
|
+
task: string;
|
|
108
|
+
goal: string;
|
|
109
|
+
status: AgentStatus;
|
|
110
|
+
startTime: number;
|
|
111
|
+
abort: () => void;
|
|
112
|
+
retry: () => void;
|
|
113
|
+
result?: SearchResult;
|
|
114
|
+
progress: SearchProgress;
|
|
115
|
+
model?: string;
|
|
116
|
+
canceledByUser?: boolean;
|
|
117
|
+
stoppedByUser?: boolean;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
class AgentRegistry {
|
|
121
|
+
private agents = new Map<string, RegisteredAgent>();
|
|
122
|
+
private listeners: Array<() => void> = [];
|
|
123
|
+
|
|
124
|
+
register(agent: RegisteredAgent) {
|
|
125
|
+
this.agents.set(agent.id, agent);
|
|
126
|
+
this.notify();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
unregister(id: string) {
|
|
130
|
+
this.agents.delete(id);
|
|
131
|
+
this.notify();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
get(id: string): RegisteredAgent | undefined {
|
|
135
|
+
return this.agents.get(id);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
getAll(): RegisteredAgent[] {
|
|
139
|
+
return Array.from(this.agents.values());
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
getRunning(): RegisteredAgent[] {
|
|
143
|
+
return this.getAll().filter((a) => a.status === "running");
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
stopAll() {
|
|
147
|
+
for (const agent of this.getRunning()) {
|
|
148
|
+
agent.abort();
|
|
149
|
+
agent.status = "stopped";
|
|
150
|
+
}
|
|
151
|
+
this.notify();
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
onChange(listener: () => void) {
|
|
155
|
+
this.listeners.push(listener);
|
|
156
|
+
return () => {
|
|
157
|
+
this.listeners = this.listeners.filter((l) => l !== listener);
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
private notify() {
|
|
162
|
+
for (const listener of this.listeners) {
|
|
163
|
+
listener();
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Global registry instance
|
|
169
|
+
const agentRegistry = new AgentRegistry();
|
|
170
|
+
|
|
171
|
+
// ── Agent Control Panel ──────────────────────────────────────────────
|
|
172
|
+
|
|
173
|
+
import { matchesKey, Key } from "@earendil-works/pi-tui";
|
|
174
|
+
|
|
175
|
+
class AgentControlPanel {
|
|
176
|
+
private selectedIndex = 0;
|
|
177
|
+
private agents: RegisteredAgent[] = [];
|
|
178
|
+
private tui: any;
|
|
179
|
+
private theme: any;
|
|
180
|
+
private done: (value: void) => void;
|
|
181
|
+
private disposeListener: () => void;
|
|
182
|
+
private pi: ExtensionAPI;
|
|
183
|
+
private ui: any;
|
|
184
|
+
|
|
185
|
+
constructor(tui: any, theme: any, done: (value: void) => void, pi: ExtensionAPI, ui: any) {
|
|
186
|
+
this.tui = tui;
|
|
187
|
+
this.theme = theme;
|
|
188
|
+
this.done = done;
|
|
189
|
+
this.pi = pi;
|
|
190
|
+
this.ui = ui;
|
|
191
|
+
this.agents = agentRegistry.getAll();
|
|
192
|
+
this.disposeListener = agentRegistry.onChange(() => {
|
|
193
|
+
this.agents = agentRegistry.getAll();
|
|
194
|
+
if (this.selectedIndex >= this.agents.length) {
|
|
195
|
+
this.selectedIndex = Math.max(0, this.agents.length - 1);
|
|
196
|
+
}
|
|
197
|
+
tui.requestRender();
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
handleInput(data: string): void {
|
|
202
|
+
if (matchesKey(data, Key.escape)) {
|
|
203
|
+
this.disposeListener();
|
|
204
|
+
this.done();
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (this.agents.length === 0) return;
|
|
209
|
+
|
|
210
|
+
if (matchesKey(data, Key.up) && this.selectedIndex > 0) {
|
|
211
|
+
this.selectedIndex--;
|
|
212
|
+
this.tui.requestRender();
|
|
213
|
+
} else if (matchesKey(data, Key.down) && this.selectedIndex < this.agents.length - 1) {
|
|
214
|
+
this.selectedIndex++;
|
|
215
|
+
this.tui.requestRender();
|
|
216
|
+
} else if (data === "s" || data === "S") {
|
|
217
|
+
// Stop — pause, no parent feedback, can retry later
|
|
218
|
+
const agent = this.agents[this.selectedIndex];
|
|
219
|
+
if (agent && agent.status === "running") {
|
|
220
|
+
agent.stoppedByUser = true;
|
|
221
|
+
agent.abort();
|
|
222
|
+
agent.status = "stopped";
|
|
223
|
+
// Don't unregister - keep in registry so it can be retried
|
|
224
|
+
this.tui.requestRender();
|
|
225
|
+
}
|
|
226
|
+
} else if (data === "c" || data === "C") {
|
|
227
|
+
// Cancel — kill, send failure to parent with "canceled by user" message
|
|
228
|
+
const agent = this.agents[this.selectedIndex];
|
|
229
|
+
if (agent) {
|
|
230
|
+
agent.canceledByUser = true;
|
|
231
|
+
// If agent is stopped, we need to send the cancel message directly
|
|
232
|
+
if (agent.status === "stopped") {
|
|
233
|
+
// Send cancel message to parent LLM
|
|
234
|
+
const summary = `Research failed for: ${agent.goal}\nStatus: Canceled by user`;
|
|
235
|
+
this.pi.sendMessage(
|
|
236
|
+
{
|
|
237
|
+
customType: "search-result",
|
|
238
|
+
content: summary,
|
|
239
|
+
display: true,
|
|
240
|
+
details: {
|
|
241
|
+
mode: "widget",
|
|
242
|
+
task: agent.task,
|
|
243
|
+
goal: agent.goal,
|
|
244
|
+
},
|
|
245
|
+
},
|
|
246
|
+
{
|
|
247
|
+
deliverAs: "followUp",
|
|
248
|
+
triggerTurn: true,
|
|
249
|
+
},
|
|
250
|
+
);
|
|
251
|
+
// Remove widget if it exists
|
|
252
|
+
if (agent.widgetId) {
|
|
253
|
+
this.ui.setWidget(agent.widgetId, undefined);
|
|
254
|
+
}
|
|
255
|
+
agentRegistry.unregister(agent.id);
|
|
256
|
+
} else {
|
|
257
|
+
agent.abort();
|
|
258
|
+
agent.status = "failed";
|
|
259
|
+
agentRegistry.unregister(agent.id);
|
|
260
|
+
}
|
|
261
|
+
this.tui.requestRender();
|
|
262
|
+
}
|
|
263
|
+
} else if (data === "r" || data === "R") {
|
|
264
|
+
// Retry — kill + respawn fresh, no parent feedback
|
|
265
|
+
const agent = this.agents[this.selectedIndex];
|
|
266
|
+
if (agent) {
|
|
267
|
+
agent.retry();
|
|
268
|
+
this.tui.requestRender();
|
|
269
|
+
}
|
|
270
|
+
} else if (data === "a" || data === "A") {
|
|
271
|
+
// Stop all
|
|
272
|
+
agentRegistry.stopAll();
|
|
273
|
+
this.tui.requestRender();
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
render(width: number): string[] {
|
|
278
|
+
const theme = this.theme;
|
|
279
|
+
const lines: string[] = [];
|
|
280
|
+
|
|
281
|
+
// Header
|
|
282
|
+
lines.push(theme.fg("accent", theme.bold("┌─ Research Agents ───────────────────────────────────────┐")));
|
|
283
|
+
lines.push("");
|
|
284
|
+
|
|
285
|
+
if (this.agents.length === 0) {
|
|
286
|
+
lines.push(theme.fg("muted", " No active agents"));
|
|
287
|
+
lines.push("");
|
|
288
|
+
} else {
|
|
289
|
+
for (let i = 0; i < this.agents.length; i++) {
|
|
290
|
+
const agent = this.agents[i];
|
|
291
|
+
const selected = i === this.selectedIndex;
|
|
292
|
+
const prefix = selected ? theme.fg("accent", "▸ ") : " ";
|
|
293
|
+
const num = `${i + 1}.`;
|
|
294
|
+
|
|
295
|
+
// Status icon
|
|
296
|
+
let icon: string;
|
|
297
|
+
let iconColor: string;
|
|
298
|
+
switch (agent.status) {
|
|
299
|
+
case "running":
|
|
300
|
+
icon = "⟳";
|
|
301
|
+
iconColor = "warning";
|
|
302
|
+
break;
|
|
303
|
+
case "stopped":
|
|
304
|
+
icon = "⏸";
|
|
305
|
+
iconColor = "muted";
|
|
306
|
+
break;
|
|
307
|
+
case "completed":
|
|
308
|
+
icon = "✓";
|
|
309
|
+
iconColor = "success";
|
|
310
|
+
break;
|
|
311
|
+
case "failed":
|
|
312
|
+
icon = "✗";
|
|
313
|
+
iconColor = "error";
|
|
314
|
+
break;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const duration = formatDuration(Date.now() - agent.startTime);
|
|
318
|
+
const modelStr = agent.model ? theme.fg("dim", ` (${agent.model})`) : "";
|
|
319
|
+
const goal = agent.goal.length > 45 ? agent.goal.slice(0, 45) + "..." : agent.goal;
|
|
320
|
+
|
|
321
|
+
// Agent line
|
|
322
|
+
lines.push(
|
|
323
|
+
`${prefix}${theme.fg("dim", num)} ${theme.fg(iconColor, icon)} ${theme.fg("text", goal)}${modelStr} ${theme.fg("dim", duration)}`
|
|
324
|
+
);
|
|
325
|
+
|
|
326
|
+
// Actions (only for selected)
|
|
327
|
+
if (selected) {
|
|
328
|
+
const actions: string[] = [];
|
|
329
|
+
if (agent.status === "running") {
|
|
330
|
+
actions.push(theme.fg("accent", "[S]top") + theme.fg("dim", " pause"));
|
|
331
|
+
actions.push(theme.fg("error", "[C]ancel") + theme.fg("dim", " kill"));
|
|
332
|
+
actions.push(theme.fg("warning", "[R]etry") + theme.fg("dim", " restart"));
|
|
333
|
+
} else if (agent.status === "stopped") {
|
|
334
|
+
actions.push(theme.fg("warning", "[R]etry") + theme.fg("dim", " restart"));
|
|
335
|
+
}
|
|
336
|
+
if (actions.length > 0) {
|
|
337
|
+
lines.push(` ${actions.join(" ")}`);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
lines.push("");
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Footer
|
|
346
|
+
lines.push(theme.fg("accent", "──────────────────────────────────────────────────────────"));
|
|
347
|
+
if (this.agents.length > 0) {
|
|
348
|
+
lines.push(theme.fg("dim", " ↑↓ navigate ") + theme.fg("accent", "[A]stop all") + theme.fg("dim", " ") + theme.fg("muted", "[Esc]close"));
|
|
349
|
+
} else {
|
|
350
|
+
lines.push(theme.fg("dim", " [Esc] close"));
|
|
351
|
+
}
|
|
352
|
+
lines.push(theme.fg("accent", "└────────────────────────────────────────────────────────┘"));
|
|
353
|
+
|
|
354
|
+
return lines;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
invalidate(): void {
|
|
358
|
+
// Clear any cached state if needed
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// ── Throttle ──────────────────────────────────────────────────────────
|
|
363
|
+
|
|
364
|
+
function throttle<T extends (...args: any[]) => void>(fn: T, ms: number): T {
|
|
365
|
+
let lastCall = 0;
|
|
366
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
367
|
+
return ((...args: any[]) => {
|
|
368
|
+
const now = Date.now();
|
|
369
|
+
const remaining = ms - (now - lastCall);
|
|
370
|
+
if (remaining <= 0) {
|
|
371
|
+
lastCall = Date.now();
|
|
372
|
+
if (timer) {
|
|
373
|
+
clearTimeout(timer);
|
|
374
|
+
timer = undefined;
|
|
375
|
+
}
|
|
376
|
+
fn(...args);
|
|
377
|
+
} else if (!timer) {
|
|
378
|
+
timer = setTimeout(() => {
|
|
379
|
+
lastCall = Date.now();
|
|
380
|
+
timer = undefined;
|
|
381
|
+
fn(...args);
|
|
382
|
+
}, remaining);
|
|
383
|
+
}
|
|
384
|
+
}) as T;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// ── Helper Functions ──────────────────────────────────────────────────
|
|
388
|
+
|
|
389
|
+
function formatTokens(count: number): string {
|
|
390
|
+
if (count < 1000) return count.toString();
|
|
391
|
+
if (count < 10000) return `${(count / 1000).toFixed(1)}k`;
|
|
392
|
+
if (count < 1000000) return `${Math.round(count / 1000)}k`;
|
|
393
|
+
return `${(count / 1000000).toFixed(1)}M`;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function formatDuration(ms: number): string {
|
|
397
|
+
if (ms < 1000) return `${ms}ms`;
|
|
398
|
+
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
|
|
399
|
+
return `${Math.floor(ms / 60000)}m${Math.floor((ms % 60000) / 1000)}s`;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function formatContextUsage(tokens: number, contextWindow: number | undefined): string {
|
|
403
|
+
if (!contextWindow) return `${formatTokens(tokens)} ctx`;
|
|
404
|
+
const pct = (tokens / contextWindow) * 100;
|
|
405
|
+
const maxStr =
|
|
406
|
+
contextWindow >= 1_000_000
|
|
407
|
+
? `${(contextWindow / 1_000_000).toFixed(1)}M`
|
|
408
|
+
: `${Math.round(contextWindow / 1000)}k`;
|
|
409
|
+
return `${pct.toFixed(1)}%/${maxStr}`;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function formatUsageStats(
|
|
413
|
+
usage: {
|
|
414
|
+
input: number;
|
|
415
|
+
output: number;
|
|
416
|
+
cacheRead: number;
|
|
417
|
+
cacheWrite: number;
|
|
418
|
+
cost: number;
|
|
419
|
+
contextTokens?: number;
|
|
420
|
+
contextWindow?: number;
|
|
421
|
+
turns?: number;
|
|
422
|
+
},
|
|
423
|
+
model?: string,
|
|
424
|
+
): string {
|
|
425
|
+
const parts: string[] = [];
|
|
426
|
+
if (usage.turns) parts.push(`${usage.turns} turn${usage.turns > 1 ? "s" : ""}`);
|
|
427
|
+
if (usage.input) parts.push(`↑${formatTokens(usage.input)}`);
|
|
428
|
+
if (usage.output) parts.push(`↓${formatTokens(usage.output)}`);
|
|
429
|
+
if (usage.cacheRead) parts.push(`R${formatTokens(usage.cacheRead)}`);
|
|
430
|
+
if (usage.cacheWrite) parts.push(`W${formatTokens(usage.cacheWrite)}`);
|
|
431
|
+
if (usage.cost) parts.push(`$${usage.cost.toFixed(4)}`);
|
|
432
|
+
if (usage.contextTokens && usage.contextTokens > 0) {
|
|
433
|
+
const ctxStr = formatContextUsage(usage.contextTokens, usage.contextWindow);
|
|
434
|
+
parts.push(`ctx:${ctxStr}`);
|
|
435
|
+
}
|
|
436
|
+
if (model) parts.push(model);
|
|
437
|
+
return parts.join(" ");
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function formatToolPreview(name: string, args: Record<string, any>): string {
|
|
441
|
+
switch (name) {
|
|
442
|
+
case "search":
|
|
443
|
+
return `search: ${((args.query as string) || "").slice(0, 80)}`;
|
|
444
|
+
case "fetch":
|
|
445
|
+
return `fetch: ${((args.url as string) || "").slice(0, 80)}`;
|
|
446
|
+
default: {
|
|
447
|
+
const s = JSON.stringify(args);
|
|
448
|
+
return `${name} ${s.slice(0, 60)}`;
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function isTransientError(result: SearchResult): boolean {
|
|
454
|
+
const msg = (
|
|
455
|
+
result.errorMessage ||
|
|
456
|
+
result.progress.error ||
|
|
457
|
+
result.stderr ||
|
|
458
|
+
""
|
|
459
|
+
).toLowerCase();
|
|
460
|
+
|
|
461
|
+
// 429 rate limit
|
|
462
|
+
if (
|
|
463
|
+
msg.includes("429") ||
|
|
464
|
+
msg.includes("too many requests") ||
|
|
465
|
+
msg.includes("rate limit")
|
|
466
|
+
)
|
|
467
|
+
return true;
|
|
468
|
+
|
|
469
|
+
// 5xx server errors
|
|
470
|
+
if (
|
|
471
|
+
/(?:^|\s)5[0-9]{2}(?:\s|$)/.test(msg) ||
|
|
472
|
+
msg.includes("500 internal server") ||
|
|
473
|
+
msg.includes("502 bad gateway") ||
|
|
474
|
+
msg.includes("503 service unavailable") ||
|
|
475
|
+
msg.includes("504 gateway timeout")
|
|
476
|
+
)
|
|
477
|
+
return true;
|
|
478
|
+
|
|
479
|
+
// Network errors
|
|
480
|
+
if (
|
|
481
|
+
msg.includes("econnreset") ||
|
|
482
|
+
msg.includes("etimedout") ||
|
|
483
|
+
msg.includes("econnrefused") ||
|
|
484
|
+
msg.includes("socket hang up") ||
|
|
485
|
+
msg.includes("network error") ||
|
|
486
|
+
msg.includes("fetch failed")
|
|
487
|
+
)
|
|
488
|
+
return true;
|
|
489
|
+
|
|
490
|
+
return false;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// ── Config ─────────────────────────────────────────────────────────────
|
|
494
|
+
|
|
495
|
+
function getConfigDir(): string {
|
|
496
|
+
const localConfig = path.join(process.cwd(), ".pi", "config", "pi-agentic-search");
|
|
497
|
+
if (fs.existsSync(localConfig)) {
|
|
498
|
+
return localConfig;
|
|
499
|
+
}
|
|
500
|
+
return path.join(os.homedir(), ".pi", "config", "pi-agentic-search");
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function getSourceDir(): string {
|
|
504
|
+
const dir =
|
|
505
|
+
typeof __dirname !== "undefined"
|
|
506
|
+
? __dirname
|
|
507
|
+
: path.dirname(new URL(import.meta.url).pathname);
|
|
508
|
+
return dir;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function loadSettings(): SearchSettings {
|
|
512
|
+
const configDir = getConfigDir();
|
|
513
|
+
const settingsPath = path.join(configDir, "settings.json");
|
|
514
|
+
|
|
515
|
+
try {
|
|
516
|
+
if (fs.existsSync(settingsPath)) {
|
|
517
|
+
const content = fs.readFileSync(settingsPath, "utf-8").trim();
|
|
518
|
+
if (content) {
|
|
519
|
+
return JSON.parse(content);
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
} catch {
|
|
523
|
+
// ignore parse errors
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
return {};
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
function readNonCommentContent(filePath: string): string | null {
|
|
530
|
+
try {
|
|
531
|
+
if (!fs.existsSync(filePath)) return null;
|
|
532
|
+
const content = fs.readFileSync(filePath, "utf-8").trim();
|
|
533
|
+
const nonCommentLines = content
|
|
534
|
+
.split("\n")
|
|
535
|
+
.filter((line) => !line.startsWith("#") && line.trim());
|
|
536
|
+
return nonCommentLines.length > 0 ? content : null;
|
|
537
|
+
} catch {
|
|
538
|
+
return null;
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
function loadSystemPrompt(): string {
|
|
543
|
+
const configDir = getConfigDir();
|
|
544
|
+
const sourceDir = getSourceDir();
|
|
545
|
+
|
|
546
|
+
const replaceContent = readNonCommentContent(path.join(configDir, "replace-system-prompt.md"));
|
|
547
|
+
if (replaceContent) {
|
|
548
|
+
return replaceContent;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
const defaultPath = path.join(sourceDir, "default-system-prompt.md");
|
|
552
|
+
let defaultPrompt: string;
|
|
553
|
+
try {
|
|
554
|
+
defaultPrompt = fs.readFileSync(defaultPath, "utf-8").trim();
|
|
555
|
+
} catch {
|
|
556
|
+
throw new Error(`Failed to load default system prompt from ${defaultPath}`);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
const prependContent = readNonCommentContent(path.join(configDir, "prepend-system-prompt.md"));
|
|
560
|
+
const appendContent = readNonCommentContent(path.join(configDir, "append-system-prompt.md"));
|
|
561
|
+
|
|
562
|
+
const parts: string[] = [];
|
|
563
|
+
if (prependContent) parts.push(prependContent);
|
|
564
|
+
parts.push(defaultPrompt);
|
|
565
|
+
if (appendContent) parts.push(appendContent);
|
|
566
|
+
|
|
567
|
+
return parts.join("\n\n");
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// ── Message Extraction ────────────────────────────────────────────────
|
|
571
|
+
|
|
572
|
+
function getFinalOutput(messages: any[]): string {
|
|
573
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
574
|
+
const msg = messages[i];
|
|
575
|
+
if (msg.role === "assistant") {
|
|
576
|
+
for (const part of msg.content) {
|
|
577
|
+
if (part.type === "text") return part.text;
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
return "";
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
function extractTextFromContent(content: any): string {
|
|
585
|
+
if (!content) return "";
|
|
586
|
+
if (typeof content === "string") return content;
|
|
587
|
+
if (Array.isArray(content)) {
|
|
588
|
+
return content
|
|
589
|
+
.filter((c: any) => c.type === "text")
|
|
590
|
+
.map((c: any) => c.text)
|
|
591
|
+
.join("\n");
|
|
592
|
+
}
|
|
593
|
+
return "";
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
function getDisplayItems(
|
|
597
|
+
messages: any[],
|
|
598
|
+
): Array<
|
|
599
|
+
{ type: "text"; text: string } | { type: "toolCall"; name: string; args: Record<string, any> }
|
|
600
|
+
> {
|
|
601
|
+
const items: Array<
|
|
602
|
+
{ type: "text"; text: string } | { type: "toolCall"; name: string; args: Record<string, any> }
|
|
603
|
+
> = [];
|
|
604
|
+
for (const msg of messages) {
|
|
605
|
+
if (msg.role === "assistant") {
|
|
606
|
+
for (const part of msg.content) {
|
|
607
|
+
if (part.type === "text") items.push({ type: "text", text: part.text });
|
|
608
|
+
else if (part.type === "toolCall")
|
|
609
|
+
items.push({ type: "toolCall", name: part.name, args: part.arguments });
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
return items;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// ── Pi Binary Resolution ─────────────────────────────────────────────
|
|
617
|
+
|
|
618
|
+
function resolvePiBinary(): { command: string; baseArgs: string[] } {
|
|
619
|
+
const entry = process.argv[1];
|
|
620
|
+
if (entry) {
|
|
621
|
+
try {
|
|
622
|
+
const realEntry = fs.realpathSync(entry);
|
|
623
|
+
if (/\.(?:mjs|cjs|js)$/i.test(realEntry)) {
|
|
624
|
+
return { command: process.execPath, baseArgs: [realEntry] };
|
|
625
|
+
}
|
|
626
|
+
} catch {
|
|
627
|
+
// ignore
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
return { command: "pi", baseArgs: [] };
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// ── Build Pi Args ────────────────────────────────────────────────────
|
|
634
|
+
|
|
635
|
+
async function buildPiArgs(
|
|
636
|
+
systemPrompt: string,
|
|
637
|
+
model: string | undefined,
|
|
638
|
+
task: string,
|
|
639
|
+
cwd: string,
|
|
640
|
+
): Promise<{ args: string[]; tempDir: string }> {
|
|
641
|
+
const piBin = resolvePiBinary();
|
|
642
|
+
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "pi-search-"));
|
|
643
|
+
|
|
644
|
+
// Write system prompt to temp file
|
|
645
|
+
const promptPath = path.join(tempDir, "search-prompt.md");
|
|
646
|
+
await fs.promises.writeFile(promptPath, systemPrompt, { encoding: "utf-8", mode: 0o600 });
|
|
647
|
+
|
|
648
|
+
const args = [
|
|
649
|
+
...piBin.baseArgs,
|
|
650
|
+
"--mode",
|
|
651
|
+
"json",
|
|
652
|
+
"-p",
|
|
653
|
+
"--no-session",
|
|
654
|
+
"--no-skills",
|
|
655
|
+
"--no-extensions",
|
|
656
|
+
"-e",
|
|
657
|
+
"npm:pi-search-tool",
|
|
658
|
+
"--tools",
|
|
659
|
+
"search,fetch",
|
|
660
|
+
"--append-system-prompt",
|
|
661
|
+
promptPath,
|
|
662
|
+
];
|
|
663
|
+
|
|
664
|
+
// Add model flag if configured
|
|
665
|
+
if (model) {
|
|
666
|
+
args.push("--model", model);
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
// Handle long tasks by writing to file
|
|
670
|
+
if (task.length > TASK_LIMIT) {
|
|
671
|
+
const taskPath = path.join(tempDir, "task.md");
|
|
672
|
+
await fs.promises.writeFile(taskPath, `Research Task: ${task}`, { encoding: "utf-8", mode: 0o600 });
|
|
673
|
+
args.push(`@${taskPath}`);
|
|
674
|
+
} else {
|
|
675
|
+
args.push(`Research Task: ${task}`);
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
return { args: [piBin.command, ...args], tempDir };
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// ── Run Search (Background) ───────────────────────────────────────────
|
|
682
|
+
|
|
683
|
+
async function runSearchInBackground(
|
|
684
|
+
task: string,
|
|
685
|
+
goal: string,
|
|
686
|
+
systemPrompt: string,
|
|
687
|
+
model: string | undefined,
|
|
688
|
+
signal: AbortSignal | undefined,
|
|
689
|
+
onUpdate: ((partial: any) => void) | undefined,
|
|
690
|
+
cwd: string,
|
|
691
|
+
startTime: number,
|
|
692
|
+
ui: any,
|
|
693
|
+
): Promise<{ result: SearchResult; widgetId: string }> {
|
|
694
|
+
|
|
695
|
+
const result: SearchResult = {
|
|
696
|
+
task,
|
|
697
|
+
exitCode: 0,
|
|
698
|
+
messages: [],
|
|
699
|
+
stderr: "",
|
|
700
|
+
usage: {
|
|
701
|
+
input: 0,
|
|
702
|
+
output: 0,
|
|
703
|
+
cacheRead: 0,
|
|
704
|
+
cacheWrite: 0,
|
|
705
|
+
cost: 0,
|
|
706
|
+
contextTokens: 0,
|
|
707
|
+
turns: 0,
|
|
708
|
+
},
|
|
709
|
+
progress: {
|
|
710
|
+
status: "running",
|
|
711
|
+
recentTools: [],
|
|
712
|
+
toolCount: 0,
|
|
713
|
+
tokens: 0,
|
|
714
|
+
durationMs: 0,
|
|
715
|
+
lastMessage: "",
|
|
716
|
+
},
|
|
717
|
+
};
|
|
718
|
+
|
|
719
|
+
const progress = result.progress;
|
|
720
|
+
const WIDGET_TOOL_LIMIT = 5;
|
|
721
|
+
|
|
722
|
+
// Activity tracking for staleness detection
|
|
723
|
+
let lastActivityTime = Date.now();
|
|
724
|
+
let activityCheckTimer: ReturnType<typeof setInterval> | undefined;
|
|
725
|
+
|
|
726
|
+
// Register widget with unique ID per spawn (supports Box rendering)
|
|
727
|
+
// The render() closure reads mutable progress/result, so it's always up-to-date
|
|
728
|
+
const widgetId = `search-progress-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
729
|
+
|
|
730
|
+
// Register widget ONCE with callback form (supports Box rendering)
|
|
731
|
+
let tuiRef: any = null;
|
|
732
|
+
ui.setWidget(widgetId, (tui: any, theme: any) => {
|
|
733
|
+
tuiRef = tui;
|
|
734
|
+
return {
|
|
735
|
+
render: () => {
|
|
736
|
+
const duration = formatDuration(Date.now() - startTime);
|
|
737
|
+
const isRunning = progress.status === "running";
|
|
738
|
+
const isFailed = progress.status === "failed";
|
|
739
|
+
const staleMs = Date.now() - lastActivityTime;
|
|
740
|
+
const isStale = isRunning && staleMs > ACTIVITY_TIMEOUT_MS;
|
|
741
|
+
const icon = isStale ? "⚠" : isRunning ? "⟳" : isFailed ? "✗" : "✓";
|
|
742
|
+
const iconColor = isStale ? "error" : isRunning ? "warning" : isFailed ? "error" : "success";
|
|
743
|
+
const modelStr = result.model ? theme.fg("dim", ` (${result.model})`) : "";
|
|
744
|
+
const stats = isStale
|
|
745
|
+
? `${progress.toolCount} searches · ${duration} · probably failed`
|
|
746
|
+
: `${progress.toolCount} searches · ${duration}`;
|
|
747
|
+
const box = new Box(1, 0, (t: string) => theme.bg("customMessageBg", t));
|
|
748
|
+
|
|
749
|
+
// Header: icon + label + stats
|
|
750
|
+
box.addChild(new Text(
|
|
751
|
+
`${theme.fg(iconColor, icon)} ${theme.fg("toolTitle", theme.bold("research"))}${modelStr} — ${theme.fg(isStale ? "error" : "dim", stats)}`,
|
|
752
|
+
0, 0,
|
|
753
|
+
));
|
|
754
|
+
|
|
755
|
+
// Tool log — last N tools
|
|
756
|
+
const tools = progress.recentTools;
|
|
757
|
+
const toShow = tools.slice(-WIDGET_TOOL_LIMIT);
|
|
758
|
+
const skipped = tools.length - toShow.length;
|
|
759
|
+
if (skipped > 0) {
|
|
760
|
+
box.addChild(new Text(theme.fg("muted", ` … ${skipped} earlier`), 0, 0));
|
|
761
|
+
}
|
|
762
|
+
for (const t of toShow) {
|
|
763
|
+
if (t.status === "running") {
|
|
764
|
+
box.addChild(new Text(
|
|
765
|
+
`${theme.fg("warning", "▸")} ${theme.fg("muted", t.tool)}: ${theme.fg("dim", t.args)}`,
|
|
766
|
+
0, 0,
|
|
767
|
+
));
|
|
768
|
+
} else {
|
|
769
|
+
box.addChild(new Text(` ${theme.fg("muted", t.tool)}: ${theme.fg("dim", t.args)}`, 0, 0));
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
// Latest "thinking" message
|
|
774
|
+
if (progress.lastMessage) {
|
|
775
|
+
const preview =
|
|
776
|
+
progress.lastMessage.length > 100
|
|
777
|
+
? progress.lastMessage.slice(0, 100) + "…"
|
|
778
|
+
: progress.lastMessage;
|
|
779
|
+
box.addChild(new Text(theme.fg("text", preview), 0, 0));
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
// Usage line
|
|
783
|
+
const usageParts: string[] = [];
|
|
784
|
+
if (result.usage.turns)
|
|
785
|
+
usageParts.push(theme.fg("dim", `${result.usage.turns} turn${result.usage.turns !== 1 ? "s" : ""}`));
|
|
786
|
+
if (result.usage.input) usageParts.push(theme.fg("dim", `↑${formatTokens(result.usage.input)}`));
|
|
787
|
+
if (result.usage.output) usageParts.push(theme.fg("dim", `↓${formatTokens(result.usage.output)}`));
|
|
788
|
+
if (result.usage.cacheRead) usageParts.push(theme.fg("dim", `R${formatTokens(result.usage.cacheRead)}`));
|
|
789
|
+
if (result.usage.cacheWrite) usageParts.push(theme.fg("dim", `W${formatTokens(result.usage.cacheWrite)}`));
|
|
790
|
+
if (result.usage.cost) usageParts.push(theme.fg("dim", `$${result.usage.cost.toFixed(4)}`));
|
|
791
|
+
if (progress.tokens > 0) {
|
|
792
|
+
const ctxStr = formatContextUsage(progress.tokens, progress.contextWindow);
|
|
793
|
+
const pct = progress.contextWindow ? (progress.tokens / progress.contextWindow) * 100 : 0;
|
|
794
|
+
const ctxColor = pct > 90 ? "error" : pct > 70 ? "warning" : "dim";
|
|
795
|
+
usageParts.push(theme.fg(ctxColor, ctxStr));
|
|
796
|
+
}
|
|
797
|
+
if (usageParts.length) {
|
|
798
|
+
box.addChild(new Text(usageParts.join(" "), 0, 0));
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
// Error
|
|
802
|
+
if (progress.error) {
|
|
803
|
+
box.addChild(new Text(theme.fg("error", `Error: ${progress.error}`), 0, 0));
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
const width = process.stdout.columns || 80;
|
|
807
|
+
const lines = box.render(width);
|
|
808
|
+
// Add separator line between widgets
|
|
809
|
+
lines.push("");
|
|
810
|
+
return lines;
|
|
811
|
+
},
|
|
812
|
+
invalidate: () => {},
|
|
813
|
+
};
|
|
814
|
+
});
|
|
815
|
+
ui.setStatus("search", ui.theme.fg("warning", "⟳ research"));
|
|
816
|
+
|
|
817
|
+
const fireUpdate = throttle(() => {
|
|
818
|
+
lastActivityTime = Date.now();
|
|
819
|
+
progress.durationMs = Date.now() - startTime;
|
|
820
|
+
if (tuiRef?.requestRender) tuiRef.requestRender();
|
|
821
|
+
if (onUpdate) {
|
|
822
|
+
onUpdate({
|
|
823
|
+
content: [{ type: "text", text: progress.lastMessage || "(searching...)" }],
|
|
824
|
+
details: {
|
|
825
|
+
mode: "widget",
|
|
826
|
+
task,
|
|
827
|
+
goal,
|
|
828
|
+
result,
|
|
829
|
+
},
|
|
830
|
+
});
|
|
831
|
+
}
|
|
832
|
+
}, UPDATE_THROTTLE_MS);
|
|
833
|
+
|
|
834
|
+
const { args, tempDir } = await buildPiArgs(systemPrompt, model, task, cwd);
|
|
835
|
+
const command = args[0];
|
|
836
|
+
const spawnArgs = args.slice(1);
|
|
837
|
+
|
|
838
|
+
try {
|
|
839
|
+
const exitCode = await new Promise<number>((resolve) => {
|
|
840
|
+
const proc = spawn(command, spawnArgs, {
|
|
841
|
+
cwd,
|
|
842
|
+
shell: false,
|
|
843
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
844
|
+
});
|
|
845
|
+
|
|
846
|
+
// Activity timeout — kill process if stuck
|
|
847
|
+
activityCheckTimer = setInterval(() => {
|
|
848
|
+
const elapsed = Date.now() - lastActivityTime;
|
|
849
|
+
if (elapsed > ACTIVITY_TIMEOUT_MS) {
|
|
850
|
+
progress.status = "failed";
|
|
851
|
+
progress.error = `No activity for ${Math.round(elapsed / 1000)}s — process appears stuck`;
|
|
852
|
+
proc.kill("SIGTERM");
|
|
853
|
+
setTimeout(() => !proc.killed && proc.kill("SIGKILL"), 3000);
|
|
854
|
+
}
|
|
855
|
+
}, 30_000); // Check every 30s
|
|
856
|
+
|
|
857
|
+
let buf = "";
|
|
858
|
+
let stderrBuf = "";
|
|
859
|
+
|
|
860
|
+
const processLine = (line: string) => {
|
|
861
|
+
if (!line.trim()) return;
|
|
862
|
+
try {
|
|
863
|
+
const evt = JSON.parse(line) as any;
|
|
864
|
+
progress.durationMs = Date.now() - startTime;
|
|
865
|
+
|
|
866
|
+
// Track tool execution start
|
|
867
|
+
if (evt.type === "tool_execution_start") {
|
|
868
|
+
progress.toolCount++;
|
|
869
|
+
progress.recentTools.push({
|
|
870
|
+
tool: evt.toolName,
|
|
871
|
+
args: formatToolPreview(evt.toolName, (evt.args || {}) as Record<string, any>),
|
|
872
|
+
toolCallId: evt.toolCallId,
|
|
873
|
+
status: "running",
|
|
874
|
+
});
|
|
875
|
+
if (progress.recentTools.length > 20) {
|
|
876
|
+
progress.recentTools = progress.recentTools.slice(-20);
|
|
877
|
+
}
|
|
878
|
+
fireUpdate();
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
// Track tool execution updates (for progress)
|
|
882
|
+
if (evt.type === "tool_execution_update") {
|
|
883
|
+
const hit = evt.toolCallId
|
|
884
|
+
? progress.recentTools.find((t) => t.toolCallId === evt.toolCallId)
|
|
885
|
+
: undefined;
|
|
886
|
+
if (hit) {
|
|
887
|
+
if (evt.args) {
|
|
888
|
+
hit.args = formatToolPreview(evt.toolName, evt.args);
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
fireUpdate();
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
// Track tool execution end
|
|
895
|
+
if (evt.type === "tool_execution_end") {
|
|
896
|
+
const hit = evt.toolCallId
|
|
897
|
+
? progress.recentTools.find((t) => t.toolCallId === evt.toolCallId)
|
|
898
|
+
: undefined;
|
|
899
|
+
if (hit) {
|
|
900
|
+
hit.status = "done";
|
|
901
|
+
}
|
|
902
|
+
fireUpdate();
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
// Track tool results
|
|
906
|
+
if (evt.type === "tool_result_end") {
|
|
907
|
+
fireUpdate();
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
// Track messages
|
|
911
|
+
if (evt.type === "message_end" && evt.message) {
|
|
912
|
+
const msg = evt.message;
|
|
913
|
+
result.messages.push(msg);
|
|
914
|
+
|
|
915
|
+
if (msg.role === "assistant") {
|
|
916
|
+
result.usage.turns++;
|
|
917
|
+
const u = msg.usage;
|
|
918
|
+
if (u) {
|
|
919
|
+
result.usage.input += u.input || 0;
|
|
920
|
+
result.usage.output += u.output || 0;
|
|
921
|
+
result.usage.cacheRead += u.cacheRead || 0;
|
|
922
|
+
result.usage.cacheWrite += u.cacheWrite || 0;
|
|
923
|
+
result.usage.cost += u.cost?.total || 0;
|
|
924
|
+
progress.tokens =
|
|
925
|
+
(u as any).totalTokens ||
|
|
926
|
+
(u.input || 0) + (u.output || 0) + (u.cacheRead || 0) + (u.cacheWrite || 0);
|
|
927
|
+
result.usage.contextTokens = progress.tokens;
|
|
928
|
+
}
|
|
929
|
+
if (!result.model && msg.model) result.model = msg.model;
|
|
930
|
+
if (msg.stopReason) result.stopReason = msg.stopReason;
|
|
931
|
+
if (msg.errorMessage) {
|
|
932
|
+
result.errorMessage = msg.errorMessage;
|
|
933
|
+
progress.error = msg.errorMessage;
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
// Extract latest prose for progress display
|
|
937
|
+
const text = extractTextFromContent(msg.content);
|
|
938
|
+
if (text) {
|
|
939
|
+
const proseLines: string[] = [];
|
|
940
|
+
let inCodeBlock = false;
|
|
941
|
+
for (const line of text.split("\n")) {
|
|
942
|
+
if (line.trimStart().startsWith("```")) {
|
|
943
|
+
inCodeBlock = !inCodeBlock;
|
|
944
|
+
continue;
|
|
945
|
+
}
|
|
946
|
+
if (!inCodeBlock && line.trim()) {
|
|
947
|
+
proseLines.push(line.trim());
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
if (proseLines.length > 0) {
|
|
951
|
+
progress.lastMessage = proseLines.slice(0, 3).join(" ");
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
fireUpdate();
|
|
957
|
+
}
|
|
958
|
+
} catch {
|
|
959
|
+
// Non-JSON lines are expected
|
|
960
|
+
}
|
|
961
|
+
};
|
|
962
|
+
|
|
963
|
+
proc.stdout.on("data", (d: Buffer) => {
|
|
964
|
+
buf += d.toString();
|
|
965
|
+
const lines = buf.split("\n");
|
|
966
|
+
buf = lines.pop() || "";
|
|
967
|
+
lines.forEach(processLine);
|
|
968
|
+
});
|
|
969
|
+
|
|
970
|
+
proc.stderr.on("data", (d: Buffer) => {
|
|
971
|
+
stderrBuf += d.toString();
|
|
972
|
+
});
|
|
973
|
+
|
|
974
|
+
proc.on("close", (code) => {
|
|
975
|
+
if (activityCheckTimer) {
|
|
976
|
+
clearInterval(activityCheckTimer);
|
|
977
|
+
activityCheckTimer = undefined;
|
|
978
|
+
}
|
|
979
|
+
if (buf.trim()) processLine(buf);
|
|
980
|
+
if (code !== 0 && stderrBuf.trim() && !progress.error) {
|
|
981
|
+
// Filter out pi's internal dashboard errors (expected when process is killed)
|
|
982
|
+
const filteredStderr = stderrBuf.trim()
|
|
983
|
+
.split('\n')
|
|
984
|
+
.filter((line: string) => !line.includes('[dashboard]'))
|
|
985
|
+
.join('\n')
|
|
986
|
+
.trim();
|
|
987
|
+
if (filteredStderr) {
|
|
988
|
+
progress.error = filteredStderr;
|
|
989
|
+
result.stderr = filteredStderr;
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
resolve(code ?? 1);
|
|
993
|
+
});
|
|
994
|
+
|
|
995
|
+
proc.on("error", () => {
|
|
996
|
+
if (activityCheckTimer) {
|
|
997
|
+
clearInterval(activityCheckTimer);
|
|
998
|
+
activityCheckTimer = undefined;
|
|
999
|
+
}
|
|
1000
|
+
resolve(1);
|
|
1001
|
+
});
|
|
1002
|
+
|
|
1003
|
+
if (signal) {
|
|
1004
|
+
const kill = () => {
|
|
1005
|
+
proc.kill("SIGTERM");
|
|
1006
|
+
setTimeout(() => !proc.killed && proc.kill("SIGKILL"), 3000);
|
|
1007
|
+
};
|
|
1008
|
+
if (signal.aborted) kill();
|
|
1009
|
+
else signal.addEventListener("abort", kill, { once: true });
|
|
1010
|
+
}
|
|
1011
|
+
});
|
|
1012
|
+
|
|
1013
|
+
result.exitCode = exitCode;
|
|
1014
|
+
progress.status = exitCode === 0 && !progress.error ? "completed" : "failed";
|
|
1015
|
+
progress.durationMs = Date.now() - startTime;
|
|
1016
|
+
|
|
1017
|
+
// Truncate output if very large
|
|
1018
|
+
if (getFinalOutput(result.messages).length > DEFAULT_MAX_BYTES) {
|
|
1019
|
+
truncateHead(getFinalOutput(result.messages), {
|
|
1020
|
+
maxLines: DEFAULT_MAX_LINES,
|
|
1021
|
+
maxBytes: DEFAULT_MAX_BYTES,
|
|
1022
|
+
});
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
return { result, widgetId };
|
|
1026
|
+
} finally {
|
|
1027
|
+
if (activityCheckTimer) {
|
|
1028
|
+
clearInterval(activityCheckTimer);
|
|
1029
|
+
activityCheckTimer = undefined;
|
|
1030
|
+
}
|
|
1031
|
+
try {
|
|
1032
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
1033
|
+
} catch {
|
|
1034
|
+
// ignore cleanup errors
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
// ── Build Search Summary ──────────────────────────────────────────────
|
|
1040
|
+
|
|
1041
|
+
function buildSearchSummary(result: SearchResult, goal: string, canceledByUser = false): string {
|
|
1042
|
+
const finalOutput = getFinalOutput(result.messages);
|
|
1043
|
+
const isError = result.exitCode !== 0;
|
|
1044
|
+
|
|
1045
|
+
const parts: string[] = [];
|
|
1046
|
+
|
|
1047
|
+
if (isError) {
|
|
1048
|
+
parts.push(`Research failed for: ${goal}`);
|
|
1049
|
+
if (canceledByUser) {
|
|
1050
|
+
parts.push(`Status: Canceled by user`);
|
|
1051
|
+
}
|
|
1052
|
+
const error = result.errorMessage || result.progress.error || result.stderr;
|
|
1053
|
+
if (error) parts.push(`Error: ${error}`);
|
|
1054
|
+
parts.push("");
|
|
1055
|
+
if (finalOutput) {
|
|
1056
|
+
parts.push("Partial results:");
|
|
1057
|
+
parts.push("");
|
|
1058
|
+
parts.push(finalOutput);
|
|
1059
|
+
}
|
|
1060
|
+
} else {
|
|
1061
|
+
parts.push(`Research completed for: ${goal}`);
|
|
1062
|
+
if (result.usage.turns > 0) {
|
|
1063
|
+
const usageStr = formatUsageStats(result.usage, result.model);
|
|
1064
|
+
if (usageStr) parts.push(`Stats: ${usageStr}`);
|
|
1065
|
+
}
|
|
1066
|
+
if (result.progress.durationMs > 0) {
|
|
1067
|
+
parts.push(`Duration: ${formatDuration(result.progress.durationMs)}`);
|
|
1068
|
+
}
|
|
1069
|
+
parts.push("");
|
|
1070
|
+
if (finalOutput) {
|
|
1071
|
+
parts.push("## Research Summary");
|
|
1072
|
+
parts.push("");
|
|
1073
|
+
parts.push(finalOutput);
|
|
1074
|
+
} else {
|
|
1075
|
+
parts.push("(no findings)");
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
return parts.join("\n");
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
// ── Main Extension ───────────────────────────────────────────────────
|
|
1083
|
+
|
|
1084
|
+
export default function (pi: ExtensionAPI) {
|
|
1085
|
+
// Ensure config files exist on session start
|
|
1086
|
+
pi.on("session_start", async (_event: any, ctx: any) => {
|
|
1087
|
+
const configDir = getConfigDir();
|
|
1088
|
+
try {
|
|
1089
|
+
await fs.promises.mkdir(configDir, { recursive: true });
|
|
1090
|
+
|
|
1091
|
+
const prependPath = path.join(configDir, "prepend-system-prompt.md");
|
|
1092
|
+
if (!fs.existsSync(prependPath)) {
|
|
1093
|
+
await fs.promises.writeFile(
|
|
1094
|
+
prependPath,
|
|
1095
|
+
"# Prepend instructions before the default research prompt\n" +
|
|
1096
|
+
"# Content here is added before the default prompt\n" +
|
|
1097
|
+
"# This file survives updates\n\n",
|
|
1098
|
+
"utf-8",
|
|
1099
|
+
);
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
const appendPath = path.join(configDir, "append-system-prompt.md");
|
|
1103
|
+
if (!fs.existsSync(appendPath)) {
|
|
1104
|
+
await fs.promises.writeFile(
|
|
1105
|
+
appendPath,
|
|
1106
|
+
"# Append instructions after the default research prompt\n" +
|
|
1107
|
+
"# Content here is added after the default prompt\n" +
|
|
1108
|
+
"# This file survives updates\n\n",
|
|
1109
|
+
"utf-8",
|
|
1110
|
+
);
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
const replacePath = path.join(configDir, "replace-system-prompt.md");
|
|
1114
|
+
if (!fs.existsSync(replacePath)) {
|
|
1115
|
+
await fs.promises.writeFile(
|
|
1116
|
+
replacePath,
|
|
1117
|
+
"# Replace the default research prompt entirely\n" +
|
|
1118
|
+
"# If this file has non-comment content, it will be used instead of the default\n" +
|
|
1119
|
+
"# This file survives updates\n\n",
|
|
1120
|
+
"utf-8",
|
|
1121
|
+
);
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
const settingsPath = path.join(configDir, "settings.json");
|
|
1125
|
+
if (!fs.existsSync(settingsPath)) {
|
|
1126
|
+
await fs.promises.writeFile(
|
|
1127
|
+
settingsPath,
|
|
1128
|
+
JSON.stringify({ model: "", keybinding: 2 }, null, 2) + "\n",
|
|
1129
|
+
"utf-8",
|
|
1130
|
+
);
|
|
1131
|
+
}
|
|
1132
|
+
} catch {
|
|
1133
|
+
// ignore errors
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
// Register keybinding for agent control panel
|
|
1137
|
+
const settings = loadSettings();
|
|
1138
|
+
const keyNum = settings.keybinding ?? 2;
|
|
1139
|
+
pi.registerShortcut(`ctrl+shift+${keyNum}` as any, {
|
|
1140
|
+
description: "Open research agent control panel",
|
|
1141
|
+
handler: async () => {
|
|
1142
|
+
ctx.ui.custom((tui: any, theme: any, _kb: any, done: any) => {
|
|
1143
|
+
return new AgentControlPanel(tui, theme, done, pi, ctx.ui);
|
|
1144
|
+
}, { overlay: true });
|
|
1145
|
+
},
|
|
1146
|
+
});
|
|
1147
|
+
});
|
|
1148
|
+
|
|
1149
|
+
// Register message renderer for research results
|
|
1150
|
+
pi.registerMessageRenderer("search-result", (message: any, options: any, theme: any) => {
|
|
1151
|
+
const { expanded } = options;
|
|
1152
|
+
const details = message.details as SearchDetails | undefined;
|
|
1153
|
+
|
|
1154
|
+
const mdTheme = getMarkdownTheme();
|
|
1155
|
+
const isError = details?.result?.exitCode !== 0;
|
|
1156
|
+
const icon = isError ? theme.fg("error", "✗") : theme.fg("success", "✓");
|
|
1157
|
+
let text = `${icon} ${theme.fg("toolTitle", theme.bold("research"))} ${theme.fg("accent", isError ? "failed" : "completed")}`;
|
|
1158
|
+
|
|
1159
|
+
if (details?.result?.usage) {
|
|
1160
|
+
const usageStr = formatUsageStats(details.result.usage, details.result.model);
|
|
1161
|
+
if (usageStr) text += `\n${theme.fg("dim", usageStr)}`;
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
if (expanded && message.content) {
|
|
1165
|
+
const box = new Box(1, 1, (t: string) => theme.bg("customMessageBg", t));
|
|
1166
|
+
box.addChild(new Text(text, 0, 0));
|
|
1167
|
+
box.addChild(new Spacer(1));
|
|
1168
|
+
box.addChild(new Markdown(message.content, 0, 0, mdTheme));
|
|
1169
|
+
return box;
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
// Collapsed: show first few lines
|
|
1173
|
+
const preview = message.content?.split("\n").slice(0, 5).join("\n") || "(no content)";
|
|
1174
|
+
text += `\n${theme.fg("text", preview)}`;
|
|
1175
|
+
|
|
1176
|
+
const box = new Box(1, 1, (t: string) => theme.bg("customMessageBg", t));
|
|
1177
|
+
box.addChild(new Text(text, 0, 0));
|
|
1178
|
+
return box;
|
|
1179
|
+
});
|
|
1180
|
+
|
|
1181
|
+
// Register the agentic search tool
|
|
1182
|
+
pi.registerTool({
|
|
1183
|
+
name: "agentic_search",
|
|
1184
|
+
label: "Agentic Search",
|
|
1185
|
+
description:
|
|
1186
|
+
"Spawn a dedicated research agent that autonomously searches, fetches, and synthesizes information on a given topic. Unlike the primitive search and fetch tools — which return raw results — this agent reasons across multiple sources, follows leads, resolves conflicts, and returns a structured Research Summary. Use when the topic requires depth, cross-referencing, or synthesis beyond a single query.",
|
|
1187
|
+
promptSnippet: "Spawn an autonomous research agent for deep, multi-source investigation",
|
|
1188
|
+
promptGuidelines: [
|
|
1189
|
+
"Use agentic_search for complex or broad topics requiring synthesis across multiple sources — not for simple factual lookups where search + fetch suffices.",
|
|
1190
|
+
"Use agentic_search when the answer requires resolving conflicting sources, following chains of references, or producing a structured summary rather than raw results.",
|
|
1191
|
+
"The research agent is read-only — it can only search the web and fetch pages. It cannot modify anything or execute code.",
|
|
1192
|
+
"Good topics for agentic_search: comparing technologies, understanding trends, researching best practices, investigating controversies, gathering evidence for decisions.",
|
|
1193
|
+
"Simple factual questions (e.g., 'What is the capital of France?') should use the search tool directly, not agentic_search.",
|
|
1194
|
+
],
|
|
1195
|
+
parameters: Type.Object({
|
|
1196
|
+
goal: Type.String({
|
|
1197
|
+
description:
|
|
1198
|
+
"The research question or topic to investigate. Be specific and outcome-oriented: what should the agent find out, compare, or explain? E.g. 'What are the current best practices for rate limiting in distributed APIs?' rather than 'rate limiting'.",
|
|
1199
|
+
}),
|
|
1200
|
+
context: Type.String({
|
|
1201
|
+
description:
|
|
1202
|
+
"Background that helps the agent focus its research: why this is being investigated, what is already known, what angle matters most, or what sources to prioritize or avoid. E.g. 'We are building a Node.js API gateway. We already use Redis. Interested in token bucket vs sliding window approaches.'",
|
|
1203
|
+
}),
|
|
1204
|
+
}),
|
|
1205
|
+
async execute(toolCallId: any, params: any, signal: any, onUpdate: any, ctx: any) {
|
|
1206
|
+
const settings = loadSettings();
|
|
1207
|
+
const systemPrompt = loadSystemPrompt();
|
|
1208
|
+
const startTime = Date.now();
|
|
1209
|
+
const task = `Research the following:\n\nGoal: ${params.goal}\n\nContext: ${params.context}`;
|
|
1210
|
+
|
|
1211
|
+
// Create an abort controller for this agent
|
|
1212
|
+
let agentAbortController = new AbortController();
|
|
1213
|
+
let agentId = `search-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
1214
|
+
let activeWidgetId: string | undefined;
|
|
1215
|
+
|
|
1216
|
+
// Fire and forget — run search in background with auto-retry on transient errors
|
|
1217
|
+
const backgroundTask = async () => {
|
|
1218
|
+
// Clear widget and status when done
|
|
1219
|
+
const clearWidget = () => {
|
|
1220
|
+
if (activeWidgetId) {
|
|
1221
|
+
ctx.ui.setWidget(activeWidgetId, undefined);
|
|
1222
|
+
}
|
|
1223
|
+
ctx.ui.setStatus("search", undefined);
|
|
1224
|
+
agentRegistry.unregister(agentId);
|
|
1225
|
+
};
|
|
1226
|
+
|
|
1227
|
+
let attempt = 0;
|
|
1228
|
+
let localCanceledByUser = false;
|
|
1229
|
+
// Initial empty result for the widget (before first run)
|
|
1230
|
+
let result: SearchResult = {
|
|
1231
|
+
task,
|
|
1232
|
+
exitCode: 0,
|
|
1233
|
+
messages: [],
|
|
1234
|
+
stderr: "",
|
|
1235
|
+
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
|
|
1236
|
+
progress: { status: "running", recentTools: [], toolCount: 0, tokens: 0, durationMs: 0, lastMessage: "" },
|
|
1237
|
+
};
|
|
1238
|
+
|
|
1239
|
+
// Retry function for the registry
|
|
1240
|
+
const retryAgent = () => {
|
|
1241
|
+
// Remove old widget before creating new one
|
|
1242
|
+
if (activeWidgetId) {
|
|
1243
|
+
ctx.ui.setWidget(activeWidgetId, undefined);
|
|
1244
|
+
}
|
|
1245
|
+
agentAbortController.abort();
|
|
1246
|
+
agentAbortController = new AbortController();
|
|
1247
|
+
attempt = 0;
|
|
1248
|
+
localCanceledByUser = false;
|
|
1249
|
+
result = {
|
|
1250
|
+
task,
|
|
1251
|
+
exitCode: 0,
|
|
1252
|
+
messages: [],
|
|
1253
|
+
stderr: "",
|
|
1254
|
+
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
|
|
1255
|
+
progress: { status: "running", recentTools: [], toolCount: 0, tokens: 0, durationMs: 0, lastMessage: "" },
|
|
1256
|
+
};
|
|
1257
|
+
// Update existing agent in registry (keep same ID and widget)
|
|
1258
|
+
const existingAgent = agentRegistry.get(agentId);
|
|
1259
|
+
if (existingAgent) {
|
|
1260
|
+
existingAgent.status = "running";
|
|
1261
|
+
existingAgent.stoppedByUser = false;
|
|
1262
|
+
existingAgent.canceledByUser = false;
|
|
1263
|
+
existingAgent.startTime = Date.now();
|
|
1264
|
+
existingAgent.progress = result.progress;
|
|
1265
|
+
existingAgent.abort = () => {
|
|
1266
|
+
localCanceledByUser = true;
|
|
1267
|
+
agentAbortController.abort();
|
|
1268
|
+
};
|
|
1269
|
+
} else {
|
|
1270
|
+
// Fallback: re-register if not found
|
|
1271
|
+
agentRegistry.register({
|
|
1272
|
+
id: agentId,
|
|
1273
|
+
widgetId: activeWidgetId || "",
|
|
1274
|
+
task,
|
|
1275
|
+
goal: params.goal,
|
|
1276
|
+
status: "running",
|
|
1277
|
+
startTime: Date.now(),
|
|
1278
|
+
abort: () => {
|
|
1279
|
+
agentAbortController.abort();
|
|
1280
|
+
},
|
|
1281
|
+
retry: retryAgent,
|
|
1282
|
+
progress: result.progress,
|
|
1283
|
+
model: settings.model,
|
|
1284
|
+
});
|
|
1285
|
+
}
|
|
1286
|
+
runBackground();
|
|
1287
|
+
};
|
|
1288
|
+
|
|
1289
|
+
// Register the agent
|
|
1290
|
+
agentRegistry.register({
|
|
1291
|
+
id: agentId,
|
|
1292
|
+
widgetId: "", // Will be updated when widget is created
|
|
1293
|
+
task,
|
|
1294
|
+
goal: params.goal,
|
|
1295
|
+
status: "running",
|
|
1296
|
+
startTime,
|
|
1297
|
+
abort: () => {
|
|
1298
|
+
localCanceledByUser = true;
|
|
1299
|
+
agentAbortController.abort();
|
|
1300
|
+
},
|
|
1301
|
+
retry: retryAgent,
|
|
1302
|
+
progress: result.progress,
|
|
1303
|
+
model: settings.model,
|
|
1304
|
+
});
|
|
1305
|
+
|
|
1306
|
+
const runBackground = async () => {
|
|
1307
|
+
attempt = 0;
|
|
1308
|
+
|
|
1309
|
+
while (attempt <= MAX_RETRIES) {
|
|
1310
|
+
try {
|
|
1311
|
+
const bgResult = await runSearchInBackground(
|
|
1312
|
+
task,
|
|
1313
|
+
params.goal,
|
|
1314
|
+
systemPrompt,
|
|
1315
|
+
settings.model,
|
|
1316
|
+
agentAbortController.signal,
|
|
1317
|
+
undefined,
|
|
1318
|
+
ctx.cwd,
|
|
1319
|
+
startTime,
|
|
1320
|
+
ctx.ui,
|
|
1321
|
+
);
|
|
1322
|
+
result = bgResult.result;
|
|
1323
|
+
activeWidgetId = bgResult.widgetId;
|
|
1324
|
+
|
|
1325
|
+
// Update agent registry with widgetId
|
|
1326
|
+
const agent = agentRegistry.get(agentId);
|
|
1327
|
+
if (agent) {
|
|
1328
|
+
agent.widgetId = activeWidgetId;
|
|
1329
|
+
agent.progress = result.progress;
|
|
1330
|
+
agent.model = result.model;
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
// Check if agent was stopped by user — no feedback to LLM, keep widget visible
|
|
1334
|
+
const currentAgent = agentRegistry.get(agentId);
|
|
1335
|
+
if (currentAgent?.stoppedByUser) {
|
|
1336
|
+
// Agent was stopped, keep in registry with stopped status
|
|
1337
|
+
// Update widget to show stopped status
|
|
1338
|
+
if (activeWidgetId) {
|
|
1339
|
+
ctx.ui.setWidget(activeWidgetId, (tui: any, theme: any) => {
|
|
1340
|
+
return {
|
|
1341
|
+
render: () => {
|
|
1342
|
+
const box = new Box(1, 0, (t: string) => theme.bg("customMessageBg", t));
|
|
1343
|
+
box.addChild(new Text(
|
|
1344
|
+
`${theme.fg("muted", "⏸")} ${theme.fg("toolTitle", theme.bold("research"))} — ${theme.fg("muted", "stopped")}`,
|
|
1345
|
+
0, 0,
|
|
1346
|
+
));
|
|
1347
|
+
box.addChild(new Text(
|
|
1348
|
+
theme.fg("dim", ` ${params.goal.length > 50 ? params.goal.slice(0, 50) + "..." : params.goal}`),
|
|
1349
|
+
0, 0,
|
|
1350
|
+
));
|
|
1351
|
+
const width = process.stdout.columns || 80;
|
|
1352
|
+
const lines = box.render(width);
|
|
1353
|
+
lines.push("");
|
|
1354
|
+
return lines;
|
|
1355
|
+
},
|
|
1356
|
+
invalidate: () => {},
|
|
1357
|
+
};
|
|
1358
|
+
});
|
|
1359
|
+
}
|
|
1360
|
+
return;
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
// If agent was canceled, send failure message with canceled status
|
|
1364
|
+
if (localCanceledByUser) {
|
|
1365
|
+
const summary = buildSearchSummary(result, params.goal, true);
|
|
1366
|
+
pi.sendMessage(
|
|
1367
|
+
{
|
|
1368
|
+
customType: "search-result",
|
|
1369
|
+
content: summary,
|
|
1370
|
+
display: true,
|
|
1371
|
+
details: {
|
|
1372
|
+
mode: "widget",
|
|
1373
|
+
task,
|
|
1374
|
+
goal: params.goal,
|
|
1375
|
+
result,
|
|
1376
|
+
},
|
|
1377
|
+
},
|
|
1378
|
+
{
|
|
1379
|
+
deliverAs: "followUp",
|
|
1380
|
+
triggerTurn: true,
|
|
1381
|
+
},
|
|
1382
|
+
);
|
|
1383
|
+
if (activeWidgetId) {
|
|
1384
|
+
ctx.ui.setWidget(activeWidgetId, undefined);
|
|
1385
|
+
}
|
|
1386
|
+
ctx.ui.setStatus("search", undefined);
|
|
1387
|
+
return;
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
// Success — send results and return
|
|
1391
|
+
if (result.progress.status === "completed") {
|
|
1392
|
+
const summary = buildSearchSummary(result, params.goal, localCanceledByUser);
|
|
1393
|
+
pi.sendMessage(
|
|
1394
|
+
{
|
|
1395
|
+
customType: "search-result",
|
|
1396
|
+
content: summary,
|
|
1397
|
+
display: true,
|
|
1398
|
+
details: {
|
|
1399
|
+
mode: "widget",
|
|
1400
|
+
task,
|
|
1401
|
+
goal: params.goal,
|
|
1402
|
+
result,
|
|
1403
|
+
},
|
|
1404
|
+
},
|
|
1405
|
+
{
|
|
1406
|
+
deliverAs: "followUp",
|
|
1407
|
+
triggerTurn: true,
|
|
1408
|
+
},
|
|
1409
|
+
);
|
|
1410
|
+
clearWidget();
|
|
1411
|
+
return;
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
// Failed — check if transient
|
|
1415
|
+
if (!isTransientError(result) || attempt >= MAX_RETRIES) {
|
|
1416
|
+
const summary = buildSearchSummary(result, params.goal, localCanceledByUser);
|
|
1417
|
+
pi.sendMessage(
|
|
1418
|
+
{
|
|
1419
|
+
customType: "search-result",
|
|
1420
|
+
content: summary,
|
|
1421
|
+
display: true,
|
|
1422
|
+
details: {
|
|
1423
|
+
mode: "widget",
|
|
1424
|
+
task,
|
|
1425
|
+
goal: params.goal,
|
|
1426
|
+
result,
|
|
1427
|
+
},
|
|
1428
|
+
},
|
|
1429
|
+
{
|
|
1430
|
+
deliverAs: "followUp",
|
|
1431
|
+
triggerTurn: true,
|
|
1432
|
+
},
|
|
1433
|
+
);
|
|
1434
|
+
clearWidget();
|
|
1435
|
+
return;
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
// Transient error — retry with backoff
|
|
1439
|
+
attempt++;
|
|
1440
|
+
const delay = INITIAL_RETRY_DELAY_MS * Math.pow(2, attempt - 1);
|
|
1441
|
+
const errorMsg =
|
|
1442
|
+
result.errorMessage || result.progress.error || result.stderr || "unknown error";
|
|
1443
|
+
|
|
1444
|
+
// Show retry status in widget
|
|
1445
|
+
if (activeWidgetId) {
|
|
1446
|
+
ctx.ui.setWidget(activeWidgetId, (_tui: any, th: any) => {
|
|
1447
|
+
return {
|
|
1448
|
+
render: () => {
|
|
1449
|
+
const box = new Box(1, 0, (t: string) => th.bg("customMessageBg", t));
|
|
1450
|
+
box.addChild(new Text(
|
|
1451
|
+
`${th.fg("warning", "⟳")} ${th.fg("toolTitle", th.bold("research"))} — ${th.fg("warning", `retrying (${attempt}/${MAX_RETRIES})`)}`,
|
|
1452
|
+
0, 0,
|
|
1453
|
+
));
|
|
1454
|
+
box.addChild(new Text(
|
|
1455
|
+
th.fg("dim", params.goal.length > 60 ? params.goal.slice(0, 60) + "..." : params.goal),
|
|
1456
|
+
0, 0,
|
|
1457
|
+
));
|
|
1458
|
+
box.addChild(new Text(
|
|
1459
|
+
th.fg("error", `Error: ${errorMsg.slice(0, 80)}`),
|
|
1460
|
+
0, 0,
|
|
1461
|
+
));
|
|
1462
|
+
box.addChild(new Text(
|
|
1463
|
+
th.fg("muted", `Waiting ${Math.round(delay / 1000)}s before retry`),
|
|
1464
|
+
0, 0,
|
|
1465
|
+
));
|
|
1466
|
+
const width = process.stdout.columns || 80;
|
|
1467
|
+
return box.render(width);
|
|
1468
|
+
},
|
|
1469
|
+
invalidate: () => {},
|
|
1470
|
+
};
|
|
1471
|
+
});
|
|
1472
|
+
}
|
|
1473
|
+
ctx.ui.setStatus("search", ctx.ui.theme.fg("warning", `⟳ research retry ${attempt}/${MAX_RETRIES}`));
|
|
1474
|
+
|
|
1475
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
1476
|
+
|
|
1477
|
+
// Check if aborted during wait
|
|
1478
|
+
if (agentAbortController.signal.aborted) return;
|
|
1479
|
+
} catch (error) {
|
|
1480
|
+
// Check if agent was stopped by user — no feedback to LLM
|
|
1481
|
+
const currentAgent = agentRegistry.get(agentId);
|
|
1482
|
+
if (currentAgent?.stoppedByUser) {
|
|
1483
|
+
// Clean up widget
|
|
1484
|
+
if (activeWidgetId) {
|
|
1485
|
+
ctx.ui.setWidget(activeWidgetId, undefined);
|
|
1486
|
+
}
|
|
1487
|
+
ctx.ui.setStatus("search", undefined);
|
|
1488
|
+
return;
|
|
1489
|
+
}
|
|
1490
|
+
// Build summary with canceledByUser flag if applicable
|
|
1491
|
+
const summary = buildSearchSummary(result, params.goal, localCanceledByUser);
|
|
1492
|
+
pi.sendMessage(
|
|
1493
|
+
{
|
|
1494
|
+
customType: "search-result",
|
|
1495
|
+
content: summary,
|
|
1496
|
+
display: true,
|
|
1497
|
+
details: {
|
|
1498
|
+
mode: "widget",
|
|
1499
|
+
task,
|
|
1500
|
+
goal: params.goal,
|
|
1501
|
+
result,
|
|
1502
|
+
},
|
|
1503
|
+
},
|
|
1504
|
+
{
|
|
1505
|
+
deliverAs: "followUp",
|
|
1506
|
+
triggerTurn: true,
|
|
1507
|
+
},
|
|
1508
|
+
);
|
|
1509
|
+
clearWidget();
|
|
1510
|
+
return;
|
|
1511
|
+
}
|
|
1512
|
+
}
|
|
1513
|
+
};
|
|
1514
|
+
|
|
1515
|
+
// Start the background run
|
|
1516
|
+
await runBackground();
|
|
1517
|
+
};
|
|
1518
|
+
|
|
1519
|
+
// Start background task (don't await) with error catching
|
|
1520
|
+
backgroundTask().catch((error) => {
|
|
1521
|
+
// Check if agent was stopped by user — no feedback to LLM
|
|
1522
|
+
const currentAgent = agentRegistry.get(agentId);
|
|
1523
|
+
if (currentAgent?.stoppedByUser) {
|
|
1524
|
+
// Clean up status
|
|
1525
|
+
ctx.ui.setStatus("search", undefined);
|
|
1526
|
+
return;
|
|
1527
|
+
}
|
|
1528
|
+
// Unexpected error — try to send failure message and clean up
|
|
1529
|
+
try {
|
|
1530
|
+
pi.sendMessage(
|
|
1531
|
+
{
|
|
1532
|
+
customType: "search-result",
|
|
1533
|
+
content: `Research failed unexpectedly: ${error instanceof Error ? error.message : String(error)}`,
|
|
1534
|
+
display: true,
|
|
1535
|
+
details: {
|
|
1536
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1537
|
+
},
|
|
1538
|
+
},
|
|
1539
|
+
{
|
|
1540
|
+
deliverAs: "followUp",
|
|
1541
|
+
triggerTurn: true,
|
|
1542
|
+
},
|
|
1543
|
+
);
|
|
1544
|
+
ctx.ui.setStatus("search", undefined);
|
|
1545
|
+
} catch {
|
|
1546
|
+
// Last resort — nothing more we can do
|
|
1547
|
+
}
|
|
1548
|
+
});
|
|
1549
|
+
|
|
1550
|
+
// Return immediately
|
|
1551
|
+
return {
|
|
1552
|
+
content: [
|
|
1553
|
+
{
|
|
1554
|
+
type: "text",
|
|
1555
|
+
text: [
|
|
1556
|
+
`Research started in background for: ${params.goal}`,
|
|
1557
|
+
"",
|
|
1558
|
+
"IMPORTANT: The research agent is running in the background and will deliver a comprehensive summary automatically when done. Wait for the results before proceeding with your own searching or fetching — the agent is doing that work for you. You may spawn additional research agents for unrelated topics in parallel.",
|
|
1559
|
+
].join("\n"),
|
|
1560
|
+
},
|
|
1561
|
+
],
|
|
1562
|
+
details: {
|
|
1563
|
+
mode: "widget",
|
|
1564
|
+
task,
|
|
1565
|
+
goal: params.goal,
|
|
1566
|
+
status: "started",
|
|
1567
|
+
},
|
|
1568
|
+
};
|
|
1569
|
+
},
|
|
1570
|
+
|
|
1571
|
+
renderCall(args: any, theme: any, _context: any) {
|
|
1572
|
+
const goal = args.goal || "...";
|
|
1573
|
+
const preview = goal.length > 50 ? `${goal.slice(0, 50)}...` : goal;
|
|
1574
|
+
|
|
1575
|
+
let text = theme.fg("toolTitle", theme.bold("research "));
|
|
1576
|
+
text += theme.fg("accent", preview);
|
|
1577
|
+
|
|
1578
|
+
if (args.context) {
|
|
1579
|
+
const contextPreview =
|
|
1580
|
+
args.context.length > 50 ? `${args.context.slice(0, 50)}...` : args.context;
|
|
1581
|
+
text += `\n ${theme.fg("dim", contextPreview)}`;
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
return new Text(text, 0, 0);
|
|
1585
|
+
},
|
|
1586
|
+
|
|
1587
|
+
renderResult(result: any, { expanded }: any, theme: any, _context: any) {
|
|
1588
|
+
const details = result.details as SearchDetails | undefined;
|
|
1589
|
+
|
|
1590
|
+
if (!details) {
|
|
1591
|
+
const text = result.content?.[0];
|
|
1592
|
+
return new Text(text?.type === "text" ? text.text : "(no output)", 0, 0);
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
// Handle "started" status (background mode)
|
|
1596
|
+
if (details.status === "started") {
|
|
1597
|
+
const goal = details.task?.match(/Goal: (.+)/)?.[1] || "";
|
|
1598
|
+
const c = new Container();
|
|
1599
|
+
|
|
1600
|
+
c.addChild(
|
|
1601
|
+
new Text(
|
|
1602
|
+
theme.fg("warning", "⟳ ") +
|
|
1603
|
+
theme.fg("toolTitle", theme.bold("research")) +
|
|
1604
|
+
theme.fg("accent", " — searching in background"),
|
|
1605
|
+
0,
|
|
1606
|
+
0,
|
|
1607
|
+
),
|
|
1608
|
+
);
|
|
1609
|
+
|
|
1610
|
+
if (goal)
|
|
1611
|
+
c.addChild(
|
|
1612
|
+
new Text(theme.fg("dim", " Goal: ") + theme.fg("text", goal), 0, 0),
|
|
1613
|
+
);
|
|
1614
|
+
|
|
1615
|
+
c.addChild(new Spacer(1));
|
|
1616
|
+
c.addChild(
|
|
1617
|
+
new Text(
|
|
1618
|
+
theme.fg(
|
|
1619
|
+
"warning",
|
|
1620
|
+
"Waiting for research results — delegate searching, do not search/fetch yourself.",
|
|
1621
|
+
),
|
|
1622
|
+
0,
|
|
1623
|
+
0,
|
|
1624
|
+
),
|
|
1625
|
+
);
|
|
1626
|
+
|
|
1627
|
+
return c;
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1630
|
+
const mdTheme = getMarkdownTheme();
|
|
1631
|
+
|
|
1632
|
+
if (!details.result) {
|
|
1633
|
+
const text = result.content?.[0];
|
|
1634
|
+
return new Text(text?.type === "text" ? text.text : "(no output)", 0, 0);
|
|
1635
|
+
}
|
|
1636
|
+
|
|
1637
|
+
const r = details.result;
|
|
1638
|
+
const prog = r.progress;
|
|
1639
|
+
const isError = r.exitCode !== 0;
|
|
1640
|
+
const isRunning = prog.status === "running";
|
|
1641
|
+
const icon = isRunning
|
|
1642
|
+
? theme.fg("warning", "⟳")
|
|
1643
|
+
: isError
|
|
1644
|
+
? theme.fg("error", "✗")
|
|
1645
|
+
: theme.fg("success", "✓");
|
|
1646
|
+
const displayItems = getDisplayItems(r.messages);
|
|
1647
|
+
const finalOutput = getFinalOutput(r.messages);
|
|
1648
|
+
|
|
1649
|
+
if (expanded) {
|
|
1650
|
+
const container = new Container();
|
|
1651
|
+
|
|
1652
|
+
// Header: icon + research + stats
|
|
1653
|
+
const modelStr = r.model ? ` (${r.model})` : "";
|
|
1654
|
+
const stats = `${prog.toolCount} searches · ${formatDuration(prog.durationMs)}`;
|
|
1655
|
+
let header = `${icon} ${theme.fg("toolTitle", theme.bold("research"))}${theme.fg("dim", modelStr)} — ${theme.fg("dim", stats)}`;
|
|
1656
|
+
if (isError && r.stopReason) header += ` ${theme.fg("error", `[${r.stopReason}]`)}`;
|
|
1657
|
+
container.addChild(new Text(header, 0, 0));
|
|
1658
|
+
|
|
1659
|
+
if (isError && (r.errorMessage || prog.error)) {
|
|
1660
|
+
container.addChild(
|
|
1661
|
+
new Text(theme.fg("error", `Error: ${r.errorMessage || prog.error}`), 0, 0),
|
|
1662
|
+
);
|
|
1663
|
+
}
|
|
1664
|
+
|
|
1665
|
+
// Tool log
|
|
1666
|
+
if (prog.recentTools.length > 0) {
|
|
1667
|
+
container.addChild(new Spacer(1));
|
|
1668
|
+
for (const t of prog.recentTools.slice(-10)) {
|
|
1669
|
+
const statusIcon =
|
|
1670
|
+
t.status === "running" ? theme.fg("warning", "▸") : theme.fg("muted", " ");
|
|
1671
|
+
container.addChild(
|
|
1672
|
+
new Text(
|
|
1673
|
+
`${statusIcon} ${theme.fg("muted", t.tool)}: ${theme.fg("dim", t.args)}`,
|
|
1674
|
+
0,
|
|
1675
|
+
0,
|
|
1676
|
+
),
|
|
1677
|
+
);
|
|
1678
|
+
}
|
|
1679
|
+
}
|
|
1680
|
+
|
|
1681
|
+
// Latest "thinking" message
|
|
1682
|
+
if (prog.lastMessage) {
|
|
1683
|
+
container.addChild(new Spacer(1));
|
|
1684
|
+
container.addChild(new Text(theme.fg("text", prog.lastMessage), 0, 0));
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1687
|
+
// Final output
|
|
1688
|
+
if (finalOutput) {
|
|
1689
|
+
container.addChild(new Spacer(1));
|
|
1690
|
+
container.addChild(new Markdown(finalOutput.trim(), 0, 0, mdTheme));
|
|
1691
|
+
} else if (displayItems.length === 0) {
|
|
1692
|
+
container.addChild(new Spacer(1));
|
|
1693
|
+
container.addChild(new Text(theme.fg("muted", "(no output)"), 0, 0));
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1696
|
+
// Usage line
|
|
1697
|
+
container.addChild(new Spacer(1));
|
|
1698
|
+
const usageParts: string[] = [];
|
|
1699
|
+
if (r.usage.turns)
|
|
1700
|
+
usageParts.push(
|
|
1701
|
+
theme.fg("dim", `${r.usage.turns} turn${r.usage.turns > 1 ? "s" : ""}`),
|
|
1702
|
+
);
|
|
1703
|
+
if (r.usage.input)
|
|
1704
|
+
usageParts.push(theme.fg("dim", `↑${formatTokens(r.usage.input)}`));
|
|
1705
|
+
if (r.usage.output)
|
|
1706
|
+
usageParts.push(theme.fg("dim", `↓${formatTokens(r.usage.output)}`));
|
|
1707
|
+
if (r.usage.cacheRead)
|
|
1708
|
+
usageParts.push(theme.fg("dim", `R${formatTokens(r.usage.cacheRead)}`));
|
|
1709
|
+
if (r.usage.cacheWrite)
|
|
1710
|
+
usageParts.push(theme.fg("dim", `W${formatTokens(r.usage.cacheWrite)}`));
|
|
1711
|
+
if (r.usage.cost) usageParts.push(theme.fg("dim", `$${r.usage.cost.toFixed(4)}`));
|
|
1712
|
+
if (prog.tokens > 0 && prog.contextWindow) {
|
|
1713
|
+
const ctxStr = formatContextUsage(prog.tokens, prog.contextWindow);
|
|
1714
|
+
const pct = (prog.tokens / prog.contextWindow) * 100;
|
|
1715
|
+
const coloredCtx =
|
|
1716
|
+
pct > 90
|
|
1717
|
+
? theme.fg("error", ctxStr)
|
|
1718
|
+
: pct > 70
|
|
1719
|
+
? theme.fg("warning", ctxStr)
|
|
1720
|
+
: theme.fg("dim", ctxStr);
|
|
1721
|
+
usageParts.push(coloredCtx);
|
|
1722
|
+
}
|
|
1723
|
+
if (usageParts.length) {
|
|
1724
|
+
container.addChild(new Text(usageParts.join(" "), 0, 0));
|
|
1725
|
+
}
|
|
1726
|
+
|
|
1727
|
+
return container;
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1730
|
+
// ── Collapsed view ──
|
|
1731
|
+
const modelStr = r.model ? theme.fg("dim", ` (${r.model})`) : "";
|
|
1732
|
+
const stats = `${prog.toolCount} searches · ${formatDuration(prog.durationMs)}`;
|
|
1733
|
+
let text = `${icon} ${theme.fg("toolTitle", theme.bold("research"))}${modelStr} — ${theme.fg("dim", stats)}`;
|
|
1734
|
+
if (isError && r.stopReason) text += ` ${theme.fg("error", `[${r.stopReason}]`)}`;
|
|
1735
|
+
|
|
1736
|
+
// Tool log — last 5
|
|
1737
|
+
const toolSlice = prog.recentTools.slice(-5);
|
|
1738
|
+
const toolSkip = prog.recentTools.length - toolSlice.length;
|
|
1739
|
+
if (toolSkip > 0) text += `\n${theme.fg("muted", ` … ${toolSkip} earlier`)}`;
|
|
1740
|
+
for (const t of toolSlice) {
|
|
1741
|
+
if (t.status === "running") {
|
|
1742
|
+
text += `\n${theme.fg("warning", "▸")} ${theme.fg("muted", t.tool)}: ${theme.fg("dim", t.args)}`;
|
|
1743
|
+
} else {
|
|
1744
|
+
text += `\n${theme.fg("muted", ` ${t.tool}:`)} ${theme.fg("dim", t.args)}`;
|
|
1745
|
+
}
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1748
|
+
// Last message or error
|
|
1749
|
+
if (isError && (r.errorMessage || prog.error)) {
|
|
1750
|
+
text += `\n${theme.fg("error", `Error: ${r.errorMessage || prog.error}`)}`;
|
|
1751
|
+
} else if (prog.lastMessage) {
|
|
1752
|
+
const preview =
|
|
1753
|
+
prog.lastMessage.length > 100
|
|
1754
|
+
? `${prog.lastMessage.slice(0, 100)}…`
|
|
1755
|
+
: prog.lastMessage;
|
|
1756
|
+
text += `\n${theme.fg("text", preview)}`;
|
|
1757
|
+
}
|
|
1758
|
+
|
|
1759
|
+
// Usage line
|
|
1760
|
+
const usageParts: string[] = [];
|
|
1761
|
+
if (r.usage.turns)
|
|
1762
|
+
usageParts.push(`${r.usage.turns} turn${r.usage.turns > 1 ? "s" : ""}`);
|
|
1763
|
+
if (r.usage.input) usageParts.push(`↑${formatTokens(r.usage.input)}`);
|
|
1764
|
+
if (r.usage.output) usageParts.push(`↓${formatTokens(r.usage.output)}`);
|
|
1765
|
+
if (r.usage.cacheRead) usageParts.push(`R${formatTokens(r.usage.cacheRead)}`);
|
|
1766
|
+
if (r.usage.cacheWrite) usageParts.push(`W${formatTokens(r.usage.cacheWrite)}`);
|
|
1767
|
+
if (r.usage.cost) usageParts.push(`$${r.usage.cost.toFixed(4)}`);
|
|
1768
|
+
if (prog.tokens > 0) {
|
|
1769
|
+
usageParts.push(formatContextUsage(prog.tokens, prog.contextWindow));
|
|
1770
|
+
}
|
|
1771
|
+
if (usageParts.length) {
|
|
1772
|
+
text += `\n${theme.fg("dim", usageParts.join(" "))}`;
|
|
1773
|
+
}
|
|
1774
|
+
|
|
1775
|
+
return new Text(text, 0, 0);
|
|
1776
|
+
},
|
|
1777
|
+
});
|
|
1778
|
+
}
|