@hasna/recordings 0.1.0 → 0.1.2

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 (60) hide show
  1. package/LICENSE +21 -191
  2. package/bun.lock +219 -0
  3. package/bunfig.toml +2 -0
  4. package/dist/__tests__/preload.d.ts +5 -0
  5. package/dist/__tests__/preload.d.ts.map +1 -0
  6. package/dist/cli/index.d.ts +3 -0
  7. package/dist/cli/index.d.ts.map +1 -0
  8. package/dist/cli/index.js +1446 -0
  9. package/dist/db/agents.d.ts +6 -0
  10. package/dist/db/agents.d.ts.map +1 -0
  11. package/dist/db/database.d.ts +7 -0
  12. package/dist/db/database.d.ts.map +1 -0
  13. package/dist/db/projects.d.ts +6 -0
  14. package/dist/db/projects.d.ts.map +1 -0
  15. package/dist/db/recordings.d.ts +15 -0
  16. package/dist/db/recordings.d.ts.map +1 -0
  17. package/dist/index.d.ts +11 -0
  18. package/dist/index.d.ts.map +1 -0
  19. package/dist/index.js +769 -30752
  20. package/dist/lib/config.d.ts +6 -0
  21. package/dist/lib/config.d.ts.map +1 -0
  22. package/dist/lib/enhancer.d.ts +25 -0
  23. package/dist/lib/enhancer.d.ts.map +1 -0
  24. package/dist/lib/recorder.d.ts +30 -0
  25. package/dist/lib/recorder.d.ts.map +1 -0
  26. package/dist/lib/transcriber.d.ts +5 -0
  27. package/dist/lib/transcriber.d.ts.map +1 -0
  28. package/dist/mcp/index.d.ts +3 -0
  29. package/dist/mcp/index.d.ts.map +1 -0
  30. package/dist/mcp/index.js +4912 -0
  31. package/dist/types/index.d.ts +103 -0
  32. package/dist/types/index.d.ts.map +1 -0
  33. package/package.json +35 -44
  34. package/src/__tests__/agents.test.ts +136 -0
  35. package/src/__tests__/config.test.ts +252 -0
  36. package/src/__tests__/database.test.ts +167 -0
  37. package/src/__tests__/enhancer.test.ts +574 -0
  38. package/src/__tests__/preload.ts +4 -0
  39. package/src/__tests__/projects.test.ts +109 -0
  40. package/src/__tests__/recorder.test.ts +278 -0
  41. package/src/__tests__/recordings.test.ts +353 -0
  42. package/src/__tests__/transcriber.test.ts +322 -0
  43. package/src/__tests__/types.test.ts +75 -0
  44. package/src/cli/index.ts +1078 -0
  45. package/src/db/agents.ts +81 -0
  46. package/src/db/database.ts +126 -0
  47. package/src/db/projects.ts +71 -0
  48. package/src/db/recordings.ts +219 -0
  49. package/src/index.ts +81 -0
  50. package/src/lib/config.ts +166 -0
  51. package/src/lib/enhancer.ts +167 -0
  52. package/src/lib/recorder.ts +198 -0
  53. package/src/lib/transcriber.ts +105 -0
  54. package/src/mcp/index.ts +446 -0
  55. package/src/native/RecordingsHelper.swift +352 -0
  56. package/src/types/index.ts +138 -0
  57. package/tsconfig.json +21 -0
  58. package/README.md +0 -145
  59. package/dist/index.js.map +0 -272
  60. package/scripts/postinstall.ts +0 -80
@@ -0,0 +1,103 @@
1
+ export interface Recording {
2
+ id: string;
3
+ audio_path: string | null;
4
+ raw_text: string;
5
+ processed_text: string | null;
6
+ processing_mode: ProcessingMode;
7
+ model_used: string;
8
+ enhancement_model: string | null;
9
+ duration_ms: number;
10
+ language: string | null;
11
+ tags: string[];
12
+ agent_id: string | null;
13
+ project_id: string | null;
14
+ session_id: string | null;
15
+ metadata: Record<string, unknown>;
16
+ created_at: string;
17
+ }
18
+ export type ProcessingMode = "raw" | "enhanced";
19
+ export interface CreateRecordingInput {
20
+ audio_path?: string;
21
+ raw_text: string;
22
+ processed_text?: string;
23
+ processing_mode?: ProcessingMode;
24
+ model_used?: string;
25
+ enhancement_model?: string;
26
+ duration_ms?: number;
27
+ language?: string;
28
+ tags?: string[];
29
+ agent_id?: string;
30
+ project_id?: string;
31
+ session_id?: string;
32
+ metadata?: Record<string, unknown>;
33
+ }
34
+ export interface RecordingFilter {
35
+ agent_id?: string;
36
+ project_id?: string;
37
+ session_id?: string;
38
+ processing_mode?: ProcessingMode;
39
+ tags?: string[];
40
+ search?: string;
41
+ since?: string;
42
+ until?: string;
43
+ limit?: number;
44
+ offset?: number;
45
+ }
46
+ export interface Agent {
47
+ id: string;
48
+ name: string;
49
+ description: string | null;
50
+ role: string;
51
+ metadata: Record<string, unknown>;
52
+ created_at: string;
53
+ last_seen_at: string;
54
+ }
55
+ export interface Project {
56
+ id: string;
57
+ name: string;
58
+ path: string;
59
+ description: string | null;
60
+ created_at: string;
61
+ updated_at: string;
62
+ }
63
+ export interface RecordingsConfig {
64
+ openai_api_key: string;
65
+ enhancement_api_key: string;
66
+ transcription_model: string;
67
+ enhancement_model: string;
68
+ language: string;
69
+ audio_format: "wav" | "mp3" | "m4a" | "webm";
70
+ sample_rate: number;
71
+ record_command: string;
72
+ hotkey: string;
73
+ auto_enhance: boolean;
74
+ enhance_triggers: string[];
75
+ db_path: string;
76
+ audio_dir: string;
77
+ max_recording_seconds: number;
78
+ }
79
+ export interface TranscriptionResult {
80
+ text: string;
81
+ duration_ms: number;
82
+ model: string;
83
+ language: string | null;
84
+ }
85
+ export interface EnhancementResult {
86
+ original: string;
87
+ enhanced: string;
88
+ model: string;
89
+ reasoning: string | null;
90
+ }
91
+ export declare class RecordingNotFoundError extends Error {
92
+ constructor(id: string);
93
+ }
94
+ export declare class RecordingError extends Error {
95
+ constructor(message: string);
96
+ }
97
+ export declare class TranscriptionError extends Error {
98
+ constructor(message: string);
99
+ }
100
+ export declare class EnhancementError extends Error {
101
+ constructor(message: string);
102
+ }
103
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/types/index.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,55 +1,46 @@
1
1
  {
2
2
  "name": "@hasna/recordings",
3
- "version": "0.1.0",
4
- "description": "Local AI-powered voice recording & transcription — record, transcribe, polish, search",
3
+ "version": "0.1.2",
5
4
  "type": "module",
5
+ "description": "Speech-to-text recording tool with MCP and CLI — records, transcribes, and optionally enhances text using AI",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
6
8
  "bin": {
7
- "recordings": "dist/index.js"
9
+ "recordings": "dist/cli/index.js",
10
+ "recordings-mcp": "dist/mcp/index.js"
8
11
  },
9
- "files": [
10
- "dist",
11
- "scripts",
12
- "README.md",
13
- "LICENSE"
14
- ],
15
- "scripts": {
16
- "postinstall": "bun run scripts/postinstall.ts",
17
- "dev": "bun run dev:api & bun run dev:web",
18
- "dev:api": "bun run packages/api-server/src/index.ts",
19
- "dev:web": "cd apps/web && bun run dev",
20
- "mcp": "bun run packages/mcp-server/src/index.ts",
21
- "build": "bun run build.ts",
22
- "prepublishOnly": "bun run build"
12
+ "exports": {
13
+ ".": {
14
+ "import": "./dist/index.js",
15
+ "types": "./dist/index.d.ts"
16
+ }
23
17
  },
24
- "publishConfig": {
25
- "access": "public",
26
- "registry": "https://registry.npmjs.org/"
18
+ "scripts": {
19
+ "build": "bun run build:cli && bun run build:mcp && bun run build:lib && tsc --emitDeclarationOnly --outDir dist",
20
+ "build:cli": "bun build src/cli/index.ts --target=bun --outfile=dist/cli/index.js --external=commander --external=chalk --external=openai",
21
+ "build:mcp": "bun build src/mcp/index.ts --target=bun --outfile=dist/mcp/index.js --external=@modelcontextprotocol/sdk --external=openai",
22
+ "build:lib": "bun build src/index.ts --target=bun --outfile=dist/index.js --external=openai",
23
+ "typecheck": "tsc --noEmit",
24
+ "test": "bun test",
25
+ "test:coverage": "bun test --coverage",
26
+ "dev:cli": "bun run src/cli/index.ts",
27
+ "dev:mcp": "bun run src/mcp/index.ts"
27
28
  },
28
- "engines": {
29
- "bun": ">=1.3.9"
29
+ "dependencies": {
30
+ "@modelcontextprotocol/sdk": "^1.12.1",
31
+ "chalk": "^5.4.1",
32
+ "commander": "^13.1.0",
33
+ "openai": "^5.1.0",
34
+ "zod": "^3.24.2"
30
35
  },
31
- "author": "Hasna",
32
- "license": "Apache-2.0",
33
- "keywords": [
34
- "recordings",
35
- "transcription",
36
- "voice",
37
- "openai",
38
- "whisper",
39
- "mcp",
40
- "cli",
41
- "bun"
42
- ],
43
- "repository": {
44
- "type": "git",
45
- "url": "git+https://github.com/hasna/recordings.git"
36
+ "devDependencies": {
37
+ "@types/bun": "^1.2.5",
38
+ "typescript": "^5.8.2"
46
39
  },
47
- "bugs": {
48
- "url": "https://github.com/hasna/recordings/issues"
40
+ "publishConfig": {
41
+ "access": "restricted",
42
+ "registry": "https://registry.npmjs.org/"
49
43
  },
50
- "homepage": "https://github.com/hasna/recordings#readme",
51
- "workspaces": [
52
- "apps/*",
53
- "packages/*"
54
- ]
44
+ "license": "MIT",
45
+ "author": "Hasna <andrei@hasna.com>"
55
46
  }
@@ -0,0 +1,136 @@
1
+ import { describe, test, expect, beforeEach, afterEach } from "bun:test";
2
+ import { tmpdir } from "os";
3
+ import { join } from "path";
4
+ import { mkdirSync, rmSync, existsSync } from "fs";
5
+ import {
6
+ getDatabase,
7
+ closeDatabase,
8
+ resetDatabase,
9
+ } from "../db/database.js";
10
+ import { registerAgent, getAgent, listAgents } from "../db/agents.js";
11
+ import { type Database } from "bun:sqlite";
12
+
13
+ let tempDir: string;
14
+ let db: Database;
15
+
16
+ beforeEach(() => {
17
+ resetDatabase();
18
+ tempDir = join(tmpdir(), `open-recordings-test-agents-${Date.now()}-${Math.random().toString(36).slice(2)}`);
19
+ mkdirSync(tempDir, { recursive: true });
20
+ const dbPath = join(tempDir, "test.db");
21
+ db = getDatabase(dbPath);
22
+ });
23
+
24
+ afterEach(() => {
25
+ closeDatabase();
26
+ resetDatabase();
27
+ if (existsSync(tempDir)) {
28
+ rmSync(tempDir, { recursive: true, force: true });
29
+ }
30
+ });
31
+
32
+ describe("registerAgent", () => {
33
+ test("creates a new agent with name only", () => {
34
+ const agent = registerAgent("maximus", undefined, undefined, db);
35
+ expect(agent).toBeDefined();
36
+ expect(agent.name).toBe("maximus");
37
+ expect(agent.role).toBe("agent");
38
+ expect(agent.description).toBeNull();
39
+ expect(agent.metadata).toEqual({});
40
+ expect(agent.id).toHaveLength(8);
41
+ expect(agent.created_at).toBeDefined();
42
+ expect(agent.last_seen_at).toBeDefined();
43
+ });
44
+
45
+ test("creates a new agent with description and role", () => {
46
+ const agent = registerAgent("cassius", "A helper agent", "assistant", db);
47
+ expect(agent.name).toBe("cassius");
48
+ expect(agent.description).toBe("A helper agent");
49
+ expect(agent.role).toBe("assistant");
50
+ });
51
+
52
+ test("returns existing agent and updates last_seen_at (idempotent)", () => {
53
+ const first = registerAgent("aurelius", undefined, undefined, db);
54
+ // Wait a tiny bit to ensure timestamp difference
55
+ const second = registerAgent("aurelius", undefined, undefined, db);
56
+
57
+ expect(second.id).toBe(first.id);
58
+ expect(second.name).toBe("aurelius");
59
+ // last_seen_at should be updated
60
+ expect(new Date(second.last_seen_at).getTime()).toBeGreaterThanOrEqual(
61
+ new Date(first.last_seen_at).getTime()
62
+ );
63
+ });
64
+
65
+ test("does not duplicate agents on re-register", () => {
66
+ registerAgent("brutus", undefined, undefined, db);
67
+ registerAgent("brutus", undefined, undefined, db);
68
+ registerAgent("brutus", undefined, undefined, db);
69
+
70
+ const agents = listAgents(db);
71
+ const brutusAgents = agents.filter((a) => a.name === "brutus");
72
+ expect(brutusAgents).toHaveLength(1);
73
+ });
74
+ });
75
+
76
+ describe("getAgent", () => {
77
+ test("finds agent by ID", () => {
78
+ const created = registerAgent("titus", undefined, undefined, db);
79
+ const found = getAgent(created.id, db);
80
+ expect(found).toBeDefined();
81
+ expect(found!.name).toBe("titus");
82
+ });
83
+
84
+ test("finds agent by name", () => {
85
+ registerAgent("nero", "The emperor", undefined, db);
86
+ const found = getAgent("nero", db);
87
+ expect(found).toBeDefined();
88
+ expect(found!.description).toBe("The emperor");
89
+ });
90
+
91
+ test("finds agent by partial ID prefix", () => {
92
+ const created = registerAgent("cicero", undefined, undefined, db);
93
+ const prefix = created.id.substring(0, 4);
94
+ const found = getAgent(prefix, db);
95
+ expect(found).toBeDefined();
96
+ expect(found!.name).toBe("cicero");
97
+ });
98
+
99
+ test("returns null for non-existent agent", () => {
100
+ const found = getAgent("nonexistent", db);
101
+ expect(found).toBeNull();
102
+ });
103
+ });
104
+
105
+ describe("listAgents", () => {
106
+ test("returns empty array when no agents", () => {
107
+ const agents = listAgents(db);
108
+ expect(agents).toEqual([]);
109
+ });
110
+
111
+ test("returns all agents ordered by last_seen_at DESC", () => {
112
+ registerAgent("alpha", undefined, undefined, db);
113
+ registerAgent("beta", undefined, undefined, db);
114
+ registerAgent("gamma", undefined, undefined, db);
115
+
116
+ const agents = listAgents(db);
117
+ expect(agents).toHaveLength(3);
118
+ // All three agents are present
119
+ const names = agents.map((a) => a.name);
120
+ expect(names).toContain("alpha");
121
+ expect(names).toContain("beta");
122
+ expect(names).toContain("gamma");
123
+ });
124
+
125
+ test("parses metadata JSON correctly", () => {
126
+ const agent = registerAgent("delta", undefined, undefined, db);
127
+ // Manually update metadata for testing
128
+ db.query("UPDATE agents SET metadata = ? WHERE id = ?").run(
129
+ JSON.stringify({ tool: "test" }),
130
+ agent.id
131
+ );
132
+
133
+ const found = getAgent(agent.id, db);
134
+ expect(found!.metadata).toEqual({ tool: "test" });
135
+ });
136
+ });
@@ -0,0 +1,252 @@
1
+ import { describe, test, expect, beforeEach, afterEach } from "bun:test";
2
+ import { tmpdir } from "os";
3
+ import { join } from "path";
4
+ import { mkdirSync, rmSync, existsSync, writeFileSync } from "fs";
5
+ import { loadConfig, getDataDir, ensureDataDir, DEFAULT_CONFIG } from "../lib/config.js";
6
+
7
+ let tempDir: string;
8
+
9
+ // Save and restore env vars
10
+ const savedEnv: Record<string, string | undefined> = {};
11
+ const envKeys = [
12
+ "OPENAI_API_KEY",
13
+ "RECORDINGS_API_KEY",
14
+ "RECORDINGS_ENHANCEMENT_KEY",
15
+ "RECORDINGS_MODEL",
16
+ "RECORDINGS_ENHANCEMENT_MODEL",
17
+ "RECORDINGS_LANGUAGE",
18
+ "RECORDINGS_DB_PATH",
19
+ "RECORDINGS_AUDIO_DIR",
20
+ "RECORDINGS_MAX_SECONDS",
21
+ ];
22
+
23
+ beforeEach(() => {
24
+ tempDir = join(tmpdir(), `open-recordings-test-config-${Date.now()}-${Math.random().toString(36).slice(2)}`);
25
+ mkdirSync(tempDir, { recursive: true });
26
+
27
+ // Save env vars
28
+ for (const key of envKeys) {
29
+ savedEnv[key] = process.env[key];
30
+ delete process.env[key];
31
+ }
32
+ });
33
+
34
+ afterEach(() => {
35
+ // Restore env vars
36
+ for (const key of envKeys) {
37
+ if (savedEnv[key] !== undefined) {
38
+ process.env[key] = savedEnv[key];
39
+ } else {
40
+ delete process.env[key];
41
+ }
42
+ }
43
+
44
+ if (existsSync(tempDir)) {
45
+ rmSync(tempDir, { recursive: true, force: true });
46
+ }
47
+ });
48
+
49
+ describe("DEFAULT_CONFIG", () => {
50
+ test("has expected default values", () => {
51
+ expect(DEFAULT_CONFIG.transcription_model).toBe("gpt-4o-mini-transcribe");
52
+ expect(DEFAULT_CONFIG.enhancement_model).toBe("gpt-4o");
53
+ expect(DEFAULT_CONFIG.language).toBe("en");
54
+ expect(DEFAULT_CONFIG.audio_format).toBe("wav");
55
+ expect(DEFAULT_CONFIG.sample_rate).toBe(16000);
56
+ expect(DEFAULT_CONFIG.record_command).toBe("sox");
57
+ expect(DEFAULT_CONFIG.hotkey).toBe("space");
58
+ expect(DEFAULT_CONFIG.auto_enhance).toBe(true);
59
+ expect(DEFAULT_CONFIG.max_recording_seconds).toBe(300);
60
+ expect(DEFAULT_CONFIG.enhance_triggers).toContain("say it better");
61
+ expect(DEFAULT_CONFIG.enhance_triggers).toContain("rewrite this");
62
+ });
63
+ });
64
+
65
+ describe("loadConfig", () => {
66
+ test("returns defaults when no config file or env vars", () => {
67
+ const config = loadConfig(join(tempDir, "nonexistent.json"));
68
+ expect(config.transcription_model).toBe("gpt-4o-mini-transcribe");
69
+ expect(config.enhancement_model).toBe("gpt-4o");
70
+ expect(config.language).toBe("en");
71
+ expect(config.audio_format).toBe("wav");
72
+ expect(config.auto_enhance).toBe(true);
73
+ });
74
+
75
+ test("loads config from file", () => {
76
+ const configPath = join(tempDir, "config.json");
77
+ writeFileSync(
78
+ configPath,
79
+ JSON.stringify({
80
+ transcription_model: "whisper-1",
81
+ language: "fr",
82
+ auto_enhance: false,
83
+ })
84
+ );
85
+
86
+ const config = loadConfig(configPath);
87
+ expect(config.transcription_model).toBe("whisper-1");
88
+ expect(config.language).toBe("fr");
89
+ expect(config.auto_enhance).toBe(false);
90
+ // Other defaults still present
91
+ expect(config.audio_format).toBe("wav");
92
+ });
93
+
94
+ test("ignores invalid JSON config file", () => {
95
+ const configPath = join(tempDir, "bad-config.json");
96
+ writeFileSync(configPath, "this is not json {{{");
97
+
98
+ const config = loadConfig(configPath);
99
+ // Should fall back to defaults
100
+ expect(config.transcription_model).toBe("gpt-4o-mini-transcribe");
101
+ });
102
+
103
+ test("env var OPENAI_API_KEY overrides config", () => {
104
+ process.env.OPENAI_API_KEY = "sk-env-key";
105
+ const config = loadConfig(join(tempDir, "nonexistent.json"));
106
+ expect(config.openai_api_key).toBe("sk-env-key");
107
+ });
108
+
109
+ test("env var RECORDINGS_API_KEY overrides OPENAI_API_KEY", () => {
110
+ process.env.OPENAI_API_KEY = "sk-openai";
111
+ process.env.RECORDINGS_API_KEY = "sk-recordings";
112
+ const config = loadConfig(join(tempDir, "nonexistent.json"));
113
+ expect(config.openai_api_key).toBe("sk-recordings");
114
+ });
115
+
116
+ test("env var RECORDINGS_ENHANCEMENT_KEY sets enhancement_api_key", () => {
117
+ process.env.RECORDINGS_ENHANCEMENT_KEY = "sk-enhance";
118
+ const config = loadConfig(join(tempDir, "nonexistent.json"));
119
+ expect(config.enhancement_api_key).toBe("sk-enhance");
120
+ });
121
+
122
+ test("env var RECORDINGS_MODEL overrides transcription_model", () => {
123
+ process.env.RECORDINGS_MODEL = "whisper-1";
124
+ const config = loadConfig(join(tempDir, "nonexistent.json"));
125
+ expect(config.transcription_model).toBe("whisper-1");
126
+ });
127
+
128
+ test("env var RECORDINGS_ENHANCEMENT_MODEL overrides enhancement_model", () => {
129
+ process.env.RECORDINGS_ENHANCEMENT_MODEL = "gpt-3.5-turbo";
130
+ const config = loadConfig(join(tempDir, "nonexistent.json"));
131
+ expect(config.enhancement_model).toBe("gpt-3.5-turbo");
132
+ });
133
+
134
+ test("env var RECORDINGS_LANGUAGE overrides language", () => {
135
+ process.env.RECORDINGS_LANGUAGE = "de";
136
+ const config = loadConfig(join(tempDir, "nonexistent.json"));
137
+ expect(config.language).toBe("de");
138
+ });
139
+
140
+ test("env var RECORDINGS_DB_PATH overrides db_path", () => {
141
+ process.env.RECORDINGS_DB_PATH = "/custom/db.sqlite";
142
+ const config = loadConfig(join(tempDir, "nonexistent.json"));
143
+ expect(config.db_path).toBe("/custom/db.sqlite");
144
+ });
145
+
146
+ test("env var RECORDINGS_AUDIO_DIR overrides audio_dir", () => {
147
+ process.env.RECORDINGS_AUDIO_DIR = "/custom/audio";
148
+ const config = loadConfig(join(tempDir, "nonexistent.json"));
149
+ expect(config.audio_dir).toBe("/custom/audio");
150
+ });
151
+
152
+ test("env var RECORDINGS_MAX_SECONDS overrides max_recording_seconds", () => {
153
+ process.env.RECORDINGS_MAX_SECONDS = "60";
154
+ const config = loadConfig(join(tempDir, "nonexistent.json"));
155
+ expect(config.max_recording_seconds).toBe(60);
156
+ });
157
+
158
+ test("sets default db_path when not configured", () => {
159
+ const config = loadConfig(join(tempDir, "nonexistent.json"));
160
+ expect(config.db_path).toBeTruthy();
161
+ expect(config.db_path).toContain("recordings.db");
162
+ });
163
+
164
+ test("sets default audio_dir when not configured", () => {
165
+ const config = loadConfig(join(tempDir, "nonexistent.json"));
166
+ expect(config.audio_dir).toBeTruthy();
167
+ expect(config.audio_dir).toContain("audio");
168
+ });
169
+
170
+ test("enhancement_api_key falls back to openai_api_key", () => {
171
+ process.env.OPENAI_API_KEY = "sk-shared";
172
+ const config = loadConfig(join(tempDir, "nonexistent.json"));
173
+ expect(config.enhancement_api_key).toBe("sk-shared");
174
+ });
175
+
176
+ test("file config values are overridden by env vars", () => {
177
+ const configPath = join(tempDir, "config.json");
178
+ writeFileSync(
179
+ configPath,
180
+ JSON.stringify({ language: "fr", transcription_model: "file-model" })
181
+ );
182
+ process.env.RECORDINGS_LANGUAGE = "ja";
183
+
184
+ const config = loadConfig(configPath);
185
+ expect(config.language).toBe("ja"); // env wins
186
+ expect(config.transcription_model).toBe("file-model"); // no env override for this
187
+ });
188
+ });
189
+
190
+ describe("ensureDataDir", () => {
191
+ test("creates audio_dir and db directory", () => {
192
+ const audioDir = join(tempDir, "audio-out");
193
+ const dbPath = join(tempDir, "db-dir/recordings.db");
194
+ const config = {
195
+ ...DEFAULT_CONFIG,
196
+ audio_dir: audioDir,
197
+ db_path: dbPath,
198
+ };
199
+
200
+ ensureDataDir(config);
201
+ expect(existsSync(audioDir)).toBe(true);
202
+ expect(existsSync(join(tempDir, "db-dir"))).toBe(true);
203
+ });
204
+ });
205
+
206
+ describe("getDataDir", () => {
207
+ test("returns a string", () => {
208
+ const dir = getDataDir();
209
+ expect(typeof dir).toBe("string");
210
+ expect(dir).toContain(".recordings");
211
+ });
212
+ });
213
+
214
+ describe("loadSecretKey (via loadConfig)", () => {
215
+ test("loads API key from ~/.secrets with double quotes", () => {
216
+ const config = loadConfig(join(tempDir, "nonexistent.json"));
217
+ // The key is either loaded from ~/.secrets or empty
218
+ expect(typeof config.openai_api_key).toBe("string");
219
+ });
220
+
221
+ test("loads secret key with single quotes format", () => {
222
+ // We can't easily mock ~/.secrets, but we test the regex patterns directly
223
+ // by testing the config loading with different env overrides
224
+ // The loadSecretKey function is internal, so we test its effect indirectly
225
+ // When no env var is set and no ~/.secrets has the key, it returns ""
226
+ const config = loadConfig(join(tempDir, "nonexistent.json"));
227
+ // At minimum, the function doesn't crash
228
+ expect(config).toBeDefined();
229
+ });
230
+ });
231
+
232
+ describe("findConfigFile (via loadConfig without explicit path)", () => {
233
+ test("loads config without explicit path (uses findConfigFile)", () => {
234
+ // When no configPath is given, findConfigFile walks up from cwd
235
+ const config = loadConfig();
236
+ expect(config).toBeDefined();
237
+ expect(config.transcription_model).toBeTruthy();
238
+ });
239
+ });
240
+
241
+ describe("ensureDataDir edge cases", () => {
242
+ test("handles db_path without directory separator", () => {
243
+ const config = {
244
+ ...DEFAULT_CONFIG,
245
+ audio_dir: join(tempDir, "audio"),
246
+ db_path: "recordings.db", // no directory part
247
+ };
248
+ // Should not throw - dbDir will be empty string
249
+ ensureDataDir(config);
250
+ expect(existsSync(join(tempDir, "audio"))).toBe(true);
251
+ });
252
+ });