@xynogen/pix-9router 0.3.4 → 0.3.5
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/README.md +4 -0
- package/package.json +4 -2
- package/src/fetch.ts +189 -79
- package/src/format.test.ts +316 -3
- package/src/render.test.ts +114 -25
- package/src/render.ts +49 -6
- package/src/search.ts +204 -99
- package/src/transcribe.test.ts +122 -6
- package/src/transcribe.ts +92 -10
package/src/transcribe.test.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { chmod, readFile, rm } from "node:fs/promises";
|
|
|
4
4
|
import { homedir, tmpdir } from "node:os";
|
|
5
5
|
import { join } from "node:path";
|
|
6
6
|
|
|
7
|
-
import {
|
|
7
|
+
import registerTranscribe, {
|
|
8
8
|
buildTranscriptionResult,
|
|
9
9
|
mimeType,
|
|
10
10
|
parseTranscriptionResponse,
|
|
@@ -205,10 +205,13 @@ describe("buildTranscriptionResult", () => {
|
|
|
205
205
|
expect(result.content).toHaveLength(1);
|
|
206
206
|
expect(result.content[0]?.type).toBe("text");
|
|
207
207
|
expect(result.content[0]?.text.length).toBe(50_000);
|
|
208
|
-
expect(result.details).
|
|
209
|
-
|
|
208
|
+
expect(result.details).toMatchObject({
|
|
209
|
+
_type: "transcribeResult",
|
|
210
|
+
outcome: "success",
|
|
210
211
|
model: "dg/nova-3",
|
|
212
|
+
source: "api",
|
|
211
213
|
chars: 60_000,
|
|
214
|
+
truncated: true,
|
|
212
215
|
});
|
|
213
216
|
});
|
|
214
217
|
|
|
@@ -384,13 +387,126 @@ describe("writeTranscriptionFile — rejection propagation", () => {
|
|
|
384
387
|
describe("buildTranscriptionResult — write failure path", () => {
|
|
385
388
|
it("returns isError + inline fallback when output_file is rejected", async () => {
|
|
386
389
|
const text = "the actual transcription that was successfully produced";
|
|
387
|
-
const result = await buildTranscriptionResult(
|
|
390
|
+
const result = await buildTranscriptionResult(
|
|
391
|
+
text,
|
|
392
|
+
"dg/nova-3",
|
|
393
|
+
"api",
|
|
394
|
+
"/etc/passwd",
|
|
395
|
+
"meeting.mp3",
|
|
396
|
+
);
|
|
388
397
|
expect(result.isError).toBe(true);
|
|
389
|
-
expect(result.details
|
|
398
|
+
expect(result.details).toMatchObject({
|
|
399
|
+
_type: "transcribeResult",
|
|
400
|
+
outcome: "error",
|
|
401
|
+
file: "meeting.mp3",
|
|
402
|
+
write_error: expect.stringMatching(/refusing to write/),
|
|
403
|
+
});
|
|
390
404
|
expect(result.details.output_path).toBeUndefined();
|
|
391
|
-
//
|
|
405
|
+
// both blocks, including the recovered transcript, remain available to the model
|
|
406
|
+
expect(result.content).toHaveLength(2);
|
|
392
407
|
const joined = result.content.map((c) => c.text).join("\n");
|
|
393
408
|
expect(joined).toContain(text);
|
|
394
409
|
expect(joined).toContain("/etc/passwd");
|
|
395
410
|
});
|
|
396
411
|
});
|
|
412
|
+
|
|
413
|
+
const theme = {
|
|
414
|
+
fg: (token: string, text: string) => `[${token}]${text}`,
|
|
415
|
+
bold: (text: string) => `*${text}*`,
|
|
416
|
+
} as never;
|
|
417
|
+
|
|
418
|
+
function captureTranscribeRenderer() {
|
|
419
|
+
let tool: Record<string, (...args: never[]) => unknown> | undefined;
|
|
420
|
+
registerTranscribe({
|
|
421
|
+
registerTool(value: Record<string, (...args: never[]) => unknown>) {
|
|
422
|
+
tool = value;
|
|
423
|
+
},
|
|
424
|
+
} as never);
|
|
425
|
+
if (!tool) throw new Error("transcribe tool was not registered");
|
|
426
|
+
return tool;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function renderTranscribe(
|
|
430
|
+
result: Record<string, unknown>,
|
|
431
|
+
expanded = false,
|
|
432
|
+
isError = false,
|
|
433
|
+
): string {
|
|
434
|
+
let text = "";
|
|
435
|
+
const component = {
|
|
436
|
+
setText(value: string) {
|
|
437
|
+
text = value;
|
|
438
|
+
},
|
|
439
|
+
render: () => [],
|
|
440
|
+
invalidate: () => {},
|
|
441
|
+
};
|
|
442
|
+
const tool = captureTranscribeRenderer();
|
|
443
|
+
tool.renderResult?.(result as never, { expanded, isPartial: false } as never, theme, {
|
|
444
|
+
lastComponent: component,
|
|
445
|
+
isError,
|
|
446
|
+
state: { collapsed: true },
|
|
447
|
+
expanded,
|
|
448
|
+
invalidate: () => {},
|
|
449
|
+
} as never);
|
|
450
|
+
return text;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
describe("transcribe compact renderer", () => {
|
|
454
|
+
it("summarizes inline and written transcripts", () => {
|
|
455
|
+
const inline = renderTranscribe({
|
|
456
|
+
content: [{ type: "text", text: "full transcript" }],
|
|
457
|
+
details: {
|
|
458
|
+
_type: "transcribeResult",
|
|
459
|
+
outcome: "success",
|
|
460
|
+
file: "/recordings/meeting.mp3",
|
|
461
|
+
model: "dg/nova-3",
|
|
462
|
+
source: "api",
|
|
463
|
+
chars: 12_400,
|
|
464
|
+
truncated: false,
|
|
465
|
+
},
|
|
466
|
+
});
|
|
467
|
+
expect(inline).toContain("✓");
|
|
468
|
+
expect(inline).toContain("meeting.mp3");
|
|
469
|
+
expect(inline).toContain("12.4K chars · dg/nova-3");
|
|
470
|
+
|
|
471
|
+
const written = renderTranscribe({
|
|
472
|
+
content: [{ type: "text", text: "Transcribed 12400 chars → /tmp/notes.md" }],
|
|
473
|
+
details: {
|
|
474
|
+
_type: "transcribeResult",
|
|
475
|
+
outcome: "success",
|
|
476
|
+
file: "/recordings/meeting.mp3",
|
|
477
|
+
model: "dg/nova-3",
|
|
478
|
+
source: "api",
|
|
479
|
+
chars: 12_400,
|
|
480
|
+
truncated: false,
|
|
481
|
+
output_path: "/tmp/notes.md",
|
|
482
|
+
},
|
|
483
|
+
});
|
|
484
|
+
expect(written).toContain("12.4K chars · wrote notes.md");
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
it("summarizes write failure and restores both exact blocks when expanded", () => {
|
|
488
|
+
const result = {
|
|
489
|
+
content: [
|
|
490
|
+
{ type: "text", text: "Writing failed: permission denied" },
|
|
491
|
+
{ type: "text", text: "recovered transcript" },
|
|
492
|
+
],
|
|
493
|
+
details: {
|
|
494
|
+
_type: "transcribeResult",
|
|
495
|
+
outcome: "error",
|
|
496
|
+
file: "/recordings/meeting.mp3",
|
|
497
|
+
model: "dg/nova-3",
|
|
498
|
+
source: "api",
|
|
499
|
+
chars: 20,
|
|
500
|
+
truncated: false,
|
|
501
|
+
write_error: "permission denied",
|
|
502
|
+
},
|
|
503
|
+
};
|
|
504
|
+
const compact = renderTranscribe(result, false, true);
|
|
505
|
+
expect(compact).toContain("✗");
|
|
506
|
+
expect(compact).toContain("write failed · transcript preserved inline");
|
|
507
|
+
|
|
508
|
+
const expanded = renderTranscribe(result, true, true);
|
|
509
|
+
expect(expanded).toContain("Writing failed: permission denied");
|
|
510
|
+
expect(expanded).toContain("recovered transcript");
|
|
511
|
+
});
|
|
512
|
+
});
|
package/src/transcribe.ts
CHANGED
|
@@ -19,11 +19,33 @@ import { basename, dirname, extname, isAbsolute, resolve } from "node:path";
|
|
|
19
19
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
20
20
|
import { Type } from "typebox";
|
|
21
21
|
import { routerBaseUrl } from "./data.js";
|
|
22
|
+
import { makeRenderCall, makeRenderResult } from "./render.js";
|
|
22
23
|
|
|
23
24
|
const REQUEST_TIMEOUT_MS = 120_000; // audio transcription can take longer
|
|
24
25
|
const CHAT_TRUNCATE_LIMIT = 50_000; // only when no output_file is provided
|
|
25
26
|
const DEFAULT_MODEL = "dg/nova-3";
|
|
26
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
|
+
|
|
27
49
|
function auth(): string | undefined {
|
|
28
50
|
return process.env.ROUTER_API_KEY;
|
|
29
51
|
}
|
|
@@ -253,15 +275,18 @@ export async function buildTranscriptionResult(
|
|
|
253
275
|
model: string,
|
|
254
276
|
source: "api" | "curl-fallback",
|
|
255
277
|
outputFile: string | undefined,
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
278
|
+
file = "audio",
|
|
279
|
+
language?: string,
|
|
280
|
+
): Promise<TranscribeResult> {
|
|
281
|
+
const details: TranscribeResultDetails = {
|
|
282
|
+
_type: "transcribeResult",
|
|
283
|
+
outcome: "success",
|
|
284
|
+
file,
|
|
263
285
|
model,
|
|
286
|
+
...(language ? { language } : {}),
|
|
287
|
+
source,
|
|
264
288
|
chars: text.length,
|
|
289
|
+
truncated: !outputFile && text.length > CHAT_TRUNCATE_LIMIT,
|
|
265
290
|
};
|
|
266
291
|
|
|
267
292
|
if (outputFile) {
|
|
@@ -279,6 +304,8 @@ export async function buildTranscriptionResult(
|
|
|
279
304
|
};
|
|
280
305
|
} catch (writeErr) {
|
|
281
306
|
const msg = writeErr instanceof Error ? writeErr.message : String(writeErr);
|
|
307
|
+
details.outcome = "error";
|
|
308
|
+
details.truncated = text.length > CHAT_TRUNCATE_LIMIT;
|
|
282
309
|
details.write_error = msg;
|
|
283
310
|
return {
|
|
284
311
|
content: [
|
|
@@ -300,7 +327,34 @@ export async function buildTranscriptionResult(
|
|
|
300
327
|
};
|
|
301
328
|
}
|
|
302
329
|
|
|
330
|
+
function compactChars(chars: number | undefined): string {
|
|
331
|
+
const value = chars ?? 0;
|
|
332
|
+
if (value < 1_000) return String(value);
|
|
333
|
+
if (value < 1_000_000) return `${(value / 1_000).toFixed(1)}K`;
|
|
334
|
+
return `${(value / 1_000_000).toFixed(1)}M`;
|
|
335
|
+
}
|
|
336
|
+
|
|
303
337
|
export default function registerTranscribe(pi: ExtensionAPI): void {
|
|
338
|
+
const renderTerminal = makeRenderResult<TranscribeResultDetails>({
|
|
339
|
+
tool: "transcribe",
|
|
340
|
+
target: (details) => basename(details.file),
|
|
341
|
+
meta: (details) => {
|
|
342
|
+
if (details.write_error) return "write failed · transcript preserved inline";
|
|
343
|
+
if (details.outcome === "error") return "failed";
|
|
344
|
+
if (details.outcome === "cancelled") return "cancelled";
|
|
345
|
+
const chars = `${compactChars(details.chars)} chars`;
|
|
346
|
+
return details.output_path
|
|
347
|
+
? `${chars} · wrote ${basename(details.output_path)}`
|
|
348
|
+
: `${chars} · ${details.model}`;
|
|
349
|
+
},
|
|
350
|
+
status: (details) =>
|
|
351
|
+
details.outcome === "error"
|
|
352
|
+
? "error"
|
|
353
|
+
: details.outcome === "cancelled"
|
|
354
|
+
? "warning"
|
|
355
|
+
: "success",
|
|
356
|
+
});
|
|
357
|
+
|
|
304
358
|
pi.registerTool({
|
|
305
359
|
name: "transcribe",
|
|
306
360
|
label: "Transcribe",
|
|
@@ -308,6 +362,14 @@ export default function registerTranscribe(pi: ExtensionAPI): void {
|
|
|
308
362
|
"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.",
|
|
309
363
|
promptSnippet:
|
|
310
364
|
"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.",
|
|
365
|
+
renderCall: makeRenderCall("transcribe", (args) => basename(String(args.file ?? ""))),
|
|
366
|
+
renderResult: (result, options, theme, context) =>
|
|
367
|
+
renderTerminal(result, options, theme, {
|
|
368
|
+
...context,
|
|
369
|
+
// Structured transcription failures have safe metadata for a compact
|
|
370
|
+
// terminal row; expansion still restores the exact returned blocks.
|
|
371
|
+
isError: context.isError && options.expanded,
|
|
372
|
+
}),
|
|
311
373
|
promptGuidelines: [
|
|
312
374
|
"Use transcribe when you need to convert speech/audio to text.",
|
|
313
375
|
"The file parameter should be an absolute or relative path to an audio file on disk.",
|
|
@@ -348,7 +410,14 @@ export default function registerTranscribe(pi: ExtensionAPI): void {
|
|
|
348
410
|
let apiMsg = "";
|
|
349
411
|
|
|
350
412
|
const run = async (source: "api" | "curl-fallback", raw: string) =>
|
|
351
|
-
buildTranscriptionResult(
|
|
413
|
+
buildTranscriptionResult(
|
|
414
|
+
parseTranscriptionResponse(raw),
|
|
415
|
+
model,
|
|
416
|
+
source,
|
|
417
|
+
outputFile,
|
|
418
|
+
filePath,
|
|
419
|
+
params.language,
|
|
420
|
+
);
|
|
352
421
|
|
|
353
422
|
try {
|
|
354
423
|
onUpdate?.({
|
|
@@ -358,7 +427,13 @@ export default function registerTranscribe(pi: ExtensionAPI): void {
|
|
|
358
427
|
text: `Transcribing: ${filePath} (model: ${model})...`,
|
|
359
428
|
},
|
|
360
429
|
],
|
|
361
|
-
details:
|
|
430
|
+
details: {
|
|
431
|
+
_type: "transcribeResult",
|
|
432
|
+
outcome: "running",
|
|
433
|
+
file: filePath,
|
|
434
|
+
model,
|
|
435
|
+
...(params.language ? { language: params.language } : {}),
|
|
436
|
+
},
|
|
362
437
|
});
|
|
363
438
|
|
|
364
439
|
const raw = await apiMultipart(
|
|
@@ -408,7 +483,14 @@ export default function registerTranscribe(pi: ExtensionAPI): void {
|
|
|
408
483
|
text: `Transcription failed (both API and curl).\nAPI: ${apiMsg}\nCurl: ${curlMsg}`,
|
|
409
484
|
},
|
|
410
485
|
],
|
|
411
|
-
details: {
|
|
486
|
+
details: {
|
|
487
|
+
_type: "transcribeResult",
|
|
488
|
+
outcome: signal?.aborted ? "cancelled" : "error",
|
|
489
|
+
file: filePath,
|
|
490
|
+
model,
|
|
491
|
+
...(params.language ? { language: params.language } : {}),
|
|
492
|
+
source: "failed",
|
|
493
|
+
},
|
|
412
494
|
isError: true,
|
|
413
495
|
};
|
|
414
496
|
}
|