@lattices/cli 0.4.9 → 0.4.11

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 (201) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +13 -13
  3. package/{app → apps/mac}/Lattices.app/Contents/Info.plist +10 -2
  4. package/{app → apps/mac}/Lattices.app/Contents/MacOS/Lattices +0 -0
  5. package/{app → apps/mac}/Package.swift +2 -1
  6. package/apps/mac/Resources/Pets/assistant-spark/pet.json +62 -0
  7. package/apps/mac/Resources/Pets/assistant-spark/spritesheet.webp +0 -0
  8. package/apps/mac/Resources/Pets/scout-ranger/pet.json +6 -0
  9. package/apps/mac/Resources/Pets/scout-ranger/spritesheet.webp +0 -0
  10. package/apps/mac/Sources/AppShell/AppActivationCoordinator.swift +27 -0
  11. package/apps/mac/Sources/AppShell/AppDelegate.swift +189 -0
  12. package/apps/mac/Sources/AppShell/AppServicesBootstrap.swift +25 -0
  13. package/{app → apps/mac}/Sources/AppShell/AppShellView.swift +18 -3
  14. package/{app → apps/mac}/Sources/AppShell/AppUpdater.swift +4 -3
  15. package/apps/mac/Sources/AppShell/HotkeyBootstrap.swift +87 -0
  16. package/{app → apps/mac}/Sources/AppShell/LatticesRuntime.swift +43 -0
  17. package/{app → apps/mac}/Sources/AppShell/MainView.swift +116 -63
  18. package/apps/mac/Sources/AppShell/MenuBarController.swift +177 -0
  19. package/{app → apps/mac}/Sources/AppShell/OnboardingView.swift +72 -60
  20. package/apps/mac/Sources/AppShell/PermissionsAssistantView.swift +366 -0
  21. package/apps/mac/Sources/AppShell/PermissionsAssistantWindow.swift +70 -0
  22. package/{app → apps/mac}/Sources/AppShell/Preferences.swift +37 -2
  23. package/{app → apps/mac}/Sources/AppShell/SettingsView.swift +815 -156
  24. package/{app → apps/mac}/Sources/AppShell/SettingsWindow.swift +10 -0
  25. package/apps/mac/Sources/AppShell/WorkspaceInspectorPresenter.swift +13 -0
  26. package/{app → apps/mac}/Sources/Core/Actions/HotkeyStore.swift +6 -1
  27. package/{app → apps/mac}/Sources/Core/Actions/IntentEngine.swift +2 -0
  28. package/{app → apps/mac}/Sources/Core/Daemon/DaemonServer.swift +5 -0
  29. package/{app → apps/mac}/Sources/Core/Daemon/LatticesApi.swift +365 -0
  30. package/{app → apps/mac}/Sources/Core/Desktop/DesktopModel.swift +1 -0
  31. package/{app → apps/mac}/Sources/Core/Desktop/OcrModel.swift +17 -13
  32. package/apps/mac/Sources/Core/Desktop/WindowCapture.swift +33 -0
  33. package/{app → apps/mac}/Sources/Core/Desktop/WindowDragSnapController.swift +18 -217
  34. package/{app → apps/mac}/Sources/Core/Desktop/WindowPreviewStore.swift +4 -5
  35. package/{app → apps/mac}/Sources/Core/Desktop/WindowTiler.swift +19 -13
  36. package/apps/mac/Sources/Core/Input/EventTapBreaker.swift +124 -0
  37. package/apps/mac/Sources/Core/Input/EventTapThread.swift +54 -0
  38. package/apps/mac/Sources/Core/Input/InputCaptureResetCenter.swift +20 -0
  39. package/apps/mac/Sources/Core/Input/KeyboardRemapController.swift +335 -0
  40. package/apps/mac/Sources/Core/Input/KeyboardRemapStore.swift +141 -0
  41. package/{app → apps/mac}/Sources/Core/Input/MouseGestureConfig.swift +155 -20
  42. package/apps/mac/Sources/Core/Input/MouseGestureController.swift +2259 -0
  43. package/apps/mac/Sources/Core/Input/MouseShortcutStore.swift +170 -0
  44. package/apps/mac/Sources/Core/Input/SecureEventInputMonitor.swift +39 -0
  45. package/apps/mac/Sources/Core/Input/ShapeRecognizer.swift +624 -0
  46. package/apps/mac/Sources/Core/Input/TapBudgetMeter.swift +56 -0
  47. package/{app → apps/mac}/Sources/Core/Overlays/ScreenMap/ScreenMapState.swift +46 -27
  48. package/{app → apps/mac}/Sources/Core/Overlays/ScreenMap/ScreenMapView.swift +580 -162
  49. package/apps/mac/Sources/Core/Overlays/ScreenOverlayCanvasController.swift +1240 -0
  50. package/{app → apps/mac}/Sources/Core/Overlays/Voice/VoiceCommandWindow.swift +11 -23
  51. package/{app → apps/mac}/Sources/Core/Pi/PiChatDock.swift +90 -43
  52. package/{app → apps/mac}/Sources/Core/Pi/PiChatSession.swift +676 -43
  53. package/{app → apps/mac}/Sources/Core/Pi/PiProviderSetupCallout.swift +5 -5
  54. package/{app → apps/mac}/Sources/Core/Pi/PiWorkspaceView.swift +93 -44
  55. package/apps/mac/Sources/Core/System/Capability.swift +79 -0
  56. package/{app → apps/mac}/Sources/Core/System/PermissionChecker.swift +43 -8
  57. package/{app → apps/mac}/Sources/Core/Voice/AudioProvider.swift +225 -56
  58. package/bin/handsoff-infer.ts +14 -5
  59. package/bin/handsoff-worker.ts +11 -7
  60. package/bin/infer.ts +406 -0
  61. package/bin/lattices-app.ts +57 -7
  62. package/bin/lattices-dev +40 -1
  63. package/bin/lattices.ts +1 -1
  64. package/docs/agent-execution-plan.md +9 -9
  65. package/docs/api.md +119 -0
  66. package/docs/app.md +1 -0
  67. package/docs/companion-deck.md +1 -1
  68. package/docs/gesture-customization-proposal.md +520 -0
  69. package/docs/mouse-gestures.md +79 -0
  70. package/docs/overview.md +2 -2
  71. package/docs/presentation-execution-review.md +9 -9
  72. package/docs/proposals/LAT-001-gesture-visual-customization.md +522 -0
  73. package/docs/proposals/LAT-002-shared-overlay-canvas.md +353 -0
  74. package/docs/proposals/LAT-003-menu-bar-controller-architecture.md +291 -0
  75. package/docs/proposals/LAT-004-interactive-overlay-actors.md +534 -0
  76. package/docs/reference/dewey.config.ts +74 -0
  77. package/docs/reference/install-agent.md +79 -0
  78. package/docs/repo-structure.md +100 -0
  79. package/docs/voice-error-model.md +7 -7
  80. package/docs/voice.md +18 -0
  81. package/package.json +23 -13
  82. package/swift/Package.swift +20 -0
  83. package/swift/Sources/DeckKit/DeckAction.swift +51 -0
  84. package/swift/Sources/DeckKit/DeckBridgeSecurity.swift +152 -0
  85. package/swift/Sources/DeckKit/DeckCockpit.swift +82 -0
  86. package/swift/Sources/DeckKit/DeckHost.swift +7 -0
  87. package/swift/Sources/DeckKit/DeckManifest.swift +145 -0
  88. package/swift/Sources/DeckKit/DeckRuntimeSnapshot.swift +533 -0
  89. package/swift/Sources/DeckKit/DeckTrackpad.swift +63 -0
  90. package/swift/Sources/DeckKit/DeckValue.swift +93 -0
  91. package/swift/Sources/DeckKit/DeckVoiceError.swift +88 -0
  92. package/swift/Tests/DeckKitTests/DeckKitTests.swift +286 -0
  93. package/app/Sources/AppShell/AppDelegate.swift +0 -408
  94. package/app/Sources/Core/Input/KeyboardRemapController.swift +0 -184
  95. package/app/Sources/Core/Input/KeyboardRemapStore.swift +0 -84
  96. package/app/Sources/Core/Input/MouseGestureController.swift +0 -1203
  97. package/app/Sources/Core/Input/MouseShortcutStore.swift +0 -107
  98. /package/{app → apps/mac}/Info.plist +0 -0
  99. /package/{app → apps/mac}/Lattices.app/Contents/Resources/AppIcon.icns +0 -0
  100. /package/{app → apps/mac}/Lattices.app/Contents/Resources/tap.wav +0 -0
  101. /package/{app → apps/mac}/Lattices.app/Contents/_CodeSignature/CodeResources +0 -0
  102. /package/{app → apps/mac}/Lattices.entitlements +0 -0
  103. /package/{app → apps/mac}/Resources/tap.wav +0 -0
  104. /package/{app → apps/mac}/Sources/AppShell/App.swift +0 -0
  105. /package/{app → apps/mac}/Sources/AppShell/CliActionLauncher.swift +0 -0
  106. /package/{app → apps/mac}/Sources/AppShell/HomeDashboardView.swift +0 -0
  107. /package/{app → apps/mac}/Sources/AppShell/KeyRecorderView.swift +0 -0
  108. /package/{app → apps/mac}/Sources/AppShell/MainWindow.swift +0 -0
  109. /package/{app → apps/mac}/Sources/Core/Actions/HotkeyManager.swift +0 -0
  110. /package/{app → apps/mac}/Sources/Core/Actions/IntentSchema.swift +0 -0
  111. /package/{app → apps/mac}/Sources/Core/Actions/Intents/CreateLayerIntent.swift +0 -0
  112. /package/{app → apps/mac}/Sources/Core/Actions/Intents/DistributeIntent.swift +0 -0
  113. /package/{app → apps/mac}/Sources/Core/Actions/Intents/FocusIntent.swift +0 -0
  114. /package/{app → apps/mac}/Sources/Core/Actions/Intents/HelpIntent.swift +0 -0
  115. /package/{app → apps/mac}/Sources/Core/Actions/Intents/KillIntent.swift +0 -0
  116. /package/{app → apps/mac}/Sources/Core/Actions/Intents/LatticeIntent.swift +0 -0
  117. /package/{app → apps/mac}/Sources/Core/Actions/Intents/LaunchIntent.swift +0 -0
  118. /package/{app → apps/mac}/Sources/Core/Actions/Intents/ListSessionsIntent.swift +0 -0
  119. /package/{app → apps/mac}/Sources/Core/Actions/Intents/ListWindowsIntent.swift +0 -0
  120. /package/{app → apps/mac}/Sources/Core/Actions/Intents/ScanIntent.swift +0 -0
  121. /package/{app → apps/mac}/Sources/Core/Actions/Intents/SearchIntent.swift +0 -0
  122. /package/{app → apps/mac}/Sources/Core/Actions/Intents/SwitchLayerIntent.swift +0 -0
  123. /package/{app → apps/mac}/Sources/Core/Actions/Intents/TileIntent.swift +0 -0
  124. /package/{app → apps/mac}/Sources/Core/Actions/PaletteCommand.swift +0 -0
  125. /package/{app → apps/mac}/Sources/Core/Actions/VoiceIntentResolver.swift +0 -0
  126. /package/{app → apps/mac}/Sources/Core/Companion/CompanionActivityLog.swift +0 -0
  127. /package/{app → apps/mac}/Sources/Core/Companion/CompanionKeyboardController.swift +0 -0
  128. /package/{app → apps/mac}/Sources/Core/Companion/LatticesCompanionBridgeServer.swift +0 -0
  129. /package/{app → apps/mac}/Sources/Core/Companion/LatticesCompanionCockpit.swift +0 -0
  130. /package/{app → apps/mac}/Sources/Core/Companion/LatticesCompanionSecurityCoordinator.swift +0 -0
  131. /package/{app → apps/mac}/Sources/Core/Companion/LatticesCompanionTrackpadController.swift +0 -0
  132. /package/{app → apps/mac}/Sources/Core/Companion/LatticesDeckHost.swift +0 -0
  133. /package/{app → apps/mac}/Sources/Core/Daemon/DaemonProtocol.swift +0 -0
  134. /package/{app → apps/mac}/Sources/Core/Desktop/AccessibilityTextExtractor.swift +0 -0
  135. /package/{app → apps/mac}/Sources/Core/Desktop/AppTypeClassifier.swift +0 -0
  136. /package/{app → apps/mac}/Sources/Core/Desktop/DesktopModelTypes.swift +0 -0
  137. /package/{app → apps/mac}/Sources/Core/Desktop/InventoryManager.swift +0 -0
  138. /package/{app → apps/mac}/Sources/Core/Desktop/InventoryPath.swift +0 -0
  139. /package/{app → apps/mac}/Sources/Core/Desktop/MouseFinder.swift +0 -0
  140. /package/{app → apps/mac}/Sources/Core/Desktop/OcrStore.swift +0 -0
  141. /package/{app → apps/mac}/Sources/Core/Desktop/PlacementSpec.swift +0 -0
  142. /package/{app → apps/mac}/Sources/Core/Desktop/SessionWindowLocator.swift +0 -0
  143. /package/{app → apps/mac}/Sources/Core/Desktop/TilePickerView.swift +0 -0
  144. /package/{app → apps/mac}/Sources/Core/Desktop/WindowPreviewCard.swift +0 -0
  145. /package/{app → apps/mac}/Sources/Core/Desktop/WindowSelectionStore.swift +0 -0
  146. /package/{app → apps/mac}/Sources/Core/Input/KeyboardRemapConfig.swift +0 -0
  147. /package/{app → apps/mac}/Sources/Core/Input/MouseInputDeviceStore.swift +0 -0
  148. /package/{app → apps/mac}/Sources/Core/Input/MouseInputEventViewer.swift +0 -0
  149. /package/{app → apps/mac}/Sources/Core/Overlays/AppWindowShell.swift +0 -0
  150. /package/{app → apps/mac}/Sources/Core/Overlays/CommandMode/CommandModeState.swift +0 -0
  151. /package/{app → apps/mac}/Sources/Core/Overlays/CommandMode/CommandModeView.swift +0 -0
  152. /package/{app → apps/mac}/Sources/Core/Overlays/CommandMode/CommandModeWindow.swift +0 -0
  153. /package/{app → apps/mac}/Sources/Core/Overlays/CommandPalette/CommandPaletteView.swift +0 -0
  154. /package/{app → apps/mac}/Sources/Core/Overlays/CommandPalette/CommandPaletteWindow.swift +0 -0
  155. /package/{app → apps/mac}/Sources/Core/Overlays/HUD/CheatSheetHUD.swift +0 -0
  156. /package/{app → apps/mac}/Sources/Core/Overlays/HUD/HUDBottomBar.swift +0 -0
  157. /package/{app → apps/mac}/Sources/Core/Overlays/HUD/HUDController.swift +0 -0
  158. /package/{app → apps/mac}/Sources/Core/Overlays/HUD/HUDLeftBar.swift +0 -0
  159. /package/{app → apps/mac}/Sources/Core/Overlays/HUD/HUDMinimap.swift +0 -0
  160. /package/{app → apps/mac}/Sources/Core/Overlays/HUD/HUDRightBar.swift +0 -0
  161. /package/{app → apps/mac}/Sources/Core/Overlays/HUD/HUDState.swift +0 -0
  162. /package/{app → apps/mac}/Sources/Core/Overlays/HUD/HUDTopBar.swift +0 -0
  163. /package/{app → apps/mac}/Sources/Core/Overlays/HUD/LauncherHUD.swift +0 -0
  164. /package/{app → apps/mac}/Sources/Core/Overlays/HUD/LayerBezel.swift +0 -0
  165. /package/{app → apps/mac}/Sources/Core/Overlays/OmniSearch/OmniSearchState.swift +0 -0
  166. /package/{app → apps/mac}/Sources/Core/Overlays/OmniSearch/OmniSearchView.swift +0 -0
  167. /package/{app → apps/mac}/Sources/Core/Overlays/OmniSearch/OmniSearchWindow.swift +0 -0
  168. /package/{app → apps/mac}/Sources/Core/Overlays/OverlayPanelShell.swift +0 -0
  169. /package/{app → apps/mac}/Sources/Core/Overlays/ScreenMap/ScreenMapWindowController.swift +0 -0
  170. /package/{app → apps/mac}/Sources/Core/Pi/PiAuthNextStepCard.swift +0 -0
  171. /package/{app → apps/mac}/Sources/Core/Pi/PiAuthPromptCard.swift +0 -0
  172. /package/{app → apps/mac}/Sources/Core/Pi/PiInstallCallout.swift +0 -0
  173. /package/{app → apps/mac}/Sources/Core/System/DiagnosticLog.swift +0 -0
  174. /package/{app → apps/mac}/Sources/Core/System/EventBus.swift +0 -0
  175. /package/{app → apps/mac}/Sources/Core/System/ProcessModel.swift +0 -0
  176. /package/{app → apps/mac}/Sources/Core/System/ProcessQuery.swift +0 -0
  177. /package/{app → apps/mac}/Sources/Core/System/SystemTelemetryMonitor.swift +0 -0
  178. /package/{app → apps/mac}/Sources/Core/Voice/AdvisorLearningStore.swift +0 -0
  179. /package/{app → apps/mac}/Sources/Core/Voice/AgentSession.swift +0 -0
  180. /package/{app → apps/mac}/Sources/Core/Voice/HandsOffSession.swift +0 -0
  181. /package/{app → apps/mac}/Sources/Core/Voice/VoiceChatView.swift +0 -0
  182. /package/{app → apps/mac}/Sources/Core/Voice/VoxClient.swift +0 -0
  183. /package/{app → apps/mac}/Sources/Core/Workspace/Project.swift +0 -0
  184. /package/{app → apps/mac}/Sources/Core/Workspace/ProjectScanner.swift +0 -0
  185. /package/{app → apps/mac}/Sources/Core/Workspace/SessionLayerStore.swift +0 -0
  186. /package/{app → apps/mac}/Sources/Core/Workspace/SessionManager.swift +0 -0
  187. /package/{app → apps/mac}/Sources/Core/Workspace/Terminal/Terminal.swift +0 -0
  188. /package/{app → apps/mac}/Sources/Core/Workspace/Terminal/TerminalQuery.swift +0 -0
  189. /package/{app → apps/mac}/Sources/Core/Workspace/Terminal/TerminalSynthesizer.swift +0 -0
  190. /package/{app → apps/mac}/Sources/Core/Workspace/Tmux/TmuxModel.swift +0 -0
  191. /package/{app → apps/mac}/Sources/Core/Workspace/Tmux/TmuxQuery.swift +0 -0
  192. /package/{app → apps/mac}/Sources/Core/Workspace/WorkspaceManager.swift +0 -0
  193. /package/{app → apps/mac}/Sources/UI/ActionRow.swift +0 -0
  194. /package/{app → apps/mac}/Sources/UI/OrphanRow.swift +0 -0
  195. /package/{app → apps/mac}/Sources/UI/ProjectRow.swift +0 -0
  196. /package/{app → apps/mac}/Sources/UI/TabGroupRow.swift +0 -0
  197. /package/{app → apps/mac}/Sources/UI/Theme.swift +0 -0
  198. /package/{app → apps/mac}/Tests/StageDragTests.swift +0 -0
  199. /package/{app → apps/mac}/Tests/StageJoinTests.swift +0 -0
  200. /package/{app → apps/mac}/Tests/StageManagerTests.swift +0 -0
  201. /package/{app → apps/mac}/Tests/StageTileTests.swift +0 -0
@@ -43,13 +43,15 @@ final class AudioLayer: ObservableObject {
43
43
  @Published var providerName: String = "none"
44
44
  @Published var agentResponse: AgentResponse?
45
45
 
46
+ private var pendingVoiceStart = false
47
+ private var voiceConnectionRetry: DispatchWorkItem?
46
48
 
47
49
  private init() {
48
50
  let vox = VoxAudioProvider()
49
51
  provider = vox
50
52
  providerName = "vox"
51
- // Connection is managed by VoiceCommandWindow not here.
52
- // Connecting here would race with (and destroy) the existing WebSocket.
53
+ // Voice entry points can arrive from the desktop UI, daemon, or iOS
54
+ // bridge, so connection setup is handled lazily when capture starts.
53
55
  }
54
56
 
55
57
  /// Start a voice command capture. Transcription is piped to the intent engine.
@@ -69,6 +71,47 @@ final class AudioLayer: ObservableObject {
69
71
  return
70
72
  }
71
73
 
74
+ pendingVoiceStart = true
75
+ voiceConnectionRetry?.cancel()
76
+ startVoiceCommandWhenReady(provider: provider, attempt: 0)
77
+ }
78
+
79
+ private func startVoiceCommandWhenReady(provider: any AudioProvider, attempt: Int) {
80
+ guard pendingVoiceStart, !isListening else { return }
81
+
82
+ if provider.isAvailable {
83
+ pendingVoiceStart = false
84
+ beginVoiceCommand(provider: provider)
85
+ return
86
+ }
87
+
88
+ let client = VoxClient.shared
89
+ if attempt == 0 {
90
+ let launched = launchVoxIfNeeded()
91
+ executionResult = launched ? "Starting Vox..." : "Connecting to Vox..."
92
+ client.connect()
93
+ } else if case .disconnected = client.connectionState {
94
+ client.connect()
95
+ } else if case .unavailable = client.connectionState {
96
+ client.connect()
97
+ }
98
+
99
+ guard attempt < 40 else {
100
+ pendingVoiceStart = false
101
+ executionResult = "Vox unavailable — open Vox and try again"
102
+ DiagnosticLog.shared.warn("AudioLayer: Vox connection failed before voice start")
103
+ return
104
+ }
105
+
106
+ let retry = DispatchWorkItem { [weak self] in
107
+ guard let self else { return }
108
+ self.startVoiceCommandWhenReady(provider: provider, attempt: attempt + 1)
109
+ }
110
+ voiceConnectionRetry = retry
111
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.25, execute: retry)
112
+ }
113
+
114
+ private func beginVoiceCommand(provider: any AudioProvider) {
72
115
  isListening = true
73
116
 
74
117
  provider.startListening { [weak self] transcription in
@@ -85,7 +128,7 @@ final class AudioLayer: ObservableObject {
85
128
  self.isListening = false
86
129
 
87
130
  // Empty transcript = transcription failed, don't try to execute
88
- guard !transcription.text.isEmpty else {
131
+ guard !transcription.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
89
132
  if self.executionResult == nil || self.executionResult == "Transcribing..." {
90
133
  self.executionResult = "No speech detected"
91
134
  }
@@ -98,19 +141,54 @@ final class AudioLayer: ObservableObject {
98
141
  }
99
142
  }
100
143
 
144
+ private func launchVoxIfNeeded() -> Bool {
145
+ guard VoxClient.shared.discoverDaemon() == nil else { return false }
146
+
147
+ let candidates = [
148
+ "/Applications/Vox.app",
149
+ NSHomeDirectory() + "/Applications/Vox.app",
150
+ ]
151
+
152
+ guard let path = candidates.first(where: { FileManager.default.fileExists(atPath: $0) }) else {
153
+ DiagnosticLog.shared.warn("AudioLayer: Vox daemon unavailable and Vox.app was not found")
154
+ return false
155
+ }
156
+
157
+ let configuration = NSWorkspace.OpenConfiguration()
158
+ configuration.activates = false
159
+ NSWorkspace.shared.openApplication(at: URL(fileURLWithPath: path), configuration: configuration) { _, error in
160
+ if let error {
161
+ DiagnosticLog.shared.warn("AudioLayer: failed to open Vox — \(error.localizedDescription)")
162
+ } else {
163
+ DiagnosticLog.shared.info("AudioLayer: opened Vox for voice command")
164
+ }
165
+ }
166
+ return true
167
+ }
168
+
101
169
  /// Track whether we already executed for this recording session.
102
170
  private var didExecuteIntent = false
103
171
 
104
172
  func stopVoiceCommand() {
173
+ if pendingVoiceStart, !isListening {
174
+ pendingVoiceStart = false
175
+ voiceConnectionRetry?.cancel()
176
+ voiceConnectionRetry = nil
177
+ executionResult = "Voice cancelled"
178
+ return
179
+ }
180
+
105
181
  guard let provider = provider, isListening else { return }
106
182
 
183
+ pendingVoiceStart = false
107
184
  isListening = false
108
185
  executionResult = "Transcribing..."
109
186
 
110
187
  provider.stopListening { [weak self] transcription in
111
188
  DispatchQueue.main.async {
112
189
  guard let self = self else { return }
113
- if let t = transcription {
190
+ if let t = transcription,
191
+ !t.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
114
192
  self.lastTranscript = t.text
115
193
  // Skip if the streaming callback already executed the intent
116
194
  guard !self.didExecuteIntent else { return }
@@ -130,6 +208,16 @@ final class AudioLayer: ObservableObject {
130
208
  // Clear previous agent response
131
209
  agentResponse = nil
132
210
 
211
+ if shouldAnswerWithAssistant(transcription.text) {
212
+ DiagnosticLog.shared.info("AudioLayer: question-like voice request, asking Assistant provider")
213
+ matchedIntent = nil
214
+ matchedSlots = [:]
215
+ executionResult = "thinking..."
216
+ executionData = nil
217
+ assistantQuestion(transcription: transcription)
218
+ return
219
+ }
220
+
133
221
  if let match = matcher.match(text: transcription.text) {
134
222
  matchedIntent = match.intentName
135
223
  matchConfidence = match.confidence
@@ -140,100 +228,181 @@ final class AudioLayer: ObservableObject {
140
228
 
141
229
  do {
142
230
  let result = try matcher.execute(match)
143
- executionResult = "ok"
231
+ executionResult = voiceResultSummary(for: match, result: result)
144
232
  executionData = result
145
- DiagnosticLog.shared.info("AudioLayer: executed '\(match.intentName)' → ok")
233
+ DiagnosticLog.shared.info("AudioLayer: executed '\(match.intentName)' → \(executionResult ?? "ok")")
146
234
  } catch {
147
- DiagnosticLog.shared.info("AudioLayer: intent error — \(error.localizedDescription), falling back to Claude")
235
+ DiagnosticLog.shared.info("AudioLayer: intent error — \(error.localizedDescription), asking Assistant provider")
148
236
  executionResult = "thinking..."
149
237
  executionData = nil
150
- claudeFallback(transcription: transcription)
238
+ assistantFallback(transcription: transcription)
151
239
  }
152
240
 
153
- // Fire parallel Haiku advisor for 5+ word utterances
241
+ // Fire parallel provider-backed advisor for 5+ word utterances.
154
242
  fireAdvisor(transcript: transcription.text, matched: "\(match.intentName)(\(matchedSlots))")
155
243
 
156
244
  } else {
157
- // No local match — Claude fallback
158
- DiagnosticLog.shared.info("AudioLayer: no phrase match for '\(transcription.text)', falling back to Claude")
245
+ // No local match — ask the selected Assistant provider.
246
+ DiagnosticLog.shared.info("AudioLayer: no phrase match for '\(transcription.text)', asking Assistant provider")
159
247
  matchedIntent = nil
160
248
  matchedSlots = [:]
161
249
  executionResult = "thinking..."
162
250
  executionData = nil
163
- claudeFallback(transcription: transcription)
251
+ assistantFallback(transcription: transcription)
164
252
  }
165
253
  }
166
254
 
167
- /// Fire the Haiku advisor in parallel — non-blocking, result arrives later.
255
+ /// Fire the selected Assistant provider in parallel — non-blocking, result arrives later.
168
256
  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)")
257
+ let assistant = PiChatSession.shared
258
+ guard assistant.isProviderInferenceReady else {
259
+ DiagnosticLog.shared.info("AudioLayer: advisor skipped (Assistant provider not ready)")
172
260
  return
173
261
  }
174
262
 
175
- let message = "Transcript: \"\(transcript)\"\nMatched: \(matched)"
176
- DiagnosticLog.shared.info("AudioLayer: firing haiku advisor")
263
+ DiagnosticLog.shared.info("AudioLayer: firing Assistant advisor via \(assistant.currentProvider.name)")
177
264
 
178
- haiku.send(message: message) { [weak self] response in
265
+ assistant.askVoiceAdvisor(transcript: transcript, matched: matched) { [weak self] response in
179
266
  guard let self = self, let response = response else { return }
180
267
  self.agentResponse = response
181
268
  if let commentary = response.commentary {
182
- DiagnosticLog.shared.info("AudioLayer: haiku says — \(commentary)")
269
+ DiagnosticLog.shared.info("AudioLayer: Assistant advisor says — \(commentary)")
183
270
  }
184
271
  if let suggestion = response.suggestion {
185
- DiagnosticLog.shared.info("AudioLayer: haiku suggests — \(suggestion.label) → \(suggestion.intent)")
272
+ DiagnosticLog.shared.info("AudioLayer: Assistant advisor suggests — \(suggestion.label) → \(suggestion.intent)")
186
273
  }
187
274
  }
188
275
  }
189
276
 
190
- private func claudeFallback(transcription: Transcription) {
191
- DispatchQueue.global(qos: .userInitiated).async { [weak self] in
277
+ private func assistantFallback(transcription: Transcription) {
278
+ let assistant = PiChatSession.shared
279
+ guard assistant.isProviderInferenceReady else {
280
+ executionResult = "Connect an Assistant provider in Settings"
281
+ DiagnosticLog.shared.info("AudioLayer: Assistant provider not ready")
282
+ return
283
+ }
284
+
285
+ assistant.resolveVoiceIntent(transcript: transcription.text) { [weak self] resolved in
192
286
  guard let self else { return }
287
+ guard let resolved else {
288
+ self.executionResult = "Assistant couldn't resolve intent"
289
+ DiagnosticLog.shared.info("AudioLayer: Assistant provider returned no intent")
290
+ return
291
+ }
193
292
 
194
- let result = ClaudeFallback.resolve(
195
- transcript: transcription.text,
196
- windows: DesktopModel.shared.windows.values.map { $0 },
197
- intentCatalog: PhraseMatcher.shared.catalog()
293
+ DiagnosticLog.shared.info("AudioLayer: Assistant resolved → \(resolved.intent) \(resolved.slots)")
294
+ self.matchedIntent = resolved.intent
295
+ self.matchedSlots = resolved.slots.reduce(into: [:]) { dict, pair in
296
+ dict[pair.key] = pair.value.stringValue ?? "\(pair.value)"
297
+ }
298
+
299
+ let intentMatch = IntentMatch(
300
+ intentName: resolved.intent,
301
+ slots: resolved.slots,
302
+ confidence: 0.8,
303
+ matchedPhrase: "assistant-provider"
198
304
  )
199
305
 
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
- }
306
+ do {
307
+ let execResult = try PhraseMatcher.shared.execute(intentMatch)
308
+ self.executionResult = self.voiceResultSummary(for: intentMatch, result: execResult)
309
+ self.executionData = execResult
310
+ DiagnosticLog.shared.info("AudioLayer: Assistant-resolved executed → \(self.executionResult ?? "\(execResult)")")
311
+ } catch {
312
+ self.executionResult = error.localizedDescription
313
+ self.executionData = nil
314
+ DiagnosticLog.shared.info("AudioLayer: Assistant-resolved execution error — \(error.localizedDescription)")
315
+ }
316
+ }
317
+ }
206
318
 
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
- }
319
+ private func assistantQuestion(transcription: Transcription) {
320
+ let assistant = PiChatSession.shared
321
+ guard assistant.isProviderInferenceReady else {
322
+ executionResult = "Connect an Assistant provider in Settings"
323
+ DiagnosticLog.shared.info("AudioLayer: Assistant provider not ready for question")
324
+ return
325
+ }
212
326
 
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
- }
327
+ assistant.answerVoiceQuestion(transcription.text) { [weak self] response in
328
+ guard let self else { return }
329
+ guard let response else {
330
+ self.executionResult = "Assistant couldn't answer"
331
+ DiagnosticLog.shared.info("AudioLayer: Assistant provider returned no answer")
332
+ return
333
+ }
334
+ self.agentResponse = response
335
+ self.executionResult = "ok"
336
+ self.executionData = nil
337
+ if let commentary = response.commentary {
338
+ DiagnosticLog.shared.info("AudioLayer: Assistant answered \(commentary.prefix(160))")
230
339
  }
231
340
  }
232
341
  }
342
+
343
+ private func shouldAnswerWithAssistant(_ text: String) -> Bool {
344
+ let lower = text.lowercased()
345
+ let questionStarters = [
346
+ "what", "how", "why", "where", "when", "who",
347
+ "can you tell", "tell me about", "explain", "summarize", "describe"
348
+ ]
349
+ let asksQuestion = text.contains("?") || questionStarters.contains(where: lower.hasPrefix)
350
+ guard asksQuestion else { return false }
351
+
352
+ let assistantTopics = [
353
+ "setting", "settings", "configured", "enabled", "disabled",
354
+ "mouse", "shortcut", "shortcuts", "gesture", "gestures",
355
+ "ocr", "terminal", "scan root", "assistant", "provider",
356
+ "lattices", "workspace"
357
+ ]
358
+ return assistantTopics.contains(where: lower.contains)
359
+ }
360
+
361
+ private func voiceResultSummary(for match: IntentMatch, result: JSON) -> String {
362
+ if let summary = result["summary"]?.stringValue, !summary.isEmpty {
363
+ return summary
364
+ }
365
+ if let message = result["message"]?.stringValue, !message.isEmpty {
366
+ return message
367
+ }
368
+ if result["ok"]?.boolValue == false {
369
+ return result["reason"]?.stringValue ?? "Voice command did not complete"
370
+ }
371
+
372
+ switch match.intentName {
373
+ case "tile_window":
374
+ let position = match.slots["position"]?.stringValue
375
+ ?? result["position"]?.stringValue
376
+ ?? "requested position"
377
+ return "Moved window to \(position)"
378
+
379
+ case "focus":
380
+ let target = result["focused"]?.stringValue
381
+ ?? match.slots["app"]?.stringValue
382
+ ?? "target"
383
+ return "Focused \(target)"
384
+
385
+ case "launch":
386
+ if let launched = result["launched"]?.stringValue {
387
+ return "Launched \(launched)"
388
+ }
389
+ let target = match.slots["project"]?.stringValue ?? "requested target"
390
+ return "Opened \(target)"
391
+
392
+ case "kill":
393
+ let target = match.slots["session"]?.stringValue
394
+ ?? match.slots["app"]?.stringValue
395
+ ?? "target"
396
+ return "Closed \(target)"
397
+
398
+ default:
399
+ return "ok"
400
+ }
401
+ }
233
402
  }
234
403
 
235
404
  // Old IntentExtractor removed — PhraseMatcher handles all intent matching now.
236
- // See app/Sources/Intents/LatticeIntent.swift
405
+ // See apps/mac/Sources/Intents/LatticeIntent.swift
237
406
 
238
407
 
239
408
  // MARK: - Vox Audio Provider (WebSocket JSON-RPC via VoxClient)
@@ -4,7 +4,7 @@
4
4
  *
5
5
  * Usage: echo '{"transcript":"tile chrome left","snapshot":{...}}' | bun run bin/handsoff-infer.ts
6
6
  *
7
- * Reads JSON from stdin, calls Groq via lib/infer.ts, prints JSON result to stdout.
7
+ * Reads JSON from stdin, calls the configured voice inference provider, prints JSON result to stdout.
8
8
  * All logging goes to stderr so it doesn't pollute the JSON output.
9
9
  */
10
10
 
@@ -14,7 +14,9 @@ import {
14
14
  normalizeAssistantPlan,
15
15
  tryLocalAssistantPlan,
16
16
  } from "./assistant-intelligence.ts";
17
- import { inferJSON } from "../lib/infer.ts";
17
+ import { inferJSON, resolveVoiceInferenceOptions } from "./infer.ts";
18
+
19
+ const INFER_TIMEOUT_MS = 15_000;
18
20
 
19
21
  // ── Read input from stdin ──────────────────────────────────────────
20
22
 
@@ -36,6 +38,7 @@ const req = JSON.parse(input) as {
36
38
  const transcript = req.transcript ?? "";
37
39
  const systemPrompt = buildAssistantSystemPrompt();
38
40
  const userMessage = buildAssistantContextMessage(transcript, req.snapshot ?? {});
41
+ const voiceInference = resolveVoiceInferenceOptions();
39
42
 
40
43
  const localPlan = tryLocalAssistantPlan(transcript, req.snapshot ?? {});
41
44
  if (localPlan) {
@@ -50,14 +53,18 @@ const messages = (req.history ?? []).map((h) => ({
50
53
  content: h.content,
51
54
  }));
52
55
 
56
+ const controller = new AbortController();
57
+ const timer = setTimeout(() => controller.abort(), INFER_TIMEOUT_MS);
58
+
53
59
  try {
54
60
  const { data, raw } = await inferJSON(userMessage, {
55
- provider: "groq",
56
- model: "llama-3.3-70b-versatile",
61
+ provider: voiceInference.provider,
62
+ model: voiceInference.model,
57
63
  system: systemPrompt,
58
64
  messages,
59
65
  temperature: 0.2,
60
66
  maxTokens: 512,
67
+ abortSignal: controller.signal,
61
68
  tag: "hands-off",
62
69
  });
63
70
 
@@ -83,5 +90,7 @@ try {
83
90
  _meta: { error: err.message },
84
91
  })
85
92
  );
86
- process.exit(1);
93
+ process.exitCode = 1;
94
+ } finally {
95
+ clearTimeout(timer);
87
96
  }
@@ -3,7 +3,7 @@
3
3
  * Hands-off worker — long-running process that handles both inference and TTS.
4
4
  *
5
5
  * Reads newline-delimited JSON commands from stdin, writes JSON responses to stdout.
6
- * Keeps SpeakEasy and inference warm — no cold starts.
6
+ * Keeps TTS and inference warm — no cold starts.
7
7
  *
8
8
  * Commands:
9
9
  * {"cmd":"infer","transcript":"...","snapshot":{...},"history":[...]}
@@ -23,9 +23,10 @@ import {
23
23
  normalizeAssistantPlan,
24
24
  tryLocalAssistantPlan,
25
25
  } from "./assistant-intelligence.ts";
26
- import { infer } from "../lib/infer.ts";
26
+ import { infer, resolveVoiceInferenceOptions } from "./infer.ts";
27
27
 
28
28
  const INFER_TIMEOUT_MS = 15_000;
29
+ const voiceInference = resolveVoiceInferenceOptions();
29
30
 
30
31
  /** Call infer and parse JSON if possible, otherwise treat as spoken-only response */
31
32
  async function inferSmart(prompt: string, options: any): Promise<{ data: any; raw: any }> {
@@ -291,12 +292,15 @@ log("worker started, streaming TTS ready");
291
292
 
292
293
  const systemPrompt = buildAssistantSystemPrompt();
293
294
  log("system prompt loaded");
295
+ log(`voice inference: ${voiceInference.provider}/${voiceInference.model}`);
294
296
 
295
297
  // ── Auto-restart on file changes ───────────────────────────────────
296
298
 
297
299
  const watchFiles = [
298
300
  assistantPromptPath,
299
301
  join(import.meta.dir, "assistant-intelligence.ts"),
302
+ join(import.meta.dir, "..", ".env"),
303
+ join(import.meta.dir, "..", ".env.local"),
300
304
  import.meta.path, // this script itself
301
305
  ];
302
306
 
@@ -376,8 +380,8 @@ async function processLine(line: string) {
376
380
  }));
377
381
 
378
382
  const { data, raw } = await inferSmart(userMessage, {
379
- provider: "xai",
380
- model: "grok-4.20-beta-0309-non-reasoning",
383
+ provider: voiceInference.provider,
384
+ model: voiceInference.model,
381
385
  system: systemPrompt,
382
386
  messages,
383
387
  temperature: 0.2,
@@ -416,7 +420,7 @@ async function processLine(line: string) {
416
420
  //
417
421
  // Timeline:
418
422
  // t=0 ──┬── ack TTS (fire & forget)
419
- // └── Groq inference
423
+ // └── model inference
420
424
  // t=~600ms ─┬── narrate TTS (what we're doing)
421
425
  // └── execute actions (in parallel with narrate)
422
426
  // t=done ── respond with results
@@ -445,8 +449,8 @@ async function processLine(line: string) {
445
449
  const userMessage = buildAssistantContextMessage(transcript, snap);
446
450
  try {
447
451
  const { data, raw } = await inferSmart(userMessage, {
448
- provider: "xai",
449
- model: "grok-4.20-beta-0309-non-reasoning",
452
+ provider: voiceInference.provider,
453
+ model: voiceInference.model,
450
454
  system: systemPrompt,
451
455
  messages,
452
456
  temperature: 0.2,