@hasna/recordings 0.0.3
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/.claude/scheduled_tasks.lock +1 -0
- package/LICENSE +21 -0
- package/dist/cli/index.js +1446 -0
- package/dist/mcp/index.js +4882 -0
- package/package.json +46 -0
- package/src/__tests__/agents.test.ts +136 -0
- package/src/__tests__/config.test.ts +252 -0
- package/src/__tests__/database.test.ts +167 -0
- package/src/__tests__/enhancer.test.ts +574 -0
- package/src/__tests__/preload.ts +4 -0
- package/src/__tests__/projects.test.ts +109 -0
- package/src/__tests__/recorder.test.ts +278 -0
- package/src/__tests__/recordings.test.ts +353 -0
- package/src/__tests__/transcriber.test.ts +322 -0
- package/src/__tests__/types.test.ts +75 -0
- package/src/cli/index.ts +1078 -0
- package/src/db/agents.ts +81 -0
- package/src/db/database.ts +126 -0
- package/src/db/projects.ts +71 -0
- package/src/db/recordings.ts +219 -0
- package/src/index.ts +81 -0
- package/src/lib/config.ts +166 -0
- package/src/lib/enhancer.ts +167 -0
- package/src/lib/recorder.ts +198 -0
- package/src/lib/transcriber.ts +105 -0
- package/src/mcp/index.ts +405 -0
- package/src/native/RecordingsHelper.swift +352 -0
- package/src/types/index.ts +138 -0
- package/tsconfig.json +21 -0
|
@@ -0,0 +1,1446 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// @bun
|
|
3
|
+
var __require = import.meta.require;
|
|
4
|
+
|
|
5
|
+
// src/cli/index.ts
|
|
6
|
+
import { Command } from "commander";
|
|
7
|
+
import chalk from "chalk";
|
|
8
|
+
|
|
9
|
+
// src/lib/config.ts
|
|
10
|
+
import { existsSync, readFileSync } from "fs";
|
|
11
|
+
import { join } from "path";
|
|
12
|
+
import { homedir } from "os";
|
|
13
|
+
var DEFAULT_CONFIG = {
|
|
14
|
+
openai_api_key: "",
|
|
15
|
+
enhancement_api_key: "",
|
|
16
|
+
transcription_model: "gpt-4o-mini-transcribe",
|
|
17
|
+
enhancement_model: "gpt-4o",
|
|
18
|
+
language: "en",
|
|
19
|
+
audio_format: "wav",
|
|
20
|
+
sample_rate: 16000,
|
|
21
|
+
record_command: "sox",
|
|
22
|
+
hotkey: "space",
|
|
23
|
+
auto_enhance: true,
|
|
24
|
+
enhance_triggers: [
|
|
25
|
+
"say it better",
|
|
26
|
+
"rewrite this",
|
|
27
|
+
"make it sound",
|
|
28
|
+
"clean this up",
|
|
29
|
+
"fix this",
|
|
30
|
+
"rephrase",
|
|
31
|
+
"write it properly",
|
|
32
|
+
"make it professional",
|
|
33
|
+
"improve this",
|
|
34
|
+
"polish this"
|
|
35
|
+
],
|
|
36
|
+
db_path: "",
|
|
37
|
+
audio_dir: "",
|
|
38
|
+
max_recording_seconds: 300
|
|
39
|
+
};
|
|
40
|
+
function loadConfig(configPath) {
|
|
41
|
+
const config = { ...DEFAULT_CONFIG };
|
|
42
|
+
const filePath = configPath || findConfigFile() || join(getDataDir(), "config.json");
|
|
43
|
+
if (existsSync(filePath)) {
|
|
44
|
+
try {
|
|
45
|
+
const raw = readFileSync(filePath, "utf-8");
|
|
46
|
+
const fileConfig = JSON.parse(raw);
|
|
47
|
+
Object.assign(config, fileConfig);
|
|
48
|
+
} catch {}
|
|
49
|
+
}
|
|
50
|
+
if (process.env.OPENAI_API_KEY) {
|
|
51
|
+
config.openai_api_key = process.env.OPENAI_API_KEY;
|
|
52
|
+
}
|
|
53
|
+
if (process.env.RECORDINGS_API_KEY) {
|
|
54
|
+
config.openai_api_key = process.env.RECORDINGS_API_KEY;
|
|
55
|
+
}
|
|
56
|
+
if (process.env.RECORDINGS_ENHANCEMENT_KEY) {
|
|
57
|
+
config.enhancement_api_key = process.env.RECORDINGS_ENHANCEMENT_KEY;
|
|
58
|
+
}
|
|
59
|
+
if (process.env.RECORDINGS_MODEL) {
|
|
60
|
+
config.transcription_model = process.env.RECORDINGS_MODEL;
|
|
61
|
+
}
|
|
62
|
+
if (process.env.RECORDINGS_ENHANCEMENT_MODEL) {
|
|
63
|
+
config.enhancement_model = process.env.RECORDINGS_ENHANCEMENT_MODEL;
|
|
64
|
+
}
|
|
65
|
+
if (process.env.RECORDINGS_LANGUAGE) {
|
|
66
|
+
config.language = process.env.RECORDINGS_LANGUAGE;
|
|
67
|
+
}
|
|
68
|
+
if (process.env.RECORDINGS_DB_PATH) {
|
|
69
|
+
config.db_path = process.env.RECORDINGS_DB_PATH;
|
|
70
|
+
}
|
|
71
|
+
if (process.env.RECORDINGS_AUDIO_DIR) {
|
|
72
|
+
config.audio_dir = process.env.RECORDINGS_AUDIO_DIR;
|
|
73
|
+
}
|
|
74
|
+
if (process.env.RECORDINGS_MAX_SECONDS) {
|
|
75
|
+
config.max_recording_seconds = parseInt(process.env.RECORDINGS_MAX_SECONDS, 10);
|
|
76
|
+
}
|
|
77
|
+
if (!config.openai_api_key) {
|
|
78
|
+
config.openai_api_key = loadSecretKey("OPENAI_API_KEY");
|
|
79
|
+
}
|
|
80
|
+
if (!config.enhancement_api_key) {
|
|
81
|
+
config.enhancement_api_key = config.openai_api_key || loadSecretKey("OPENAI_API_KEY");
|
|
82
|
+
}
|
|
83
|
+
if (!config.db_path) {
|
|
84
|
+
config.db_path = join(getDataDir(), "recordings.db");
|
|
85
|
+
}
|
|
86
|
+
if (!config.audio_dir) {
|
|
87
|
+
config.audio_dir = join(getDataDir(), "audio");
|
|
88
|
+
}
|
|
89
|
+
return config;
|
|
90
|
+
}
|
|
91
|
+
function findConfigFile() {
|
|
92
|
+
let dir = process.cwd();
|
|
93
|
+
const root = "/";
|
|
94
|
+
while (dir !== root) {
|
|
95
|
+
const candidate = join(dir, ".recordings", "config.json");
|
|
96
|
+
if (existsSync(candidate))
|
|
97
|
+
return candidate;
|
|
98
|
+
const parent = join(dir, "..");
|
|
99
|
+
if (parent === dir)
|
|
100
|
+
break;
|
|
101
|
+
dir = parent;
|
|
102
|
+
}
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
function getDataDir() {
|
|
106
|
+
let dir = process.cwd();
|
|
107
|
+
const root = "/";
|
|
108
|
+
while (dir !== root) {
|
|
109
|
+
const candidate = join(dir, ".recordings");
|
|
110
|
+
if (existsSync(candidate))
|
|
111
|
+
return candidate;
|
|
112
|
+
const parent = join(dir, "..");
|
|
113
|
+
if (parent === dir)
|
|
114
|
+
break;
|
|
115
|
+
dir = parent;
|
|
116
|
+
}
|
|
117
|
+
return join(homedir(), ".recordings");
|
|
118
|
+
}
|
|
119
|
+
function loadSecretKey(keyName) {
|
|
120
|
+
const secretsPath = join(homedir(), ".secrets");
|
|
121
|
+
if (!existsSync(secretsPath))
|
|
122
|
+
return "";
|
|
123
|
+
try {
|
|
124
|
+
const content = readFileSync(secretsPath, "utf-8");
|
|
125
|
+
const match = content.match(new RegExp(`export\\s+${keyName}\\s*=\\s*"([^"]+)"`));
|
|
126
|
+
if (match)
|
|
127
|
+
return match[1];
|
|
128
|
+
const match2 = content.match(new RegExp(`export\\s+${keyName}\\s*=\\s*'([^']+)'`));
|
|
129
|
+
if (match2)
|
|
130
|
+
return match2[1];
|
|
131
|
+
const match3 = content.match(new RegExp(`${keyName}\\s*=\\s*(.+)`));
|
|
132
|
+
if (match3)
|
|
133
|
+
return match3[1].trim().replace(/^["']|["']$/g, "");
|
|
134
|
+
} catch {}
|
|
135
|
+
return "";
|
|
136
|
+
}
|
|
137
|
+
function ensureDataDir(config) {
|
|
138
|
+
const { mkdirSync } = __require("fs");
|
|
139
|
+
mkdirSync(config.audio_dir, { recursive: true });
|
|
140
|
+
const dbDir = config.db_path.substring(0, config.db_path.lastIndexOf("/"));
|
|
141
|
+
if (dbDir)
|
|
142
|
+
mkdirSync(dbDir, { recursive: true });
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// src/db/database.ts
|
|
146
|
+
import { Database } from "bun:sqlite";
|
|
147
|
+
import { mkdirSync } from "fs";
|
|
148
|
+
import { dirname } from "path";
|
|
149
|
+
var _db = null;
|
|
150
|
+
var MIGRATIONS = [
|
|
151
|
+
`
|
|
152
|
+
CREATE TABLE IF NOT EXISTS projects (
|
|
153
|
+
id TEXT PRIMARY KEY,
|
|
154
|
+
name TEXT NOT NULL,
|
|
155
|
+
path TEXT UNIQUE NOT NULL,
|
|
156
|
+
description TEXT,
|
|
157
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
158
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
CREATE TABLE IF NOT EXISTS agents (
|
|
162
|
+
id TEXT PRIMARY KEY,
|
|
163
|
+
name TEXT NOT NULL UNIQUE,
|
|
164
|
+
description TEXT,
|
|
165
|
+
role TEXT DEFAULT 'agent',
|
|
166
|
+
metadata TEXT DEFAULT '{}',
|
|
167
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
168
|
+
last_seen_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
CREATE TABLE IF NOT EXISTS recordings (
|
|
172
|
+
id TEXT PRIMARY KEY,
|
|
173
|
+
audio_path TEXT,
|
|
174
|
+
raw_text TEXT NOT NULL,
|
|
175
|
+
processed_text TEXT,
|
|
176
|
+
processing_mode TEXT NOT NULL DEFAULT 'raw' CHECK(processing_mode IN ('raw', 'enhanced')),
|
|
177
|
+
model_used TEXT NOT NULL DEFAULT 'gpt-4o-mini-transcribe',
|
|
178
|
+
enhancement_model TEXT,
|
|
179
|
+
duration_ms INTEGER DEFAULT 0,
|
|
180
|
+
language TEXT,
|
|
181
|
+
tags TEXT DEFAULT '[]',
|
|
182
|
+
agent_id TEXT REFERENCES agents(id) ON DELETE SET NULL,
|
|
183
|
+
project_id TEXT REFERENCES projects(id) ON DELETE SET NULL,
|
|
184
|
+
session_id TEXT,
|
|
185
|
+
metadata TEXT DEFAULT '{}',
|
|
186
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
CREATE TABLE IF NOT EXISTS recording_tags (
|
|
190
|
+
recording_id TEXT NOT NULL REFERENCES recordings(id) ON DELETE CASCADE,
|
|
191
|
+
tag TEXT NOT NULL,
|
|
192
|
+
PRIMARY KEY (recording_id, tag)
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
CREATE TABLE IF NOT EXISTS _migrations (
|
|
196
|
+
id INTEGER PRIMARY KEY,
|
|
197
|
+
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
CREATE INDEX IF NOT EXISTS idx_recordings_agent ON recordings(agent_id);
|
|
201
|
+
CREATE INDEX IF NOT EXISTS idx_recordings_project ON recordings(project_id);
|
|
202
|
+
CREATE INDEX IF NOT EXISTS idx_recordings_session ON recordings(session_id);
|
|
203
|
+
CREATE INDEX IF NOT EXISTS idx_recordings_created ON recordings(created_at);
|
|
204
|
+
CREATE INDEX IF NOT EXISTS idx_recordings_mode ON recordings(processing_mode);
|
|
205
|
+
CREATE INDEX IF NOT EXISTS idx_recording_tags_tag ON recording_tags(tag);
|
|
206
|
+
`
|
|
207
|
+
];
|
|
208
|
+
function getDatabase(dbPath) {
|
|
209
|
+
if (_db)
|
|
210
|
+
return _db;
|
|
211
|
+
const path = dbPath || loadConfig().db_path;
|
|
212
|
+
const dir = dirname(path);
|
|
213
|
+
mkdirSync(dir, { recursive: true });
|
|
214
|
+
_db = new Database(path, { create: true });
|
|
215
|
+
_db.run("PRAGMA journal_mode = WAL");
|
|
216
|
+
_db.run("PRAGMA busy_timeout = 5000");
|
|
217
|
+
_db.run("PRAGMA foreign_keys = ON");
|
|
218
|
+
runMigrations(_db);
|
|
219
|
+
return _db;
|
|
220
|
+
}
|
|
221
|
+
function runMigrations(db) {
|
|
222
|
+
db.run(`
|
|
223
|
+
CREATE TABLE IF NOT EXISTS _migrations (
|
|
224
|
+
id INTEGER PRIMARY KEY,
|
|
225
|
+
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
226
|
+
)
|
|
227
|
+
`);
|
|
228
|
+
const result = db.query("SELECT MAX(id) as max_id FROM _migrations").get();
|
|
229
|
+
const currentLevel = result?.max_id ?? -1;
|
|
230
|
+
for (let i = currentLevel + 1;i < MIGRATIONS.length; i++) {
|
|
231
|
+
db.run(MIGRATIONS[i]);
|
|
232
|
+
db.query("INSERT INTO _migrations (id) VALUES (?)").run(i);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// src/db/recordings.ts
|
|
237
|
+
function parseRow(row) {
|
|
238
|
+
return {
|
|
239
|
+
id: row["id"],
|
|
240
|
+
audio_path: row["audio_path"] || null,
|
|
241
|
+
raw_text: row["raw_text"],
|
|
242
|
+
processed_text: row["processed_text"] || null,
|
|
243
|
+
processing_mode: row["processing_mode"] || "raw",
|
|
244
|
+
model_used: row["model_used"] || "gpt-4o-mini-transcribe",
|
|
245
|
+
enhancement_model: row["enhancement_model"] || null,
|
|
246
|
+
duration_ms: row["duration_ms"] || 0,
|
|
247
|
+
language: row["language"] || null,
|
|
248
|
+
tags: JSON.parse(row["tags"] || "[]"),
|
|
249
|
+
agent_id: row["agent_id"] || null,
|
|
250
|
+
project_id: row["project_id"] || null,
|
|
251
|
+
session_id: row["session_id"] || null,
|
|
252
|
+
metadata: JSON.parse(row["metadata"] || "{}"),
|
|
253
|
+
created_at: row["created_at"]
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
function createRecording(input, db) {
|
|
257
|
+
const d = db || getDatabase();
|
|
258
|
+
const id = crypto.randomUUID();
|
|
259
|
+
const tagsJson = JSON.stringify(input.tags || []);
|
|
260
|
+
const metadataJson = JSON.stringify(input.metadata || {});
|
|
261
|
+
d.query(`INSERT INTO recordings (id, audio_path, raw_text, processed_text, processing_mode, model_used, enhancement_model, duration_ms, language, tags, agent_id, project_id, session_id, metadata)
|
|
262
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(id, input.audio_path || null, input.raw_text, input.processed_text || null, input.processing_mode || "raw", input.model_used || "gpt-4o-mini-transcribe", input.enhancement_model || null, input.duration_ms || 0, input.language || null, tagsJson, input.agent_id || null, input.project_id || null, input.session_id || null, metadataJson);
|
|
263
|
+
if (input.tags && input.tags.length > 0) {
|
|
264
|
+
const insertTag = d.query("INSERT OR IGNORE INTO recording_tags (recording_id, tag) VALUES (?, ?)");
|
|
265
|
+
for (const tag of input.tags) {
|
|
266
|
+
insertTag.run(id, tag);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
return getRecording(id, d);
|
|
270
|
+
}
|
|
271
|
+
function getRecording(id, db) {
|
|
272
|
+
const d = db || getDatabase();
|
|
273
|
+
let row = d.query("SELECT * FROM recordings WHERE id = ?").get(id);
|
|
274
|
+
if (!row) {
|
|
275
|
+
row = d.query("SELECT * FROM recordings WHERE id LIKE ? || '%'").get(id);
|
|
276
|
+
}
|
|
277
|
+
return row ? parseRow(row) : null;
|
|
278
|
+
}
|
|
279
|
+
function listRecordings(filter, db) {
|
|
280
|
+
const d = db || getDatabase();
|
|
281
|
+
const conditions = [];
|
|
282
|
+
const params = [];
|
|
283
|
+
if (filter?.agent_id) {
|
|
284
|
+
conditions.push("agent_id = ?");
|
|
285
|
+
params.push(filter.agent_id);
|
|
286
|
+
}
|
|
287
|
+
if (filter?.project_id) {
|
|
288
|
+
conditions.push("project_id = ?");
|
|
289
|
+
params.push(filter.project_id);
|
|
290
|
+
}
|
|
291
|
+
if (filter?.session_id) {
|
|
292
|
+
conditions.push("session_id = ?");
|
|
293
|
+
params.push(filter.session_id);
|
|
294
|
+
}
|
|
295
|
+
if (filter?.processing_mode) {
|
|
296
|
+
conditions.push("processing_mode = ?");
|
|
297
|
+
params.push(filter.processing_mode);
|
|
298
|
+
}
|
|
299
|
+
if (filter?.tags && filter.tags.length > 0) {
|
|
300
|
+
for (const tag of filter.tags) {
|
|
301
|
+
conditions.push("id IN (SELECT recording_id FROM recording_tags WHERE tag = ?)");
|
|
302
|
+
params.push(tag);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
if (filter?.search) {
|
|
306
|
+
conditions.push("(raw_text LIKE ? OR processed_text LIKE ? OR tags LIKE ?)");
|
|
307
|
+
const q = `%${filter.search}%`;
|
|
308
|
+
params.push(q, q, q);
|
|
309
|
+
}
|
|
310
|
+
if (filter?.since) {
|
|
311
|
+
conditions.push("created_at >= ?");
|
|
312
|
+
params.push(filter.since);
|
|
313
|
+
}
|
|
314
|
+
if (filter?.until) {
|
|
315
|
+
conditions.push("created_at <= ?");
|
|
316
|
+
params.push(filter.until);
|
|
317
|
+
}
|
|
318
|
+
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
319
|
+
const limit = filter?.limit || 50;
|
|
320
|
+
const offset = filter?.offset || 0;
|
|
321
|
+
const sql = `SELECT * FROM recordings ${where} ORDER BY created_at DESC LIMIT ? OFFSET ?`;
|
|
322
|
+
params.push(limit, offset);
|
|
323
|
+
const rows = d.query(sql).all(...params);
|
|
324
|
+
return rows.map(parseRow);
|
|
325
|
+
}
|
|
326
|
+
function deleteRecording(id, db) {
|
|
327
|
+
const d = db || getDatabase();
|
|
328
|
+
const result = d.query("DELETE FROM recordings WHERE id = ?").run(id);
|
|
329
|
+
return result.changes > 0;
|
|
330
|
+
}
|
|
331
|
+
function searchRecordings(query, filter, db) {
|
|
332
|
+
return listRecordings({ ...filter, search: query }, db);
|
|
333
|
+
}
|
|
334
|
+
function getRecordingStats(db) {
|
|
335
|
+
const d = db || getDatabase();
|
|
336
|
+
const total = d.query("SELECT COUNT(*) as c FROM recordings").get().c;
|
|
337
|
+
const raw = d.query("SELECT COUNT(*) as c FROM recordings WHERE processing_mode = 'raw'").get().c;
|
|
338
|
+
const enhanced = d.query("SELECT COUNT(*) as c FROM recordings WHERE processing_mode = 'enhanced'").get().c;
|
|
339
|
+
const totalDuration = d.query("SELECT COALESCE(SUM(duration_ms), 0) as d FROM recordings").get().d;
|
|
340
|
+
const modelRows = d.query("SELECT model_used, COUNT(*) as c FROM recordings GROUP BY model_used").all();
|
|
341
|
+
const byModel = {};
|
|
342
|
+
for (const row of modelRows) {
|
|
343
|
+
byModel[row.model_used] = row.c;
|
|
344
|
+
}
|
|
345
|
+
return {
|
|
346
|
+
total,
|
|
347
|
+
raw,
|
|
348
|
+
enhanced,
|
|
349
|
+
total_duration_ms: totalDuration,
|
|
350
|
+
by_model: byModel
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// src/db/agents.ts
|
|
355
|
+
function parseAgent(row) {
|
|
356
|
+
return {
|
|
357
|
+
id: row["id"],
|
|
358
|
+
name: row["name"],
|
|
359
|
+
description: row["description"] || null,
|
|
360
|
+
role: row["role"] || "agent",
|
|
361
|
+
metadata: JSON.parse(row["metadata"] || "{}"),
|
|
362
|
+
created_at: row["created_at"],
|
|
363
|
+
last_seen_at: row["last_seen_at"]
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
function listAgents(db) {
|
|
367
|
+
const d = db || getDatabase();
|
|
368
|
+
const rows = d.query("SELECT * FROM agents ORDER BY last_seen_at DESC").all();
|
|
369
|
+
return rows.map(parseAgent);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// src/db/projects.ts
|
|
373
|
+
function parseProject(row) {
|
|
374
|
+
return {
|
|
375
|
+
id: row["id"],
|
|
376
|
+
name: row["name"],
|
|
377
|
+
path: row["path"],
|
|
378
|
+
description: row["description"] || null,
|
|
379
|
+
created_at: row["created_at"],
|
|
380
|
+
updated_at: row["updated_at"]
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
function listProjects(db) {
|
|
384
|
+
const d = db || getDatabase();
|
|
385
|
+
const rows = d.query("SELECT * FROM projects ORDER BY updated_at DESC").all();
|
|
386
|
+
return rows.map(parseProject);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// src/lib/recorder.ts
|
|
390
|
+
import { spawn } from "child_process";
|
|
391
|
+
import { join as join2 } from "path";
|
|
392
|
+
import { existsSync as existsSync2 } from "fs";
|
|
393
|
+
|
|
394
|
+
// src/types/index.ts
|
|
395
|
+
class RecordingError extends Error {
|
|
396
|
+
constructor(message) {
|
|
397
|
+
super(message);
|
|
398
|
+
this.name = "RecordingError";
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
class TranscriptionError extends Error {
|
|
403
|
+
constructor(message) {
|
|
404
|
+
super(message);
|
|
405
|
+
this.name = "TranscriptionError";
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
class EnhancementError extends Error {
|
|
410
|
+
constructor(message) {
|
|
411
|
+
super(message);
|
|
412
|
+
this.name = "EnhancementError";
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// src/lib/recorder.ts
|
|
417
|
+
var _recordProcess = null;
|
|
418
|
+
var _currentFile = null;
|
|
419
|
+
async function checkRecordingDeps() {
|
|
420
|
+
try {
|
|
421
|
+
const proc = Bun.spawn(["which", "sox"], {
|
|
422
|
+
stdout: "pipe",
|
|
423
|
+
stderr: "pipe"
|
|
424
|
+
});
|
|
425
|
+
await proc.exited;
|
|
426
|
+
if (proc.exitCode === 0) {
|
|
427
|
+
return { available: true, tool: "sox", message: "sox is available" };
|
|
428
|
+
}
|
|
429
|
+
} catch {}
|
|
430
|
+
try {
|
|
431
|
+
const proc = Bun.spawn(["which", "rec"], {
|
|
432
|
+
stdout: "pipe",
|
|
433
|
+
stderr: "pipe"
|
|
434
|
+
});
|
|
435
|
+
await proc.exited;
|
|
436
|
+
if (proc.exitCode === 0) {
|
|
437
|
+
return { available: true, tool: "rec", message: "rec is available" };
|
|
438
|
+
}
|
|
439
|
+
} catch {}
|
|
440
|
+
try {
|
|
441
|
+
const proc = Bun.spawn(["which", "ffmpeg"], {
|
|
442
|
+
stdout: "pipe",
|
|
443
|
+
stderr: "pipe"
|
|
444
|
+
});
|
|
445
|
+
await proc.exited;
|
|
446
|
+
if (proc.exitCode === 0) {
|
|
447
|
+
return {
|
|
448
|
+
available: true,
|
|
449
|
+
tool: "ffmpeg",
|
|
450
|
+
message: "ffmpeg is available"
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
} catch {}
|
|
454
|
+
return {
|
|
455
|
+
available: false,
|
|
456
|
+
tool: "none",
|
|
457
|
+
message: "No recording tool found. Install sox: brew install sox (macOS) or apt install sox (Linux)"
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
function startRecording(config) {
|
|
461
|
+
if (_recordProcess) {
|
|
462
|
+
throw new RecordingError("Already recording. Stop the current recording first.");
|
|
463
|
+
}
|
|
464
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
465
|
+
const filename = `recording-${timestamp}.${config.audio_format}`;
|
|
466
|
+
const filepath = join2(config.audio_dir, filename);
|
|
467
|
+
const args = buildRecordArgs(filepath, config);
|
|
468
|
+
_recordProcess = spawn(args[0], args.slice(1), {
|
|
469
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
470
|
+
});
|
|
471
|
+
_currentFile = filepath;
|
|
472
|
+
_recordProcess.on("error", (err) => {
|
|
473
|
+
_recordProcess = null;
|
|
474
|
+
_currentFile = null;
|
|
475
|
+
throw new RecordingError(`Recording process error: ${err.message}`);
|
|
476
|
+
});
|
|
477
|
+
_recordProcess.on("exit", () => {
|
|
478
|
+
_recordProcess = null;
|
|
479
|
+
});
|
|
480
|
+
return filepath;
|
|
481
|
+
}
|
|
482
|
+
function stopRecording() {
|
|
483
|
+
if (!_recordProcess) {
|
|
484
|
+
return null;
|
|
485
|
+
}
|
|
486
|
+
const filepath = _currentFile;
|
|
487
|
+
_recordProcess.kill("SIGINT");
|
|
488
|
+
_recordProcess = null;
|
|
489
|
+
_currentFile = null;
|
|
490
|
+
return filepath;
|
|
491
|
+
}
|
|
492
|
+
function buildRecordArgs(filepath, config) {
|
|
493
|
+
const format = config.audio_format;
|
|
494
|
+
const rate = config.sample_rate;
|
|
495
|
+
const maxSeconds = config.max_recording_seconds;
|
|
496
|
+
return [
|
|
497
|
+
"rec",
|
|
498
|
+
"-r",
|
|
499
|
+
rate.toString(),
|
|
500
|
+
"-c",
|
|
501
|
+
"1",
|
|
502
|
+
"-b",
|
|
503
|
+
"16",
|
|
504
|
+
filepath,
|
|
505
|
+
"trim",
|
|
506
|
+
"0",
|
|
507
|
+
maxSeconds.toString()
|
|
508
|
+
];
|
|
509
|
+
}
|
|
510
|
+
async function recordDuration(seconds, config) {
|
|
511
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
512
|
+
const filename = `recording-${timestamp}.${config.audio_format}`;
|
|
513
|
+
const filepath = join2(config.audio_dir, filename);
|
|
514
|
+
const args = [
|
|
515
|
+
"rec",
|
|
516
|
+
"-r",
|
|
517
|
+
config.sample_rate.toString(),
|
|
518
|
+
"-c",
|
|
519
|
+
"1",
|
|
520
|
+
"-b",
|
|
521
|
+
"16",
|
|
522
|
+
filepath,
|
|
523
|
+
"trim",
|
|
524
|
+
"0",
|
|
525
|
+
seconds.toString()
|
|
526
|
+
];
|
|
527
|
+
const proc = Bun.spawn(args, {
|
|
528
|
+
stdout: "pipe",
|
|
529
|
+
stderr: "pipe"
|
|
530
|
+
});
|
|
531
|
+
const exitCode = await proc.exited;
|
|
532
|
+
if (exitCode !== 0 && !existsSync2(filepath)) {
|
|
533
|
+
const stderr = await new Response(proc.stderr).text();
|
|
534
|
+
throw new RecordingError(`Recording failed (exit ${exitCode}): ${stderr}`);
|
|
535
|
+
}
|
|
536
|
+
return filepath;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// src/lib/transcriber.ts
|
|
540
|
+
import OpenAI from "openai";
|
|
541
|
+
import { createReadStream } from "fs";
|
|
542
|
+
var _client = null;
|
|
543
|
+
function getClient(config) {
|
|
544
|
+
if (_client)
|
|
545
|
+
return _client;
|
|
546
|
+
if (!config.openai_api_key) {
|
|
547
|
+
throw new TranscriptionError("OpenAI API key not configured. Set OPENAI_API_KEY env var or add to ~/.secrets");
|
|
548
|
+
}
|
|
549
|
+
_client = new OpenAI({ apiKey: config.openai_api_key });
|
|
550
|
+
return _client;
|
|
551
|
+
}
|
|
552
|
+
async function transcribeAudio(audioPath, config) {
|
|
553
|
+
const client = getClient(config);
|
|
554
|
+
const startTime = Date.now();
|
|
555
|
+
try {
|
|
556
|
+
const transcription = await client.audio.transcriptions.create({
|
|
557
|
+
file: createReadStream(audioPath),
|
|
558
|
+
model: config.transcription_model,
|
|
559
|
+
language: config.language || undefined,
|
|
560
|
+
response_format: "json"
|
|
561
|
+
});
|
|
562
|
+
const durationMs = Date.now() - startTime;
|
|
563
|
+
return {
|
|
564
|
+
text: transcription.text,
|
|
565
|
+
duration_ms: durationMs,
|
|
566
|
+
model: config.transcription_model,
|
|
567
|
+
language: transcription.language
|
|
568
|
+
};
|
|
569
|
+
} catch (error) {
|
|
570
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
571
|
+
throw new TranscriptionError(`Transcription failed: ${msg}`);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// src/lib/enhancer.ts
|
|
576
|
+
import OpenAI2 from "openai";
|
|
577
|
+
var _enhancementClient = null;
|
|
578
|
+
function getEnhancementClient(config) {
|
|
579
|
+
if (_enhancementClient)
|
|
580
|
+
return _enhancementClient;
|
|
581
|
+
const key = config.enhancement_api_key || config.openai_api_key;
|
|
582
|
+
if (!key) {
|
|
583
|
+
throw new EnhancementError("API key not configured for enhancement. Set OPENAI_API_KEY or RECORDINGS_ENHANCEMENT_KEY");
|
|
584
|
+
}
|
|
585
|
+
_enhancementClient = new OpenAI2({ apiKey: key });
|
|
586
|
+
return _enhancementClient;
|
|
587
|
+
}
|
|
588
|
+
function needsEnhancement(text, config) {
|
|
589
|
+
const lower = text.toLowerCase().trim();
|
|
590
|
+
for (const trigger of config.enhance_triggers) {
|
|
591
|
+
if (lower.includes(trigger.toLowerCase())) {
|
|
592
|
+
return {
|
|
593
|
+
needs: true,
|
|
594
|
+
reason: `Explicit trigger: "${trigger}"`,
|
|
595
|
+
instruction: extractInstruction(text, trigger)
|
|
596
|
+
};
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
const instructionPatterns = [
|
|
600
|
+
/(?:write|draft|compose|create)\s+(?:an?\s+)?(?:email|message|response|reply|letter|note|text|slack|dm)/i,
|
|
601
|
+
/(?:give|provide|send)\s+(?:them|him|her|it|the\s+agent|the\s+team)\s+(?:full\s+)?instructions/i,
|
|
602
|
+
/(?:tell|ask)\s+(?:them|him|her|it|the\s+agent)\s+(?:to|that)/i,
|
|
603
|
+
/(?:make\s+it|make\s+this)\s+(?:sound|look|read)\s+(?:more\s+)?(?:professional|formal|casual|friendly|better)/i,
|
|
604
|
+
/(?:ok\s+so|okay\s+so|alright\s+so)\s+(?:say|write|tell|put)/i,
|
|
605
|
+
/(?:i\s+need|i\s+want)\s+(?:the\s+agent|it|them|you)\s+to\s+(?:build|create|implement|design|make)/i
|
|
606
|
+
];
|
|
607
|
+
for (const pattern of instructionPatterns) {
|
|
608
|
+
if (pattern.test(text)) {
|
|
609
|
+
return {
|
|
610
|
+
needs: true,
|
|
611
|
+
reason: `Instruction pattern detected`,
|
|
612
|
+
instruction: text
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
return { needs: false, reason: "Direct dictation", instruction: text };
|
|
617
|
+
}
|
|
618
|
+
function extractInstruction(text, trigger) {
|
|
619
|
+
const lower = text.toLowerCase();
|
|
620
|
+
const idx = lower.indexOf(trigger.toLowerCase());
|
|
621
|
+
if (idx === -1)
|
|
622
|
+
return text;
|
|
623
|
+
const after = text.substring(idx + trigger.length).trim();
|
|
624
|
+
const before = text.substring(0, idx).trim();
|
|
625
|
+
if (before.length > after.length && before.length > 10) {
|
|
626
|
+
return before;
|
|
627
|
+
}
|
|
628
|
+
return text;
|
|
629
|
+
}
|
|
630
|
+
async function enhanceText(rawText, instruction, config) {
|
|
631
|
+
const client = getEnhancementClient(config);
|
|
632
|
+
try {
|
|
633
|
+
const response = await client.chat.completions.create({
|
|
634
|
+
model: config.enhancement_model,
|
|
635
|
+
messages: [
|
|
636
|
+
{
|
|
637
|
+
role: "system",
|
|
638
|
+
content: `You are a writing assistant. The user has dictated speech that needs to be transformed into polished output.
|
|
639
|
+
|
|
640
|
+
Rules:
|
|
641
|
+
- Output ONLY the enhanced/rewritten text \u2014 no explanations, no preamble
|
|
642
|
+
- Preserve the user's intent and meaning
|
|
643
|
+
- Fix grammar, structure, and clarity
|
|
644
|
+
- If the user is giving instructions (e.g., "write an email saying..."), produce the actual output (the email), not a description of it
|
|
645
|
+
- If the user says "say it better" or similar, rewrite their preceding text to be clearer and more professional
|
|
646
|
+
- Match the appropriate tone (formal for business, casual for personal)`
|
|
647
|
+
},
|
|
648
|
+
{
|
|
649
|
+
role: "user",
|
|
650
|
+
content: instruction
|
|
651
|
+
}
|
|
652
|
+
],
|
|
653
|
+
temperature: 0.3,
|
|
654
|
+
max_tokens: 4096
|
|
655
|
+
});
|
|
656
|
+
const enhanced = response.choices[0]?.message?.content?.trim() || rawText;
|
|
657
|
+
return {
|
|
658
|
+
original: rawText,
|
|
659
|
+
enhanced,
|
|
660
|
+
model: config.enhancement_model,
|
|
661
|
+
reasoning: null
|
|
662
|
+
};
|
|
663
|
+
} catch (error) {
|
|
664
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
665
|
+
throw new EnhancementError(`Enhancement failed: ${msg}`);
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
async function processText(rawText, config) {
|
|
669
|
+
if (!config.auto_enhance) {
|
|
670
|
+
return { text: rawText, mode: "raw", enhancement_model: null };
|
|
671
|
+
}
|
|
672
|
+
const detection = needsEnhancement(rawText, config);
|
|
673
|
+
if (!detection.needs) {
|
|
674
|
+
return { text: rawText, mode: "raw", enhancement_model: null };
|
|
675
|
+
}
|
|
676
|
+
const result = await enhanceText(rawText, detection.instruction, config);
|
|
677
|
+
return {
|
|
678
|
+
text: result.enhanced,
|
|
679
|
+
mode: "enhanced",
|
|
680
|
+
enhancement_model: result.model
|
|
681
|
+
};
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// src/cli/index.ts
|
|
685
|
+
var __dirname = "/Users/hasna/Workspace/hasna/opensource/opensourcedev/open-recordings/src/cli";
|
|
686
|
+
var program = new Command;
|
|
687
|
+
program.name("recordings").description("Speech-to-text recording tool \u2014 record, transcribe, and enhance with AI").version("0.0.1").option("--json", "Output as JSON").option("--agent <name>", "Agent name or ID").option("--project <name>", "Project name or ID").option("--session <id>", "Session ID");
|
|
688
|
+
program.command("record").description("Record from microphone, transcribe, and optionally enhance").option("-d, --duration <seconds>", "Record for specific duration").option("--no-enhance", "Skip AI enhancement").option("-t, --tags <tags>", "Comma-separated tags").option("-l, --language <lang>", "Language code (e.g. en, es, fr)").action(async (opts) => {
|
|
689
|
+
const config = loadConfig();
|
|
690
|
+
ensureDataDir(config);
|
|
691
|
+
if (opts.language)
|
|
692
|
+
config.language = opts.language;
|
|
693
|
+
if (opts.noEnhance === false)
|
|
694
|
+
config.auto_enhance = false;
|
|
695
|
+
const deps = await checkRecordingDeps();
|
|
696
|
+
if (!deps.available) {
|
|
697
|
+
console.error(chalk.red(`Error: ${deps.message}`));
|
|
698
|
+
process.exit(1);
|
|
699
|
+
}
|
|
700
|
+
let audioPath;
|
|
701
|
+
if (opts.duration) {
|
|
702
|
+
const seconds = parseInt(opts.duration, 10);
|
|
703
|
+
console.log(chalk.blue(`Recording for ${seconds} seconds...`));
|
|
704
|
+
audioPath = await recordDuration(seconds, config);
|
|
705
|
+
console.log(chalk.green("Recording complete."));
|
|
706
|
+
} else {
|
|
707
|
+
console.log(chalk.blue("Recording... Press") + chalk.yellow(" Enter ") + chalk.blue("to stop."));
|
|
708
|
+
audioPath = startRecording(config);
|
|
709
|
+
await new Promise((resolve) => {
|
|
710
|
+
process.stdin.setRawMode?.(true);
|
|
711
|
+
process.stdin.resume();
|
|
712
|
+
process.stdin.once("data", () => {
|
|
713
|
+
process.stdin.setRawMode?.(false);
|
|
714
|
+
process.stdin.pause();
|
|
715
|
+
resolve();
|
|
716
|
+
});
|
|
717
|
+
});
|
|
718
|
+
stopRecording();
|
|
719
|
+
console.log(chalk.green("Recording stopped."));
|
|
720
|
+
}
|
|
721
|
+
console.log(chalk.blue("Transcribing..."));
|
|
722
|
+
const transcription = await transcribeAudio(audioPath, config);
|
|
723
|
+
console.log(chalk.dim(`Raw: ${transcription.text}`));
|
|
724
|
+
const processed = await processText(transcription.text, config);
|
|
725
|
+
if (processed.mode === "enhanced") {
|
|
726
|
+
console.log(chalk.green(`
|
|
727
|
+
Enhanced output:`));
|
|
728
|
+
console.log(processed.text);
|
|
729
|
+
} else {
|
|
730
|
+
console.log(chalk.green(`
|
|
731
|
+
Output:`));
|
|
732
|
+
console.log(transcription.text);
|
|
733
|
+
}
|
|
734
|
+
const tags = opts.tags ? opts.tags.split(",").map((t) => t.trim()) : [];
|
|
735
|
+
const parentOpts = program.opts();
|
|
736
|
+
const recording = createRecording({
|
|
737
|
+
audio_path: audioPath,
|
|
738
|
+
raw_text: transcription.text,
|
|
739
|
+
processed_text: processed.mode === "enhanced" ? processed.text : undefined,
|
|
740
|
+
processing_mode: processed.mode,
|
|
741
|
+
model_used: transcription.model,
|
|
742
|
+
enhancement_model: processed.enhancement_model || undefined,
|
|
743
|
+
duration_ms: transcription.duration_ms,
|
|
744
|
+
language: transcription.language || undefined,
|
|
745
|
+
tags,
|
|
746
|
+
agent_id: parentOpts.agent || undefined,
|
|
747
|
+
project_id: parentOpts.project || undefined,
|
|
748
|
+
session_id: parentOpts.session || undefined
|
|
749
|
+
});
|
|
750
|
+
if (parentOpts.json) {
|
|
751
|
+
console.log(JSON.stringify(recording, null, 2));
|
|
752
|
+
} else {
|
|
753
|
+
console.log(chalk.dim(`
|
|
754
|
+
Saved as ${recording.id.slice(0, 8)}`));
|
|
755
|
+
}
|
|
756
|
+
});
|
|
757
|
+
program.command("transcribe <file>").description("Transcribe an existing audio file").option("--no-enhance", "Skip AI enhancement").option("-t, --tags <tags>", "Comma-separated tags").action(async (file, opts) => {
|
|
758
|
+
const config = loadConfig();
|
|
759
|
+
ensureDataDir(config);
|
|
760
|
+
if (opts.noEnhance === false)
|
|
761
|
+
config.auto_enhance = false;
|
|
762
|
+
console.log(chalk.blue("Transcribing..."));
|
|
763
|
+
const transcription = await transcribeAudio(file, config);
|
|
764
|
+
const processed = await processText(transcription.text, config);
|
|
765
|
+
const parentOpts = program.opts();
|
|
766
|
+
const tags = opts.tags ? opts.tags.split(",").map((t) => t.trim()) : [];
|
|
767
|
+
const recording = createRecording({
|
|
768
|
+
audio_path: file,
|
|
769
|
+
raw_text: transcription.text,
|
|
770
|
+
processed_text: processed.mode === "enhanced" ? processed.text : undefined,
|
|
771
|
+
processing_mode: processed.mode,
|
|
772
|
+
model_used: transcription.model,
|
|
773
|
+
enhancement_model: processed.enhancement_model || undefined,
|
|
774
|
+
duration_ms: transcription.duration_ms,
|
|
775
|
+
language: transcription.language || undefined,
|
|
776
|
+
tags,
|
|
777
|
+
agent_id: parentOpts.agent || undefined,
|
|
778
|
+
project_id: parentOpts.project || undefined,
|
|
779
|
+
session_id: parentOpts.session || undefined
|
|
780
|
+
});
|
|
781
|
+
if (processed.mode === "enhanced") {
|
|
782
|
+
console.log(chalk.green("Enhanced:"));
|
|
783
|
+
console.log(processed.text);
|
|
784
|
+
} else {
|
|
785
|
+
console.log(chalk.green("Transcription:"));
|
|
786
|
+
console.log(transcription.text);
|
|
787
|
+
}
|
|
788
|
+
if (parentOpts.json) {
|
|
789
|
+
console.log(JSON.stringify(recording, null, 2));
|
|
790
|
+
} else {
|
|
791
|
+
console.log(chalk.dim(`Saved as ${recording.id.slice(0, 8)}`));
|
|
792
|
+
}
|
|
793
|
+
});
|
|
794
|
+
program.command("list").description("List recordings").option("-n, --limit <n>", "Max results", "20").option("--mode <mode>", "Filter by mode: raw or enhanced").option("-t, --tags <tags>", "Filter by tags").option("--since <date>", "After date (ISO)").option("--until <date>", "Before date (ISO)").action((opts) => {
|
|
795
|
+
const config = loadConfig();
|
|
796
|
+
getDatabase(config.db_path);
|
|
797
|
+
const parentOpts = program.opts();
|
|
798
|
+
const recordings = listRecordings({
|
|
799
|
+
limit: parseInt(opts.limit, 10),
|
|
800
|
+
processing_mode: opts.mode,
|
|
801
|
+
tags: opts.tags ? opts.tags.split(",") : undefined,
|
|
802
|
+
since: opts.since,
|
|
803
|
+
until: opts.until,
|
|
804
|
+
agent_id: parentOpts.agent,
|
|
805
|
+
project_id: parentOpts.project,
|
|
806
|
+
session_id: parentOpts.session
|
|
807
|
+
});
|
|
808
|
+
if (parentOpts.json) {
|
|
809
|
+
console.log(JSON.stringify(recordings, null, 2));
|
|
810
|
+
return;
|
|
811
|
+
}
|
|
812
|
+
if (recordings.length === 0) {
|
|
813
|
+
console.log(chalk.dim("No recordings found."));
|
|
814
|
+
return;
|
|
815
|
+
}
|
|
816
|
+
console.log(chalk.bold(`${recordings.length} recording(s):
|
|
817
|
+
`));
|
|
818
|
+
for (const r of recordings) {
|
|
819
|
+
console.log(formatRecordingLine(r));
|
|
820
|
+
}
|
|
821
|
+
});
|
|
822
|
+
program.command("show <id>").description("Show recording details").action((id) => {
|
|
823
|
+
const config = loadConfig();
|
|
824
|
+
getDatabase(config.db_path);
|
|
825
|
+
const parentOpts = program.opts();
|
|
826
|
+
const recording = getRecording(id);
|
|
827
|
+
if (!recording) {
|
|
828
|
+
console.error(chalk.red(`Recording not found: ${id}`));
|
|
829
|
+
process.exit(1);
|
|
830
|
+
}
|
|
831
|
+
if (parentOpts.json) {
|
|
832
|
+
console.log(JSON.stringify(recording, null, 2));
|
|
833
|
+
return;
|
|
834
|
+
}
|
|
835
|
+
console.log(formatRecordingDetail(recording));
|
|
836
|
+
});
|
|
837
|
+
program.command("search <query>").description("Search recordings by text content").option("-n, --limit <n>", "Max results", "20").action((query, opts) => {
|
|
838
|
+
const config = loadConfig();
|
|
839
|
+
getDatabase(config.db_path);
|
|
840
|
+
const parentOpts = program.opts();
|
|
841
|
+
const results = searchRecordings(query, {
|
|
842
|
+
limit: parseInt(opts.limit, 10),
|
|
843
|
+
agent_id: parentOpts.agent,
|
|
844
|
+
project_id: parentOpts.project
|
|
845
|
+
});
|
|
846
|
+
if (parentOpts.json) {
|
|
847
|
+
console.log(JSON.stringify(results, null, 2));
|
|
848
|
+
return;
|
|
849
|
+
}
|
|
850
|
+
if (results.length === 0) {
|
|
851
|
+
console.log(chalk.dim("No results."));
|
|
852
|
+
return;
|
|
853
|
+
}
|
|
854
|
+
console.log(chalk.bold(`${results.length} result(s):
|
|
855
|
+
`));
|
|
856
|
+
for (const r of results) {
|
|
857
|
+
console.log(formatRecordingLine(r));
|
|
858
|
+
}
|
|
859
|
+
});
|
|
860
|
+
program.command("delete <id>").description("Delete a recording").action((id) => {
|
|
861
|
+
const config = loadConfig();
|
|
862
|
+
getDatabase(config.db_path);
|
|
863
|
+
const deleted = deleteRecording(id);
|
|
864
|
+
if (deleted) {
|
|
865
|
+
console.log(chalk.green(`Deleted recording ${id}`));
|
|
866
|
+
} else {
|
|
867
|
+
console.error(chalk.red(`Recording not found: ${id}`));
|
|
868
|
+
process.exit(1);
|
|
869
|
+
}
|
|
870
|
+
});
|
|
871
|
+
program.command("stats").description("Show recording statistics").action(() => {
|
|
872
|
+
const config = loadConfig();
|
|
873
|
+
getDatabase(config.db_path);
|
|
874
|
+
const parentOpts = program.opts();
|
|
875
|
+
const stats = getRecordingStats();
|
|
876
|
+
if (parentOpts.json) {
|
|
877
|
+
console.log(JSON.stringify(stats, null, 2));
|
|
878
|
+
return;
|
|
879
|
+
}
|
|
880
|
+
console.log(chalk.bold(`Recording Statistics
|
|
881
|
+
`));
|
|
882
|
+
console.log(` Total: ${stats.total}`);
|
|
883
|
+
console.log(` Raw: ${stats.raw}`);
|
|
884
|
+
console.log(` Enhanced: ${stats.enhanced}`);
|
|
885
|
+
console.log(` Duration: ${(stats.total_duration_ms / 1000).toFixed(1)}s`);
|
|
886
|
+
if (Object.keys(stats.by_model).length > 0) {
|
|
887
|
+
console.log(` By model:`);
|
|
888
|
+
for (const [model, count] of Object.entries(stats.by_model)) {
|
|
889
|
+
console.log(` ${model}: ${count}`);
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
});
|
|
893
|
+
program.command("agents").description("List registered agents").action(() => {
|
|
894
|
+
const config = loadConfig();
|
|
895
|
+
getDatabase(config.db_path);
|
|
896
|
+
const parentOpts = program.opts();
|
|
897
|
+
const agents = listAgents();
|
|
898
|
+
if (parentOpts.json) {
|
|
899
|
+
console.log(JSON.stringify(agents, null, 2));
|
|
900
|
+
return;
|
|
901
|
+
}
|
|
902
|
+
if (agents.length === 0) {
|
|
903
|
+
console.log(chalk.dim("No agents registered."));
|
|
904
|
+
return;
|
|
905
|
+
}
|
|
906
|
+
for (const a of agents) {
|
|
907
|
+
console.log(`${chalk.cyan(a.id)} ${chalk.bold(a.name)} (${a.role}) \u2014 last seen ${a.last_seen_at}`);
|
|
908
|
+
}
|
|
909
|
+
});
|
|
910
|
+
program.command("projects").description("List registered projects").action(() => {
|
|
911
|
+
const config = loadConfig();
|
|
912
|
+
getDatabase(config.db_path);
|
|
913
|
+
const parentOpts = program.opts();
|
|
914
|
+
const projects = listProjects();
|
|
915
|
+
if (parentOpts.json) {
|
|
916
|
+
console.log(JSON.stringify(projects, null, 2));
|
|
917
|
+
return;
|
|
918
|
+
}
|
|
919
|
+
if (projects.length === 0) {
|
|
920
|
+
console.log(chalk.dim("No projects registered."));
|
|
921
|
+
return;
|
|
922
|
+
}
|
|
923
|
+
for (const p of projects) {
|
|
924
|
+
console.log(`${chalk.cyan(p.id.slice(0, 8))} ${chalk.bold(p.name)} \u2014 ${p.path}`);
|
|
925
|
+
}
|
|
926
|
+
});
|
|
927
|
+
program.command("init").description("Initialize .recordings/ in current directory").action(() => {
|
|
928
|
+
const { mkdirSync: mkdirSync2, writeFileSync, existsSync: existsSync3 } = __require("fs");
|
|
929
|
+
const { join: join3 } = __require("path");
|
|
930
|
+
const dir = join3(process.cwd(), ".recordings");
|
|
931
|
+
const audioDir = join3(dir, "audio");
|
|
932
|
+
const configFile = join3(dir, "config.json");
|
|
933
|
+
mkdirSync2(audioDir, { recursive: true });
|
|
934
|
+
if (!existsSync3(configFile)) {
|
|
935
|
+
const defaultConf = {
|
|
936
|
+
transcription_model: "gpt-4o-mini-transcribe",
|
|
937
|
+
enhancement_model: "gpt-4o",
|
|
938
|
+
language: "en",
|
|
939
|
+
auto_enhance: true
|
|
940
|
+
};
|
|
941
|
+
writeFileSync(configFile, JSON.stringify(defaultConf, null, 2));
|
|
942
|
+
}
|
|
943
|
+
console.log(chalk.green("Initialized .recordings/ directory"));
|
|
944
|
+
console.log(chalk.dim(" config: .recordings/config.json"));
|
|
945
|
+
console.log(chalk.dim(" audio: .recordings/audio/"));
|
|
946
|
+
console.log(chalk.dim(" db: .recordings/recordings.db"));
|
|
947
|
+
});
|
|
948
|
+
program.command("check").description("Check system dependencies (sox, API keys)").action(async () => {
|
|
949
|
+
const config = loadConfig();
|
|
950
|
+
const deps = await checkRecordingDeps();
|
|
951
|
+
if (deps.available) {
|
|
952
|
+
console.log(chalk.green(`\u2713 Recording tool: ${deps.tool}`));
|
|
953
|
+
} else {
|
|
954
|
+
console.log(chalk.red(`\u2717 ${deps.message}`));
|
|
955
|
+
}
|
|
956
|
+
if (config.openai_api_key) {
|
|
957
|
+
console.log(chalk.green(`\u2713 OpenAI API key configured`));
|
|
958
|
+
} else {
|
|
959
|
+
console.log(chalk.red(`\u2717 OpenAI API key not found. Set OPENAI_API_KEY env var or add to ~/.secrets`));
|
|
960
|
+
}
|
|
961
|
+
const enhKey = config.enhancement_api_key || config.openai_api_key;
|
|
962
|
+
if (enhKey) {
|
|
963
|
+
console.log(chalk.green(`\u2713 Enhancement API key configured (model: ${config.enhancement_model})`));
|
|
964
|
+
} else {
|
|
965
|
+
console.log(chalk.yellow(`\u26A0 Enhancement API key not configured \u2014 enhancement disabled`));
|
|
966
|
+
}
|
|
967
|
+
});
|
|
968
|
+
program.command("start").description("Launch the menu bar helper app (F5 to toggle recording)").option("--login", "Also add to Login Items so it starts automatically").action(async (opts) => {
|
|
969
|
+
const { execSync } = __require("child_process");
|
|
970
|
+
const { join: pathJoin } = __require("path");
|
|
971
|
+
const { homedir: getHome } = __require("os");
|
|
972
|
+
const { existsSync: fileExists } = __require("fs");
|
|
973
|
+
const home = getHome();
|
|
974
|
+
const appPath = pathJoin(home, ".recordings", "RecordingsHelper.app");
|
|
975
|
+
if (!fileExists(appPath)) {
|
|
976
|
+
console.error(chalk.red("RecordingsHelper.app not found. Run: recordings shortcut --install"));
|
|
977
|
+
process.exit(1);
|
|
978
|
+
}
|
|
979
|
+
try {
|
|
980
|
+
execSync("pkill -f RecordingsHelper", { stdio: "pipe" });
|
|
981
|
+
} catch {}
|
|
982
|
+
execSync(`open "${appPath}"`, { stdio: "pipe" });
|
|
983
|
+
console.log(chalk.green("Recordings helper launched \u2014 press F5 to record"));
|
|
984
|
+
if (opts.login) {
|
|
985
|
+
try {
|
|
986
|
+
execSync(`osascript -e 'tell application "System Events" to make login item at end with properties {path:"${appPath}", hidden:true}'`, { stdio: "pipe" });
|
|
987
|
+
console.log(chalk.green("Added to Login Items \u2014 will start on boot"));
|
|
988
|
+
} catch {
|
|
989
|
+
console.log(chalk.yellow("Could not add to Login Items \u2014 add manually in System Settings > General > Login Items"));
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
});
|
|
993
|
+
program.command("stop").description("Stop the menu bar helper app").action(() => {
|
|
994
|
+
const { execSync } = __require("child_process");
|
|
995
|
+
try {
|
|
996
|
+
execSync("pkill -f RecordingsHelper", { stdio: "pipe" });
|
|
997
|
+
console.log(chalk.green("Recordings helper stopped"));
|
|
998
|
+
} catch {
|
|
999
|
+
console.log(chalk.dim("Not running"));
|
|
1000
|
+
}
|
|
1001
|
+
});
|
|
1002
|
+
program.command("listen").description("Push-to-talk mode \u2014 press Space to start/stop recording, Esc to quit").option("-t, --tags <tags>", "Comma-separated tags for all recordings").option("--no-enhance", "Skip AI enhancement").option("-l, --language <lang>", "Language code").option("--copy", "Copy output to clipboard").option("--paste", "Copy output to clipboard AND paste into frontmost app").action(async (opts) => {
|
|
1003
|
+
const config = loadConfig();
|
|
1004
|
+
ensureDataDir(config);
|
|
1005
|
+
if (opts.language)
|
|
1006
|
+
config.language = opts.language;
|
|
1007
|
+
if (opts.noEnhance === false)
|
|
1008
|
+
config.auto_enhance = false;
|
|
1009
|
+
const deps = await checkRecordingDeps();
|
|
1010
|
+
if (!deps.available) {
|
|
1011
|
+
console.error(chalk.red(`Error: ${deps.message}`));
|
|
1012
|
+
process.exit(1);
|
|
1013
|
+
}
|
|
1014
|
+
if (!config.openai_api_key) {
|
|
1015
|
+
console.error(chalk.red("Error: OpenAI API key not configured."));
|
|
1016
|
+
process.exit(1);
|
|
1017
|
+
}
|
|
1018
|
+
const tags = opts.tags ? opts.tags.split(",").map((t) => t.trim()) : [];
|
|
1019
|
+
const parentOpts = program.opts();
|
|
1020
|
+
console.log(chalk.bold(`
|
|
1021
|
+
Recordings \u2014 Push-to-Talk
|
|
1022
|
+
`));
|
|
1023
|
+
console.log(` ${chalk.yellow("Space")} Start/stop recording`);
|
|
1024
|
+
console.log(` ${chalk.yellow("Esc")} Quit
|
|
1025
|
+
`);
|
|
1026
|
+
let recording = false;
|
|
1027
|
+
let audioPath = null;
|
|
1028
|
+
process.stdin.setRawMode?.(true);
|
|
1029
|
+
process.stdin.resume();
|
|
1030
|
+
process.stdin.setEncoding("utf8");
|
|
1031
|
+
const cleanup = () => {
|
|
1032
|
+
process.stdin.setRawMode?.(false);
|
|
1033
|
+
process.stdin.pause();
|
|
1034
|
+
};
|
|
1035
|
+
process.stdin.on("data", async (key) => {
|
|
1036
|
+
if (key === "\x1B") {
|
|
1037
|
+
if (recording) {
|
|
1038
|
+
stopRecording();
|
|
1039
|
+
}
|
|
1040
|
+
cleanup();
|
|
1041
|
+
console.log(chalk.dim(`
|
|
1042
|
+
Bye.`));
|
|
1043
|
+
process.exit(0);
|
|
1044
|
+
}
|
|
1045
|
+
if (key === "\x03") {
|
|
1046
|
+
if (recording) {
|
|
1047
|
+
stopRecording();
|
|
1048
|
+
}
|
|
1049
|
+
cleanup();
|
|
1050
|
+
process.exit(0);
|
|
1051
|
+
}
|
|
1052
|
+
if (key === " ") {
|
|
1053
|
+
if (!recording) {
|
|
1054
|
+
try {
|
|
1055
|
+
audioPath = startRecording(config);
|
|
1056
|
+
recording = true;
|
|
1057
|
+
process.stdout.write(chalk.red(" \u25CF Recording... ") + chalk.dim("(Space to stop)"));
|
|
1058
|
+
} catch (e) {
|
|
1059
|
+
console.error(chalk.red(`
|
|
1060
|
+
Error: ${e instanceof Error ? e.message : e}`));
|
|
1061
|
+
}
|
|
1062
|
+
} else {
|
|
1063
|
+
stopRecording();
|
|
1064
|
+
recording = false;
|
|
1065
|
+
process.stdout.write("\r" + " ".repeat(60) + "\r");
|
|
1066
|
+
if (!audioPath)
|
|
1067
|
+
return;
|
|
1068
|
+
process.stdout.write(chalk.blue(" Transcribing..."));
|
|
1069
|
+
try {
|
|
1070
|
+
const transcription = await transcribeAudio(audioPath, config);
|
|
1071
|
+
const processed = await processText(transcription.text, config);
|
|
1072
|
+
const output = processed.mode === "enhanced" ? processed.text : transcription.text;
|
|
1073
|
+
createRecording({
|
|
1074
|
+
audio_path: audioPath,
|
|
1075
|
+
raw_text: transcription.text,
|
|
1076
|
+
processed_text: processed.mode === "enhanced" ? processed.text : undefined,
|
|
1077
|
+
processing_mode: processed.mode,
|
|
1078
|
+
model_used: transcription.model,
|
|
1079
|
+
enhancement_model: processed.enhancement_model || undefined,
|
|
1080
|
+
duration_ms: transcription.duration_ms,
|
|
1081
|
+
language: transcription.language || undefined,
|
|
1082
|
+
tags,
|
|
1083
|
+
agent_id: parentOpts.agent || undefined,
|
|
1084
|
+
project_id: parentOpts.project || undefined,
|
|
1085
|
+
session_id: parentOpts.session || undefined
|
|
1086
|
+
});
|
|
1087
|
+
process.stdout.write("\r" + " ".repeat(60) + "\r");
|
|
1088
|
+
const modeLabel = processed.mode === "enhanced" ? chalk.green(" [enhanced] ") : chalk.dim(" [raw] ");
|
|
1089
|
+
console.log(modeLabel + output);
|
|
1090
|
+
if (opts.copy || opts.paste) {
|
|
1091
|
+
try {
|
|
1092
|
+
const { execSync } = __require("child_process");
|
|
1093
|
+
execSync("pbcopy", { input: output, stdio: ["pipe", "pipe", "pipe"] });
|
|
1094
|
+
if (opts.paste) {
|
|
1095
|
+
execSync(`osascript -e 'delay 0.1' -e 'tell application "System Events" to keystroke "v" using command down'`, { stdio: "pipe" });
|
|
1096
|
+
}
|
|
1097
|
+
} catch {}
|
|
1098
|
+
}
|
|
1099
|
+
console.log("");
|
|
1100
|
+
} catch (e) {
|
|
1101
|
+
process.stdout.write("\r" + " ".repeat(60) + "\r");
|
|
1102
|
+
console.error(chalk.red(` Error: ${e instanceof Error ? e.message : e}
|
|
1103
|
+
`));
|
|
1104
|
+
}
|
|
1105
|
+
audioPath = null;
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
});
|
|
1109
|
+
});
|
|
1110
|
+
program.command("shortcut").description("Set up a global keyboard shortcut for recording (macOS)").option("--raycast", "Generate Raycast script command").option("--install", "Set up F5 global shortcut via macOS Services (no extra installs)").option("--karabiner", "Set up Fn key via Karabiner-Elements").option("--skhd", "Generate skhd hotkey config").option("--hammerspoon", "Generate Hammerspoon config").option("--script", "Just output the shell script path").action((opts) => {
|
|
1111
|
+
const { writeFileSync, mkdirSync: mkdirSync2, chmodSync, existsSync: fileExists } = __require("fs");
|
|
1112
|
+
const { join: pathJoin } = __require("path");
|
|
1113
|
+
const { homedir: getHome } = __require("os");
|
|
1114
|
+
const home = getHome();
|
|
1115
|
+
const scriptDir = pathJoin(home, ".recordings");
|
|
1116
|
+
mkdirSync2(scriptDir, { recursive: true });
|
|
1117
|
+
const scriptPath = pathJoin(scriptDir, "record-toggle.sh");
|
|
1118
|
+
const pidFile = pathJoin(scriptDir, ".recording.pid");
|
|
1119
|
+
const recordingsBin = pathJoin(home, ".bun", "bin", "recordings");
|
|
1120
|
+
const script = `#!/bin/bash
|
|
1121
|
+
# Toggle recording on/off. Run this from a global hotkey.
|
|
1122
|
+
# Each press toggles: start recording -> stop + transcribe + copy to clipboard
|
|
1123
|
+
set -e
|
|
1124
|
+
|
|
1125
|
+
PID_FILE="${pidFile}"
|
|
1126
|
+
RECORDINGS="${recordingsBin}"
|
|
1127
|
+
|
|
1128
|
+
if [ -f "$PID_FILE" ]; then
|
|
1129
|
+
# Stop recording
|
|
1130
|
+
PID=$(cat "$PID_FILE")
|
|
1131
|
+
kill -INT "$PID" 2>/dev/null || true
|
|
1132
|
+
rm -f "$PID_FILE"
|
|
1133
|
+
|
|
1134
|
+
# Find the most recent audio file
|
|
1135
|
+
AUDIO_DIR="${pathJoin(scriptDir, "audio")}"
|
|
1136
|
+
LATEST=$(ls -t "$AUDIO_DIR"/*.wav 2>/dev/null | head -1)
|
|
1137
|
+
|
|
1138
|
+
if [ -n "$LATEST" ]; then
|
|
1139
|
+
# Transcribe and copy to clipboard
|
|
1140
|
+
OUTPUT=$("$RECORDINGS" transcribe "$LATEST" --json 2>/dev/null)
|
|
1141
|
+
TEXT=$(echo "$OUTPUT" | grep -o '"processed_text":"[^"]*"' | head -1 | cut -d'"' -f4)
|
|
1142
|
+
if [ -z "$TEXT" ]; then
|
|
1143
|
+
TEXT=$(echo "$OUTPUT" | grep -o '"raw_text":"[^"]*"' | head -1 | cut -d'"' -f4)
|
|
1144
|
+
fi
|
|
1145
|
+
if [ -n "$TEXT" ]; then
|
|
1146
|
+
echo -n "$TEXT" | pbcopy
|
|
1147
|
+
# Optional: paste into frontmost app
|
|
1148
|
+
# osascript -e 'delay 0.1' -e 'tell application "System Events" to keystroke "v" using command down'
|
|
1149
|
+
fi
|
|
1150
|
+
fi
|
|
1151
|
+
|
|
1152
|
+
# Notification
|
|
1153
|
+
osascript -e 'display notification "Recording saved and copied to clipboard" with title "Recordings"' 2>/dev/null || true
|
|
1154
|
+
else
|
|
1155
|
+
# Start recording in background
|
|
1156
|
+
mkdir -p "${pathJoin(scriptDir, "audio")}"
|
|
1157
|
+
rec -r 16000 -c 1 -b 16 "${pathJoin(scriptDir, "audio")}/recording-$(date +%Y%m%dT%H%M%S).wav" trim 0 300 &
|
|
1158
|
+
echo $! > "$PID_FILE"
|
|
1159
|
+
|
|
1160
|
+
# Notification
|
|
1161
|
+
osascript -e 'display notification "Recording started..." with title "Recordings"' 2>/dev/null || true
|
|
1162
|
+
fi
|
|
1163
|
+
`;
|
|
1164
|
+
writeFileSync(scriptPath, script, "utf-8");
|
|
1165
|
+
chmodSync(scriptPath, 493);
|
|
1166
|
+
if (opts.install) {
|
|
1167
|
+
const { execSync: exec } = __require("child_process");
|
|
1168
|
+
const appPath = pathJoin(home, ".recordings", "RecordingsHelper.app");
|
|
1169
|
+
const srcSwift = pathJoin(__dirname, "..", "native", "RecordingsHelper.swift");
|
|
1170
|
+
const distApp = pathJoin(__dirname, "..", "RecordingsHelper.app");
|
|
1171
|
+
if (fileExists(pathJoin(distApp, "Contents", "MacOS", "RecordingsHelper"))) {
|
|
1172
|
+
exec(`rm -rf "${appPath}" && cp -R "${distApp}" "${appPath}"`, { stdio: "pipe", shell: "/bin/bash" });
|
|
1173
|
+
} else if (fileExists(srcSwift)) {
|
|
1174
|
+
console.log(chalk.blue("Compiling native helper app..."));
|
|
1175
|
+
const appDir = pathJoin(home, ".recordings", "RecordingsHelper.app", "Contents", "MacOS");
|
|
1176
|
+
mkdirSync2(appDir, { recursive: true });
|
|
1177
|
+
const plistDir = pathJoin(home, ".recordings", "RecordingsHelper.app", "Contents");
|
|
1178
|
+
const plist = `<?xml version="1.0" encoding="UTF-8"?>
|
|
1179
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
1180
|
+
<plist version="1.0"><dict>
|
|
1181
|
+
<key>CFBundleExecutable</key><string>RecordingsHelper</string>
|
|
1182
|
+
<key>CFBundleIdentifier</key><string>com.hasna.recordings-helper</string>
|
|
1183
|
+
<key>CFBundleName</key><string>Recordings</string>
|
|
1184
|
+
<key>LSUIElement</key><true/>
|
|
1185
|
+
<key>NSMicrophoneUsageDescription</key><string>Recordings needs microphone access for speech transcription.</string>
|
|
1186
|
+
</dict></plist>`;
|
|
1187
|
+
writeFileSync(pathJoin(plistDir, "Info.plist"), plist, "utf-8");
|
|
1188
|
+
try {
|
|
1189
|
+
exec(`DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer xcrun swiftc -O -o "${appDir}/RecordingsHelper" "${srcSwift}" -framework Cocoa -framework Carbon`, { stdio: "pipe" });
|
|
1190
|
+
} catch {
|
|
1191
|
+
exec(`swiftc -O -o "${appDir}/RecordingsHelper" "${srcSwift}" -framework Cocoa -framework Carbon`, { stdio: "pipe" });
|
|
1192
|
+
}
|
|
1193
|
+
} else {
|
|
1194
|
+
console.error(chalk.red("Cannot find RecordingsHelper. Run from the project directory or rebuild."));
|
|
1195
|
+
process.exit(1);
|
|
1196
|
+
}
|
|
1197
|
+
try {
|
|
1198
|
+
exec("pkill -f RecordingsHelper", { stdio: "pipe" });
|
|
1199
|
+
} catch {}
|
|
1200
|
+
exec(`open "${appPath}"`, { stdio: "pipe" });
|
|
1201
|
+
try {
|
|
1202
|
+
exec(`osascript -e 'tell application "System Events" to make login item at end with properties {path:"${appPath}", hidden:true}'`, { stdio: "pipe" });
|
|
1203
|
+
} catch {}
|
|
1204
|
+
console.log(chalk.green(`
|
|
1205
|
+
Recordings helper installed and running!
|
|
1206
|
+
`));
|
|
1207
|
+
console.log(` ${chalk.yellow("F5")} Start/stop recording`);
|
|
1208
|
+
console.log(` ${chalk.dim("\uD83C\uDF99")} Menu bar icon (click for options)`);
|
|
1209
|
+
console.log(` ${chalk.dim("Auto")} Starts on login
|
|
1210
|
+
`);
|
|
1211
|
+
console.log(chalk.dim(" Press F5 \u2192 speak \u2192 F5 \u2192 text is pasted where your cursor is."));
|
|
1212
|
+
return;
|
|
1213
|
+
}
|
|
1214
|
+
if (opts.karabiner) {
|
|
1215
|
+
const karabinerDir = pathJoin(home, ".config", "karabiner", "assets", "complex_modifications");
|
|
1216
|
+
mkdirSync2(karabinerDir, { recursive: true });
|
|
1217
|
+
const rule = {
|
|
1218
|
+
title: "Recordings \u2014 Fn key to toggle recording",
|
|
1219
|
+
rules: [
|
|
1220
|
+
{
|
|
1221
|
+
description: "Fn key toggles speech recording (open-recordings)",
|
|
1222
|
+
manipulators: [
|
|
1223
|
+
{
|
|
1224
|
+
type: "basic",
|
|
1225
|
+
from: {
|
|
1226
|
+
key_code: "fn",
|
|
1227
|
+
modifiers: { optional: ["any"] }
|
|
1228
|
+
},
|
|
1229
|
+
to: [
|
|
1230
|
+
{
|
|
1231
|
+
shell_command: scriptPath
|
|
1232
|
+
}
|
|
1233
|
+
]
|
|
1234
|
+
}
|
|
1235
|
+
]
|
|
1236
|
+
}
|
|
1237
|
+
]
|
|
1238
|
+
};
|
|
1239
|
+
const karabinerPath = pathJoin(karabinerDir, "recordings-fn.json");
|
|
1240
|
+
writeFileSync(karabinerPath, JSON.stringify(rule, null, 2) + `
|
|
1241
|
+
`, "utf-8");
|
|
1242
|
+
console.log(chalk.green("Karabiner-Elements rule created!"));
|
|
1243
|
+
console.log(chalk.dim(` ${karabinerPath}
|
|
1244
|
+
`));
|
|
1245
|
+
console.log("To activate:");
|
|
1246
|
+
console.log(" 1. Open Karabiner-Elements");
|
|
1247
|
+
console.log(" 2. Go to Complex Modifications tab");
|
|
1248
|
+
console.log(" 3. Click Add Predefined Rule");
|
|
1249
|
+
console.log(' 4. Enable "Fn key toggles speech recording"');
|
|
1250
|
+
console.log(chalk.dim(`
|
|
1251
|
+
Press Fn to start recording, Fn again to stop + copy to clipboard`));
|
|
1252
|
+
return;
|
|
1253
|
+
}
|
|
1254
|
+
if (opts.raycast) {
|
|
1255
|
+
const raycastDir = pathJoin(home, ".config", "raycast", "script-commands");
|
|
1256
|
+
mkdirSync2(raycastDir, { recursive: true });
|
|
1257
|
+
const raycastScript = `#!/bin/bash
|
|
1258
|
+
|
|
1259
|
+
# Required parameters:
|
|
1260
|
+
# @raycast.schemaVersion 1
|
|
1261
|
+
# @raycast.title Toggle Recording
|
|
1262
|
+
# @raycast.mode silent
|
|
1263
|
+
# @raycast.packageName Recordings
|
|
1264
|
+
|
|
1265
|
+
# Optional parameters:
|
|
1266
|
+
# @raycast.icon \uD83C\uDF99\uFE0F
|
|
1267
|
+
|
|
1268
|
+
${scriptPath}
|
|
1269
|
+
`;
|
|
1270
|
+
const raycastPath = pathJoin(raycastDir, "toggle-recording.sh");
|
|
1271
|
+
writeFileSync(raycastPath, raycastScript, "utf-8");
|
|
1272
|
+
chmodSync(raycastPath, 493);
|
|
1273
|
+
console.log(chalk.green("Raycast script command created!"));
|
|
1274
|
+
console.log(chalk.dim(` ${raycastPath}`));
|
|
1275
|
+
console.log(chalk.dim(" Open Raycast > Script Commands > reload to see it"));
|
|
1276
|
+
console.log(chalk.dim(" Then assign a hotkey in Raycast preferences"));
|
|
1277
|
+
return;
|
|
1278
|
+
}
|
|
1279
|
+
if (opts.skhd) {
|
|
1280
|
+
console.log(chalk.bold(`Add to ~/.skhdrc:
|
|
1281
|
+
`));
|
|
1282
|
+
console.log(chalk.cyan(` fn - space : ${scriptPath}`));
|
|
1283
|
+
console.log(chalk.dim(`
|
|
1284
|
+
Then reload: skhd --restart-service`));
|
|
1285
|
+
return;
|
|
1286
|
+
}
|
|
1287
|
+
if (opts.hammerspoon) {
|
|
1288
|
+
console.log(chalk.bold(`Add to ~/.hammerspoon/init.lua:
|
|
1289
|
+
`));
|
|
1290
|
+
console.log(chalk.cyan(` hs.hotkey.bind({"ctrl"}, "space", function()
|
|
1291
|
+
hs.execute("${scriptPath}")
|
|
1292
|
+
end)`));
|
|
1293
|
+
console.log(chalk.dim(`
|
|
1294
|
+
Then reload Hammerspoon config`));
|
|
1295
|
+
return;
|
|
1296
|
+
}
|
|
1297
|
+
console.log(chalk.bold("Global shortcut script created:"));
|
|
1298
|
+
console.log(chalk.cyan(` ${scriptPath}
|
|
1299
|
+
`));
|
|
1300
|
+
console.log(`Bind it to a hotkey using any of these:
|
|
1301
|
+
`);
|
|
1302
|
+
console.log(chalk.bold(" macOS built-in") + chalk.dim(" (no extra installs \u2014 recommended)"));
|
|
1303
|
+
console.log(` recordings shortcut --install
|
|
1304
|
+
`);
|
|
1305
|
+
console.log(chalk.bold(" Karabiner-Elements") + chalk.dim(" (for Fn key specifically)"));
|
|
1306
|
+
console.log(` brew install --cask karabiner-elements`);
|
|
1307
|
+
console.log(` recordings shortcut --karabiner
|
|
1308
|
+
`);
|
|
1309
|
+
console.log(chalk.bold(" Raycast"));
|
|
1310
|
+
console.log(` recordings shortcut --raycast
|
|
1311
|
+
`);
|
|
1312
|
+
console.log(chalk.bold(" skhd"));
|
|
1313
|
+
console.log(` recordings shortcut --skhd
|
|
1314
|
+
`);
|
|
1315
|
+
console.log(chalk.bold(" Hammerspoon"));
|
|
1316
|
+
console.log(` recordings shortcut --hammerspoon
|
|
1317
|
+
`);
|
|
1318
|
+
console.log(chalk.bold(" macOS Automator"));
|
|
1319
|
+
console.log(` 1. Open Automator > Quick Action`);
|
|
1320
|
+
console.log(` 2. Add "Run Shell Script" action`);
|
|
1321
|
+
console.log(` 3. Paste: ${scriptPath}`);
|
|
1322
|
+
console.log(` 4. Save as "Toggle Recording"`);
|
|
1323
|
+
console.log(` 5. System Settings > Keyboard > Shortcuts > Services`);
|
|
1324
|
+
console.log(` 6. Assign a shortcut to "Toggle Recording"
|
|
1325
|
+
`);
|
|
1326
|
+
console.log(chalk.bold(" Alfred"));
|
|
1327
|
+
console.log(` Create a workflow with a Hotkey trigger \u2192 Run Script: ${scriptPath}
|
|
1328
|
+
`);
|
|
1329
|
+
});
|
|
1330
|
+
function formatRecordingLine(r) {
|
|
1331
|
+
const id = chalk.cyan(r.id.slice(0, 8));
|
|
1332
|
+
const mode = r.processing_mode === "enhanced" ? chalk.green("enhanced") : chalk.dim("raw");
|
|
1333
|
+
const text = (r.processed_text || r.raw_text).slice(0, 80);
|
|
1334
|
+
const date = chalk.dim(r.created_at.slice(0, 16));
|
|
1335
|
+
const tags = r.tags.length > 0 ? chalk.yellow(` [${r.tags.join(", ")}]`) : "";
|
|
1336
|
+
return `${id} ${mode} ${date}${tags}
|
|
1337
|
+
${text}${text.length >= 80 ? "..." : ""}`;
|
|
1338
|
+
}
|
|
1339
|
+
function formatRecordingDetail(r) {
|
|
1340
|
+
const lines = [
|
|
1341
|
+
chalk.bold(`Recording ${r.id.slice(0, 8)}`),
|
|
1342
|
+
"",
|
|
1343
|
+
` Mode: ${r.processing_mode === "enhanced" ? chalk.green("enhanced") : chalk.dim("raw")}`,
|
|
1344
|
+
` Model: ${r.model_used}`
|
|
1345
|
+
];
|
|
1346
|
+
if (r.enhancement_model) {
|
|
1347
|
+
lines.push(` Enhanced: ${r.enhancement_model}`);
|
|
1348
|
+
}
|
|
1349
|
+
if (r.duration_ms) {
|
|
1350
|
+
lines.push(` Duration: ${(r.duration_ms / 1000).toFixed(1)}s`);
|
|
1351
|
+
}
|
|
1352
|
+
if (r.language) {
|
|
1353
|
+
lines.push(` Language: ${r.language}`);
|
|
1354
|
+
}
|
|
1355
|
+
if (r.audio_path) {
|
|
1356
|
+
lines.push(` Audio: ${r.audio_path}`);
|
|
1357
|
+
}
|
|
1358
|
+
if (r.tags.length > 0) {
|
|
1359
|
+
lines.push(` Tags: ${r.tags.join(", ")}`);
|
|
1360
|
+
}
|
|
1361
|
+
lines.push(` Created: ${r.created_at}`);
|
|
1362
|
+
lines.push("");
|
|
1363
|
+
lines.push(chalk.bold("Raw text:"));
|
|
1364
|
+
lines.push(r.raw_text);
|
|
1365
|
+
if (r.processed_text && r.processed_text !== r.raw_text) {
|
|
1366
|
+
lines.push("");
|
|
1367
|
+
lines.push(chalk.bold("Enhanced text:"));
|
|
1368
|
+
lines.push(r.processed_text);
|
|
1369
|
+
}
|
|
1370
|
+
return lines.join(`
|
|
1371
|
+
`);
|
|
1372
|
+
}
|
|
1373
|
+
program.command("mcp").description("Install recordings MCP server into Claude Code, Codex, or Gemini").option("--claude", "Install into Claude Code (via `claude mcp add`)").option("--codex", "Install into Codex (~/.codex/config.toml)").option("--gemini", "Install into Gemini (~/.gemini/settings.json)").option("--all", "Install into all supported agents").option("--uninstall", "Remove recordings MCP from config").action(async (opts) => {
|
|
1374
|
+
const { readFileSync: readFileSync2, writeFileSync, existsSync: fileExists } = __require("fs");
|
|
1375
|
+
const { join: pathJoin } = __require("path");
|
|
1376
|
+
const { homedir: getHome } = __require("os");
|
|
1377
|
+
const { execSync } = __require("child_process");
|
|
1378
|
+
const home = getHome();
|
|
1379
|
+
const mcpCmd = process.argv[0]?.includes("bun") ? pathJoin(home, ".bun", "bin", "recordings-mcp") : "recordings-mcp";
|
|
1380
|
+
const targets = opts.all ? ["claude", "codex", "gemini"] : [
|
|
1381
|
+
opts.claude ? "claude" : null,
|
|
1382
|
+
opts.codex ? "codex" : null,
|
|
1383
|
+
opts.gemini ? "gemini" : null
|
|
1384
|
+
].filter(Boolean);
|
|
1385
|
+
if (targets.length === 0) {
|
|
1386
|
+
console.log(chalk.yellow("Specify a target: --claude, --codex, --gemini, or --all"));
|
|
1387
|
+
console.log(chalk.gray("Example: recordings mcp --all"));
|
|
1388
|
+
return;
|
|
1389
|
+
}
|
|
1390
|
+
const action = opts.uninstall ? "Removed from" : "Installed into";
|
|
1391
|
+
for (const target of targets) {
|
|
1392
|
+
try {
|
|
1393
|
+
if (target === "claude") {
|
|
1394
|
+
if (opts.uninstall) {
|
|
1395
|
+
execSync("claude mcp remove recordings", { stdio: "pipe" });
|
|
1396
|
+
} else {
|
|
1397
|
+
try {
|
|
1398
|
+
execSync("claude mcp remove recordings", { stdio: "pipe" });
|
|
1399
|
+
} catch {}
|
|
1400
|
+
execSync(`claude mcp add --transport stdio --scope user recordings -- ${mcpCmd}`, { stdio: "pipe" });
|
|
1401
|
+
}
|
|
1402
|
+
console.log(chalk.green(`${action} Claude Code (user scope in ~/.claude.json)`));
|
|
1403
|
+
}
|
|
1404
|
+
if (target === "codex") {
|
|
1405
|
+
const configPath = pathJoin(home, ".codex", "config.toml");
|
|
1406
|
+
if (fileExists(configPath)) {
|
|
1407
|
+
let content = readFileSync2(configPath, "utf-8");
|
|
1408
|
+
if (opts.uninstall) {
|
|
1409
|
+
content = content.replace(/\n\[mcp_servers\.recordings\]\ncommand = "[^"]*"\nargs = \[\]\n?/g, `
|
|
1410
|
+
`);
|
|
1411
|
+
} else if (!content.includes("[mcp_servers.recordings]")) {
|
|
1412
|
+
content += `
|
|
1413
|
+
[mcp_servers.recordings]
|
|
1414
|
+
command = "${mcpCmd}"
|
|
1415
|
+
args = []
|
|
1416
|
+
`;
|
|
1417
|
+
}
|
|
1418
|
+
writeFileSync(configPath, content, "utf-8");
|
|
1419
|
+
console.log(chalk.green(`${action} Codex: ${configPath}`));
|
|
1420
|
+
} else {
|
|
1421
|
+
console.log(chalk.yellow(`Codex config not found: ${configPath}`));
|
|
1422
|
+
}
|
|
1423
|
+
}
|
|
1424
|
+
if (target === "gemini") {
|
|
1425
|
+
const configPath = pathJoin(home, ".gemini", "settings.json");
|
|
1426
|
+
let config = {};
|
|
1427
|
+
if (fileExists(configPath)) {
|
|
1428
|
+
config = JSON.parse(readFileSync2(configPath, "utf-8"));
|
|
1429
|
+
}
|
|
1430
|
+
const servers = config["mcpServers"] || {};
|
|
1431
|
+
if (opts.uninstall) {
|
|
1432
|
+
delete servers["recordings"];
|
|
1433
|
+
} else {
|
|
1434
|
+
servers["recordings"] = { command: mcpCmd, args: [] };
|
|
1435
|
+
}
|
|
1436
|
+
config["mcpServers"] = servers;
|
|
1437
|
+
writeFileSync(configPath, JSON.stringify(config, null, 2) + `
|
|
1438
|
+
`, "utf-8");
|
|
1439
|
+
console.log(chalk.green(`${action} Gemini: ${configPath}`));
|
|
1440
|
+
}
|
|
1441
|
+
} catch (e) {
|
|
1442
|
+
console.error(chalk.red(`Failed for ${target}: ${e instanceof Error ? e.message : String(e)}`));
|
|
1443
|
+
}
|
|
1444
|
+
}
|
|
1445
|
+
});
|
|
1446
|
+
program.parse();
|