@xynogen/pix-9router 0.3.4 → 0.3.6
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 +190 -79
- package/src/format.test.ts +332 -3
- package/src/render.test.ts +114 -25
- package/src/render.ts +49 -6
- package/src/search.ts +205 -99
- package/src/transcribe.test.ts +127 -6
- package/src/transcribe.ts +93 -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,131 @@ 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, unknown> | undefined;
|
|
420
|
+
registerTranscribe({
|
|
421
|
+
registerTool(value: Record<string, 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
|
+
const renderResult = tool.renderResult as ((...args: never[]) => unknown) | undefined;
|
|
444
|
+
renderResult?.(result as never, { expanded, isPartial: false } as never, theme, {
|
|
445
|
+
lastComponent: component,
|
|
446
|
+
isError,
|
|
447
|
+
state: { collapsed: true },
|
|
448
|
+
expanded,
|
|
449
|
+
invalidate: () => {},
|
|
450
|
+
} as never);
|
|
451
|
+
return text;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
describe("transcribe compact renderer", () => {
|
|
455
|
+
it("uses the self-rendered shell so the status mark has no leading box padding", () => {
|
|
456
|
+
expect(captureTranscribeRenderer().renderShell).toBe("self");
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
it("summarizes inline and written transcripts", () => {
|
|
460
|
+
const inline = renderTranscribe({
|
|
461
|
+
content: [{ type: "text", text: "full transcript" }],
|
|
462
|
+
details: {
|
|
463
|
+
_type: "transcribeResult",
|
|
464
|
+
outcome: "success",
|
|
465
|
+
file: "/recordings/meeting.mp3",
|
|
466
|
+
model: "dg/nova-3",
|
|
467
|
+
source: "api",
|
|
468
|
+
chars: 12_400,
|
|
469
|
+
truncated: false,
|
|
470
|
+
},
|
|
471
|
+
});
|
|
472
|
+
expect(inline).toContain("✓");
|
|
473
|
+
expect(inline).toContain("meeting.mp3");
|
|
474
|
+
expect(inline).toContain("12.4K chars · dg/nova-3");
|
|
475
|
+
|
|
476
|
+
const written = renderTranscribe({
|
|
477
|
+
content: [{ type: "text", text: "Transcribed 12400 chars → /tmp/notes.md" }],
|
|
478
|
+
details: {
|
|
479
|
+
_type: "transcribeResult",
|
|
480
|
+
outcome: "success",
|
|
481
|
+
file: "/recordings/meeting.mp3",
|
|
482
|
+
model: "dg/nova-3",
|
|
483
|
+
source: "api",
|
|
484
|
+
chars: 12_400,
|
|
485
|
+
truncated: false,
|
|
486
|
+
output_path: "/tmp/notes.md",
|
|
487
|
+
},
|
|
488
|
+
});
|
|
489
|
+
expect(written).toContain("12.4K chars · wrote notes.md");
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
it("summarizes write failure and restores both exact blocks when expanded", () => {
|
|
493
|
+
const result = {
|
|
494
|
+
content: [
|
|
495
|
+
{ type: "text", text: "Writing failed: permission denied" },
|
|
496
|
+
{ type: "text", text: "recovered transcript" },
|
|
497
|
+
],
|
|
498
|
+
details: {
|
|
499
|
+
_type: "transcribeResult",
|
|
500
|
+
outcome: "error",
|
|
501
|
+
file: "/recordings/meeting.mp3",
|
|
502
|
+
model: "dg/nova-3",
|
|
503
|
+
source: "api",
|
|
504
|
+
chars: 20,
|
|
505
|
+
truncated: false,
|
|
506
|
+
write_error: "permission denied",
|
|
507
|
+
},
|
|
508
|
+
};
|
|
509
|
+
const compact = renderTranscribe(result, false, true);
|
|
510
|
+
expect(compact).toContain("✗");
|
|
511
|
+
expect(compact).toContain("write failed · transcript preserved inline");
|
|
512
|
+
|
|
513
|
+
const expanded = renderTranscribe(result, true, true);
|
|
514
|
+
expect(expanded).toContain("Writing failed: permission denied");
|
|
515
|
+
expect(expanded).toContain("recovered transcript");
|
|
516
|
+
});
|
|
517
|
+
});
|
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,14 +327,50 @@ 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",
|
|
361
|
+
renderShell: "self",
|
|
307
362
|
description:
|
|
308
363
|
"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
364
|
promptSnippet:
|
|
310
365
|
"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.",
|
|
366
|
+
renderCall: makeRenderCall("transcribe", (args) => basename(String(args.file ?? ""))),
|
|
367
|
+
renderResult: (result, options, theme, context) =>
|
|
368
|
+
renderTerminal(result, options, theme, {
|
|
369
|
+
...context,
|
|
370
|
+
// Structured transcription failures have safe metadata for a compact
|
|
371
|
+
// terminal row; expansion still restores the exact returned blocks.
|
|
372
|
+
isError: context.isError && options.expanded,
|
|
373
|
+
}),
|
|
311
374
|
promptGuidelines: [
|
|
312
375
|
"Use transcribe when you need to convert speech/audio to text.",
|
|
313
376
|
"The file parameter should be an absolute or relative path to an audio file on disk.",
|
|
@@ -348,7 +411,14 @@ export default function registerTranscribe(pi: ExtensionAPI): void {
|
|
|
348
411
|
let apiMsg = "";
|
|
349
412
|
|
|
350
413
|
const run = async (source: "api" | "curl-fallback", raw: string) =>
|
|
351
|
-
buildTranscriptionResult(
|
|
414
|
+
buildTranscriptionResult(
|
|
415
|
+
parseTranscriptionResponse(raw),
|
|
416
|
+
model,
|
|
417
|
+
source,
|
|
418
|
+
outputFile,
|
|
419
|
+
filePath,
|
|
420
|
+
params.language,
|
|
421
|
+
);
|
|
352
422
|
|
|
353
423
|
try {
|
|
354
424
|
onUpdate?.({
|
|
@@ -358,7 +428,13 @@ export default function registerTranscribe(pi: ExtensionAPI): void {
|
|
|
358
428
|
text: `Transcribing: ${filePath} (model: ${model})...`,
|
|
359
429
|
},
|
|
360
430
|
],
|
|
361
|
-
details:
|
|
431
|
+
details: {
|
|
432
|
+
_type: "transcribeResult",
|
|
433
|
+
outcome: "running",
|
|
434
|
+
file: filePath,
|
|
435
|
+
model,
|
|
436
|
+
...(params.language ? { language: params.language } : {}),
|
|
437
|
+
},
|
|
362
438
|
});
|
|
363
439
|
|
|
364
440
|
const raw = await apiMultipart(
|
|
@@ -408,7 +484,14 @@ export default function registerTranscribe(pi: ExtensionAPI): void {
|
|
|
408
484
|
text: `Transcription failed (both API and curl).\nAPI: ${apiMsg}\nCurl: ${curlMsg}`,
|
|
409
485
|
},
|
|
410
486
|
],
|
|
411
|
-
details: {
|
|
487
|
+
details: {
|
|
488
|
+
_type: "transcribeResult",
|
|
489
|
+
outcome: signal?.aborted ? "cancelled" : "error",
|
|
490
|
+
file: filePath,
|
|
491
|
+
model,
|
|
492
|
+
...(params.language ? { language: params.language } : {}),
|
|
493
|
+
source: "failed",
|
|
494
|
+
},
|
|
412
495
|
isError: true,
|
|
413
496
|
};
|
|
414
497
|
}
|