@lovrozagar/grunt 0.5.2 → 0.6.1

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.
Files changed (157) hide show
  1. package/.agents/agents/grunt.md +2 -24
  2. package/.agents/agents/orchestrator.md +5 -50
  3. package/.agents/skills/ask/SKILL.md +5 -12
  4. package/.agents/skills/auto/SKILL.md +7 -12
  5. package/.agents/skills/browser/SKILL.md +4 -8
  6. package/.agents/skills/clasp/SKILL.md +11 -0
  7. package/.agents/skills/commit/SKILL.md +3 -1
  8. package/.agents/skills/commit-push/SKILL.md +2 -0
  9. package/.agents/skills/commit-push-deploy/SKILL.md +2 -0
  10. package/.agents/skills/commit-push-release/SKILL.md +2 -0
  11. package/.agents/skills/explain/SKILL.md +7 -13
  12. package/.agents/skills/google-workspace/SKILL.md +13 -0
  13. package/.agents/skills/handoff/SKILL.md +7 -11
  14. package/.agents/skills/implement-plan/SKILL.md +5 -109
  15. package/.agents/skills/listen/SKILL.md +15 -0
  16. package/.agents/skills/pickup/SKILL.md +6 -12
  17. package/.agents/skills/speak/SKILL.md +13 -0
  18. package/.agents/skills/tmp/SKILL.md +6 -10
  19. package/.agents/skills/write-plan/SKILL.md +5 -77
  20. package/.claude/agents/grunt.md +2 -26
  21. package/.claude/agents/orchestrator.md +12 -50
  22. package/.claude/skills/ask/SKILL.md +5 -12
  23. package/.claude/skills/auto/SKILL.md +7 -12
  24. package/.claude/skills/browser/SKILL.md +4 -8
  25. package/.claude/skills/clasp/SKILL.md +11 -0
  26. package/.claude/skills/commit/SKILL.md +3 -1
  27. package/.claude/skills/commit-push/SKILL.md +2 -0
  28. package/.claude/skills/commit-push-deploy/SKILL.md +2 -0
  29. package/.claude/skills/commit-push-release/SKILL.md +2 -0
  30. package/.claude/skills/explain/SKILL.md +7 -13
  31. package/.claude/skills/google-workspace/SKILL.md +13 -0
  32. package/.claude/skills/handoff/SKILL.md +7 -11
  33. package/.claude/skills/implement-plan/SKILL.md +5 -109
  34. package/.claude/skills/listen/SKILL.md +15 -0
  35. package/.claude/skills/pickup/SKILL.md +6 -12
  36. package/.claude/skills/speak/SKILL.md +13 -0
  37. package/.claude/skills/tmp/SKILL.md +6 -10
  38. package/.claude/skills/write-plan/SKILL.md +5 -77
  39. package/.codex/agents/grunt.toml +2 -26
  40. package/.codex/agents/orchestrator.toml +4 -50
  41. package/.grok/agents/grunt.md +2 -24
  42. package/.grok/agents/orchestrator.md +4 -48
  43. package/.grok/global-settings.toml +1 -3
  44. package/.grok/hooks/orchestrate-parent.js +190 -358
  45. package/.grok/skills/ask/SKILL.md +5 -12
  46. package/.grok/skills/auto/SKILL.md +7 -12
  47. package/.grok/skills/browser/SKILL.md +4 -8
  48. package/.grok/skills/clasp/SKILL.md +11 -0
  49. package/.grok/skills/commit/SKILL.md +3 -1
  50. package/.grok/skills/commit-push/SKILL.md +2 -0
  51. package/.grok/skills/commit-push-deploy/SKILL.md +2 -0
  52. package/.grok/skills/commit-push-release/SKILL.md +2 -0
  53. package/.grok/skills/explain/SKILL.md +7 -13
  54. package/.grok/skills/google-workspace/SKILL.md +13 -0
  55. package/.grok/skills/handoff/SKILL.md +7 -11
  56. package/.grok/skills/implement-plan/SKILL.md +5 -109
  57. package/.grok/skills/listen/SKILL.md +15 -0
  58. package/.grok/skills/pickup/SKILL.md +6 -12
  59. package/.grok/skills/speak/SKILL.md +13 -0
  60. package/.grok/skills/tmp/SKILL.md +6 -10
  61. package/.grok/skills/write-plan/SKILL.md +5 -77
  62. package/.rulesync/hooks.jsonc +1 -1
  63. package/.rulesync/mcp-policy.jsonc +0 -2
  64. package/.rulesync/reference/INDEX.md +30 -20
  65. package/.rulesync/reference/browser.md +13 -6
  66. package/.rulesync/reference/cascade.md +6 -86
  67. package/.rulesync/reference/clasp.md +21 -0
  68. package/.rulesync/reference/google-workspace.md +79 -0
  69. package/.rulesync/reference/hooks.md +7 -11
  70. package/.rulesync/reference/implementation-format.md +113 -0
  71. package/.rulesync/reference/law.md +5 -3
  72. package/.rulesync/reference/listen.md +80 -0
  73. package/.rulesync/reference/map.md +40 -36
  74. package/.rulesync/reference/output.md +3 -51
  75. package/.rulesync/reference/plan-format.md +4 -2
  76. package/.rulesync/reference/refs-map.md +12 -5
  77. package/.rulesync/reference/rtk.md +3 -2
  78. package/.rulesync/reference/scope.md +37 -0
  79. package/.rulesync/reference/setup.md +27 -0
  80. package/.rulesync/reference/skills-map.md +13 -12
  81. package/.rulesync/reference/speak.md +59 -0
  82. package/.rulesync/rules/CLAUDE.md +3 -49
  83. package/.rulesync/rules/overview.md +9 -47
  84. package/.rulesync/skills/ask/SKILL.md +5 -12
  85. package/.rulesync/skills/auto/SKILL.md +5 -12
  86. package/.rulesync/skills/browser/SKILL.md +4 -8
  87. package/.rulesync/skills/clasp/SKILL.md +9 -0
  88. package/.rulesync/skills/commit/SKILL.md +3 -1
  89. package/.rulesync/skills/commit-push/SKILL.md +2 -0
  90. package/.rulesync/skills/commit-push-deploy/SKILL.md +2 -0
  91. package/.rulesync/skills/commit-push-release/SKILL.md +2 -0
  92. package/.rulesync/skills/explain/SKILL.md +7 -14
  93. package/.rulesync/skills/google-workspace/SKILL.md +11 -0
  94. package/.rulesync/skills/handoff/SKILL.md +7 -11
  95. package/.rulesync/skills/implement-plan/SKILL.md +5 -119
  96. package/.rulesync/skills/listen/SKILL.md +13 -0
  97. package/.rulesync/skills/pickup/SKILL.md +6 -12
  98. package/.rulesync/skills/speak/SKILL.md +13 -0
  99. package/.rulesync/skills/tmp/SKILL.md +6 -10
  100. package/.rulesync/skills/write-plan/SKILL.md +5 -84
  101. package/.rulesync/subagents/grunt.md +2 -24
  102. package/.rulesync/subagents/orchestrator.md +14 -59
  103. package/AGENTS.md +9 -47
  104. package/CHANGELOG.md +67 -0
  105. package/CLAUDE.md +1 -49
  106. package/README.md +89 -80
  107. package/cli/grunt.mjs +21 -3
  108. package/cli/grunt.test.ts +47 -3
  109. package/cli/init.mjs +134 -234
  110. package/cli/init.test.ts +241 -58
  111. package/cli/prompt.mjs +9 -36
  112. package/cli/prompt.test.ts +14 -0
  113. package/package.json +10 -2
  114. package/scripts/browser.mjs +128 -7
  115. package/scripts/doctor.mjs +104 -10
  116. package/scripts/emit-gemini.mjs +2 -2
  117. package/scripts/emit-mcp-policy.mjs +1 -1
  118. package/scripts/gate-fat-tools.mjs +223 -173
  119. package/scripts/google-workspace.mjs +713 -0
  120. package/scripts/grunt-job.mjs +453 -69
  121. package/scripts/guarded-md.mjs +230 -0
  122. package/scripts/guarded-roots.mjs +1 -1
  123. package/scripts/interactive.mjs +15 -0
  124. package/scripts/listen.mjs +829 -0
  125. package/scripts/parse-need.mjs +21 -2
  126. package/scripts/persist-implementation.mjs +278 -0
  127. package/scripts/prompt.mjs +32 -0
  128. package/scripts/scrub-spawn-prompt.mjs +3 -50
  129. package/scripts/setup.mjs +746 -0
  130. package/scripts/speak.mjs +291 -0
  131. package/.agents/agents/implementer.md +0 -32
  132. package/.agents/agents/thinker.md +0 -29
  133. package/.agents/skills/cascade/SKILL.md +0 -28
  134. package/.agents/skills/parent/SKILL.md +0 -6
  135. package/.agents/skills/solo/SKILL.md +0 -25
  136. package/.claude/agents/implementer.md +0 -37
  137. package/.claude/agents/thinker.md +0 -36
  138. package/.claude/skills/cascade/SKILL.md +0 -28
  139. package/.claude/skills/parent/SKILL.md +0 -6
  140. package/.claude/skills/solo/SKILL.md +0 -25
  141. package/.codex/agents/implementer.toml +0 -26
  142. package/.codex/agents/thinker.toml +0 -26
  143. package/.grok/agents/implementer.md +0 -27
  144. package/.grok/agents/thinker.md +0 -27
  145. package/.grok/parent.md +0 -1
  146. package/.grok/roles/implementer.toml +0 -5
  147. package/.grok/roles/thinker.toml +0 -5
  148. package/.grok/skills/cascade/SKILL.md +0 -28
  149. package/.grok/skills/parent/SKILL.md +0 -6
  150. package/.grok/skills/solo/SKILL.md +0 -25
  151. package/.rulesync/grunt.config.jsonc +0 -7
  152. package/.rulesync/skills/cascade/SKILL.md +0 -28
  153. package/.rulesync/skills/parent/SKILL.md +0 -6
  154. package/.rulesync/skills/solo/SKILL.md +0 -25
  155. package/.rulesync/subagents/implementer.md +0 -49
  156. package/.rulesync/subagents/thinker.md +0 -49
  157. package/scripts/grunt-config.mjs +0 -50
@@ -0,0 +1,829 @@
1
+ #!/usr/bin/env node
2
+ /** Speech-to-text (input only). Mic/file → OpenAI Whisper. */
3
+ import { spawn, spawnSync } from "node:child_process";
4
+ import fs from "node:fs";
5
+ import os from "node:os";
6
+ import path from "node:path";
7
+ import { fileURLToPath, pathToFileURL } from "node:url";
8
+ import { whichBin } from "./doctor.mjs";
9
+
10
+ export const DEFAULT_MODEL = "whisper-1";
11
+ export const DEFAULT_GGML = "ggml-base.en.bin";
12
+ export const WHISPER_BINS = ["whisper-cli", "whisper-cpp"];
13
+ export const DEFAULT_SECONDS = 8;
14
+ export const MAX_SECONDS = 120;
15
+ export const USAGE =
16
+ "usage: listen whoami | devices | rec [--seconds N] [--device D] [--clip] | start [--device D] | stop [--clip] | toggle [--clip] | status | file --path P [--clip] | latest";
17
+ export const SETUP = "node scripts/setup.mjs listen";
18
+ export const NEED_STT =
19
+ "missing whisper-cli (mac: brew install whisper-cpp) or OPENAI_API_KEY / ~/.grunt/speak.json openai.apiKey. setup: node scripts/setup.mjs listen";
20
+ export const NEED_FFMPEG =
21
+ "missing ffmpeg. mac: brew install ffmpeg · linux: sudo apt install ffmpeg · win: winget install Gyan.FFmpeg. setup: node scripts/setup.mjs listen";
22
+ export const NEED_OPENAI =
23
+ "missing OPENAI_API_KEY (or ~/.grunt/speak.json openai.apiKey). setup: node scripts/setup.mjs listen";
24
+ export const NEED_GGML =
25
+ "whisper-cli found but no ggml model (auto-download failed). set WHISPER_MODEL or retry. setup: node scripts/setup.mjs listen";
26
+
27
+ export function formatHttpError(status, body, provider = "openai") {
28
+ const n = Number(status);
29
+ const raw = String(body || "");
30
+ const auth =
31
+ n === 401 ||
32
+ n === 403 ||
33
+ /unauthorized|invalid.?api.?key|authentication|missing_permissions/i.test(raw);
34
+ if (auth) {
35
+ return `${provider} key missing or rejected (${n || status}). setup: ${SETUP}`;
36
+ }
37
+ return `${status} ${raw.slice(0, 200)}`.trim();
38
+ }
39
+ export const GGML_URL =
40
+ "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.en.bin";
41
+ const TRANSCRIBE_URL = "https://api.openai.com/v1/audio/transcriptions";
42
+
43
+ export function parseArgv(argv) {
44
+ const out = { _: [], flags: {} };
45
+ const args = Array.isArray(argv) ? argv : [];
46
+ for (let i = 0; i < args.length; i++) {
47
+ const a = args[i];
48
+ if (a === "--") {
49
+ out._.push(...args.slice(i + 1));
50
+ break;
51
+ }
52
+ if (a.startsWith("--") && a.length > 2) {
53
+ const key = a.slice(2);
54
+ const nxt = args[i + 1];
55
+ if (nxt && !nxt.startsWith("--")) {
56
+ out.flags[key] = nxt;
57
+ i += 1;
58
+ } else out.flags[key] = "1";
59
+ } else out._.push(a);
60
+ }
61
+ return out;
62
+ }
63
+
64
+ function readJson(p) {
65
+ try {
66
+ const j = JSON.parse(fs.readFileSync(p, "utf8"));
67
+ if (j && typeof j === "object" && !Array.isArray(j)) return j;
68
+ } catch {
69
+ /* none */
70
+ }
71
+ return {};
72
+ }
73
+
74
+ function str(v, fallback = "") {
75
+ const s = String(v ?? "").trim();
76
+ return s || fallback;
77
+ }
78
+
79
+ export function clampSeconds(v, fallback = DEFAULT_SECONDS) {
80
+ const n = Number(v);
81
+ if (!Number.isFinite(n) || n <= 0) return fallback;
82
+ return Math.min(MAX_SECONDS, Math.max(1, Math.round(n)));
83
+ }
84
+
85
+ export function loadCfg({ env = process.env, home = os.homedir() } = {}) {
86
+ const filePath = path.join(home, ".grunt", "speak.json");
87
+ const file = readJson(filePath);
88
+ const openai = file.openai && typeof file.openai === "object" ? file.openai : {};
89
+ const listen = file.listen && typeof file.listen === "object" ? file.listen : {};
90
+ return {
91
+ filePath,
92
+ apiKey: str(env.OPENAI_API_KEY || openai.apiKey),
93
+ model: str(env.LISTEN_MODEL || listen.model, DEFAULT_MODEL),
94
+ device: str(env.LISTEN_DEVICE || listen.device),
95
+ seconds: clampSeconds(env.LISTEN_SECONDS || listen.seconds, DEFAULT_SECONDS),
96
+ ffmpegFormat: str(env.LISTEN_FFMPEG_FORMAT || listen.format).toLowerCase(),
97
+ whisperModel: str(env.WHISPER_MODEL || env.LISTEN_WHISPER_MODEL || listen.whisperModel),
98
+ stt: str(env.LISTEN_STT || listen.stt, "auto").toLowerCase(),
99
+ };
100
+ }
101
+
102
+ export function findWhisperBin(whichFn, pathEnv, platform) {
103
+ const fn = whichFn || whichBin;
104
+ for (const n of WHISPER_BINS) {
105
+ const hit = fn(n, pathEnv, platform);
106
+ if (hit) return hit;
107
+ }
108
+ return "";
109
+ }
110
+
111
+ function isFile(p) {
112
+ try {
113
+ return Boolean(p) && fs.statSync(p).isFile();
114
+ } catch {
115
+ return false;
116
+ }
117
+ }
118
+
119
+ export function findWhisperModel({ cfg, home = os.homedir(), cwd = "" } = {}) {
120
+ const names = [
121
+ cfg && cfg.whisperModel,
122
+ path.join(home, ".grunt", "whisper", DEFAULT_GGML),
123
+ path.join(home, "models", DEFAULT_GGML),
124
+ cwd ? path.join(cwd, "models", DEFAULT_GGML) : "",
125
+ ];
126
+ for (const p of names) {
127
+ const s = str(p);
128
+ if (isFile(s)) return path.resolve(s);
129
+ }
130
+ return "";
131
+ }
132
+
133
+ export function probeStt({
134
+ cfg,
135
+ whichFn,
136
+ pathEnv,
137
+ platform,
138
+ home = os.homedir(),
139
+ cwd = "",
140
+ } = {}) {
141
+ const force = str(cfg && cfg.stt, "auto").toLowerCase();
142
+ const bin = findWhisperBin(whichFn, pathEnv, platform);
143
+ const modelPath = findWhisperModel({ cfg, home, cwd });
144
+ const local = bin && modelPath ? { kind: "local", bin, model: modelPath } : null;
145
+ const openai = cfg && cfg.apiKey ? { kind: "openai", model: cfg.model || DEFAULT_MODEL } : null;
146
+ if (force === "local") return local;
147
+ if (force === "openai") return openai;
148
+ return local || openai;
149
+ }
150
+
151
+ export function resolveStt(opts) {
152
+ const force = str(opts && opts.cfg && opts.cfg.stt, "auto").toLowerCase();
153
+ const hit = probeStt(opts);
154
+ if (hit) return hit;
155
+ const bin = findWhisperBin(opts.whichFn, opts.pathEnv, opts.platform);
156
+ if (force === "local" || (bin && force !== "openai")) {
157
+ throw new Error(NEED_GGML);
158
+ }
159
+ if (force === "openai") throw new Error(NEED_OPENAI);
160
+ throw new Error(NEED_STT);
161
+ }
162
+
163
+ export function defaultGgmlPath(home) {
164
+ return path.join(home || os.homedir(), ".grunt", "whisper", DEFAULT_GGML);
165
+ }
166
+
167
+ export async function downloadGgml({
168
+ home = os.homedir(),
169
+ fetchFn = globalThis.fetch,
170
+ log,
171
+ } = {}) {
172
+ const dest = defaultGgmlPath(home);
173
+ if (isFile(dest)) return dest;
174
+ fs.mkdirSync(path.dirname(dest), { recursive: true, mode: 0o700 });
175
+ const part = `${dest}.part`;
176
+ if (log && typeof log.error === "function") {
177
+ log.error(`downloading ${DEFAULT_GGML} (~142MB) to ${dest}`);
178
+ }
179
+ const fn = fetchFn || globalThis.fetch;
180
+ if (typeof fn !== "function") throw new Error("fetch is not available");
181
+ const res = await fn(GGML_URL, {
182
+ headers: { "User-Agent": "grunt-listen" },
183
+ redirect: "follow",
184
+ });
185
+ if (!res || !res.ok) {
186
+ const status = res && res.status != null ? res.status : "err";
187
+ throw new Error(`could not download ggml model (${status}). setup: ${SETUP}`);
188
+ }
189
+ const buf = Buffer.from(await res.arrayBuffer());
190
+ if (!buf.length) throw new Error("ggml download empty");
191
+ fs.writeFileSync(part, buf);
192
+ fs.renameSync(part, dest);
193
+ return dest;
194
+ }
195
+
196
+ export async function ensureStt(opts) {
197
+ const force = str(opts && opts.cfg && opts.cfg.stt, "auto").toLowerCase();
198
+ const bin = findWhisperBin(opts.whichFn, opts.pathEnv, opts.platform);
199
+ let modelPath = findWhisperModel({
200
+ cfg: opts.cfg,
201
+ home: opts.home,
202
+ cwd: opts.cwd,
203
+ });
204
+ if (force !== "openai" && bin && !modelPath) {
205
+ try {
206
+ modelPath = await downloadGgml({
207
+ home: opts.home,
208
+ fetchFn: opts.fetchFn,
209
+ log: opts.log,
210
+ });
211
+ } catch (e) {
212
+ if (force === "local" || !(opts.cfg && opts.cfg.apiKey)) throw e;
213
+ return { kind: "openai", model: opts.cfg.model || DEFAULT_MODEL };
214
+ }
215
+ }
216
+ const cfg =
217
+ opts.cfg && modelPath ? { ...opts.cfg, whisperModel: modelPath } : opts.cfg;
218
+ return resolveStt({ ...opts, cfg });
219
+ }
220
+
221
+ export function listenDir(cwd) {
222
+ const d = path.join(cwd || process.cwd(), ".tmp/grunt/listen");
223
+ fs.mkdirSync(d, { recursive: true });
224
+ return d;
225
+ }
226
+
227
+ export function sessionPath(cwd) {
228
+ return path.join(listenDir(cwd), "session.json");
229
+ }
230
+
231
+ export function latestPath(cwd) {
232
+ return path.join(listenDir(cwd), "latest.txt");
233
+ }
234
+
235
+ export function defaultDevice(platform = process.platform) {
236
+ if (platform === "darwin") return ":0";
237
+ if (platform === "win32") return "";
238
+ return "default";
239
+ }
240
+
241
+ export function darwinAudioSpec(device) {
242
+ const d = str(device, ":0");
243
+ if (/^none:/i.test(d)) return `:${d.slice(5)}` || ":0";
244
+ if (d.startsWith(":")) return d;
245
+ if (/^\d+$/.test(d)) return `:${d}`;
246
+ return d.includes(":") ? d : `:${d}`;
247
+ }
248
+
249
+ export function ffmpegInputArgs(platform, device, ffmpegFormat = "") {
250
+ const d = str(device, defaultDevice(platform));
251
+ if (platform === "darwin") {
252
+ return ["-f", "avfoundation", "-i", darwinAudioSpec(d)];
253
+ }
254
+ if (platform === "win32") {
255
+ if (!d) {
256
+ throw new Error(
257
+ "missing --device or LISTEN_DEVICE. run: node scripts/listen.mjs devices. setup: node scripts/setup.mjs listen",
258
+ );
259
+ }
260
+ const spec = d.startsWith("audio=") ? d : `audio=${d}`;
261
+ return ["-f", "dshow", "-i", spec];
262
+ }
263
+ const fmt = ffmpegFormat || (d.startsWith("hw:") ? "alsa" : "pulse");
264
+ return ["-f", fmt, "-i", d];
265
+ }
266
+
267
+ export function ffmpegRecordArgs({ platform, device, out, seconds, ffmpegFormat } = {}) {
268
+ const t = seconds ? ["-t", String(clampSeconds(seconds))] : [];
269
+ const noIn = seconds ? ["-nostdin"] : [];
270
+ return [
271
+ "-hide_banner",
272
+ "-loglevel",
273
+ "warning",
274
+ ...noIn,
275
+ ...ffmpegInputArgs(platform, device, ffmpegFormat),
276
+ "-ac",
277
+ "1",
278
+ "-ar",
279
+ "16000",
280
+ "-c:a",
281
+ "pcm_s16le",
282
+ ...t,
283
+ "-y",
284
+ out,
285
+ ];
286
+ }
287
+
288
+ export function recSpawnOpts(seconds) {
289
+ const s = clampSeconds(seconds);
290
+ return {
291
+ encoding: "utf8",
292
+ stdio: ["ignore", "ignore", "pipe"],
293
+ timeout: (s + 2) * 1000,
294
+ killSignal: "SIGKILL",
295
+ };
296
+ }
297
+
298
+ export function recCaptureOpts(errFd) {
299
+ return {
300
+ detached: true,
301
+ stdio: ["ignore", "ignore", errFd == null ? "ignore" : errFd],
302
+ windowsHide: true,
303
+ };
304
+ }
305
+
306
+ export function recCapture({
307
+ bin,
308
+ args,
309
+ seconds,
310
+ spawnFn = spawn,
311
+ killFn = process.kill,
312
+ errFd,
313
+ } = {}) {
314
+ const s = clampSeconds(seconds);
315
+ return new Promise((resolve) => {
316
+ const child = spawnFn(bin, args, recCaptureOpts(errFd));
317
+ let done = false;
318
+ const finish = (status, signal, error) => {
319
+ if (done) return;
320
+ done = true;
321
+ clearTimeout(timer);
322
+ resolve({ status, signal, stderr: "", error });
323
+ };
324
+ const timer = setTimeout(() => {
325
+ const pid = child && child.pid;
326
+ try {
327
+ if (pid) killFn(-pid, "SIGKILL");
328
+ } catch {
329
+ try {
330
+ if (child && typeof child.kill === "function") child.kill("SIGKILL");
331
+ } catch {
332
+ /* none */
333
+ }
334
+ }
335
+ finish(null, "SIGKILL", { code: "ETIMEDOUT" });
336
+ }, (s + 2) * 1000);
337
+ if (child && typeof child.on === "function") {
338
+ child.on("error", (error) => finish(null, null, error));
339
+ child.on("exit", (code, signal) => finish(code, signal, null));
340
+ } else {
341
+ finish(0, null, null);
342
+ }
343
+ });
344
+ }
345
+
346
+ export function parseDevices(text, platform = process.platform) {
347
+ const lines = String(text || "").split(/\r?\n/);
348
+ const out = [];
349
+ if (platform === "darwin") {
350
+ let audio = false;
351
+ for (const line of lines) {
352
+ if (/AVFoundation audio devices/i.test(line) || /audio devices:\s*$/i.test(line)) {
353
+ audio = true;
354
+ continue;
355
+ }
356
+ if (/video devices/i.test(line)) {
357
+ audio = false;
358
+ continue;
359
+ }
360
+ if (!audio) continue;
361
+ const m = line.match(/\[(\d+)\]\s+(.+)$/);
362
+ if (m) out.push({ id: `:${m[1]}`, name: m[2].trim() });
363
+ }
364
+ return out;
365
+ }
366
+ if (platform === "win32") {
367
+ let audio = false;
368
+ for (const line of lines) {
369
+ if (/DirectShow audio devices/i.test(line)) {
370
+ audio = true;
371
+ continue;
372
+ }
373
+ if (/DirectShow video devices/i.test(line)) {
374
+ audio = false;
375
+ continue;
376
+ }
377
+ if (!audio) continue;
378
+ const m = line.match(/"([^"]+)"/);
379
+ if (m) out.push({ id: m[1], name: m[1] });
380
+ }
381
+ return out;
382
+ }
383
+ for (const line of lines) {
384
+ const m = line.match(/^\s*(\d+)\s+(\S+)/);
385
+ if (m) out.push({ id: m[2], name: m[2] });
386
+ }
387
+ if (!out.length) out.push({ id: "default", name: "pulse default" });
388
+ return out;
389
+ }
390
+
391
+ function readSession(cwd) {
392
+ return readJson(sessionPath(cwd));
393
+ }
394
+
395
+ function writeSession(cwd, obj) {
396
+ fs.writeFileSync(sessionPath(cwd), JSON.stringify(obj, null, 2) + "\n");
397
+ }
398
+
399
+ function clearSession(cwd) {
400
+ try {
401
+ fs.unlinkSync(sessionPath(cwd));
402
+ } catch {
403
+ /* none */
404
+ }
405
+ }
406
+
407
+ export function pidAlive(pid, killFn = process.kill) {
408
+ const n = Number(pid);
409
+ if (!Number.isFinite(n) || n <= 0) return false;
410
+ try {
411
+ killFn(n, 0);
412
+ return true;
413
+ } catch {
414
+ return false;
415
+ }
416
+ }
417
+
418
+ function needFfmpeg(bin) {
419
+ if (!bin) {
420
+ throw new Error(NEED_FFMPEG);
421
+ }
422
+ return bin;
423
+ }
424
+
425
+ export function transcribeLocal({
426
+ file,
427
+ bin,
428
+ model,
429
+ spawnSyncFn = spawnSync,
430
+ } = {}) {
431
+ const p = str(file);
432
+ if (!p || !isFile(p)) throw new Error("transcribe needs an audio file");
433
+ const prefix = path.join(path.dirname(p), `${path.basename(p, path.extname(p))}.whisper`);
434
+ const txt = `${prefix}.txt`;
435
+ const r = spawnSyncFn(
436
+ bin,
437
+ ["-m", model, "-f", p, "-otxt", "-of", prefix, "-np", "-nt"],
438
+ { encoding: "utf8" },
439
+ );
440
+ let text = "";
441
+ if (isFile(txt)) {
442
+ text = fs.readFileSync(txt, "utf8").trim();
443
+ try {
444
+ fs.unlinkSync(txt);
445
+ } catch {
446
+ /* none */
447
+ }
448
+ }
449
+ if (!text) text = str(r && r.stdout).trim();
450
+ if (text) return text;
451
+ throw new Error(str(r && r.stderr, "whisper-cli failed"));
452
+ }
453
+
454
+ export async function transcribe({
455
+ file,
456
+ cfg,
457
+ stt,
458
+ fetchFn = globalThis.fetch,
459
+ spawnSyncFn = spawnSync,
460
+ } = {}) {
461
+ const p = str(file);
462
+ if (!p || !fs.existsSync(p)) throw new Error("transcribe needs an audio file");
463
+ const engine = stt || { kind: "openai" };
464
+ if (engine.kind === "local") {
465
+ return transcribeLocal({ file: p, bin: engine.bin, model: engine.model, spawnSyncFn });
466
+ }
467
+ if (!cfg?.apiKey) throw new Error(NEED_STT);
468
+ const buf = fs.readFileSync(p);
469
+ const fd = new FormData();
470
+ fd.append("file", new Blob([buf], { type: "audio/wav" }), path.basename(p) || "audio.wav");
471
+ fd.append("model", cfg.model || DEFAULT_MODEL);
472
+ const res = await fetchFn(TRANSCRIBE_URL, {
473
+ method: "POST",
474
+ headers: { Authorization: `Bearer ${cfg.apiKey}` },
475
+ body: fd,
476
+ });
477
+ if (!res || !res.ok) {
478
+ let t = "";
479
+ try {
480
+ t = res && typeof res.text === "function" ? await res.text() : "";
481
+ } catch {
482
+ /* none */
483
+ }
484
+ const status = res && res.status != null ? res.status : "err";
485
+ throw new Error(formatHttpError(status, t, "openai"));
486
+ }
487
+ const j = typeof res.json === "function" ? await res.json() : {};
488
+ return str(j && j.text, typeof j === "string" ? j : "");
489
+ }
490
+
491
+ export function copyClip(text, { platform = process.platform, spawnSyncFn = spawnSync } = {}) {
492
+ const body = String(text ?? "");
493
+ if (platform === "darwin") {
494
+ const r = spawnSyncFn("pbcopy", [], { input: body, encoding: "utf8" });
495
+ if (r.status !== 0) throw new Error("pbcopy failed");
496
+ return;
497
+ }
498
+ if (platform === "win32") {
499
+ const r = spawnSyncFn("clip", [], { input: body, encoding: "utf8", shell: true });
500
+ if (r.status !== 0) throw new Error("clip failed");
501
+ return;
502
+ }
503
+ for (const [bin, args] of [
504
+ ["wl-copy", []],
505
+ ["xclip", ["-selection", "clipboard"]],
506
+ ["xsel", ["--clipboard", "--input"]],
507
+ ]) {
508
+ const r = spawnSyncFn(bin, args, { input: body, encoding: "utf8" });
509
+ if (r && r.status === 0) return;
510
+ }
511
+ throw new Error("no wl-copy, xclip, or xsel on PATH");
512
+ }
513
+
514
+ function saveLatest(cwd, text) {
515
+ const p = latestPath(cwd);
516
+ fs.writeFileSync(p, String(text ?? "") + (String(text ?? "").endsWith("\n") ? "" : "\n"));
517
+ return p;
518
+ }
519
+
520
+ async function finishTranscript({
521
+ wav,
522
+ cfg,
523
+ cwd,
524
+ clip,
525
+ fetchFn,
526
+ platform,
527
+ spawnSyncFn,
528
+ stt,
529
+ log,
530
+ }) {
531
+ const text = await transcribe({ file: wav, cfg, stt, fetchFn, spawnSyncFn });
532
+ const latest = saveLatest(cwd, text);
533
+ if (clip) copyClip(text, { platform, spawnSyncFn });
534
+ if (log) {
535
+ log.log(text);
536
+ const rel = path.relative(cwd, latest).replace(/\\/g, "/");
537
+ log.log(`transcript=${rel || latest}`);
538
+ }
539
+ return text;
540
+ }
541
+
542
+ export function listDevicesText({
543
+ platform = process.platform,
544
+ ffmpegBin,
545
+ spawnSyncFn = spawnSync,
546
+ } = {}) {
547
+ needFfmpeg(ffmpegBin);
548
+ if (platform === "darwin") {
549
+ const r = spawnSyncFn(ffmpegBin, ["-f", "avfoundation", "-list_devices", "true", "-i", ""], {
550
+ encoding: "utf8",
551
+ });
552
+ return parseDevices(`${r.stderr || ""}\n${r.stdout || ""}`, "darwin");
553
+ }
554
+ if (platform === "win32") {
555
+ const r = spawnSyncFn(
556
+ ffmpegBin,
557
+ ["-list_devices", "true", "-f", "dshow", "-i", "dummy"],
558
+ { encoding: "utf8" },
559
+ );
560
+ return parseDevices(`${r.stderr || ""}\n${r.stdout || ""}`, "win32");
561
+ }
562
+ const pactl = spawnSyncFn("pactl", ["list", "short", "sources"], { encoding: "utf8" });
563
+ if (pactl.status === 0 && str(pactl.stdout)) {
564
+ return parseDevices(pactl.stdout, "linux");
565
+ }
566
+ return parseDevices("", "linux");
567
+ }
568
+
569
+ function waitPidExit(pid, killFn, timeoutMs = 8000) {
570
+ const start = Date.now();
571
+ while (Date.now() - start < timeoutMs) {
572
+ if (!pidAlive(pid, killFn)) return;
573
+ spawnSync(process.execPath, ["-e", ""], { timeout: 50 });
574
+ }
575
+ }
576
+
577
+ export async function main(argv = process.argv.slice(2), opts = {}) {
578
+ const { _, flags } = parseArgv(argv);
579
+ const env = opts.env || process.env;
580
+ const home = opts.home || str(env.HOME || env.USERPROFILE, os.homedir());
581
+ const cwd = str(flags.cwd || opts.cwd, process.cwd());
582
+ const platform = opts.platform || process.platform;
583
+ const fetchFn = opts.fetchFn || globalThis.fetch;
584
+ const spawnFn = opts.spawnFn || spawn;
585
+ const spawnSyncFn = opts.spawnSyncFn || spawnSync;
586
+ const killFn = opts.killFn || process.kill;
587
+ const whichFn = opts.whichFn || whichBin;
588
+ const log = opts.log || console;
589
+ const pathEnv = opts.pathEnv || env.PATH || process.env.PATH;
590
+ const ffmpegBin = whichFn("ffmpeg", pathEnv, platform);
591
+ const scriptPath = opts.scriptPath || fileURLToPath(import.meta.url);
592
+ const nodeBin = opts.nodeBin || process.execPath;
593
+ const cfg = loadCfg({ env, home });
594
+ const device = str(flags.device, cfg.device);
595
+ const clip = flags.clip === "1" || flags.clip === true || flags.clip === "true";
596
+ const verb = String(_[0] || "").toLowerCase();
597
+ const sttOpts = { cfg, whichFn, pathEnv, platform, home, cwd, fetchFn, log };
598
+
599
+ try {
600
+ if (verb === "whoami") {
601
+ const ff = ffmpegBin || "missing";
602
+ let stt;
603
+ try {
604
+ stt = await ensureStt(sttOpts);
605
+ } catch {
606
+ stt = probeStt(sttOpts);
607
+ }
608
+ if (!stt) {
609
+ log.log(`stt=none ffmpeg=${ff}`);
610
+ log.error(NEED_STT);
611
+ return 1;
612
+ }
613
+ if (stt.kind === "local") {
614
+ log.log(`stt=local bin=${stt.bin} model=${stt.model} ffmpeg=${ff}`);
615
+ return 0;
616
+ }
617
+ log.log(`stt=openai model=${stt.model} ffmpeg=${ff}`);
618
+ return 0;
619
+ }
620
+ if (verb === "devices") {
621
+ const list = listDevicesText({ platform, ffmpegBin, spawnSyncFn });
622
+ if (!list.length) log.log("no audio devices");
623
+ else for (const d of list) log.log(`${d.id} ${d.name}`);
624
+ return 0;
625
+ }
626
+ if (verb === "latest") {
627
+ const p = latestPath(cwd);
628
+ if (!fs.existsSync(p)) {
629
+ log.error("no transcript");
630
+ return 1;
631
+ }
632
+ log.log(fs.readFileSync(p, "utf8").trimEnd());
633
+ return 0;
634
+ }
635
+ if (verb === "status") {
636
+ const s = readSession(cwd);
637
+ if (!s.pid) {
638
+ log.log("idle");
639
+ return 0;
640
+ }
641
+ const alive = pidAlive(s.pid, killFn);
642
+ log.log(`${alive ? "recording" : "stale"} pid=${s.pid} wav=${s.wav || "-"}`);
643
+ if (!alive) clearSession(cwd);
644
+ return 0;
645
+ }
646
+ if (verb === "file") {
647
+ const file = str(flags.path || flags.file || flags.out || _[1]);
648
+ if (!file) throw new Error("file needs --path");
649
+ await finishTranscript({
650
+ wav: file,
651
+ cfg,
652
+ cwd,
653
+ clip,
654
+ fetchFn,
655
+ platform,
656
+ spawnSyncFn,
657
+ stt: await ensureStt(sttOpts),
658
+ log,
659
+ });
660
+ return 0;
661
+ }
662
+ if (verb === "_record") {
663
+ const out = str(flags.out);
664
+ if (!out) throw new Error("_record needs --out");
665
+ needFfmpeg(ffmpegBin);
666
+ const seconds = flags.seconds ? clampSeconds(flags.seconds) : 0;
667
+ const args = ffmpegRecordArgs({
668
+ platform,
669
+ device,
670
+ out,
671
+ seconds: seconds || undefined,
672
+ ffmpegFormat: cfg.ffmpegFormat,
673
+ });
674
+ fs.mkdirSync(path.dirname(out), { recursive: true });
675
+ writeSession(cwd, { pid: process.pid, wav: out, started: Date.now() });
676
+ const child = spawnFn(ffmpegBin, args, { stdio: ["pipe", "ignore", "pipe"] });
677
+ const stop = () => {
678
+ try {
679
+ if (child.stdin) child.stdin.write("q\n");
680
+ } catch {
681
+ /* none */
682
+ }
683
+ try {
684
+ if (child.stdin) child.stdin.end();
685
+ } catch {
686
+ /* none */
687
+ }
688
+ };
689
+ process.on("SIGINT", stop);
690
+ process.on("SIGTERM", stop);
691
+ const code = await new Promise((resolve, reject) => {
692
+ child.on("error", reject);
693
+ child.on("exit", (c) => resolve(c == null ? 0 : c));
694
+ });
695
+ return code && code !== 0 && code !== 255 ? 1 : 0;
696
+ }
697
+ if (verb === "start") {
698
+ needFfmpeg(ffmpegBin);
699
+ const cur = readSession(cwd);
700
+ if (cur.pid && pidAlive(cur.pid, killFn)) {
701
+ log.error(`already recording pid=${cur.pid}`);
702
+ return 1;
703
+ }
704
+ const wav = path.join(listenDir(cwd), `rec-${Date.now()}.wav`);
705
+ const child = spawnFn(
706
+ nodeBin,
707
+ [
708
+ scriptPath,
709
+ "_record",
710
+ "--out",
711
+ wav,
712
+ ...(device ? ["--device", device] : []),
713
+ ...(flags.cwd || opts.cwd ? ["--cwd", cwd] : []),
714
+ ],
715
+ {
716
+ detached: true,
717
+ stdio: "ignore",
718
+ cwd,
719
+ env,
720
+ windowsHide: true,
721
+ },
722
+ );
723
+ if (typeof child.unref === "function") child.unref();
724
+ writeSession(cwd, { pid: child.pid, wav, started: Date.now() });
725
+ log.log(`recording pid=${child.pid}\n${wav}`);
726
+ return 0;
727
+ }
728
+ if (verb === "stop") {
729
+ const s = readSession(cwd);
730
+ if (!s.pid) throw new Error("not recording");
731
+ if (pidAlive(s.pid, killFn)) {
732
+ try {
733
+ killFn(s.pid, "SIGINT");
734
+ } catch {
735
+ /* none */
736
+ }
737
+ if (typeof opts.waitPid === "function") opts.waitPid(s.pid, killFn);
738
+ else waitPidExit(s.pid, killFn);
739
+ }
740
+ clearSession(cwd);
741
+ const wav = str(s.wav);
742
+ if (!wav || !fs.existsSync(wav)) throw new Error("no wav from session");
743
+ await finishTranscript({
744
+ wav,
745
+ cfg,
746
+ cwd,
747
+ clip,
748
+ fetchFn,
749
+ platform,
750
+ spawnSyncFn,
751
+ stt: await ensureStt(sttOpts),
752
+ log,
753
+ });
754
+ return 0;
755
+ }
756
+ if (verb === "toggle") {
757
+ const s = readSession(cwd);
758
+ if (s.pid && pidAlive(s.pid, killFn)) {
759
+ return main(["stop", ...(clip ? ["--clip"] : []), ...(flags.cwd ? ["--cwd", cwd] : [])], opts);
760
+ }
761
+ return main(["start", ...(device ? ["--device", device] : []), ...(flags.cwd ? ["--cwd", cwd] : [])], opts);
762
+ }
763
+ if (verb === "rec") {
764
+ needFfmpeg(ffmpegBin);
765
+ const stt = await ensureStt(sttOpts);
766
+ const seconds = clampSeconds(flags.seconds || cfg.seconds);
767
+ const wav = str(flags.out) || path.join(listenDir(cwd), `rec-${Date.now()}.wav`);
768
+ const args = ffmpegRecordArgs({
769
+ platform,
770
+ device,
771
+ out: wav,
772
+ seconds,
773
+ ffmpegFormat: cfg.ffmpegFormat,
774
+ });
775
+ log.error(`recording ${seconds}s on ${args[args.indexOf("-i") + 1] || "mic"}…`);
776
+ const ffLog = path.join(listenDir(cwd), "ffmpeg.log");
777
+ const errFd = fs.openSync(ffLog, "w");
778
+ let r;
779
+ try {
780
+ r = await recCapture({
781
+ bin: ffmpegBin,
782
+ args,
783
+ seconds,
784
+ spawnFn,
785
+ killFn,
786
+ errFd,
787
+ });
788
+ } finally {
789
+ try {
790
+ fs.closeSync(errFd);
791
+ } catch {
792
+ /* none */
793
+ }
794
+ }
795
+ const ffErr = isFile(ffLog) ? str(fs.readFileSync(ffLog, "utf8")).slice(0, 400) : "";
796
+ if (r.error && (r.error.code === "ETIMEDOUT" || r.signal === "SIGKILL")) {
797
+ throw new Error(str(ffErr, "ffmpeg rec timed out"));
798
+ }
799
+ if (r.status !== 0 && !fs.existsSync(wav)) {
800
+ throw new Error(str(ffErr, "ffmpeg rec failed"));
801
+ }
802
+ if (!fs.existsSync(wav)) throw new Error("ffmpeg wrote no wav");
803
+ log.error("transcribing…");
804
+ await finishTranscript({
805
+ wav,
806
+ cfg,
807
+ cwd,
808
+ clip,
809
+ fetchFn,
810
+ platform,
811
+ spawnSyncFn,
812
+ stt,
813
+ log,
814
+ });
815
+ return 0;
816
+ }
817
+ log.error(USAGE);
818
+ return 1;
819
+ } catch (e) {
820
+ log.error(String(e && e.message ? e.message : e));
821
+ return 1;
822
+ }
823
+ }
824
+
825
+ const thisFile = fileURLToPath(import.meta.url);
826
+ const invoked = process.argv[1] ? path.resolve(process.argv[1]) : "";
827
+ if (invoked === thisFile || import.meta.url === pathToFileURL(invoked).href) {
828
+ main().then((code) => process.exit(code));
829
+ }