@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.
@@ -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,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(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, 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
- ): 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,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(parseTranscriptionResponse(raw), model, source, outputFile);
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: undefined,
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: { source: "failed" },
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
  }