@haoyiyin/9router 0.1.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.
@@ -0,0 +1,468 @@
1
+ /**
2
+ * transcribe.ts — speech-to-text tool via 9Router audio transcription API, with curl fallback.
3
+ *
4
+ * Uses the OpenAI-compatible /audio/transcriptions endpoint through the router.
5
+ * Accepts a file path to an audio file and returns the transcribed text.
6
+ *
7
+ * Default model: dg/nova-3 (Deepgram Nova 3)
8
+ *
9
+ * Environment:
10
+ * ROUTER_API_BASE — router API base URL (default: https://9router.example.com/v1)
11
+ * ROUTER_API_KEY — bearer token for the router
12
+ */
13
+
14
+ import { constants as fsConstants } from "node:fs";
15
+ import { access, lstat, mkdir, readFile, writeFile } from "node:fs/promises";
16
+ import { homedir } from "node:os";
17
+ import { basename, dirname, extname, isAbsolute, resolve } from "node:path";
18
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
19
+ import { ioTimeoutSignal } from "@xynogen/pix-runtime/io";
20
+ import { Type } from "typebox";
21
+ import { routerBaseUrl } from "./data.ts";
22
+ import { auth, curl } from "./http.ts";
23
+ import { makeRenderCall, makeRenderResult } from "./render.ts";
24
+
25
+ const CHAT_TRUNCATE_LIMIT = 50_000; // only when no output_file is provided
26
+ const DEFAULT_MODEL = "dg/nova-3";
27
+
28
+ type TranscribeOutcome = "running" | "success" | "cancelled" | "error";
29
+
30
+ export interface TranscribeResultDetails {
31
+ _type: "transcribeResult";
32
+ outcome: TranscribeOutcome;
33
+ file: string;
34
+ model: string;
35
+ language?: string;
36
+ source?: "api" | "curl-fallback" | "failed";
37
+ chars?: number;
38
+ truncated?: boolean;
39
+ output_path?: string;
40
+ write_error?: string;
41
+ }
42
+
43
+ interface TranscribeResult {
44
+ content: { type: "text"; text: string }[];
45
+ details: TranscribeResultDetails;
46
+ isError?: boolean;
47
+ }
48
+
49
+ /** Map file extension to MIME type for common audio formats. */
50
+ export function mimeType(filePath: string): string {
51
+ const ext = extname(filePath).toLowerCase();
52
+ const types: Record<string, string> = {
53
+ ".mp3": "audio/mpeg",
54
+ ".wav": "audio/wav",
55
+ ".flac": "audio/flac",
56
+ ".ogg": "audio/ogg",
57
+ ".m4a": "audio/mp4",
58
+ ".webm": "audio/webm",
59
+ ".mp4": "audio/mp4",
60
+ ".mpga": "audio/mpeg",
61
+ };
62
+ return types[ext] ?? "application/octet-stream";
63
+ }
64
+
65
+ async function apiMultipart(
66
+ path: string,
67
+ filePath: string,
68
+ model: string,
69
+ language: string | undefined,
70
+ signal?: AbortSignal,
71
+ ): Promise<string> {
72
+ const url = `${routerBaseUrl()}${path}`;
73
+ const key = auth();
74
+ const requestSignal = ioTimeoutSignal(signal);
75
+ const fileData = await readFile(filePath, { signal: requestSignal });
76
+ const blob = new Blob([fileData], { type: mimeType(filePath) });
77
+
78
+ const form = new FormData();
79
+ form.append("file", blob, basename(filePath));
80
+ form.append("model", model);
81
+ if (language) form.append("language", language);
82
+
83
+ const res = await fetch(url, {
84
+ method: "POST",
85
+ headers: {
86
+ ...(key ? { Authorization: `Bearer ${key}` } : {}),
87
+ },
88
+ body: form,
89
+ signal: requestSignal,
90
+ });
91
+ if (!res.ok) {
92
+ const errText = await res.text().catch(() => "");
93
+ throw new Error(`API ${res.status}: ${errText.slice(0, 500)}`);
94
+ }
95
+ return res.text();
96
+ }
97
+
98
+ /** Extract the `text` field from a JSON envelope, or return the raw string. */
99
+ export function parseTranscriptionResponse(raw: string): string {
100
+ try {
101
+ const parsed = JSON.parse(raw) as { text?: string };
102
+ return parsed.text ?? raw;
103
+ } catch {
104
+ return raw;
105
+ }
106
+ }
107
+
108
+ /** Resolve a possibly-relative `output_file` to an absolute path. */
109
+ export function resolveOutputPath(outputFile: string): string {
110
+ return isAbsolute(outputFile) ? outputFile : resolve(process.cwd(), outputFile);
111
+ }
112
+
113
+ /**
114
+ * Pre-flight safety check for `output_file`. Rejects paths that target sensitive
115
+ * system locations, null bytes, non-existent or non-writable parents, symlinks
116
+ * (at the target or anywhere in the parent chain), and existing-directory targets.
117
+ *
118
+ * Returns a discriminated result so the caller can surface a precise reason to
119
+ * the model without leaking OS internals.
120
+ */
121
+ export type PathValidation = { ok: true; path: string } | { ok: false; reason: string };
122
+
123
+ /** Block-list of absolute prefixes that should never receive transcription output. */
124
+ function sensitivePrefixes(): string[] {
125
+ const home = homedir();
126
+ return [
127
+ "/etc",
128
+ "/proc",
129
+ "/sys",
130
+ "/boot",
131
+ `${home}/.ssh`,
132
+ `${home}/.aws`,
133
+ `${home}/.gnupg`,
134
+ `${home}/.config/gh`,
135
+ ];
136
+ }
137
+
138
+ export async function validateOutputPath(absPath: string): Promise<PathValidation> {
139
+ // 1. Null byte injection guard (defence in depth — Node already rejects, fail fast with clear msg)
140
+ if (absPath.includes("\0")) {
141
+ return { ok: false, reason: "path contains a null byte" };
142
+ }
143
+
144
+ // 2. Sensitive prefix block-list
145
+ for (const prefix of sensitivePrefixes()) {
146
+ if (absPath === prefix || absPath.startsWith(`${prefix}/`)) {
147
+ return { ok: false, reason: `refusing to write under ${prefix}` };
148
+ }
149
+ }
150
+
151
+ // 3. Target must not be a symlink and must not be an existing directory.
152
+ // (lstat does NOT follow symlinks — that's the whole point of using it here.)
153
+ try {
154
+ const st = await lstat(absPath);
155
+ if (st.isSymbolicLink()) {
156
+ return { ok: false, reason: `target is a symlink: ${absPath}` };
157
+ }
158
+ if (st.isDirectory()) {
159
+ return {
160
+ ok: false,
161
+ reason: `target is an existing directory: ${absPath}`,
162
+ };
163
+ }
164
+ } catch (err) {
165
+ // ENOENT is fine — we'll create the file. Anything else is a hard fail.
166
+ if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
167
+ return {
168
+ ok: false,
169
+ reason: `cannot stat target: ${(err as Error).message}`,
170
+ };
171
+ }
172
+ }
173
+
174
+ // 4. Walk up the parent chain. For every ancestor that EXISTS, it must
175
+ // (a) not be a symlink (stops /tmp/safe-looking-dir → /etc redirect), and
176
+ // (b) be writable so mkdir -p can create missing intermediates.
177
+ // Ancestors that don't exist (ENOENT) are fine — mkdir -p will create them.
178
+ const parent = dirname(absPath);
179
+ let cursor = parent;
180
+ let nearestExisting: string | null = null;
181
+ while (cursor !== dirname(cursor)) {
182
+ let st: Awaited<ReturnType<typeof lstat>>;
183
+ try {
184
+ st = await lstat(cursor);
185
+ } catch (err) {
186
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") {
187
+ // intermediate doesn't exist yet — keep walking up
188
+ cursor = dirname(cursor);
189
+ continue;
190
+ }
191
+ return {
192
+ ok: false,
193
+ reason: `cannot stat parent: ${(err as Error).message}`,
194
+ };
195
+ }
196
+ if (st.isSymbolicLink()) {
197
+ return { ok: false, reason: `parent is a symlink: ${cursor}` };
198
+ }
199
+ if (st.isDirectory() && nearestExisting === null) {
200
+ nearestExisting = cursor;
201
+ }
202
+ cursor = dirname(cursor);
203
+ }
204
+
205
+ if (nearestExisting === null) {
206
+ return {
207
+ ok: false,
208
+ reason: `no existing ancestor directory for ${parent}`,
209
+ };
210
+ }
211
+ try {
212
+ await access(nearestExisting, fsConstants.W_OK);
213
+ } catch {
214
+ return {
215
+ ok: false,
216
+ reason: `no writable ancestor directory: ${nearestExisting}`,
217
+ };
218
+ }
219
+
220
+ return { ok: true, path: absPath };
221
+ }
222
+
223
+ /** Write transcription text to disk, creating parent directories as needed.
224
+ * Performs pre-flight safety checks; throws on rejection. */
225
+ export async function writeTranscriptionFile(outputFile: string, text: string): Promise<string> {
226
+ const abs = resolveOutputPath(outputFile);
227
+ const validation = await validateOutputPath(abs);
228
+ if (!validation.ok) {
229
+ throw new Error(`output_file rejected: ${validation.reason}`);
230
+ }
231
+ await mkdir(dirname(abs), { recursive: true });
232
+ await writeFile(abs, text, "utf-8");
233
+ return abs;
234
+ }
235
+
236
+ /**
237
+ * Build the tool return value from a successful transcription.
238
+ * - If `outputFile` is set: write the full text verbatim and return a short summary.
239
+ * - Otherwise: return the text inline, truncated to fit chat.
240
+ */
241
+ export async function buildTranscriptionResult(
242
+ text: string,
243
+ model: string,
244
+ source: "api" | "curl-fallback",
245
+ outputFile: string | undefined,
246
+ file = "audio",
247
+ language?: string,
248
+ ): Promise<TranscribeResult> {
249
+ const details: TranscribeResultDetails = {
250
+ _type: "transcribeResult",
251
+ outcome: "success",
252
+ file,
253
+ model,
254
+ ...(language ? { language } : {}),
255
+ source,
256
+ chars: text.length,
257
+ truncated: !outputFile && text.length > CHAT_TRUNCATE_LIMIT,
258
+ };
259
+
260
+ if (outputFile) {
261
+ try {
262
+ const abs = await writeTranscriptionFile(outputFile, text);
263
+ details.output_path = abs;
264
+ return {
265
+ content: [
266
+ {
267
+ type: "text",
268
+ text: `Transcribed ${text.length} chars → ${abs}`,
269
+ },
270
+ ],
271
+ details,
272
+ };
273
+ } catch (writeErr) {
274
+ const msg = writeErr instanceof Error ? writeErr.message : String(writeErr);
275
+ details.outcome = "error";
276
+ details.truncated = text.length > CHAT_TRUNCATE_LIMIT;
277
+ details.write_error = msg;
278
+ return {
279
+ content: [
280
+ {
281
+ type: "text",
282
+ text: `Transcription succeeded but writing to ${outputFile} failed: ${msg}\nThe transcribed text is included below — consider writing it to a different path.`,
283
+ },
284
+ { type: "text", text: text.slice(0, CHAT_TRUNCATE_LIMIT) },
285
+ ],
286
+ details,
287
+ isError: true,
288
+ };
289
+ }
290
+ }
291
+
292
+ return {
293
+ content: [{ type: "text", text: text.slice(0, CHAT_TRUNCATE_LIMIT) }],
294
+ details,
295
+ };
296
+ }
297
+
298
+ function compactChars(chars: number | undefined): string {
299
+ const value = chars ?? 0;
300
+ if (value < 1_000) return String(value);
301
+ if (value < 1_000_000) return `${(value / 1_000).toFixed(1)}K`;
302
+ return `${(value / 1_000_000).toFixed(1)}M`;
303
+ }
304
+
305
+ export default function registerTranscribe(pi: ExtensionAPI): void {
306
+ const renderTerminal = makeRenderResult<TranscribeResultDetails>({
307
+ tool: "transcribe",
308
+ target: (details) => basename(details.file),
309
+ meta: (details) => {
310
+ if (details.write_error) return "write failed · transcript preserved inline";
311
+ if (details.outcome === "error") return "failed";
312
+ if (details.outcome === "cancelled") return "cancelled";
313
+ const chars = `${compactChars(details.chars)} chars`;
314
+ return details.output_path
315
+ ? `${chars} · wrote ${basename(details.output_path)}`
316
+ : `${chars} · ${details.model}`;
317
+ },
318
+ status: (details) =>
319
+ details.outcome === "error"
320
+ ? "error"
321
+ : details.outcome === "cancelled"
322
+ ? "warning"
323
+ : "success",
324
+ });
325
+
326
+ pi.registerTool({
327
+ name: "transcribe",
328
+ label: "Transcribe",
329
+ renderShell: "self",
330
+ description:
331
+ "Convert speech to text. Transcribes an audio file using the 9Router audio transcription API (Deepgram Nova 3). Optionally writes the full text to a file on disk.",
332
+ promptSnippet:
333
+ "transcribe(file, output_file?, model?, language?) — Transcribe an audio file to text. Supports mp3, wav, flac, ogg, m4a, webm. Default model: dg/nova-3. If output_file is set, the full text is written to that path (parent dirs created) and only a short path summary is returned to the model.",
334
+ renderCall: makeRenderCall("transcribe", (args) => basename(String(args.file ?? ""))),
335
+ renderResult: (result, options, theme, context) =>
336
+ renderTerminal(result, options, theme, {
337
+ ...context,
338
+ // Structured transcription failures have safe metadata for a compact
339
+ // terminal row; expansion still restores the exact returned blocks.
340
+ isError: context.isError && options.expanded,
341
+ }),
342
+ promptGuidelines: [
343
+ "Use transcribe when you need to convert speech/audio to text.",
344
+ "The file parameter should be an absolute or relative path to an audio file on disk.",
345
+ "Supports common audio formats: mp3, wav, flac, ogg, m4a, webm, mp4.",
346
+ "Default model is dg/nova-3 (Deepgram Nova 3). Override with model parameter if needed.",
347
+ "Optionally specify language as ISO 639-1 code (e.g. 'en', 'es', 'fr') for better accuracy.",
348
+ "Pass output_file to write the full transcription to disk — useful when the result is long, when it will be re-read or piped to another tool, or to keep chat context small. Relative paths are resolved against the current working directory.",
349
+ "output_file is pre-flight checked: paths under /etc, /proc, /sys, /boot, ~/.ssh, ~/.aws, ~/.gnupg, and ~/.config/gh are rejected; the target must not be a symlink or existing directory; the parent must exist and be writable; symlinks anywhere in the parent chain are rejected. On rejection, the transcription text is still returned inline so it is not lost.",
350
+ "Without output_file the transcribed text is returned inline (truncated to 50,000 chars).",
351
+ ],
352
+ parameters: Type.Object({
353
+ file: Type.String({
354
+ description: "Path to the audio file to transcribe",
355
+ }),
356
+ output_file: Type.Optional(
357
+ Type.String({
358
+ description:
359
+ "Write the full transcription text to this path (parent dirs are created). Relative paths resolve against cwd. When set, content returned to the model is just a short path summary.",
360
+ }),
361
+ ),
362
+ model: Type.Optional(
363
+ Type.String({
364
+ description: "Transcription model to use (default: dg/nova-3)",
365
+ default: DEFAULT_MODEL,
366
+ }),
367
+ ),
368
+ language: Type.Optional(
369
+ Type.String({
370
+ description: "ISO 639-1 language code (e.g. 'en', 'es', 'fr') for better accuracy",
371
+ }),
372
+ ),
373
+ }),
374
+
375
+ async execute(_toolCallId, params, signal, onUpdate) {
376
+ const model = params.model ?? DEFAULT_MODEL;
377
+ const filePath = params.file;
378
+ const outputFile = params.output_file;
379
+ let apiMsg = "";
380
+
381
+ const run = async (source: "api" | "curl-fallback", raw: string) =>
382
+ buildTranscriptionResult(
383
+ parseTranscriptionResponse(raw),
384
+ model,
385
+ source,
386
+ outputFile,
387
+ filePath,
388
+ params.language,
389
+ );
390
+
391
+ try {
392
+ onUpdate?.({
393
+ content: [
394
+ {
395
+ type: "text",
396
+ text: `Transcribing: ${filePath} (model: ${model})...`,
397
+ },
398
+ ],
399
+ details: {
400
+ _type: "transcribeResult",
401
+ outcome: "running",
402
+ file: filePath,
403
+ model,
404
+ ...(params.language ? { language: params.language } : {}),
405
+ },
406
+ });
407
+
408
+ const raw = await apiMultipart(
409
+ "/audio/transcriptions",
410
+ filePath,
411
+ model,
412
+ params.language,
413
+ signal,
414
+ );
415
+
416
+ return await run("api", raw);
417
+ } catch (apiErr: unknown) {
418
+ apiMsg = apiErr instanceof Error ? apiErr.message : String(apiErr);
419
+ onUpdate?.({
420
+ content: [
421
+ {
422
+ type: "text",
423
+ text: `API failed: ${apiMsg}\nFalling back to curl...`,
424
+ },
425
+ ],
426
+ details: undefined,
427
+ });
428
+ }
429
+
430
+ // curl fallback — uses multipart form upload
431
+ try {
432
+ const curlArgs = [
433
+ "-X",
434
+ "POST",
435
+ ...(auth() ? ["-H", `Authorization: Bearer ${auth()}`] : []),
436
+ "-F",
437
+ `file=@${filePath}`,
438
+ "-F",
439
+ `model=${model}`,
440
+ ...(params.language ? ["-F", `language=${params.language}`] : []),
441
+ `${routerBaseUrl()}/audio/transcriptions`,
442
+ ];
443
+
444
+ const raw = await curl(curlArgs);
445
+ return await run("curl-fallback", raw);
446
+ } catch (curlErr: unknown) {
447
+ const curlMsg = curlErr instanceof Error ? curlErr.message : String(curlErr);
448
+ return {
449
+ content: [
450
+ {
451
+ type: "text",
452
+ text: `Transcription failed (both API and curl).\nAPI: ${apiMsg}\nCurl: ${curlMsg}`,
453
+ },
454
+ ],
455
+ details: {
456
+ _type: "transcribeResult",
457
+ outcome: signal?.aborted ? "cancelled" : "error",
458
+ file: filePath,
459
+ model,
460
+ ...(params.language ? { language: params.language } : {}),
461
+ source: "failed",
462
+ },
463
+ isError: true,
464
+ };
465
+ }
466
+ },
467
+ });
468
+ }
package/src/zen.ts ADDED
@@ -0,0 +1,76 @@
1
+ import { homedir } from "node:os";
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+
5
+ export const ZEN_URL = "https://opencode.ai/zen/v1/models";
6
+
7
+ const TTL = 30 * 60 * 1000;
8
+
9
+ export function cacheDir(): string {
10
+ return join(homedir(), ".cache", "pi");
11
+ }
12
+
13
+ export function cachePath(): string {
14
+ return join(cacheDir(), "zen-oc-models.json");
15
+ }
16
+
17
+ interface ZenCache {
18
+ ts: number;
19
+ models: string[];
20
+ }
21
+
22
+ function parseModels(payload: unknown): string[] {
23
+ let arr: unknown;
24
+ if (Array.isArray(payload)) {
25
+ arr = payload;
26
+ } else if (payload && typeof payload === "object" && Array.isArray((payload as { data?: unknown }).data)) {
27
+ arr = (payload as { data: unknown[] }).data;
28
+ } else {
29
+ return [];
30
+ }
31
+ return (arr as unknown[])
32
+ .map((item) => (item && typeof item === "object" ? (item as { id?: unknown }).id : undefined))
33
+ .filter((id): id is string => typeof id === "string" && /-free$/.test(id));
34
+ }
35
+
36
+ export async function fetchZenFreeModels(): Promise<string[]> {
37
+ try {
38
+ const res = await fetch(ZEN_URL, {
39
+ headers: {
40
+ "x-opencode-client": "desktop",
41
+ "User-Agent": "pi-coding-agent",
42
+ },
43
+ signal: AbortSignal.timeout(10_000),
44
+ });
45
+ if (!res.ok) return [];
46
+ const data = await res.json();
47
+ const models = parseModels(data);
48
+ if (models.length >= 1) {
49
+ mkdirSync(cacheDir(), { recursive: true });
50
+ const cache: ZenCache = { ts: Date.now(), models };
51
+ writeFileSync(cachePath(), JSON.stringify(cache), { mode: 0o600 });
52
+ }
53
+ return models;
54
+ } catch {
55
+ return [];
56
+ }
57
+ }
58
+
59
+ export function loadZenCache(ignoreTtl = false): string[] {
60
+ if (!existsSync(cachePath())) return [];
61
+ try {
62
+ const cache = JSON.parse(readFileSync(cachePath(), "utf8")) as ZenCache;
63
+ if (!ignoreTtl && Date.now() - cache.ts > TTL) return [];
64
+ return Array.isArray(cache.models) ? cache.models : [];
65
+ } catch {
66
+ return [];
67
+ }
68
+ }
69
+
70
+ export async function getZenFreeModels(): Promise<string[]> {
71
+ const fresh = loadZenCache();
72
+ if (fresh.length >= 1) return fresh;
73
+ const fetched = await fetchZenFreeModels();
74
+ if (fetched.length >= 1) return fetched;
75
+ return loadZenCache(true);
76
+ }