@xynogen/pix-9router 0.2.7 → 0.3.2

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,6 +1,6 @@
1
1
  {
2
2
  "name": "@xynogen/pix-9router",
3
- "version": "0.2.7",
3
+ "version": "0.3.2",
4
4
  "description": "Pi extension — 9Router provider + fetch/search tools via router API",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -42,7 +42,7 @@
42
42
  "@earendil-works/pi-ai": "*"
43
43
  },
44
44
  "dependencies": {
45
- "@xynogen/pix-data": "*",
45
+ "@xynogen/pix-data": "^0.3.0",
46
46
  "typebox": "^1.1.38"
47
47
  }
48
48
  }
@@ -1,37 +1,17 @@
1
1
  import { describe, expect, it } from "bun:test";
2
- import { extname } from "node:path";
3
-
4
- // ── Re-export internal helpers for testing via module augmentation ────────────
5
- // transcribe.ts exports only the default fn; we test the pure logic
6
- // inline here to avoid coupling tests to private internals.
7
-
8
- // ── mimeType (copied from transcribe.ts) ─────────────────────────────────────
9
-
10
- function mimeType(filePath: string): string {
11
- const ext = extname(filePath).toLowerCase();
12
- const types: Record<string, string> = {
13
- ".mp3": "audio/mpeg",
14
- ".wav": "audio/wav",
15
- ".flac": "audio/flac",
16
- ".ogg": "audio/ogg",
17
- ".m4a": "audio/mp4",
18
- ".webm": "audio/webm",
19
- ".mp4": "audio/mp4",
20
- ".mpga": "audio/mpeg",
21
- };
22
- return types[ext] ?? "application/octet-stream";
23
- }
24
-
25
- // ── parseTranscriptionResponse (extracted logic from execute) ─────────────────
26
-
27
- function parseTranscriptionResponse(raw: string): string {
28
- try {
29
- const parsed = JSON.parse(raw) as { text?: string };
30
- return parsed.text ?? raw;
31
- } catch {
32
- return raw;
33
- }
34
- }
2
+ import { mkdirSync, mkdtempSync, symlinkSync, writeFileSync } from "node:fs";
3
+ import { chmod, readFile, rm } from "node:fs/promises";
4
+ import { homedir, tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+
7
+ import {
8
+ buildTranscriptionResult,
9
+ mimeType,
10
+ parseTranscriptionResponse,
11
+ resolveOutputPath,
12
+ validateOutputPath,
13
+ writeTranscriptionFile,
14
+ } from "./transcribe.js";
35
15
 
36
16
  // ── mimeType ─────────────────────────────────────────────────────────────────
37
17
 
@@ -158,3 +138,295 @@ describe("parseTranscriptionResponse", () => {
158
138
  expect(parseTranscriptionResponse("")).toBe("");
159
139
  });
160
140
  });
141
+
142
+ // ── resolveOutputPath ────────────────────────────────────────────────────────
143
+
144
+ describe("resolveOutputPath", () => {
145
+ it("keeps absolute paths as-is", () => {
146
+ const abs = "/tmp/foo/bar.txt";
147
+ expect(resolveOutputPath(abs)).toBe(abs);
148
+ });
149
+
150
+ it("resolves relative paths against cwd", () => {
151
+ const rel = "transcripts/out.txt";
152
+ const result = resolveOutputPath(rel);
153
+ expect(result.endsWith("transcripts/out.txt")).toBe(true);
154
+ expect(result.startsWith(process.cwd())).toBe(true);
155
+ });
156
+ });
157
+
158
+ // ── writeTranscriptionFile ───────────────────────────────────────────────────
159
+
160
+ describe("writeTranscriptionFile", () => {
161
+ const tmpRoot = mkdtempSync(join(tmpdir(), "pix-transcribe-test-"));
162
+
163
+ it("writes text to the given file path", async () => {
164
+ const file = join(tmpRoot, "simple.txt");
165
+ const abs = await writeTranscriptionFile(file, "hello world");
166
+ expect(abs).toBe(file);
167
+ expect(await readFile(file, "utf-8")).toBe("hello world");
168
+ });
169
+
170
+ it("creates parent directories recursively", async () => {
171
+ const file = join(tmpRoot, "deep", "nested", "dir", "out.txt");
172
+ const abs = await writeTranscriptionFile(file, "deep content");
173
+ expect(abs).toBe(file);
174
+ expect(await readFile(file, "utf-8")).toBe("deep content");
175
+ });
176
+
177
+ it("preserves full unicode without truncation", async () => {
178
+ const text = `日本語のテスト\n${"x".repeat(100_000)}`;
179
+ const file = join(tmpRoot, "huge.txt");
180
+ await writeTranscriptionFile(file, text);
181
+ const got = await readFile(file, "utf-8");
182
+ expect(got.length).toBe(text.length);
183
+ expect(got).toBe(text);
184
+ });
185
+
186
+ it("overwrites an existing file", async () => {
187
+ const file = join(tmpRoot, "overwrite.txt");
188
+ await writeTranscriptionFile(file, "first");
189
+ await writeTranscriptionFile(file, "second");
190
+ expect(await readFile(file, "utf-8")).toBe("second");
191
+ });
192
+
193
+ // cleanup tmp root
194
+ it("cleanup", async () => {
195
+ await rm(tmpRoot, { recursive: true, force: true });
196
+ });
197
+ });
198
+
199
+ // ── buildTranscriptionResult ─────────────────────────────────────────────────
200
+
201
+ describe("buildTranscriptionResult", () => {
202
+ const tmpRoot = mkdtempSync(join(tmpdir(), "pix-transcribe-result-"));
203
+
204
+ it("returns inline text (truncated at 50_000) when no output_file is set", async () => {
205
+ const text = "a".repeat(60_000);
206
+ const result = await buildTranscriptionResult(
207
+ text,
208
+ "dg/nova-3",
209
+ "api",
210
+ undefined,
211
+ );
212
+ expect(result.content).toHaveLength(1);
213
+ expect(result.content[0]?.type).toBe("text");
214
+ expect(result.content[0]?.text.length).toBe(50_000);
215
+ expect(result.details).toEqual({
216
+ source: "api",
217
+ model: "dg/nova-3",
218
+ chars: 60_000,
219
+ });
220
+ });
221
+
222
+ it("returns inline text (untruncated) when short and no output_file", async () => {
223
+ const text = "short transcript";
224
+ const result = await buildTranscriptionResult(
225
+ text,
226
+ "dg/nova-3",
227
+ "api",
228
+ undefined,
229
+ );
230
+ expect(result.content[0]?.text).toBe("short transcript");
231
+ expect(result.details.chars).toBe(text.length);
232
+ });
233
+
234
+ it("writes full text to file and returns short path summary when output_file is set", async () => {
235
+ const text = "a".repeat(100_000); // way over 50k
236
+ const file = join(tmpRoot, "result-a.txt");
237
+ const result = await buildTranscriptionResult(
238
+ text,
239
+ "dg/nova-3",
240
+ "api",
241
+ file,
242
+ );
243
+
244
+ // content is a short summary, not the full text
245
+ expect(result.content).toHaveLength(1);
246
+ const summary = result.content[0]?.text ?? "";
247
+ expect(summary.length).toBeLessThan(200);
248
+ expect(summary).toContain("100000");
249
+ expect(summary).toContain(file);
250
+
251
+ // full text was written verbatim
252
+ const onDisk = await readFile(file, "utf-8");
253
+ expect(onDisk.length).toBe(100_000);
254
+ expect(onDisk).toBe(text);
255
+
256
+ // details includes resolved absolute path
257
+ expect(result.details.output_path).toBe(file);
258
+ expect(result.details.chars).toBe(100_000);
259
+ expect(result.details.source).toBe("api");
260
+ });
261
+
262
+ it("passes through curl-fallback source label", async () => {
263
+ const text = "hi";
264
+ const result = await buildTranscriptionResult(
265
+ text,
266
+ "dg/nova-3",
267
+ "curl-fallback",
268
+ undefined,
269
+ );
270
+ expect(result.details.source).toBe("curl-fallback");
271
+ });
272
+
273
+ it("relative output_file is resolved against cwd and created", async () => {
274
+ const text = "relative path content";
275
+ const relDir = join(tmpRoot, "rel", "sub");
276
+ const relFile = join(relDir, "out.txt");
277
+ const result = await buildTranscriptionResult(
278
+ text,
279
+ "dg/nova-3",
280
+ "api",
281
+ relFile,
282
+ );
283
+
284
+ expect(result.details.output_path).toBe(relFile);
285
+ expect(await readFile(relFile, "utf-8")).toBe("relative path content");
286
+ });
287
+
288
+ it("cleanup", async () => {
289
+ await rm(tmpRoot, { recursive: true, force: true });
290
+ });
291
+ });
292
+
293
+ // ── validateOutputPath ───────────────────────────────────────────────────────
294
+
295
+ describe("validateOutputPath", () => {
296
+ const tmpRoot = mkdtempSync(join(tmpdir(), "pix-validate-"));
297
+
298
+ it("accepts a fresh path under a writable parent", async () => {
299
+ const result = await validateOutputPath(join(tmpRoot, "fresh.txt"));
300
+ expect(result.ok).toBe(true);
301
+ if (result.ok) expect(result.path).toBe(join(tmpRoot, "fresh.txt"));
302
+ });
303
+
304
+ it("accepts a fresh path several levels deep", async () => {
305
+ const result = await validateOutputPath(
306
+ join(tmpRoot, "a", "b", "c", "deep.txt"),
307
+ );
308
+ expect(result.ok).toBe(true);
309
+ });
310
+
311
+ it("rejects null bytes", async () => {
312
+ const result = await validateOutputPath(join(tmpRoot, "x\0y.txt"));
313
+ expect(result.ok).toBe(false);
314
+ if (!result.ok) expect(result.reason).toContain("null byte");
315
+ });
316
+
317
+ it.each([
318
+ "/etc",
319
+ "/etc/passwd",
320
+ "/etc/cron.daily/x",
321
+ "/proc/cpuinfo",
322
+ "/sys/kernel/x",
323
+ "/boot/efi/x",
324
+ `${homedir()}/.ssh/authorized_keys`,
325
+ `${homedir()}/.aws/credentials`,
326
+ `${homedir()}/.gnupg/gpg.conf`,
327
+ `${homedir()}/.config/gh/hosts.yml`,
328
+ ])("rejects sensitive prefix %s", async (bad) => {
329
+ const result = await validateOutputPath(bad);
330
+ expect(result.ok).toBe(false);
331
+ if (!result.ok) {
332
+ expect(result.reason).toMatch(/refusing to write/);
333
+ }
334
+ });
335
+
336
+ it("rejects when no ancestor directory exists at all", async () => {
337
+ // Deep path under a non-existent tree with no existing ancestor
338
+ const result = await validateOutputPath("/no-such-root-xyz/abc/def/x.txt");
339
+ expect(result.ok).toBe(false);
340
+ if (!result.ok) expect(result.reason).toMatch(/no existing ancestor/);
341
+ });
342
+
343
+ it("rejects when parent is not writable", async () => {
344
+ const readOnlyParent = join(tmpRoot, "ro");
345
+ mkdirSync(readOnlyParent);
346
+ await chmod(readOnlyParent, 0o555);
347
+ try {
348
+ const result = await validateOutputPath(join(readOnlyParent, "x.txt"));
349
+ expect(result.ok).toBe(false);
350
+ if (!result.ok) expect(result.reason).toMatch(/no writable ancestor/);
351
+ } finally {
352
+ // restore so cleanup can rm -rf
353
+ await chmod(readOnlyParent, 0o755);
354
+ }
355
+ });
356
+
357
+ it("rejects when target is a symlink", async () => {
358
+ const real = join(tmpRoot, "real.txt");
359
+ writeFileSync(real, "x");
360
+ const link = join(tmpRoot, "link.txt");
361
+ symlinkSync(real, link);
362
+ const result = await validateOutputPath(link);
363
+ expect(result.ok).toBe(false);
364
+ if (!result.ok) expect(result.reason).toMatch(/symlink/);
365
+ });
366
+
367
+ it("rejects when target is an existing directory", async () => {
368
+ const dir = join(tmpRoot, "isadir");
369
+ mkdirSync(dir);
370
+ const result = await validateOutputPath(dir);
371
+ expect(result.ok).toBe(false);
372
+ if (!result.ok) expect(result.reason).toMatch(/directory/);
373
+ });
374
+
375
+ it("rejects when a parent in the chain is a symlink", async () => {
376
+ const realSubdir = join(tmpRoot, "real-sub");
377
+ mkdirSync(realSubdir);
378
+ const symlinkedParent = join(tmpRoot, "fake-parent");
379
+ symlinkSync(realSubdir, symlinkedParent);
380
+ const result = await validateOutputPath(join(symlinkedParent, "x.txt"));
381
+ expect(result.ok).toBe(false);
382
+ if (!result.ok) expect(result.reason).toMatch(/parent is a symlink/);
383
+ });
384
+
385
+ it("accepts a path inside an existing file's parent (overwrite OK)", async () => {
386
+ const existing = join(tmpRoot, "exists.txt");
387
+ writeFileSync(existing, "old");
388
+ const result = await validateOutputPath(existing);
389
+ expect(result.ok).toBe(true);
390
+ });
391
+
392
+ it("cleanup", async () => {
393
+ await rm(tmpRoot, { recursive: true, force: true });
394
+ });
395
+ });
396
+
397
+ // ── writeTranscriptionFile — rejection propagation ───────────────────────────
398
+
399
+ describe("writeTranscriptionFile — rejection propagation", () => {
400
+ it("throws on sensitive prefix", async () => {
401
+ await expect(writeTranscriptionFile("/etc/some-file", "x")).rejects.toThrow(
402
+ /refusing to write under \/etc/,
403
+ );
404
+ });
405
+
406
+ it("throws on null byte", async () => {
407
+ await expect(
408
+ writeTranscriptionFile("/tmp/pix-test-\0x.txt", "x"),
409
+ ).rejects.toThrow(/null byte/);
410
+ });
411
+ });
412
+
413
+ // ── buildTranscriptionResult — write failure path ───────────────────────────
414
+
415
+ describe("buildTranscriptionResult — write failure path", () => {
416
+ it("returns isError + inline fallback when output_file is rejected", async () => {
417
+ const text = "the actual transcription that was successfully produced";
418
+ const result = await buildTranscriptionResult(
419
+ text,
420
+ "dg/nova-3",
421
+ "api",
422
+ "/etc/passwd",
423
+ );
424
+ expect(result.isError).toBe(true);
425
+ expect(result.details.write_error).toMatch(/refusing to write/);
426
+ expect(result.details.output_path).toBeUndefined();
427
+ // inline text is still included so the model doesn't lose the result
428
+ const joined = result.content.map((c) => c.text).join("\n");
429
+ expect(joined).toContain(text);
430
+ expect(joined).toContain("/etc/passwd");
431
+ });
432
+ });
package/src/transcribe.ts CHANGED
@@ -12,13 +12,16 @@
12
12
  */
13
13
 
14
14
  import { type ExecFileException, execFile } from "node:child_process";
15
- import { readFile } from "node:fs/promises";
16
- import { basename, extname } from "node:path";
15
+ import { constants as fsConstants } from "node:fs";
16
+ import { access, lstat, mkdir, readFile, writeFile } from "node:fs/promises";
17
+ import { homedir } from "node:os";
18
+ import { basename, dirname, extname, isAbsolute, resolve } from "node:path";
17
19
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
18
20
  import { Type } from "typebox";
19
21
  import { routerBaseUrl } from "./data.js";
20
22
 
21
23
  const REQUEST_TIMEOUT_MS = 120_000; // audio transcription can take longer
24
+ const CHAT_TRUNCATE_LIMIT = 50_000; // only when no output_file is provided
22
25
  const DEFAULT_MODEL = "dg/nova-3";
23
26
 
24
27
  function auth(): string | undefined {
@@ -26,7 +29,7 @@ function auth(): string | undefined {
26
29
  }
27
30
 
28
31
  /** Map file extension to MIME type for common audio formats. */
29
- function mimeType(filePath: string): string {
32
+ export function mimeType(filePath: string): string {
30
33
  const ext = extname(filePath).toLowerCase();
31
34
  const types: Record<string, string> = {
32
35
  ".mp3": "audio/mpeg",
@@ -104,25 +107,239 @@ function curl(args: string[]): Promise<string> {
104
107
  });
105
108
  }
106
109
 
110
+ /** Extract the `text` field from a JSON envelope, or return the raw string. */
111
+ export function parseTranscriptionResponse(raw: string): string {
112
+ try {
113
+ const parsed = JSON.parse(raw) as { text?: string };
114
+ return parsed.text ?? raw;
115
+ } catch {
116
+ return raw;
117
+ }
118
+ }
119
+
120
+ /** Resolve a possibly-relative `output_file` to an absolute path. */
121
+ export function resolveOutputPath(outputFile: string): string {
122
+ return isAbsolute(outputFile)
123
+ ? outputFile
124
+ : resolve(process.cwd(), outputFile);
125
+ }
126
+
127
+ /**
128
+ * Pre-flight safety check for `output_file`. Rejects paths that target sensitive
129
+ * system locations, null bytes, non-existent or non-writable parents, symlinks
130
+ * (at the target or anywhere in the parent chain), and existing-directory targets.
131
+ *
132
+ * Returns a discriminated result so the caller can surface a precise reason to
133
+ * the model without leaking OS internals.
134
+ */
135
+ export type PathValidation =
136
+ | { ok: true; path: string }
137
+ | { ok: false; reason: string };
138
+
139
+ /** Block-list of absolute prefixes that should never receive transcription output. */
140
+ function sensitivePrefixes(): string[] {
141
+ const home = homedir();
142
+ return [
143
+ "/etc",
144
+ "/proc",
145
+ "/sys",
146
+ "/boot",
147
+ `${home}/.ssh`,
148
+ `${home}/.aws`,
149
+ `${home}/.gnupg`,
150
+ `${home}/.config/gh`,
151
+ ];
152
+ }
153
+
154
+ export async function validateOutputPath(
155
+ absPath: string,
156
+ ): Promise<PathValidation> {
157
+ // 1. Null byte injection guard (defence in depth — Node already rejects, fail fast with clear msg)
158
+ if (absPath.includes("\0")) {
159
+ return { ok: false, reason: "path contains a null byte" };
160
+ }
161
+
162
+ // 2. Sensitive prefix block-list
163
+ for (const prefix of sensitivePrefixes()) {
164
+ if (absPath === prefix || absPath.startsWith(`${prefix}/`)) {
165
+ return { ok: false, reason: `refusing to write under ${prefix}` };
166
+ }
167
+ }
168
+
169
+ // 3. Target must not be a symlink and must not be an existing directory.
170
+ // (lstat does NOT follow symlinks — that's the whole point of using it here.)
171
+ try {
172
+ const st = await lstat(absPath);
173
+ if (st.isSymbolicLink()) {
174
+ return { ok: false, reason: `target is a symlink: ${absPath}` };
175
+ }
176
+ if (st.isDirectory()) {
177
+ return {
178
+ ok: false,
179
+ reason: `target is an existing directory: ${absPath}`,
180
+ };
181
+ }
182
+ } catch (err) {
183
+ // ENOENT is fine — we'll create the file. Anything else is a hard fail.
184
+ if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
185
+ return {
186
+ ok: false,
187
+ reason: `cannot stat target: ${(err as Error).message}`,
188
+ };
189
+ }
190
+ }
191
+
192
+ // 4. Walk up the parent chain. For every ancestor that EXISTS, it must
193
+ // (a) not be a symlink (stops /tmp/safe-looking-dir → /etc redirect), and
194
+ // (b) be writable so mkdir -p can create missing intermediates.
195
+ // Ancestors that don't exist (ENOENT) are fine — mkdir -p will create them.
196
+ const parent = dirname(absPath);
197
+ let cursor = parent;
198
+ let nearestExisting: string | null = null;
199
+ while (cursor !== dirname(cursor)) {
200
+ let st: Awaited<ReturnType<typeof lstat>>;
201
+ try {
202
+ st = await lstat(cursor);
203
+ } catch (err) {
204
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") {
205
+ // intermediate doesn't exist yet — keep walking up
206
+ cursor = dirname(cursor);
207
+ continue;
208
+ }
209
+ return {
210
+ ok: false,
211
+ reason: `cannot stat parent: ${(err as Error).message}`,
212
+ };
213
+ }
214
+ if (st.isSymbolicLink()) {
215
+ return { ok: false, reason: `parent is a symlink: ${cursor}` };
216
+ }
217
+ if (st.isDirectory() && nearestExisting === null) {
218
+ nearestExisting = cursor;
219
+ }
220
+ cursor = dirname(cursor);
221
+ }
222
+
223
+ if (nearestExisting === null) {
224
+ return {
225
+ ok: false,
226
+ reason: `no existing ancestor directory for ${parent}`,
227
+ };
228
+ }
229
+ try {
230
+ await access(nearestExisting, fsConstants.W_OK);
231
+ } catch {
232
+ return {
233
+ ok: false,
234
+ reason: `no writable ancestor directory: ${nearestExisting}`,
235
+ };
236
+ }
237
+
238
+ return { ok: true, path: absPath };
239
+ }
240
+
241
+ /** Write transcription text to disk, creating parent directories as needed.
242
+ * Performs pre-flight safety checks; throws on rejection. */
243
+ export async function writeTranscriptionFile(
244
+ outputFile: string,
245
+ text: string,
246
+ ): Promise<string> {
247
+ const abs = resolveOutputPath(outputFile);
248
+ const validation = await validateOutputPath(abs);
249
+ if (!validation.ok) {
250
+ throw new Error(`output_file rejected: ${validation.reason}`);
251
+ }
252
+ await mkdir(dirname(abs), { recursive: true });
253
+ await writeFile(abs, text, "utf-8");
254
+ return abs;
255
+ }
256
+
257
+ /**
258
+ * Build the tool return value from a successful transcription.
259
+ * - If `outputFile` is set: write the full text verbatim and return a short summary.
260
+ * - Otherwise: return the text inline, truncated to fit chat.
261
+ */
262
+ export async function buildTranscriptionResult(
263
+ text: string,
264
+ model: string,
265
+ source: "api" | "curl-fallback",
266
+ outputFile: string | undefined,
267
+ ): Promise<{
268
+ content: { type: "text"; text: string }[];
269
+ details: Record<string, unknown>;
270
+ isError?: boolean;
271
+ }> {
272
+ const details: Record<string, unknown> = {
273
+ source,
274
+ model,
275
+ chars: text.length,
276
+ };
277
+
278
+ if (outputFile) {
279
+ try {
280
+ const abs = await writeTranscriptionFile(outputFile, text);
281
+ details.output_path = abs;
282
+ return {
283
+ content: [
284
+ {
285
+ type: "text",
286
+ text: `Transcribed ${text.length} chars → ${abs}`,
287
+ },
288
+ ],
289
+ details,
290
+ };
291
+ } catch (writeErr) {
292
+ const msg =
293
+ writeErr instanceof Error ? writeErr.message : String(writeErr);
294
+ details.write_error = msg;
295
+ return {
296
+ content: [
297
+ {
298
+ type: "text",
299
+ text: `Transcription succeeded but writing to ${outputFile} failed: ${msg}\nThe transcribed text is included below — consider writing it to a different path.`,
300
+ },
301
+ { type: "text", text: text.slice(0, CHAT_TRUNCATE_LIMIT) },
302
+ ],
303
+ details,
304
+ isError: true,
305
+ };
306
+ }
307
+ }
308
+
309
+ return {
310
+ content: [{ type: "text", text: text.slice(0, CHAT_TRUNCATE_LIMIT) }],
311
+ details,
312
+ };
313
+ }
314
+
107
315
  export default function registerTranscribe(pi: ExtensionAPI): void {
108
316
  pi.registerTool({
109
317
  name: "transcribe",
110
318
  label: "Transcribe",
111
319
  description:
112
- "Convert speech to text. Transcribes an audio file using the 9Router audio transcription API (Deepgram Nova 3).",
320
+ "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.",
113
321
  promptSnippet:
114
- "transcribe(file, model?, language?) — Transcribe an audio file to text. Supports mp3, wav, flac, ogg, m4a, webm. Default model: dg/nova-3.",
322
+ "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.",
115
323
  promptGuidelines: [
116
324
  "Use transcribe when you need to convert speech/audio to text.",
117
325
  "The file parameter should be an absolute or relative path to an audio file on disk.",
118
326
  "Supports common audio formats: mp3, wav, flac, ogg, m4a, webm, mp4.",
119
327
  "Default model is dg/nova-3 (Deepgram Nova 3). Override with model parameter if needed.",
120
328
  "Optionally specify language as ISO 639-1 code (e.g. 'en', 'es', 'fr') for better accuracy.",
329
+ "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.",
330
+ "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.",
331
+ "Without output_file the transcribed text is returned inline (truncated to 50,000 chars).",
121
332
  ],
122
333
  parameters: Type.Object({
123
334
  file: Type.String({
124
335
  description: "Path to the audio file to transcribe",
125
336
  }),
337
+ output_file: Type.Optional(
338
+ Type.String({
339
+ description:
340
+ "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.",
341
+ }),
342
+ ),
126
343
  model: Type.Optional(
127
344
  Type.String({
128
345
  description: "Transcription model to use (default: dg/nova-3)",
@@ -140,8 +357,17 @@ export default function registerTranscribe(pi: ExtensionAPI): void {
140
357
  async execute(_toolCallId, params, signal, onUpdate) {
141
358
  const model = params.model ?? DEFAULT_MODEL;
142
359
  const filePath = params.file;
360
+ const outputFile = params.output_file;
143
361
  let apiMsg = "";
144
362
 
363
+ const run = async (source: "api" | "curl-fallback", raw: string) =>
364
+ buildTranscriptionResult(
365
+ parseTranscriptionResponse(raw),
366
+ model,
367
+ source,
368
+ outputFile,
369
+ );
370
+
145
371
  try {
146
372
  onUpdate?.({
147
373
  content: [
@@ -161,23 +387,7 @@ export default function registerTranscribe(pi: ExtensionAPI): void {
161
387
  signal,
162
388
  );
163
389
 
164
- // The API may return JSON {"text": "..."} or plain text
165
- let text: string;
166
- try {
167
- const parsed = JSON.parse(raw) as { text?: string };
168
- text = parsed.text ?? raw;
169
- } catch {
170
- text = raw;
171
- }
172
-
173
- return {
174
- content: [{ type: "text", text: text.slice(0, 50_000) }],
175
- details: {
176
- source: "api",
177
- model,
178
- chars: text.length,
179
- },
180
- };
390
+ return await run("api", raw);
181
391
  } catch (apiErr: unknown) {
182
392
  apiMsg = apiErr instanceof Error ? apiErr.message : String(apiErr);
183
393
  onUpdate?.({
@@ -206,24 +416,7 @@ export default function registerTranscribe(pi: ExtensionAPI): void {
206
416
  ];
207
417
 
208
418
  const raw = await curl(curlArgs);
209
-
210
- let text: string;
211
- try {
212
- const parsed = JSON.parse(raw) as { text?: string };
213
- text = parsed.text ?? raw;
214
- } catch {
215
- text = raw;
216
- }
217
-
218
- return {
219
- content: [
220
- {
221
- type: "text",
222
- text: `[FALLBACK — curl] API called via curl instead of fetch.\n\n${text.slice(0, 49_500)}`,
223
- },
224
- ],
225
- details: { source: "curl-fallback", model },
226
- };
419
+ return await run("curl-fallback", raw);
227
420
  } catch (curlErr: unknown) {
228
421
  const curlMsg =
229
422
  curlErr instanceof Error ? curlErr.message : String(curlErr);