@oh-my-pi/pi-coding-agent 3.20.1 → 3.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +107 -8
- package/docs/custom-tools.md +3 -3
- package/docs/extensions.md +226 -220
- package/docs/hooks.md +2 -2
- package/docs/sdk.md +50 -53
- package/examples/custom-tools/README.md +2 -17
- package/examples/extensions/README.md +76 -74
- package/examples/extensions/todo.ts +2 -5
- package/examples/hooks/custom-compaction.ts +2 -4
- package/examples/hooks/handoff.ts +1 -1
- package/examples/hooks/qna.ts +1 -1
- package/examples/sdk/02-custom-model.ts +1 -1
- package/examples/sdk/README.md +7 -11
- package/package.json +6 -6
- package/src/cli/args.ts +9 -6
- package/src/cli/file-processor.ts +1 -1
- package/src/cli/list-models.ts +1 -1
- package/src/core/agent-session.ts +16 -5
- package/src/core/auth-storage.ts +1 -1
- package/src/core/compaction/branch-summarization.ts +2 -2
- package/src/core/compaction/compaction.ts +2 -2
- package/src/core/compaction/utils.ts +1 -1
- package/src/core/custom-tools/types.ts +1 -1
- package/src/core/custom-tools/wrapper.ts +0 -1
- package/src/core/extensions/index.ts +1 -6
- package/src/core/extensions/runner.ts +1 -1
- package/src/core/extensions/types.ts +1 -1
- package/src/core/extensions/wrapper.ts +1 -8
- package/src/core/file-mentions.ts +5 -8
- package/src/core/hooks/runner.ts +2 -2
- package/src/core/hooks/types.ts +1 -1
- package/src/core/messages.ts +1 -1
- package/src/core/model-registry.ts +1 -1
- package/src/core/model-resolver.ts +1 -1
- package/src/core/sdk.ts +64 -105
- package/src/core/session-manager.ts +18 -22
- package/src/core/settings-manager.ts +66 -1
- package/src/core/slash-commands.ts +12 -5
- package/src/core/system-prompt.ts +49 -36
- package/src/core/title-generator.ts +2 -2
- package/src/core/tools/ask.ts +98 -4
- package/src/core/tools/bash-interceptor.ts +11 -4
- package/src/core/tools/bash.ts +121 -5
- package/src/core/tools/context.ts +7 -0
- package/src/core/tools/edit-diff.ts +73 -24
- package/src/core/tools/edit.ts +221 -34
- package/src/core/tools/exa/render.ts +4 -16
- package/src/core/tools/find.ts +149 -5
- package/src/core/tools/gemini-image.ts +279 -56
- package/src/core/tools/git.ts +17 -3
- package/src/core/tools/grep.ts +185 -5
- package/src/core/tools/index.test.ts +180 -0
- package/src/core/tools/index.ts +96 -242
- package/src/core/tools/ls.ts +133 -5
- package/src/core/tools/lsp/index.ts +32 -29
- package/src/core/tools/lsp/render.ts +21 -22
- package/src/core/tools/notebook.ts +112 -4
- package/src/core/tools/output.ts +175 -15
- package/src/core/tools/read.ts +127 -25
- package/src/core/tools/render-utils.ts +241 -0
- package/src/core/tools/renderers.ts +40 -828
- package/src/core/tools/review.ts +26 -25
- package/src/core/tools/rulebook.ts +11 -3
- package/src/core/tools/task/agents.ts +28 -7
- package/src/core/tools/task/discovery.ts +0 -6
- package/src/core/tools/task/executor.ts +264 -254
- package/src/core/tools/task/index.ts +48 -208
- package/src/core/tools/task/render.ts +26 -11
- package/src/core/tools/task/types.ts +7 -12
- package/src/core/tools/task/worker-protocol.ts +17 -0
- package/src/core/tools/task/worker.ts +238 -0
- package/src/core/tools/truncate.ts +27 -1
- package/src/core/tools/web-fetch.ts +25 -49
- package/src/core/tools/web-search/index.ts +132 -46
- package/src/core/tools/web-search/providers/anthropic.ts +7 -2
- package/src/core/tools/web-search/providers/exa.ts +2 -1
- package/src/core/tools/web-search/providers/perplexity.ts +6 -1
- package/src/core/tools/web-search/render.ts +6 -4
- package/src/core/tools/web-search/types.ts +13 -0
- package/src/core/tools/write.ts +96 -14
- package/src/core/voice.ts +1 -1
- package/src/discovery/helpers.test.ts +1 -1
- package/src/index.ts +5 -16
- package/src/main.ts +5 -5
- package/src/modes/interactive/components/assistant-message.ts +1 -1
- package/src/modes/interactive/components/custom-message.ts +1 -1
- package/src/modes/interactive/components/extensions/inspector-panel.ts +25 -22
- package/src/modes/interactive/components/extensions/state-manager.ts +12 -0
- package/src/modes/interactive/components/footer.ts +1 -1
- package/src/modes/interactive/components/hook-message.ts +1 -1
- package/src/modes/interactive/components/model-selector.ts +1 -1
- package/src/modes/interactive/components/oauth-selector.ts +1 -1
- package/src/modes/interactive/components/settings-defs.ts +49 -0
- package/src/modes/interactive/components/status-line.ts +1 -1
- package/src/modes/interactive/components/tool-execution.ts +93 -538
- package/src/modes/interactive/interactive-mode.ts +19 -7
- package/src/modes/interactive/theme/theme.ts +4 -4
- package/src/modes/print-mode.ts +1 -1
- package/src/modes/rpc/rpc-client.ts +1 -1
- package/src/modes/rpc/rpc-types.ts +1 -1
- package/src/prompts/system-prompt.md +4 -0
- package/src/prompts/task.md +0 -7
- package/src/prompts/tools/gemini-image.md +5 -1
- package/src/prompts/tools/output.md +6 -2
- package/src/prompts/tools/task.md +68 -0
- package/src/prompts/tools/web-fetch.md +1 -0
- package/src/prompts/tools/web-search.md +2 -0
- package/src/utils/image-convert.ts +8 -2
- package/src/utils/image-magick.ts +247 -0
- package/src/utils/image-resize.ts +53 -13
- package/examples/custom-tools/question/index.ts +0 -84
- package/examples/custom-tools/subagent/README.md +0 -172
- package/examples/custom-tools/subagent/agents/planner.md +0 -37
- package/examples/custom-tools/subagent/agents/scout.md +0 -50
- package/examples/custom-tools/subagent/agents/worker.md +0 -24
- package/examples/custom-tools/subagent/agents.ts +0 -156
- package/examples/custom-tools/subagent/commands/implement-and-review.md +0 -10
- package/examples/custom-tools/subagent/commands/implement.md +0 -10
- package/examples/custom-tools/subagent/commands/scout-and-plan.md +0 -9
- package/examples/custom-tools/subagent/index.ts +0 -1002
- package/examples/sdk/05-tools.ts +0 -94
- package/examples/sdk/12-full-control.ts +0 -95
- package/src/prompts/browser.md +0 -71
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Worker thread for subagent execution.
|
|
3
|
+
*
|
|
4
|
+
* This worker runs in a separate thread via Bun's Worker API. It creates a minimal
|
|
5
|
+
* AgentSession and forwards events back to the parent thread.
|
|
6
|
+
*
|
|
7
|
+
* ## Event Flow
|
|
8
|
+
*
|
|
9
|
+
* 1. Parent sends { type: "start", payload } with task config
|
|
10
|
+
* 2. Worker creates AgentSession and subscribes to events
|
|
11
|
+
* 3. Worker forwards AgentEvent messages via postMessage
|
|
12
|
+
* 4. Worker sends { type: "done", exitCode, ... } on completion
|
|
13
|
+
* 5. Parent can send { type: "abort" } to request cancellation
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { Api, Model } from "@mariozechner/pi-ai";
|
|
17
|
+
import type { AgentEvent, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
|
|
18
|
+
import type { AgentSessionEvent } from "../../agent-session";
|
|
19
|
+
import { parseModelPattern, parseModelString } from "../../model-resolver";
|
|
20
|
+
import { createAgentSession, discoverAuthStorage, discoverModels } from "../../sdk";
|
|
21
|
+
import { SessionManager } from "../../session-manager";
|
|
22
|
+
import type { SubagentWorkerRequest, SubagentWorkerResponse, SubagentWorkerStartPayload } from "./worker-protocol";
|
|
23
|
+
|
|
24
|
+
type PostMessageFn = (message: SubagentWorkerResponse) => void;
|
|
25
|
+
|
|
26
|
+
const postMessageSafe: PostMessageFn = (message) => {
|
|
27
|
+
(globalThis as typeof globalThis & { postMessage: PostMessageFn }).postMessage(message);
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
interface WorkerMessageEvent<T> {
|
|
31
|
+
data: T;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Agent event types to forward to parent (excludes session-only events like compaction) */
|
|
35
|
+
const agentEventTypes = new Set<AgentEvent["type"]>([
|
|
36
|
+
"agent_start",
|
|
37
|
+
"agent_end",
|
|
38
|
+
"turn_start",
|
|
39
|
+
"turn_end",
|
|
40
|
+
"message_start",
|
|
41
|
+
"message_update",
|
|
42
|
+
"message_end",
|
|
43
|
+
"tool_execution_start",
|
|
44
|
+
"tool_execution_update",
|
|
45
|
+
"tool_execution_end",
|
|
46
|
+
]);
|
|
47
|
+
|
|
48
|
+
const isAgentEvent = (event: AgentSessionEvent): event is AgentEvent => {
|
|
49
|
+
return agentEventTypes.has(event.type as AgentEvent["type"]);
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
let running = false;
|
|
53
|
+
let abortRequested = false;
|
|
54
|
+
let activeSession: { abort: () => Promise<void>; dispose: () => Promise<void> } | null = null;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Resolve model string to Model object with optional thinking level.
|
|
58
|
+
* Supports both exact "provider/id" format and fuzzy matching ("sonnet", "opus").
|
|
59
|
+
*/
|
|
60
|
+
function resolveModelOverride(
|
|
61
|
+
override: string | undefined,
|
|
62
|
+
modelRegistry: { getAvailable: () => Model<Api>[]; find: (provider: string, id: string) => Model<Api> | undefined },
|
|
63
|
+
): { model?: Model<Api>; thinkingLevel?: ThinkingLevel } {
|
|
64
|
+
if (!override) return {};
|
|
65
|
+
|
|
66
|
+
// Try exact "provider/id" format first
|
|
67
|
+
const parsed = parseModelString(override);
|
|
68
|
+
if (parsed) {
|
|
69
|
+
return { model: modelRegistry.find(parsed.provider, parsed.id) };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Fall back to fuzzy pattern matching
|
|
73
|
+
const result = parseModelPattern(override, modelRegistry.getAvailable());
|
|
74
|
+
return {
|
|
75
|
+
model: result.model,
|
|
76
|
+
thinkingLevel: result.thinkingLevel !== "off" ? result.thinkingLevel : undefined,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Main task execution function.
|
|
82
|
+
*
|
|
83
|
+
* Equivalent to CLI flow:
|
|
84
|
+
* 1. omp --mode json --non-interactive
|
|
85
|
+
* 2. --append-system-prompt <agent.systemPrompt>
|
|
86
|
+
* 3. --tools <toolNames> (if specified)
|
|
87
|
+
* 4. --model <model> (if specified)
|
|
88
|
+
* 5. --session <sessionFile> OR --no-session
|
|
89
|
+
* 6. --prompt <task>
|
|
90
|
+
*
|
|
91
|
+
* Environment equivalent:
|
|
92
|
+
* - OMP_BLOCKED_AGENT: payload.blockedAgent (prevents same-agent recursion)
|
|
93
|
+
* - OMP_SPAWNS: payload.spawnsEnv (controls nested spawn permissions)
|
|
94
|
+
*/
|
|
95
|
+
async function runTask(payload: SubagentWorkerStartPayload): Promise<void> {
|
|
96
|
+
const startTime = Date.now();
|
|
97
|
+
let exitCode = 0;
|
|
98
|
+
let error: string | undefined;
|
|
99
|
+
let aborted = false;
|
|
100
|
+
|
|
101
|
+
try {
|
|
102
|
+
// Check for pre-start abort
|
|
103
|
+
if (abortRequested) {
|
|
104
|
+
aborted = true;
|
|
105
|
+
exitCode = 1;
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Set working directory (CLI does this implicitly)
|
|
110
|
+
process.chdir(payload.cwd);
|
|
111
|
+
|
|
112
|
+
// Discover auth and models (equivalent to CLI's discoverAuthStorage/discoverModels)
|
|
113
|
+
const authStorage = await discoverAuthStorage();
|
|
114
|
+
const modelRegistry = await discoverModels(authStorage);
|
|
115
|
+
|
|
116
|
+
// Resolve model override (equivalent to CLI's parseModelPattern with --model)
|
|
117
|
+
const { model, thinkingLevel } = resolveModelOverride(payload.model, modelRegistry);
|
|
118
|
+
|
|
119
|
+
// Create session manager (equivalent to CLI's --session or --no-session)
|
|
120
|
+
const sessionManager = payload.sessionFile
|
|
121
|
+
? await SessionManager.open(payload.sessionFile)
|
|
122
|
+
: SessionManager.inMemory(payload.cwd);
|
|
123
|
+
|
|
124
|
+
// Create agent session (equivalent to CLI's createAgentSession)
|
|
125
|
+
// Note: hasUI: false disables interactive features
|
|
126
|
+
const { session } = await createAgentSession({
|
|
127
|
+
cwd: payload.cwd,
|
|
128
|
+
authStorage,
|
|
129
|
+
modelRegistry,
|
|
130
|
+
model,
|
|
131
|
+
thinkingLevel,
|
|
132
|
+
toolNames: payload.toolNames,
|
|
133
|
+
// Append system prompt (equivalent to CLI's --append-system-prompt)
|
|
134
|
+
systemPrompt: (defaultPrompt) => `${defaultPrompt}\n\n${payload.systemPrompt}`,
|
|
135
|
+
sessionManager,
|
|
136
|
+
hasUI: false,
|
|
137
|
+
// Pass spawn restrictions to nested tasks
|
|
138
|
+
spawns: payload.spawnsEnv,
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
activeSession = session;
|
|
142
|
+
|
|
143
|
+
// Initialize extensions (equivalent to CLI's extension initialization)
|
|
144
|
+
// Note: Does not support --extension CLI flag or extension CLI flags
|
|
145
|
+
const extensionRunner = session.extensionRunner;
|
|
146
|
+
if (extensionRunner) {
|
|
147
|
+
extensionRunner.initialize({
|
|
148
|
+
getModel: () => session.model,
|
|
149
|
+
sendMessageHandler: (message, options) => {
|
|
150
|
+
session.sendCustomMessage(message, options).catch((e) => {
|
|
151
|
+
console.error(`Extension sendMessage failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
152
|
+
});
|
|
153
|
+
},
|
|
154
|
+
appendEntryHandler: (customType, data) => {
|
|
155
|
+
session.sessionManager.appendCustomEntry(customType, data);
|
|
156
|
+
},
|
|
157
|
+
getActiveToolsHandler: () => session.getActiveToolNames(),
|
|
158
|
+
getAllToolsHandler: () => session.getAllToolNames(),
|
|
159
|
+
setActiveToolsHandler: (toolNamesList: string[]) => session.setActiveToolsByName(toolNamesList),
|
|
160
|
+
});
|
|
161
|
+
extensionRunner.onError((err) => {
|
|
162
|
+
console.error(`Extension error (${err.extensionPath}): ${err.error}`);
|
|
163
|
+
});
|
|
164
|
+
await extensionRunner.emit({ type: "session_start" });
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Subscribe to events and forward to parent (equivalent to --mode json output)
|
|
168
|
+
session.subscribe((event: AgentSessionEvent) => {
|
|
169
|
+
if (isAgentEvent(event)) {
|
|
170
|
+
postMessageSafe({ type: "event", event });
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// Run the prompt (equivalent to --prompt flag)
|
|
175
|
+
await session.prompt(payload.task);
|
|
176
|
+
|
|
177
|
+
// Check if aborted during execution
|
|
178
|
+
const lastMessage = session.state.messages[session.state.messages.length - 1];
|
|
179
|
+
if (lastMessage?.role === "assistant" && lastMessage.stopReason === "aborted") {
|
|
180
|
+
aborted = true;
|
|
181
|
+
exitCode = 1;
|
|
182
|
+
}
|
|
183
|
+
} catch (err) {
|
|
184
|
+
exitCode = 1;
|
|
185
|
+
error = err instanceof Error ? err.stack || err.message : String(err);
|
|
186
|
+
} finally {
|
|
187
|
+
// Handle abort requested during execution
|
|
188
|
+
if (abortRequested) {
|
|
189
|
+
aborted = true;
|
|
190
|
+
if (exitCode === 0) exitCode = 1;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Cleanup session
|
|
194
|
+
if (activeSession) {
|
|
195
|
+
try {
|
|
196
|
+
await activeSession.dispose();
|
|
197
|
+
} catch {
|
|
198
|
+
// Ignore cleanup errors
|
|
199
|
+
}
|
|
200
|
+
activeSession = null;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Send completion message to parent
|
|
204
|
+
postMessageSafe({
|
|
205
|
+
type: "done",
|
|
206
|
+
exitCode,
|
|
207
|
+
durationMs: Date.now() - startTime,
|
|
208
|
+
error,
|
|
209
|
+
aborted,
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Handle abort request from parent */
|
|
215
|
+
function handleAbort(): void {
|
|
216
|
+
abortRequested = true;
|
|
217
|
+
if (activeSession) {
|
|
218
|
+
void activeSession.abort();
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Message handler - receives start/abort commands from parent
|
|
223
|
+
globalThis.addEventListener("message", (event: WorkerMessageEvent<SubagentWorkerRequest>) => {
|
|
224
|
+
const message = event.data;
|
|
225
|
+
if (!message) return;
|
|
226
|
+
|
|
227
|
+
if (message.type === "abort") {
|
|
228
|
+
handleAbort();
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (message.type === "start") {
|
|
233
|
+
// Only allow one task per worker
|
|
234
|
+
if (running) return;
|
|
235
|
+
running = true;
|
|
236
|
+
void runTask(message.payload);
|
|
237
|
+
}
|
|
238
|
+
});
|
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
* - Line limit (default: 2000 lines)
|
|
6
6
|
* - Byte limit (default: 50KB)
|
|
7
7
|
*
|
|
8
|
-
* Never returns partial lines (except bash tail truncation edge case
|
|
8
|
+
* Never returns partial lines (except bash tail truncation edge case
|
|
9
|
+
* and the read tool's long-line snippet fallback).
|
|
9
10
|
*/
|
|
10
11
|
|
|
11
12
|
export const DEFAULT_MAX_LINES = 2000;
|
|
@@ -250,6 +251,31 @@ function truncateStringToBytesFromEnd(str: string, maxBytes: number): string {
|
|
|
250
251
|
return buf.slice(start).toString("utf-8");
|
|
251
252
|
}
|
|
252
253
|
|
|
254
|
+
/**
|
|
255
|
+
* Truncate a string to fit within a byte limit (from the start).
|
|
256
|
+
* Handles multi-byte UTF-8 characters correctly.
|
|
257
|
+
*/
|
|
258
|
+
export function truncateStringToBytesFromStart(str: string, maxBytes: number): { text: string; bytes: number } {
|
|
259
|
+
const buf = Buffer.from(str, "utf-8");
|
|
260
|
+
if (buf.length <= maxBytes) {
|
|
261
|
+
return { text: str, bytes: buf.length };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
let end = maxBytes;
|
|
265
|
+
|
|
266
|
+
// Find a valid UTF-8 boundary (start of a character)
|
|
267
|
+
while (end > 0 && (buf[end] & 0xc0) === 0x80) {
|
|
268
|
+
end--;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (end <= 0) {
|
|
272
|
+
return { text: "", bytes: 0 };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const text = buf.slice(0, end).toString("utf-8");
|
|
276
|
+
return { text, bytes: Buffer.byteLength(text, "utf-8") };
|
|
277
|
+
}
|
|
278
|
+
|
|
253
279
|
/**
|
|
254
280
|
* Truncate a single line to max characters, adding [truncated] suffix.
|
|
255
281
|
* Used for grep match lines.
|
|
@@ -5,6 +5,7 @@ import { Type } from "@sinclair/typebox";
|
|
|
5
5
|
import { parse as parseHtml } from "node-html-parser";
|
|
6
6
|
import webFetchDescription from "../../prompts/tools/web-fetch.md" with { type: "text" };
|
|
7
7
|
import { logger } from "../logger";
|
|
8
|
+
import type { ToolSession } from "./index";
|
|
8
9
|
|
|
9
10
|
// =============================================================================
|
|
10
11
|
// Types and Constants
|
|
@@ -66,8 +67,6 @@ const CONVERTIBLE_EXTENSIONS = new Set([
|
|
|
66
67
|
".ogg",
|
|
67
68
|
]);
|
|
68
69
|
|
|
69
|
-
const isWindows = process.platform === "win32";
|
|
70
|
-
|
|
71
70
|
const USER_AGENTS = [
|
|
72
71
|
"curl/8.0",
|
|
73
72
|
"Mozilla/5.0 (compatible; TextBot/1.0)",
|
|
@@ -211,13 +210,7 @@ function exec(
|
|
|
211
210
|
* Check if a command exists (cross-platform)
|
|
212
211
|
*/
|
|
213
212
|
function hasCommand(cmd: string): boolean {
|
|
214
|
-
|
|
215
|
-
const result = Bun.spawnSync([checkCmd, cmd], {
|
|
216
|
-
stdin: "ignore",
|
|
217
|
-
stdout: "pipe",
|
|
218
|
-
stderr: "pipe",
|
|
219
|
-
});
|
|
220
|
-
return result.exitCode === 0;
|
|
213
|
+
return Boolean(Bun.which(cmd));
|
|
221
214
|
}
|
|
222
215
|
|
|
223
216
|
/**
|
|
@@ -626,7 +619,18 @@ async function fetchBinary(
|
|
|
626
619
|
|
|
627
620
|
const contentType = response.headers.get("content-type") ?? "";
|
|
628
621
|
const contentDisposition = response.headers.get("content-disposition") ?? undefined;
|
|
622
|
+
const contentLength = response.headers.get("content-length");
|
|
623
|
+
if (contentLength) {
|
|
624
|
+
const size = Number.parseInt(contentLength, 10);
|
|
625
|
+
if (Number.isFinite(size) && size > MAX_BYTES) {
|
|
626
|
+
return { buffer: Buffer.alloc(0), contentType, contentDisposition, ok: false };
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
629
630
|
const buffer = Buffer.from(await response.arrayBuffer());
|
|
631
|
+
if (buffer.length > MAX_BYTES) {
|
|
632
|
+
return { buffer: Buffer.alloc(0), contentType, contentDisposition, ok: false };
|
|
633
|
+
}
|
|
630
634
|
|
|
631
635
|
return { buffer, contentType, contentDisposition, ok: true };
|
|
632
636
|
} catch {
|
|
@@ -1236,9 +1240,7 @@ async function handleStackOverflow(url: string, timeout: number): Promise<Render
|
|
|
1236
1240
|
md += `**Score:** ${question.score} · **Answers:** ${question.answer_count}`;
|
|
1237
1241
|
md += question.is_answered ? " (Answered)" : "";
|
|
1238
1242
|
md += `\n**Tags:** ${question.tags.join(", ")}\n`;
|
|
1239
|
-
md += `**Asked by:** ${question.owner.display_name} · ${
|
|
1240
|
-
new Date(question.creation_date * 1000).toISOString().split("T")[0]
|
|
1241
|
-
}\n\n`;
|
|
1243
|
+
md += `**Asked by:** ${question.owner.display_name} · ${new Date(question.creation_date * 1000).toISOString().split("T")[0]}\n\n`;
|
|
1242
1244
|
md += `---\n\n## Question\n\n${htmlToBasicMarkdown(question.body)}\n\n`;
|
|
1243
1245
|
|
|
1244
1246
|
// Fetch answers
|
|
@@ -1959,16 +1961,16 @@ async function renderUrl(url: string, timeout: number, raw: boolean = false): Pr
|
|
|
1959
1961
|
const notes: string[] = [];
|
|
1960
1962
|
const fetchedAt = new Date().toISOString();
|
|
1961
1963
|
|
|
1962
|
-
// Step 0:
|
|
1964
|
+
// Step 0: Normalize URL (ensure scheme for special handlers)
|
|
1965
|
+
url = normalizeUrl(url);
|
|
1966
|
+
const origin = getOrigin(url);
|
|
1967
|
+
|
|
1968
|
+
// Step 1: Try special handlers for known sites (unless raw mode)
|
|
1963
1969
|
if (!raw) {
|
|
1964
1970
|
const specialResult = await handleSpecialUrls(url, timeout);
|
|
1965
1971
|
if (specialResult) return specialResult;
|
|
1966
1972
|
}
|
|
1967
1973
|
|
|
1968
|
-
// Step 1: Normalize URL
|
|
1969
|
-
url = normalizeUrl(url);
|
|
1970
|
-
const origin = getOrigin(url);
|
|
1971
|
-
|
|
1972
1974
|
// Step 2: Fetch page
|
|
1973
1975
|
const response = await loadPage(url, { timeout });
|
|
1974
1976
|
if (!response.ok) {
|
|
@@ -2267,10 +2269,10 @@ export interface WebFetchToolDetails {
|
|
|
2267
2269
|
notes: string[];
|
|
2268
2270
|
}
|
|
2269
2271
|
|
|
2270
|
-
export function createWebFetchTool(
|
|
2272
|
+
export function createWebFetchTool(_session: ToolSession): AgentTool<typeof webFetchSchema> {
|
|
2271
2273
|
return {
|
|
2272
2274
|
name: "web_fetch",
|
|
2273
|
-
label: "
|
|
2275
|
+
label: "Web Fetch",
|
|
2274
2276
|
description: webFetchDescription,
|
|
2275
2277
|
parameters: webFetchSchema,
|
|
2276
2278
|
execute: async (
|
|
@@ -2313,9 +2315,6 @@ export function createWebFetchTool(_cwd: string): AgentTool<typeof webFetchSchem
|
|
|
2313
2315
|
};
|
|
2314
2316
|
}
|
|
2315
2317
|
|
|
2316
|
-
/** Default web fetch tool using process.cwd() - for backwards compatibility */
|
|
2317
|
-
export const webFetchTool = createWebFetchTool(process.cwd());
|
|
2318
|
-
|
|
2319
2318
|
// =============================================================================
|
|
2320
2319
|
// TUI Rendering
|
|
2321
2320
|
// =============================================================================
|
|
@@ -2323,7 +2322,7 @@ export const webFetchTool = createWebFetchTool(process.cwd());
|
|
|
2323
2322
|
import type { Component } from "@oh-my-pi/pi-tui";
|
|
2324
2323
|
import { Text } from "@oh-my-pi/pi-tui";
|
|
2325
2324
|
import { type Theme, theme } from "../../modes/interactive/theme/theme";
|
|
2326
|
-
import type {
|
|
2325
|
+
import type { RenderResultOptions } from "../custom-tools/types";
|
|
2327
2326
|
|
|
2328
2327
|
/** Truncate text to max length with ellipsis */
|
|
2329
2328
|
function truncate(text: string, maxLen: number, ellipsis: string): string {
|
|
@@ -2483,30 +2482,7 @@ export function renderWebFetchResult(
|
|
|
2483
2482
|
return new Text(text, 0, 0);
|
|
2484
2483
|
}
|
|
2485
2484
|
|
|
2486
|
-
|
|
2487
|
-
|
|
2488
|
-
|
|
2489
|
-
export const webFetchCustomTool: CustomTool<typeof webFetchSchema, WebFetchToolDetails> = {
|
|
2490
|
-
name: "web_fetch",
|
|
2491
|
-
label: "Web Fetch",
|
|
2492
|
-
description: webFetchDescription,
|
|
2493
|
-
parameters: webFetchSchema,
|
|
2494
|
-
|
|
2495
|
-
async execute(
|
|
2496
|
-
toolCallId: string,
|
|
2497
|
-
params: WebFetchParams,
|
|
2498
|
-
_onUpdate,
|
|
2499
|
-
_ctx: CustomToolContext,
|
|
2500
|
-
_signal?: AbortSignal,
|
|
2501
|
-
) {
|
|
2502
|
-
return webFetchTool.execute(toolCallId, params);
|
|
2503
|
-
},
|
|
2504
|
-
|
|
2505
|
-
renderCall(args: WebFetchParams, uiTheme: Theme) {
|
|
2506
|
-
return renderWebFetchCall(args, uiTheme);
|
|
2507
|
-
},
|
|
2508
|
-
|
|
2509
|
-
renderResult(result, options: RenderResultOptions, uiTheme: Theme) {
|
|
2510
|
-
return renderWebFetchResult(result, options, uiTheme);
|
|
2511
|
-
},
|
|
2485
|
+
export const webFetchToolRenderer = {
|
|
2486
|
+
renderCall: renderWebFetchCall,
|
|
2487
|
+
renderResult: renderWebFetchResult,
|
|
2512
2488
|
};
|
|
@@ -20,12 +20,15 @@ import type { CustomTool, CustomToolContext, RenderResultOptions } from "../../c
|
|
|
20
20
|
import { callExaTool, findApiKey as findExaKey, formatSearchResults, isSearchResponse } from "../exa/mcp-client";
|
|
21
21
|
import { renderExaCall, renderExaResult } from "../exa/render";
|
|
22
22
|
import type { ExaRenderDetails } from "../exa/types";
|
|
23
|
+
import type { ToolSession } from "../index";
|
|
23
24
|
import { formatAge } from "../render-utils";
|
|
25
|
+
import { findAnthropicAuth } from "./auth";
|
|
24
26
|
import { searchAnthropic } from "./providers/anthropic";
|
|
25
27
|
import { searchExa } from "./providers/exa";
|
|
26
28
|
import { findApiKey as findPerplexityKey, searchPerplexity } from "./providers/perplexity";
|
|
27
29
|
import { renderWebSearchCall, renderWebSearchResult, type WebSearchRenderDetails } from "./render";
|
|
28
30
|
import type { WebSearchProvider, WebSearchResponse } from "./types";
|
|
31
|
+
import { WebSearchProviderError } from "./types";
|
|
29
32
|
|
|
30
33
|
/** Web search parameters schema */
|
|
31
34
|
export const webSearchSchema = Type.Object({
|
|
@@ -95,18 +98,78 @@ export type WebSearchParams = {
|
|
|
95
98
|
return_related_questions?: boolean;
|
|
96
99
|
};
|
|
97
100
|
|
|
98
|
-
/**
|
|
99
|
-
|
|
100
|
-
|
|
101
|
+
/** Preferred provider set via settings (default: auto) */
|
|
102
|
+
let preferredProvider: WebSearchProvider | "auto" = "auto";
|
|
103
|
+
|
|
104
|
+
/** Set the preferred web search provider from settings */
|
|
105
|
+
export function setPreferredWebSearchProvider(provider: WebSearchProvider | "auto"): void {
|
|
106
|
+
preferredProvider = provider;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Determine which providers are configured (priority order) */
|
|
110
|
+
async function getAvailableProviders(): Promise<WebSearchProvider[]> {
|
|
111
|
+
const providers: WebSearchProvider[] = [];
|
|
112
|
+
|
|
101
113
|
const exaKey = await findExaKey();
|
|
102
|
-
if (exaKey)
|
|
114
|
+
if (exaKey) providers.push("exa");
|
|
103
115
|
|
|
104
|
-
// Perplexity second priority
|
|
105
116
|
const perplexityKey = await findPerplexityKey();
|
|
106
|
-
if (perplexityKey)
|
|
117
|
+
if (perplexityKey) providers.push("perplexity");
|
|
118
|
+
|
|
119
|
+
const anthropicAuth = await findAnthropicAuth();
|
|
120
|
+
if (anthropicAuth) providers.push("anthropic");
|
|
121
|
+
|
|
122
|
+
return providers;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function formatProviderLabel(provider: WebSearchProvider): string {
|
|
126
|
+
switch (provider) {
|
|
127
|
+
case "exa":
|
|
128
|
+
return "Exa";
|
|
129
|
+
case "perplexity":
|
|
130
|
+
return "Perplexity";
|
|
131
|
+
case "anthropic":
|
|
132
|
+
return "Anthropic";
|
|
133
|
+
default:
|
|
134
|
+
return provider;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function formatProviderList(providers: WebSearchProvider[]): string {
|
|
139
|
+
return providers.map((provider) => formatProviderLabel(provider)).join(", ");
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function buildNoProviderError(): string {
|
|
143
|
+
return "No web search provider configured. Set EXA_API_KEY, PERPLEXITY_API_KEY, ANTHROPIC_SEARCH_API_KEY, or ANTHROPIC_API_KEY.";
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function formatProviderError(error: unknown, provider: WebSearchProvider): string {
|
|
147
|
+
if (error instanceof WebSearchProviderError) {
|
|
148
|
+
if (error.provider === "anthropic" && error.status === 404) {
|
|
149
|
+
return "Anthropic web search returned 404 (model or endpoint not found). Set ANTHROPIC_SEARCH_MODEL/ANTHROPIC_SEARCH_BASE_URL, or configure EXA_API_KEY or PERPLEXITY_API_KEY.";
|
|
150
|
+
}
|
|
151
|
+
if (error.status === 401 || error.status === 403) {
|
|
152
|
+
return `${formatProviderLabel(error.provider)} authorization failed (${error.status}). Check API key or base URL.`;
|
|
153
|
+
}
|
|
154
|
+
return error.message;
|
|
155
|
+
}
|
|
156
|
+
if (error instanceof Error) return error.message;
|
|
157
|
+
return `Unknown error from ${formatProviderLabel(provider)}`;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function resolveProviderChain(
|
|
161
|
+
requestedProvider?: WebSearchProvider | "auto",
|
|
162
|
+
): Promise<{ providers: WebSearchProvider[]; allowFallback: boolean }> {
|
|
163
|
+
if (requestedProvider && requestedProvider !== "auto") {
|
|
164
|
+
return { providers: [requestedProvider], allowFallback: false };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (preferredProvider !== "auto") {
|
|
168
|
+
return { providers: [preferredProvider], allowFallback: false };
|
|
169
|
+
}
|
|
107
170
|
|
|
108
|
-
|
|
109
|
-
return
|
|
171
|
+
const providers = await getAvailableProviders();
|
|
172
|
+
return { providers, allowFallback: true };
|
|
110
173
|
}
|
|
111
174
|
|
|
112
175
|
/** Truncate text for tool output */
|
|
@@ -198,48 +261,71 @@ async function executeWebSearch(
|
|
|
198
261
|
_toolCallId: string,
|
|
199
262
|
params: WebSearchParams,
|
|
200
263
|
): Promise<{ content: Array<{ type: "text"; text: string }>; details: WebSearchRenderDetails }> {
|
|
201
|
-
|
|
202
|
-
const provider = params.provider && params.provider !== "auto" ? params.provider : await detectProvider();
|
|
203
|
-
|
|
204
|
-
let response: WebSearchResponse;
|
|
205
|
-
if (provider === "exa") {
|
|
206
|
-
response = await searchExa({
|
|
207
|
-
query: params.query,
|
|
208
|
-
num_results: params.num_results,
|
|
209
|
-
});
|
|
210
|
-
} else if (provider === "anthropic") {
|
|
211
|
-
response = await searchAnthropic({
|
|
212
|
-
query: params.query,
|
|
213
|
-
system_prompt: params.system_prompt,
|
|
214
|
-
max_tokens: params.max_tokens,
|
|
215
|
-
num_results: params.num_results,
|
|
216
|
-
});
|
|
217
|
-
} else {
|
|
218
|
-
response = await searchPerplexity({
|
|
219
|
-
query: params.query,
|
|
220
|
-
model: params.model,
|
|
221
|
-
system_prompt: params.system_prompt,
|
|
222
|
-
search_recency_filter: params.search_recency_filter,
|
|
223
|
-
search_domain_filter: params.search_domain_filter,
|
|
224
|
-
search_context_size: params.search_context_size,
|
|
225
|
-
return_related_questions: params.return_related_questions,
|
|
226
|
-
num_results: params.num_results,
|
|
227
|
-
});
|
|
228
|
-
}
|
|
264
|
+
const { providers, allowFallback } = await resolveProviderChain(params.provider);
|
|
229
265
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
content: [{ type: "text" as const, text }],
|
|
234
|
-
details: { response },
|
|
235
|
-
};
|
|
236
|
-
} catch (error) {
|
|
237
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
266
|
+
if (providers.length === 0) {
|
|
267
|
+
const message = buildNoProviderError();
|
|
268
|
+
const fallbackProvider = preferredProvider === "auto" ? "anthropic" : preferredProvider;
|
|
238
269
|
return {
|
|
239
270
|
content: [{ type: "text" as const, text: `Error: ${message}` }],
|
|
240
|
-
details: { response: { provider:
|
|
271
|
+
details: { response: { provider: fallbackProvider, sources: [] }, error: message },
|
|
241
272
|
};
|
|
242
273
|
}
|
|
274
|
+
|
|
275
|
+
let lastError: unknown;
|
|
276
|
+
let lastProvider = providers[0];
|
|
277
|
+
|
|
278
|
+
for (const provider of providers) {
|
|
279
|
+
lastProvider = provider;
|
|
280
|
+
try {
|
|
281
|
+
let response: WebSearchResponse;
|
|
282
|
+
if (provider === "exa") {
|
|
283
|
+
response = await searchExa({
|
|
284
|
+
query: params.query,
|
|
285
|
+
num_results: params.num_results,
|
|
286
|
+
});
|
|
287
|
+
} else if (provider === "anthropic") {
|
|
288
|
+
response = await searchAnthropic({
|
|
289
|
+
query: params.query,
|
|
290
|
+
system_prompt: params.system_prompt,
|
|
291
|
+
max_tokens: params.max_tokens,
|
|
292
|
+
num_results: params.num_results,
|
|
293
|
+
});
|
|
294
|
+
} else {
|
|
295
|
+
response = await searchPerplexity({
|
|
296
|
+
query: params.query,
|
|
297
|
+
model: params.model,
|
|
298
|
+
system_prompt: params.system_prompt,
|
|
299
|
+
search_recency_filter: params.search_recency_filter,
|
|
300
|
+
search_domain_filter: params.search_domain_filter,
|
|
301
|
+
search_context_size: params.search_context_size,
|
|
302
|
+
return_related_questions: params.return_related_questions,
|
|
303
|
+
num_results: params.num_results,
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const text = formatForLLM(response);
|
|
308
|
+
|
|
309
|
+
return {
|
|
310
|
+
content: [{ type: "text" as const, text }],
|
|
311
|
+
details: { response },
|
|
312
|
+
};
|
|
313
|
+
} catch (error) {
|
|
314
|
+
lastError = error;
|
|
315
|
+
if (!allowFallback) break;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const baseMessage = formatProviderError(lastError, lastProvider);
|
|
320
|
+
const message =
|
|
321
|
+
allowFallback && providers.length > 1
|
|
322
|
+
? `All web search providers failed (${formatProviderList(providers)}). Last error: ${baseMessage}`
|
|
323
|
+
: baseMessage;
|
|
324
|
+
|
|
325
|
+
return {
|
|
326
|
+
content: [{ type: "text" as const, text: `Error: ${message}` }],
|
|
327
|
+
details: { response: { provider: lastProvider, sources: [] }, error: message },
|
|
328
|
+
};
|
|
243
329
|
}
|
|
244
330
|
|
|
245
331
|
/** Web search tool as AgentTool (for allTools export) */
|
|
@@ -280,7 +366,7 @@ export const webSearchCustomTool: CustomTool<typeof webSearchSchema, WebSearchRe
|
|
|
280
366
|
};
|
|
281
367
|
|
|
282
368
|
/** Factory function for backward compatibility */
|
|
283
|
-
export function createWebSearchTool(
|
|
369
|
+
export function createWebSearchTool(_session: ToolSession): AgentTool<typeof webSearchSchema> {
|
|
284
370
|
return webSearchTool;
|
|
285
371
|
}
|
|
286
372
|
|
|
@@ -14,8 +14,9 @@ import type {
|
|
|
14
14
|
WebSearchResponse,
|
|
15
15
|
WebSearchSource,
|
|
16
16
|
} from "../types";
|
|
17
|
+
import { WebSearchProviderError } from "../types";
|
|
17
18
|
|
|
18
|
-
const DEFAULT_MODEL = "claude-
|
|
19
|
+
const DEFAULT_MODEL = "claude-haiku-4-5";
|
|
19
20
|
const DEFAULT_MAX_TOKENS = 4096;
|
|
20
21
|
|
|
21
22
|
export interface AnthropicSearchParams {
|
|
@@ -80,7 +81,11 @@ async function callWebSearch(
|
|
|
80
81
|
|
|
81
82
|
if (!response.ok) {
|
|
82
83
|
const errorText = await response.text();
|
|
83
|
-
throw new
|
|
84
|
+
throw new WebSearchProviderError(
|
|
85
|
+
"anthropic",
|
|
86
|
+
`Anthropic API error (${response.status}): ${errorText}`,
|
|
87
|
+
response.status,
|
|
88
|
+
);
|
|
84
89
|
}
|
|
85
90
|
|
|
86
91
|
return response.json() as Promise<AnthropicApiResponse>;
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import { existsSync, readFileSync } from "node:fs";
|
|
9
9
|
import { homedir } from "node:os";
|
|
10
10
|
import type { WebSearchResponse, WebSearchSource } from "../types";
|
|
11
|
+
import { WebSearchProviderError } from "../types";
|
|
11
12
|
|
|
12
13
|
const EXA_API_URL = "https://api.exa.ai/search";
|
|
13
14
|
|
|
@@ -142,7 +143,7 @@ async function callExaSearch(apiKey: string, params: ExaSearchParams): Promise<E
|
|
|
142
143
|
|
|
143
144
|
if (!response.ok) {
|
|
144
145
|
const errorText = await response.text();
|
|
145
|
-
throw new
|
|
146
|
+
throw new WebSearchProviderError("exa", `Exa API error (${response.status}): ${errorText}`, response.status);
|
|
146
147
|
}
|
|
147
148
|
|
|
148
149
|
return response.json() as Promise<ExaSearchResponse>;
|