@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 +45 -38
- package/src/data.test.ts +2 -2
- package/src/data.ts +70 -21
- package/src/fetch.ts +50 -10
- package/src/format.test.ts +95 -0
- package/src/index.ts +1 -1
- package/src/provider.test.ts +1 -1
- package/src/provider.ts +11 -6
- package/src/render.test.ts +78 -0
- package/src/render.ts +111 -0
- package/src/search.ts +59 -20
- package/src/transcribe.test.ts +1 -1
- package/src/transcribe.ts +6 -3
package/package.json
CHANGED
|
@@ -1,40 +1,47 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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 {
|
|
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?: {
|
|
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<
|
|
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(() => {
|
|
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 {
|
|
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()) {
|
|
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 =
|
|
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, {
|
|
113
|
-
|
|
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(
|
|
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<
|
|
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 {
|
|
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(
|
|
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(
|
|
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) =>
|
|
174
|
-
|
|
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
|
|
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(
|
|
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(
|
|
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<
|
|
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
|
|
63
|
+
const e = err as ExecFileException;
|
|
64
|
+
const msg = e.killed
|
|
63
65
|
? "curl timed out"
|
|
64
|
-
: `curl exit ${
|
|
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 &&
|
|
126
|
-
?
|
|
127
|
-
:
|
|
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:
|
|
134
|
-
truncated: maxChars > 0 &&
|
|
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)
|
|
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
|
|
package/src/provider.test.ts
CHANGED
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
|
|
63
|
+
const e = err as ExecFileException;
|
|
64
|
+
const msg = e.killed
|
|
63
65
|
? "curl timed out"
|
|
64
|
-
: `curl exit ${
|
|
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:
|
|
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${
|
|
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" },
|
package/src/transcribe.test.ts
CHANGED
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
|
|
94
|
+
const e = err as ExecFileException;
|
|
95
|
+
const msg = e.killed
|
|
95
96
|
? "curl timed out"
|
|
96
|
-
: `curl exit ${
|
|
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
|
|