recmp3-cli 1.0.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.
- package/LICENSE +661 -0
- package/LICENSE-COMMERCIAL.md +25 -0
- package/README.md +255 -0
- package/dist/chunk-7NR5CU7W.js +46 -0
- package/dist/chunk-DDXRBIWU.js +40 -0
- package/dist/chunk-FNZ6ZCOK.js +51 -0
- package/dist/chunk-NUWDWBJQ.js +63 -0
- package/dist/chunk-NY5EJT5D.js +127 -0
- package/dist/chunk-XGHYROLT.js +481 -0
- package/dist/index.js +1960 -0
- package/dist/keychain-CLYHGYS2.js +3 -0
- package/dist/linux-pulse-AROLYZNB.js +183 -0
- package/dist/local-whisper-VH26RX7Y.js +4 -0
- package/dist/mac-avfoundation-COPCFRZT.js +136 -0
- package/dist/registry-D5SOVUEJ.js +5 -0
- package/dist/windows-dshow-O2GU4ZLR.js +150 -0
- package/package.json +75 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1960 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { setSecret, keychainAvailable } from './chunk-FNZ6ZCOK.js';
|
|
3
|
+
import { loadConfig, providerUploads, createProvider, configFilePath, getApiKey, RecmpConfigSchema, paths, saveConfig, resetConfigCache } from './chunk-XGHYROLT.js';
|
|
4
|
+
import './chunk-NY5EJT5D.js';
|
|
5
|
+
import { initLogger, redactKey, log } from './chunk-DDXRBIWU.js';
|
|
6
|
+
import { checkFfmpegVersion, supportsInputFormat, findFfmpeg } from './chunk-7NR5CU7W.js';
|
|
7
|
+
import { ExitCode, RecmpError, InputError, ConfigError, AudioCaptureError } from './chunk-NUWDWBJQ.js';
|
|
8
|
+
import { Command } from 'commander';
|
|
9
|
+
import pc2 from 'picocolors';
|
|
10
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
11
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
12
|
+
import { z } from 'zod';
|
|
13
|
+
import { existsSync, readFileSync } from 'fs';
|
|
14
|
+
import { mkdtemp, writeFile, rm, mkdir, stat, readdir } from 'fs/promises';
|
|
15
|
+
import { join, basename, dirname } from 'path';
|
|
16
|
+
import { createInterface } from 'readline';
|
|
17
|
+
import { randomBytes } from 'crypto';
|
|
18
|
+
import { tmpdir } from 'os';
|
|
19
|
+
import { execFile } from 'child_process';
|
|
20
|
+
import { promisify } from 'util';
|
|
21
|
+
import { render, useApp, useInput, Box, Text } from 'ink';
|
|
22
|
+
import { useState, useRef, useEffect, useCallback } from 'react';
|
|
23
|
+
import { jsx, jsxs } from 'react/jsx-runtime';
|
|
24
|
+
|
|
25
|
+
var SCHEMA_VERSION = 1;
|
|
26
|
+
var HumanSink = class {
|
|
27
|
+
ok(_command, _payload, humanRender) {
|
|
28
|
+
if (humanRender) humanRender();
|
|
29
|
+
}
|
|
30
|
+
fail(error, _command) {
|
|
31
|
+
process.stderr.write(`
|
|
32
|
+
${pc2.red("\u2717")} ${error.message}
|
|
33
|
+
|
|
34
|
+
`);
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
var JsonSink = class {
|
|
38
|
+
ok(command, payload) {
|
|
39
|
+
const envelope = {
|
|
40
|
+
ok: true,
|
|
41
|
+
command,
|
|
42
|
+
schemaVersion: SCHEMA_VERSION,
|
|
43
|
+
data: payload
|
|
44
|
+
};
|
|
45
|
+
process.stdout.write(`${JSON.stringify(envelope, null, 2)}
|
|
46
|
+
`);
|
|
47
|
+
}
|
|
48
|
+
fail(error, command) {
|
|
49
|
+
const envelope = {
|
|
50
|
+
ok: false,
|
|
51
|
+
command,
|
|
52
|
+
schemaVersion: SCHEMA_VERSION,
|
|
53
|
+
error
|
|
54
|
+
};
|
|
55
|
+
process.stdout.write(`${JSON.stringify(envelope, null, 2)}
|
|
56
|
+
`);
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
var CaptureSink = class {
|
|
60
|
+
envelope = null;
|
|
61
|
+
ok(command, payload) {
|
|
62
|
+
this.envelope = {
|
|
63
|
+
ok: true,
|
|
64
|
+
command,
|
|
65
|
+
schemaVersion: SCHEMA_VERSION,
|
|
66
|
+
data: payload
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
fail(error, command) {
|
|
70
|
+
this.envelope = {
|
|
71
|
+
ok: false,
|
|
72
|
+
command,
|
|
73
|
+
schemaVersion: SCHEMA_VERSION,
|
|
74
|
+
error
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
function toErrorPayload(err) {
|
|
79
|
+
if (err instanceof RecmpError) {
|
|
80
|
+
return { code: err.code, message: err.message, exitCode: err.exitCode };
|
|
81
|
+
}
|
|
82
|
+
if (err instanceof Error) {
|
|
83
|
+
return {
|
|
84
|
+
code: "UNEXPECTED_ERROR",
|
|
85
|
+
message: err.message,
|
|
86
|
+
exitCode: ExitCode.UNKNOWN
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
return {
|
|
90
|
+
code: "UNKNOWN_ERROR",
|
|
91
|
+
message: String(err),
|
|
92
|
+
exitCode: ExitCode.UNKNOWN
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// src/agent/context.ts
|
|
97
|
+
var AgentContext = class _AgentContext {
|
|
98
|
+
json;
|
|
99
|
+
yes;
|
|
100
|
+
quiet;
|
|
101
|
+
color;
|
|
102
|
+
sink;
|
|
103
|
+
constructor(opts = {}) {
|
|
104
|
+
this.json = opts.json ?? false;
|
|
105
|
+
this.yes = opts.yes ?? false;
|
|
106
|
+
this.quiet = opts.quiet ?? false;
|
|
107
|
+
this.color = opts.color ?? true;
|
|
108
|
+
this.sink = opts.sink ?? (this.json ? new JsonSink() : new HumanSink());
|
|
109
|
+
}
|
|
110
|
+
/** Build the context from resolved global option values + environment. */
|
|
111
|
+
static fromGlobals(opts) {
|
|
112
|
+
const json = Boolean(opts.json) || process.env.RECMP3_JSON === "1";
|
|
113
|
+
const yes = Boolean(opts.yes) || process.env.RECMP3_YES === "1" || process.env.RECMP3_SKIP_CONSENT === "1";
|
|
114
|
+
const quiet = Boolean(opts.quiet) || process.env.RECMP3_QUIET === "1";
|
|
115
|
+
const color = opts.color !== false && !process.env.NO_COLOR && process.stdout.isTTY !== false;
|
|
116
|
+
return new _AgentContext({
|
|
117
|
+
json,
|
|
118
|
+
yes,
|
|
119
|
+
quiet,
|
|
120
|
+
color,
|
|
121
|
+
sink: json ? new JsonSink() : new HumanSink()
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
/** Context for MCP tool calls: captured JSON, prompts auto-skipped, no chatter. */
|
|
125
|
+
static forCapture() {
|
|
126
|
+
return new _AgentContext({
|
|
127
|
+
json: true,
|
|
128
|
+
yes: true,
|
|
129
|
+
quiet: true,
|
|
130
|
+
color: false,
|
|
131
|
+
sink: new CaptureSink()
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
/** Emit a successful result. `humanRender` runs only in human (non-json) mode. */
|
|
135
|
+
ok(command, payload, humanRender) {
|
|
136
|
+
this.sink.ok(command, payload, humanRender);
|
|
137
|
+
}
|
|
138
|
+
/** Emit a failure envelope. Does not exit — the caller controls process exit. */
|
|
139
|
+
fail(err, command) {
|
|
140
|
+
this.sink.fail(toErrorPayload(err), command);
|
|
141
|
+
}
|
|
142
|
+
/** Write progress/diagnostic chatter to stderr unless quiet. */
|
|
143
|
+
note(text) {
|
|
144
|
+
if (!this.quiet) process.stderr.write(text);
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
// src/agent/stdin.ts
|
|
149
|
+
async function readStdinBuffer() {
|
|
150
|
+
const chunks = [];
|
|
151
|
+
for await (const chunk of process.stdin) {
|
|
152
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
153
|
+
}
|
|
154
|
+
return Buffer.concat(chunks);
|
|
155
|
+
}
|
|
156
|
+
async function readStdinText() {
|
|
157
|
+
return (await readStdinBuffer()).toString("utf-8");
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// src/commands/config.ts
|
|
161
|
+
var ENV_VAR = {
|
|
162
|
+
groq: "GROQ_API_KEY",
|
|
163
|
+
openai: "OPENAI_API_KEY"
|
|
164
|
+
};
|
|
165
|
+
async function prompt(question) {
|
|
166
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
167
|
+
return new Promise((resolve) => {
|
|
168
|
+
rl.question(question, (answer) => {
|
|
169
|
+
rl.close();
|
|
170
|
+
resolve(answer.trim());
|
|
171
|
+
});
|
|
172
|
+
rl.once("close", () => resolve(""));
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
async function confirm(question, defaultYes = true) {
|
|
176
|
+
const hint = defaultYes ? "[Y/n]" : "[y/N]";
|
|
177
|
+
const answer = await prompt(`${question} ${hint} `);
|
|
178
|
+
if (!answer) return defaultYes;
|
|
179
|
+
return answer.toLowerCase().startsWith("y");
|
|
180
|
+
}
|
|
181
|
+
async function runConfigInit(opts, ctx2) {
|
|
182
|
+
const flagDriven = Boolean(
|
|
183
|
+
opts.provider || opts.lang || opts.outdir || opts.key
|
|
184
|
+
);
|
|
185
|
+
const interactive = !flagDriven && !ctx2.yes && process.stdout.isTTY === true && !ctx2.json;
|
|
186
|
+
if (interactive) {
|
|
187
|
+
return runConfigInitInteractive();
|
|
188
|
+
}
|
|
189
|
+
const providerName = ["groq", "openai", "local-whisper"].includes(opts.provider ?? "") ? opts.provider : "groq";
|
|
190
|
+
const config = RecmpConfigSchema.parse({});
|
|
191
|
+
config.provider.default = providerName;
|
|
192
|
+
if (opts.lang) config.transcription.defaultLanguage = opts.lang;
|
|
193
|
+
config.output.recordingDir = opts.outdir ?? paths.recordings;
|
|
194
|
+
await mkdir(dirname(configFilePath), { recursive: true });
|
|
195
|
+
await mkdir(config.output.recordingDir, { recursive: true });
|
|
196
|
+
await saveConfig(config);
|
|
197
|
+
let keychainStored = false;
|
|
198
|
+
if (opts.key && (providerName === "groq" || providerName === "openai")) {
|
|
199
|
+
keychainStored = await setSecret(ENV_VAR[providerName], opts.key);
|
|
200
|
+
if (!keychainStored) {
|
|
201
|
+
ctx2.note(
|
|
202
|
+
pc2.yellow(
|
|
203
|
+
" OS keychain unavailable \u2014 set the key via env var instead.\n"
|
|
204
|
+
)
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
ctx2.ok(
|
|
209
|
+
"config init",
|
|
210
|
+
{
|
|
211
|
+
configPath: configFilePath,
|
|
212
|
+
provider: providerName,
|
|
213
|
+
recordingDir: config.output.recordingDir,
|
|
214
|
+
keychainStored
|
|
215
|
+
},
|
|
216
|
+
() => {
|
|
217
|
+
console.log(`${pc2.green("\u2713")} Config saved: ${configFilePath}`);
|
|
218
|
+
console.log(`${pc2.green("\u2713")} Provider: ${providerName}`);
|
|
219
|
+
if (keychainStored)
|
|
220
|
+
console.log(`${pc2.green("\u2713")} API key stored in OS keychain`);
|
|
221
|
+
}
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
async function runConfigInitInteractive() {
|
|
225
|
+
console.log(`
|
|
226
|
+
${pc2.bold("recmp3 \u2014 First-time setup")}`);
|
|
227
|
+
console.log(pc2.gray(`Config will be saved to: ${configFilePath}
|
|
228
|
+
`));
|
|
229
|
+
console.log(`${pc2.bold("1. Transcription provider")}`);
|
|
230
|
+
console.log(
|
|
231
|
+
` ${pc2.cyan("groq")} \u2014 Groq Whisper API (fast, cheap, recommended)`
|
|
232
|
+
);
|
|
233
|
+
console.log(` ${pc2.cyan("openai")} \u2014 OpenAI Whisper API`);
|
|
234
|
+
console.log(` ${pc2.cyan("local-whisper")} \u2014 local whisper.cpp (no upload)`);
|
|
235
|
+
const providerInput = await prompt("\n Choice [groq]: ");
|
|
236
|
+
const providerName = ["groq", "openai", "local-whisper"].includes(providerInput) ? providerInput : "groq";
|
|
237
|
+
console.log(`
|
|
238
|
+
${pc2.bold("2. API key")}`);
|
|
239
|
+
if (providerName === "local-whisper") {
|
|
240
|
+
console.log(
|
|
241
|
+
` ${pc2.gray("No API key needed. Set RECMP3_WHISPER_BIN and RECMP3_WHISPER_MODEL.")}`
|
|
242
|
+
);
|
|
243
|
+
} else {
|
|
244
|
+
const envVar = ENV_VAR[providerName];
|
|
245
|
+
const existingKey = await getApiKey(providerName);
|
|
246
|
+
if (existingKey) {
|
|
247
|
+
console.log(
|
|
248
|
+
` ${pc2.green("\u2713")} ${envVar} is already set: ${redactKey(existingKey)}`
|
|
249
|
+
);
|
|
250
|
+
} else {
|
|
251
|
+
const entered = await prompt(
|
|
252
|
+
` Paste ${envVar} (stored in OS keychain), or leave blank: `
|
|
253
|
+
);
|
|
254
|
+
if (entered) {
|
|
255
|
+
const stored = await setSecret(envVar, entered);
|
|
256
|
+
console.log(
|
|
257
|
+
stored ? ` ${pc2.green("\u2713")} Stored in OS keychain` : ` ${pc2.yellow("!")} Keychain unavailable \u2014 set ${envVar} as an env var.`
|
|
258
|
+
);
|
|
259
|
+
} else {
|
|
260
|
+
const ok = await confirm(
|
|
261
|
+
" Continue without setting the key for now?",
|
|
262
|
+
true
|
|
263
|
+
);
|
|
264
|
+
if (!ok) {
|
|
265
|
+
console.log(
|
|
266
|
+
pc2.gray("\n Setup cancelled. Re-run after setting the API key.\n")
|
|
267
|
+
);
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
console.log(`
|
|
274
|
+
${pc2.bold("3. Default language")}`);
|
|
275
|
+
const lang = await prompt(" Language code (e.g. es, en) [auto]: ");
|
|
276
|
+
console.log(`
|
|
277
|
+
${pc2.bold("4. Recordings directory")}`);
|
|
278
|
+
console.log(` ${pc2.gray(`Default: ${paths.recordings}`)}`);
|
|
279
|
+
const outDir = await prompt(" Directory [default]: ");
|
|
280
|
+
const config = RecmpConfigSchema.parse({});
|
|
281
|
+
config.provider.default = providerName;
|
|
282
|
+
if (lang) config.transcription.defaultLanguage = lang;
|
|
283
|
+
config.output.recordingDir = outDir || paths.recordings;
|
|
284
|
+
await mkdir(dirname(configFilePath), { recursive: true });
|
|
285
|
+
await mkdir(config.output.recordingDir, { recursive: true });
|
|
286
|
+
await saveConfig(config);
|
|
287
|
+
console.log(`
|
|
288
|
+
${pc2.green("\u2713")} Config saved: ${configFilePath}`);
|
|
289
|
+
console.log(
|
|
290
|
+
`${pc2.green("\u2713")} Recordings directory: ${config.output.recordingDir}`
|
|
291
|
+
);
|
|
292
|
+
console.log(`
|
|
293
|
+
${pc2.cyan("recmp3 doctor")} \u2014 verify setup
|
|
294
|
+
`);
|
|
295
|
+
}
|
|
296
|
+
async function runConfigShow(ctx2) {
|
|
297
|
+
const config = await loadConfig();
|
|
298
|
+
const groqKey = await getApiKey("groq");
|
|
299
|
+
const openaiKey = await getApiKey("openai");
|
|
300
|
+
const payload = {
|
|
301
|
+
configPath: existsSync(configFilePath) ? configFilePath : null,
|
|
302
|
+
provider: {
|
|
303
|
+
default: config.provider.default,
|
|
304
|
+
groqModel: config.provider.groq?.model ?? "whisper-large-v3-turbo",
|
|
305
|
+
openaiModel: config.provider.openai?.model ?? "whisper-1",
|
|
306
|
+
local: config.provider.local ?? null
|
|
307
|
+
},
|
|
308
|
+
keys: {
|
|
309
|
+
groq: groqKey ? redactKey(groqKey) : null,
|
|
310
|
+
openai: openaiKey ? redactKey(openaiKey) : null
|
|
311
|
+
},
|
|
312
|
+
audio: config.audio,
|
|
313
|
+
output: config.output,
|
|
314
|
+
transcription: config.transcription
|
|
315
|
+
};
|
|
316
|
+
ctx2.ok("config show", payload, () => {
|
|
317
|
+
console.log(`
|
|
318
|
+
${pc2.bold("recmp3 configuration")}`);
|
|
319
|
+
console.log(
|
|
320
|
+
pc2.gray(
|
|
321
|
+
`Config file: ${payload.configPath ?? `${configFilePath} (not found \u2014 using defaults)`}
|
|
322
|
+
`
|
|
323
|
+
)
|
|
324
|
+
);
|
|
325
|
+
console.log(`${pc2.bold("Provider")}`);
|
|
326
|
+
console.log(` default: ${pc2.cyan(config.provider.default)}`);
|
|
327
|
+
console.log(` groq model: ${payload.provider.groqModel}`);
|
|
328
|
+
console.log(` openai model:${payload.provider.openaiModel}`);
|
|
329
|
+
console.log(`
|
|
330
|
+
${pc2.bold("API keys")}`);
|
|
331
|
+
console.log(
|
|
332
|
+
` GROQ_API_KEY: ${groqKey ? pc2.green(`set (${redactKey(groqKey)})`) : pc2.red("not set")}`
|
|
333
|
+
);
|
|
334
|
+
console.log(
|
|
335
|
+
` OPENAI_API_KEY: ${openaiKey ? pc2.green(`set (${redactKey(openaiKey)})`) : pc2.gray("not set")}`
|
|
336
|
+
);
|
|
337
|
+
console.log(`
|
|
338
|
+
${pc2.bold("Audio")}`);
|
|
339
|
+
console.log(` source: ${config.audio.source}`);
|
|
340
|
+
console.log(`
|
|
341
|
+
${pc2.bold("Output")}`);
|
|
342
|
+
console.log(` recordingDir: ${config.output.recordingDir}`);
|
|
343
|
+
console.log(`
|
|
344
|
+
${pc2.bold("Transcription")}`);
|
|
345
|
+
console.log(
|
|
346
|
+
` language: ${config.transcription.defaultLanguage ?? "auto-detect"}
|
|
347
|
+
`
|
|
348
|
+
);
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
async function runConfigPath(ctx2) {
|
|
352
|
+
ctx2.ok(
|
|
353
|
+
"config path",
|
|
354
|
+
{ path: configFilePath },
|
|
355
|
+
() => console.log(configFilePath)
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
async function runConfigSet(key, value, ctx2) {
|
|
359
|
+
const config = await loadConfig();
|
|
360
|
+
const parts = key.split(".");
|
|
361
|
+
let obj = config;
|
|
362
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
363
|
+
const part = parts[i];
|
|
364
|
+
if (typeof obj[part] !== "object" || obj[part] === null) obj[part] = {};
|
|
365
|
+
obj = obj[part];
|
|
366
|
+
}
|
|
367
|
+
const lastKey = parts[parts.length - 1];
|
|
368
|
+
if (value === "true") obj[lastKey] = true;
|
|
369
|
+
else if (value === "false") obj[lastKey] = false;
|
|
370
|
+
else if (!Number.isNaN(Number(value))) obj[lastKey] = Number(value);
|
|
371
|
+
else obj[lastKey] = value;
|
|
372
|
+
const parsed = RecmpConfigSchema.safeParse(config);
|
|
373
|
+
if (!parsed.success) {
|
|
374
|
+
throw new ConfigError(`Invalid config value: ${parsed.error.message}`);
|
|
375
|
+
}
|
|
376
|
+
await saveConfig(parsed.data);
|
|
377
|
+
resetConfigCache();
|
|
378
|
+
ctx2.ok(
|
|
379
|
+
"config set",
|
|
380
|
+
{ key, value },
|
|
381
|
+
() => console.log(`${pc2.green("\u2713")} Set ${key} = ${value}`)
|
|
382
|
+
);
|
|
383
|
+
}
|
|
384
|
+
async function runConfigSetKey(provider, opts, ctx2) {
|
|
385
|
+
if (provider !== "groq" && provider !== "openai") {
|
|
386
|
+
throw new InputError(
|
|
387
|
+
`Unknown provider: "${provider}". Valid: groq, openai.`
|
|
388
|
+
);
|
|
389
|
+
}
|
|
390
|
+
let value = opts.key;
|
|
391
|
+
if (!value && process.stdin.isTTY !== true) {
|
|
392
|
+
value = (await readStdinText()).trim();
|
|
393
|
+
}
|
|
394
|
+
if (!value) value = process.env[ENV_VAR[provider]];
|
|
395
|
+
if (!value) {
|
|
396
|
+
throw new InputError(
|
|
397
|
+
"No key provided. Use --key, pipe it on stdin, or set the *_API_KEY env var."
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
if (!await keychainAvailable()) {
|
|
401
|
+
throw new ConfigError(
|
|
402
|
+
"OS keychain (keytar) is unavailable on this machine."
|
|
403
|
+
);
|
|
404
|
+
}
|
|
405
|
+
await setSecret(ENV_VAR[provider], value);
|
|
406
|
+
ctx2.ok(
|
|
407
|
+
"config set-key",
|
|
408
|
+
{ provider, stored: true, backend: "keychain" },
|
|
409
|
+
() => console.log(
|
|
410
|
+
`${pc2.green("\u2713")} Stored ${ENV_VAR[provider]} in the OS keychain (${redactKey(value)})`
|
|
411
|
+
)
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
function printCheck(check) {
|
|
415
|
+
const icon = check.ok ? pc2.green("\u2713") : pc2.red("\u2717");
|
|
416
|
+
const label = pc2.bold(check.label.padEnd(30));
|
|
417
|
+
const detail = check.detail ? pc2.gray(check.detail) : "";
|
|
418
|
+
console.log(` ${icon} ${label} ${detail}`);
|
|
419
|
+
if (!check.ok && check.hint) {
|
|
420
|
+
console.log(` ${pc2.yellow("\u2192")} ${pc2.yellow(check.hint)}`);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
async function runDoctor(ctx2) {
|
|
424
|
+
const checks = [];
|
|
425
|
+
const nodeVersion = process.version;
|
|
426
|
+
const [nodeMajor] = nodeVersion.slice(1).split(".").map(Number);
|
|
427
|
+
const nodeOk = nodeMajor >= 20;
|
|
428
|
+
checks.push({
|
|
429
|
+
label: "Node.js version",
|
|
430
|
+
ok: nodeOk,
|
|
431
|
+
detail: nodeVersion,
|
|
432
|
+
hint: nodeOk ? void 0 : "Requires Node.js 20+. Visit https://nodejs.org/"
|
|
433
|
+
});
|
|
434
|
+
const platform = process.platform;
|
|
435
|
+
const platformLabels = {
|
|
436
|
+
linux: "Linux",
|
|
437
|
+
darwin: "macOS",
|
|
438
|
+
win32: "Windows"
|
|
439
|
+
};
|
|
440
|
+
const platformLabel = platformLabels[platform] ?? platform;
|
|
441
|
+
const platformSupported = ["linux", "darwin", "win32"].includes(platform);
|
|
442
|
+
checks.push({
|
|
443
|
+
label: "Platform",
|
|
444
|
+
ok: platformSupported,
|
|
445
|
+
detail: `${platformLabel} (${process.arch})`,
|
|
446
|
+
hint: platformSupported ? void 0 : `Platform "${platform}" may not be fully supported.`
|
|
447
|
+
});
|
|
448
|
+
const ffmpegCheck = await checkFfmpegVersion();
|
|
449
|
+
checks.push({
|
|
450
|
+
label: "ffmpeg",
|
|
451
|
+
ok: ffmpegCheck.meets,
|
|
452
|
+
detail: ffmpegCheck.version,
|
|
453
|
+
hint: ffmpegCheck.meets ? void 0 : "Requires ffmpeg 4.4+. Install: sudo apt install ffmpeg"
|
|
454
|
+
});
|
|
455
|
+
if (ffmpegCheck.meets) {
|
|
456
|
+
const backendFormats = {
|
|
457
|
+
linux: "pulse",
|
|
458
|
+
darwin: "avfoundation",
|
|
459
|
+
win32: "dshow"
|
|
460
|
+
};
|
|
461
|
+
const backendFormat = backendFormats[platform] ?? "pulse";
|
|
462
|
+
const backendOk = await supportsInputFormat(backendFormat).catch(
|
|
463
|
+
() => false
|
|
464
|
+
);
|
|
465
|
+
checks.push({
|
|
466
|
+
label: "Audio backend",
|
|
467
|
+
ok: backendOk,
|
|
468
|
+
detail: backendFormat,
|
|
469
|
+
hint: backendOk ? void 0 : `ffmpeg missing "${backendFormat}" input support. Reinstall ffmpeg.`
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
const configExists = existsSync(configFilePath);
|
|
473
|
+
checks.push({
|
|
474
|
+
label: "Config file",
|
|
475
|
+
ok: true,
|
|
476
|
+
detail: configExists ? configFilePath : `${configFilePath} (using defaults)`
|
|
477
|
+
});
|
|
478
|
+
let config;
|
|
479
|
+
try {
|
|
480
|
+
config = await loadConfig();
|
|
481
|
+
} catch (err) {
|
|
482
|
+
checks.push({
|
|
483
|
+
label: "Config load",
|
|
484
|
+
ok: false,
|
|
485
|
+
detail: err instanceof Error ? err.message : String(err),
|
|
486
|
+
hint: "Run: recmp3 config init"
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
if (config) {
|
|
490
|
+
const providerName = config.provider.default;
|
|
491
|
+
if (providerName === "local-whisper") {
|
|
492
|
+
const { LocalWhisperProvider } = await import('./local-whisper-VH26RX7Y.js');
|
|
493
|
+
const provider = new LocalWhisperProvider(config.provider.local ?? {});
|
|
494
|
+
const ping = await provider.ping();
|
|
495
|
+
checks.push({
|
|
496
|
+
label: "Provider: local-whisper",
|
|
497
|
+
ok: ping.ok,
|
|
498
|
+
detail: ping.ok ? `ready (${ping.latencyMs}ms)` : ping.error,
|
|
499
|
+
hint: ping.ok ? void 0 : "Set RECMP3_WHISPER_BIN and RECMP3_WHISPER_MODEL."
|
|
500
|
+
});
|
|
501
|
+
} else {
|
|
502
|
+
const apiKey = await getApiKey(providerName);
|
|
503
|
+
const keyOk = Boolean(apiKey);
|
|
504
|
+
checks.push({
|
|
505
|
+
label: `Provider: ${providerName}`,
|
|
506
|
+
ok: keyOk,
|
|
507
|
+
detail: keyOk ? `API key set (${redactKey(apiKey)})` : "API key not set",
|
|
508
|
+
hint: keyOk ? void 0 : `Set ${providerName === "groq" ? "GROQ_API_KEY" : "OPENAI_API_KEY"}, or run: recmp3 config set-key ${providerName}`
|
|
509
|
+
});
|
|
510
|
+
if (keyOk) {
|
|
511
|
+
try {
|
|
512
|
+
const { createProvider: createProvider2 } = await import('./registry-D5SOVUEJ.js');
|
|
513
|
+
const provider = await createProvider2(config);
|
|
514
|
+
const ping = provider.ping ? await provider.ping() : null;
|
|
515
|
+
if (ping) {
|
|
516
|
+
checks.push({
|
|
517
|
+
label: "Provider ping",
|
|
518
|
+
ok: ping.ok,
|
|
519
|
+
detail: ping.ok ? `${ping.latencyMs}ms` : ping.error,
|
|
520
|
+
hint: ping.ok ? void 0 : "Check network connection or API key validity."
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
} catch (err) {
|
|
524
|
+
checks.push({
|
|
525
|
+
label: "Provider ping",
|
|
526
|
+
ok: false,
|
|
527
|
+
detail: err instanceof Error ? err.message : String(err),
|
|
528
|
+
hint: "Check network connectivity."
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
checks.push({
|
|
534
|
+
label: "Recordings directory",
|
|
535
|
+
ok: true,
|
|
536
|
+
detail: config.output.recordingDir
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
const allOk = checks.every((c) => c.ok);
|
|
540
|
+
ctx2.ok("doctor", { ok: allOk, checks }, () => {
|
|
541
|
+
console.log(`
|
|
542
|
+
${pc2.bold("recmp3 doctor \u2014 preflight checks")}
|
|
543
|
+
`);
|
|
544
|
+
for (const check of checks) printCheck(check);
|
|
545
|
+
console.log("");
|
|
546
|
+
if (allOk) {
|
|
547
|
+
console.log(
|
|
548
|
+
pc2.green(" \u2713 All checks passed. Run: recmp3 record --transcribe\n")
|
|
549
|
+
);
|
|
550
|
+
} else {
|
|
551
|
+
console.log(
|
|
552
|
+
pc2.yellow(
|
|
553
|
+
" Some checks failed. Address the issues above and re-run: recmp3 doctor\n"
|
|
554
|
+
)
|
|
555
|
+
);
|
|
556
|
+
}
|
|
557
|
+
});
|
|
558
|
+
if (!allOk) process.exitCode = 1;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// src/agent/manifest.ts
|
|
562
|
+
var GLOBAL_FLAGS = [
|
|
563
|
+
{
|
|
564
|
+
name: "--json",
|
|
565
|
+
type: "boolean",
|
|
566
|
+
description: "Emit a stable JSON envelope on stdout",
|
|
567
|
+
env: "RECMP3_JSON"
|
|
568
|
+
},
|
|
569
|
+
{
|
|
570
|
+
name: "--yes",
|
|
571
|
+
type: "boolean",
|
|
572
|
+
description: "Skip all interactive prompts",
|
|
573
|
+
env: "RECMP3_YES"
|
|
574
|
+
},
|
|
575
|
+
{
|
|
576
|
+
name: "--quiet",
|
|
577
|
+
type: "boolean",
|
|
578
|
+
description: "Suppress stderr chatter",
|
|
579
|
+
env: "RECMP3_QUIET"
|
|
580
|
+
},
|
|
581
|
+
{
|
|
582
|
+
name: "--no-color",
|
|
583
|
+
type: "boolean",
|
|
584
|
+
description: "Disable colored output",
|
|
585
|
+
env: "NO_COLOR"
|
|
586
|
+
}
|
|
587
|
+
];
|
|
588
|
+
var MANIFEST = {
|
|
589
|
+
name: "recmp3",
|
|
590
|
+
version: "1.0.0",
|
|
591
|
+
description: "Record audio, transcribe with AI, output developer-ready prompts.",
|
|
592
|
+
globalFlags: GLOBAL_FLAGS,
|
|
593
|
+
exitCodes: {
|
|
594
|
+
success: ExitCode.SUCCESS,
|
|
595
|
+
unknown: ExitCode.UNKNOWN,
|
|
596
|
+
config: ExitCode.CONFIG,
|
|
597
|
+
audio: ExitCode.AUDIO,
|
|
598
|
+
transcription: ExitCode.TRANSCRIPTION,
|
|
599
|
+
network: ExitCode.NETWORK,
|
|
600
|
+
localWhisper: ExitCode.LOCAL_WHISPER,
|
|
601
|
+
input: ExitCode.INPUT,
|
|
602
|
+
userAbort: ExitCode.USER_ABORT
|
|
603
|
+
},
|
|
604
|
+
commands: [
|
|
605
|
+
{
|
|
606
|
+
name: "transcribe",
|
|
607
|
+
tool: "recmp3_transcribe",
|
|
608
|
+
summary: "Transcribe an existing audio file.",
|
|
609
|
+
agentSafe: true,
|
|
610
|
+
args: [
|
|
611
|
+
{
|
|
612
|
+
name: "file",
|
|
613
|
+
required: true,
|
|
614
|
+
description: 'Audio file path, or "-" for stdin'
|
|
615
|
+
}
|
|
616
|
+
],
|
|
617
|
+
flags: [
|
|
618
|
+
{
|
|
619
|
+
name: "--provider",
|
|
620
|
+
type: "string",
|
|
621
|
+
description: "groq | openai | local-whisper"
|
|
622
|
+
},
|
|
623
|
+
{
|
|
624
|
+
name: "--lang",
|
|
625
|
+
type: "string",
|
|
626
|
+
description: "Force language code (e.g. es, en)"
|
|
627
|
+
},
|
|
628
|
+
{
|
|
629
|
+
name: "--copy",
|
|
630
|
+
type: "boolean",
|
|
631
|
+
description: "Copy transcript to clipboard"
|
|
632
|
+
}
|
|
633
|
+
],
|
|
634
|
+
stdin: true,
|
|
635
|
+
stdout: "json"
|
|
636
|
+
},
|
|
637
|
+
{
|
|
638
|
+
name: "prompt",
|
|
639
|
+
tool: "recmp3_prompt",
|
|
640
|
+
summary: "Wrap a transcript in a developer prompt template (no network).",
|
|
641
|
+
agentSafe: true,
|
|
642
|
+
args: [
|
|
643
|
+
{
|
|
644
|
+
name: "file",
|
|
645
|
+
required: true,
|
|
646
|
+
description: 'Transcript file path, or "-" for stdin'
|
|
647
|
+
}
|
|
648
|
+
],
|
|
649
|
+
flags: [
|
|
650
|
+
{
|
|
651
|
+
name: "--template",
|
|
652
|
+
type: "string",
|
|
653
|
+
description: "claude-code | prd | bug | todo | meeting-notes | commit-message | raw",
|
|
654
|
+
default: "claude-code"
|
|
655
|
+
},
|
|
656
|
+
{
|
|
657
|
+
name: "--out",
|
|
658
|
+
type: "string",
|
|
659
|
+
description: "Write output to a file"
|
|
660
|
+
},
|
|
661
|
+
{
|
|
662
|
+
name: "--copy",
|
|
663
|
+
type: "boolean",
|
|
664
|
+
description: "Copy output to clipboard"
|
|
665
|
+
}
|
|
666
|
+
],
|
|
667
|
+
stdin: true,
|
|
668
|
+
stdout: "json"
|
|
669
|
+
},
|
|
670
|
+
{
|
|
671
|
+
name: "sources",
|
|
672
|
+
tool: "recmp3_sources",
|
|
673
|
+
summary: "List available audio input sources for the OS.",
|
|
674
|
+
agentSafe: true,
|
|
675
|
+
flags: [],
|
|
676
|
+
stdin: false,
|
|
677
|
+
stdout: "json"
|
|
678
|
+
},
|
|
679
|
+
{
|
|
680
|
+
name: "doctor",
|
|
681
|
+
tool: "recmp3_doctor",
|
|
682
|
+
summary: "Run preflight checks (Node, ffmpeg, audio backend, provider, etc.).",
|
|
683
|
+
agentSafe: true,
|
|
684
|
+
flags: [],
|
|
685
|
+
stdin: false,
|
|
686
|
+
stdout: "json"
|
|
687
|
+
},
|
|
688
|
+
{
|
|
689
|
+
name: "config show",
|
|
690
|
+
tool: "recmp3_config_show",
|
|
691
|
+
summary: "Show resolved configuration (API keys redacted).",
|
|
692
|
+
agentSafe: true,
|
|
693
|
+
flags: [],
|
|
694
|
+
stdin: false,
|
|
695
|
+
stdout: "json"
|
|
696
|
+
},
|
|
697
|
+
{
|
|
698
|
+
name: "manifest",
|
|
699
|
+
tool: "recmp3_manifest",
|
|
700
|
+
summary: "Print the command/tool manifest.",
|
|
701
|
+
agentSafe: true,
|
|
702
|
+
flags: [],
|
|
703
|
+
stdin: false,
|
|
704
|
+
stdout: "json"
|
|
705
|
+
},
|
|
706
|
+
{
|
|
707
|
+
name: "record",
|
|
708
|
+
tool: "recmp3_record",
|
|
709
|
+
summary: "Record audio. Agent/headless mode requires --duration.",
|
|
710
|
+
agentSafe: true,
|
|
711
|
+
flags: [
|
|
712
|
+
{
|
|
713
|
+
name: "--duration",
|
|
714
|
+
type: "number",
|
|
715
|
+
description: "Headless: record N seconds then stop"
|
|
716
|
+
},
|
|
717
|
+
{ name: "--name", type: "string", description: "Output filename stem" },
|
|
718
|
+
{ name: "--out", type: "string", description: "Output directory" },
|
|
719
|
+
{
|
|
720
|
+
name: "--transcribe",
|
|
721
|
+
type: "boolean",
|
|
722
|
+
description: "Transcribe after recording"
|
|
723
|
+
},
|
|
724
|
+
{
|
|
725
|
+
name: "--provider",
|
|
726
|
+
type: "string",
|
|
727
|
+
description: "groq | openai | local-whisper"
|
|
728
|
+
},
|
|
729
|
+
{ name: "--lang", type: "string", description: "Force language code" },
|
|
730
|
+
{
|
|
731
|
+
name: "--source",
|
|
732
|
+
type: "string",
|
|
733
|
+
description: 'Audio source id, or "auto" for the best physical mic'
|
|
734
|
+
}
|
|
735
|
+
],
|
|
736
|
+
stdin: false,
|
|
737
|
+
stdout: "json"
|
|
738
|
+
},
|
|
739
|
+
{
|
|
740
|
+
name: "config init",
|
|
741
|
+
summary: "First-time setup. Flag-driven when non-interactive.",
|
|
742
|
+
agentSafe: false,
|
|
743
|
+
flags: [
|
|
744
|
+
{
|
|
745
|
+
name: "--provider",
|
|
746
|
+
type: "string",
|
|
747
|
+
description: "groq | openai | local-whisper"
|
|
748
|
+
},
|
|
749
|
+
{
|
|
750
|
+
name: "--lang",
|
|
751
|
+
type: "string",
|
|
752
|
+
description: "Default language code"
|
|
753
|
+
},
|
|
754
|
+
{
|
|
755
|
+
name: "--outdir",
|
|
756
|
+
type: "string",
|
|
757
|
+
description: "Recordings output directory"
|
|
758
|
+
},
|
|
759
|
+
{
|
|
760
|
+
name: "--key",
|
|
761
|
+
type: "string",
|
|
762
|
+
description: "API key to store in the OS keychain"
|
|
763
|
+
}
|
|
764
|
+
],
|
|
765
|
+
stdin: false,
|
|
766
|
+
stdout: "none"
|
|
767
|
+
},
|
|
768
|
+
{
|
|
769
|
+
name: "config set-key",
|
|
770
|
+
summary: "Store an API key in the OS keychain.",
|
|
771
|
+
agentSafe: false,
|
|
772
|
+
args: [
|
|
773
|
+
{ name: "provider", required: true, description: "groq | openai" }
|
|
774
|
+
],
|
|
775
|
+
flags: [
|
|
776
|
+
{
|
|
777
|
+
name: "--key",
|
|
778
|
+
type: "string",
|
|
779
|
+
description: "Key value (else stdin or *_API_KEY env)"
|
|
780
|
+
}
|
|
781
|
+
],
|
|
782
|
+
stdin: true,
|
|
783
|
+
stdout: "none"
|
|
784
|
+
},
|
|
785
|
+
{
|
|
786
|
+
name: "mcp",
|
|
787
|
+
summary: "Start the Model Context Protocol server over stdio.",
|
|
788
|
+
agentSafe: false,
|
|
789
|
+
flags: [],
|
|
790
|
+
stdin: true,
|
|
791
|
+
stdout: "none"
|
|
792
|
+
}
|
|
793
|
+
]
|
|
794
|
+
};
|
|
795
|
+
|
|
796
|
+
// src/commands/manifest.ts
|
|
797
|
+
async function runManifest(ctx2) {
|
|
798
|
+
ctx2.ok("manifest", MANIFEST, () => {
|
|
799
|
+
process.stdout.write(`${JSON.stringify(MANIFEST, null, 2)}
|
|
800
|
+
`);
|
|
801
|
+
});
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
// src/output/clipboard.ts
|
|
805
|
+
async function copyToClipboard(text) {
|
|
806
|
+
try {
|
|
807
|
+
const clipboardy = await import('clipboardy');
|
|
808
|
+
await clipboardy.default.write(text);
|
|
809
|
+
return true;
|
|
810
|
+
} catch (err) {
|
|
811
|
+
log.info(
|
|
812
|
+
`Clipboard copy failed (headless or missing xclip/wl-copy): ${err instanceof Error ? err.message : String(err)}`
|
|
813
|
+
);
|
|
814
|
+
return false;
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
// src/commands/prompt.ts
|
|
819
|
+
var TEMPLATES = {
|
|
820
|
+
raw: (text) => text,
|
|
821
|
+
"claude-code": (text, name) => `# Claude Code Prompt${name ? ` \u2014 ${name}` : ""}
|
|
822
|
+
|
|
823
|
+
## Objective
|
|
824
|
+
${text}
|
|
825
|
+
|
|
826
|
+
## Context
|
|
827
|
+
[Add relevant codebase context here]
|
|
828
|
+
|
|
829
|
+
## Scope
|
|
830
|
+
- In scope: [define boundaries]
|
|
831
|
+
- Out of scope: [define exclusions]
|
|
832
|
+
|
|
833
|
+
## Constraints
|
|
834
|
+
- [Technical constraints]
|
|
835
|
+
- [Time constraints]
|
|
836
|
+
|
|
837
|
+
## Acceptance Criteria
|
|
838
|
+
- [ ] [Add specific acceptance criteria]
|
|
839
|
+
- [ ] All existing tests pass
|
|
840
|
+
- [ ] No regressions introduced
|
|
841
|
+
|
|
842
|
+
## Verification
|
|
843
|
+
\`\`\`bash
|
|
844
|
+
# Add verification commands here
|
|
845
|
+
\`\`\`
|
|
846
|
+
|
|
847
|
+
## CKIS writeback suggestion
|
|
848
|
+
[Note any architectural decisions made during implementation]
|
|
849
|
+
`,
|
|
850
|
+
prd: (text, name) => `# Product Requirements Document${name ? ` \u2014 ${name}` : ""}
|
|
851
|
+
|
|
852
|
+
**Created:** ${(/* @__PURE__ */ new Date()).toISOString().split("T")[0]}
|
|
853
|
+
**Status:** Draft
|
|
854
|
+
|
|
855
|
+
## Problem Statement
|
|
856
|
+
${text}
|
|
857
|
+
|
|
858
|
+
## Goals
|
|
859
|
+
- [Primary goal]
|
|
860
|
+
- [Secondary goal]
|
|
861
|
+
|
|
862
|
+
## Non-Goals
|
|
863
|
+
- [What this does NOT address]
|
|
864
|
+
|
|
865
|
+
## User Stories
|
|
866
|
+
- As a [user], I want [feature] so that [benefit]
|
|
867
|
+
|
|
868
|
+
## Requirements
|
|
869
|
+
### Functional
|
|
870
|
+
- [ ] [Requirement 1]
|
|
871
|
+
|
|
872
|
+
### Non-Functional
|
|
873
|
+
- [ ] [Performance requirement]
|
|
874
|
+
- [ ] [Security requirement]
|
|
875
|
+
|
|
876
|
+
## Success Metrics
|
|
877
|
+
- [Metric 1]
|
|
878
|
+
|
|
879
|
+
## Open Questions
|
|
880
|
+
- [Question 1]
|
|
881
|
+
|
|
882
|
+
## Timeline
|
|
883
|
+
- [Milestone]: [Date]
|
|
884
|
+
`,
|
|
885
|
+
bug: (text, name) => `# Bug Report${name ? ` \u2014 ${name}` : ""}
|
|
886
|
+
|
|
887
|
+
**Date:** ${(/* @__PURE__ */ new Date()).toISOString().split("T")[0]}
|
|
888
|
+
**Severity:** [critical / high / medium / low]
|
|
889
|
+
|
|
890
|
+
## Description
|
|
891
|
+
${text}
|
|
892
|
+
|
|
893
|
+
## Steps to Reproduce
|
|
894
|
+
1. [Step 1]
|
|
895
|
+
2. [Step 2]
|
|
896
|
+
3. [Step 3]
|
|
897
|
+
|
|
898
|
+
## Expected Behavior
|
|
899
|
+
[What should happen]
|
|
900
|
+
|
|
901
|
+
## Actual Behavior
|
|
902
|
+
[What actually happens]
|
|
903
|
+
|
|
904
|
+
## Environment
|
|
905
|
+
- OS: ${process.platform}
|
|
906
|
+
- Node: ${process.version}
|
|
907
|
+
|
|
908
|
+
## Possible Fix
|
|
909
|
+
[Your hypothesis]
|
|
910
|
+
|
|
911
|
+
## Attachments
|
|
912
|
+
- [ ] Screenshots
|
|
913
|
+
- [ ] Logs
|
|
914
|
+
- [ ] Reproduction repo
|
|
915
|
+
`,
|
|
916
|
+
"meeting-notes": (text, name) => `# Meeting Notes${name ? ` \u2014 ${name}` : ""}
|
|
917
|
+
|
|
918
|
+
**Date:** ${(/* @__PURE__ */ new Date()).toISOString().split("T")[0]}
|
|
919
|
+
|
|
920
|
+
## Summary
|
|
921
|
+
${text}
|
|
922
|
+
|
|
923
|
+
## Action Items
|
|
924
|
+
- [ ] [Owner] \u2014 [Action] \u2014 [Due date]
|
|
925
|
+
|
|
926
|
+
## Decisions Made
|
|
927
|
+
- [Decision 1]
|
|
928
|
+
|
|
929
|
+
## Open Questions
|
|
930
|
+
- [Question 1]
|
|
931
|
+
|
|
932
|
+
## Next Meeting
|
|
933
|
+
- [ ] Schedule: [Date/Time]
|
|
934
|
+
`,
|
|
935
|
+
todo: (text) => {
|
|
936
|
+
const lines = text.split(/[.!?]+/).filter((l) => l.trim().length > 5);
|
|
937
|
+
const todos = lines.map((l) => `- [ ] ${l.trim()}`).join("\n");
|
|
938
|
+
return `# TODO List
|
|
939
|
+
|
|
940
|
+
${todos}
|
|
941
|
+
`;
|
|
942
|
+
},
|
|
943
|
+
"commit-message": (text) => {
|
|
944
|
+
const firstSentence = text.split(/[.!?]/)[0]?.trim() ?? text;
|
|
945
|
+
const subject = firstSentence.slice(0, 72).toLowerCase().replace(/^i /, "");
|
|
946
|
+
const body = text.length > firstSentence.length ? `
|
|
947
|
+
|
|
948
|
+
${text}` : "";
|
|
949
|
+
return `${subject}${body}
|
|
950
|
+
`;
|
|
951
|
+
}
|
|
952
|
+
};
|
|
953
|
+
function listTemplates() {
|
|
954
|
+
console.log(`
|
|
955
|
+
${pc2.bold("Available prompt templates:")}
|
|
956
|
+
`);
|
|
957
|
+
for (const name of Object.keys(TEMPLATES)) {
|
|
958
|
+
console.log(` ${pc2.cyan(name)}`);
|
|
959
|
+
}
|
|
960
|
+
console.log(
|
|
961
|
+
"\n Usage: recmp3 prompt <transcript.txt> --template claude-code\n"
|
|
962
|
+
);
|
|
963
|
+
}
|
|
964
|
+
async function runPrompt(transcriptFile, opts, ctx2) {
|
|
965
|
+
if (opts.listTemplates) {
|
|
966
|
+
listTemplates();
|
|
967
|
+
return;
|
|
968
|
+
}
|
|
969
|
+
const templateName = opts.template ?? "claude-code";
|
|
970
|
+
const templateFn = TEMPLATES[templateName];
|
|
971
|
+
if (!templateFn) {
|
|
972
|
+
throw new InputError(
|
|
973
|
+
`Unknown template: "${templateName}". Available: ${Object.keys(TEMPLATES).join(", ")}`
|
|
974
|
+
);
|
|
975
|
+
}
|
|
976
|
+
let text;
|
|
977
|
+
let name;
|
|
978
|
+
if (transcriptFile === "-") {
|
|
979
|
+
text = (await readStdinText()).trim();
|
|
980
|
+
if (!text) throw new InputError("No text received on stdin.");
|
|
981
|
+
} else {
|
|
982
|
+
if (!existsSync(transcriptFile)) {
|
|
983
|
+
throw new InputError(`File not found: ${transcriptFile}`);
|
|
984
|
+
}
|
|
985
|
+
text = readFileSync(transcriptFile, "utf-8").trim();
|
|
986
|
+
name = basename(transcriptFile, ".txt");
|
|
987
|
+
}
|
|
988
|
+
const output = templateFn(text, name);
|
|
989
|
+
if (opts.out) {
|
|
990
|
+
await writeFile(opts.out, output, "utf-8");
|
|
991
|
+
ctx2.note(`${pc2.green("\u2713")} Written to: ${opts.out}
|
|
992
|
+
`);
|
|
993
|
+
}
|
|
994
|
+
if (opts.copy) {
|
|
995
|
+
const copied = await copyToClipboard(output);
|
|
996
|
+
if (copied) ctx2.note(pc2.gray(" Copied to clipboard.\n"));
|
|
997
|
+
}
|
|
998
|
+
ctx2.ok(
|
|
999
|
+
"prompt",
|
|
1000
|
+
{ template: templateName, output },
|
|
1001
|
+
() => process.stdout.write(output)
|
|
1002
|
+
);
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
// src/audio/auto-source.ts
|
|
1006
|
+
var MONITOR_RE = /\.monitor\b|\bmonitor\b/i;
|
|
1007
|
+
function pickAutoSource(sources) {
|
|
1008
|
+
const physical = sources.filter(
|
|
1009
|
+
(s) => s.id !== "default" && !MONITOR_RE.test(s.id) && !MONITOR_RE.test(s.label)
|
|
1010
|
+
);
|
|
1011
|
+
const preferred = physical.find((s) => /input/i.test(s.id));
|
|
1012
|
+
return (preferred ?? physical[0])?.id ?? "default";
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
// src/audio/capture.ts
|
|
1016
|
+
var _factory = null;
|
|
1017
|
+
async function getAudioFactory() {
|
|
1018
|
+
if (_factory) return _factory;
|
|
1019
|
+
const platform = process.platform;
|
|
1020
|
+
if (platform === "linux") {
|
|
1021
|
+
const { LinuxPulseCaptureFactory } = await import('./linux-pulse-AROLYZNB.js');
|
|
1022
|
+
_factory = new LinuxPulseCaptureFactory();
|
|
1023
|
+
} else if (platform === "darwin") {
|
|
1024
|
+
const { MacAvFoundationFactory } = await import('./mac-avfoundation-COPCFRZT.js');
|
|
1025
|
+
_factory = new MacAvFoundationFactory();
|
|
1026
|
+
} else if (platform === "win32") {
|
|
1027
|
+
const { WindowsDshowFactory } = await import('./windows-dshow-O2GU4ZLR.js');
|
|
1028
|
+
_factory = new WindowsDshowFactory();
|
|
1029
|
+
} else {
|
|
1030
|
+
throw new Error(
|
|
1031
|
+
`Unsupported platform: ${platform}. Supported platforms: linux, darwin, win32.`
|
|
1032
|
+
);
|
|
1033
|
+
}
|
|
1034
|
+
return _factory;
|
|
1035
|
+
}
|
|
1036
|
+
async function ensureUploadConsent(ctx2) {
|
|
1037
|
+
const config = await loadConfig();
|
|
1038
|
+
if (config.consent.uploadsAcknowledged) return;
|
|
1039
|
+
if (ctx2.yes) {
|
|
1040
|
+
config.consent.uploadsAcknowledged = true;
|
|
1041
|
+
config.consent.acknowledgedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1042
|
+
await saveConfig(config).catch(() => {
|
|
1043
|
+
});
|
|
1044
|
+
return;
|
|
1045
|
+
}
|
|
1046
|
+
if (!process.stdout.isTTY || ctx2.json) {
|
|
1047
|
+
ctx2.note(
|
|
1048
|
+
`${pc2.yellow("\u26A0 recmp3 will upload audio to the configured provider for transcription.\n")} Pass --yes or set RECMP3_YES=1 to suppress this in scripts.
|
|
1049
|
+
`
|
|
1050
|
+
);
|
|
1051
|
+
return;
|
|
1052
|
+
}
|
|
1053
|
+
process.stdout.write(
|
|
1054
|
+
`
|
|
1055
|
+
${pc2.bold(" recmp3 will upload your audio to the transcription provider.\n")}${pc2.gray(" Audio is transmitted over HTTPS. Provider data retention terms apply.\n")}${pc2.gray(` Current provider: ${config.provider.default}
|
|
1056
|
+
`)}
|
|
1057
|
+
Continue? [Y/n] `
|
|
1058
|
+
);
|
|
1059
|
+
const answer = await new Promise((resolve) => {
|
|
1060
|
+
const rl = createInterface({
|
|
1061
|
+
input: process.stdin,
|
|
1062
|
+
output: process.stdout
|
|
1063
|
+
});
|
|
1064
|
+
rl.once("line", (line) => {
|
|
1065
|
+
rl.close();
|
|
1066
|
+
resolve(line.trim().toLowerCase());
|
|
1067
|
+
});
|
|
1068
|
+
rl.once("close", () => resolve("y"));
|
|
1069
|
+
});
|
|
1070
|
+
if (answer === "n" || answer === "no") {
|
|
1071
|
+
process.stdout.write(pc2.gray(" Cancelled. No audio was uploaded.\n\n"));
|
|
1072
|
+
process.exit(0);
|
|
1073
|
+
}
|
|
1074
|
+
config.consent.uploadsAcknowledged = true;
|
|
1075
|
+
config.consent.acknowledgedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1076
|
+
await saveConfig(config).catch(() => {
|
|
1077
|
+
});
|
|
1078
|
+
process.stdout.write("\n");
|
|
1079
|
+
}
|
|
1080
|
+
function formatDate(d = /* @__PURE__ */ new Date()) {
|
|
1081
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
1082
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}_${pad(d.getHours())}-${pad(d.getMinutes())}-${pad(d.getSeconds())}`;
|
|
1083
|
+
}
|
|
1084
|
+
function generateRecordingName(opts) {
|
|
1085
|
+
const ext = opts.ext ?? "wav";
|
|
1086
|
+
const ts = formatDate();
|
|
1087
|
+
if (opts.name) {
|
|
1088
|
+
const slug = opts.name.toLowerCase().replace(/[^a-z0-9-_]+/g, "-").replace(/^-+|-+$/g, "");
|
|
1089
|
+
return `${slug}-${ts}.${ext}`;
|
|
1090
|
+
}
|
|
1091
|
+
const prefix = opts.prefix ?? "rec";
|
|
1092
|
+
return `${prefix}-${ts}.${ext}`;
|
|
1093
|
+
}
|
|
1094
|
+
function transcriptPath(audioPath, ext) {
|
|
1095
|
+
const base = audioPath.replace(/\.(wav|mp3|m4a|ogg|flac)$/i, "");
|
|
1096
|
+
return `${base}.${ext}`;
|
|
1097
|
+
}
|
|
1098
|
+
function buildOutputPath(dir, name) {
|
|
1099
|
+
return join(dir, name);
|
|
1100
|
+
}
|
|
1101
|
+
async function writeTranscriptFiles(audioPath, result) {
|
|
1102
|
+
const txtPath = transcriptPath(audioPath, "txt");
|
|
1103
|
+
const jsonPath = transcriptPath(audioPath, "json");
|
|
1104
|
+
await writeFile(txtPath, `${result.text}
|
|
1105
|
+
`, "utf-8");
|
|
1106
|
+
const meta = {
|
|
1107
|
+
text: result.text,
|
|
1108
|
+
provider: result.provider,
|
|
1109
|
+
model: result.model,
|
|
1110
|
+
language: result.language,
|
|
1111
|
+
durationSec: result.durationSec,
|
|
1112
|
+
latencyMs: result.latencyMs,
|
|
1113
|
+
audioFile: audioPath,
|
|
1114
|
+
segments: result.segments
|
|
1115
|
+
};
|
|
1116
|
+
await writeFile(jsonPath, `${JSON.stringify(meta, null, 2)}
|
|
1117
|
+
`, "utf-8");
|
|
1118
|
+
return { txtPath, jsonPath };
|
|
1119
|
+
}
|
|
1120
|
+
var execFileAsync = promisify(execFile);
|
|
1121
|
+
async function transcribeWithChunking(provider, input, chunkSeconds = 600) {
|
|
1122
|
+
const fileStat = await stat(input.audioPath);
|
|
1123
|
+
if (fileStat.size <= provider.maxFileSizeBytes) {
|
|
1124
|
+
return provider.transcribe(input);
|
|
1125
|
+
}
|
|
1126
|
+
const tmpDir = join(
|
|
1127
|
+
(await import('os')).tmpdir(),
|
|
1128
|
+
`recmp3-chunks-${Date.now()}`
|
|
1129
|
+
);
|
|
1130
|
+
await mkdir(tmpDir, { recursive: true });
|
|
1131
|
+
const ffmpeg = await findFfmpeg();
|
|
1132
|
+
const chunkPattern = join(tmpDir, "chunk-%04d.wav");
|
|
1133
|
+
await execFileAsync(ffmpeg, [
|
|
1134
|
+
"-hide_banner",
|
|
1135
|
+
"-loglevel",
|
|
1136
|
+
"error",
|
|
1137
|
+
"-i",
|
|
1138
|
+
input.audioPath,
|
|
1139
|
+
"-f",
|
|
1140
|
+
"segment",
|
|
1141
|
+
"-segment_time",
|
|
1142
|
+
String(chunkSeconds),
|
|
1143
|
+
"-c",
|
|
1144
|
+
"copy",
|
|
1145
|
+
chunkPattern
|
|
1146
|
+
]);
|
|
1147
|
+
const files = (await readdir(tmpDir)).filter((f) => f.startsWith("chunk-") && f.endsWith(".wav")).sort().map((f) => join(tmpDir, f));
|
|
1148
|
+
if (files.length === 0) {
|
|
1149
|
+
return provider.transcribe(input);
|
|
1150
|
+
}
|
|
1151
|
+
const results = [];
|
|
1152
|
+
for (const chunkPath of files) {
|
|
1153
|
+
const result = await provider.transcribe({
|
|
1154
|
+
...input,
|
|
1155
|
+
audioPath: chunkPath
|
|
1156
|
+
});
|
|
1157
|
+
results.push(result);
|
|
1158
|
+
}
|
|
1159
|
+
const combinedText = results.map((r) => r.text).join(" ");
|
|
1160
|
+
const totalLatency = results.reduce((sum, r) => sum + r.latencyMs, 0);
|
|
1161
|
+
return {
|
|
1162
|
+
text: combinedText,
|
|
1163
|
+
language: results[0]?.language,
|
|
1164
|
+
durationSec: results.reduce((sum, r) => sum + (r.durationSec ?? 0), 0),
|
|
1165
|
+
raw: results.map((r) => r.raw),
|
|
1166
|
+
provider: results[0]?.provider ?? provider.name,
|
|
1167
|
+
model: results[0]?.model ?? "",
|
|
1168
|
+
latencyMs: totalLatency
|
|
1169
|
+
};
|
|
1170
|
+
}
|
|
1171
|
+
var execFileAsync2 = promisify(execFile);
|
|
1172
|
+
async function concatSegments(segments, outputPath, tmpDir, format = "wav") {
|
|
1173
|
+
const validSegments = segments.filter((s) => s.sizeBytes > 0);
|
|
1174
|
+
if (validSegments.length === 0) {
|
|
1175
|
+
throw new AudioCaptureError(
|
|
1176
|
+
"No audio was recorded. The recording was empty or too short."
|
|
1177
|
+
);
|
|
1178
|
+
}
|
|
1179
|
+
const ffmpeg = await findFfmpeg();
|
|
1180
|
+
if (validSegments.length === 1) {
|
|
1181
|
+
if (format === "wav") {
|
|
1182
|
+
const { copyFile } = await import('fs/promises');
|
|
1183
|
+
await copyFile(validSegments[0].path, outputPath);
|
|
1184
|
+
return outputPath;
|
|
1185
|
+
}
|
|
1186
|
+
await execFileAsync2(ffmpeg, [
|
|
1187
|
+
"-hide_banner",
|
|
1188
|
+
"-loglevel",
|
|
1189
|
+
"error",
|
|
1190
|
+
"-i",
|
|
1191
|
+
validSegments[0].path,
|
|
1192
|
+
"-c:a",
|
|
1193
|
+
"libmp3lame",
|
|
1194
|
+
"-b:a",
|
|
1195
|
+
"192k",
|
|
1196
|
+
"-y",
|
|
1197
|
+
outputPath
|
|
1198
|
+
]);
|
|
1199
|
+
return outputPath;
|
|
1200
|
+
}
|
|
1201
|
+
const listPath = join(tmpDir, "concat-list.txt");
|
|
1202
|
+
const listContent = validSegments.map((s) => `file '${s.path.replace(/'/g, "'\\''")}'`).join("\n");
|
|
1203
|
+
await writeFile(listPath, listContent, "utf-8");
|
|
1204
|
+
const codecArgs = format === "mp3" ? ["-c:a", "libmp3lame", "-b:a", "192k"] : ["-c", "copy"];
|
|
1205
|
+
await execFileAsync2(ffmpeg, [
|
|
1206
|
+
"-hide_banner",
|
|
1207
|
+
"-loglevel",
|
|
1208
|
+
"error",
|
|
1209
|
+
"-f",
|
|
1210
|
+
"concat",
|
|
1211
|
+
"-safe",
|
|
1212
|
+
"0",
|
|
1213
|
+
"-i",
|
|
1214
|
+
listPath,
|
|
1215
|
+
...codecArgs,
|
|
1216
|
+
"-y",
|
|
1217
|
+
outputPath
|
|
1218
|
+
]);
|
|
1219
|
+
return outputPath;
|
|
1220
|
+
}
|
|
1221
|
+
var RecorderUI = ({
|
|
1222
|
+
capture,
|
|
1223
|
+
captureOpts,
|
|
1224
|
+
outputPath,
|
|
1225
|
+
onResult
|
|
1226
|
+
}) => {
|
|
1227
|
+
const { exit } = useApp();
|
|
1228
|
+
const [status, setStatus] = useState("recording");
|
|
1229
|
+
const [elapsedMs, setElapsedMs] = useState(0);
|
|
1230
|
+
const [segmentCount, setSegmentCount] = useState(1);
|
|
1231
|
+
const [statusMessage, setStatusMessage] = useState("");
|
|
1232
|
+
const [busy, setBusy] = useState(false);
|
|
1233
|
+
const segmentsRef = useRef([]);
|
|
1234
|
+
const accumulatedMsRef = useRef(0);
|
|
1235
|
+
const segmentStartRef = useRef(Date.now());
|
|
1236
|
+
const currentSegmentIndexRef = useRef(1);
|
|
1237
|
+
const isRecordingRef = useRef(true);
|
|
1238
|
+
useEffect(() => {
|
|
1239
|
+
const interval = setInterval(() => {
|
|
1240
|
+
if (isRecordingRef.current) {
|
|
1241
|
+
setElapsedMs(
|
|
1242
|
+
accumulatedMsRef.current + (Date.now() - segmentStartRef.current)
|
|
1243
|
+
);
|
|
1244
|
+
}
|
|
1245
|
+
}, 100);
|
|
1246
|
+
return () => clearInterval(interval);
|
|
1247
|
+
}, []);
|
|
1248
|
+
const stopCurrentSegment = useCallback(async () => {
|
|
1249
|
+
if (!isRecordingRef.current) return null;
|
|
1250
|
+
try {
|
|
1251
|
+
const segment = await capture.stop();
|
|
1252
|
+
accumulatedMsRef.current += Date.now() - segmentStartRef.current;
|
|
1253
|
+
isRecordingRef.current = false;
|
|
1254
|
+
segmentsRef.current.push(segment);
|
|
1255
|
+
return segment;
|
|
1256
|
+
} catch {
|
|
1257
|
+
return null;
|
|
1258
|
+
}
|
|
1259
|
+
}, [capture]);
|
|
1260
|
+
const startNewSegment = useCallback(async () => {
|
|
1261
|
+
currentSegmentIndexRef.current += 1;
|
|
1262
|
+
const segPath = `${captureOpts.tmpDir}/segment-${String(currentSegmentIndexRef.current).padStart(4, "0")}.wav`;
|
|
1263
|
+
await capture.start({ ...captureOpts, outputPath: segPath });
|
|
1264
|
+
segmentStartRef.current = Date.now();
|
|
1265
|
+
isRecordingRef.current = true;
|
|
1266
|
+
setSegmentCount(currentSegmentIndexRef.current + 1);
|
|
1267
|
+
}, [capture, captureOpts]);
|
|
1268
|
+
const handlePause = useCallback(async () => {
|
|
1269
|
+
if (busy || status !== "recording") return;
|
|
1270
|
+
setBusy(true);
|
|
1271
|
+
await stopCurrentSegment();
|
|
1272
|
+
setStatus("paused");
|
|
1273
|
+
setBusy(false);
|
|
1274
|
+
}, [busy, status, stopCurrentSegment]);
|
|
1275
|
+
const handleResume = useCallback(async () => {
|
|
1276
|
+
if (busy || status !== "paused") return;
|
|
1277
|
+
setBusy(true);
|
|
1278
|
+
await startNewSegment();
|
|
1279
|
+
setStatus("recording");
|
|
1280
|
+
setBusy(false);
|
|
1281
|
+
}, [busy, status, startNewSegment]);
|
|
1282
|
+
const handleSave = useCallback(async () => {
|
|
1283
|
+
if (busy || status !== "recording" && status !== "paused") return;
|
|
1284
|
+
setBusy(true);
|
|
1285
|
+
setStatus("saving");
|
|
1286
|
+
try {
|
|
1287
|
+
await stopCurrentSegment();
|
|
1288
|
+
setStatusMessage("Concatenating segments...");
|
|
1289
|
+
const finalPath = await concatSegments(
|
|
1290
|
+
segmentsRef.current,
|
|
1291
|
+
outputPath,
|
|
1292
|
+
captureOpts.tmpDir,
|
|
1293
|
+
"wav"
|
|
1294
|
+
);
|
|
1295
|
+
setStatusMessage("");
|
|
1296
|
+
setStatus("done");
|
|
1297
|
+
onResult({
|
|
1298
|
+
cancelled: false,
|
|
1299
|
+
outputPath: finalPath,
|
|
1300
|
+
segments: segmentsRef.current,
|
|
1301
|
+
totalDurationMs: accumulatedMsRef.current
|
|
1302
|
+
});
|
|
1303
|
+
setTimeout(() => exit(), 800);
|
|
1304
|
+
} catch (err) {
|
|
1305
|
+
setStatus("error");
|
|
1306
|
+
setStatusMessage(err instanceof Error ? err.message : String(err));
|
|
1307
|
+
setTimeout(() => exit(), 2e3);
|
|
1308
|
+
}
|
|
1309
|
+
}, [
|
|
1310
|
+
busy,
|
|
1311
|
+
status,
|
|
1312
|
+
stopCurrentSegment,
|
|
1313
|
+
outputPath,
|
|
1314
|
+
captureOpts.tmpDir,
|
|
1315
|
+
onResult,
|
|
1316
|
+
exit
|
|
1317
|
+
]);
|
|
1318
|
+
const handleCancel = useCallback(async () => {
|
|
1319
|
+
if (busy) return;
|
|
1320
|
+
setBusy(true);
|
|
1321
|
+
await capture.dispose();
|
|
1322
|
+
setStatus("cancelled");
|
|
1323
|
+
onResult({ cancelled: true, segments: [], totalDurationMs: 0 });
|
|
1324
|
+
setTimeout(() => exit(), 300);
|
|
1325
|
+
}, [busy, capture, onResult, exit]);
|
|
1326
|
+
useInput((input, key) => {
|
|
1327
|
+
if (key.ctrl && input === "c") {
|
|
1328
|
+
handleCancel();
|
|
1329
|
+
return;
|
|
1330
|
+
}
|
|
1331
|
+
if (input === "c" || key.escape) {
|
|
1332
|
+
handleCancel();
|
|
1333
|
+
return;
|
|
1334
|
+
}
|
|
1335
|
+
if (input === "p" || input === " ") {
|
|
1336
|
+
if (status === "recording") handlePause();
|
|
1337
|
+
else if (status === "paused") handleResume();
|
|
1338
|
+
return;
|
|
1339
|
+
}
|
|
1340
|
+
if (input === "s" || key.return) {
|
|
1341
|
+
handleSave();
|
|
1342
|
+
return;
|
|
1343
|
+
}
|
|
1344
|
+
});
|
|
1345
|
+
const elapsed = Math.floor(elapsedMs / 1e3);
|
|
1346
|
+
const h = Math.floor(elapsed / 3600);
|
|
1347
|
+
const m = Math.floor(elapsed % 3600 / 60);
|
|
1348
|
+
const s = elapsed % 60;
|
|
1349
|
+
const timeStr = `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
|
|
1350
|
+
const statusColors = {
|
|
1351
|
+
recording: "red",
|
|
1352
|
+
paused: "yellow",
|
|
1353
|
+
saving: "cyan",
|
|
1354
|
+
done: "green",
|
|
1355
|
+
cancelled: "gray",
|
|
1356
|
+
error: "red"
|
|
1357
|
+
};
|
|
1358
|
+
const statusLabels = {
|
|
1359
|
+
recording: "\u25CF REC",
|
|
1360
|
+
paused: "\u2016 PAUSED",
|
|
1361
|
+
saving: "\u25CC SAVING",
|
|
1362
|
+
done: "\u2713 DONE",
|
|
1363
|
+
cancelled: "\u2717 CANCELLED",
|
|
1364
|
+
error: "\u2717 ERROR"
|
|
1365
|
+
};
|
|
1366
|
+
const showControls = status === "recording" || status === "paused";
|
|
1367
|
+
return /* @__PURE__ */ jsxs(
|
|
1368
|
+
Box,
|
|
1369
|
+
{
|
|
1370
|
+
flexDirection: "column",
|
|
1371
|
+
borderStyle: "round",
|
|
1372
|
+
paddingX: 2,
|
|
1373
|
+
paddingY: 1,
|
|
1374
|
+
width: 44,
|
|
1375
|
+
children: [
|
|
1376
|
+
/* @__PURE__ */ jsx(Box, { justifyContent: "center", children: /* @__PURE__ */ jsxs(
|
|
1377
|
+
Text,
|
|
1378
|
+
{
|
|
1379
|
+
bold: true,
|
|
1380
|
+
color: statusColors[status],
|
|
1381
|
+
children: [
|
|
1382
|
+
statusLabels[status],
|
|
1383
|
+
" ",
|
|
1384
|
+
timeStr
|
|
1385
|
+
]
|
|
1386
|
+
}
|
|
1387
|
+
) }),
|
|
1388
|
+
statusMessage ? /* @__PURE__ */ jsx(Box, { justifyContent: "center", marginTop: 1, children: /* @__PURE__ */ jsx(Text, { color: "cyan", children: statusMessage }) }) : showControls ? /* @__PURE__ */ jsx(Box, { justifyContent: "center", marginTop: 1, children: /* @__PURE__ */ jsxs(Text, { color: "gray", children: [
|
|
1389
|
+
status === "recording" ? "[p] pause" : "[p] resume",
|
|
1390
|
+
" [s] save [c] cancel"
|
|
1391
|
+
] }) }) : null
|
|
1392
|
+
]
|
|
1393
|
+
}
|
|
1394
|
+
);
|
|
1395
|
+
};
|
|
1396
|
+
async function runRecorderTUI(capture, captureOpts, outputPath) {
|
|
1397
|
+
return new Promise((resolve) => {
|
|
1398
|
+
let result = null;
|
|
1399
|
+
const { waitUntilExit } = render(
|
|
1400
|
+
/* @__PURE__ */ jsx(
|
|
1401
|
+
RecorderUI,
|
|
1402
|
+
{
|
|
1403
|
+
capture,
|
|
1404
|
+
captureOpts: {
|
|
1405
|
+
...captureOpts,
|
|
1406
|
+
outputPath: `${captureOpts.tmpDir}/segment-0001.wav`
|
|
1407
|
+
},
|
|
1408
|
+
outputPath,
|
|
1409
|
+
onResult: (r) => {
|
|
1410
|
+
result = r;
|
|
1411
|
+
}
|
|
1412
|
+
}
|
|
1413
|
+
),
|
|
1414
|
+
{ exitOnCtrlC: false }
|
|
1415
|
+
);
|
|
1416
|
+
waitUntilExit().then(() => {
|
|
1417
|
+
resolve(result ?? { cancelled: true, segments: [], totalDurationMs: 0 });
|
|
1418
|
+
});
|
|
1419
|
+
});
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
// src/commands/record.ts
|
|
1423
|
+
async function resolveSource(opts, config, factory) {
|
|
1424
|
+
const requested = opts.source ?? config.audio.source;
|
|
1425
|
+
if (requested !== "auto") return requested;
|
|
1426
|
+
const sources = await factory.listSources();
|
|
1427
|
+
return pickAutoSource(sources);
|
|
1428
|
+
}
|
|
1429
|
+
async function runRecord(opts, ctx2) {
|
|
1430
|
+
const config = await loadConfig();
|
|
1431
|
+
const durationSec = opts.duration ? Number(opts.duration) : void 0;
|
|
1432
|
+
const headless = opts.tui === false || durationSec !== void 0 || ctx2.json || process.stdout.isTTY !== true;
|
|
1433
|
+
const outDir = opts.out ?? config.output.recordingDir;
|
|
1434
|
+
await mkdir(outDir, { recursive: true });
|
|
1435
|
+
if (headless) {
|
|
1436
|
+
await recordHeadless(opts, ctx2, config, outDir, durationSec);
|
|
1437
|
+
} else {
|
|
1438
|
+
await recordTui(opts, ctx2, config, outDir);
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
1441
|
+
async function recordHeadless(opts, ctx2, config, outDir, durationSec) {
|
|
1442
|
+
if (opts.mp3)
|
|
1443
|
+
ctx2.note(pc2.yellow(" --mp3 is ignored in headless mode; saving WAV.\n"));
|
|
1444
|
+
const filename = generateRecordingName({
|
|
1445
|
+
name: opts.name,
|
|
1446
|
+
prefix: config.output.namePrefix,
|
|
1447
|
+
ext: "wav"
|
|
1448
|
+
});
|
|
1449
|
+
const outputPath = buildOutputPath(outDir, filename);
|
|
1450
|
+
const factory = await getAudioFactory();
|
|
1451
|
+
const capture = factory.create();
|
|
1452
|
+
const source = await resolveSource(opts, config, factory);
|
|
1453
|
+
try {
|
|
1454
|
+
await capture.start({
|
|
1455
|
+
source,
|
|
1456
|
+
outputPath,
|
|
1457
|
+
sampleRate: 16e3,
|
|
1458
|
+
channels: 1,
|
|
1459
|
+
format: "wav"
|
|
1460
|
+
});
|
|
1461
|
+
} catch (err) {
|
|
1462
|
+
if (err instanceof RecmpError) throw err;
|
|
1463
|
+
throw new RecmpError(
|
|
1464
|
+
"AUDIO_START_FAILED",
|
|
1465
|
+
`Failed to start recording: ${err instanceof Error ? err.message : String(err)}`,
|
|
1466
|
+
3
|
|
1467
|
+
);
|
|
1468
|
+
}
|
|
1469
|
+
ctx2.note(
|
|
1470
|
+
pc2.cyan(
|
|
1471
|
+
durationSec !== void 0 ? ` Recording for ${durationSec}s...
|
|
1472
|
+
` : " Recording... press Ctrl+C to stop.\n"
|
|
1473
|
+
)
|
|
1474
|
+
);
|
|
1475
|
+
await new Promise((resolve) => {
|
|
1476
|
+
let done = false;
|
|
1477
|
+
const finish = () => {
|
|
1478
|
+
if (done) return;
|
|
1479
|
+
done = true;
|
|
1480
|
+
process.off("SIGINT", finish);
|
|
1481
|
+
if (timer) clearTimeout(timer);
|
|
1482
|
+
resolve();
|
|
1483
|
+
};
|
|
1484
|
+
const timer = durationSec !== void 0 ? setTimeout(finish, durationSec * 1e3) : null;
|
|
1485
|
+
process.on("SIGINT", finish);
|
|
1486
|
+
});
|
|
1487
|
+
const segment = await capture.stop();
|
|
1488
|
+
await capture.dispose().catch(() => {
|
|
1489
|
+
});
|
|
1490
|
+
ctx2.note(`${pc2.green("\u2713")} Saved: ${segment.path}
|
|
1491
|
+
`);
|
|
1492
|
+
const transcription = opts.transcribe ? await transcribeRecording(segment.path, opts, ctx2, config) : void 0;
|
|
1493
|
+
ctx2.ok(
|
|
1494
|
+
"record",
|
|
1495
|
+
{
|
|
1496
|
+
audioPath: segment.path,
|
|
1497
|
+
durationSec: segment.durationSec,
|
|
1498
|
+
sizeBytes: segment.sizeBytes,
|
|
1499
|
+
transcription
|
|
1500
|
+
},
|
|
1501
|
+
() => {
|
|
1502
|
+
if (transcription) process.stdout.write(`${transcription.text}
|
|
1503
|
+
`);
|
|
1504
|
+
}
|
|
1505
|
+
);
|
|
1506
|
+
}
|
|
1507
|
+
async function recordTui(opts, ctx2, config, outDir) {
|
|
1508
|
+
const ext = opts.mp3 ? "mp3" : "wav";
|
|
1509
|
+
const filename = generateRecordingName({
|
|
1510
|
+
name: opts.name,
|
|
1511
|
+
prefix: config.output.namePrefix,
|
|
1512
|
+
ext
|
|
1513
|
+
});
|
|
1514
|
+
const outputPath = buildOutputPath(outDir, filename);
|
|
1515
|
+
const sessionId = randomBytes(4).toString("hex");
|
|
1516
|
+
const tmpDir = join(tmpdir(), `recmp3-${sessionId}`);
|
|
1517
|
+
await mkdir(tmpDir, { recursive: true });
|
|
1518
|
+
const factory = await getAudioFactory();
|
|
1519
|
+
const capture = factory.create();
|
|
1520
|
+
const source = await resolveSource(opts, config, factory);
|
|
1521
|
+
const firstSegPath = join(tmpDir, "segment-0001.wav");
|
|
1522
|
+
try {
|
|
1523
|
+
await capture.start({
|
|
1524
|
+
source,
|
|
1525
|
+
outputPath: firstSegPath,
|
|
1526
|
+
sampleRate: 16e3,
|
|
1527
|
+
channels: 1,
|
|
1528
|
+
format: "wav"
|
|
1529
|
+
});
|
|
1530
|
+
} catch (err) {
|
|
1531
|
+
await rm(tmpDir, { recursive: true, force: true });
|
|
1532
|
+
if (err instanceof RecmpError) throw err;
|
|
1533
|
+
throw new RecmpError(
|
|
1534
|
+
"AUDIO_START_FAILED",
|
|
1535
|
+
`Failed to start recording: ${err instanceof Error ? err.message : String(err)}`,
|
|
1536
|
+
3
|
|
1537
|
+
);
|
|
1538
|
+
}
|
|
1539
|
+
let result;
|
|
1540
|
+
try {
|
|
1541
|
+
result = await runRecorderTUI(
|
|
1542
|
+
capture,
|
|
1543
|
+
{ source, sampleRate: 16e3, channels: 1, format: "wav", tmpDir },
|
|
1544
|
+
outputPath
|
|
1545
|
+
);
|
|
1546
|
+
} finally {
|
|
1547
|
+
await rm(tmpDir, { recursive: true, force: true }).catch(() => {
|
|
1548
|
+
});
|
|
1549
|
+
}
|
|
1550
|
+
if (result.cancelled || !result.outputPath) {
|
|
1551
|
+
ctx2.note(pc2.gray(" Recording cancelled.\n"));
|
|
1552
|
+
return;
|
|
1553
|
+
}
|
|
1554
|
+
process.stdout.write(`
|
|
1555
|
+
${pc2.green("\u2713")} Saved: ${result.outputPath}
|
|
1556
|
+
`);
|
|
1557
|
+
if (opts.transcribe) {
|
|
1558
|
+
const transcription = await transcribeRecording(
|
|
1559
|
+
result.outputPath,
|
|
1560
|
+
opts,
|
|
1561
|
+
ctx2,
|
|
1562
|
+
config
|
|
1563
|
+
);
|
|
1564
|
+
const shouldPrint = opts.print !== false && config.ui.printOnTranscribe;
|
|
1565
|
+
if (shouldPrint) process.stdout.write(`
|
|
1566
|
+
${transcription.text}
|
|
1567
|
+
|
|
1568
|
+
`);
|
|
1569
|
+
}
|
|
1570
|
+
}
|
|
1571
|
+
async function transcribeRecording(audioPath, opts, ctx2, config) {
|
|
1572
|
+
const providerConfig = { ...config };
|
|
1573
|
+
if (opts.provider) {
|
|
1574
|
+
providerConfig.provider.default = opts.provider;
|
|
1575
|
+
}
|
|
1576
|
+
if (providerUploads(providerConfig.provider.default)) {
|
|
1577
|
+
await ensureUploadConsent(ctx2);
|
|
1578
|
+
}
|
|
1579
|
+
ctx2.note(pc2.cyan(" Transcribing...\n"));
|
|
1580
|
+
const provider = await createProvider(providerConfig);
|
|
1581
|
+
const transcription = await transcribeWithChunking(
|
|
1582
|
+
provider,
|
|
1583
|
+
{
|
|
1584
|
+
audioPath,
|
|
1585
|
+
language: opts.lang ?? config.transcription.defaultLanguage,
|
|
1586
|
+
responseFormat: "verbose_json"
|
|
1587
|
+
},
|
|
1588
|
+
config.transcription.chunking.chunkSeconds
|
|
1589
|
+
);
|
|
1590
|
+
let transcriptPath2;
|
|
1591
|
+
if (config.output.saveTranscriptToFile) {
|
|
1592
|
+
const { txtPath } = await writeTranscriptFiles(audioPath, transcription);
|
|
1593
|
+
transcriptPath2 = txtPath;
|
|
1594
|
+
ctx2.note(`${pc2.green("\u2713")} Transcript: ${txtPath}
|
|
1595
|
+
`);
|
|
1596
|
+
}
|
|
1597
|
+
const shouldCopy = opts.copy !== false && config.ui.clipboardOnTranscribe;
|
|
1598
|
+
if (shouldCopy) {
|
|
1599
|
+
const copied = await copyToClipboard(transcription.text);
|
|
1600
|
+
if (copied) ctx2.note(pc2.gray(" Copied to clipboard.\n"));
|
|
1601
|
+
}
|
|
1602
|
+
ctx2.note(
|
|
1603
|
+
pc2.gray(
|
|
1604
|
+
` Provider: ${transcription.provider} \xB7 Model: ${transcription.model} \xB7 ${(transcription.latencyMs / 1e3).toFixed(1)}s
|
|
1605
|
+
`
|
|
1606
|
+
)
|
|
1607
|
+
);
|
|
1608
|
+
return {
|
|
1609
|
+
text: transcription.text,
|
|
1610
|
+
provider: transcription.provider,
|
|
1611
|
+
model: transcription.model,
|
|
1612
|
+
language: transcription.language,
|
|
1613
|
+
durationSec: transcription.durationSec,
|
|
1614
|
+
latencyMs: transcription.latencyMs,
|
|
1615
|
+
segments: transcription.segments,
|
|
1616
|
+
transcriptPath: transcriptPath2
|
|
1617
|
+
};
|
|
1618
|
+
}
|
|
1619
|
+
async function runSources(ctx2) {
|
|
1620
|
+
let sources = [];
|
|
1621
|
+
try {
|
|
1622
|
+
const factory = await getAudioFactory();
|
|
1623
|
+
sources = await factory.listSources();
|
|
1624
|
+
} catch (err) {
|
|
1625
|
+
throw err instanceof RecmpError ? err : new RecmpError(
|
|
1626
|
+
"AUDIO_CAPTURE_ERROR",
|
|
1627
|
+
`Failed to list audio sources: ${err instanceof Error ? err.message : String(err)}. Make sure ffmpeg is installed.`,
|
|
1628
|
+
3
|
|
1629
|
+
);
|
|
1630
|
+
}
|
|
1631
|
+
const platform = process.platform;
|
|
1632
|
+
const recommended = sources.length ? pickAutoSource(sources) : "default";
|
|
1633
|
+
ctx2.ok("sources", { platform, sources, recommended }, () => {
|
|
1634
|
+
const platformLabels = {
|
|
1635
|
+
linux: "Linux (PulseAudio/PipeWire)",
|
|
1636
|
+
darwin: "macOS (AVFoundation)",
|
|
1637
|
+
win32: "Windows (DirectShow)"
|
|
1638
|
+
};
|
|
1639
|
+
const platformLabel = platformLabels[platform] ?? platform;
|
|
1640
|
+
console.log(`
|
|
1641
|
+
${pc2.bold("Audio sources")} on ${platformLabel}:
|
|
1642
|
+
`);
|
|
1643
|
+
if (sources.length === 0) {
|
|
1644
|
+
console.log(
|
|
1645
|
+
pc2.yellow(" No audio sources found. Check your audio hardware.")
|
|
1646
|
+
);
|
|
1647
|
+
return;
|
|
1648
|
+
}
|
|
1649
|
+
for (const source of sources) {
|
|
1650
|
+
const markers = [
|
|
1651
|
+
source.isDefault ? pc2.green(" (default)") : "",
|
|
1652
|
+
source.id === recommended ? pc2.yellow(" (recommended)") : ""
|
|
1653
|
+
].join("");
|
|
1654
|
+
const id = pc2.cyan(source.id);
|
|
1655
|
+
const label = source.label !== source.id ? pc2.gray(` \u2014 ${source.label}`) : "";
|
|
1656
|
+
console.log(` ${id}${label}${markers}`);
|
|
1657
|
+
}
|
|
1658
|
+
console.log(
|
|
1659
|
+
`
|
|
1660
|
+
${pc2.gray(" Best mic:")} ${pc2.cyan("recmp3 record --source auto")}`
|
|
1661
|
+
);
|
|
1662
|
+
console.log(`${pc2.gray(" Specific:")} recmp3 record --source <id>`);
|
|
1663
|
+
console.log(
|
|
1664
|
+
pc2.gray(" Or set: RECMP3_SOURCE=<id> in your environment\n")
|
|
1665
|
+
);
|
|
1666
|
+
});
|
|
1667
|
+
}
|
|
1668
|
+
async function runTranscribe(audioFile, opts, ctx2) {
|
|
1669
|
+
let path = audioFile;
|
|
1670
|
+
let tmpFromStdin = null;
|
|
1671
|
+
if (audioFile === "-") {
|
|
1672
|
+
const buf = await readStdinBuffer();
|
|
1673
|
+
if (buf.length === 0) throw new InputError("No audio received on stdin.");
|
|
1674
|
+
const dir = await mkdtemp(join(tmpdir(), "recmp3-stdin-"));
|
|
1675
|
+
tmpFromStdin = join(dir, "input.wav");
|
|
1676
|
+
await writeFile(tmpFromStdin, buf);
|
|
1677
|
+
path = tmpFromStdin;
|
|
1678
|
+
} else if (!existsSync(audioFile)) {
|
|
1679
|
+
throw new InputError(`File not found: ${audioFile}`);
|
|
1680
|
+
}
|
|
1681
|
+
try {
|
|
1682
|
+
const config = await loadConfig();
|
|
1683
|
+
if (opts.provider) {
|
|
1684
|
+
config.provider.default = opts.provider;
|
|
1685
|
+
}
|
|
1686
|
+
if (providerUploads(config.provider.default)) {
|
|
1687
|
+
await ensureUploadConsent(ctx2);
|
|
1688
|
+
}
|
|
1689
|
+
const provider = await createProvider(config);
|
|
1690
|
+
ctx2.note(pc2.cyan(` Transcribing with ${provider.name}...
|
|
1691
|
+
`));
|
|
1692
|
+
const result = await transcribeWithChunking(
|
|
1693
|
+
provider,
|
|
1694
|
+
{
|
|
1695
|
+
audioPath: path,
|
|
1696
|
+
language: opts.lang ?? config.transcription.defaultLanguage,
|
|
1697
|
+
responseFormat: "verbose_json"
|
|
1698
|
+
},
|
|
1699
|
+
config.transcription.chunking.chunkSeconds
|
|
1700
|
+
);
|
|
1701
|
+
let transcriptPath2;
|
|
1702
|
+
if (config.output.saveTranscriptToFile && !tmpFromStdin) {
|
|
1703
|
+
const { txtPath } = await writeTranscriptFiles(audioFile, result);
|
|
1704
|
+
transcriptPath2 = txtPath;
|
|
1705
|
+
ctx2.note(`${pc2.green("\u2713")} Transcript saved: ${txtPath}
|
|
1706
|
+
`);
|
|
1707
|
+
}
|
|
1708
|
+
if (opts.copy) {
|
|
1709
|
+
const copied = await copyToClipboard(result.text);
|
|
1710
|
+
if (copied) ctx2.note(pc2.gray(" Copied to clipboard.\n"));
|
|
1711
|
+
}
|
|
1712
|
+
ctx2.note(
|
|
1713
|
+
pc2.gray(
|
|
1714
|
+
` ${provider.name} \xB7 ${result.model} \xB7 ${(result.latencyMs / 1e3).toFixed(1)}s
|
|
1715
|
+
`
|
|
1716
|
+
)
|
|
1717
|
+
);
|
|
1718
|
+
ctx2.ok(
|
|
1719
|
+
"transcribe",
|
|
1720
|
+
{
|
|
1721
|
+
text: result.text,
|
|
1722
|
+
provider: result.provider,
|
|
1723
|
+
model: result.model,
|
|
1724
|
+
language: result.language,
|
|
1725
|
+
durationSec: result.durationSec,
|
|
1726
|
+
latencyMs: result.latencyMs,
|
|
1727
|
+
segments: result.segments,
|
|
1728
|
+
transcriptPath: transcriptPath2
|
|
1729
|
+
},
|
|
1730
|
+
// Human mode: transcript text on stdout (pipeable), nothing else.
|
|
1731
|
+
() => process.stdout.write(`${result.text}
|
|
1732
|
+
`)
|
|
1733
|
+
);
|
|
1734
|
+
} finally {
|
|
1735
|
+
if (tmpFromStdin) {
|
|
1736
|
+
await rm(join(tmpFromStdin, ".."), {
|
|
1737
|
+
recursive: true,
|
|
1738
|
+
force: true
|
|
1739
|
+
}).catch(() => {
|
|
1740
|
+
});
|
|
1741
|
+
}
|
|
1742
|
+
}
|
|
1743
|
+
}
|
|
1744
|
+
|
|
1745
|
+
// src/agent/mcp.ts
|
|
1746
|
+
async function callCommand(run, args) {
|
|
1747
|
+
const ctx2 = AgentContext.forCapture();
|
|
1748
|
+
const sink = ctx2.sink;
|
|
1749
|
+
try {
|
|
1750
|
+
await run(args, ctx2);
|
|
1751
|
+
} catch (err) {
|
|
1752
|
+
ctx2.fail(err, "mcp");
|
|
1753
|
+
}
|
|
1754
|
+
const envelope = sink.envelope;
|
|
1755
|
+
return {
|
|
1756
|
+
isError: envelope?.ok === false,
|
|
1757
|
+
content: [
|
|
1758
|
+
{ type: "text", text: JSON.stringify(envelope, null, 2) }
|
|
1759
|
+
]
|
|
1760
|
+
};
|
|
1761
|
+
}
|
|
1762
|
+
function descriptionFor(tool) {
|
|
1763
|
+
return MANIFEST.commands.find((c) => c.tool === tool)?.summary ?? "";
|
|
1764
|
+
}
|
|
1765
|
+
function register(server, tool, inputSchema, run) {
|
|
1766
|
+
server.registerTool(
|
|
1767
|
+
tool,
|
|
1768
|
+
{ description: descriptionFor(tool), inputSchema },
|
|
1769
|
+
async (args) => callCommand(run, args ?? {})
|
|
1770
|
+
);
|
|
1771
|
+
}
|
|
1772
|
+
async function runMcpServer() {
|
|
1773
|
+
const server = new McpServer({
|
|
1774
|
+
name: MANIFEST.name,
|
|
1775
|
+
version: MANIFEST.version
|
|
1776
|
+
});
|
|
1777
|
+
register(
|
|
1778
|
+
server,
|
|
1779
|
+
"recmp3_transcribe",
|
|
1780
|
+
{
|
|
1781
|
+
file: z.string().describe("Audio file path (must exist on the server host)"),
|
|
1782
|
+
provider: z.string().optional(),
|
|
1783
|
+
lang: z.string().optional()
|
|
1784
|
+
},
|
|
1785
|
+
(a, ctx2) => runTranscribe(
|
|
1786
|
+
a.file,
|
|
1787
|
+
{ provider: a.provider, lang: a.lang },
|
|
1788
|
+
ctx2
|
|
1789
|
+
)
|
|
1790
|
+
);
|
|
1791
|
+
register(
|
|
1792
|
+
server,
|
|
1793
|
+
"recmp3_prompt",
|
|
1794
|
+
{
|
|
1795
|
+
file: z.string().describe('Transcript file path, or "-" for stdin'),
|
|
1796
|
+
template: z.string().optional()
|
|
1797
|
+
},
|
|
1798
|
+
(a, ctx2) => runPrompt(a.file, { template: a.template }, ctx2)
|
|
1799
|
+
);
|
|
1800
|
+
register(server, "recmp3_sources", {}, (_a, ctx2) => runSources(ctx2));
|
|
1801
|
+
register(server, "recmp3_doctor", {}, (_a, ctx2) => runDoctor(ctx2));
|
|
1802
|
+
register(server, "recmp3_config_show", {}, (_a, ctx2) => runConfigShow(ctx2));
|
|
1803
|
+
register(server, "recmp3_manifest", {}, (_a, ctx2) => runManifest(ctx2));
|
|
1804
|
+
register(
|
|
1805
|
+
server,
|
|
1806
|
+
"recmp3_record",
|
|
1807
|
+
{
|
|
1808
|
+
duration: z.number().describe("Seconds to record (headless)"),
|
|
1809
|
+
name: z.string().optional(),
|
|
1810
|
+
out: z.string().optional(),
|
|
1811
|
+
transcribe: z.boolean().optional(),
|
|
1812
|
+
provider: z.string().optional(),
|
|
1813
|
+
lang: z.string().optional(),
|
|
1814
|
+
source: z.string().optional().describe('Audio source id, or "auto" for the best physical mic')
|
|
1815
|
+
},
|
|
1816
|
+
(a, ctx2) => runRecord(
|
|
1817
|
+
{
|
|
1818
|
+
duration: String(a.duration),
|
|
1819
|
+
name: a.name,
|
|
1820
|
+
out: a.out,
|
|
1821
|
+
transcribe: a.transcribe,
|
|
1822
|
+
provider: a.provider,
|
|
1823
|
+
lang: a.lang,
|
|
1824
|
+
source: a.source
|
|
1825
|
+
},
|
|
1826
|
+
ctx2
|
|
1827
|
+
)
|
|
1828
|
+
);
|
|
1829
|
+
let closing = false;
|
|
1830
|
+
const shutdown = async () => {
|
|
1831
|
+
if (closing) return;
|
|
1832
|
+
closing = true;
|
|
1833
|
+
try {
|
|
1834
|
+
await server.close();
|
|
1835
|
+
} catch {
|
|
1836
|
+
}
|
|
1837
|
+
process.exit(0);
|
|
1838
|
+
};
|
|
1839
|
+
process.once("SIGTERM", shutdown);
|
|
1840
|
+
process.once("SIGINT", shutdown);
|
|
1841
|
+
process.stdin.once("end", shutdown);
|
|
1842
|
+
const transport = new StdioServerTransport();
|
|
1843
|
+
await server.connect(transport);
|
|
1844
|
+
}
|
|
1845
|
+
|
|
1846
|
+
// src/index.ts
|
|
1847
|
+
var VERSION = "1.0.0";
|
|
1848
|
+
var program = new Command();
|
|
1849
|
+
var ctx = new AgentContext();
|
|
1850
|
+
program.name("recmp3").description(
|
|
1851
|
+
"Record audio, transcribe with AI, output developer-ready prompts."
|
|
1852
|
+
).version(VERSION, "-v, --version").option(
|
|
1853
|
+
"--json",
|
|
1854
|
+
"Emit a stable machine-readable JSON envelope on stdout",
|
|
1855
|
+
false
|
|
1856
|
+
).option("-y, --yes", "Skip all interactive prompts (consent, setup)", false).option("--quiet", "Suppress progress/diagnostic output on stderr", false).option("--no-color", "Disable colored output").option("--debug", "Enable debug output", false).option("--verbose", "Enable verbose output", false).hook("preAction", (_thisCommand, actionCommand) => {
|
|
1857
|
+
const opts = actionCommand.optsWithGlobals();
|
|
1858
|
+
initLogger({ debug: opts.debug, verbose: opts.verbose });
|
|
1859
|
+
ctx = AgentContext.fromGlobals(opts);
|
|
1860
|
+
});
|
|
1861
|
+
program.action(async () => {
|
|
1862
|
+
await handleError("record", () => runRecord({}, ctx));
|
|
1863
|
+
});
|
|
1864
|
+
program.command("record").description("Record audio from your microphone").option("-n, --name <name>", 'Output filename stem (e.g. "my-idea")').option("-o, --out <dir>", "Output directory").option("-t, --transcribe", "Transcribe immediately after recording").option("--mp3", "Save as MP3 instead of WAV (post-processing)").option(
|
|
1865
|
+
"--provider <name>",
|
|
1866
|
+
"Override transcription provider (groq, openai, local-whisper)"
|
|
1867
|
+
).option("--lang <code>", "Force language code (e.g. es, en)").option(
|
|
1868
|
+
"--source <id>",
|
|
1869
|
+
'Audio source id, or "auto" to pick the best physical mic'
|
|
1870
|
+
).option(
|
|
1871
|
+
"--duration <seconds>",
|
|
1872
|
+
"Headless: record for N seconds then stop (no TUI)"
|
|
1873
|
+
).option(
|
|
1874
|
+
"--no-tui",
|
|
1875
|
+
"Force headless capture (record until SIGINT or --duration)"
|
|
1876
|
+
).option(
|
|
1877
|
+
"--copy",
|
|
1878
|
+
"Copy transcript to clipboard (default: on with --transcribe)"
|
|
1879
|
+
).option("--no-copy", "Do not copy transcript to clipboard").option(
|
|
1880
|
+
"--print",
|
|
1881
|
+
"Print transcript to stdout (default: on with --transcribe)"
|
|
1882
|
+
).option("--no-print", "Do not print transcript to stdout").action(async (opts) => {
|
|
1883
|
+
await handleError("record", () => runRecord(opts, ctx));
|
|
1884
|
+
});
|
|
1885
|
+
program.command("transcribe <file>").description('Transcribe an existing audio file ("-" reads audio from stdin)').option(
|
|
1886
|
+
"--provider <name>",
|
|
1887
|
+
"Override provider (groq, openai, local-whisper)"
|
|
1888
|
+
).option("--lang <code>", "Force language code").option("--copy", "Copy transcript to clipboard").action(async (file, opts) => {
|
|
1889
|
+
await handleError("transcribe", () => runTranscribe(file, opts, ctx));
|
|
1890
|
+
});
|
|
1891
|
+
program.command("sources").description("List available audio input sources for your OS").action(async () => {
|
|
1892
|
+
await handleError("sources", () => runSources(ctx));
|
|
1893
|
+
});
|
|
1894
|
+
var configCmd = program.command("config").description("Manage recmp3 configuration");
|
|
1895
|
+
configCmd.command("init").description(
|
|
1896
|
+
"First-time setup (interactive, or flag-driven when non-interactive)"
|
|
1897
|
+
).option("--provider <name>", "Provider (groq, openai, local-whisper)").option("--lang <code>", "Default language code").option("--outdir <dir>", "Recordings output directory").option("--key <value>", "API key to store in the OS keychain").action(async (opts) => {
|
|
1898
|
+
await handleError("config init", () => runConfigInit(opts, ctx));
|
|
1899
|
+
});
|
|
1900
|
+
configCmd.command("show").description("Show resolved configuration (API keys redacted)").action(async () => {
|
|
1901
|
+
await handleError("config show", () => runConfigShow(ctx));
|
|
1902
|
+
});
|
|
1903
|
+
configCmd.command("path").description("Print path to config file").action(async () => {
|
|
1904
|
+
await handleError("config path", () => runConfigPath(ctx));
|
|
1905
|
+
});
|
|
1906
|
+
configCmd.command("set <key> <value>").description("Set a config value (e.g. provider.default groq)").action(async (key, value) => {
|
|
1907
|
+
await handleError("config set", () => runConfigSet(key, value, ctx));
|
|
1908
|
+
});
|
|
1909
|
+
configCmd.command("set-key <provider>").description(
|
|
1910
|
+
"Store an API key in the OS keychain (value from --key, stdin, or env)"
|
|
1911
|
+
).option(
|
|
1912
|
+
"--key <value>",
|
|
1913
|
+
"API key value (otherwise read from stdin or *_API_KEY env)"
|
|
1914
|
+
).action(async (provider, opts) => {
|
|
1915
|
+
await handleError(
|
|
1916
|
+
"config set-key",
|
|
1917
|
+
() => runConfigSetKey(provider, opts, ctx)
|
|
1918
|
+
);
|
|
1919
|
+
});
|
|
1920
|
+
program.command("doctor").description("Run preflight checks to verify your setup").action(async () => {
|
|
1921
|
+
await handleError("doctor", () => runDoctor(ctx));
|
|
1922
|
+
});
|
|
1923
|
+
program.command("prompt <file>").description(
|
|
1924
|
+
'Wrap a transcript file in a prompt template ("-" reads from stdin)'
|
|
1925
|
+
).option(
|
|
1926
|
+
"-t, --template <name>",
|
|
1927
|
+
"Template name (claude-code, prd, bug, todo, meeting-notes, commit-message, raw)",
|
|
1928
|
+
"claude-code"
|
|
1929
|
+
).option("--copy", "Copy output to clipboard").option("--out <file>", "Write output to a file").option("--list-templates", "List available templates").action(async (file, opts) => {
|
|
1930
|
+
if (opts.listTemplates) {
|
|
1931
|
+
listTemplates();
|
|
1932
|
+
return;
|
|
1933
|
+
}
|
|
1934
|
+
await handleError("prompt", () => runPrompt(file, opts, ctx));
|
|
1935
|
+
});
|
|
1936
|
+
program.command("manifest").description("Print the command/tool manifest (use --json for machine form)").action(async () => {
|
|
1937
|
+
await handleError("manifest", () => runManifest(ctx));
|
|
1938
|
+
});
|
|
1939
|
+
program.command("mcp").description("Start the Model Context Protocol server over stdio").action(async () => {
|
|
1940
|
+
await runMcpServer();
|
|
1941
|
+
});
|
|
1942
|
+
async function handleError(command, fn) {
|
|
1943
|
+
try {
|
|
1944
|
+
await fn();
|
|
1945
|
+
} catch (err) {
|
|
1946
|
+
const payload = toErrorPayload(err);
|
|
1947
|
+
ctx.fail(err, command);
|
|
1948
|
+
if (process.env.RECMP3_DEBUG && err instanceof Error) {
|
|
1949
|
+
process.stderr.write(`${err.stack ?? ""}
|
|
1950
|
+
`);
|
|
1951
|
+
}
|
|
1952
|
+
process.exit(payload.exitCode);
|
|
1953
|
+
}
|
|
1954
|
+
}
|
|
1955
|
+
program.parseAsync(process.argv).catch((err) => {
|
|
1956
|
+
const payload = toErrorPayload(err);
|
|
1957
|
+
process.stderr.write(`${pc2.red("\u2717")} ${payload.message}
|
|
1958
|
+
`);
|
|
1959
|
+
process.exit(err instanceof RecmpError ? err.exitCode : ExitCode.UNKNOWN);
|
|
1960
|
+
});
|