@haoyiyin/9router 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +39 -0
- package/package.json +44 -0
- package/src/command.ts +52 -0
- package/src/config.ts +50 -0
- package/src/data.ts +81 -0
- package/src/fetch.ts +262 -0
- package/src/http.ts +87 -0
- package/src/index.ts +35 -0
- package/src/provider.ts +158 -0
- package/src/render.ts +147 -0
- package/src/search.ts +283 -0
- package/src/transcribe.ts +468 -0
- package/src/zen.ts +76 -0
package/src/provider.ts
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* provider.ts — 9Router model provider
|
|
3
|
+
*
|
|
4
|
+
* Registers the "9router" provider in Pi, pulling the live router model list
|
|
5
|
+
* plus OpenCode's free oc/-free models. Falls back to an empty model list if
|
|
6
|
+
* the API key is unset (so the /9router command can re-register after config
|
|
7
|
+
* is saved).
|
|
8
|
+
*
|
|
9
|
+
* Config (base URL + key, with env fallback) is owned by ./config.ts.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { ExtensionAPI, ProviderModelConfig } from "@earendil-works/pi-coding-agent";
|
|
13
|
+
import type { ModelsDevModel, RouterModel } from "./data.ts";
|
|
14
|
+
import { fetchModelsDevIndex, lookupInIndex, routerBaseUrl, routerModels } from "./data.ts";
|
|
15
|
+
import { getZenFreeModels } from "./zen.ts";
|
|
16
|
+
import { loadConfig } from "./config.ts";
|
|
17
|
+
|
|
18
|
+
const DEFAULT_CONTEXT_WINDOW = 128_000;
|
|
19
|
+
const DEFAULT_MAX_TOKENS = 16_384;
|
|
20
|
+
|
|
21
|
+
const ZERO_COST = {
|
|
22
|
+
input: 0,
|
|
23
|
+
output: 0,
|
|
24
|
+
cacheRead: 0,
|
|
25
|
+
cacheWrite: 0,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
// Fallback pattern-based detection if models.dev lookup fails
|
|
29
|
+
const IMAGE_CAPABLE_PATTERNS = [/claude/i, /gpt-5/i, /gpt-4/i, /kimi-k2/i, /hy3/i];
|
|
30
|
+
|
|
31
|
+
export function getInputTypes(model: RouterModel, devModel?: ModelsDevModel): ("text" | "image")[] {
|
|
32
|
+
if (devModel?.modalities?.input) {
|
|
33
|
+
const inputs = devModel.modalities.input.filter(
|
|
34
|
+
(i): i is "text" | "image" => i === "text" || i === "image",
|
|
35
|
+
);
|
|
36
|
+
if (inputs.length > 0) return inputs;
|
|
37
|
+
}
|
|
38
|
+
const id = model.id ?? "";
|
|
39
|
+
if (IMAGE_CAPABLE_PATTERNS.some((p) => p.test(id))) return ["text", "image"];
|
|
40
|
+
return ["text"];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function getModelName(model: RouterModel, devModel?: ModelsDevModel): string {
|
|
44
|
+
return model.name || devModel?.name || model.id || "unknown";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function getContextWindow(model: RouterModel, devModel?: ModelsDevModel): number {
|
|
48
|
+
return (
|
|
49
|
+
model.context_window ||
|
|
50
|
+
model.contextWindow ||
|
|
51
|
+
model.capabilities?.contextWindow ||
|
|
52
|
+
devModel?.limit?.context ||
|
|
53
|
+
DEFAULT_CONTEXT_WINDOW
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function getMaxTokens(model: RouterModel, devModel?: ModelsDevModel): number {
|
|
58
|
+
return (
|
|
59
|
+
model.max_tokens ||
|
|
60
|
+
model.maxTokens ||
|
|
61
|
+
model.capabilities?.maxOutput ||
|
|
62
|
+
devModel?.limit?.output ||
|
|
63
|
+
DEFAULT_MAX_TOKENS
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function getReasoning(model: RouterModel, devModel?: ModelsDevModel): boolean {
|
|
68
|
+
if (typeof devModel?.reasoning === "boolean") return devModel.reasoning;
|
|
69
|
+
return /reasoner|thinking|xhigh|high|max|pro|codex|opus|sonnet/i.test(model.id ?? "");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function registerProvider(pi: ExtensionAPI): Promise<void> {
|
|
73
|
+
const cfg = loadConfig();
|
|
74
|
+
|
|
75
|
+
if (!cfg.apiKey) {
|
|
76
|
+
// Register shell provider so the name is known; no models available yet.
|
|
77
|
+
pi.registerProvider("9router", {
|
|
78
|
+
name: "9Router",
|
|
79
|
+
baseUrl: cfg.baseUrl,
|
|
80
|
+
apiKey: "",
|
|
81
|
+
api: "openai-completions",
|
|
82
|
+
models: [],
|
|
83
|
+
});
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const [models, devIndex, zenIds] = await Promise.all([
|
|
88
|
+
routerModels.get(),
|
|
89
|
+
fetchModelsDevIndex().catch(() => new Map<string, ModelsDevModel>()),
|
|
90
|
+
getZenFreeModels(),
|
|
91
|
+
]);
|
|
92
|
+
|
|
93
|
+
// Upstream moved OpenAI `compat` settings from the provider level to the
|
|
94
|
+
// per-model level (ProviderModelConfig.compat). Apply the same shim to every
|
|
95
|
+
// registered model.
|
|
96
|
+
const COMPAT = {
|
|
97
|
+
supportsDeveloperRole: false,
|
|
98
|
+
supportsUsageInStreaming: false,
|
|
99
|
+
maxTokensField: "max_tokens",
|
|
100
|
+
} as const;
|
|
101
|
+
|
|
102
|
+
const seen = new Set<string>();
|
|
103
|
+
const out: ProviderModelConfig[] = [];
|
|
104
|
+
|
|
105
|
+
// Router models (live list), mapped the same as the original registration.
|
|
106
|
+
for (const model of models) {
|
|
107
|
+
const id = model.id ?? "";
|
|
108
|
+
if (!id || seen.has(id)) continue;
|
|
109
|
+
seen.add(id);
|
|
110
|
+
const devModel = lookupInIndex(id, devIndex);
|
|
111
|
+
out.push({
|
|
112
|
+
id,
|
|
113
|
+
name: getModelName(model, devModel),
|
|
114
|
+
reasoning: getReasoning(model, devModel),
|
|
115
|
+
input: getInputTypes(model, devModel),
|
|
116
|
+
cost: ZERO_COST,
|
|
117
|
+
contextWindow: getContextWindow(model, devModel),
|
|
118
|
+
maxTokens: getMaxTokens(model, devModel),
|
|
119
|
+
compat: COMPAT,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// OpenCode free models (zen) — bare ids, no auth needed.
|
|
124
|
+
// ponytail: zen returns bare ids (e.g. "deepseek-v4-flash-free") with no
|
|
125
|
+
// metadata, so the contextWindow/maxTokens defaults below are guesses.
|
|
126
|
+
// Upgrade when zen ships model detail.
|
|
127
|
+
for (const id of zenIds) {
|
|
128
|
+
const ocId = `oc/${id}`;
|
|
129
|
+
if (seen.has(ocId)) continue;
|
|
130
|
+
seen.add(ocId);
|
|
131
|
+
out.push({
|
|
132
|
+
id: ocId,
|
|
133
|
+
name: id,
|
|
134
|
+
reasoning: /reasoner|thinking|xhigh|high|max|pro|codex|opus|sonnet|fable|terra/i.test(id),
|
|
135
|
+
input: /claude|gpt-|grok|gemini|kimi|glm|qwen|minimax|mimo/i.test(id)
|
|
136
|
+
? ["text", "image"]
|
|
137
|
+
: ["text"],
|
|
138
|
+
cost: ZERO_COST,
|
|
139
|
+
contextWindow: 200_000,
|
|
140
|
+
maxTokens: 32_000,
|
|
141
|
+
compat: COMPAT,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (typeof (pi as { unregisterProvider?: unknown }).unregisterProvider === "function") {
|
|
146
|
+
pi.unregisterProvider("9router");
|
|
147
|
+
}
|
|
148
|
+
pi.registerProvider("9router", {
|
|
149
|
+
name: "9Router",
|
|
150
|
+
baseUrl: cfg.baseUrl,
|
|
151
|
+
apiKey: cfg.apiKey,
|
|
152
|
+
api: "openai-completions",
|
|
153
|
+
headers: { "User-Agent": "pi-coding-agent" },
|
|
154
|
+
models: out,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export default registerProvider;
|
package/src/render.ts
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* render.ts — shared dim renderers for fetch/search tool output.
|
|
3
|
+
*
|
|
4
|
+
* fetch/search return external web content the model already consumed; the
|
|
5
|
+
* full body is noise in the transcript. We render it dim (theme `dim` token)
|
|
6
|
+
* so it reads like faded reasoning — present for inspection, visually quiet.
|
|
7
|
+
* The tool title line stays normal so calls remain scannable.
|
|
8
|
+
*
|
|
9
|
+
* pi-tui's Text component is loaded via require() so the renderers degrade to
|
|
10
|
+
* the default pi renderer (undefined-safe via cast) when pi-tui is absent.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type {
|
|
14
|
+
AgentToolResult,
|
|
15
|
+
Theme,
|
|
16
|
+
ToolRenderResultOptions,
|
|
17
|
+
} from "@earendil-works/pi-coding-agent";
|
|
18
|
+
import type { Component } from "@earendil-works/pi-tui";
|
|
19
|
+
import {
|
|
20
|
+
type CollapsedToolStatus,
|
|
21
|
+
formatCollapsedToolRow,
|
|
22
|
+
hideCollapsedToolCall,
|
|
23
|
+
} from "@xynogen/pix-pretty/utils";
|
|
24
|
+
import { type CollapseState, tickCollapse } from "@xynogen/pix-runtime/collapse";
|
|
25
|
+
|
|
26
|
+
interface TextLike extends Component {
|
|
27
|
+
setText(text: string): void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ToolRenderContext is not re-exported by pi-coding-agent; model the fields the
|
|
31
|
+
// renderers actually read. Structurally compatible with the SDK context.
|
|
32
|
+
interface RenderCtx {
|
|
33
|
+
lastComponent: Component | undefined;
|
|
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;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
type TextCtor = new (text?: string, padX?: number, padY?: number) => TextLike;
|
|
48
|
+
|
|
49
|
+
// Preview cap is intentionally generous — dimming already de-emphasises the
|
|
50
|
+
// body, so we only truncate to keep pathological multi-thousand-line dumps
|
|
51
|
+
// from flooding the viewport. Expanding shows everything.
|
|
52
|
+
const MAX_PREVIEW_LINES = 32;
|
|
53
|
+
|
|
54
|
+
let TextComponent: TextCtor | undefined;
|
|
55
|
+
try {
|
|
56
|
+
TextComponent = (require("@earendil-works/pi-tui") as { Text: TextCtor }).Text;
|
|
57
|
+
} catch {
|
|
58
|
+
TextComponent = undefined;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function getText(lastComponent: Component | undefined): TextLike {
|
|
62
|
+
if (lastComponent && "setText" in lastComponent) return lastComponent as TextLike;
|
|
63
|
+
// TextComponent is always present in the interactive TUI (pi-tui peer); the
|
|
64
|
+
// require() guard only matters for headless/test contexts where renderers
|
|
65
|
+
// are never invoked.
|
|
66
|
+
if (TextComponent) return new TextComponent("", 0, 0);
|
|
67
|
+
throw new Error("pi-tui Text component unavailable");
|
|
68
|
+
}
|
|
69
|
+
|
|
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");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function dimBody(body: string, theme: Theme, expanded: boolean): string {
|
|
78
|
+
const lines = body.split("\n");
|
|
79
|
+
const maxShow = expanded ? lines.length : MAX_PREVIEW_LINES;
|
|
80
|
+
const shown = lines.slice(0, maxShow);
|
|
81
|
+
const out = shown.map((line) => ` ${theme.fg("dim", line)}`);
|
|
82
|
+
const remaining = lines.length - maxShow;
|
|
83
|
+
if (remaining > 0) {
|
|
84
|
+
out.push(` ${theme.fg("dim", `… ${remaining} more lines`)}`);
|
|
85
|
+
}
|
|
86
|
+
return out.join("\n");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Build a `renderCall` that shows `<title> <accent arg>`. */
|
|
90
|
+
export function makeRenderCall<TArgs>(title: string, pickArg: (args: TArgs) => string) {
|
|
91
|
+
return (args: TArgs, theme: Theme, ctx: RenderCtx): Component => {
|
|
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;
|
|
99
|
+
const arg = pickArg(args);
|
|
100
|
+
text.setText(`${theme.fg("toolTitle", theme.bold(title))} ${theme.fg("muted", arg)}`);
|
|
101
|
+
return text;
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Build a `renderResult` with a bounded detail preview and optional compact terminal row. */
|
|
106
|
+
export function makeRenderResult<TDetails>(config?: CompactRendererConfig<TDetails>) {
|
|
107
|
+
return (
|
|
108
|
+
result: AgentToolResult<unknown>,
|
|
109
|
+
opts: ToolRenderResultOptions,
|
|
110
|
+
theme: Theme,
|
|
111
|
+
ctx: RenderCtx,
|
|
112
|
+
): Component => {
|
|
113
|
+
const text = getText(ctx.lastComponent);
|
|
114
|
+
const body = allText(result);
|
|
115
|
+
|
|
116
|
+
if (ctx.isError) {
|
|
117
|
+
text.setText(` ${theme.fg("error", body || "Error")}`);
|
|
118
|
+
return text;
|
|
119
|
+
}
|
|
120
|
+
if (!body.trim()) {
|
|
121
|
+
text.setText(` ${theme.fg("dim", "(empty)")}`);
|
|
122
|
+
return text;
|
|
123
|
+
}
|
|
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
|
+
|
|
144
|
+
text.setText(dimBody(body, theme, opts.expanded));
|
|
145
|
+
return text;
|
|
146
|
+
};
|
|
147
|
+
}
|
package/src/search.ts
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* search.ts — search tool via 9Router API, with curl fallback.
|
|
3
|
+
*
|
|
4
|
+
* Environment:
|
|
5
|
+
* ROUTER_API_BASE — router API base URL (default: https://9router.example.com/v1)
|
|
6
|
+
* ROUTER_API_KEY — bearer token for the router
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { StringEnum } from "@earendil-works/pi-ai";
|
|
10
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
11
|
+
import { Type } from "typebox";
|
|
12
|
+
import { routerBaseUrl } from "./data.ts";
|
|
13
|
+
import { apiPost, auth, curl, isCancelled } from "./http.ts";
|
|
14
|
+
import { makeRenderCall, makeRenderResult } from "./render.ts";
|
|
15
|
+
|
|
16
|
+
type RouterOutcome = "running" | "fallback" | "success" | "cancelled" | "error";
|
|
17
|
+
type SearchType = "web" | "news";
|
|
18
|
+
|
|
19
|
+
export interface SearchResultDetails {
|
|
20
|
+
_type: "searchResult";
|
|
21
|
+
outcome: RouterOutcome;
|
|
22
|
+
query: string;
|
|
23
|
+
searchType: SearchType;
|
|
24
|
+
source?: "api" | "curl-fallback" | "failed";
|
|
25
|
+
resultCount?: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface SearchParams {
|
|
29
|
+
query: string;
|
|
30
|
+
search_type?: SearchType;
|
|
31
|
+
max_results?: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface SearchResult {
|
|
35
|
+
content: { type: "text"; text: string }[];
|
|
36
|
+
details: SearchResultDetails;
|
|
37
|
+
isError?: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface SearchDependencies {
|
|
41
|
+
apiPost: typeof apiPost;
|
|
42
|
+
curl: typeof curl;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface SearchResultItem {
|
|
46
|
+
title?: string | null;
|
|
47
|
+
url?: string | null;
|
|
48
|
+
snippet?: string | null;
|
|
49
|
+
published_at?: string | null;
|
|
50
|
+
metadata?: { author?: string | null } | null;
|
|
51
|
+
author?: string | null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Parse and format a raw exa search response with its authoritative result count. */
|
|
55
|
+
export function parseSearchResults(raw: string): { text: string; count: number } {
|
|
56
|
+
let parsed: unknown;
|
|
57
|
+
try {
|
|
58
|
+
parsed = JSON.parse(raw);
|
|
59
|
+
} catch {
|
|
60
|
+
return { text: raw.slice(0, 20_000), count: 0 };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const obj = parsed as Record<string, unknown>;
|
|
64
|
+
const results = obj?.results;
|
|
65
|
+
if (!Array.isArray(results)) {
|
|
66
|
+
return { text: JSON.stringify(parsed, null, 2).slice(0, 20_000), count: 0 };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (results.length === 0) return { text: "No results.", count: 0 };
|
|
70
|
+
|
|
71
|
+
const lines: string[] = [];
|
|
72
|
+
results.forEach((r: SearchResultItem, i: number) => {
|
|
73
|
+
const title = (r.title ?? "").trim() || r.url || "(untitled)";
|
|
74
|
+
lines.push(`${i + 1}. ${title}`);
|
|
75
|
+
if (r.url) lines.push(` ${r.url}`);
|
|
76
|
+
const meta: string[] = [];
|
|
77
|
+
const author = r.author ?? r.metadata?.author;
|
|
78
|
+
if (author) meta.push(author);
|
|
79
|
+
if (r.published_at) meta.push(r.published_at.slice(0, 10));
|
|
80
|
+
if (meta.length > 0) lines.push(` (${meta.join(" — ")})`);
|
|
81
|
+
const snippet = (r.snippet ?? "").trim();
|
|
82
|
+
if (snippet) lines.push(` ${snippet.replace(/\s+/g, " ").slice(0, 300)}`);
|
|
83
|
+
lines.push("");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
text: lines.join("\n").trim().slice(0, 20_000),
|
|
88
|
+
count: results.length,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Format a raw exa search response as a compact markdown list. */
|
|
93
|
+
export function formatSearchResults(raw: string): string {
|
|
94
|
+
return parseSearchResults(raw).text;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export async function executeSearch(
|
|
98
|
+
params: SearchParams,
|
|
99
|
+
signal?: AbortSignal,
|
|
100
|
+
onUpdate?: (result: SearchResult) => void,
|
|
101
|
+
dependencies: SearchDependencies = { apiPost, curl },
|
|
102
|
+
): Promise<SearchResult> {
|
|
103
|
+
const max = Math.min(params.max_results ?? 5, 10);
|
|
104
|
+
const searchType = params.search_type ?? "web";
|
|
105
|
+
let apiMsg = "";
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
onUpdate?.({
|
|
109
|
+
content: [{ type: "text", text: `Searching (${searchType}): ${params.query}...` }],
|
|
110
|
+
details: {
|
|
111
|
+
_type: "searchResult",
|
|
112
|
+
outcome: "running",
|
|
113
|
+
query: params.query,
|
|
114
|
+
searchType,
|
|
115
|
+
},
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
const raw = await dependencies.apiPost(
|
|
119
|
+
"/search",
|
|
120
|
+
{
|
|
121
|
+
model: "exa",
|
|
122
|
+
query: params.query,
|
|
123
|
+
search_type: searchType,
|
|
124
|
+
max_results: max,
|
|
125
|
+
},
|
|
126
|
+
signal,
|
|
127
|
+
);
|
|
128
|
+
const parsed = parseSearchResults(raw);
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
content: [{ type: "text", text: parsed.text }],
|
|
132
|
+
details: {
|
|
133
|
+
_type: "searchResult",
|
|
134
|
+
outcome: "success",
|
|
135
|
+
query: params.query,
|
|
136
|
+
searchType,
|
|
137
|
+
source: "api",
|
|
138
|
+
resultCount: parsed.count,
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
} catch (apiErr: unknown) {
|
|
142
|
+
apiMsg = apiErr instanceof Error ? apiErr.message : String(apiErr);
|
|
143
|
+
if (isCancelled(apiErr, signal)) {
|
|
144
|
+
return {
|
|
145
|
+
content: [{ type: "text", text: `Search cancelled.\nAPI: ${apiMsg}` }],
|
|
146
|
+
details: {
|
|
147
|
+
_type: "searchResult",
|
|
148
|
+
outcome: "cancelled",
|
|
149
|
+
query: params.query,
|
|
150
|
+
searchType,
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
onUpdate?.({
|
|
155
|
+
content: [
|
|
156
|
+
{
|
|
157
|
+
type: "text",
|
|
158
|
+
text: `API failed: ${apiMsg}\nFalling back to curl...`,
|
|
159
|
+
},
|
|
160
|
+
],
|
|
161
|
+
details: {
|
|
162
|
+
_type: "searchResult",
|
|
163
|
+
outcome: "running",
|
|
164
|
+
query: params.query,
|
|
165
|
+
searchType,
|
|
166
|
+
},
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
try {
|
|
171
|
+
const body = JSON.stringify({
|
|
172
|
+
model: "exa",
|
|
173
|
+
query: params.query,
|
|
174
|
+
search_type: searchType,
|
|
175
|
+
max_results: max,
|
|
176
|
+
});
|
|
177
|
+
const raw = await dependencies.curl([
|
|
178
|
+
"-X",
|
|
179
|
+
"POST",
|
|
180
|
+
"-H",
|
|
181
|
+
"Content-Type: application/json",
|
|
182
|
+
...(auth() ? ["-H", `Authorization: Bearer ${auth()}`] : []),
|
|
183
|
+
"-d",
|
|
184
|
+
body,
|
|
185
|
+
`${routerBaseUrl()}/search`,
|
|
186
|
+
]);
|
|
187
|
+
const parsed = parseSearchResults(raw);
|
|
188
|
+
|
|
189
|
+
return {
|
|
190
|
+
content: [
|
|
191
|
+
{
|
|
192
|
+
type: "text",
|
|
193
|
+
text: `[FALLBACK — curl] API called via curl instead of fetch.\n\n${parsed.text.slice(0, 19_500)}`,
|
|
194
|
+
},
|
|
195
|
+
],
|
|
196
|
+
details: {
|
|
197
|
+
_type: "searchResult",
|
|
198
|
+
outcome: "fallback",
|
|
199
|
+
query: params.query,
|
|
200
|
+
searchType,
|
|
201
|
+
source: "curl-fallback",
|
|
202
|
+
resultCount: parsed.count,
|
|
203
|
+
},
|
|
204
|
+
};
|
|
205
|
+
} catch (curlErr: unknown) {
|
|
206
|
+
const curlMsg = curlErr instanceof Error ? curlErr.message : String(curlErr);
|
|
207
|
+
return {
|
|
208
|
+
content: [
|
|
209
|
+
{
|
|
210
|
+
type: "text",
|
|
211
|
+
text: `Search failed (both API and curl).\nAPI: ${apiMsg}\nCurl: ${curlMsg}`,
|
|
212
|
+
},
|
|
213
|
+
],
|
|
214
|
+
details: {
|
|
215
|
+
_type: "searchResult",
|
|
216
|
+
outcome: "error",
|
|
217
|
+
query: params.query,
|
|
218
|
+
searchType,
|
|
219
|
+
source: "failed",
|
|
220
|
+
},
|
|
221
|
+
isError: true,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export default function registerSearch(pi: ExtensionAPI): void {
|
|
227
|
+
pi.registerTool({
|
|
228
|
+
name: "search",
|
|
229
|
+
label: "Search",
|
|
230
|
+
renderShell: "self",
|
|
231
|
+
description: "Web or news search via exa. Returns title, url, and snippet for each result.",
|
|
232
|
+
promptSnippet:
|
|
233
|
+
"search(query, search_type, max_results?) — search_type: 'web' or 'news'. Defaults to 5 results, max 10.",
|
|
234
|
+
promptGuidelines: [
|
|
235
|
+
"Use search when you need up-to-date information or facts from the web.",
|
|
236
|
+
"Set search_type='web' for general web results, search_type='news' for recent news articles.",
|
|
237
|
+
],
|
|
238
|
+
renderCall: makeRenderCall<unknown>("search", (args) =>
|
|
239
|
+
String((args as Partial<SearchParams>).query ?? ""),
|
|
240
|
+
),
|
|
241
|
+
renderResult: (
|
|
242
|
+
(renderTerminal) => (result, options, theme, context) =>
|
|
243
|
+
renderTerminal(result, options, theme, {
|
|
244
|
+
...context,
|
|
245
|
+
isError: context.isError && options.expanded,
|
|
246
|
+
})
|
|
247
|
+
)(
|
|
248
|
+
makeRenderResult<SearchResultDetails>({
|
|
249
|
+
tool: "search",
|
|
250
|
+
target: (details) => `“${details.query}”`,
|
|
251
|
+
meta: (details) => {
|
|
252
|
+
if (details.outcome === "error") return "failed";
|
|
253
|
+
if (details.outcome === "cancelled") return "cancelled";
|
|
254
|
+
const count = details.resultCount ?? 0;
|
|
255
|
+
return `${count} ${count === 1 ? "result" : "results"} · ${details.searchType}`;
|
|
256
|
+
},
|
|
257
|
+
status: (details) =>
|
|
258
|
+
details.outcome === "error"
|
|
259
|
+
? "error"
|
|
260
|
+
: details.outcome === "fallback" || details.outcome === "cancelled"
|
|
261
|
+
? "warning"
|
|
262
|
+
: "success",
|
|
263
|
+
}),
|
|
264
|
+
),
|
|
265
|
+
parameters: Type.Object({
|
|
266
|
+
query: Type.String({ description: "Search query" }),
|
|
267
|
+
search_type: StringEnum(["web", "news"] as const, {
|
|
268
|
+
description:
|
|
269
|
+
'Required choice. Enter exactly "web" for general web results or "news" for recent news articles.',
|
|
270
|
+
}),
|
|
271
|
+
max_results: Type.Optional(
|
|
272
|
+
Type.Number({
|
|
273
|
+
description: "Max results (default 5, max 10)",
|
|
274
|
+
default: 5,
|
|
275
|
+
}),
|
|
276
|
+
),
|
|
277
|
+
}),
|
|
278
|
+
|
|
279
|
+
async execute(_toolCallId, params, signal, onUpdate) {
|
|
280
|
+
return executeSearch(params as SearchParams, signal, onUpdate);
|
|
281
|
+
},
|
|
282
|
+
});
|
|
283
|
+
}
|