@mafdet/cli 1.4.18 → 1.4.29

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,6 @@
1
+ import { ExitCode } from "../output/exit-codes.js";
2
+ import { type CommandIo } from "./runtime.js";
3
+ export declare function transcribeCommand(argv: readonly string[], io: CommandIo): Promise<ExitCode>;
4
+ /** Container magic numbers. Returns null for anything not recognised. */
5
+ export declare function sniffFormat(buffer: Buffer): string | null;
6
+ //# sourceMappingURL=transcribe.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"transcribe.d.ts","sourceRoot":"","sources":["../../src/commands/transcribe.ts"],"names":[],"mappings":"AAWA,OAAO,EAAE,QAAQ,EAAE,MAAM,yBAAyB,CAAC;AACnD,OAAO,EAKL,KAAK,SAAS,EACf,MAAM,cAAc,CAAC;AAsBtB,wBAAsB,iBAAiB,CACrC,IAAI,EAAE,SAAS,MAAM,EAAE,EACvB,EAAE,EAAE,SAAS,GACZ,OAAO,CAAC,QAAQ,CAAC,CAoFnB;AAqED,yEAAyE;AACzE,wBAAgB,WAAW,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAczD"}
@@ -0,0 +1,177 @@
1
+ import { readFileSync, statSync } from "node:fs";
2
+ import { z } from "zod";
3
+ import { parseApiData } from "../api/validate.js";
4
+ import { ApiError } from "../api/errors.js";
5
+ import { assertSurface, fetchCatalog, SurfaceMismatchError, } from "../api/models.js";
6
+ import { ExitCode } from "../output/exit-codes.js";
7
+ import { buildRuntime, reportError, writeHumanError, requireApiKey, } from "./runtime.js";
8
+ const USAGE = `Usage:
9
+ mafdet transcribe <file> [--model <id>] [--prompt <text>] [--json]
10
+
11
+ Turns a recording into text. The audio is sent inline, so it must fit the
12
+ gateway's 25 MB request limit — base64 inflates it by a third, which caps the
13
+ file at about 18 MB (roughly 10 minutes of 16 kHz mono WAV). Longer recordings
14
+ have to be split; the model's own 60-minute limit is not reachable this way.
15
+ `;
16
+ /** DEFAULT_MODEL is the file-transcription model, not the streaming one. */
17
+ const DEFAULT_MODEL = "gemini-3.5-transcribe";
18
+ /**
19
+ * 18 MiB. nginx allows 25 MB on `location /v1/` and base64 costs a third, so
20
+ * this is the largest raw file whose encoded body still fits, with room for the
21
+ * JSON envelope. Checked locally so a 40 MB file fails in a millisecond with a
22
+ * sentence, rather than after a long upload with a 413.
23
+ */
24
+ const MAX_BYTES = 18 * 1024 * 1024;
25
+ export async function transcribeCommand(argv, io) {
26
+ try {
27
+ const runtime = buildRuntime(io);
28
+ requireApiKey(runtime.config);
29
+ let model = DEFAULT_MODEL;
30
+ let prompt = "Transcribe this audio.";
31
+ let json = false;
32
+ const positional = [];
33
+ for (let i = 0; i < argv.length; i += 1) {
34
+ const arg = argv[i];
35
+ const next = () => argv[(i += 1)];
36
+ if (arg === "--model")
37
+ model = next() ?? "";
38
+ else if (arg === "--prompt")
39
+ prompt = next() ?? "";
40
+ else if (arg === "--json")
41
+ json = true;
42
+ else if (arg.startsWith("--")) {
43
+ io.stderr(`mafdet transcribe: unknown option '${arg}'\n${USAGE}`);
44
+ return ExitCode.Usage;
45
+ }
46
+ else
47
+ positional.push(arg);
48
+ }
49
+ if (positional.length !== 1) {
50
+ io.stderr(`mafdet transcribe: one audio file is required\n${USAGE}`);
51
+ return ExitCode.Usage;
52
+ }
53
+ if (!model) {
54
+ io.stderr("mafdet transcribe: --model needs a value\n");
55
+ return ExitCode.Usage;
56
+ }
57
+ const path = positional[0];
58
+ const audio = readAudio(path, io);
59
+ if (audio === null)
60
+ return ExitCode.Usage;
61
+ const catalog = await fetchCatalog(runtime.accountPlane).catch(() => []);
62
+ assertSurface(catalog, model, "CHAT_COMPLETIONS");
63
+ const body = {
64
+ model,
65
+ messages: [
66
+ {
67
+ role: "user",
68
+ content: [
69
+ { type: "text", text: prompt },
70
+ {
71
+ type: "input_audio",
72
+ input_audio: { data: audio.base64, format: audio.format },
73
+ },
74
+ ],
75
+ },
76
+ ],
77
+ };
78
+ const { data: raw } = await runtime.dataPlane.postJson("/chat/completions", body);
79
+ const data = parseApiData(responseSchema, raw, "transcribe API");
80
+ const text = data.choices[0]?.message.content;
81
+ if (typeof text !== "string" || text.length === 0) {
82
+ io.stderr("mafdet transcribe: the model returned no text. That usually means no speech was detected in the file.\n");
83
+ return ExitCode.Unavailable;
84
+ }
85
+ io.stdout(json ? `${JSON.stringify({ text }, null, 2)}\n` : `${text}\n`);
86
+ return ExitCode.Ok;
87
+ }
88
+ catch (error) {
89
+ if (error instanceof SurfaceMismatchError) {
90
+ writeHumanError(io, `mafdet transcribe: ${error.message}\n`);
91
+ return ExitCode.Usage;
92
+ }
93
+ if (error instanceof ApiError && error.status === 413) {
94
+ writeHumanError(io, "mafdet transcribe: the gateway rejected the upload as too large. Split the recording and transcribe each part.\n");
95
+ return ExitCode.Usage;
96
+ }
97
+ return reportError(error, io);
98
+ }
99
+ }
100
+ /**
101
+ * Deliberately NOT the shared chat schemas. Both of them insist on at least one
102
+ * choice, and this model legitimately returns `choices: []` when the recording
103
+ * holds no speech (verified against Vertex directly, 2026-08-29) — so reusing
104
+ * them turns an ordinary outcome into "returned an invalid response at choices",
105
+ * which is both wrong and unactionable.
106
+ */
107
+ const responseSchema = z
108
+ .object({
109
+ choices: z.array(z
110
+ .object({
111
+ message: z
112
+ .object({ content: z.string().nullable().optional() })
113
+ .passthrough(),
114
+ })
115
+ .passthrough()),
116
+ usage: z.unknown().optional(),
117
+ })
118
+ .passthrough();
119
+ /**
120
+ * Reads the file and names its format from the bytes, never the extension — a
121
+ * `.wav` holding an MP3 is common and the extension would send the wrong label.
122
+ *
123
+ * The label barely matters in practice: the provider sniffs the content and
124
+ * ignores the declared type (measured 2026-09-03 — the same WAV bytes labelled
125
+ * wav, mp3 and flac all returned an identical 25 audio tokens). It is sent
126
+ * honestly anyway, and an unrecognised header is refused here so a text file
127
+ * cannot be uploaded and billed as audio.
128
+ */
129
+ function readAudio(path, io) {
130
+ let size;
131
+ try {
132
+ size = statSync(path).size;
133
+ }
134
+ catch {
135
+ io.stderr(`mafdet transcribe: cannot read '${path}'\n`);
136
+ return null;
137
+ }
138
+ if (size === 0) {
139
+ io.stderr(`mafdet transcribe: '${path}' is empty\n`);
140
+ return null;
141
+ }
142
+ if (size > MAX_BYTES) {
143
+ io.stderr(`mafdet transcribe: '${path}' is ${mib(size)} MiB; the limit is ${mib(MAX_BYTES)} MiB. Split the recording and transcribe each part.\n`);
144
+ return null;
145
+ }
146
+ const buffer = readFileSync(path);
147
+ const format = sniffFormat(buffer);
148
+ if (format === null) {
149
+ io.stderr(`mafdet transcribe: '${path}' does not look like audio (unrecognised file header)\n`);
150
+ return null;
151
+ }
152
+ return { base64: buffer.toString("base64"), format };
153
+ }
154
+ /** Container magic numbers. Returns null for anything not recognised. */
155
+ export function sniffFormat(buffer) {
156
+ const ascii = (start, end) => buffer.subarray(start, end).toString("latin1");
157
+ if (buffer.length < 12)
158
+ return null;
159
+ if (ascii(0, 4) === "RIFF" && ascii(8, 12) === "WAVE")
160
+ return "wav";
161
+ if (ascii(0, 4) === "fLaC")
162
+ return "flac";
163
+ if (ascii(0, 4) === "OggS")
164
+ return "ogg";
165
+ if (ascii(4, 8) === "ftyp")
166
+ return "m4a";
167
+ if (ascii(0, 3) === "ID3")
168
+ return "mp3";
169
+ // MPEG audio frame sync: 11 set bits. 0xFFF1/0xFFF9 are ADTS AAC.
170
+ if (buffer[0] === 0xff && (buffer[1] & 0xe0) === 0xe0) {
171
+ return (buffer[1] & 0x16) === 0x10 ? "aac" : "mp3";
172
+ }
173
+ return null;
174
+ }
175
+ function mib(bytes) {
176
+ return (bytes / (1024 * 1024)).toFixed(1);
177
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";AAeA,OAAO,EAAE,QAAQ,EAAE,MAAM,wBAAwB,CAAC;AAmElD,wBAAsB,GAAG,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,GAAG,OAAO,CAAC,QAAQ,CAAC,CAyGpE"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";AAgBA,OAAO,EAAE,QAAQ,EAAE,MAAM,wBAAwB,CAAC;AAoElD,wBAAsB,GAAG,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,GAAG,OAAO,CAAC,QAAQ,CAAC,CA4GpE"}
package/dist/index.js CHANGED
@@ -9,6 +9,7 @@ import { configCommand } from "./commands/config.js";
9
9
  import { doctorCommand } from "./commands/doctor.js";
10
10
  import { embedCommand } from "./commands/embed.js";
11
11
  import { speechCommand } from "./commands/speech.js";
12
+ import { transcribeCommand } from "./commands/transcribe.js";
12
13
  import { modelsCommand } from "./commands/models.js";
13
14
  import { sessionsCommand } from "./commands/sessions.js";
14
15
  import { ExitCode } from "./output/exit-codes.js";
@@ -34,6 +35,7 @@ Commands:
34
35
  chat Send a prompt to a model
35
36
  embed Turn text into a vector
36
37
  speech Turn text into an audio file
38
+ transcribe Turn a recording into text
37
39
  models list List the models this key can call
38
40
  models show Describe one model
39
41
  account balance Wallet balance and bonus
@@ -153,6 +155,9 @@ export async function run(argv) {
153
155
  if (command === "embed") {
154
156
  return embedCommand(argv.slice(1), { ...io, readStdin });
155
157
  }
158
+ if (command === "transcribe") {
159
+ return transcribeCommand(argv.slice(1), io);
160
+ }
156
161
  if (command === "speech") {
157
162
  return speechCommand(argv.slice(1), { ...io, readStdin });
158
163
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mafdet/cli",
3
- "version": "1.4.18",
3
+ "version": "1.4.29",
4
4
  "description": "Mafdet AI command line — model calls, account queries, and a permissioned local agent.",
5
5
  "type": "module",
6
6
  "license": "MIT",