@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.
@@ -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).toEqual({
209
- source: "api",
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(text, "dg/nova-3", "api", "/etc/passwd");
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.write_error).toMatch(/refusing to write/);
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
- // inline text is still included so the model doesn't lose the result
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
- ): Promise<{
257
- content: { type: "text"; text: string }[];
258
- details: Record<string, unknown>;
259
- isError?: boolean;
260
- }> {
261
- const details: Record<string, unknown> = {
262
- source,
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(parseTranscriptionResponse(raw), model, source, outputFile);
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: undefined,
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: { source: "failed" },
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
  }