@xynogen/pix-9router 0.4.5 → 0.7.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/src/render.ts DELETED
@@ -1,151 +0,0 @@
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
- frameToolResult,
23
- hideCollapsedToolCall,
24
- unframeToolResult,
25
- } from "@xynogen/pix-pretty/utils";
26
- import { type CollapseState, tickCollapse } from "@xynogen/pix-runtime/collapse";
27
-
28
- interface TextLike extends Component {
29
- setText(text: string): void;
30
- }
31
-
32
- // ToolRenderContext is not re-exported by pi-coding-agent; model the fields the
33
- // renderers actually read. Structurally compatible with the SDK context.
34
- interface RenderCtx {
35
- lastComponent: Component | undefined;
36
- isError: boolean;
37
- state: Record<string, unknown>;
38
- expanded: boolean;
39
- invalidate: () => void;
40
- }
41
-
42
- interface CompactRendererConfig<TDetails> {
43
- tool: string;
44
- target: (details: TDetails) => string;
45
- meta: (details: TDetails) => string;
46
- status?: (details: TDetails) => CollapsedToolStatus;
47
- }
48
-
49
- type TextCtor = new (text?: string, padX?: number, padY?: number) => TextLike;
50
-
51
- // Preview cap is intentionally generous — dimming already de-emphasises the
52
- // body, so we only truncate to keep pathological multi-thousand-line dumps
53
- // from flooding the viewport. Expanding shows everything.
54
- const MAX_PREVIEW_LINES = 32;
55
-
56
- let TextComponent: TextCtor | undefined;
57
- try {
58
- TextComponent = (require("@earendil-works/pi-tui") as { Text: TextCtor }).Text;
59
- } catch {
60
- TextComponent = undefined;
61
- }
62
-
63
- function getText(lastComponent: Component | undefined): TextLike {
64
- if (lastComponent && "setText" in lastComponent)
65
- return unframeToolResult(lastComponent as TextLike);
66
- // TextComponent is always present in the interactive TUI (pi-tui peer); the
67
- // require() guard only matters for headless/test contexts where renderers
68
- // are never invoked.
69
- if (TextComponent) return new TextComponent("", 0, 0);
70
- throw new Error("pi-tui Text component unavailable");
71
- }
72
-
73
- function allText(result: AgentToolResult<unknown>): string {
74
- return (result.content ?? [])
75
- .filter((block): block is { type: "text"; text: string } => block.type === "text")
76
- .map((block) => block.text)
77
- .join("\n");
78
- }
79
-
80
- function dimBody(body: string, theme: Theme, expanded: boolean): string {
81
- const lines = body.split("\n");
82
- const maxShow = expanded ? lines.length : MAX_PREVIEW_LINES;
83
- const shown = lines.slice(0, maxShow);
84
- const out = shown.map((line) => ` ${theme.fg("dim", line)}`);
85
- const remaining = lines.length - maxShow;
86
- if (remaining > 0) {
87
- out.push(` ${theme.fg("muted", `… ${remaining} more lines`)}`);
88
- }
89
- return out.join("\n");
90
- }
91
-
92
- /** Build a `renderCall` that shows `<title> <secondary arg>`. */
93
- export function makeRenderCall<TArgs>(title: string, pickArg: (args: TArgs) => string) {
94
- return (args: TArgs, theme: Theme, ctx: RenderCtx): Component => {
95
- const text = getText(ctx.lastComponent);
96
- if (
97
- hideCollapsedToolCall(ctx.state as CollapseState, ctx.expanded, (value) =>
98
- text.setText(value),
99
- )
100
- )
101
- return text;
102
- const arg = pickArg(args);
103
- text.setText(`${theme.fg("toolTitle", theme.bold(title))} ${theme.fg("dim", arg)}`);
104
- return text;
105
- };
106
- }
107
-
108
- /** Build a `renderResult` with a bounded detail preview and optional compact terminal row. */
109
- export function makeRenderResult<TDetails>(config?: CompactRendererConfig<TDetails>) {
110
- return (
111
- result: AgentToolResult<unknown>,
112
- opts: ToolRenderResultOptions,
113
- theme: Theme,
114
- ctx: RenderCtx,
115
- ): Component => {
116
- const text = getText(ctx.lastComponent);
117
- const body = allText(result);
118
-
119
- if (ctx.isError) {
120
- text.setText(` ${theme.fg("error", body || "Error")}`);
121
- return opts.isPartial ? text : frameToolResult(text, theme, true);
122
- }
123
- if (!body.trim()) {
124
- text.setText(` ${theme.fg("muted", "(empty)")}`);
125
- return opts.isPartial ? text : frameToolResult(text, theme, false);
126
- }
127
-
128
- const details = result.details as TDetails | undefined;
129
- const isError = Boolean(details && config?.status?.(details) === "error");
130
- if (
131
- !opts.isPartial &&
132
- config &&
133
- details &&
134
- tickCollapse(config.tool, ctx.state as CollapseState, ctx.invalidate, opts.expanded)
135
- ) {
136
- text.setText(
137
- formatCollapsedToolRow(
138
- theme,
139
- config.tool,
140
- config.target(details),
141
- config.meta(details),
142
- config.status?.(details) ?? "success",
143
- ),
144
- );
145
- return text;
146
- }
147
-
148
- text.setText(dimBody(body, theme, opts.expanded));
149
- return opts.isPartial ? text : frameToolResult(text, theme, isError);
150
- };
151
- }
package/src/search.ts DELETED
@@ -1,282 +0,0 @@
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.js";
13
- import { apiPost, auth, curl, isCancelled } from "./http.js";
14
- import { makeRenderCall, makeRenderResult } from "./render.js";
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
- "search: search_type='web' for general web results, 'news' for recent news articles.",
236
- ],
237
- renderCall: makeRenderCall<unknown>("search", (args) =>
238
- String((args as Partial<SearchParams>).query ?? ""),
239
- ),
240
- renderResult: (
241
- (renderTerminal) => (result, options, theme, context) =>
242
- renderTerminal(result, options, theme, {
243
- ...context,
244
- isError: context.isError && options.expanded,
245
- })
246
- )(
247
- makeRenderResult<SearchResultDetails>({
248
- tool: "search",
249
- target: (details) => `“${details.query}”`,
250
- meta: (details) => {
251
- if (details.outcome === "error") return "failed";
252
- if (details.outcome === "cancelled") return "cancelled";
253
- const count = details.resultCount ?? 0;
254
- return `${count} ${count === 1 ? "result" : "results"} · ${details.searchType}`;
255
- },
256
- status: (details) =>
257
- details.outcome === "error"
258
- ? "error"
259
- : details.outcome === "fallback" || details.outcome === "cancelled"
260
- ? "warning"
261
- : "success",
262
- }),
263
- ),
264
- parameters: Type.Object({
265
- query: Type.String({ description: "Search query" }),
266
- search_type: StringEnum(["web", "news"] as const, {
267
- description:
268
- 'Required choice. Enter exactly "web" for general web results or "news" for recent news articles.',
269
- }),
270
- max_results: Type.Optional(
271
- Type.Number({
272
- description: "Max results (default 5, max 10)",
273
- default: 5,
274
- }),
275
- ),
276
- }),
277
-
278
- async execute(_toolCallId, params, signal, onUpdate) {
279
- return executeSearch(params as SearchParams, signal, onUpdate);
280
- },
281
- });
282
- }