@xynogen/pix-9router 0.2.6 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/provider.ts +3 -2
- package/src/transcribe.test.ts +305 -33
- package/src/transcribe.ts +233 -40
package/package.json
CHANGED
package/src/provider.ts
CHANGED
|
@@ -122,9 +122,10 @@ export default async function registerProvider(
|
|
|
122
122
|
api: "openai-completions",
|
|
123
123
|
headers: { "User-Agent": "pi-coding-agent" },
|
|
124
124
|
models: models.map((model) => {
|
|
125
|
-
const
|
|
125
|
+
const id = model.id ?? "";
|
|
126
|
+
const devModel = lookupInIndex(id, devIndex);
|
|
126
127
|
return {
|
|
127
|
-
id
|
|
128
|
+
id,
|
|
128
129
|
name: getModelName(model, devModel),
|
|
129
130
|
reasoning: getReasoning(model, devModel),
|
|
130
131
|
input: getInputTypes(model, devModel),
|
package/src/transcribe.test.ts
CHANGED
|
@@ -1,37 +1,17 @@
|
|
|
1
1
|
import { describe, expect, it } from "bun:test";
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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 {
|
|
16
|
-
import {
|
|
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
|
-
|
|
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);
|