@hasna/recordings 0.1.23 → 0.1.25

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 (48) hide show
  1. package/LICENSE +191 -170
  2. package/README.md +16 -7
  3. package/dist/cli/index.js +119 -65
  4. package/dist/cli/storage.d.ts +3 -0
  5. package/dist/cli/storage.d.ts.map +1 -0
  6. package/dist/db/pg-migrations.d.ts +1 -1
  7. package/dist/db/storage-config.d.ts +27 -0
  8. package/dist/db/storage-config.d.ts.map +1 -0
  9. package/dist/db/storage-sync.d.ts +36 -0
  10. package/dist/db/storage-sync.d.ts.map +1 -0
  11. package/dist/index.d.ts +2 -2
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +110 -50
  14. package/dist/lib/enhancer.d.ts.map +1 -1
  15. package/dist/lib/transcriber.d.ts +1 -0
  16. package/dist/lib/transcriber.d.ts.map +1 -1
  17. package/dist/mcp/http.d.ts +13 -0
  18. package/dist/mcp/http.d.ts.map +1 -0
  19. package/dist/mcp/index.d.ts +2 -1
  20. package/dist/mcp/index.d.ts.map +1 -1
  21. package/dist/mcp/index.js +519 -408
  22. package/dist/mcp/storage-tools.d.ts +3 -0
  23. package/dist/mcp/storage-tools.d.ts.map +1 -0
  24. package/dist/storage.d.ts +7 -0
  25. package/dist/storage.d.ts.map +1 -0
  26. package/dist/storage.js +5656 -0
  27. package/dist/version.d.ts +1 -1
  28. package/package.json +8 -3
  29. package/scripts/install_macos_app.sh +17 -0
  30. package/src/native/Recordings/App/RecordingsApp.swift +3 -2
  31. package/src/native/Recordings/RecordingsLib/FnKeyMonitor.swift +2 -0
  32. package/src/native/Recordings/RecordingsLib/MenuBarPopover.swift +101 -80
  33. package/src/native/Recordings/RecordingsLib/OpenAIAPIKeyStore.swift +26 -0
  34. package/src/native/Recordings/RecordingsLib/RealtimeTranscriptionClient.swift +13 -1
  35. package/src/native/Recordings/RecordingsLib/RecordingEngine.swift +103 -31
  36. package/src/native/Recordings/RecordingsLib/SettingsView.swift +5 -1
  37. package/src/native/Recordings/RecordingsTests/CLIRunnerTests.swift +12 -0
  38. package/src/native/Recordings/RecordingsTests/OpenAIAPIKeyStoreTests.swift +49 -0
  39. package/src/native/Recordings/RecordingsTests/PasteTargetTests.swift +54 -0
  40. package/src/native/Recordings/RecordingsTests/TranscriptResolutionTests.swift +58 -0
  41. package/dist/cli/cloud.d.ts +0 -3
  42. package/dist/cli/cloud.d.ts.map +0 -1
  43. package/dist/db/cloud-config.d.ts +0 -14
  44. package/dist/db/cloud-config.d.ts.map +0 -1
  45. package/dist/db/cloud-sync.d.ts +0 -29
  46. package/dist/db/cloud-sync.d.ts.map +0 -1
  47. package/dist/mcp/cloud-tools.d.ts +0 -3
  48. package/dist/mcp/cloud-tools.d.ts.map +0 -1
@@ -17,6 +17,18 @@ struct CLIRunnerTests {
17
17
  #expect(CLIRunner.parseError("ERROR: OpenAI API key not configured") == "OpenAI API key not configured on this Mac")
18
18
  }
19
19
 
20
+ @Test("parseError maps invalid API key (401) to a friendly message")
21
+ func invalidKeyError() {
22
+ let input = "ERROR: Transcription failed: 401 Incorrect API key provided: sk-proj-****vosA. You can find your API key at https://platform.openai.com."
23
+ #expect(CLIRunner.parseError(input) == "OpenAI API key invalid or expired — update it in Recordings Settings")
24
+ }
25
+
26
+ @Test("parseError maps quota errors (429) to a friendly message")
27
+ func quotaError() {
28
+ let input = "ERROR: Transcription failed: 429 You exceeded your current quota, please check your plan and billing details."
29
+ #expect(CLIRunner.parseError(input) == "OpenAI quota exceeded — check the OpenAI account billing")
30
+ }
31
+
20
32
  @Test("parseError truncates long messages to 120 chars")
21
33
  func truncation() {
22
34
  let longMsg = String(repeating: "a", count: 200)
@@ -74,6 +74,55 @@ struct OpenAIAPIKeyStoreTests {
74
74
  #expect(key == "secret-key")
75
75
  }
76
76
 
77
+ @Test("Saving a key writes it to config.json so the CLI uses the same key")
78
+ func saveWritesConfig() throws {
79
+ let home = try makeHome()
80
+
81
+ try OpenAIAPIKeyStore.save(key: "sk-new-key", homePath: home.path)
82
+
83
+ let key = OpenAIAPIKeyStore.load(
84
+ homePath: home.path,
85
+ environment: [:],
86
+ userDefaultKey: nil
87
+ )
88
+ #expect(key == "sk-new-key")
89
+ }
90
+
91
+ @Test("Saving a key preserves unrelated config.json fields")
92
+ func savePreservesOtherFields() throws {
93
+ let home = try makeHome()
94
+ try writeConfig(home: home, [
95
+ "openai_api_key": "old-key",
96
+ "transcription_model": "gpt-4o-mini-transcribe",
97
+ ])
98
+
99
+ try OpenAIAPIKeyStore.save(key: "sk-rotated", homePath: home.path)
100
+
101
+ let configURL = home
102
+ .appendingPathComponent(".hasna")
103
+ .appendingPathComponent("recordings")
104
+ .appendingPathComponent("config.json")
105
+ let data = try Data(contentsOf: configURL)
106
+ let json = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any])
107
+ #expect(json["openai_api_key"] as? String == "sk-rotated")
108
+ #expect(json["transcription_model"] as? String == "gpt-4o-mini-transcribe")
109
+ }
110
+
111
+ @Test("Saving an empty key removes it from config.json")
112
+ func saveEmptyRemovesKey() throws {
113
+ let home = try makeHome()
114
+ try writeConfig(home: home, ["openai_api_key": "old-key"])
115
+
116
+ try OpenAIAPIKeyStore.save(key: " ", homePath: home.path)
117
+
118
+ let key = OpenAIAPIKeyStore.load(
119
+ homePath: home.path,
120
+ environment: [:],
121
+ userDefaultKey: nil
122
+ )
123
+ #expect(key == "")
124
+ }
125
+
77
126
  private func makeHome() throws -> URL {
78
127
  let url = FileManager.default.temporaryDirectory
79
128
  .appendingPathComponent("recordings-key-store-\(UUID().uuidString)")
@@ -35,4 +35,58 @@ struct PasteTargetTests {
35
35
 
36
36
  #expect(selected?.pid == 30)
37
37
  }
38
+
39
+ @Test("paste target prefers the frontmost app over an arbitrary regular app")
40
+ func prefersFrontmostFallback() {
41
+ let candidates = [
42
+ PasteTargetCandidate(pid: 5, bundleIdentifier: "com.background", isRegularApp: true),
43
+ PasteTargetCandidate(pid: 30, bundleIdentifier: "com.notes", isRegularApp: true),
44
+ ]
45
+
46
+ let selected = RecordingEngine.selectPasteTarget(
47
+ candidates: candidates,
48
+ currentPid: 99,
49
+ targetBundleIdentifier: nil,
50
+ targetPid: nil,
51
+ frontmostPid: 30
52
+ )
53
+
54
+ #expect(selected?.pid == 30)
55
+ }
56
+
57
+ @Test("paste target never selects the recorder app even when frontmost")
58
+ func skipsOwnAppWhenFrontmost() {
59
+ let candidates = [
60
+ PasteTargetCandidate(pid: 99, bundleIdentifier: "com.hasna.recordings", isRegularApp: true),
61
+ PasteTargetCandidate(pid: 30, bundleIdentifier: "com.notes", isRegularApp: true),
62
+ ]
63
+
64
+ let selected = RecordingEngine.selectPasteTarget(
65
+ candidates: candidates,
66
+ currentPid: 99,
67
+ targetBundleIdentifier: nil,
68
+ targetPid: nil,
69
+ frontmostPid: 99
70
+ )
71
+
72
+ #expect(selected?.pid == 30)
73
+ }
74
+
75
+ @Test("captured pid wins over frontmost fallback")
76
+ func capturedPidBeatsFrontmost() {
77
+ let candidates = [
78
+ PasteTargetCandidate(pid: 10, bundleIdentifier: "com.editor", isRegularApp: true),
79
+ PasteTargetCandidate(pid: 30, bundleIdentifier: "com.notes", isRegularApp: true),
80
+ ]
81
+
82
+ let selected = RecordingEngine.selectPasteTarget(
83
+ candidates: candidates,
84
+ currentPid: 99,
85
+ targetBundleIdentifier: nil,
86
+ targetPid: 10,
87
+ frontmostPid: 30
88
+ )
89
+
90
+ #expect(selected?.pid == 10)
91
+ }
38
92
  }
@@ -0,0 +1,58 @@
1
+ import Testing
2
+ @testable import RecordingsLib
3
+
4
+ struct TranscriptResolutionTests {
5
+ @Test("CLI transcript is preferred when present")
6
+ func cliWins() {
7
+ let resolved = RecordingEngine.resolveFinalTranscript(
8
+ cliText: "full transcript from whole audio",
9
+ cliError: nil,
10
+ realtimeText: "partial realtime"
11
+ )
12
+ #expect(resolved.text == "full transcript from whole audio")
13
+ #expect(resolved.failureStatus == nil)
14
+ }
15
+
16
+ @Test("Realtime transcript is used when CLI transcription fails")
17
+ func realtimeFallbackOnError() {
18
+ let resolved = RecordingEngine.resolveFinalTranscript(
19
+ cliText: nil,
20
+ cliError: "OpenAI API key invalid or expired — update it in Recordings Settings",
21
+ realtimeText: "what the user actually said"
22
+ )
23
+ #expect(resolved.text == "what the user actually said")
24
+ #expect(resolved.failureStatus == nil)
25
+ }
26
+
27
+ @Test("Realtime transcript is used when CLI returns empty text")
28
+ func realtimeFallbackOnEmpty() {
29
+ let resolved = RecordingEngine.resolveFinalTranscript(
30
+ cliText: " ",
31
+ cliError: nil,
32
+ realtimeText: "spoken words"
33
+ )
34
+ #expect(resolved.text == "spoken words")
35
+ }
36
+
37
+ @Test("CLI error is surfaced when no transcript exists at all")
38
+ func errorWhenNothing() {
39
+ let resolved = RecordingEngine.resolveFinalTranscript(
40
+ cliText: nil,
41
+ cliError: "OpenAI API key invalid or expired — update it in Recordings Settings",
42
+ realtimeText: " "
43
+ )
44
+ #expect(resolved.text == nil)
45
+ #expect(resolved.failureStatus == "OpenAI API key invalid or expired — update it in Recordings Settings")
46
+ }
47
+
48
+ @Test("Generic failure status when both transcripts are empty without error")
49
+ func genericEmpty() {
50
+ let resolved = RecordingEngine.resolveFinalTranscript(
51
+ cliText: "",
52
+ cliError: nil,
53
+ realtimeText: nil
54
+ )
55
+ #expect(resolved.text == nil)
56
+ #expect(resolved.failureStatus == "Empty transcription")
57
+ }
58
+ }
@@ -1,3 +0,0 @@
1
- import type { Command } from "commander";
2
- export declare function registerCloudCommands(program: Command): void;
3
- //# sourceMappingURL=cloud.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"cloud.d.ts","sourceRoot":"","sources":["../../src/cli/cloud.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AA8BzC,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CAkI5D"}
@@ -1,14 +0,0 @@
1
- export type CloudMode = "local" | "hybrid" | "cloud";
2
- export interface CloudConfig {
3
- mode: CloudMode;
4
- rds: {
5
- host: string;
6
- port: number;
7
- username: string;
8
- password_env: string;
9
- ssl: boolean;
10
- };
11
- }
12
- export declare function getCloudConfig(): CloudConfig;
13
- export declare function getConnectionString(dbName?: string): string;
14
- //# sourceMappingURL=cloud-config.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"cloud-config.d.ts","sourceRoot":"","sources":["../../src/db/cloud-config.ts"],"names":[],"mappings":"AAIA,MAAM,MAAM,SAAS,GAAG,OAAO,GAAG,QAAQ,GAAG,OAAO,CAAC;AAErD,MAAM,WAAW,WAAW;IAC1B,IAAI,EAAE,SAAS,CAAC;IAChB,GAAG,EAAE;QACH,IAAI,EAAE,MAAM,CAAC;QACb,IAAI,EAAE,MAAM,CAAC;QACb,QAAQ,EAAE,MAAM,CAAC;QACjB,YAAY,EAAE,MAAM,CAAC;QACrB,GAAG,EAAE,OAAO,CAAC;KACd,CAAC;CACH;AAgBD,wBAAgB,cAAc,IAAI,WAAW,CAiC5C;AAED,wBAAgB,mBAAmB,CAAC,MAAM,SAAe,GAAG,MAAM,CAiBjE"}
@@ -1,29 +0,0 @@
1
- import { type Database } from "bun:sqlite";
2
- import { PgAdapterAsync } from "./remote-storage.js";
3
- export interface SyncResult {
4
- table: string;
5
- direction: "push" | "pull";
6
- rows_read: number;
7
- rows_written: number;
8
- errors: string[];
9
- }
10
- export interface CloudStatus {
11
- mode: string;
12
- enabled: boolean;
13
- db_path: string;
14
- tables: Array<{
15
- table: string;
16
- rows: number;
17
- }>;
18
- }
19
- export declare function getCloudPg(): Promise<PgAdapterAsync>;
20
- export declare function runCloudMigrations(remote: PgAdapterAsync): Promise<void>;
21
- export declare function getCloudStatus(db?: Database): CloudStatus;
22
- export declare function pushCloudChanges(tables?: string[]): Promise<SyncResult[]>;
23
- export declare function pullCloudChanges(tables?: string[]): Promise<SyncResult[]>;
24
- export declare function syncCloudChanges(tables?: string[]): Promise<{
25
- push: SyncResult[];
26
- pull: SyncResult[];
27
- }>;
28
- export declare function parseCloudTables(raw?: string): string[];
29
- //# sourceMappingURL=cloud-sync.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"cloud-sync.d.ts","sourceRoot":"","sources":["../../src/db/cloud-sync.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,QAAQ,EAAE,MAAM,YAAY,CAAC;AAG3C,OAAO,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAC;AAKrD,MAAM,WAAW,UAAU;IACzB,KAAK,EAAE,MAAM,CAAC;IACd,SAAS,EAAE,MAAM,GAAG,MAAM,CAAC;IAC3B,SAAS,EAAE,MAAM,CAAC;IAClB,YAAY,EAAE,MAAM,CAAC;IACrB,MAAM,EAAE,MAAM,EAAE,CAAC;CAClB;AAED,MAAM,WAAW,WAAW;IAC1B,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,OAAO,CAAC;IACjB,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,KAAK,CAAC;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;CAChD;AAqFD,wBAAsB,UAAU,IAAI,OAAO,CAAC,cAAc,CAAC,CAE1D;AAED,wBAAsB,kBAAkB,CAAC,MAAM,EAAE,cAAc,GAAG,OAAO,CAAC,IAAI,CAAC,CAI9E;AAED,wBAAgB,cAAc,CAAC,EAAE,GAAE,QAAwB,GAAG,WAAW,CAexE;AAED,wBAAsB,gBAAgB,CAAC,MAAM,GAAE,MAAM,EAAsB,GAAG,OAAO,CAAC,UAAU,EAAE,CAAC,CAuBlG;AAED,wBAAsB,gBAAgB,CAAC,MAAM,GAAE,MAAM,EAAsB,GAAG,OAAO,CAAC,UAAU,EAAE,CAAC,CAuBlG;AAED,wBAAsB,gBAAgB,CAAC,MAAM,GAAE,MAAM,EAAsB,GAAG,OAAO,CAAC;IAAE,IAAI,EAAE,UAAU,EAAE,CAAC;IAAC,IAAI,EAAE,UAAU,EAAE,CAAA;CAAE,CAAC,CAKhI;AAED,wBAAgB,gBAAgB,CAAC,GAAG,CAAC,EAAE,MAAM,GAAG,MAAM,EAAE,CAIvD"}
@@ -1,3 +0,0 @@
1
- import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
- export declare function registerRecordingsCloudTools(server: McpServer): void;
3
- //# sourceMappingURL=cloud-tools.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"cloud-tools.d.ts","sourceRoot":"","sources":["../../src/mcp/cloud-tools.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAsBzE,wBAAgB,4BAA4B,CAAC,MAAM,EAAE,SAAS,GAAG,IAAI,CAmFpE"}