@hasna/recordings 0.1.11 → 0.1.13

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 (69) hide show
  1. package/README.md +2 -0
  2. package/dist/cli/index.js +395 -37
  3. package/dist/db/database.d.ts.map +1 -1
  4. package/dist/db/recordings.d.ts.map +1 -1
  5. package/dist/index.js +78 -11
  6. package/dist/lib/config.d.ts.map +1 -1
  7. package/dist/lib/enhancer.d.ts.map +1 -1
  8. package/dist/lib/recorder.d.ts.map +1 -1
  9. package/dist/lib/transcriber.d.ts +8 -2
  10. package/dist/lib/transcriber.d.ts.map +1 -1
  11. package/dist/mcp/index.js +147 -30
  12. package/dist/types/index.d.ts +3 -0
  13. package/dist/types/index.d.ts.map +1 -1
  14. package/dist/version.d.ts +2 -0
  15. package/dist/version.d.ts.map +1 -0
  16. package/package.json +20 -3
  17. package/scripts/install_macos_app.sh +76 -0
  18. package/src/native/Recordings/{Recordings → App}/RecordingsApp.swift +5 -1
  19. package/src/native/Recordings/Package.resolved +21 -3
  20. package/src/native/Recordings/Package.swift +16 -5
  21. package/src/native/Recordings/{Recordings → RecordingsLib}/Info.plist +6 -0
  22. package/src/native/Recordings/{Recordings → RecordingsLib}/MenuBarPopover.swift +55 -11
  23. package/src/native/Recordings/RecordingsLib/NativeAppDiagnostics.swift +36 -0
  24. package/src/native/Recordings/RecordingsLib/NativePCMRecorder.swift +164 -0
  25. package/src/native/Recordings/RecordingsLib/OpenAIAPIKeyStore.swift +113 -0
  26. package/src/native/Recordings/{Recordings → RecordingsLib}/ProjectStore.swift +11 -11
  27. package/src/native/Recordings/RecordingsLib/RealtimeTranscriptionClient.swift +383 -0
  28. package/src/native/Recordings/RecordingsLib/RecordingEngine.swift +835 -0
  29. package/src/native/Recordings/{Recordings → RecordingsLib}/SettingsView.swift +19 -5
  30. package/src/native/Recordings/{Recordings → RecordingsLib}/VoiceShortcuts.swift +12 -8
  31. package/src/native/Recordings/RecordingsTests/CLIRunnerTests.swift +63 -0
  32. package/src/native/Recordings/RecordingsTests/NativeAppDiagnosticsTests.swift +23 -0
  33. package/src/native/Recordings/RecordingsTests/NativePCMRecorderTests.swift +33 -0
  34. package/src/native/Recordings/RecordingsTests/OpenAIAPIKeyStoreTests.swift +92 -0
  35. package/src/native/Recordings/RecordingsTests/ProjectStoreTests.swift +58 -0
  36. package/src/native/Recordings/RecordingsTests/RealtimeTranscriptionTests.swift +113 -0
  37. package/src/native/Recordings/build.sh +6 -6
  38. package/.takumi/settings.local.json +0 -7
  39. package/bun.lock +0 -250
  40. package/bunfig.toml +0 -2
  41. package/src/__tests__/agents.test.ts +0 -136
  42. package/src/__tests__/config.test.ts +0 -252
  43. package/src/__tests__/database.test.ts +0 -167
  44. package/src/__tests__/enhancer.test.ts +0 -639
  45. package/src/__tests__/preload.ts +0 -4
  46. package/src/__tests__/projects.test.ts +0 -109
  47. package/src/__tests__/recorder.test.ts +0 -278
  48. package/src/__tests__/recordings.test.ts +0 -353
  49. package/src/__tests__/transcriber.test.ts +0 -322
  50. package/src/__tests__/types.test.ts +0 -75
  51. package/src/cli/index.ts +0 -988
  52. package/src/db/agents.ts +0 -104
  53. package/src/db/database.ts +0 -163
  54. package/src/db/pg-migrations.ts +0 -82
  55. package/src/db/projects.ts +0 -71
  56. package/src/db/recordings.ts +0 -225
  57. package/src/index.ts +0 -81
  58. package/src/lib/config.ts +0 -223
  59. package/src/lib/enhancer.ts +0 -173
  60. package/src/lib/recorder.ts +0 -198
  61. package/src/lib/transcriber.ts +0 -105
  62. package/src/mcp/index.ts +0 -464
  63. package/src/native/Recordings/Recordings/RecordingEngine.swift +0 -455
  64. package/src/native/Recordings/test_fn.swift +0 -79
  65. package/src/native/Recordings/test_fn2.swift +0 -33
  66. package/src/types/index.ts +0 -144
  67. package/tsconfig.json +0 -21
  68. /package/src/native/Recordings/{Recordings → RecordingsLib}/FnKeyMonitor.swift +0 -0
  69. /package/src/native/Recordings/{Recordings → RecordingsLib}/Recordings.entitlements +0 -0
package/src/cli/index.ts DELETED
@@ -1,988 +0,0 @@
1
- #!/usr/bin/env bun
2
- import { Command } from "commander";
3
- import chalk from "chalk";
4
- import { loadConfig, ensureDataDir } from "../lib/config.js";
5
- import { getDatabase, getAdapter } from "../db/database.js";
6
- import {
7
- createRecording,
8
- getRecording,
9
- listRecordings,
10
- deleteRecording,
11
- searchRecordings,
12
- getRecordingStats,
13
- } from "../db/recordings.js";
14
- import { registerAgent, getAgent, listAgents } from "../db/agents.js";
15
- import {
16
- registerProject,
17
- listProjects,
18
- } from "../db/projects.js";
19
- import {
20
- startRecording,
21
- stopRecording,
22
- isRecording,
23
- checkRecordingDeps,
24
- recordDuration,
25
- } from "../lib/recorder.js";
26
- import { transcribeAudio } from "../lib/transcriber.js";
27
- import { processText, needsEnhancement } from "../lib/enhancer.js";
28
- import type { Recording } from "../types/index.js";
29
-
30
- const program = new Command();
31
-
32
- program
33
- .name("recordings")
34
- .description(
35
- "Speech-to-text recording tool — record, transcribe, and enhance with AI"
36
- )
37
- .version("0.0.3")
38
- .option("--json", "Output as JSON")
39
- .option("--agent <name>", "Agent name or ID")
40
- .option("--project <name>", "Project name or ID")
41
- .option("--session <id>", "Session ID");
42
-
43
- // ── record ──────────────────────────────────────────────────────────────────
44
-
45
- program
46
- .command("record")
47
- .description("Record from microphone, transcribe, and optionally enhance")
48
- .option("-d, --duration <seconds>", "Record for specific duration")
49
- .option("--no-enhance", "Skip AI enhancement")
50
- .option("-t, --tags <tags>", "Comma-separated tags")
51
- .option("-l, --language <lang>", "Language code (e.g. en, es, fr)")
52
- .action(async (opts) => {
53
- const config = loadConfig();
54
- ensureDataDir(config);
55
-
56
- if (opts.language) config.language = opts.language;
57
- if (opts.noEnhance === false) config.auto_enhance = false;
58
-
59
- // Check dependencies
60
- const deps = await checkRecordingDeps();
61
- if (!deps.available) {
62
- console.error(chalk.red(`Error: ${deps.message}`));
63
- process.exit(1);
64
- }
65
-
66
- let audioPath: string;
67
-
68
- if (opts.duration) {
69
- // Fixed duration recording
70
- const seconds = parseInt(opts.duration, 10);
71
- console.log(
72
- chalk.blue(`Recording for ${seconds} seconds...`)
73
- );
74
- audioPath = await recordDuration(seconds, config);
75
- console.log(chalk.green("Recording complete."));
76
- } else {
77
- // Interactive recording — press Enter to stop
78
- console.log(
79
- chalk.blue("Recording... Press") +
80
- chalk.yellow(" Enter ") +
81
- chalk.blue("to stop.")
82
- );
83
- audioPath = startRecording(config);
84
-
85
- // Wait for Enter key
86
- await new Promise<void>((resolve) => {
87
- process.stdin.setRawMode?.(true);
88
- process.stdin.resume();
89
- process.stdin.once("data", () => {
90
- process.stdin.setRawMode?.(false);
91
- process.stdin.pause();
92
- resolve();
93
- });
94
- });
95
-
96
- stopRecording();
97
- console.log(chalk.green("Recording stopped."));
98
- }
99
-
100
- // Transcribe
101
- console.log(chalk.blue("Transcribing..."));
102
- const transcription = await transcribeAudio(audioPath, config);
103
- console.log(chalk.dim(`Raw: ${transcription.text}`));
104
-
105
- // Process (detect & enhance if needed)
106
- const processed = await processText(transcription.text, config);
107
-
108
- if (processed.mode === "enhanced") {
109
- console.log(chalk.green("\nEnhanced output:"));
110
- console.log(processed.text);
111
- } else {
112
- console.log(chalk.green("\nOutput:"));
113
- console.log(transcription.text);
114
- }
115
-
116
- // Save to database
117
- const tags = opts.tags ? opts.tags.split(",").map((t: string) => t.trim()) : [];
118
- const parentOpts = program.opts();
119
-
120
- const recording = createRecording({
121
- audio_path: audioPath,
122
- raw_text: transcription.text,
123
- processed_text: processed.mode === "enhanced" ? processed.text : undefined,
124
- processing_mode: processed.mode,
125
- model_used: transcription.model,
126
- enhancement_model: processed.enhancement_model || undefined,
127
- duration_ms: transcription.duration_ms,
128
- language: transcription.language || undefined,
129
- tags,
130
- agent_id: parentOpts.agent || undefined,
131
- project_id: parentOpts.project || undefined,
132
- session_id: parentOpts.session || undefined,
133
- });
134
-
135
- if (parentOpts.json) {
136
- console.log(JSON.stringify(recording, null, 2));
137
- } else {
138
- console.log(
139
- chalk.dim(`\nSaved as ${recording.id.slice(0, 8)}`)
140
- );
141
- }
142
- });
143
-
144
- // ── transcribe ──────────────────────────────────────────────────────────────
145
-
146
- program
147
- .command("transcribe <file>")
148
- .description("Transcribe an existing audio file")
149
- .option("--no-enhance", "Skip AI enhancement")
150
- .option("-t, --tags <tags>", "Comma-separated tags")
151
- .option("--system-prompt <prompt>", "System prompt for enhancement context")
152
- .action(async (file, opts) => {
153
- const config = loadConfig();
154
- ensureDataDir(config);
155
- if (opts.noEnhance === false) config.auto_enhance = false;
156
-
157
- console.log(chalk.blue("Transcribing..."));
158
- const transcription = await transcribeAudio(file, config);
159
-
160
- const processed = await processText(transcription.text, config, opts.systemPrompt);
161
- const parentOpts = program.opts();
162
- const tags = opts.tags ? opts.tags.split(",").map((t: string) => t.trim()) : [];
163
-
164
- const recording = createRecording({
165
- audio_path: file,
166
- raw_text: transcription.text,
167
- processed_text: processed.mode === "enhanced" ? processed.text : undefined,
168
- processing_mode: processed.mode,
169
- model_used: transcription.model,
170
- enhancement_model: processed.enhancement_model || undefined,
171
- duration_ms: transcription.duration_ms,
172
- language: transcription.language || undefined,
173
- tags,
174
- agent_id: parentOpts.agent || undefined,
175
- project_id: parentOpts.project || undefined,
176
- session_id: parentOpts.session || undefined,
177
- });
178
-
179
- if (processed.mode === "enhanced") {
180
- console.log(chalk.green("Enhanced:"));
181
- console.log(processed.text);
182
- } else {
183
- console.log(chalk.green("Transcription:"));
184
- console.log(transcription.text);
185
- }
186
-
187
- if (parentOpts.json) {
188
- console.log(JSON.stringify(recording, null, 2));
189
- } else {
190
- console.log(chalk.dim(`Saved as ${recording.id.slice(0, 8)}`));
191
- }
192
- });
193
-
194
- // ── list ────────────────────────────────────────────────────────────────────
195
-
196
- program
197
- .command("list")
198
- .description("List recordings")
199
- .option("-n, --limit <n>", "Max results", "20")
200
- .option("--mode <mode>", "Filter by mode: raw or enhanced")
201
- .option("-t, --tags <tags>", "Filter by tags")
202
- .option("--since <date>", "After date (ISO)")
203
- .option("--until <date>", "Before date (ISO)")
204
- .action((opts) => {
205
- const config = loadConfig();
206
- getDatabase(config.db_path);
207
- const parentOpts = program.opts();
208
-
209
- const recordings = listRecordings({
210
- limit: parseInt(opts.limit, 10),
211
- processing_mode: opts.mode,
212
- tags: opts.tags ? opts.tags.split(",") : undefined,
213
- since: opts.since,
214
- until: opts.until,
215
- agent_id: parentOpts.agent,
216
- project_id: parentOpts.project,
217
- session_id: parentOpts.session,
218
- });
219
-
220
- if (parentOpts.json) {
221
- console.log(JSON.stringify(recordings, null, 2));
222
- return;
223
- }
224
-
225
- if (recordings.length === 0) {
226
- console.log(chalk.dim("No recordings found."));
227
- return;
228
- }
229
-
230
- console.log(
231
- chalk.bold(`${recordings.length} recording(s):\n`)
232
- );
233
- for (const r of recordings) {
234
- console.log(formatRecordingLine(r));
235
- }
236
- });
237
-
238
- // ── show ────────────────────────────────────────────────────────────────────
239
-
240
- program
241
- .command("show <id>")
242
- .description("Show recording details")
243
- .action((id) => {
244
- const config = loadConfig();
245
- getDatabase(config.db_path);
246
- const parentOpts = program.opts();
247
-
248
- const recording = getRecording(id);
249
- if (!recording) {
250
- console.error(chalk.red(`Recording not found: ${id}`));
251
- process.exit(1);
252
- }
253
-
254
- if (parentOpts.json) {
255
- console.log(JSON.stringify(recording, null, 2));
256
- return;
257
- }
258
-
259
- console.log(formatRecordingDetail(recording));
260
- });
261
-
262
- // ── search ──────────────────────────────────────────────────────────────────
263
-
264
- program
265
- .command("search <query>")
266
- .description("Search recordings by text content")
267
- .option("-n, --limit <n>", "Max results", "20")
268
- .action((query, opts) => {
269
- const config = loadConfig();
270
- getDatabase(config.db_path);
271
- const parentOpts = program.opts();
272
-
273
- const results = searchRecordings(query, {
274
- limit: parseInt(opts.limit, 10),
275
- agent_id: parentOpts.agent,
276
- project_id: parentOpts.project,
277
- });
278
-
279
- if (parentOpts.json) {
280
- console.log(JSON.stringify(results, null, 2));
281
- return;
282
- }
283
-
284
- if (results.length === 0) {
285
- console.log(chalk.dim("No results."));
286
- return;
287
- }
288
-
289
- console.log(chalk.bold(`${results.length} result(s):\n`));
290
- for (const r of results) {
291
- console.log(formatRecordingLine(r));
292
- }
293
- });
294
-
295
- // ── delete ──────────────────────────────────────────────────────────────────
296
-
297
- program
298
- .command("delete <id>")
299
- .description("Delete a recording")
300
- .action((id) => {
301
- const config = loadConfig();
302
- getDatabase(config.db_path);
303
-
304
- const deleted = deleteRecording(id);
305
- if (deleted) {
306
- console.log(chalk.green(`Deleted recording ${id}`));
307
- } else {
308
- console.error(chalk.red(`Recording not found: ${id}`));
309
- process.exit(1);
310
- }
311
- });
312
-
313
- // ── stats ───────────────────────────────────────────────────────────────────
314
-
315
- program
316
- .command("stats")
317
- .description("Show recording statistics")
318
- .action(() => {
319
- const config = loadConfig();
320
- getDatabase(config.db_path);
321
- const parentOpts = program.opts();
322
-
323
- const stats = getRecordingStats();
324
-
325
- if (parentOpts.json) {
326
- console.log(JSON.stringify(stats, null, 2));
327
- return;
328
- }
329
-
330
- console.log(chalk.bold("Recording Statistics\n"));
331
- console.log(` Total: ${stats.total}`);
332
- console.log(` Raw: ${stats.raw}`);
333
- console.log(` Enhanced: ${stats.enhanced}`);
334
- console.log(
335
- ` Duration: ${(stats.total_duration_ms / 1000).toFixed(1)}s`
336
- );
337
- if (Object.keys(stats.by_model).length > 0) {
338
- console.log(` By model:`);
339
- for (const [model, count] of Object.entries(stats.by_model)) {
340
- console.log(` ${model}: ${count}`);
341
- }
342
- }
343
- });
344
-
345
- // ── agents ──────────────────────────────────────────────────────────────────
346
-
347
- program
348
- .command("agents")
349
- .description("List registered agents")
350
- .action(() => {
351
- const config = loadConfig();
352
- getDatabase(config.db_path);
353
- const parentOpts = program.opts();
354
-
355
- const agents = listAgents();
356
-
357
- if (parentOpts.json) {
358
- console.log(JSON.stringify(agents, null, 2));
359
- return;
360
- }
361
-
362
- if (agents.length === 0) {
363
- console.log(chalk.dim("No agents registered."));
364
- return;
365
- }
366
-
367
- for (const a of agents) {
368
- console.log(
369
- `${chalk.cyan(a.id)} ${chalk.bold(a.name)} (${a.role}) — last seen ${a.last_seen_at}`
370
- );
371
- }
372
- });
373
-
374
- // ── projects ────────────────────────────────────────────────────────────────
375
-
376
- program
377
- .command("projects")
378
- .description("List registered projects")
379
- .action(() => {
380
- const config = loadConfig();
381
- getDatabase(config.db_path);
382
- const parentOpts = program.opts();
383
-
384
- const projects = listProjects();
385
-
386
- if (parentOpts.json) {
387
- console.log(JSON.stringify(projects, null, 2));
388
- return;
389
- }
390
-
391
- if (projects.length === 0) {
392
- console.log(chalk.dim("No projects registered."));
393
- return;
394
- }
395
-
396
- for (const p of projects) {
397
- console.log(
398
- `${chalk.cyan(p.id.slice(0, 8))} ${chalk.bold(p.name)} — ${p.path}`
399
- );
400
- }
401
- });
402
-
403
- // ── init ────────────────────────────────────────────────────────────────────
404
-
405
- program
406
- .command("init")
407
- .description("Initialize .recordings/ in current directory")
408
- .action(() => {
409
- const { mkdirSync, writeFileSync, existsSync } = require("fs") as typeof import("fs");
410
- const { join } = require("path") as typeof import("path");
411
-
412
- const dir = join(process.cwd(), ".recordings");
413
- const audioDir = join(dir, "audio");
414
- const configFile = join(dir, "config.json");
415
-
416
- mkdirSync(audioDir, { recursive: true });
417
-
418
- if (!existsSync(configFile)) {
419
- const defaultConf = {
420
- transcription_model: "gpt-4o-mini-transcribe",
421
- enhancement_model: "gpt-4o",
422
- language: "en",
423
- auto_enhance: true,
424
- };
425
- writeFileSync(configFile, JSON.stringify(defaultConf, null, 2));
426
- }
427
-
428
- console.log(chalk.green("Initialized .recordings/ directory"));
429
- console.log(chalk.dim(" config: .recordings/config.json"));
430
- console.log(chalk.dim(" audio: .recordings/audio/"));
431
- console.log(chalk.dim(" db: .recordings/recordings.db"));
432
- });
433
-
434
- // ── check ───────────────────────────────────────────────────────────────────
435
-
436
- program
437
- .command("check")
438
- .description("Check system dependencies (sox, API keys)")
439
- .action(async () => {
440
- const config = loadConfig();
441
-
442
- // Check recording deps
443
- const deps = await checkRecordingDeps();
444
- if (deps.available) {
445
- console.log(chalk.green(`✓ Recording tool: ${deps.tool}`));
446
- } else {
447
- console.log(chalk.red(`✗ ${deps.message}`));
448
- }
449
-
450
- // Check API key
451
- if (config.openai_api_key) {
452
- console.log(
453
- chalk.green(`✓ OpenAI API key configured`)
454
- );
455
- } else {
456
- console.log(
457
- chalk.red(
458
- `✗ OpenAI API key not found. Set OPENAI_API_KEY env var or add to ~/.secrets`
459
- )
460
- );
461
- }
462
-
463
- // Check enhancement key
464
- const enhKey = config.enhancement_api_key || config.openai_api_key;
465
- if (enhKey) {
466
- console.log(
467
- chalk.green(`✓ Enhancement API key configured (model: ${config.enhancement_model})`)
468
- );
469
- } else {
470
- console.log(
471
- chalk.yellow(`⚠ Enhancement API key not configured — enhancement disabled`)
472
- );
473
- }
474
- });
475
-
476
- // ── listen ───────────────────────────────────────────────────────────────────
477
-
478
- program
479
- .command("listen")
480
- .description("Push-to-talk mode — press Space to start/stop recording, Esc to quit")
481
- .option("-t, --tags <tags>", "Comma-separated tags for all recordings")
482
- .option("--no-enhance", "Skip AI enhancement")
483
- .option("-l, --language <lang>", "Language code")
484
- .option("--copy", "Copy output to clipboard")
485
- .option("--paste", "Copy output to clipboard AND paste into frontmost app")
486
- .action(async (opts) => {
487
- const config = loadConfig();
488
- ensureDataDir(config);
489
- if (opts.language) config.language = opts.language;
490
- if (opts.noEnhance === false) config.auto_enhance = false;
491
-
492
- const deps = await checkRecordingDeps();
493
- if (!deps.available) {
494
- console.error(chalk.red(`Error: ${deps.message}`));
495
- process.exit(1);
496
- }
497
-
498
- if (!config.openai_api_key) {
499
- console.error(chalk.red("Error: OpenAI API key not configured."));
500
- process.exit(1);
501
- }
502
-
503
- const tags = opts.tags ? opts.tags.split(",").map((t: string) => t.trim()) : [];
504
- const parentOpts = program.opts();
505
-
506
- console.log(chalk.bold("\n Recordings — Push-to-Talk\n"));
507
- console.log(` ${chalk.yellow("Space")} Start/stop recording`);
508
- console.log(` ${chalk.yellow("Esc")} Quit\n`);
509
-
510
- let recording = false;
511
- let audioPath: string | null = null;
512
-
513
- process.stdin.setRawMode?.(true);
514
- process.stdin.resume();
515
- process.stdin.setEncoding("utf8");
516
-
517
- const cleanup = () => {
518
- process.stdin.setRawMode?.(false);
519
- process.stdin.pause();
520
- };
521
-
522
- process.stdin.on("data", async (key: string) => {
523
- // Esc
524
- if (key === "\u001b") {
525
- if (recording) {
526
- stopRecording();
527
- }
528
- cleanup();
529
- console.log(chalk.dim("\nBye."));
530
- process.exit(0);
531
- }
532
-
533
- // Ctrl+C
534
- if (key === "\u0003") {
535
- if (recording) {
536
- stopRecording();
537
- }
538
- cleanup();
539
- process.exit(0);
540
- }
541
-
542
- // Space
543
- if (key === " ") {
544
- if (!recording) {
545
- // Start recording
546
- try {
547
- audioPath = startRecording(config);
548
- recording = true;
549
- process.stdout.write(chalk.red(" ● Recording... ") + chalk.dim("(Space to stop)"));
550
- } catch (e) {
551
- console.error(chalk.red(`\n Error: ${e instanceof Error ? e.message : e}`));
552
- }
553
- } else {
554
- // Stop recording
555
- stopRecording();
556
- recording = false;
557
- process.stdout.write("\r" + " ".repeat(60) + "\r");
558
-
559
- if (!audioPath) return;
560
-
561
- process.stdout.write(chalk.blue(" Transcribing..."));
562
-
563
- try {
564
- const transcription = await transcribeAudio(audioPath, config);
565
- const processed = await processText(transcription.text, config);
566
-
567
- const output = processed.mode === "enhanced" ? processed.text : transcription.text;
568
-
569
- // Save to DB
570
- createRecording({
571
- audio_path: audioPath,
572
- raw_text: transcription.text,
573
- processed_text: processed.mode === "enhanced" ? processed.text : undefined,
574
- processing_mode: processed.mode,
575
- model_used: transcription.model,
576
- enhancement_model: processed.enhancement_model || undefined,
577
- duration_ms: transcription.duration_ms,
578
- language: transcription.language || undefined,
579
- tags,
580
- agent_id: parentOpts.agent || undefined,
581
- project_id: parentOpts.project || undefined,
582
- session_id: parentOpts.session || undefined,
583
- });
584
-
585
- // Clear line and show output
586
- process.stdout.write("\r" + " ".repeat(60) + "\r");
587
- const modeLabel = processed.mode === "enhanced"
588
- ? chalk.green(" [enhanced] ")
589
- : chalk.dim(" [raw] ");
590
- console.log(modeLabel + output);
591
-
592
- // Copy to clipboard / paste
593
- if (opts.copy || opts.paste) {
594
- try {
595
- const { execSync } = require("node:child_process") as typeof import("node:child_process");
596
- execSync("pbcopy", { input: output, stdio: ["pipe", "pipe", "pipe"] });
597
- if (opts.paste) {
598
- // Small delay then Cmd+V via osascript
599
- execSync(
600
- `osascript -e 'delay 0.1' -e 'tell application "System Events" to keystroke "v" using command down'`,
601
- { stdio: "pipe" }
602
- );
603
- }
604
- } catch {
605
- // Clipboard not available
606
- }
607
- }
608
-
609
- console.log("");
610
- } catch (e) {
611
- process.stdout.write("\r" + " ".repeat(60) + "\r");
612
- console.error(chalk.red(` Error: ${e instanceof Error ? e.message : e}\n`));
613
- }
614
- audioPath = null;
615
- }
616
- }
617
- });
618
- });
619
-
620
- // ── shortcut ────────────────────────────────────────────────────────────────
621
-
622
- program
623
- .command("shortcut")
624
- .description("Set up a global keyboard shortcut for recording (macOS)")
625
- .option("--raycast", "Generate Raycast script command")
626
- .option("--karabiner", "Set up Fn key via Karabiner-Elements")
627
- .option("--skhd", "Generate skhd hotkey config")
628
- .option("--hammerspoon", "Generate Hammerspoon config")
629
- .option("--script", "Just output the shell script path")
630
- .action((opts) => {
631
- const { writeFileSync, mkdirSync, chmodSync } = require("node:fs") as typeof import("node:fs");
632
- const { join: pathJoin } = require("node:path") as typeof import("node:path");
633
- const { homedir: getHome } = require("node:os") as typeof import("node:os");
634
- const home = getHome();
635
-
636
- const scriptDir = pathJoin(home, ".hasna", "recordings");
637
- mkdirSync(scriptDir, { recursive: true });
638
-
639
- const scriptPath = pathJoin(scriptDir, "record-toggle.sh");
640
- const pidFile = pathJoin(scriptDir, ".recording.pid");
641
- const recordingsBin = pathJoin(home, ".bun", "bin", "recordings");
642
-
643
- // Write the toggle script
644
- const script = `#!/bin/bash
645
- # Toggle recording on/off. Run this from a global hotkey.
646
- # Each press toggles: start recording -> stop + transcribe + copy to clipboard
647
- set -e
648
-
649
- PID_FILE="${pidFile}"
650
- RECORDINGS="${recordingsBin}"
651
-
652
- if [ -f "$PID_FILE" ]; then
653
- # Stop recording
654
- PID=$(cat "$PID_FILE")
655
- kill -INT "$PID" 2>/dev/null || true
656
- rm -f "$PID_FILE"
657
-
658
- # Find the most recent audio file
659
- AUDIO_DIR="${pathJoin(scriptDir, "audio")}"
660
- LATEST=$(ls -t "$AUDIO_DIR"/*.wav 2>/dev/null | head -1)
661
-
662
- if [ -n "$LATEST" ]; then
663
- # Transcribe and copy to clipboard
664
- OUTPUT=$("$RECORDINGS" transcribe "$LATEST" --json 2>/dev/null)
665
- TEXT=$(echo "$OUTPUT" | grep -o '"processed_text":"[^"]*"' | head -1 | cut -d'"' -f4)
666
- if [ -z "$TEXT" ]; then
667
- TEXT=$(echo "$OUTPUT" | grep -o '"raw_text":"[^"]*"' | head -1 | cut -d'"' -f4)
668
- fi
669
- if [ -n "$TEXT" ]; then
670
- echo -n "$TEXT" | pbcopy
671
- # Optional: paste into frontmost app
672
- # osascript -e 'delay 0.1' -e 'tell application "System Events" to keystroke "v" using command down'
673
- fi
674
- fi
675
-
676
- # Notification
677
- osascript -e 'display notification "Recording saved and copied to clipboard" with title "Recordings"' 2>/dev/null || true
678
- else
679
- # Start recording in background
680
- mkdir -p "${pathJoin(scriptDir, "audio")}"
681
- rec -r 16000 -c 1 -b 16 "${pathJoin(scriptDir, "audio")}/recording-$(date +%Y%m%dT%H%M%S).wav" trim 0 300 &
682
- echo $! > "$PID_FILE"
683
-
684
- # Notification
685
- osascript -e 'display notification "Recording started..." with title "Recordings"' 2>/dev/null || true
686
- fi
687
- `;
688
- writeFileSync(scriptPath, script, "utf-8");
689
- chmodSync(scriptPath, 0o755);
690
-
691
- if (opts.karabiner) {
692
- const karabinerDir = pathJoin(home, ".config", "karabiner", "assets", "complex_modifications");
693
- mkdirSync(karabinerDir, { recursive: true });
694
-
695
- const rule = {
696
- title: "Recordings — Fn key to toggle recording",
697
- rules: [
698
- {
699
- description: "Fn key toggles speech recording (open-recordings)",
700
- manipulators: [
701
- {
702
- type: "basic",
703
- from: {
704
- key_code: "fn",
705
- modifiers: { optional: ["any"] },
706
- },
707
- to: [
708
- {
709
- shell_command: scriptPath,
710
- },
711
- ],
712
- },
713
- ],
714
- },
715
- ],
716
- };
717
-
718
- const karabinerPath = pathJoin(karabinerDir, "recordings-fn.json");
719
- writeFileSync(karabinerPath, JSON.stringify(rule, null, 2) + "\n", "utf-8");
720
-
721
- console.log(chalk.green("Karabiner-Elements rule created!"));
722
- console.log(chalk.dim(` ${karabinerPath}\n`));
723
- console.log("To activate:");
724
- console.log(" 1. Open Karabiner-Elements");
725
- console.log(" 2. Go to Complex Modifications tab");
726
- console.log(" 3. Click Add Predefined Rule");
727
- console.log(' 4. Enable "Fn key toggles speech recording"');
728
- console.log(chalk.dim("\n Press Fn to start recording, Fn again to stop + copy to clipboard"));
729
- return;
730
- }
731
-
732
- if (opts.raycast) {
733
- const raycastDir = pathJoin(home, ".config", "raycast", "script-commands");
734
- mkdirSync(raycastDir, { recursive: true });
735
- const raycastScript = `#!/bin/bash
736
-
737
- # Required parameters:
738
- # @raycast.schemaVersion 1
739
- # @raycast.title Toggle Recording
740
- # @raycast.mode silent
741
- # @raycast.packageName Recordings
742
-
743
- # Optional parameters:
744
- # @raycast.icon 🎙️
745
-
746
- ${scriptPath}
747
- `;
748
- const raycastPath = pathJoin(raycastDir, "toggle-recording.sh");
749
- writeFileSync(raycastPath, raycastScript, "utf-8");
750
- chmodSync(raycastPath, 0o755);
751
- console.log(chalk.green("Raycast script command created!"));
752
- console.log(chalk.dim(` ${raycastPath}`));
753
- console.log(chalk.dim(" Open Raycast > Script Commands > reload to see it"));
754
- console.log(chalk.dim(" Then assign a hotkey in Raycast preferences"));
755
- return;
756
- }
757
-
758
- if (opts.skhd) {
759
- console.log(chalk.bold("Add to ~/.skhdrc:\n"));
760
- console.log(chalk.cyan(` fn - space : ${scriptPath}`));
761
- console.log(chalk.dim("\n Then reload: skhd --restart-service"));
762
- return;
763
- }
764
-
765
- if (opts.hammerspoon) {
766
- console.log(chalk.bold("Add to ~/.hammerspoon/init.lua:\n"));
767
- console.log(chalk.cyan(` hs.hotkey.bind({"ctrl"}, "space", function()
768
- hs.execute("${scriptPath}")
769
- end)`));
770
- console.log(chalk.dim("\n Then reload Hammerspoon config"));
771
- return;
772
- }
773
-
774
- // Default: show all options
775
- console.log(chalk.bold("Global shortcut script created:"));
776
- console.log(chalk.cyan(` ${scriptPath}\n`));
777
- console.log("Bind it to a hotkey using any of these:\n");
778
-
779
- console.log(chalk.bold(" Karabiner-Elements") + chalk.dim(" (for Fn key specifically)"));
780
- console.log(` brew install --cask karabiner-elements`);
781
- console.log(` recordings shortcut --karabiner\n`);
782
-
783
- console.log(chalk.bold(" Raycast"));
784
- console.log(` recordings shortcut --raycast\n`);
785
-
786
- console.log(chalk.bold(" skhd"));
787
- console.log(` recordings shortcut --skhd\n`);
788
-
789
- console.log(chalk.bold(" Hammerspoon"));
790
- console.log(` recordings shortcut --hammerspoon\n`);
791
-
792
- console.log(chalk.bold(" macOS Automator"));
793
- console.log(` 1. Open Automator > Quick Action`);
794
- console.log(` 2. Add "Run Shell Script" action`);
795
- console.log(` 3. Paste: ${scriptPath}`);
796
- console.log(` 4. Save as "Toggle Recording"`);
797
- console.log(` 5. System Settings > Keyboard > Shortcuts > Services`);
798
- console.log(` 6. Assign a shortcut to "Toggle Recording"\n`);
799
-
800
- console.log(chalk.bold(" Alfred"));
801
- console.log(` Create a workflow with a Hotkey trigger → Run Script: ${scriptPath}\n`);
802
- });
803
-
804
- // ── Formatting helpers ──────────────────────────────────────────────────────
805
-
806
- function formatRecordingLine(r: Recording): string {
807
- const id = chalk.cyan(r.id.slice(0, 8));
808
- const mode =
809
- r.processing_mode === "enhanced"
810
- ? chalk.green("enhanced")
811
- : chalk.dim("raw");
812
- const text = (r.processed_text || r.raw_text).slice(0, 80);
813
- const date = chalk.dim(r.created_at.slice(0, 16));
814
- const tags =
815
- r.tags.length > 0
816
- ? chalk.yellow(` [${r.tags.join(", ")}]`)
817
- : "";
818
-
819
- return `${id} ${mode} ${date}${tags}\n ${text}${text.length >= 80 ? "..." : ""}`;
820
- }
821
-
822
- function formatRecordingDetail(r: Recording): string {
823
- const lines: string[] = [
824
- chalk.bold(`Recording ${r.id.slice(0, 8)}`),
825
- "",
826
- ` Mode: ${r.processing_mode === "enhanced" ? chalk.green("enhanced") : chalk.dim("raw")}`,
827
- ` Model: ${r.model_used}`,
828
- ];
829
-
830
- if (r.enhancement_model) {
831
- lines.push(` Enhanced: ${r.enhancement_model}`);
832
- }
833
- if (r.duration_ms) {
834
- lines.push(` Duration: ${(r.duration_ms / 1000).toFixed(1)}s`);
835
- }
836
- if (r.language) {
837
- lines.push(` Language: ${r.language}`);
838
- }
839
- if (r.audio_path) {
840
- lines.push(` Audio: ${r.audio_path}`);
841
- }
842
- if (r.tags.length > 0) {
843
- lines.push(` Tags: ${r.tags.join(", ")}`);
844
- }
845
-
846
- lines.push(` Created: ${r.created_at}`);
847
- lines.push("");
848
- lines.push(chalk.bold("Raw text:"));
849
- lines.push(r.raw_text);
850
-
851
- if (r.processed_text && r.processed_text !== r.raw_text) {
852
- lines.push("");
853
- lines.push(chalk.bold("Enhanced text:"));
854
- lines.push(r.processed_text);
855
- }
856
-
857
- return lines.join("\n");
858
- }
859
-
860
- // ── mcp ─────────────────────────────────────────────────────────────────────
861
-
862
- program
863
- .command("mcp")
864
- .description("Install recordings MCP server into Claude Code, Codex, or Gemini")
865
- .option("--claude", "Install into Claude Code (via `claude mcp add`)")
866
- .option("--codex", "Install into Codex (~/.codex/config.toml)")
867
- .option("--gemini", "Install into Gemini (~/.gemini/settings.json)")
868
- .option("--all", "Install into all supported agents")
869
- .option("--uninstall", "Remove recordings MCP from config")
870
- .action(async (opts: { claude?: boolean; codex?: boolean; gemini?: boolean; all?: boolean; uninstall?: boolean }) => {
871
- const { readFileSync, writeFileSync, existsSync: fileExists } = require("node:fs") as typeof import("node:fs");
872
- const { join: pathJoin } = require("node:path") as typeof import("node:path");
873
- const { homedir: getHome } = require("node:os") as typeof import("node:os");
874
- const { execSync } = require("node:child_process") as typeof import("node:child_process");
875
- const home = getHome();
876
-
877
- const mcpCmd = process.argv[0]?.includes("bun")
878
- ? pathJoin(home, ".bun", "bin", "recordings-mcp")
879
- : "recordings-mcp";
880
-
881
- const targets = opts.all
882
- ? ["claude", "codex", "gemini"]
883
- : [
884
- opts.claude ? "claude" : null,
885
- opts.codex ? "codex" : null,
886
- opts.gemini ? "gemini" : null,
887
- ].filter(Boolean) as string[];
888
-
889
- if (targets.length === 0) {
890
- console.log(chalk.yellow("Specify a target: --claude, --codex, --gemini, or --all"));
891
- console.log(chalk.gray("Example: recordings mcp --all"));
892
- return;
893
- }
894
-
895
- const action = opts.uninstall ? "Removed from" : "Installed into";
896
-
897
- for (const target of targets) {
898
- try {
899
- // Claude Code: use `claude mcp add/remove` — stores in ~/.claude.json (user scope)
900
- if (target === "claude") {
901
- if (opts.uninstall) {
902
- execSync("claude mcp remove recordings", { stdio: "pipe" });
903
- } else {
904
- // Remove first if it exists, then add fresh
905
- try { execSync("claude mcp remove recordings", { stdio: "pipe" }); } catch { /* ignore if not found */ }
906
- execSync(
907
- `claude mcp add --transport stdio --scope user recordings -- ${mcpCmd}`,
908
- { stdio: "pipe" }
909
- );
910
- }
911
- console.log(chalk.green(`${action} Claude Code (user scope in ~/.claude.json)`));
912
- }
913
-
914
- if (target === "codex") {
915
- const configPath = pathJoin(home, ".codex", "config.toml");
916
- if (fileExists(configPath)) {
917
- let content = readFileSync(configPath, "utf-8");
918
- if (opts.uninstall) {
919
- content = content.replace(/\n\[mcp_servers\.recordings\]\ncommand = "[^"]*"\nargs = \[\]\n?/g, "\n");
920
- } else if (!content.includes("[mcp_servers.recordings]")) {
921
- content += `\n[mcp_servers.recordings]\ncommand = "${mcpCmd}"\nargs = []\n`;
922
- }
923
- writeFileSync(configPath, content, "utf-8");
924
- console.log(chalk.green(`${action} Codex: ${configPath}`));
925
- } else {
926
- console.log(chalk.yellow(`Codex config not found: ${configPath}`));
927
- }
928
- }
929
-
930
- if (target === "gemini") {
931
- const configPath = pathJoin(home, ".gemini", "settings.json");
932
- let config: Record<string, unknown> = {};
933
- if (fileExists(configPath)) {
934
- config = JSON.parse(readFileSync(configPath, "utf-8")) as Record<string, unknown>;
935
- }
936
- const servers = (config["mcpServers"] || {}) as Record<string, unknown>;
937
- if (opts.uninstall) {
938
- delete servers["recordings"];
939
- } else {
940
- servers["recordings"] = { command: mcpCmd, args: [] };
941
- }
942
- config["mcpServers"] = servers;
943
- writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", "utf-8");
944
- console.log(chalk.green(`${action} Gemini: ${configPath}`));
945
- }
946
- } catch (e) {
947
- console.error(chalk.red(`Failed for ${target}: ${e instanceof Error ? e.message : String(e)}`));
948
- }
949
- }
950
- });
951
-
952
- // ── remove/uninstall ─────────────────────────────────────────────────────────
953
-
954
- program
955
- .command("remove <id>")
956
- .alias("rm")
957
- .alias("uninstall")
958
- .description("Delete a recording by ID")
959
- .action((id: string) => {
960
- const deleted = deleteRecording(id);
961
- if (deleted) {
962
- console.log(chalk.green(`✓ Recording ${id} deleted`));
963
- } else {
964
- console.error(chalk.red(`Recording not found: ${id}`));
965
- process.exit(1);
966
- }
967
- });
968
-
969
- // ── Feedback ────────────────────────────────────────────────────────────────
970
-
971
- program
972
- .command("feedback <message>")
973
- .description("Send feedback")
974
- .option("--email <email>", "Contact email")
975
- .option("--category <category>", "Category: bug, feature, general")
976
- .action((message: string, opts: { email?: string; category?: string }) => {
977
- const adapter = getAdapter();
978
- const pkg = require("../../package.json");
979
- adapter.run(
980
- "INSERT INTO feedback (message, email, category, version) VALUES (?, ?, ?, ?)",
981
- message, opts.email || null, opts.category || "general", pkg.version
982
- );
983
- console.log(chalk.green("Feedback saved. Thank you!"));
984
- });
985
-
986
- // ── Run ─────────────────────────────────────────────────────────────────────
987
-
988
- program.parse();