@lattices/cli 0.3.0 → 0.4.0

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 (95) hide show
  1. package/README.md +85 -9
  2. package/app/Package.swift +8 -1
  3. package/app/Sources/AdvisorLearningStore.swift +90 -0
  4. package/app/Sources/AgentSession.swift +377 -0
  5. package/app/Sources/AppDelegate.swift +44 -12
  6. package/app/Sources/AppShellView.swift +81 -8
  7. package/app/Sources/AudioProvider.swift +386 -0
  8. package/app/Sources/CheatSheetHUD.swift +261 -19
  9. package/app/Sources/DaemonProtocol.swift +13 -0
  10. package/app/Sources/DaemonServer.swift +8 -0
  11. package/app/Sources/DesktopModel.swift +164 -5
  12. package/app/Sources/DesktopModelTypes.swift +2 -0
  13. package/app/Sources/DiagnosticLog.swift +104 -2
  14. package/app/Sources/EventBus.swift +1 -0
  15. package/app/Sources/HUDBottomBar.swift +279 -0
  16. package/app/Sources/HUDController.swift +1158 -0
  17. package/app/Sources/HUDLeftBar.swift +849 -0
  18. package/app/Sources/HUDMinimap.swift +179 -0
  19. package/app/Sources/HUDRightBar.swift +774 -0
  20. package/app/Sources/HUDState.swift +367 -0
  21. package/app/Sources/HUDTopBar.swift +243 -0
  22. package/app/Sources/HandsOffSession.swift +733 -0
  23. package/app/Sources/HomeDashboardView.swift +125 -0
  24. package/app/Sources/HotkeyManager.swift +2 -0
  25. package/app/Sources/HotkeyStore.swift +45 -9
  26. package/app/Sources/IntentEngine.swift +925 -0
  27. package/app/Sources/Intents/CreateLayerIntent.swift +54 -0
  28. package/app/Sources/Intents/DistributeIntent.swift +56 -0
  29. package/app/Sources/Intents/FocusIntent.swift +69 -0
  30. package/app/Sources/Intents/HelpIntent.swift +41 -0
  31. package/app/Sources/Intents/KillIntent.swift +47 -0
  32. package/app/Sources/Intents/LatticeIntent.swift +78 -0
  33. package/app/Sources/Intents/LaunchIntent.swift +67 -0
  34. package/app/Sources/Intents/ListSessionsIntent.swift +32 -0
  35. package/app/Sources/Intents/ListWindowsIntent.swift +30 -0
  36. package/app/Sources/Intents/ScanIntent.swift +52 -0
  37. package/app/Sources/Intents/SearchIntent.swift +190 -0
  38. package/app/Sources/Intents/SwitchLayerIntent.swift +50 -0
  39. package/app/Sources/Intents/TileIntent.swift +61 -0
  40. package/app/Sources/LatticesApi.swift +1235 -30
  41. package/app/Sources/LauncherHUD.swift +348 -0
  42. package/app/Sources/MainView.swift +147 -44
  43. package/app/Sources/OcrModel.swift +34 -1
  44. package/app/Sources/OmniSearchState.swift +99 -102
  45. package/app/Sources/OnboardingView.swift +457 -0
  46. package/app/Sources/PermissionChecker.swift +2 -12
  47. package/app/Sources/PiChatDock.swift +454 -0
  48. package/app/Sources/PiChatSession.swift +815 -0
  49. package/app/Sources/PiWorkspaceView.swift +364 -0
  50. package/app/Sources/PlacementSpec.swift +195 -0
  51. package/app/Sources/Preferences.swift +59 -0
  52. package/app/Sources/ProjectScanner.swift +1 -1
  53. package/app/Sources/ScreenMapState.swift +701 -55
  54. package/app/Sources/ScreenMapView.swift +843 -103
  55. package/app/Sources/ScreenMapWindowController.swift +22 -0
  56. package/app/Sources/SessionLayerStore.swift +285 -0
  57. package/app/Sources/SessionManager.swift +4 -1
  58. package/app/Sources/SettingsView.swift +186 -3
  59. package/app/Sources/Theme.swift +9 -8
  60. package/app/Sources/TmuxModel.swift +7 -0
  61. package/app/Sources/TmuxQuery.swift +27 -3
  62. package/app/Sources/VoiceChatView.swift +192 -0
  63. package/app/Sources/VoiceCommandWindow.swift +1594 -0
  64. package/app/Sources/VoiceIntentResolver.swift +671 -0
  65. package/app/Sources/VoxClient.swift +454 -0
  66. package/app/Sources/WindowTiler.swift +348 -87
  67. package/app/Sources/WorkspaceManager.swift +127 -18
  68. package/bin/client.ts +16 -0
  69. package/bin/{daemon-client.js → daemon-client.ts} +49 -30
  70. package/bin/handsoff-infer.ts +280 -0
  71. package/bin/handsoff-worker.ts +731 -0
  72. package/bin/{lattices-app.js → lattices-app.ts} +67 -32
  73. package/bin/lattices-dev +160 -0
  74. package/bin/{lattices.js → lattices.ts} +600 -137
  75. package/bin/project-twin.ts +645 -0
  76. package/docs/agent-execution-plan.md +562 -0
  77. package/docs/agents.md +142 -0
  78. package/docs/api.md +153 -34
  79. package/docs/app.md +29 -1
  80. package/docs/config.md +5 -1
  81. package/docs/handsoff-test-scenarios.md +84 -0
  82. package/docs/layers.md +20 -20
  83. package/docs/ocr.md +14 -5
  84. package/docs/overview.md +5 -1
  85. package/docs/presentation-execution-review.md +491 -0
  86. package/docs/prompts/hands-off-system.md +374 -0
  87. package/docs/prompts/hands-off-turn.md +30 -0
  88. package/docs/prompts/voice-advisor.md +31 -0
  89. package/docs/prompts/voice-fallback.md +23 -0
  90. package/docs/tiling-reference.md +167 -0
  91. package/docs/twins.md +138 -0
  92. package/docs/voice-command-protocol.md +278 -0
  93. package/docs/voice.md +219 -0
  94. package/package.json +21 -10
  95. package/bin/client.js +0 -4
@@ -81,19 +81,47 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
81
81
 
82
82
  let store = HotkeyStore.shared
83
83
  store.register(action: .palette) { CommandPaletteWindow.shared.toggle() }
84
- store.register(action: .screenMap) { ScreenMapWindowController.shared.toggle() }
84
+ store.register(action: .unifiedWindow) { ScreenMapWindowController.shared.toggle() }
85
85
  store.register(action: .bezel) { WindowBezel.showBezelForFrontmostWindow() }
86
86
  store.register(action: .cheatSheet) { CheatSheetHUD.shared.toggle() }
87
- store.register(action: .desktopInventory) { CommandModeWindow.shared.toggle() }
87
+ store.register(action: .voiceCommand) {
88
+ DiagnosticLog.shared.info("Hotkey: voiceCommand triggered")
89
+ VoiceCommandWindow.shared.toggle()
90
+ }
91
+ store.register(action: .handsOff) {
92
+ DiagnosticLog.shared.info("Hotkey: handsOff triggered")
93
+ HandsOffSession.shared.toggle()
94
+ // Show voice bar when starting, hide when stopping
95
+ if HandsOffSession.shared.state != .idle {
96
+ HUDController.shared.showVoiceBar()
97
+ } else {
98
+ HUDController.shared.hideVoiceBar()
99
+ }
100
+ }
101
+ store.register(action: .hud) { HUDController.shared.toggle() }
102
+
103
+ // Pre-render HUD panels off-screen for instant first open
104
+ DispatchQueue.main.async { HUDController.shared.warmUp() }
88
105
  store.register(action: .omniSearch) { OmniSearchWindow.shared.toggle() }
89
106
 
90
- // Layer-switching hotkeys
107
+ // Session layer cycling
108
+ store.register(action: .layerNext) { SessionLayerStore.shared.cycleNext() }
109
+ store.register(action: .layerPrev) { SessionLayerStore.shared.cyclePrev() }
110
+ store.register(action: .layerTag) { SessionLayerStore.shared.tagFrontmostWindow() }
111
+
112
+ // Layer-switching hotkeys (1-9): session layers take priority
91
113
  let workspace = WorkspaceManager.shared
92
- let layerCount = (workspace.config?.layers ?? []).count
93
- for (i, action) in HotkeyAction.layerActions.prefix(layerCount).enumerated() {
114
+ let configLayerCount = (workspace.config?.layers ?? []).count
115
+ let maxLayers = max(configLayerCount, 9)
116
+ for (i, action) in HotkeyAction.layerActions.prefix(maxLayers).enumerated() {
94
117
  let index = i
95
118
  store.register(action: action) {
96
- workspace.tileLayer(index: index, launch: true, force: true)
119
+ let session = SessionLayerStore.shared
120
+ if !session.layers.isEmpty && index < session.layers.count {
121
+ session.switchTo(index: index)
122
+ } else {
123
+ workspace.focusLayer(index: index)
124
+ }
97
125
  EventBus.shared.post(.layerSwitched(index: index))
98
126
  }
99
127
  }
@@ -113,8 +141,10 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
113
141
  }
114
142
  store.register(action: .tileDistribute) { WindowTiler.distributeVisible() }
115
143
 
116
- // Check macOS permissions (Accessibility, Screen Recording)
117
- PermissionChecker.shared.check()
144
+ // Onboarding on first launch; otherwise just check permissions
145
+ if !OnboardingWindowController.shared.showIfNeeded() {
146
+ PermissionChecker.shared.check()
147
+ }
118
148
 
119
149
  // Start daemon services
120
150
  let diag = DiagnosticLog.shared
@@ -126,6 +156,8 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
126
156
  ProcessModel.shared.start()
127
157
  LatticesApi.setup()
128
158
  DaemonServer.shared.start()
159
+ AgentPool.shared.start()
160
+ HandsOffSession.shared.start()
129
161
  diag.finish(tBoot)
130
162
 
131
163
  // --diagnostics flag: auto-open diagnostics panel on launch
@@ -175,7 +207,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
175
207
  let p = NSPopover()
176
208
  p.contentViewController = NSHostingController(rootView: MainView(scanner: ProjectScanner.shared))
177
209
  p.behavior = .transient
178
- p.contentSize = NSSize(width: 380, height: 520)
210
+ p.contentSize = NSSize(width: 380, height: 560)
179
211
  p.appearance = NSAppearance(named: .darkAqua)
180
212
  p.delegate = self
181
213
  popover = p
@@ -196,8 +228,8 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
196
228
 
197
229
  let actions: [(String, String, Selector)] = [
198
230
  ("Command Palette", "⌘⇧M", #selector(menuCommandPalette)),
199
- ("Screen Map", "", #selector(menuScreenMap)),
200
- ("Desktop Inventory", "", #selector(menuDesktopInventory)),
231
+ ("Unified Window", "", #selector(menuScreenMap)),
232
+ ("HUD", "", #selector(menuHUD)),
201
233
  ("Window Bezel", "", #selector(menuWindowBezel)),
202
234
  ("Cheat Sheet", "", #selector(menuCheatSheet)),
203
235
  ("Omni Search", "", #selector(menuOmniSearch)),
@@ -232,7 +264,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
232
264
 
233
265
  @objc private func menuCommandPalette() { CommandPaletteWindow.shared.toggle() }
234
266
  @objc private func menuScreenMap() { ScreenMapWindowController.shared.toggle() }
235
- @objc private func menuDesktopInventory() { CommandModeWindow.shared.toggle() }
267
+ @objc private func menuHUD() { HUDController.shared.toggle() }
236
268
  @objc private func menuWindowBezel() { WindowBezel.showBezelForFrontmostWindow() }
237
269
  @objc private func menuCheatSheet() { CheatSheetHUD.shared.toggle() }
238
270
  @objc private func menuOmniSearch() { OmniSearchWindow.shared.toggle() }
@@ -3,25 +3,37 @@ import SwiftUI
3
3
  // MARK: - Navigation Pages
4
4
 
5
5
  enum AppPage: String, CaseIterable {
6
+ case home
6
7
  case screenMap
8
+ case desktopInventory
9
+ case pi
7
10
  case settings
8
11
  case docs
9
12
 
10
13
  var label: String {
11
14
  switch self {
12
- case .screenMap: return "Screen Map"
13
- case .settings: return "Settings"
14
- case .docs: return "Docs"
15
+ case .home: return "Home"
16
+ case .screenMap: return "Screen Map"
17
+ case .desktopInventory: return "Desktop Inventory"
18
+ case .pi: return "Pi"
19
+ case .settings: return "Settings"
20
+ case .docs: return "Docs"
15
21
  }
16
22
  }
17
23
 
18
24
  var icon: String {
19
25
  switch self {
20
- case .screenMap: return "rectangle.3.group"
21
- case .settings: return "gearshape"
22
- case .docs: return "book"
26
+ case .home: return "house"
27
+ case .screenMap: return "rectangle.3.group"
28
+ case .desktopInventory: return "macwindow.on.rectangle"
29
+ case .pi: return "terminal"
30
+ case .settings: return "gearshape"
31
+ case .docs: return "book"
23
32
  }
24
33
  }
34
+
35
+ /// Pages shown as primary tabs in the unified window
36
+ static var primaryTabs: [AppPage] { [.home, .screenMap, .desktopInventory, .pi] }
25
37
  }
26
38
 
27
39
  // MARK: - App Shell View
@@ -29,10 +41,61 @@ enum AppPage: String, CaseIterable {
29
41
  struct AppShellView: View {
30
42
  @ObservedObject var controller: ScreenMapController
31
43
  @ObservedObject var windowController = ScreenMapWindowController.shared
44
+ @StateObject private var commandState = CommandModeState()
32
45
 
33
46
  var body: some View {
34
- contentArea
35
- .background(Palette.bg)
47
+ VStack(spacing: 0) {
48
+ // Tab bar (only on primary pages)
49
+ if AppPage.primaryTabs.contains(windowController.activePage) {
50
+ tabBar
51
+ Rectangle().fill(Palette.border).frame(height: 0.5)
52
+ }
53
+
54
+ contentArea
55
+ }
56
+ .background(Palette.bg)
57
+ .onAppear {
58
+ commandState.onDismiss = { windowController.activePage = .home }
59
+ }
60
+ }
61
+
62
+ // MARK: - Tab Bar
63
+
64
+ private var tabBar: some View {
65
+ HStack(spacing: 0) {
66
+ ForEach(AppPage.primaryTabs, id: \.rawValue) { tab in
67
+ tabButton(tab)
68
+ }
69
+ Spacer()
70
+ }
71
+ .padding(.horizontal, 12)
72
+ .padding(.top, 8)
73
+ .padding(.bottom, 4)
74
+ }
75
+
76
+ private func tabButton(_ tab: AppPage) -> some View {
77
+ let isActive = windowController.activePage == tab
78
+
79
+ return Button {
80
+ windowController.activePage = tab
81
+ if tab == .screenMap { controller.enter() }
82
+ if tab == .desktopInventory { commandState.enter() }
83
+ } label: {
84
+ HStack(spacing: 5) {
85
+ Image(systemName: tab.icon)
86
+ .font(.system(size: 10))
87
+ Text(tab.label)
88
+ .font(Typo.monoBold(11))
89
+ }
90
+ .foregroundColor(isActive ? Palette.text : Palette.textMuted)
91
+ .padding(.horizontal, 12)
92
+ .padding(.vertical, 6)
93
+ .background(
94
+ RoundedRectangle(cornerRadius: 6)
95
+ .fill(isActive ? Palette.surfaceHov : Color.clear)
96
+ )
97
+ }
98
+ .buttonStyle(.plain)
36
99
  }
37
100
 
38
101
  // MARK: - Content Area
@@ -40,10 +103,20 @@ struct AppShellView: View {
40
103
  @ViewBuilder
41
104
  private var contentArea: some View {
42
105
  switch windowController.activePage {
106
+ case .home:
107
+ HomeDashboardView(onNavigate: { page in
108
+ windowController.activePage = page
109
+ if page == .screenMap { controller.enter() }
110
+ if page == .desktopInventory { commandState.enter() }
111
+ })
43
112
  case .screenMap:
44
113
  ScreenMapView(controller: controller, onNavigate: { page in
45
114
  windowController.activePage = page
46
115
  })
116
+ case .desktopInventory:
117
+ CommandModeView(state: commandState)
118
+ case .pi:
119
+ PiWorkspaceView()
47
120
  case .settings:
48
121
  SettingsContentView(
49
122
  prefs: Preferences.shared,
@@ -0,0 +1,386 @@
1
+ import AppKit
2
+
3
+ // MARK: - Audio Provider Protocol
4
+
5
+ /// A provider that can capture audio and return transcriptions.
6
+ /// Lattices doesn't do transcription itself — it delegates to an external
7
+ /// service (Vox, Whisper, etc.) and maps the result to intents.
8
+ protocol AudioProvider: AnyObject {
9
+ var isAvailable: Bool { get }
10
+ var isListening: Bool { get }
11
+
12
+ /// Start listening. Transcription arrives via the callback.
13
+ func startListening(onTranscript: @escaping (Transcription) -> Void)
14
+
15
+ /// Stop listening and return the final transcription.
16
+ func stopListening(completion: @escaping (Transcription?) -> Void)
17
+
18
+ /// Check if the provider service is reachable.
19
+ func checkHealth(completion: @escaping (Bool) -> Void)
20
+ }
21
+
22
+ struct Transcription {
23
+ let text: String
24
+ let confidence: Double
25
+ let source: String // "vox", "whisper", etc.
26
+ let isPartial: Bool // true for streaming partial results
27
+ let durationMs: Int?
28
+ }
29
+
30
+ // MARK: - Audio Layer (coordinates provider + intent engine)
31
+
32
+ final class AudioLayer: ObservableObject {
33
+ static let shared = AudioLayer()
34
+
35
+ @Published var isListening = false
36
+ @Published var lastTranscript: String?
37
+ @Published var matchedIntent: String?
38
+ @Published var matchedSlots: [String: String] = [:]
39
+ @Published var matchConfidence: Double = 0
40
+ @Published var executionResult: String? // "ok" or error message
41
+ @Published var executionData: JSON? // Full result data from intent execution
42
+ @Published var provider: (any AudioProvider)?
43
+ @Published var providerName: String = "none"
44
+ @Published var agentResponse: AgentResponse?
45
+
46
+
47
+ private init() {
48
+ let vox = VoxAudioProvider()
49
+ provider = vox
50
+ providerName = "vox"
51
+ // Connection is managed by VoiceCommandWindow — not here.
52
+ // Connecting here would race with (and destroy) the existing WebSocket.
53
+ }
54
+
55
+ /// Start a voice command capture. Transcription is piped to the intent engine.
56
+ func startVoiceCommand() {
57
+ guard !isListening else { return }
58
+
59
+ // Clear previous state
60
+ lastTranscript = nil
61
+ matchedIntent = nil
62
+ matchedSlots = [:]
63
+ matchConfidence = 0
64
+ executionResult = nil
65
+ didExecuteIntent = false
66
+
67
+ guard let provider = provider else {
68
+ executionResult = "No voice provider — install Vox"
69
+ return
70
+ }
71
+
72
+ isListening = true
73
+
74
+ provider.startListening { [weak self] transcription in
75
+ DispatchQueue.main.async {
76
+ guard let self = self else { return }
77
+
78
+ if transcription.isPartial {
79
+ self.lastTranscript = transcription.text
80
+ return
81
+ }
82
+
83
+ // Final transcript (e.g. from streaming providers)
84
+ self.lastTranscript = transcription.text
85
+ self.isListening = false
86
+
87
+ // Empty transcript = transcription failed, don't try to execute
88
+ guard !transcription.text.isEmpty else {
89
+ if self.executionResult == nil || self.executionResult == "Transcribing..." {
90
+ self.executionResult = "No speech detected"
91
+ }
92
+ return
93
+ }
94
+
95
+ EventBus.shared.post(.voiceCommand(text: transcription.text, confidence: transcription.confidence))
96
+ self.executeVoiceIntent(transcription)
97
+ }
98
+ }
99
+ }
100
+
101
+ /// Track whether we already executed for this recording session.
102
+ private var didExecuteIntent = false
103
+
104
+ func stopVoiceCommand() {
105
+ guard let provider = provider, isListening else { return }
106
+
107
+ isListening = false
108
+ executionResult = "Transcribing..."
109
+
110
+ provider.stopListening { [weak self] transcription in
111
+ DispatchQueue.main.async {
112
+ guard let self = self else { return }
113
+ if let t = transcription {
114
+ self.lastTranscript = t.text
115
+ // Skip if the streaming callback already executed the intent
116
+ guard !self.didExecuteIntent else { return }
117
+ EventBus.shared.post(.voiceCommand(text: t.text, confidence: t.confidence))
118
+ self.executeVoiceIntent(t)
119
+ } else if !self.didExecuteIntent {
120
+ self.executionResult = "No speech detected"
121
+ }
122
+ }
123
+ }
124
+ }
125
+
126
+ private func executeVoiceIntent(_ transcription: Transcription) {
127
+ didExecuteIntent = true
128
+ let matcher = PhraseMatcher.shared
129
+
130
+ // Clear previous agent response
131
+ agentResponse = nil
132
+
133
+ if let match = matcher.match(text: transcription.text) {
134
+ matchedIntent = match.intentName
135
+ matchConfidence = match.confidence
136
+ matchedSlots = match.slots.reduce(into: [:]) { dict, pair in
137
+ dict[pair.key] = pair.value.stringValue ?? "\(pair.value)"
138
+ }
139
+ DiagnosticLog.shared.info("AudioLayer: matched '\(match.intentName)' via '\(match.matchedPhrase)' slots=\(matchedSlots)")
140
+
141
+ do {
142
+ let result = try matcher.execute(match)
143
+ executionResult = "ok"
144
+ executionData = result
145
+ DiagnosticLog.shared.info("AudioLayer: executed '\(match.intentName)' → ok")
146
+ } catch {
147
+ DiagnosticLog.shared.info("AudioLayer: intent error — \(error.localizedDescription), falling back to Claude")
148
+ executionResult = "thinking..."
149
+ executionData = nil
150
+ claudeFallback(transcription: transcription)
151
+ }
152
+
153
+ // Fire parallel Haiku advisor for 5+ word utterances
154
+ fireAdvisor(transcript: transcription.text, matched: "\(match.intentName)(\(matchedSlots))")
155
+
156
+ } else {
157
+ // No local match — Claude fallback
158
+ DiagnosticLog.shared.info("AudioLayer: no phrase match for '\(transcription.text)', falling back to Claude")
159
+ matchedIntent = nil
160
+ matchedSlots = [:]
161
+ executionResult = "thinking..."
162
+ executionData = nil
163
+ claudeFallback(transcription: transcription)
164
+ }
165
+ }
166
+
167
+ /// Fire the Haiku advisor in parallel — non-blocking, result arrives later.
168
+ private func fireAdvisor(transcript: String, matched: String) {
169
+ let haiku = AgentPool.shared.haiku
170
+ guard haiku.isReady else {
171
+ DiagnosticLog.shared.info("AudioLayer: advisor skipped (haiku not ready)")
172
+ return
173
+ }
174
+
175
+ let message = "Transcript: \"\(transcript)\"\nMatched: \(matched)"
176
+ DiagnosticLog.shared.info("AudioLayer: firing haiku advisor")
177
+
178
+ haiku.send(message: message) { [weak self] response in
179
+ guard let self = self, let response = response else { return }
180
+ self.agentResponse = response
181
+ if let commentary = response.commentary {
182
+ DiagnosticLog.shared.info("AudioLayer: haiku says — \(commentary)")
183
+ }
184
+ if let suggestion = response.suggestion {
185
+ DiagnosticLog.shared.info("AudioLayer: haiku suggests — \(suggestion.label) → \(suggestion.intent)")
186
+ }
187
+ }
188
+ }
189
+
190
+ private func claudeFallback(transcription: Transcription) {
191
+ DispatchQueue.global(qos: .userInitiated).async { [weak self] in
192
+ guard let self else { return }
193
+
194
+ let result = ClaudeFallback.resolve(
195
+ transcript: transcription.text,
196
+ windows: DesktopModel.shared.windows.values.map { $0 },
197
+ intentCatalog: PhraseMatcher.shared.catalog()
198
+ )
199
+
200
+ DispatchQueue.main.async {
201
+ guard let resolved = result else {
202
+ self.executionResult = "Claude couldn't resolve intent"
203
+ DiagnosticLog.shared.info("AudioLayer: Claude fallback returned nil")
204
+ return
205
+ }
206
+
207
+ DiagnosticLog.shared.info("AudioLayer: Claude resolved → \(resolved.intent) \(resolved.slots)")
208
+ self.matchedIntent = resolved.intent
209
+ self.matchedSlots = resolved.slots.reduce(into: [:]) { dict, pair in
210
+ dict[pair.key] = pair.value.stringValue ?? "\(pair.value)"
211
+ }
212
+
213
+ let intentMatch = IntentMatch(
214
+ intentName: resolved.intent,
215
+ slots: resolved.slots,
216
+ confidence: 0.8,
217
+ matchedPhrase: "claude-fallback"
218
+ )
219
+
220
+ do {
221
+ let execResult = try PhraseMatcher.shared.execute(intentMatch)
222
+ self.executionResult = "ok"
223
+ self.executionData = execResult
224
+ DiagnosticLog.shared.info("AudioLayer: Claude-resolved executed → \(execResult)")
225
+ } catch {
226
+ self.executionResult = error.localizedDescription
227
+ self.executionData = nil
228
+ DiagnosticLog.shared.info("AudioLayer: Claude-resolved execution error — \(error.localizedDescription)")
229
+ }
230
+ }
231
+ }
232
+ }
233
+ }
234
+
235
+ // Old IntentExtractor removed — PhraseMatcher handles all intent matching now.
236
+ // See app/Sources/Intents/LatticeIntent.swift
237
+
238
+
239
+ // MARK: - Vox Audio Provider (WebSocket JSON-RPC via VoxClient)
240
+ //
241
+ // Delegates recording and transcription entirely to the Vox daemon (voxd).
242
+ // Lattices never touches the mic — Vox owns the mic, recording, and
243
+ // transcription. We call transcribe.startSession to begin recording
244
+ // and transcribe.stopSession to stop and get the transcript.
245
+ //
246
+ // Session events flow on the startSession call ID:
247
+ // session.state: {state, sessionId, previous}
248
+ // session.final: {sessionId, text, words[], elapsedMs, metrics}
249
+
250
+ final class VoxAudioProvider: AudioProvider {
251
+ private var onTranscript: ((Transcription) -> Void)?
252
+ private var stopCompletion: ((Transcription?) -> Void)?
253
+ private var _isListening = false
254
+ private var startTime: Date?
255
+
256
+ var isAvailable: Bool {
257
+ VoxClient.shared.connectionState == .connected
258
+ }
259
+
260
+ var isListening: Bool { _isListening }
261
+
262
+ func checkHealth(completion: @escaping (Bool) -> Void) {
263
+ let client = VoxClient.shared
264
+ if client.connectionState == .connected {
265
+ client.call(method: "health") { result in
266
+ switch result {
267
+ case .success: DispatchQueue.main.async { completion(true) }
268
+ case .failure: DispatchQueue.main.async { completion(false) }
269
+ }
270
+ }
271
+ } else {
272
+ completion(false)
273
+ }
274
+ }
275
+
276
+ func startListening(onTranscript: @escaping (Transcription) -> Void) {
277
+ let client = VoxClient.shared
278
+ guard client.connectionState == .connected else {
279
+ DiagnosticLog.shared.warn("VoxAudioProvider: not connected to Vox")
280
+ onTranscript(Transcription(text: "", confidence: 0, source: "vox", isPartial: false, durationMs: nil))
281
+ return
282
+ }
283
+
284
+ self.onTranscript = onTranscript
285
+ _isListening = true
286
+ startTime = Date()
287
+
288
+ DiagnosticLog.shared.info("VoxAudioProvider: starting session via Vox")
289
+
290
+ // transcribe.startSession — Vox records from mic, emits session events on this call ID
291
+ client.startSession(
292
+ onProgress: { [weak self] event, data in
293
+ guard let self else { return }
294
+ DispatchQueue.main.async {
295
+ switch event {
296
+ case "session.state":
297
+ let state = data["state"] as? String ?? ""
298
+ DiagnosticLog.shared.info("VoxAudioProvider: session → \(state)")
299
+
300
+ case "session.final":
301
+ // Final transcript arrived — deliver it
302
+ if let text = data["text"] as? String, !text.isEmpty {
303
+ let elapsed = data["elapsedMs"] as? Int
304
+ let t = Transcription(
305
+ text: text, confidence: 0.95, source: "vox",
306
+ isPartial: false, durationMs: elapsed
307
+ )
308
+ DiagnosticLog.shared.info("VoxAudioProvider: transcribed → '\(text)' (\(elapsed ?? 0)ms)")
309
+ self.onTranscript?(t)
310
+ self.stopCompletion?(t)
311
+ self.stopCompletion = nil
312
+ }
313
+
314
+ default:
315
+ break
316
+ }
317
+ }
318
+ },
319
+ completion: { [weak self] result in
320
+ guard let self else { return }
321
+ DispatchQueue.main.async {
322
+ self._isListening = false
323
+
324
+ switch result {
325
+ case .success(let data):
326
+ // Final result also comes here (same data as session.final)
327
+ if let text = data["text"] as? String, !text.isEmpty,
328
+ self.stopCompletion != nil {
329
+ // Only deliver if session.final didn't already
330
+ let elapsed = data["elapsedMs"] as? Int
331
+ let t = Transcription(
332
+ text: text, confidence: 0.95, source: "vox",
333
+ isPartial: false, durationMs: elapsed
334
+ )
335
+ self.onTranscript?(t)
336
+ self.stopCompletion?(t)
337
+ self.stopCompletion = nil
338
+ } else if self.stopCompletion != nil {
339
+ self.stopCompletion?(nil)
340
+ self.stopCompletion = nil
341
+ }
342
+
343
+ case .failure(let error):
344
+ DiagnosticLog.shared.warn("VoxAudioProvider: session error — \(error.localizedDescription)")
345
+ if case .sessionBusy = error {
346
+ AudioLayer.shared.executionResult = "Session already active"
347
+ } else {
348
+ AudioLayer.shared.executionResult = "Transcription failed"
349
+ }
350
+ self.onTranscript?(Transcription(
351
+ text: "", confidence: 0, source: "vox",
352
+ isPartial: false, durationMs: nil
353
+ ))
354
+ self.stopCompletion?(nil)
355
+ self.stopCompletion = nil
356
+ }
357
+ }
358
+ }
359
+ )
360
+ }
361
+
362
+ func stopListening(completion: @escaping (Transcription?) -> Void) {
363
+ _isListening = false
364
+
365
+ let client = VoxClient.shared
366
+ guard client.connectionState == .connected else {
367
+ completion(nil)
368
+ return
369
+ }
370
+
371
+ DiagnosticLog.shared.info("VoxAudioProvider: stopping session")
372
+
373
+ // Store completion — the startSession's session.final event delivers the transcript
374
+ self.stopCompletion = completion
375
+
376
+ client.stopSession { result in
377
+ if case .failure(let error) = result {
378
+ DiagnosticLog.shared.warn("VoxAudioProvider: stopSession error — \(error.localizedDescription)")
379
+ DispatchQueue.main.async {
380
+ self.stopCompletion?(nil)
381
+ self.stopCompletion = nil
382
+ }
383
+ }
384
+ }
385
+ }
386
+ }