@xynogen/pix-9router 0.1.0

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.
@@ -0,0 +1,160 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import { extname } from "node:path";
3
+
4
+ // ── Re-export internal helpers for testing via module augmentation ────────────
5
+ // transcribe.ts exports only the default fn; we test the pure logic
6
+ // inline here to avoid coupling tests to private internals.
7
+
8
+ // ── mimeType (copied from transcribe.ts) ─────────────────────────────────────
9
+
10
+ function mimeType(filePath: string): string {
11
+ const ext = extname(filePath).toLowerCase();
12
+ const types: Record<string, string> = {
13
+ ".mp3": "audio/mpeg",
14
+ ".wav": "audio/wav",
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
+ }
35
+
36
+ // ── mimeType ─────────────────────────────────────────────────────────────────
37
+
38
+ describe("mimeType", () => {
39
+ it("returns audio/mpeg for .mp3", () => {
40
+ expect(mimeType("recording.mp3")).toBe("audio/mpeg");
41
+ });
42
+
43
+ it("returns audio/mpeg for .mpga", () => {
44
+ expect(mimeType("recording.mpga")).toBe("audio/mpeg");
45
+ });
46
+
47
+ it("returns audio/wav for .wav", () => {
48
+ expect(mimeType("recording.wav")).toBe("audio/wav");
49
+ });
50
+
51
+ it("returns audio/flac for .flac", () => {
52
+ expect(mimeType("music.flac")).toBe("audio/flac");
53
+ });
54
+
55
+ it("returns audio/ogg for .ogg", () => {
56
+ expect(mimeType("voice.ogg")).toBe("audio/ogg");
57
+ });
58
+
59
+ it("returns audio/mp4 for .m4a", () => {
60
+ expect(mimeType("podcast.m4a")).toBe("audio/mp4");
61
+ });
62
+
63
+ it("returns audio/webm for .webm", () => {
64
+ expect(mimeType("clip.webm")).toBe("audio/webm");
65
+ });
66
+
67
+ it("returns audio/mp4 for .mp4", () => {
68
+ expect(mimeType("video.mp4")).toBe("audio/mp4");
69
+ });
70
+
71
+ it("is case-insensitive on extension", () => {
72
+ expect(mimeType("LOUD.MP3")).toBe("audio/mpeg");
73
+ expect(mimeType("file.WAV")).toBe("audio/wav");
74
+ expect(mimeType("track.Flac")).toBe("audio/flac");
75
+ });
76
+
77
+ it("returns application/octet-stream for unknown extension", () => {
78
+ expect(mimeType("archive.zip")).toBe("application/octet-stream");
79
+ });
80
+
81
+ it("returns application/octet-stream for no extension", () => {
82
+ expect(mimeType("noext")).toBe("application/octet-stream");
83
+ });
84
+
85
+ it("handles paths with directories", () => {
86
+ expect(mimeType("/home/user/audio/meeting.mp3")).toBe("audio/mpeg");
87
+ expect(mimeType("./recordings/call.wav")).toBe("audio/wav");
88
+ });
89
+
90
+ it("handles dotfiles with audio extensions", () => {
91
+ expect(mimeType(".hidden.mp3")).toBe("audio/mpeg");
92
+ });
93
+ });
94
+
95
+ // ── parseTranscriptionResponse ───────────────────────────────────────────────
96
+
97
+ describe("parseTranscriptionResponse", () => {
98
+ it("extracts text from JSON response", () => {
99
+ const raw = JSON.stringify({ text: "Hello, world!" });
100
+ expect(parseTranscriptionResponse(raw)).toBe("Hello, world!");
101
+ });
102
+
103
+ it("extracts text from JSON with extra fields", () => {
104
+ const raw = JSON.stringify({
105
+ text: "Transcribed content",
106
+ duration: 5.2,
107
+ language: "en",
108
+ });
109
+ expect(parseTranscriptionResponse(raw)).toBe("Transcribed content");
110
+ });
111
+
112
+ it("returns raw JSON string when text field is missing", () => {
113
+ const raw = JSON.stringify({ result: "no text field" });
114
+ expect(parseTranscriptionResponse(raw)).toBe(raw);
115
+ });
116
+
117
+ it("returns plain text as-is when not JSON", () => {
118
+ expect(parseTranscriptionResponse("Just plain text")).toBe(
119
+ "Just plain text",
120
+ );
121
+ });
122
+
123
+ it("returns empty string from JSON with empty text", () => {
124
+ const raw = JSON.stringify({ text: "" });
125
+ // ?? only triggers on null/undefined, not empty string — so "" is returned
126
+ expect(parseTranscriptionResponse(raw)).toBe("");
127
+ });
128
+
129
+ it("returns raw when text is null", () => {
130
+ const raw = JSON.stringify({ text: null });
131
+ // null ?? raw → raw
132
+ expect(parseTranscriptionResponse(raw)).toBe(raw);
133
+ });
134
+
135
+ it("returns raw when text is undefined (absent from parsed object)", () => {
136
+ const raw = JSON.stringify({ other: "stuff" });
137
+ // parsed.text is undefined → undefined ?? raw → raw
138
+ expect(parseTranscriptionResponse(raw)).toBe(raw);
139
+ });
140
+
141
+ it("handles multiline transcription", () => {
142
+ const text = "Line one.\nLine two.\nLine three.";
143
+ const raw = JSON.stringify({ text });
144
+ expect(parseTranscriptionResponse(raw)).toBe(text);
145
+ });
146
+
147
+ it("handles unicode in transcription", () => {
148
+ const text = "日本語のテスト 🎙️";
149
+ const raw = JSON.stringify({ text });
150
+ expect(parseTranscriptionResponse(raw)).toBe(text);
151
+ });
152
+
153
+ it("handles broken JSON gracefully", () => {
154
+ expect(parseTranscriptionResponse("{broken")).toBe("{broken");
155
+ });
156
+
157
+ it("handles empty string input", () => {
158
+ expect(parseTranscriptionResponse("")).toBe("");
159
+ });
160
+ });
@@ -0,0 +1,240 @@
1
+ /**
2
+ * transcribe.ts — speech-to-text tool via 9Router audio transcription API, with curl fallback.
3
+ *
4
+ * Uses the OpenAI-compatible /audio/transcriptions endpoint through the router.
5
+ * Accepts a file path to an audio file and returns the transcribed text.
6
+ *
7
+ * Default model: dg/nova-3 (Deepgram Nova 3)
8
+ *
9
+ * Environment:
10
+ * ROUTER_API_BASE — router API base URL (default: https://9r.xynogen.xyz/v1)
11
+ * ROUTER_API_KEY — bearer token for the router
12
+ */
13
+
14
+ import { readFile } from "node:fs/promises";
15
+ import { execFile } from "node:child_process";
16
+ import { basename, extname } from "node:path";
17
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
18
+ import { Type } from "typebox";
19
+ import { routerBaseUrl } from "./data.js";
20
+
21
+ const REQUEST_TIMEOUT_MS = 120_000; // audio transcription can take longer
22
+ const DEFAULT_MODEL = "dg/nova-3";
23
+
24
+ function auth(): string | undefined {
25
+ return process.env.ROUTER_API_KEY;
26
+ }
27
+
28
+ /** Map file extension to MIME type for common audio formats. */
29
+ function mimeType(filePath: string): string {
30
+ const ext = extname(filePath).toLowerCase();
31
+ const types: Record<string, string> = {
32
+ ".mp3": "audio/mpeg",
33
+ ".wav": "audio/wav",
34
+ ".flac": "audio/flac",
35
+ ".ogg": "audio/ogg",
36
+ ".m4a": "audio/mp4",
37
+ ".webm": "audio/webm",
38
+ ".mp4": "audio/mp4",
39
+ ".mpga": "audio/mpeg",
40
+ };
41
+ return types[ext] ?? "application/octet-stream";
42
+ }
43
+
44
+ async function apiMultipart(
45
+ path: string,
46
+ filePath: string,
47
+ model: string,
48
+ language: string | undefined,
49
+ signal?: AbortSignal,
50
+ ): Promise<string> {
51
+ const url = `${routerBaseUrl()}${path}`;
52
+ const key = auth();
53
+ const controller = new AbortController();
54
+ const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
55
+ const s = signal
56
+ ? AbortSignal.any([signal, controller.signal])
57
+ : controller.signal;
58
+
59
+ try {
60
+ const fileData = await readFile(filePath);
61
+ const blob = new Blob([fileData], { type: mimeType(filePath) });
62
+
63
+ const form = new FormData();
64
+ form.append("file", blob, basename(filePath));
65
+ form.append("model", model);
66
+ if (language) form.append("language", language);
67
+
68
+ const res = await fetch(url, {
69
+ method: "POST",
70
+ headers: {
71
+ ...(key ? { Authorization: `Bearer ${key}` } : {}),
72
+ },
73
+ body: form,
74
+ signal: s,
75
+ });
76
+ if (!res.ok) {
77
+ const errText = await res.text().catch(() => "");
78
+ throw new Error(`API ${res.status}: ${errText.slice(0, 500)}`);
79
+ }
80
+ return await res.text();
81
+ } finally {
82
+ clearTimeout(timeout);
83
+ }
84
+ }
85
+
86
+ function curl(args: string[]): Promise<string> {
87
+ return new Promise((resolve, reject) => {
88
+ execFile(
89
+ "curl",
90
+ ["-sS", "--connect-timeout", "10", "--max-time", "120", ...args],
91
+ { maxBuffer: 10 * 1024 * 1024, timeout: REQUEST_TIMEOUT_MS },
92
+ (err, stdout, stderr) => {
93
+ if (err) {
94
+ const msg = (err as NodeJS.ErrnoException).killed
95
+ ? "curl timed out"
96
+ : `curl exit ${(err as NodeJS.ErrnoException).code ?? "??"}: ${stderr.slice(0, 300)}`;
97
+ reject(new Error(msg));
98
+ return;
99
+ }
100
+ resolve(stdout);
101
+ },
102
+ );
103
+ });
104
+ }
105
+
106
+ export default function registerTranscribe(pi: ExtensionAPI): void {
107
+ pi.registerTool({
108
+ name: "transcribe",
109
+ label: "Transcribe",
110
+ description:
111
+ "Convert speech to text. Transcribes an audio file using the 9Router audio transcription API (Deepgram Nova 3).",
112
+ promptSnippet:
113
+ "transcribe(file, model?, language?) — Transcribe an audio file to text. Supports mp3, wav, flac, ogg, m4a, webm. Default model: dg/nova-3.",
114
+ promptGuidelines: [
115
+ "Use transcribe when you need to convert speech/audio to text.",
116
+ "The file parameter should be an absolute or relative path to an audio file on disk.",
117
+ "Supports common audio formats: mp3, wav, flac, ogg, m4a, webm, mp4.",
118
+ "Default model is dg/nova-3 (Deepgram Nova 3). Override with model parameter if needed.",
119
+ "Optionally specify language as ISO 639-1 code (e.g. 'en', 'es', 'fr') for better accuracy.",
120
+ ],
121
+ parameters: Type.Object({
122
+ file: Type.String({
123
+ description: "Path to the audio file to transcribe",
124
+ }),
125
+ model: Type.Optional(
126
+ Type.String({
127
+ description: "Transcription model to use (default: dg/nova-3)",
128
+ default: DEFAULT_MODEL,
129
+ }),
130
+ ),
131
+ language: Type.Optional(
132
+ Type.String({
133
+ description:
134
+ "ISO 639-1 language code (e.g. 'en', 'es', 'fr') for better accuracy",
135
+ }),
136
+ ),
137
+ }),
138
+
139
+ async execute(_toolCallId, params, signal, onUpdate) {
140
+ const model = params.model ?? DEFAULT_MODEL;
141
+ const filePath = params.file;
142
+ let apiMsg = "";
143
+
144
+ try {
145
+ onUpdate?.({
146
+ content: [
147
+ {
148
+ type: "text",
149
+ text: `Transcribing: ${filePath} (model: ${model})...`,
150
+ },
151
+ ],
152
+ });
153
+
154
+ const raw = await apiMultipart(
155
+ "/audio/transcriptions",
156
+ filePath,
157
+ model,
158
+ params.language,
159
+ signal,
160
+ );
161
+
162
+ // The API may return JSON {"text": "..."} or plain text
163
+ let text: string;
164
+ try {
165
+ const parsed = JSON.parse(raw) as { text?: string };
166
+ text = parsed.text ?? raw;
167
+ } catch {
168
+ text = raw;
169
+ }
170
+
171
+ return {
172
+ content: [{ type: "text", text: text.slice(0, 50_000) }],
173
+ details: {
174
+ source: "api",
175
+ model,
176
+ chars: text.length,
177
+ },
178
+ };
179
+ } catch (apiErr: unknown) {
180
+ apiMsg = apiErr instanceof Error ? apiErr.message : String(apiErr);
181
+ onUpdate?.({
182
+ content: [
183
+ {
184
+ type: "text",
185
+ text: `API failed: ${apiMsg}\nFalling back to curl...`,
186
+ },
187
+ ],
188
+ });
189
+ }
190
+
191
+ // curl fallback — uses multipart form upload
192
+ try {
193
+ const curlArgs = [
194
+ "-X",
195
+ "POST",
196
+ ...(auth() ? ["-H", `Authorization: Bearer ${auth()}`] : []),
197
+ "-F",
198
+ `file=@${filePath}`,
199
+ "-F",
200
+ `model=${model}`,
201
+ ...(params.language ? ["-F", `language=${params.language}`] : []),
202
+ `${routerBaseUrl()}/audio/transcriptions`,
203
+ ];
204
+
205
+ const raw = await curl(curlArgs);
206
+
207
+ let text: string;
208
+ try {
209
+ const parsed = JSON.parse(raw) as { text?: string };
210
+ text = parsed.text ?? raw;
211
+ } catch {
212
+ text = raw;
213
+ }
214
+
215
+ return {
216
+ content: [
217
+ {
218
+ type: "text",
219
+ text: `[FALLBACK — curl] API called via curl instead of fetch.\n\n${text.slice(0, 49_500)}`,
220
+ },
221
+ ],
222
+ details: { source: "curl-fallback", model },
223
+ };
224
+ } catch (curlErr: unknown) {
225
+ const curlMsg =
226
+ curlErr instanceof Error ? curlErr.message : String(curlErr);
227
+ return {
228
+ content: [
229
+ {
230
+ type: "text",
231
+ text: `Transcription failed (both API and curl).\nAPI: ${apiMsg}\nCurl: ${curlMsg}`,
232
+ },
233
+ ],
234
+ details: { source: "failed" },
235
+ isError: true,
236
+ };
237
+ }
238
+ },
239
+ });
240
+ }