@xynogen/pix-9router 0.1.0 → 0.2.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/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.2.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": [
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,11 +6,12 @@
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
+ import { makeRenderCall, makeRenderResult } from "./render.js";
14
15
 
15
16
  const REQUEST_TIMEOUT_MS = 30_000;
16
17
 
@@ -59,9 +60,10 @@ function curl(args: string[]): Promise<string> {
59
60
  { maxBuffer: 10 * 1024 * 1024, timeout: 30_000 },
60
61
  (err, stdout, stderr) => {
61
62
  if (err) {
62
- const msg = (err as NodeJS.ErrnoException).killed
63
+ const e = err as ExecFileException;
64
+ const msg = e.killed
63
65
  ? "curl timed out"
64
- : `curl exit ${(err as NodeJS.ErrnoException).code ?? "??"}: ${stderr.slice(0, 300)}`;
66
+ : `curl exit ${e.code ?? "??"}: ${stderr.slice(0, 300)}`;
65
67
  reject(new Error(msg));
66
68
  return;
67
69
  }
@@ -71,6 +73,39 @@ function curl(args: string[]): Promise<string> {
71
73
  });
72
74
  }
73
75
 
76
+ /**
77
+ * Extract readable page content from the exa fetch JSON envelope.
78
+ * Shape: { provider, url, title, content: { format, text }, ... }
79
+ * Falls back to the raw string when it isn't the expected JSON shape.
80
+ */
81
+ export function formatFetchResult(raw: string): string {
82
+ let parsed: unknown;
83
+ try {
84
+ parsed = JSON.parse(raw);
85
+ } catch {
86
+ return raw;
87
+ }
88
+ if (typeof parsed !== "object" || parsed === null) return raw;
89
+
90
+ const obj = parsed as {
91
+ title?: string | null;
92
+ url?: string | null;
93
+ content?: { text?: string | null } | string | null;
94
+ };
95
+
96
+ const text =
97
+ typeof obj.content === "string"
98
+ ? obj.content
99
+ : (obj.content?.text ?? undefined);
100
+ if (typeof text !== "string") return raw;
101
+
102
+ const header: string[] = [];
103
+ if (obj.title) header.push(`# ${obj.title}`);
104
+ if (obj.url) header.push(`URL: ${obj.url}`);
105
+
106
+ return header.length > 0 ? `${header.join("\n")}\n\n${text}` : text;
107
+ }
108
+
74
109
  export default function registerFetch(pi: ExtensionAPI): void {
75
110
  pi.registerTool({
76
111
  name: "fetch",
@@ -85,6 +120,8 @@ export default function registerFetch(pi: ExtensionAPI): void {
85
120
  "Set max_characters to cap response size (default 1000, 0 = unlimited). Use 5000-10000 for typical pages.",
86
121
  "Always prefer fetch over raw curl/browser for reading page content.",
87
122
  ],
123
+ renderCall: makeRenderCall("fetch", (args) => String(args.url ?? "")),
124
+ renderResult: makeRenderResult(),
88
125
  parameters: Type.Object({
89
126
  url: Type.String({ description: "URL to fetch" }),
90
127
  format: StringEnum(["markdown", "text", "html"] as const, {
@@ -108,6 +145,7 @@ export default function registerFetch(pi: ExtensionAPI): void {
108
145
  content: [
109
146
  { type: "text", text: `Fetching (${fmt}): ${params.url}...` },
110
147
  ],
148
+ details: undefined,
111
149
  });
112
150
 
113
151
  const raw = await apiPost(
@@ -121,17 +159,18 @@ export default function registerFetch(pi: ExtensionAPI): void {
121
159
  signal,
122
160
  );
123
161
 
162
+ const formatted = formatFetchResult(raw);
124
163
  const truncated =
125
- maxChars > 0 && raw.length > maxChars
126
- ? raw.slice(0, maxChars) + "\n\n[truncated]"
127
- : raw;
164
+ maxChars > 0 && formatted.length > maxChars
165
+ ? `${formatted.slice(0, maxChars)}\n\n[truncated]`
166
+ : formatted;
128
167
 
129
168
  return {
130
169
  content: [{ type: "text", text: truncated.slice(0, 20_000) }],
131
170
  details: {
132
171
  source: "api",
133
- chars: raw.length,
134
- truncated: maxChars > 0 && raw.length > maxChars,
172
+ chars: formatted.length,
173
+ truncated: maxChars > 0 && formatted.length > maxChars,
135
174
  },
136
175
  };
137
176
  } catch (apiErr: unknown) {
@@ -143,13 +182,14 @@ export default function registerFetch(pi: ExtensionAPI): void {
143
182
  text: `API failed: ${apiMsg}\nFalling back to curl...`,
144
183
  },
145
184
  ],
185
+ details: undefined,
146
186
  });
147
187
  }
148
188
 
149
189
  try {
150
190
  let html = await curl(["-L", params.url]);
151
191
  if (maxChars > 0 && html.length > maxChars)
152
- html = html.slice(0, maxChars) + "\n\n[truncated]";
192
+ html = `${html.slice(0, maxChars)}\n\n[truncated]`;
153
193
 
154
194
  const banner =
155
195
  "[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
  });
@@ -0,0 +1,78 @@
1
+ import { expect, test } from "bun:test";
2
+ import { makeRenderCall, makeRenderResult } from "./render.js";
3
+
4
+ const theme = {
5
+ fg: (t: string, s: string) => `[${t}]${s}`,
6
+ bold: (s: string) => `*${s}*`,
7
+ } as never;
8
+
9
+ function stubComponent() {
10
+ let captured = "";
11
+ const component = {
12
+ setText: (s: string) => {
13
+ captured = s;
14
+ },
15
+ render: () => [],
16
+ invalidate: () => {},
17
+ };
18
+ return { component, get: () => captured };
19
+ }
20
+
21
+ test("renderCall shows title + accent arg", () => {
22
+ const stub = stubComponent();
23
+ 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
+ });
28
+ expect(stub.get()).toBe("[toolTitle]*fetch* [accent]https://x.com");
29
+ });
30
+
31
+ test("renderResult dims every line", () => {
32
+ const stub = stubComponent();
33
+ const rr = makeRenderResult();
34
+ rr(
35
+ { content: [{ type: "text", text: "a\nb" }] } as never,
36
+ { expanded: true, isPartial: false },
37
+ theme,
38
+ { lastComponent: stub.component as never, isError: false },
39
+ );
40
+ expect(stub.get()).toBe(" [dim]a\n [dim]b");
41
+ });
42
+
43
+ test("renderResult caps preview when not expanded", () => {
44
+ const stub = stubComponent();
45
+ const rr = makeRenderResult();
46
+ const body = Array.from({ length: 45 }, (_, i) => `L${i}`).join("\n");
47
+ rr(
48
+ { content: [{ type: "text", text: body }] } as never,
49
+ { expanded: false, isPartial: false },
50
+ theme,
51
+ { lastComponent: stub.component as never, isError: false },
52
+ );
53
+ expect(stub.get()).toContain("… 5 more lines");
54
+ });
55
+
56
+ test("renderResult renders empty body marker", () => {
57
+ const stub = stubComponent();
58
+ const rr = makeRenderResult();
59
+ rr(
60
+ { content: [{ type: "text", text: " " }] } as never,
61
+ { expanded: false, isPartial: false },
62
+ theme,
63
+ { lastComponent: stub.component as never, isError: false },
64
+ );
65
+ expect(stub.get()).toBe(" [dim](empty)");
66
+ });
67
+
68
+ test("renderResult uses error token on failure", () => {
69
+ const stub = stubComponent();
70
+ const rr = makeRenderResult();
71
+ rr(
72
+ { content: [{ type: "text", text: "boom" }] } as never,
73
+ { expanded: false, isPartial: false },
74
+ theme,
75
+ { lastComponent: stub.component as never, isError: true },
76
+ );
77
+ expect(stub.get()).toBe(" [error]boom");
78
+ });
package/src/render.ts ADDED
@@ -0,0 +1,111 @@
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
+
20
+ interface TextLike extends Component {
21
+ setText(text: string): void;
22
+ }
23
+
24
+ // ToolRenderContext is not re-exported by pi-coding-agent; model the fields the
25
+ // renderers actually read. Structurally compatible with the SDK context.
26
+ interface RenderCtx {
27
+ lastComponent: Component | undefined;
28
+ isError: boolean;
29
+ }
30
+
31
+ type TextCtor = new (text?: string, padX?: number, padY?: number) => TextLike;
32
+
33
+ // Preview cap is intentionally generous — dimming already de-emphasises the
34
+ // body, so we only truncate to keep pathological multi-thousand-line dumps
35
+ // from flooding the viewport. Expanding shows everything.
36
+ const MAX_PREVIEW_LINES = 40;
37
+
38
+ let TextComponent: TextCtor | undefined;
39
+ try {
40
+ TextComponent = (require("@earendil-works/pi-tui") as { Text: TextCtor })
41
+ .Text;
42
+ } catch {
43
+ TextComponent = undefined;
44
+ }
45
+
46
+ function getText(lastComponent: Component | undefined): TextLike {
47
+ if (lastComponent && "setText" in lastComponent)
48
+ return lastComponent as TextLike;
49
+ // TextComponent is always present in the interactive TUI (pi-tui peer); the
50
+ // require() guard only matters for headless/test contexts where renderers
51
+ // are never invoked.
52
+ if (TextComponent) return new TextComponent("", 0, 0);
53
+ throw new Error("pi-tui Text component unavailable");
54
+ }
55
+
56
+ function firstText(result: AgentToolResult<unknown>): string {
57
+ const block = result.content?.find((c) => c.type === "text");
58
+ return (block as { text?: string } | undefined)?.text ?? "";
59
+ }
60
+
61
+ function dimBody(body: string, theme: Theme, expanded: boolean): string {
62
+ const lines = body.split("\n");
63
+ const maxShow = expanded ? lines.length : MAX_PREVIEW_LINES;
64
+ const shown = lines.slice(0, maxShow);
65
+ const out = shown.map((line) => ` ${theme.fg("dim", line)}`);
66
+ const remaining = lines.length - maxShow;
67
+ if (remaining > 0) {
68
+ out.push(` ${theme.fg("dim", `… ${remaining} more lines`)}`);
69
+ }
70
+ return out.join("\n");
71
+ }
72
+
73
+ /** Build a `renderCall` that shows `<title> <accent arg>`. */
74
+ export function makeRenderCall<TArgs>(
75
+ title: string,
76
+ pickArg: (args: TArgs) => string,
77
+ ) {
78
+ return (args: TArgs, theme: Theme, ctx: RenderCtx): Component => {
79
+ const text = getText(ctx.lastComponent);
80
+ const arg = pickArg(args);
81
+ text.setText(
82
+ `${theme.fg("toolTitle", theme.bold(title))} ${theme.fg("accent", arg)}`,
83
+ );
84
+ return text;
85
+ };
86
+ }
87
+
88
+ /** Build a `renderResult` that dims the whole result body. */
89
+ export function makeRenderResult() {
90
+ return (
91
+ result: AgentToolResult<unknown>,
92
+ opts: ToolRenderResultOptions,
93
+ theme: Theme,
94
+ ctx: RenderCtx,
95
+ ): Component => {
96
+ const text = getText(ctx.lastComponent);
97
+ const body = firstText(result);
98
+
99
+ if (ctx.isError) {
100
+ text.setText(` ${theme.fg("error", body || "Error")}`);
101
+ return text;
102
+ }
103
+ if (!body.trim()) {
104
+ text.setText(` ${theme.fg("dim", "(empty)")}`);
105
+ return text;
106
+ }
107
+
108
+ text.setText(dimBody(body, theme, opts.expanded));
109
+ return text;
110
+ };
111
+ }
package/src/search.ts CHANGED
@@ -6,11 +6,12 @@
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
+ import { makeRenderCall, makeRenderResult } from "./render.js";
14
15
 
15
16
  const REQUEST_TIMEOUT_MS = 30_000;
16
17
 
@@ -59,9 +60,10 @@ function curl(args: string[], stdin?: string): Promise<string> {
59
60
  { maxBuffer: 10 * 1024 * 1024, timeout: 30_000 },
60
61
  (err, stdout, stderr) => {
61
62
  if (err) {
62
- const msg = (err as NodeJS.ErrnoException).killed
63
+ const e = err as ExecFileException;
64
+ const msg = e.killed
63
65
  ? "curl timed out"
64
- : `curl exit ${(err as NodeJS.ErrnoException).code ?? "??"}: ${stderr.slice(0, 300)}`;
66
+ : `curl exit ${e.code ?? "??"}: ${stderr.slice(0, 300)}`;
65
67
  reject(new Error(msg));
66
68
  return;
67
69
  }
@@ -75,6 +77,53 @@ function curl(args: string[], stdin?: string): Promise<string> {
75
77
  });
76
78
  }
77
79
 
80
+ interface SearchResultItem {
81
+ title?: string | null;
82
+ url?: string | null;
83
+ snippet?: string | null;
84
+ published_at?: string | null;
85
+ metadata?: { author?: string | null } | null;
86
+ author?: string | null;
87
+ }
88
+
89
+ /**
90
+ * Format a raw exa search response as a compact markdown list.
91
+ * Falls back to pretty-printed JSON when the shape is unexpected.
92
+ */
93
+ export function formatSearchResults(raw: string): string {
94
+ let parsed: unknown;
95
+ try {
96
+ parsed = JSON.parse(raw);
97
+ } catch {
98
+ return raw.slice(0, 20_000);
99
+ }
100
+
101
+ const obj = parsed as Record<string, unknown>;
102
+ const results = obj?.results;
103
+ if (!Array.isArray(results)) {
104
+ return JSON.stringify(parsed, null, 2).slice(0, 20_000);
105
+ }
106
+
107
+ if (results.length === 0) return "No results.";
108
+
109
+ const lines: string[] = [];
110
+ results.forEach((r: SearchResultItem, i: number) => {
111
+ const title = (r.title ?? "").trim() || r.url || "(untitled)";
112
+ lines.push(`${i + 1}. ${title}`);
113
+ if (r.url) lines.push(` ${r.url}`);
114
+ const meta: string[] = [];
115
+ const author = r.author ?? r.metadata?.author;
116
+ if (author) meta.push(author);
117
+ if (r.published_at) meta.push(r.published_at.slice(0, 10));
118
+ if (meta.length > 0) lines.push(` (${meta.join(" — ")})`);
119
+ const snippet = (r.snippet ?? "").trim();
120
+ if (snippet) lines.push(` ${snippet.replace(/\s+/g, " ").slice(0, 300)}`);
121
+ lines.push("");
122
+ });
123
+
124
+ return lines.join("\n").trim().slice(0, 20_000);
125
+ }
126
+
78
127
  export default function registerSearch(pi: ExtensionAPI): void {
79
128
  pi.registerTool({
80
129
  name: "search",
@@ -88,6 +137,8 @@ export default function registerSearch(pi: ExtensionAPI): void {
88
137
  "Set search_type='web' for general web results, search_type='news' for recent news articles.",
89
138
  "Always prefer search over raw curl/browser requests for information lookup.",
90
139
  ],
140
+ renderCall: makeRenderCall("search", (args) => String(args.query ?? "")),
141
+ renderResult: makeRenderResult(),
91
142
  parameters: Type.Object({
92
143
  query: Type.String({ description: "Search query" }),
93
144
  search_type: StringEnum(["web", "news"] as const, {
@@ -114,6 +165,7 @@ export default function registerSearch(pi: ExtensionAPI): void {
114
165
  text: `Searching (${searchType}): ${params.query}...`,
115
166
  },
116
167
  ],
168
+ details: undefined,
117
169
  });
118
170
 
119
171
  const raw = await apiPost(
@@ -127,18 +179,11 @@ export default function registerSearch(pi: ExtensionAPI): void {
127
179
  signal,
128
180
  );
129
181
 
130
- let parsed: unknown;
131
- try {
132
- parsed = JSON.parse(raw);
133
- } catch {
134
- parsed = raw;
135
- }
136
-
137
182
  return {
138
183
  content: [
139
184
  {
140
185
  type: "text",
141
- text: JSON.stringify(parsed, null, 2).slice(0, 20_000),
186
+ text: formatSearchResults(raw),
142
187
  },
143
188
  ],
144
189
  details: { source: "api" },
@@ -152,6 +197,7 @@ export default function registerSearch(pi: ExtensionAPI): void {
152
197
  text: `API failed: ${apiMsg}\nFalling back to curl...`,
153
198
  },
154
199
  ],
200
+ details: undefined,
155
201
  });
156
202
  }
157
203
 
@@ -173,18 +219,11 @@ export default function registerSearch(pi: ExtensionAPI): void {
173
219
  `${routerBaseUrl()}/search`,
174
220
  ]);
175
221
 
176
- let parsed: unknown;
177
- try {
178
- parsed = JSON.parse(raw);
179
- } catch {
180
- parsed = raw;
181
- }
182
-
183
222
  return {
184
223
  content: [
185
224
  {
186
225
  type: "text",
187
- text: `[FALLBACK — curl] API called via curl instead of fetch.\n\n${JSON.stringify(parsed, null, 2).slice(0, 19_500)}`,
226
+ text: `[FALLBACK — curl] API called via curl instead of fetch.\n\n${formatSearchResults(raw).slice(0, 19_500)}`,
188
227
  },
189
228
  ],
190
229
  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