@xynogen/pix-9router 0.4.4 → 0.6.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/fetch.ts DELETED
@@ -1,260 +0,0 @@
1
- /**
2
- * fetch.ts — fetch 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 { apiPost, curl, isCancelled } from "./http.js";
13
- import { makeRenderCall, makeRenderResult } from "./render.js";
14
-
15
- type RouterOutcome = "running" | "fallback" | "success" | "cancelled" | "error";
16
- type FetchFormat = "markdown" | "text" | "html";
17
-
18
- export interface FetchResultDetails {
19
- _type: "fetchResult";
20
- outcome: RouterOutcome;
21
- url: string;
22
- format: FetchFormat;
23
- source?: "api" | "curl-fallback" | "failed";
24
- chars?: number;
25
- truncated?: boolean;
26
- }
27
-
28
- export interface FetchParams {
29
- url: string;
30
- format?: FetchFormat;
31
- max_characters?: number;
32
- }
33
-
34
- interface FetchResult {
35
- content: { type: "text"; text: string }[];
36
- details: FetchResultDetails;
37
- isError?: boolean;
38
- }
39
-
40
- interface FetchDependencies {
41
- apiPost: typeof apiPost;
42
- curl: typeof curl;
43
- }
44
-
45
- /**
46
- * Extract readable page content from the exa fetch JSON envelope.
47
- * Shape: { provider, url, title, content: { format, text }, ... }
48
- * Falls back to the raw string when it isn't the expected JSON shape.
49
- */
50
- export function formatFetchResult(raw: string): string {
51
- let parsed: unknown;
52
- try {
53
- parsed = JSON.parse(raw);
54
- } catch {
55
- return raw;
56
- }
57
- if (typeof parsed !== "object" || parsed === null) return raw;
58
-
59
- const obj = parsed as {
60
- title?: string | null;
61
- url?: string | null;
62
- content?: { text?: string | null } | string | null;
63
- };
64
-
65
- const text = typeof obj.content === "string" ? obj.content : (obj.content?.text ?? undefined);
66
- if (typeof text !== "string") return raw;
67
-
68
- const header: string[] = [];
69
- if (obj.title) header.push(`# ${obj.title}`);
70
- if (obj.url) header.push(`URL: ${obj.url}`);
71
-
72
- return header.length > 0 ? `${header.join("\n")}\n\n${text}` : text;
73
- }
74
-
75
- export async function executeFetch(
76
- params: FetchParams,
77
- signal?: AbortSignal,
78
- onUpdate?: (result: FetchResult) => void,
79
- dependencies: FetchDependencies = { apiPost, curl },
80
- ): Promise<FetchResult> {
81
- const maxChars = params.max_characters ?? 1000;
82
- const fmt = params.format ?? "markdown";
83
- let apiMsg = "";
84
-
85
- try {
86
- onUpdate?.({
87
- content: [{ type: "text", text: `Fetching (${fmt}): ${params.url}...` }],
88
- details: {
89
- _type: "fetchResult",
90
- outcome: "running",
91
- url: params.url,
92
- format: fmt,
93
- },
94
- });
95
-
96
- const raw = await dependencies.apiPost(
97
- "/web/fetch",
98
- {
99
- model: "exa",
100
- url: params.url,
101
- format: fmt,
102
- max_characters: maxChars,
103
- },
104
- signal,
105
- );
106
-
107
- const formatted = formatFetchResult(raw);
108
- const truncated =
109
- maxChars > 0 && formatted.length > maxChars
110
- ? `${formatted.slice(0, maxChars)}\n\n[truncated]`
111
- : formatted;
112
-
113
- return {
114
- content: [{ type: "text", text: truncated.slice(0, 20_000) }],
115
- details: {
116
- _type: "fetchResult",
117
- outcome: "success",
118
- url: params.url,
119
- format: fmt,
120
- source: "api",
121
- chars: formatted.length,
122
- truncated: maxChars > 0 && formatted.length > maxChars,
123
- },
124
- };
125
- } catch (apiErr: unknown) {
126
- apiMsg = apiErr instanceof Error ? apiErr.message : String(apiErr);
127
- if (isCancelled(apiErr, signal)) {
128
- return {
129
- content: [{ type: "text", text: `Fetch cancelled.\nAPI: ${apiMsg}` }],
130
- details: {
131
- _type: "fetchResult",
132
- outcome: "cancelled",
133
- url: params.url,
134
- format: fmt,
135
- },
136
- };
137
- }
138
- onUpdate?.({
139
- content: [
140
- {
141
- type: "text",
142
- text: `API failed: ${apiMsg}\nFalling back to curl...`,
143
- },
144
- ],
145
- details: {
146
- _type: "fetchResult",
147
- outcome: "running",
148
- url: params.url,
149
- format: fmt,
150
- },
151
- });
152
- }
153
-
154
- try {
155
- let html = await dependencies.curl(["-L", params.url]);
156
- const chars = html.length;
157
- const truncated = maxChars > 0 && html.length > maxChars;
158
- if (truncated) html = `${html.slice(0, maxChars)}\n\n[truncated]`;
159
-
160
- const banner =
161
- "[FALLBACK — raw curl] API unavailable, content may differ from exa formatted output.";
162
- const body =
163
- fmt === "html"
164
- ? `${banner}\n\n${html.slice(0, 19_500)}`
165
- : `${banner}\n\nRaw HTML (format='${fmt}' not applied):\n\n${html.slice(0, 19_000)}`;
166
-
167
- return {
168
- content: [{ type: "text", text: body.slice(0, 20_000) }],
169
- details: {
170
- _type: "fetchResult",
171
- outcome: "fallback",
172
- url: params.url,
173
- format: fmt,
174
- source: "curl-fallback",
175
- chars,
176
- truncated,
177
- },
178
- };
179
- } catch (curlErr: unknown) {
180
- const curlMsg = curlErr instanceof Error ? curlErr.message : String(curlErr);
181
- return {
182
- content: [
183
- {
184
- type: "text",
185
- text: `Fetch failed (both API and curl).\nAPI: ${apiMsg}\nCurl: ${curlMsg}`,
186
- },
187
- ],
188
- details: {
189
- _type: "fetchResult",
190
- outcome: "error",
191
- url: params.url,
192
- format: fmt,
193
- source: "failed",
194
- },
195
- isError: true,
196
- };
197
- }
198
- }
199
-
200
- export default function registerFetch(pi: ExtensionAPI): void {
201
- pi.registerTool({
202
- name: "fetch",
203
- label: "Fetch",
204
- renderShell: "self",
205
- description: "Fetch a web page as markdown, text, or raw HTML via exa. Returns page content.",
206
- promptSnippet:
207
- "fetch(url, format, max_characters?) — format: 'markdown', 'text', or 'html'. Read page content via exa.",
208
- promptGuidelines: [
209
- "fetch: format='markdown' for readable content, 'text' for plain extraction, 'html' for raw source. max_characters caps size (default 1000, 0 = unlimited; 5000-10000 for typical pages).",
210
- ],
211
- renderCall: makeRenderCall<unknown>("fetch", (args) =>
212
- String((args as Partial<FetchParams>).url ?? ""),
213
- ),
214
- renderResult: (
215
- (renderTerminal) => (result, options, theme, context) =>
216
- renderTerminal(result, options, theme, {
217
- ...context,
218
- isError: context.isError && options.expanded,
219
- })
220
- )(
221
- makeRenderResult<FetchResultDetails>({
222
- tool: "fetch",
223
- target: (details) => details.url,
224
- meta: (details) => {
225
- if (details.outcome === "error") return "failed";
226
- if (details.outcome === "cancelled") return "cancelled";
227
- const chars = `${details.chars ?? 0} chars`;
228
- return details.outcome === "fallback"
229
- ? `${chars} · curl fallback`
230
- : `${chars} · ${details.format}`;
231
- },
232
- status: (details) =>
233
- details.outcome === "error"
234
- ? "error"
235
- : details.outcome === "fallback" || details.outcome === "cancelled"
236
- ? "warning"
237
- : "success",
238
- }),
239
- ),
240
- parameters: Type.Object({
241
- url: Type.String({ description: "URL to fetch" }),
242
- format: Type.Optional(
243
- StringEnum(["markdown", "text", "html"] as const, {
244
- description:
245
- 'Optional choice. Enter exactly "markdown" for readable Markdown, "text" for plain text, or "html" for raw HTML. Default is "markdown".',
246
- }),
247
- ),
248
- max_characters: Type.Optional(
249
- Type.Number({
250
- description: "Max characters (default 1000, 0 = unlimited)",
251
- default: 1000,
252
- }),
253
- ),
254
- }),
255
-
256
- async execute(_toolCallId, params, signal, onUpdate) {
257
- return executeFetch(params as FetchParams, signal, onUpdate);
258
- },
259
- });
260
- }
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
- }