@xynogen/pix-9router 0.3.4 → 0.3.6
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 +4 -0
- package/package.json +4 -2
- package/src/fetch.ts +190 -79
- package/src/format.test.ts +332 -3
- package/src/render.test.ts +114 -25
- package/src/render.ts +49 -6
- package/src/search.ts +205 -99
- package/src/transcribe.test.ts +127 -6
- package/src/transcribe.ts +93 -10
package/src/render.test.ts
CHANGED
|
@@ -18,61 +18,150 @@ function stubComponent() {
|
|
|
18
18
|
return { component, get: () => captured };
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
function context(stub: ReturnType<typeof stubComponent>, overrides: Record<string, unknown> = {}) {
|
|
22
|
+
return {
|
|
23
|
+
lastComponent: stub.component as never,
|
|
24
|
+
isError: false,
|
|
25
|
+
state: {},
|
|
26
|
+
expanded: false,
|
|
27
|
+
invalidate: () => {},
|
|
28
|
+
...overrides,
|
|
29
|
+
} as never;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const config = {
|
|
33
|
+
tool: "fetch",
|
|
34
|
+
target: (_details: { _type: "fetchResult"; chars: number }) => "https://example.com",
|
|
35
|
+
meta: (details: { _type: "fetchResult"; chars: number }) => `${details.chars} chars`,
|
|
36
|
+
};
|
|
37
|
+
|
|
21
38
|
test("renderCall shows title + accent arg", () => {
|
|
22
39
|
const stub = stubComponent();
|
|
23
40
|
const rc = makeRenderCall<{ url: string }>("fetch", (a) => a.url);
|
|
24
|
-
rc({ url: "https://x.com" }, theme,
|
|
25
|
-
lastComponent: stub.component as never,
|
|
26
|
-
isError: false,
|
|
27
|
-
});
|
|
41
|
+
rc({ url: "https://x.com" }, theme, context(stub));
|
|
28
42
|
expect(stub.get()).toBe("[toolTitle]*fetch* [accent]https://x.com");
|
|
29
43
|
});
|
|
30
44
|
|
|
31
|
-
test("
|
|
45
|
+
test("renderCall hides only an effectively collapsed call row", () => {
|
|
46
|
+
const stub = stubComponent();
|
|
47
|
+
const rc = makeRenderCall<{ url: string }>("fetch", (a) => a.url);
|
|
48
|
+
rc({ url: "https://x.com" }, theme, context(stub, { state: { collapsed: true } }));
|
|
49
|
+
expect(stub.get()).toBe("");
|
|
50
|
+
|
|
51
|
+
rc(
|
|
52
|
+
{ url: "https://x.com" },
|
|
53
|
+
theme,
|
|
54
|
+
context(stub, { state: { collapsed: true }, expanded: true }),
|
|
55
|
+
);
|
|
56
|
+
expect(stub.get()).toBe("[toolTitle]*fetch* [accent]https://x.com");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("renderResult renders structured compact details after collapse", () => {
|
|
60
|
+
const stub = stubComponent();
|
|
61
|
+
const rr = makeRenderResult(config);
|
|
62
|
+
rr(
|
|
63
|
+
{
|
|
64
|
+
content: [{ type: "text", text: "full body" }],
|
|
65
|
+
details: { _type: "fetchResult", chars: 9 },
|
|
66
|
+
} as never,
|
|
67
|
+
{ expanded: false, isPartial: false },
|
|
68
|
+
theme,
|
|
69
|
+
context(stub, { state: { collapsed: true } }),
|
|
70
|
+
);
|
|
71
|
+
expect(stub.get()).toContain("[success]✓");
|
|
72
|
+
expect(stub.get()).toContain("https://example.com");
|
|
73
|
+
expect(stub.get()).toContain("9 chars");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("partial updates keep detail visible and do not schedule collapse", () => {
|
|
32
77
|
const stub = stubComponent();
|
|
33
|
-
const
|
|
78
|
+
const state: Record<string, unknown> = {};
|
|
79
|
+
const rr = makeRenderResult(config);
|
|
34
80
|
rr(
|
|
35
|
-
{
|
|
81
|
+
{
|
|
82
|
+
content: [{ type: "text", text: "streaming body" }],
|
|
83
|
+
details: { _type: "fetchResult", chars: 14 },
|
|
84
|
+
} as never,
|
|
85
|
+
{ expanded: false, isPartial: true },
|
|
86
|
+
theme,
|
|
87
|
+
context(stub, { state }),
|
|
88
|
+
);
|
|
89
|
+
expect(stub.get()).toContain("streaming body");
|
|
90
|
+
expect(state.timer).toBeUndefined();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("expanded mode restores full joined text", () => {
|
|
94
|
+
const stub = stubComponent();
|
|
95
|
+
const rr = makeRenderResult(config);
|
|
96
|
+
rr(
|
|
97
|
+
{
|
|
98
|
+
content: [
|
|
99
|
+
{ type: "text", text: "first" },
|
|
100
|
+
{ type: "image", data: "ignored", mimeType: "image/png" },
|
|
101
|
+
{ type: "text", text: "second" },
|
|
102
|
+
],
|
|
103
|
+
details: { _type: "fetchResult", chars: 12 },
|
|
104
|
+
} as never,
|
|
36
105
|
{ expanded: true, isPartial: false },
|
|
37
106
|
theme,
|
|
38
|
-
{
|
|
107
|
+
context(stub, { state: { collapsed: true }, expanded: true }),
|
|
39
108
|
);
|
|
40
|
-
expect(stub.get()).toBe(" [dim]
|
|
109
|
+
expect(stub.get()).toBe(" [dim]first\n [dim]second");
|
|
41
110
|
});
|
|
42
111
|
|
|
43
|
-
test("renderResult caps
|
|
112
|
+
test("renderResult caps the normal preview", () => {
|
|
44
113
|
const stub = stubComponent();
|
|
45
|
-
const rr = makeRenderResult();
|
|
114
|
+
const rr = makeRenderResult(config);
|
|
46
115
|
const body = Array.from({ length: 37 }, (_, i) => `L${i}`).join("\n");
|
|
47
116
|
rr(
|
|
48
|
-
{
|
|
117
|
+
{
|
|
118
|
+
content: [{ type: "text", text: body }],
|
|
119
|
+
details: { _type: "fetchResult", chars: body.length },
|
|
120
|
+
} as never,
|
|
49
121
|
{ expanded: false, isPartial: false },
|
|
50
122
|
theme,
|
|
51
|
-
{
|
|
123
|
+
context(stub, { state: { timer: 1 } }),
|
|
52
124
|
);
|
|
53
|
-
expect(stub.get()).toContain("… 5 more lines");
|
|
125
|
+
expect(stub.get()).toContain("… 5 more lines");
|
|
54
126
|
});
|
|
55
127
|
|
|
56
|
-
test("
|
|
57
|
-
const
|
|
58
|
-
|
|
128
|
+
test("warning and error callbacks select shared status glyphs", () => {
|
|
129
|
+
for (const [status, glyph] of [
|
|
130
|
+
["warning", "⚡"],
|
|
131
|
+
["error", "✗"],
|
|
132
|
+
] as const) {
|
|
133
|
+
const stub = stubComponent();
|
|
134
|
+
const rr = makeRenderResult({ ...config, status: () => status });
|
|
135
|
+
rr(
|
|
136
|
+
{
|
|
137
|
+
content: [{ type: "text", text: "body" }],
|
|
138
|
+
details: { _type: "fetchResult", chars: 4 },
|
|
139
|
+
} as never,
|
|
140
|
+
{ expanded: false, isPartial: false },
|
|
141
|
+
theme,
|
|
142
|
+
context(stub, { state: { collapsed: true } }),
|
|
143
|
+
);
|
|
144
|
+
expect(stub.get()).toContain(`[${status}]${glyph}`);
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("renderResult renders empty and errors without compacting", () => {
|
|
149
|
+
const rr = makeRenderResult(config);
|
|
150
|
+
const empty = stubComponent();
|
|
59
151
|
rr(
|
|
60
152
|
{ content: [{ type: "text", text: " " }] } as never,
|
|
61
153
|
{ expanded: false, isPartial: false },
|
|
62
154
|
theme,
|
|
63
|
-
|
|
155
|
+
context(empty),
|
|
64
156
|
);
|
|
65
|
-
expect(
|
|
66
|
-
});
|
|
157
|
+
expect(empty.get()).toBe(" [dim](empty)");
|
|
67
158
|
|
|
68
|
-
|
|
69
|
-
const stub = stubComponent();
|
|
70
|
-
const rr = makeRenderResult();
|
|
159
|
+
const failed = stubComponent();
|
|
71
160
|
rr(
|
|
72
161
|
{ content: [{ type: "text", text: "boom" }] } as never,
|
|
73
162
|
{ expanded: false, isPartial: false },
|
|
74
163
|
theme,
|
|
75
|
-
{
|
|
164
|
+
context(failed, { isError: true, state: { collapsed: true } }),
|
|
76
165
|
);
|
|
77
|
-
expect(
|
|
166
|
+
expect(failed.get()).toBe(" [error]boom");
|
|
78
167
|
});
|
package/src/render.ts
CHANGED
|
@@ -16,6 +16,12 @@ import type {
|
|
|
16
16
|
ToolRenderResultOptions,
|
|
17
17
|
} from "@earendil-works/pi-coding-agent";
|
|
18
18
|
import type { Component } from "@earendil-works/pi-tui";
|
|
19
|
+
import { type CollapseState, tickCollapse } from "@xynogen/pix-data/collapse";
|
|
20
|
+
import {
|
|
21
|
+
type CollapsedToolStatus,
|
|
22
|
+
formatCollapsedToolRow,
|
|
23
|
+
hideCollapsedToolCall,
|
|
24
|
+
} from "@xynogen/pix-pretty/utils";
|
|
19
25
|
|
|
20
26
|
interface TextLike extends Component {
|
|
21
27
|
setText(text: string): void;
|
|
@@ -26,6 +32,16 @@ interface TextLike extends Component {
|
|
|
26
32
|
interface RenderCtx {
|
|
27
33
|
lastComponent: Component | undefined;
|
|
28
34
|
isError: boolean;
|
|
35
|
+
state: Record<string, unknown>;
|
|
36
|
+
expanded: boolean;
|
|
37
|
+
invalidate: () => void;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface CompactRendererConfig<TDetails> {
|
|
41
|
+
tool: string;
|
|
42
|
+
target: (details: TDetails) => string;
|
|
43
|
+
meta: (details: TDetails) => string;
|
|
44
|
+
status?: (details: TDetails) => CollapsedToolStatus;
|
|
29
45
|
}
|
|
30
46
|
|
|
31
47
|
type TextCtor = new (text?: string, padX?: number, padY?: number) => TextLike;
|
|
@@ -51,9 +67,11 @@ function getText(lastComponent: Component | undefined): TextLike {
|
|
|
51
67
|
throw new Error("pi-tui Text component unavailable");
|
|
52
68
|
}
|
|
53
69
|
|
|
54
|
-
function
|
|
55
|
-
|
|
56
|
-
|
|
70
|
+
function allText(result: AgentToolResult<unknown>): string {
|
|
71
|
+
return (result.content ?? [])
|
|
72
|
+
.filter((block): block is { type: "text"; text: string } => block.type === "text")
|
|
73
|
+
.map((block) => block.text)
|
|
74
|
+
.join("\n");
|
|
57
75
|
}
|
|
58
76
|
|
|
59
77
|
function dimBody(body: string, theme: Theme, expanded: boolean): string {
|
|
@@ -72,14 +90,20 @@ function dimBody(body: string, theme: Theme, expanded: boolean): string {
|
|
|
72
90
|
export function makeRenderCall<TArgs>(title: string, pickArg: (args: TArgs) => string) {
|
|
73
91
|
return (args: TArgs, theme: Theme, ctx: RenderCtx): Component => {
|
|
74
92
|
const text = getText(ctx.lastComponent);
|
|
93
|
+
if (
|
|
94
|
+
hideCollapsedToolCall(ctx.state as CollapseState, ctx.expanded, (value) =>
|
|
95
|
+
text.setText(value),
|
|
96
|
+
)
|
|
97
|
+
)
|
|
98
|
+
return text;
|
|
75
99
|
const arg = pickArg(args);
|
|
76
100
|
text.setText(`${theme.fg("toolTitle", theme.bold(title))} ${theme.fg("accent", arg)}`);
|
|
77
101
|
return text;
|
|
78
102
|
};
|
|
79
103
|
}
|
|
80
104
|
|
|
81
|
-
/** Build a `renderResult`
|
|
82
|
-
export function makeRenderResult() {
|
|
105
|
+
/** Build a `renderResult` with a bounded detail preview and optional compact terminal row. */
|
|
106
|
+
export function makeRenderResult<TDetails>(config?: CompactRendererConfig<TDetails>) {
|
|
83
107
|
return (
|
|
84
108
|
result: AgentToolResult<unknown>,
|
|
85
109
|
opts: ToolRenderResultOptions,
|
|
@@ -87,7 +111,7 @@ export function makeRenderResult() {
|
|
|
87
111
|
ctx: RenderCtx,
|
|
88
112
|
): Component => {
|
|
89
113
|
const text = getText(ctx.lastComponent);
|
|
90
|
-
const body =
|
|
114
|
+
const body = allText(result);
|
|
91
115
|
|
|
92
116
|
if (ctx.isError) {
|
|
93
117
|
text.setText(` ${theme.fg("error", body || "Error")}`);
|
|
@@ -98,6 +122,25 @@ export function makeRenderResult() {
|
|
|
98
122
|
return text;
|
|
99
123
|
}
|
|
100
124
|
|
|
125
|
+
const details = result.details as TDetails | undefined;
|
|
126
|
+
if (
|
|
127
|
+
!opts.isPartial &&
|
|
128
|
+
config &&
|
|
129
|
+
details &&
|
|
130
|
+
tickCollapse(config.tool, ctx.state as CollapseState, ctx.invalidate, opts.expanded)
|
|
131
|
+
) {
|
|
132
|
+
text.setText(
|
|
133
|
+
formatCollapsedToolRow(
|
|
134
|
+
theme,
|
|
135
|
+
config.tool,
|
|
136
|
+
config.target(details),
|
|
137
|
+
config.meta(details),
|
|
138
|
+
config.status?.(details) ?? "success",
|
|
139
|
+
),
|
|
140
|
+
);
|
|
141
|
+
return text;
|
|
142
|
+
}
|
|
143
|
+
|
|
101
144
|
text.setText(dimBody(body, theme, opts.expanded));
|
|
102
145
|
return text;
|
|
103
146
|
};
|
package/src/search.ts
CHANGED
|
@@ -15,6 +15,35 @@ import { makeRenderCall, makeRenderResult } from "./render.js";
|
|
|
15
15
|
|
|
16
16
|
const REQUEST_TIMEOUT_MS = 30_000;
|
|
17
17
|
|
|
18
|
+
type RouterOutcome = "running" | "fallback" | "success" | "cancelled" | "error";
|
|
19
|
+
type SearchType = "web" | "news";
|
|
20
|
+
|
|
21
|
+
export interface SearchResultDetails {
|
|
22
|
+
_type: "searchResult";
|
|
23
|
+
outcome: RouterOutcome;
|
|
24
|
+
query: string;
|
|
25
|
+
searchType: SearchType;
|
|
26
|
+
source?: "api" | "curl-fallback" | "failed";
|
|
27
|
+
resultCount?: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface SearchParams {
|
|
31
|
+
query: string;
|
|
32
|
+
search_type?: SearchType;
|
|
33
|
+
max_results?: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface SearchResult {
|
|
37
|
+
content: { type: "text"; text: string }[];
|
|
38
|
+
details: SearchResultDetails;
|
|
39
|
+
isError?: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface SearchDependencies {
|
|
43
|
+
apiPost: typeof apiPost;
|
|
44
|
+
curl: typeof curl;
|
|
45
|
+
}
|
|
46
|
+
|
|
18
47
|
function auth(): string | undefined {
|
|
19
48
|
return process.env.ROUTER_API_KEY;
|
|
20
49
|
}
|
|
@@ -84,25 +113,22 @@ interface SearchResultItem {
|
|
|
84
113
|
author?: string | null;
|
|
85
114
|
}
|
|
86
115
|
|
|
87
|
-
/**
|
|
88
|
-
|
|
89
|
-
* Falls back to pretty-printed JSON when the shape is unexpected.
|
|
90
|
-
*/
|
|
91
|
-
export function formatSearchResults(raw: string): string {
|
|
116
|
+
/** Parse and format a raw exa search response with its authoritative result count. */
|
|
117
|
+
export function parseSearchResults(raw: string): { text: string; count: number } {
|
|
92
118
|
let parsed: unknown;
|
|
93
119
|
try {
|
|
94
120
|
parsed = JSON.parse(raw);
|
|
95
121
|
} catch {
|
|
96
|
-
return raw.slice(0, 20_000);
|
|
122
|
+
return { text: raw.slice(0, 20_000), count: 0 };
|
|
97
123
|
}
|
|
98
124
|
|
|
99
125
|
const obj = parsed as Record<string, unknown>;
|
|
100
126
|
const results = obj?.results;
|
|
101
127
|
if (!Array.isArray(results)) {
|
|
102
|
-
return JSON.stringify(parsed, null, 2).slice(0, 20_000);
|
|
128
|
+
return { text: JSON.stringify(parsed, null, 2).slice(0, 20_000), count: 0 };
|
|
103
129
|
}
|
|
104
130
|
|
|
105
|
-
if (results.length === 0) return "No results.";
|
|
131
|
+
if (results.length === 0) return { text: "No results.", count: 0 };
|
|
106
132
|
|
|
107
133
|
const lines: string[] = [];
|
|
108
134
|
results.forEach((r: SearchResultItem, i: number) => {
|
|
@@ -119,13 +145,155 @@ export function formatSearchResults(raw: string): string {
|
|
|
119
145
|
lines.push("");
|
|
120
146
|
});
|
|
121
147
|
|
|
122
|
-
return
|
|
148
|
+
return {
|
|
149
|
+
text: lines.join("\n").trim().slice(0, 20_000),
|
|
150
|
+
count: results.length,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Format a raw exa search response as a compact markdown list. */
|
|
155
|
+
export function formatSearchResults(raw: string): string {
|
|
156
|
+
return parseSearchResults(raw).text;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function isCancelled(error: unknown, signal: AbortSignal | undefined): boolean {
|
|
160
|
+
return signal?.aborted === true || (error instanceof DOMException && error.name === "AbortError");
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export async function executeSearch(
|
|
164
|
+
params: SearchParams,
|
|
165
|
+
signal?: AbortSignal,
|
|
166
|
+
onUpdate?: (result: SearchResult) => void,
|
|
167
|
+
dependencies: SearchDependencies = { apiPost, curl },
|
|
168
|
+
): Promise<SearchResult> {
|
|
169
|
+
const max = Math.min(params.max_results ?? 5, 10);
|
|
170
|
+
const searchType = params.search_type ?? "web";
|
|
171
|
+
let apiMsg = "";
|
|
172
|
+
|
|
173
|
+
try {
|
|
174
|
+
onUpdate?.({
|
|
175
|
+
content: [{ type: "text", text: `Searching (${searchType}): ${params.query}...` }],
|
|
176
|
+
details: {
|
|
177
|
+
_type: "searchResult",
|
|
178
|
+
outcome: "running",
|
|
179
|
+
query: params.query,
|
|
180
|
+
searchType,
|
|
181
|
+
},
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
const raw = await dependencies.apiPost(
|
|
185
|
+
"/search",
|
|
186
|
+
{
|
|
187
|
+
model: "exa",
|
|
188
|
+
query: params.query,
|
|
189
|
+
search_type: searchType,
|
|
190
|
+
max_results: max,
|
|
191
|
+
},
|
|
192
|
+
signal,
|
|
193
|
+
);
|
|
194
|
+
const parsed = parseSearchResults(raw);
|
|
195
|
+
|
|
196
|
+
return {
|
|
197
|
+
content: [{ type: "text", text: parsed.text }],
|
|
198
|
+
details: {
|
|
199
|
+
_type: "searchResult",
|
|
200
|
+
outcome: "success",
|
|
201
|
+
query: params.query,
|
|
202
|
+
searchType,
|
|
203
|
+
source: "api",
|
|
204
|
+
resultCount: parsed.count,
|
|
205
|
+
},
|
|
206
|
+
};
|
|
207
|
+
} catch (apiErr: unknown) {
|
|
208
|
+
apiMsg = apiErr instanceof Error ? apiErr.message : String(apiErr);
|
|
209
|
+
if (isCancelled(apiErr, signal)) {
|
|
210
|
+
return {
|
|
211
|
+
content: [{ type: "text", text: `Search cancelled.\nAPI: ${apiMsg}` }],
|
|
212
|
+
details: {
|
|
213
|
+
_type: "searchResult",
|
|
214
|
+
outcome: "cancelled",
|
|
215
|
+
query: params.query,
|
|
216
|
+
searchType,
|
|
217
|
+
},
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
onUpdate?.({
|
|
221
|
+
content: [
|
|
222
|
+
{
|
|
223
|
+
type: "text",
|
|
224
|
+
text: `API failed: ${apiMsg}\nFalling back to curl...`,
|
|
225
|
+
},
|
|
226
|
+
],
|
|
227
|
+
details: {
|
|
228
|
+
_type: "searchResult",
|
|
229
|
+
outcome: "running",
|
|
230
|
+
query: params.query,
|
|
231
|
+
searchType,
|
|
232
|
+
},
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
try {
|
|
237
|
+
const body = JSON.stringify({
|
|
238
|
+
model: "exa",
|
|
239
|
+
query: params.query,
|
|
240
|
+
search_type: searchType,
|
|
241
|
+
max_results: max,
|
|
242
|
+
});
|
|
243
|
+
const raw = await dependencies.curl([
|
|
244
|
+
"-X",
|
|
245
|
+
"POST",
|
|
246
|
+
"-H",
|
|
247
|
+
"Content-Type: application/json",
|
|
248
|
+
...(auth() ? ["-H", `Authorization: Bearer ${auth()}`] : []),
|
|
249
|
+
"-d",
|
|
250
|
+
body,
|
|
251
|
+
`${routerBaseUrl()}/search`,
|
|
252
|
+
]);
|
|
253
|
+
const parsed = parseSearchResults(raw);
|
|
254
|
+
|
|
255
|
+
return {
|
|
256
|
+
content: [
|
|
257
|
+
{
|
|
258
|
+
type: "text",
|
|
259
|
+
text: `[FALLBACK — curl] API called via curl instead of fetch.\n\n${parsed.text.slice(0, 19_500)}`,
|
|
260
|
+
},
|
|
261
|
+
],
|
|
262
|
+
details: {
|
|
263
|
+
_type: "searchResult",
|
|
264
|
+
outcome: "fallback",
|
|
265
|
+
query: params.query,
|
|
266
|
+
searchType,
|
|
267
|
+
source: "curl-fallback",
|
|
268
|
+
resultCount: parsed.count,
|
|
269
|
+
},
|
|
270
|
+
};
|
|
271
|
+
} catch (curlErr: unknown) {
|
|
272
|
+
const curlMsg = curlErr instanceof Error ? curlErr.message : String(curlErr);
|
|
273
|
+
return {
|
|
274
|
+
content: [
|
|
275
|
+
{
|
|
276
|
+
type: "text",
|
|
277
|
+
text: `Search failed (both API and curl).\nAPI: ${apiMsg}\nCurl: ${curlMsg}`,
|
|
278
|
+
},
|
|
279
|
+
],
|
|
280
|
+
details: {
|
|
281
|
+
_type: "searchResult",
|
|
282
|
+
outcome: "error",
|
|
283
|
+
query: params.query,
|
|
284
|
+
searchType,
|
|
285
|
+
source: "failed",
|
|
286
|
+
},
|
|
287
|
+
isError: true,
|
|
288
|
+
};
|
|
289
|
+
}
|
|
123
290
|
}
|
|
124
291
|
|
|
125
292
|
export default function registerSearch(pi: ExtensionAPI): void {
|
|
126
293
|
pi.registerTool({
|
|
127
294
|
name: "search",
|
|
128
295
|
label: "Search",
|
|
296
|
+
renderShell: "self",
|
|
129
297
|
description: "Web or news search via exa. Returns title, url, and snippet for each result.",
|
|
130
298
|
promptSnippet:
|
|
131
299
|
"search(query, search_type, max_results?) — search_type: 'web' or 'news'. Defaults to 5 results, max 10.",
|
|
@@ -133,8 +301,33 @@ export default function registerSearch(pi: ExtensionAPI): void {
|
|
|
133
301
|
"Use search when you need up-to-date information or facts from the web.",
|
|
134
302
|
"Set search_type='web' for general web results, search_type='news' for recent news articles.",
|
|
135
303
|
],
|
|
136
|
-
renderCall: makeRenderCall("search", (args) =>
|
|
137
|
-
|
|
304
|
+
renderCall: makeRenderCall<unknown>("search", (args) =>
|
|
305
|
+
String((args as Partial<SearchParams>).query ?? ""),
|
|
306
|
+
),
|
|
307
|
+
renderResult: (
|
|
308
|
+
(renderTerminal) => (result, options, theme, context) =>
|
|
309
|
+
renderTerminal(result, options, theme, {
|
|
310
|
+
...context,
|
|
311
|
+
isError: context.isError && options.expanded,
|
|
312
|
+
})
|
|
313
|
+
)(
|
|
314
|
+
makeRenderResult<SearchResultDetails>({
|
|
315
|
+
tool: "search",
|
|
316
|
+
target: (details) => `“${details.query}”`,
|
|
317
|
+
meta: (details) => {
|
|
318
|
+
if (details.outcome === "error") return "failed";
|
|
319
|
+
if (details.outcome === "cancelled") return "cancelled";
|
|
320
|
+
const count = details.resultCount ?? 0;
|
|
321
|
+
return `${count} ${count === 1 ? "result" : "results"} · ${details.searchType}`;
|
|
322
|
+
},
|
|
323
|
+
status: (details) =>
|
|
324
|
+
details.outcome === "error"
|
|
325
|
+
? "error"
|
|
326
|
+
: details.outcome === "fallback" || details.outcome === "cancelled"
|
|
327
|
+
? "warning"
|
|
328
|
+
: "success",
|
|
329
|
+
}),
|
|
330
|
+
),
|
|
138
331
|
parameters: Type.Object({
|
|
139
332
|
query: Type.String({ description: "Search query" }),
|
|
140
333
|
search_type: StringEnum(["web", "news"] as const, {
|
|
@@ -150,94 +343,7 @@ export default function registerSearch(pi: ExtensionAPI): void {
|
|
|
150
343
|
}),
|
|
151
344
|
|
|
152
345
|
async execute(_toolCallId, params, signal, onUpdate) {
|
|
153
|
-
|
|
154
|
-
const searchType = params.search_type ?? "web";
|
|
155
|
-
let apiMsg = "";
|
|
156
|
-
|
|
157
|
-
try {
|
|
158
|
-
onUpdate?.({
|
|
159
|
-
content: [
|
|
160
|
-
{
|
|
161
|
-
type: "text",
|
|
162
|
-
text: `Searching (${searchType}): ${params.query}...`,
|
|
163
|
-
},
|
|
164
|
-
],
|
|
165
|
-
details: undefined,
|
|
166
|
-
});
|
|
167
|
-
|
|
168
|
-
const raw = await apiPost(
|
|
169
|
-
"/search",
|
|
170
|
-
{
|
|
171
|
-
model: "exa",
|
|
172
|
-
query: params.query,
|
|
173
|
-
search_type: searchType,
|
|
174
|
-
max_results: max,
|
|
175
|
-
},
|
|
176
|
-
signal,
|
|
177
|
-
);
|
|
178
|
-
|
|
179
|
-
return {
|
|
180
|
-
content: [
|
|
181
|
-
{
|
|
182
|
-
type: "text",
|
|
183
|
-
text: formatSearchResults(raw),
|
|
184
|
-
},
|
|
185
|
-
],
|
|
186
|
-
details: { source: "api" },
|
|
187
|
-
};
|
|
188
|
-
} catch (apiErr: unknown) {
|
|
189
|
-
apiMsg = apiErr instanceof Error ? apiErr.message : String(apiErr);
|
|
190
|
-
onUpdate?.({
|
|
191
|
-
content: [
|
|
192
|
-
{
|
|
193
|
-
type: "text",
|
|
194
|
-
text: `API failed: ${apiMsg}\nFalling back to curl...`,
|
|
195
|
-
},
|
|
196
|
-
],
|
|
197
|
-
details: undefined,
|
|
198
|
-
});
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
try {
|
|
202
|
-
const body = JSON.stringify({
|
|
203
|
-
model: "exa",
|
|
204
|
-
query: params.query,
|
|
205
|
-
search_type: searchType,
|
|
206
|
-
max_results: max,
|
|
207
|
-
});
|
|
208
|
-
const raw = await curl([
|
|
209
|
-
"-X",
|
|
210
|
-
"POST",
|
|
211
|
-
"-H",
|
|
212
|
-
"Content-Type: application/json",
|
|
213
|
-
...(auth() ? ["-H", `Authorization: Bearer ${auth()}`] : []),
|
|
214
|
-
"-d",
|
|
215
|
-
body,
|
|
216
|
-
`${routerBaseUrl()}/search`,
|
|
217
|
-
]);
|
|
218
|
-
|
|
219
|
-
return {
|
|
220
|
-
content: [
|
|
221
|
-
{
|
|
222
|
-
type: "text",
|
|
223
|
-
text: `[FALLBACK — curl] API called via curl instead of fetch.\n\n${formatSearchResults(raw).slice(0, 19_500)}`,
|
|
224
|
-
},
|
|
225
|
-
],
|
|
226
|
-
details: { source: "curl-fallback" },
|
|
227
|
-
};
|
|
228
|
-
} catch (curlErr: unknown) {
|
|
229
|
-
const curlMsg = curlErr instanceof Error ? curlErr.message : String(curlErr);
|
|
230
|
-
return {
|
|
231
|
-
content: [
|
|
232
|
-
{
|
|
233
|
-
type: "text",
|
|
234
|
-
text: `Search failed (both API and curl).\nAPI: ${apiMsg}\nCurl: ${curlMsg}`,
|
|
235
|
-
},
|
|
236
|
-
],
|
|
237
|
-
details: { source: "failed" },
|
|
238
|
-
isError: true,
|
|
239
|
-
};
|
|
240
|
-
}
|
|
346
|
+
return executeSearch(params as SearchParams, signal, onUpdate);
|
|
241
347
|
},
|
|
242
348
|
});
|
|
243
349
|
}
|