@xynogen/pix-9router 0.1.0 → 0.1.1

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/package.json CHANGED
@@ -1,40 +1,47 @@
1
1
  {
2
- "name": "@xynogen/pix-9router",
3
- "version": "0.1.0",
4
- "description": "Pi extension — 9Router provider + fetch/search tools via router API",
5
- "type": "module",
6
- "main": "src/index.ts",
7
- "scripts": {
8
- "test": "bun test"
9
- },
10
- "files": [
11
- "src",
12
- "README.md",
13
- "LICENSE"
14
- ],
15
- "pi": {
16
- "extensions": ["src/index.ts"]
17
- },
18
- "keywords": ["pi", "pi-package", "pi-extension", "9router"],
19
- "author": "xynogen",
20
- "license": "MIT",
21
- "repository": {
22
- "type": "git",
23
- "url": "git+https://github.com/xynogen/pix-mono.git",
24
- "directory": "packages/pix-9router"
25
- },
26
- "homepage": "https://github.com/xynogen/pix-mono/tree/main/packages/pix-9router#readme",
27
- "bugs": {
28
- "url": "https://github.com/xynogen/pix-mono/issues"
29
- },
30
- "publishConfig": {
31
- "access": "public"
32
- },
33
- "peerDependencies": {
34
- "@earendil-works/pi-coding-agent": "*",
35
- "@earendil-works/pi-ai": "*"
36
- },
37
- "dependencies": {
38
- "typebox": "^1.1.38"
39
- }
2
+ "name": "@xynogen/pix-9router",
3
+ "version": "0.1.1",
4
+ "description": "Pi extension — 9Router provider + fetch/search tools via router API",
5
+ "type": "module",
6
+ "main": "src/index.ts",
7
+ "scripts": {
8
+ "test": "bun test"
9
+ },
10
+ "files": [
11
+ "src",
12
+ "README.md",
13
+ "LICENSE"
14
+ ],
15
+ "pi": {
16
+ "extensions": [
17
+ "src/index.ts"
18
+ ]
19
+ },
20
+ "keywords": [
21
+ "pi",
22
+ "pi-package",
23
+ "pi-extension",
24
+ "9router"
25
+ ],
26
+ "author": "xynogen",
27
+ "license": "MIT",
28
+ "repository": {
29
+ "type": "git",
30
+ "url": "git+https://github.com/xynogen/pix-mono.git",
31
+ "directory": "packages/pix-9router"
32
+ },
33
+ "homepage": "https://github.com/xynogen/pix-mono/tree/main/packages/pix-9router#readme",
34
+ "bugs": {
35
+ "url": "https://github.com/xynogen/pix-mono/issues"
36
+ },
37
+ "publishConfig": {
38
+ "access": "public"
39
+ },
40
+ "peerDependencies": {
41
+ "@earendil-works/pi-coding-agent": "*",
42
+ "@earendil-works/pi-ai": "*"
43
+ },
44
+ "dependencies": {
45
+ "typebox": "^1.1.38"
46
+ }
40
47
  }
package/src/data.test.ts CHANGED
@@ -1,10 +1,10 @@
1
- import { describe, it, expect, beforeEach, afterEach } from "bun:test";
1
+ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
2
2
  import {
3
- routerBaseUrl,
4
3
  buildModelsDevIndex,
5
4
  lookupInIndex,
6
5
  type ModelsDevApi,
7
6
  type ModelsDevModel,
7
+ routerBaseUrl,
8
8
  } from "./data.ts";
9
9
 
10
10
  // ── routerBaseUrl ────────────────────────────────────────────────────────────
package/src/data.ts CHANGED
@@ -13,8 +13,8 @@
13
13
  * ROUTER_API_KEY — bearer token (required)
14
14
  */
15
15
 
16
- import { mkdir, readFile, writeFile } from "node:fs/promises";
17
16
  import { existsSync, readFileSync } from "node:fs";
17
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
18
18
  import { homedir } from "node:os";
19
19
  import { dirname, join } from "node:path";
20
20
 
@@ -38,10 +38,18 @@ export interface ModelsDevModel {
38
38
  reasoning?: boolean;
39
39
  modalities?: { input?: string[]; output?: string[] };
40
40
  limit?: { context?: number; output?: number };
41
- cost?: { input?: number; output?: number; cache_read?: number; cache_write?: number };
41
+ cost?: {
42
+ input?: number;
43
+ output?: number;
44
+ cache_read?: number;
45
+ cache_write?: number;
46
+ };
42
47
  }
43
48
 
44
- export type ModelsDevApi = Record<string, { models?: Record<string, ModelsDevModel> }>;
49
+ export type ModelsDevApi = Record<
50
+ string,
51
+ { models?: Record<string, ModelsDevModel> }
52
+ >;
45
53
 
46
54
  interface RouterModelsResponse {
47
55
  data?: RouterModel[];
@@ -79,7 +87,9 @@ class DataSource<T> {
79
87
 
80
88
  async get(): Promise<T> {
81
89
  if (this._inflight) return this._inflight;
82
- this._inflight = this._load().finally(() => { this._inflight = null; });
90
+ this._inflight = this._load().finally(() => {
91
+ this._inflight = null;
92
+ });
83
93
  return this._inflight;
84
94
  }
85
95
 
@@ -87,7 +97,9 @@ class DataSource<T> {
87
97
  if (this._mem) return this._mem;
88
98
  try {
89
99
  if (existsSync(this.opts.cachePath)) {
90
- const raw = JSON.parse(readFileSync(this.opts.cachePath, "utf-8")) as { data: unknown };
100
+ const raw = JSON.parse(readFileSync(this.opts.cachePath, "utf-8")) as {
101
+ data: unknown;
102
+ };
91
103
  this._mem = this.opts.parseCache(raw.data);
92
104
  return this._mem;
93
105
  }
@@ -98,7 +110,10 @@ class DataSource<T> {
98
110
  }
99
111
 
100
112
  private async _load(): Promise<T> {
101
- if (this.opts.skip()) { this._mem = this.opts.empty; return this.opts.empty; }
113
+ if (this.opts.skip()) {
114
+ this._mem = this.opts.empty;
115
+ return this.opts.empty;
116
+ }
102
117
  const cached = await this._readCache();
103
118
  if (cached !== undefined && Date.now() - cached.ts < this.opts.ttlMs) {
104
119
  const val = this.opts.parseCache(cached.data);
@@ -106,11 +121,16 @@ class DataSource<T> {
106
121
  return val;
107
122
  }
108
123
  try {
109
- const url = typeof this.opts.url === "function" ? this.opts.url() : this.opts.url;
124
+ const url =
125
+ typeof this.opts.url === "function" ? this.opts.url() : this.opts.url;
110
126
  const controller = new AbortController();
111
127
  const timer = setTimeout(() => controller.abort(), this.opts.timeoutMs);
112
- const response = await fetch(url, { signal: controller.signal, headers: this.opts.headers() }).finally(() => clearTimeout(timer));
113
- if (!response.ok) throw new Error(`${this.opts.label} fetch failed: ${response.status}`);
128
+ const response = await fetch(url, {
129
+ signal: controller.signal,
130
+ headers: this.opts.headers(),
131
+ }).finally(() => clearTimeout(timer));
132
+ if (!response.ok)
133
+ throw new Error(`${this.opts.label} fetch failed: ${response.status}`);
114
134
  const raw = await response.json();
115
135
  const val = this.opts.parse(raw);
116
136
  this._mem = val;
@@ -119,7 +139,9 @@ class DataSource<T> {
119
139
  } catch (error) {
120
140
  const msg = error instanceof Error ? error.message : String(error);
121
141
  if (cached !== undefined) {
122
- console.warn(`${this.opts.label} fetch failed, using stale cache: ${msg}`);
142
+ console.warn(
143
+ `${this.opts.label} fetch failed, using stale cache: ${msg}`,
144
+ );
123
145
  const val = this.opts.parseCache(cached.data);
124
146
  this._mem = val;
125
147
  return val;
@@ -129,19 +151,26 @@ class DataSource<T> {
129
151
  }
130
152
  }
131
153
 
132
- private async _readCache(): Promise<{ ts: number; data: unknown } | undefined> {
154
+ private async _readCache(): Promise<
155
+ { ts: number; data: unknown } | undefined
156
+ > {
133
157
  try {
134
158
  const raw = await readFile(this.opts.cachePath, "utf8");
135
159
  const parsed = JSON.parse(raw) as { ts: number; data: unknown };
136
160
  if (typeof parsed.ts !== "number") return undefined;
137
161
  return parsed;
138
- } catch { return undefined; }
162
+ } catch {
163
+ return undefined;
164
+ }
139
165
  }
140
166
 
141
167
  private async _writeCache(data: unknown): Promise<void> {
142
168
  try {
143
169
  await mkdir(dirname(this.opts.cachePath), { recursive: true });
144
- await writeFile(this.opts.cachePath, JSON.stringify({ ts: Date.now(), data }));
170
+ await writeFile(
171
+ this.opts.cachePath,
172
+ JSON.stringify({ ts: Date.now(), data }),
173
+ );
145
174
  } catch {
146
175
  // Write failure is non-fatal
147
176
  }
@@ -150,12 +179,18 @@ class DataSource<T> {
150
179
 
151
180
  // ── Constants ──────────────────────────────────────────────────────────────────
152
181
 
153
- const CACHE_DIR = join(process.env.XDG_CACHE_HOME || join(homedir(), ".cache"), "pi");
182
+ const CACHE_DIR = join(
183
+ process.env.XDG_CACHE_HOME || join(homedir(), ".cache"),
184
+ "pi",
185
+ );
154
186
 
155
187
  const ROUTER_DEFAULT_BASE = "https://9router.example.com/v1";
156
188
 
157
189
  export function routerBaseUrl(): string {
158
- return (process.env.ROUTER_API_BASE || ROUTER_DEFAULT_BASE).replace(/\/$/, "");
190
+ return (process.env.ROUTER_API_BASE || ROUTER_DEFAULT_BASE).replace(
191
+ /\/$/,
192
+ "",
193
+ );
159
194
  }
160
195
 
161
196
  // ── Router models ──────────────────────────────────────────────────────────────
@@ -170,8 +205,12 @@ export const routerModels = new DataSource<RouterModel[]>({
170
205
  skip: () => !process.env.ROUTER_API_KEY,
171
206
  cachePath: join(CACHE_DIR, "9router.json"),
172
207
  ttlMs: 30 * 60 * 1000, // 30 minutes
173
- parse: (raw) => ((raw as RouterModelsResponse).data ?? []).filter((m) => Boolean(m.id)),
174
- parseCache: (data) => ((data as RouterModelsResponse | undefined)?.data ?? []).filter((m) => Boolean(m.id)),
208
+ parse: (raw) =>
209
+ ((raw as RouterModelsResponse).data ?? []).filter((m) => Boolean(m.id)),
210
+ parseCache: (data) =>
211
+ ((data as RouterModelsResponse | undefined)?.data ?? []).filter((m) =>
212
+ Boolean(m.id),
213
+ ),
175
214
  empty: [],
176
215
  });
177
216
 
@@ -187,7 +226,10 @@ const modelsDevCache = new DataSource<ModelsDevApi>({
187
226
  });
188
227
 
189
228
  function normalize(id: string): string {
190
- return id.toLowerCase().replace(/[:@].*$/, "").replace(/-\d{8}$/, "");
229
+ return id
230
+ .toLowerCase()
231
+ .replace(/[:@].*$/, "")
232
+ .replace(/-\d{8}$/, "");
191
233
  }
192
234
 
193
235
  function stripPrefix(id: string): string {
@@ -195,7 +237,9 @@ function stripPrefix(id: string): string {
195
237
  return i >= 0 ? id.slice(i + 1) : id;
196
238
  }
197
239
 
198
- export function buildModelsDevIndex(api: ModelsDevApi): Map<string, ModelsDevModel> {
240
+ export function buildModelsDevIndex(
241
+ api: ModelsDevApi,
242
+ ): Map<string, ModelsDevModel> {
199
243
  const index = new Map<string, ModelsDevModel>();
200
244
  for (const provider of Object.values(api)) {
201
245
  if (!provider?.models) continue;
@@ -209,7 +253,10 @@ export function buildModelsDevIndex(api: ModelsDevApi): Map<string, ModelsDevMod
209
253
  return index;
210
254
  }
211
255
 
212
- export function lookupInIndex(id: string, index: Map<string, ModelsDevModel>): ModelsDevModel | undefined {
256
+ export function lookupInIndex(
257
+ id: string,
258
+ index: Map<string, ModelsDevModel>,
259
+ ): ModelsDevModel | undefined {
213
260
  const stripped = stripPrefix(id);
214
261
  const direct = index.get(stripped) ?? index.get(normalize(stripped));
215
262
  if (direct) return direct;
@@ -220,6 +267,8 @@ export function lookupInIndex(id: string, index: Map<string, ModelsDevModel>): M
220
267
  return undefined;
221
268
  }
222
269
 
223
- export async function fetchModelsDevIndex(): Promise<Map<string, ModelsDevModel>> {
270
+ export async function fetchModelsDevIndex(): Promise<
271
+ Map<string, ModelsDevModel>
272
+ > {
224
273
  return buildModelsDevIndex(await modelsDevCache.get());
225
274
  }
package/src/fetch.ts CHANGED
@@ -6,10 +6,10 @@
6
6
  * ROUTER_API_KEY — bearer token for the router
7
7
  */
8
8
 
9
- import { execFile } from "node:child_process";
9
+ import { type ExecFileException, execFile } from "node:child_process";
10
+ import { StringEnum } from "@earendil-works/pi-ai";
10
11
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
11
12
  import { Type } from "typebox";
12
- import { StringEnum } from "@earendil-works/pi-ai";
13
13
  import { routerBaseUrl } from "./data.js";
14
14
 
15
15
  const REQUEST_TIMEOUT_MS = 30_000;
@@ -59,9 +59,10 @@ function curl(args: string[]): Promise<string> {
59
59
  { maxBuffer: 10 * 1024 * 1024, timeout: 30_000 },
60
60
  (err, stdout, stderr) => {
61
61
  if (err) {
62
- const msg = (err as NodeJS.ErrnoException).killed
62
+ const e = err as ExecFileException;
63
+ const msg = e.killed
63
64
  ? "curl timed out"
64
- : `curl exit ${(err as NodeJS.ErrnoException).code ?? "??"}: ${stderr.slice(0, 300)}`;
65
+ : `curl exit ${e.code ?? "??"}: ${stderr.slice(0, 300)}`;
65
66
  reject(new Error(msg));
66
67
  return;
67
68
  }
@@ -71,6 +72,39 @@ function curl(args: string[]): Promise<string> {
71
72
  });
72
73
  }
73
74
 
75
+ /**
76
+ * Extract readable page content from the exa fetch JSON envelope.
77
+ * Shape: { provider, url, title, content: { format, text }, ... }
78
+ * Falls back to the raw string when it isn't the expected JSON shape.
79
+ */
80
+ export function formatFetchResult(raw: string): string {
81
+ let parsed: unknown;
82
+ try {
83
+ parsed = JSON.parse(raw);
84
+ } catch {
85
+ return raw;
86
+ }
87
+ if (typeof parsed !== "object" || parsed === null) return raw;
88
+
89
+ const obj = parsed as {
90
+ title?: string | null;
91
+ url?: string | null;
92
+ content?: { text?: string | null } | string | null;
93
+ };
94
+
95
+ const text =
96
+ typeof obj.content === "string"
97
+ ? obj.content
98
+ : (obj.content?.text ?? undefined);
99
+ if (typeof text !== "string") return raw;
100
+
101
+ const header: string[] = [];
102
+ if (obj.title) header.push(`# ${obj.title}`);
103
+ if (obj.url) header.push(`URL: ${obj.url}`);
104
+
105
+ return header.length > 0 ? `${header.join("\n")}\n\n${text}` : text;
106
+ }
107
+
74
108
  export default function registerFetch(pi: ExtensionAPI): void {
75
109
  pi.registerTool({
76
110
  name: "fetch",
@@ -108,6 +142,7 @@ export default function registerFetch(pi: ExtensionAPI): void {
108
142
  content: [
109
143
  { type: "text", text: `Fetching (${fmt}): ${params.url}...` },
110
144
  ],
145
+ details: undefined,
111
146
  });
112
147
 
113
148
  const raw = await apiPost(
@@ -121,17 +156,18 @@ export default function registerFetch(pi: ExtensionAPI): void {
121
156
  signal,
122
157
  );
123
158
 
159
+ const formatted = formatFetchResult(raw);
124
160
  const truncated =
125
- maxChars > 0 && raw.length > maxChars
126
- ? raw.slice(0, maxChars) + "\n\n[truncated]"
127
- : raw;
161
+ maxChars > 0 && formatted.length > maxChars
162
+ ? `${formatted.slice(0, maxChars)}\n\n[truncated]`
163
+ : formatted;
128
164
 
129
165
  return {
130
166
  content: [{ type: "text", text: truncated.slice(0, 20_000) }],
131
167
  details: {
132
168
  source: "api",
133
- chars: raw.length,
134
- truncated: maxChars > 0 && raw.length > maxChars,
169
+ chars: formatted.length,
170
+ truncated: maxChars > 0 && formatted.length > maxChars,
135
171
  },
136
172
  };
137
173
  } catch (apiErr: unknown) {
@@ -143,13 +179,14 @@ export default function registerFetch(pi: ExtensionAPI): void {
143
179
  text: `API failed: ${apiMsg}\nFalling back to curl...`,
144
180
  },
145
181
  ],
182
+ details: undefined,
146
183
  });
147
184
  }
148
185
 
149
186
  try {
150
187
  let html = await curl(["-L", params.url]);
151
188
  if (maxChars > 0 && html.length > maxChars)
152
- html = html.slice(0, maxChars) + "\n\n[truncated]";
189
+ html = `${html.slice(0, maxChars)}\n\n[truncated]`;
153
190
 
154
191
  const banner =
155
192
  "[FALLBACK — raw curl] API unavailable, content may differ from exa formatted output.";
@@ -0,0 +1,95 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { formatFetchResult } from "./fetch.ts";
3
+ import { formatSearchResults } from "./search.ts";
4
+
5
+ describe("formatSearchResults", () => {
6
+ it("formats results as a numbered markdown list", () => {
7
+ const raw = JSON.stringify({
8
+ provider: "exa",
9
+ query: "test",
10
+ results: [
11
+ {
12
+ title: "First Result",
13
+ url: "https://example.com/a",
14
+ snippet: "Some snippet\n text here",
15
+ published_at: "2026-03-04T22:36:57.000Z",
16
+ metadata: { author: "alice" },
17
+ },
18
+ {
19
+ title: "",
20
+ url: "https://example.com/b",
21
+ snippet: null,
22
+ },
23
+ ],
24
+ });
25
+
26
+ const out = formatSearchResults(raw);
27
+ expect(out).toContain("1. First Result");
28
+ expect(out).toContain(" https://example.com/a");
29
+ expect(out).toContain("(alice — 2026-03-04)");
30
+ expect(out).toContain("Some snippet text here");
31
+ // untitled result falls back to url as title
32
+ expect(out).toContain("2. https://example.com/b");
33
+ // no raw JSON noise
34
+ expect(out).not.toContain("{");
35
+ expect(out).not.toContain('"provider"');
36
+ });
37
+
38
+ it("handles empty results", () => {
39
+ expect(formatSearchResults(JSON.stringify({ results: [] }))).toBe(
40
+ "No results.",
41
+ );
42
+ });
43
+
44
+ it("falls back to pretty JSON for unexpected shapes", () => {
45
+ const raw = JSON.stringify({ answer: "42" });
46
+ expect(formatSearchResults(raw)).toContain('"answer"');
47
+ });
48
+
49
+ it("returns raw string when not JSON", () => {
50
+ expect(formatSearchResults("plain text error")).toBe("plain text error");
51
+ });
52
+
53
+ it("truncates long snippets to 300 chars", () => {
54
+ const raw = JSON.stringify({
55
+ results: [
56
+ { title: "T", url: "https://x.dev", snippet: "y".repeat(1000) },
57
+ ],
58
+ });
59
+ const out = formatSearchResults(raw);
60
+ const snippetLine = out
61
+ .split("\n")
62
+ .find((l) => l.trimStart().startsWith("yyy"));
63
+ expect(snippetLine?.trim().length).toBe(300);
64
+ });
65
+ });
66
+
67
+ describe("formatFetchResult", () => {
68
+ it("extracts content.text with title/url header", () => {
69
+ const raw = JSON.stringify({
70
+ provider: "exa",
71
+ url: "https://example.com",
72
+ title: "Example Page",
73
+ content: { format: "markdown", text: "# Hello\n\nBody text." },
74
+ });
75
+
76
+ const out = formatFetchResult(raw);
77
+ expect(out).toBe(
78
+ "# Example Page\nURL: https://example.com\n\n# Hello\n\nBody text.",
79
+ );
80
+ });
81
+
82
+ it("supports content as a plain string", () => {
83
+ const raw = JSON.stringify({ content: "just text" });
84
+ expect(formatFetchResult(raw)).toBe("just text");
85
+ });
86
+
87
+ it("returns raw when content.text is missing", () => {
88
+ const raw = JSON.stringify({ status: "error" });
89
+ expect(formatFetchResult(raw)).toBe(raw);
90
+ });
91
+
92
+ it("returns raw when not JSON", () => {
93
+ expect(formatFetchResult("<html>raw</html>")).toBe("<html>raw</html>");
94
+ });
95
+ });
package/src/index.ts CHANGED
@@ -13,8 +13,8 @@
13
13
  */
14
14
 
15
15
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
16
- import registerProvider from "./provider.js";
17
16
  import registerFetch from "./fetch.js";
17
+ import registerProvider from "./provider.js";
18
18
  import registerSearch from "./search.js";
19
19
  import registerTranscribe from "./transcribe.js";
20
20
 
@@ -1,4 +1,4 @@
1
- import { describe, it, expect } from "bun:test";
1
+ import { describe, expect, it } from "bun:test";
2
2
  import type { ModelsDevModel, RouterModel } from "./data.ts";
3
3
 
4
4
  // ── Re-export internal helpers for testing via module augmentation ────────────
package/src/provider.ts CHANGED
@@ -10,13 +10,13 @@
10
10
  */
11
11
 
12
12
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
13
+ import type { ModelsDevModel, RouterModel } from "./data.js";
13
14
  import {
14
15
  fetchModelsDevIndex,
15
16
  lookupInIndex,
16
17
  routerBaseUrl,
17
18
  routerModels,
18
19
  } from "./data.js";
19
- import type { ModelsDevModel, RouterModel } from "./data.js";
20
20
 
21
21
  const DEFAULT_CONTEXT_WINDOW = 128_000;
22
22
  const DEFAULT_MAX_TOKENS = 16_384;
@@ -106,17 +106,21 @@ export default async function registerProvider(
106
106
  fetchModelsDevIndex().catch(() => new Map<string, ModelsDevModel>()),
107
107
  ]);
108
108
 
109
+ // Upstream moved OpenAI `compat` settings from the provider level to the
110
+ // per-model level (ProviderModelConfig.compat). Apply the same shim to every
111
+ // registered model.
112
+ const COMPAT = {
113
+ supportsDeveloperRole: false,
114
+ supportsUsageInStreaming: false,
115
+ maxTokensField: "max_tokens",
116
+ } as const;
117
+
109
118
  pi.registerProvider("9router", {
110
119
  name: "9Router",
111
120
  baseUrl: routerBaseUrl(),
112
121
  apiKey: "$ROUTER_API_KEY",
113
122
  api: "openai-completions",
114
123
  headers: { "User-Agent": "pi-coding-agent" },
115
- compat: {
116
- supportsDeveloperRole: false,
117
- supportsUsageInStreaming: false,
118
- maxTokensField: "max_tokens",
119
- },
120
124
  models: models.map((model) => {
121
125
  const devModel = lookupInIndex(model.id!, devIndex);
122
126
  return {
@@ -127,6 +131,7 @@ export default async function registerProvider(
127
131
  cost: ZERO_COST,
128
132
  contextWindow: getContextWindow(model, devModel),
129
133
  maxTokens: getMaxTokens(model, devModel),
134
+ compat: COMPAT,
130
135
  };
131
136
  }),
132
137
  });
package/src/search.ts CHANGED
@@ -6,10 +6,10 @@
6
6
  * ROUTER_API_KEY — bearer token for the router
7
7
  */
8
8
 
9
- import { execFile } from "node:child_process";
9
+ import { type ExecFileException, execFile } from "node:child_process";
10
+ import { StringEnum } from "@earendil-works/pi-ai";
10
11
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
11
12
  import { Type } from "typebox";
12
- import { StringEnum } from "@earendil-works/pi-ai";
13
13
  import { routerBaseUrl } from "./data.js";
14
14
 
15
15
  const REQUEST_TIMEOUT_MS = 30_000;
@@ -59,9 +59,10 @@ function curl(args: string[], stdin?: string): Promise<string> {
59
59
  { maxBuffer: 10 * 1024 * 1024, timeout: 30_000 },
60
60
  (err, stdout, stderr) => {
61
61
  if (err) {
62
- const msg = (err as NodeJS.ErrnoException).killed
62
+ const e = err as ExecFileException;
63
+ const msg = e.killed
63
64
  ? "curl timed out"
64
- : `curl exit ${(err as NodeJS.ErrnoException).code ?? "??"}: ${stderr.slice(0, 300)}`;
65
+ : `curl exit ${e.code ?? "??"}: ${stderr.slice(0, 300)}`;
65
66
  reject(new Error(msg));
66
67
  return;
67
68
  }
@@ -75,6 +76,53 @@ function curl(args: string[], stdin?: string): Promise<string> {
75
76
  });
76
77
  }
77
78
 
79
+ interface SearchResultItem {
80
+ title?: string | null;
81
+ url?: string | null;
82
+ snippet?: string | null;
83
+ published_at?: string | null;
84
+ metadata?: { author?: string | null } | null;
85
+ author?: string | null;
86
+ }
87
+
88
+ /**
89
+ * Format a raw exa search response as a compact markdown list.
90
+ * Falls back to pretty-printed JSON when the shape is unexpected.
91
+ */
92
+ export function formatSearchResults(raw: string): string {
93
+ let parsed: unknown;
94
+ try {
95
+ parsed = JSON.parse(raw);
96
+ } catch {
97
+ return raw.slice(0, 20_000);
98
+ }
99
+
100
+ const obj = parsed as Record<string, unknown>;
101
+ const results = obj?.results;
102
+ if (!Array.isArray(results)) {
103
+ return JSON.stringify(parsed, null, 2).slice(0, 20_000);
104
+ }
105
+
106
+ if (results.length === 0) return "No results.";
107
+
108
+ const lines: string[] = [];
109
+ results.forEach((r: SearchResultItem, i: number) => {
110
+ const title = (r.title ?? "").trim() || r.url || "(untitled)";
111
+ lines.push(`${i + 1}. ${title}`);
112
+ if (r.url) lines.push(` ${r.url}`);
113
+ const meta: string[] = [];
114
+ const author = r.author ?? r.metadata?.author;
115
+ if (author) meta.push(author);
116
+ if (r.published_at) meta.push(r.published_at.slice(0, 10));
117
+ if (meta.length > 0) lines.push(` (${meta.join(" — ")})`);
118
+ const snippet = (r.snippet ?? "").trim();
119
+ if (snippet) lines.push(` ${snippet.replace(/\s+/g, " ").slice(0, 300)}`);
120
+ lines.push("");
121
+ });
122
+
123
+ return lines.join("\n").trim().slice(0, 20_000);
124
+ }
125
+
78
126
  export default function registerSearch(pi: ExtensionAPI): void {
79
127
  pi.registerTool({
80
128
  name: "search",
@@ -114,6 +162,7 @@ export default function registerSearch(pi: ExtensionAPI): void {
114
162
  text: `Searching (${searchType}): ${params.query}...`,
115
163
  },
116
164
  ],
165
+ details: undefined,
117
166
  });
118
167
 
119
168
  const raw = await apiPost(
@@ -127,18 +176,11 @@ export default function registerSearch(pi: ExtensionAPI): void {
127
176
  signal,
128
177
  );
129
178
 
130
- let parsed: unknown;
131
- try {
132
- parsed = JSON.parse(raw);
133
- } catch {
134
- parsed = raw;
135
- }
136
-
137
179
  return {
138
180
  content: [
139
181
  {
140
182
  type: "text",
141
- text: JSON.stringify(parsed, null, 2).slice(0, 20_000),
183
+ text: formatSearchResults(raw),
142
184
  },
143
185
  ],
144
186
  details: { source: "api" },
@@ -152,6 +194,7 @@ export default function registerSearch(pi: ExtensionAPI): void {
152
194
  text: `API failed: ${apiMsg}\nFalling back to curl...`,
153
195
  },
154
196
  ],
197
+ details: undefined,
155
198
  });
156
199
  }
157
200
 
@@ -173,18 +216,11 @@ export default function registerSearch(pi: ExtensionAPI): void {
173
216
  `${routerBaseUrl()}/search`,
174
217
  ]);
175
218
 
176
- let parsed: unknown;
177
- try {
178
- parsed = JSON.parse(raw);
179
- } catch {
180
- parsed = raw;
181
- }
182
-
183
219
  return {
184
220
  content: [
185
221
  {
186
222
  type: "text",
187
- text: `[FALLBACK — curl] API called via curl instead of fetch.\n\n${JSON.stringify(parsed, null, 2).slice(0, 19_500)}`,
223
+ text: `[FALLBACK — curl] API called via curl instead of fetch.\n\n${formatSearchResults(raw).slice(0, 19_500)}`,
188
224
  },
189
225
  ],
190
226
  details: { source: "curl-fallback" },
@@ -1,4 +1,4 @@
1
- import { describe, it, expect } from "bun:test";
1
+ import { describe, expect, it } from "bun:test";
2
2
  import { extname } from "node:path";
3
3
 
4
4
  // ── Re-export internal helpers for testing via module augmentation ────────────
package/src/transcribe.ts CHANGED
@@ -11,8 +11,8 @@
11
11
  * ROUTER_API_KEY — bearer token for the router
12
12
  */
13
13
 
14
+ import { type ExecFileException, execFile } from "node:child_process";
14
15
  import { readFile } from "node:fs/promises";
15
- import { execFile } from "node:child_process";
16
16
  import { basename, extname } from "node:path";
17
17
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
18
18
  import { Type } from "typebox";
@@ -91,9 +91,10 @@ function curl(args: string[]): Promise<string> {
91
91
  { maxBuffer: 10 * 1024 * 1024, timeout: REQUEST_TIMEOUT_MS },
92
92
  (err, stdout, stderr) => {
93
93
  if (err) {
94
- const msg = (err as NodeJS.ErrnoException).killed
94
+ const e = err as ExecFileException;
95
+ const msg = e.killed
95
96
  ? "curl timed out"
96
- : `curl exit ${(err as NodeJS.ErrnoException).code ?? "??"}: ${stderr.slice(0, 300)}`;
97
+ : `curl exit ${e.code ?? "??"}: ${stderr.slice(0, 300)}`;
97
98
  reject(new Error(msg));
98
99
  return;
99
100
  }
@@ -149,6 +150,7 @@ export default function registerTranscribe(pi: ExtensionAPI): void {
149
150
  text: `Transcribing: ${filePath} (model: ${model})...`,
150
151
  },
151
152
  ],
153
+ details: undefined,
152
154
  });
153
155
 
154
156
  const raw = await apiMultipart(
@@ -185,6 +187,7 @@ export default function registerTranscribe(pi: ExtensionAPI): void {
185
187
  text: `API failed: ${apiMsg}\nFalling back to curl...`,
186
188
  },
187
189
  ],
190
+ details: undefined,
188
191
  });
189
192
  }
190
193