@lattices/cli 0.4.14 → 0.5.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 (180) hide show
  1. package/README.md +5 -7
  2. package/apps/mac/Info.plist +2 -2
  3. package/apps/mac/Lattices.app/Contents/Info.plist +4 -12
  4. package/apps/mac/Lattices.app/Contents/MacOS/Lattices +0 -0
  5. package/bin/lattices-app.ts +110 -17
  6. package/bin/lattices-build +125 -0
  7. package/bin/lattices-dev +89 -16
  8. package/bin/lattices.ts +977 -16
  9. package/docs/agents.md +81 -4
  10. package/docs/ai-chat-ux-review.md +416 -0
  11. package/docs/api.md +135 -3
  12. package/docs/app.md +30 -8
  13. package/docs/config.md +4 -0
  14. package/docs/mouse-gestures.md +60 -1
  15. package/docs/proposals/LAT-004-interactive-overlay-actors.md +1 -1
  16. package/docs/proposals/LAT-005-action-runtime-product-spine.md +914 -0
  17. package/docs/proposals/LAT-006-mira-in-lattices.md +553 -0
  18. package/docs/reference/dewey.config.ts +2 -2
  19. package/docs/release.md +171 -0
  20. package/docs/repo-structure.md +4 -5
  21. package/docs/voice.md +11 -27
  22. package/package.json +9 -10
  23. package/apps/mac/Package.swift +0 -27
  24. package/apps/mac/Sources/AppShell/App.swift +0 -26
  25. package/apps/mac/Sources/AppShell/AppActivationCoordinator.swift +0 -27
  26. package/apps/mac/Sources/AppShell/AppDelegate.swift +0 -189
  27. package/apps/mac/Sources/AppShell/AppServicesBootstrap.swift +0 -25
  28. package/apps/mac/Sources/AppShell/AppShellView.swift +0 -171
  29. package/apps/mac/Sources/AppShell/AppUpdater.swift +0 -305
  30. package/apps/mac/Sources/AppShell/CliActionLauncher.swift +0 -50
  31. package/apps/mac/Sources/AppShell/HomeDashboardView.swift +0 -133
  32. package/apps/mac/Sources/AppShell/HotkeyBootstrap.swift +0 -87
  33. package/apps/mac/Sources/AppShell/KeyRecorderView.swift +0 -210
  34. package/apps/mac/Sources/AppShell/LatticesRuntime.swift +0 -104
  35. package/apps/mac/Sources/AppShell/MainView.swift +0 -847
  36. package/apps/mac/Sources/AppShell/MainWindow.swift +0 -83
  37. package/apps/mac/Sources/AppShell/MenuBarController.swift +0 -177
  38. package/apps/mac/Sources/AppShell/OnboardingView.swift +0 -483
  39. package/apps/mac/Sources/AppShell/PermissionsAssistantView.swift +0 -366
  40. package/apps/mac/Sources/AppShell/PermissionsAssistantWindow.swift +0 -70
  41. package/apps/mac/Sources/AppShell/Preferences.swift +0 -297
  42. package/apps/mac/Sources/AppShell/SettingsView.swift +0 -3163
  43. package/apps/mac/Sources/AppShell/SettingsWindow.swift +0 -34
  44. package/apps/mac/Sources/AppShell/WorkspaceInspectorPresenter.swift +0 -13
  45. package/apps/mac/Sources/Core/Actions/HotkeyManager.swift +0 -256
  46. package/apps/mac/Sources/Core/Actions/HotkeyStore.swift +0 -399
  47. package/apps/mac/Sources/Core/Actions/IntentEngine.swift +0 -988
  48. package/apps/mac/Sources/Core/Actions/IntentSchema.swift +0 -94
  49. package/apps/mac/Sources/Core/Actions/Intents/CreateLayerIntent.swift +0 -54
  50. package/apps/mac/Sources/Core/Actions/Intents/DistributeIntent.swift +0 -56
  51. package/apps/mac/Sources/Core/Actions/Intents/FocusIntent.swift +0 -69
  52. package/apps/mac/Sources/Core/Actions/Intents/HelpIntent.swift +0 -41
  53. package/apps/mac/Sources/Core/Actions/Intents/KillIntent.swift +0 -47
  54. package/apps/mac/Sources/Core/Actions/Intents/LatticeIntent.swift +0 -53
  55. package/apps/mac/Sources/Core/Actions/Intents/LaunchIntent.swift +0 -67
  56. package/apps/mac/Sources/Core/Actions/Intents/ListSessionsIntent.swift +0 -32
  57. package/apps/mac/Sources/Core/Actions/Intents/ListWindowsIntent.swift +0 -30
  58. package/apps/mac/Sources/Core/Actions/Intents/ScanIntent.swift +0 -52
  59. package/apps/mac/Sources/Core/Actions/Intents/SearchIntent.swift +0 -190
  60. package/apps/mac/Sources/Core/Actions/Intents/SwitchLayerIntent.swift +0 -50
  61. package/apps/mac/Sources/Core/Actions/Intents/TileIntent.swift +0 -61
  62. package/apps/mac/Sources/Core/Actions/PaletteCommand.swift +0 -439
  63. package/apps/mac/Sources/Core/Actions/VoiceIntentResolver.swift +0 -713
  64. package/apps/mac/Sources/Core/Companion/CompanionActivityLog.swift +0 -70
  65. package/apps/mac/Sources/Core/Companion/CompanionKeyboardController.swift +0 -141
  66. package/apps/mac/Sources/Core/Companion/LatticesCompanionBridgeServer.swift +0 -454
  67. package/apps/mac/Sources/Core/Companion/LatticesCompanionCockpit.swift +0 -555
  68. package/apps/mac/Sources/Core/Companion/LatticesCompanionSecurityCoordinator.swift +0 -629
  69. package/apps/mac/Sources/Core/Companion/LatticesCompanionTrackpadController.swift +0 -204
  70. package/apps/mac/Sources/Core/Companion/LatticesDeckHost.swift +0 -1463
  71. package/apps/mac/Sources/Core/Daemon/DaemonProtocol.swift +0 -114
  72. package/apps/mac/Sources/Core/Daemon/DaemonServer.swift +0 -427
  73. package/apps/mac/Sources/Core/Daemon/LatticesApi.swift +0 -2965
  74. package/apps/mac/Sources/Core/Desktop/AccessibilityTextExtractor.swift +0 -111
  75. package/apps/mac/Sources/Core/Desktop/AppTypeClassifier.swift +0 -106
  76. package/apps/mac/Sources/Core/Desktop/DesktopModel.swift +0 -331
  77. package/apps/mac/Sources/Core/Desktop/DesktopModelTypes.swift +0 -73
  78. package/apps/mac/Sources/Core/Desktop/InventoryManager.swift +0 -35
  79. package/apps/mac/Sources/Core/Desktop/InventoryPath.swift +0 -43
  80. package/apps/mac/Sources/Core/Desktop/MouseFinder.swift +0 -527
  81. package/apps/mac/Sources/Core/Desktop/OcrModel.swift +0 -467
  82. package/apps/mac/Sources/Core/Desktop/OcrStore.swift +0 -329
  83. package/apps/mac/Sources/Core/Desktop/PlacementSpec.swift +0 -195
  84. package/apps/mac/Sources/Core/Desktop/SessionWindowLocator.swift +0 -139
  85. package/apps/mac/Sources/Core/Desktop/TilePickerView.swift +0 -209
  86. package/apps/mac/Sources/Core/Desktop/WindowCapture.swift +0 -33
  87. package/apps/mac/Sources/Core/Desktop/WindowDragSnapController.swift +0 -429
  88. package/apps/mac/Sources/Core/Desktop/WindowPreviewCard.swift +0 -100
  89. package/apps/mac/Sources/Core/Desktop/WindowPreviewStore.swift +0 -112
  90. package/apps/mac/Sources/Core/Desktop/WindowSelectionStore.swift +0 -76
  91. package/apps/mac/Sources/Core/Desktop/WindowTiler.swift +0 -2222
  92. package/apps/mac/Sources/Core/Input/EventTapBreaker.swift +0 -124
  93. package/apps/mac/Sources/Core/Input/EventTapThread.swift +0 -54
  94. package/apps/mac/Sources/Core/Input/InputCaptureResetCenter.swift +0 -20
  95. package/apps/mac/Sources/Core/Input/KeyboardRemapConfig.swift +0 -69
  96. package/apps/mac/Sources/Core/Input/KeyboardRemapController.swift +0 -346
  97. package/apps/mac/Sources/Core/Input/KeyboardRemapStore.swift +0 -141
  98. package/apps/mac/Sources/Core/Input/MouseGestureConfig.swift +0 -499
  99. package/apps/mac/Sources/Core/Input/MouseGestureController.swift +0 -2583
  100. package/apps/mac/Sources/Core/Input/MouseInputDeviceStore.swift +0 -98
  101. package/apps/mac/Sources/Core/Input/MouseInputEventViewer.swift +0 -272
  102. package/apps/mac/Sources/Core/Input/MouseShortcutStore.swift +0 -170
  103. package/apps/mac/Sources/Core/Input/SecureEventInputMonitor.swift +0 -39
  104. package/apps/mac/Sources/Core/Input/ShapeRecognizer.swift +0 -624
  105. package/apps/mac/Sources/Core/Input/TapBudgetMeter.swift +0 -56
  106. package/apps/mac/Sources/Core/Overlays/AppWindowShell.swift +0 -63
  107. package/apps/mac/Sources/Core/Overlays/CommandMode/CommandModeState.swift +0 -1566
  108. package/apps/mac/Sources/Core/Overlays/CommandMode/CommandModeView.swift +0 -1927
  109. package/apps/mac/Sources/Core/Overlays/CommandMode/CommandModeWindow.swift +0 -196
  110. package/apps/mac/Sources/Core/Overlays/CommandPalette/CommandPaletteView.swift +0 -307
  111. package/apps/mac/Sources/Core/Overlays/CommandPalette/CommandPaletteWindow.swift +0 -67
  112. package/apps/mac/Sources/Core/Overlays/HUD/CheatSheetHUD.swift +0 -576
  113. package/apps/mac/Sources/Core/Overlays/HUD/HUDBottomBar.swift +0 -279
  114. package/apps/mac/Sources/Core/Overlays/HUD/HUDController.swift +0 -1158
  115. package/apps/mac/Sources/Core/Overlays/HUD/HUDLeftBar.swift +0 -849
  116. package/apps/mac/Sources/Core/Overlays/HUD/HUDMinimap.swift +0 -179
  117. package/apps/mac/Sources/Core/Overlays/HUD/HUDRightBar.swift +0 -596
  118. package/apps/mac/Sources/Core/Overlays/HUD/HUDState.swift +0 -367
  119. package/apps/mac/Sources/Core/Overlays/HUD/HUDTopBar.swift +0 -243
  120. package/apps/mac/Sources/Core/Overlays/HUD/LauncherHUD.swift +0 -334
  121. package/apps/mac/Sources/Core/Overlays/HUD/LayerBezel.swift +0 -203
  122. package/apps/mac/Sources/Core/Overlays/OmniSearch/OmniSearchState.swift +0 -280
  123. package/apps/mac/Sources/Core/Overlays/OmniSearch/OmniSearchView.swift +0 -422
  124. package/apps/mac/Sources/Core/Overlays/OmniSearch/OmniSearchWindow.swift +0 -94
  125. package/apps/mac/Sources/Core/Overlays/OverlayPanelShell.swift +0 -241
  126. package/apps/mac/Sources/Core/Overlays/ScreenMap/ScreenMapState.swift +0 -3135
  127. package/apps/mac/Sources/Core/Overlays/ScreenMap/ScreenMapView.swift +0 -3977
  128. package/apps/mac/Sources/Core/Overlays/ScreenMap/ScreenMapWindowController.swift +0 -119
  129. package/apps/mac/Sources/Core/Overlays/ScreenOverlayCanvasController.swift +0 -1217
  130. package/apps/mac/Sources/Core/Overlays/Voice/VoiceCommandWindow.swift +0 -1575
  131. package/apps/mac/Sources/Core/Pi/PiAuthNextStepCard.swift +0 -148
  132. package/apps/mac/Sources/Core/Pi/PiAuthPromptCard.swift +0 -90
  133. package/apps/mac/Sources/Core/Pi/PiChatDock.swift +0 -564
  134. package/apps/mac/Sources/Core/Pi/PiChatSession.swift +0 -1948
  135. package/apps/mac/Sources/Core/Pi/PiInstallCallout.swift +0 -86
  136. package/apps/mac/Sources/Core/Pi/PiProviderSetupCallout.swift +0 -99
  137. package/apps/mac/Sources/Core/Pi/PiWorkspaceView.swift +0 -510
  138. package/apps/mac/Sources/Core/System/Capability.swift +0 -79
  139. package/apps/mac/Sources/Core/System/DiagnosticLog.swift +0 -373
  140. package/apps/mac/Sources/Core/System/EventBus.swift +0 -31
  141. package/apps/mac/Sources/Core/System/PermissionChecker.swift +0 -224
  142. package/apps/mac/Sources/Core/System/ProcessModel.swift +0 -199
  143. package/apps/mac/Sources/Core/System/ProcessQuery.swift +0 -151
  144. package/apps/mac/Sources/Core/System/SystemTelemetryMonitor.swift +0 -273
  145. package/apps/mac/Sources/Core/Voice/AdvisorLearningStore.swift +0 -90
  146. package/apps/mac/Sources/Core/Voice/AgentSession.swift +0 -377
  147. package/apps/mac/Sources/Core/Voice/AudioProvider.swift +0 -555
  148. package/apps/mac/Sources/Core/Voice/HandsOffSession.swift +0 -839
  149. package/apps/mac/Sources/Core/Voice/VoiceChatView.swift +0 -192
  150. package/apps/mac/Sources/Core/Voice/VoxClient.swift +0 -454
  151. package/apps/mac/Sources/Core/Workspace/Project.swift +0 -28
  152. package/apps/mac/Sources/Core/Workspace/ProjectScanner.swift +0 -141
  153. package/apps/mac/Sources/Core/Workspace/SessionLayerStore.swift +0 -285
  154. package/apps/mac/Sources/Core/Workspace/SessionManager.swift +0 -75
  155. package/apps/mac/Sources/Core/Workspace/Terminal/Terminal.swift +0 -259
  156. package/apps/mac/Sources/Core/Workspace/Terminal/TerminalQuery.swift +0 -156
  157. package/apps/mac/Sources/Core/Workspace/Terminal/TerminalSynthesizer.swift +0 -200
  158. package/apps/mac/Sources/Core/Workspace/Tmux/TmuxModel.swift +0 -60
  159. package/apps/mac/Sources/Core/Workspace/Tmux/TmuxQuery.swift +0 -105
  160. package/apps/mac/Sources/Core/Workspace/WorkspaceManager.swift +0 -1027
  161. package/apps/mac/Sources/UI/ActionRow.swift +0 -78
  162. package/apps/mac/Sources/UI/OrphanRow.swift +0 -129
  163. package/apps/mac/Sources/UI/ProjectRow.swift +0 -368
  164. package/apps/mac/Sources/UI/TabGroupRow.swift +0 -178
  165. package/apps/mac/Sources/UI/Theme.swift +0 -164
  166. package/apps/mac/Tests/StageDragTests.swift +0 -333
  167. package/apps/mac/Tests/StageJoinTests.swift +0 -313
  168. package/apps/mac/Tests/StageManagerTests.swift +0 -280
  169. package/apps/mac/Tests/StageTileTests.swift +0 -353
  170. package/swift/Package.swift +0 -20
  171. package/swift/Sources/DeckKit/DeckAction.swift +0 -51
  172. package/swift/Sources/DeckKit/DeckBridgeSecurity.swift +0 -152
  173. package/swift/Sources/DeckKit/DeckCockpit.swift +0 -82
  174. package/swift/Sources/DeckKit/DeckHost.swift +0 -7
  175. package/swift/Sources/DeckKit/DeckManifest.swift +0 -145
  176. package/swift/Sources/DeckKit/DeckRuntimeSnapshot.swift +0 -533
  177. package/swift/Sources/DeckKit/DeckTrackpad.swift +0 -63
  178. package/swift/Sources/DeckKit/DeckValue.swift +0 -93
  179. package/swift/Sources/DeckKit/DeckVoiceError.swift +0 -88
  180. package/swift/Tests/DeckKitTests/DeckKitTests.swift +0 -286
@@ -1,988 +0,0 @@
1
- import AppKit
2
-
3
- // MARK: - Intent Engine
4
-
5
- final class IntentEngine {
6
- static let shared = IntentEngine()
7
-
8
- private var intents: [String: IntentDef] = [:]
9
- private var intentOrder: [String] = []
10
-
11
- private init() {
12
- registerBuiltins()
13
- }
14
-
15
- func register(_ intent: IntentDef) {
16
- intents[intent.name] = intent
17
- if !intentOrder.contains(intent.name) {
18
- intentOrder.append(intent.name)
19
- }
20
- }
21
-
22
- func definitions() -> [IntentDef] {
23
- intentOrder.compactMap { intents[$0] }
24
- }
25
-
26
- // MARK: - Execution
27
-
28
- func execute(_ request: IntentRequest) throws -> JSON {
29
- // 1. Direct match by intent name
30
- if let def = intents[request.intent] {
31
- return try def.handler(request)
32
- }
33
-
34
- // 2. Fuzzy match by intent name (handle voice transcription typos)
35
- let normalized = request.intent.lowercased().replacingOccurrences(of: " ", with: "_")
36
- .replacingOccurrences(of: "-", with: "_")
37
- if let def = intents[normalized] {
38
- return try def.handler(request)
39
- }
40
-
41
- // 3. No match
42
- throw IntentError.unknownIntent(request.intent, available: Array(intents.keys).sorted())
43
- }
44
-
45
- // MARK: - Discovery
46
-
47
- func catalog() -> JSON {
48
- .array(intentOrder.compactMap { name in
49
- guard let def = intents[name] else { return nil }
50
- return .object([
51
- "intent": .string(def.name),
52
- "description": .string(def.description),
53
- "examples": .array(def.examples.map { .string($0) }),
54
- "slots": .array(def.slots.map { slot in
55
- var obj: [String: JSON] = [
56
- "name": .string(slot.name),
57
- "type": .string(slot.type),
58
- "required": .bool(slot.required),
59
- "description": .string(slot.description),
60
- ]
61
- if let vals = slot.enumValues {
62
- obj["values"] = .array(vals.map { .string($0) })
63
- }
64
- return .object(obj)
65
- })
66
- ])
67
- })
68
- }
69
-
70
- // MARK: - Built-in Intents
71
-
72
- /// Track recently tiled wids so batch operations (e.g. "tile iTerm left, iTerm right")
73
- /// don't pick the same window twice. Resets after 2 seconds.
74
- private static var recentlyTiledWids: Set<UInt32> = []
75
- private static var recentlyTiledTimer: Timer?
76
-
77
- private static func markTiled(_ wid: UInt32) {
78
- recentlyTiledWids.insert(wid)
79
- recentlyTiledTimer?.invalidate()
80
- recentlyTiledTimer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: false) { _ in
81
- recentlyTiledWids.removeAll()
82
- }
83
- }
84
-
85
- private func registerBuiltins() {
86
-
87
- // ── Window Tiling ───────────────────────────────────────
88
-
89
- register(IntentDef(
90
- name: "tile_window",
91
- description: "Tile a window to a screen position",
92
- examples: [
93
- "tile this left",
94
- "snap to the right half",
95
- "maximize the window",
96
- "put it in the top left corner",
97
- "center the window",
98
- "make it full screen"
99
- ],
100
- slots: [
101
- IntentSlot(name: "position", type: "position", required: true,
102
- description: "Target tile position. Named positions or grid:CxR:C,R syntax.",
103
- enumValues: TilePosition.allCases.map(\.rawValue)),
104
- IntentSlot(name: "app", type: "string", required: false,
105
- description: "Target app name (defaults to frontmost)", enumValues: nil),
106
- IntentSlot(name: "wid", type: "int", required: false,
107
- description: "Target window ID", enumValues: nil),
108
- IntentSlot(name: "session", type: "string", required: false,
109
- description: "Target session name", enumValues: nil),
110
- IntentSlot(name: "selection", type: "bool", required: false,
111
- description: "Apply to the active multi-window selection instead of a single window", enumValues: nil),
112
- ],
113
- handler: { req in
114
- guard let posStr = req.slots["position"]?.stringValue else {
115
- throw IntentError.missingSlot("position")
116
- }
117
- guard let placement = PlacementSpec(string: posStr) else {
118
- throw IntentError.invalidSlot("Unknown position: \(posStr)")
119
- }
120
-
121
- // Resolve target: explicit session, wid, app name, or frontmost
122
- if let session = req.slots["session"]?.stringValue {
123
- return try LatticesApi.shared.dispatch(
124
- method: "window.place",
125
- params: .object(["session": .string(session), "placement": placement.jsonValue])
126
- )
127
- }
128
-
129
- // For wid/app/frontmost: use WindowTiler directly
130
- func tileEntry(_ entry: WindowEntry) {
131
- IntentEngine.markTiled(entry.wid)
132
- DispatchQueue.main.async {
133
- WindowTiler.tileWindowById(wid: entry.wid, pid: entry.pid, to: placement)
134
- }
135
- }
136
-
137
- if let wid = req.slots["wid"]?.uint32Value,
138
- let entry = DesktopModel.shared.windows[wid] {
139
- tileEntry(entry)
140
- return .object(["ok": .bool(true), "wid": .int(Int(wid)), "position": .string(posStr)])
141
- }
142
-
143
- if let app = req.slots["app"]?.stringValue {
144
- // Skip windows already tiled in this batch (e.g. two iTerm windows side by side)
145
- let alreadyTiled = IntentEngine.recentlyTiledWids
146
- if let entry = DesktopModel.shared.windows.values.first(where: {
147
- $0.app.localizedCaseInsensitiveContains(app) && !alreadyTiled.contains($0.wid)
148
- }) {
149
- tileEntry(entry)
150
- return .object(["ok": .bool(true), "app": .string(entry.app), "wid": .int(Int(entry.wid)), "position": .string(posStr)])
151
- }
152
- throw IntentError.targetNotFound("No window found for app '\(app)'")
153
- }
154
-
155
- if req.slots["selection"]?.boolValue == true {
156
- let selectionIds = WindowSelectionStore.shared.windowIds
157
- guard !selectionIds.isEmpty else {
158
- throw IntentError.targetNotFound("No active window selection")
159
- }
160
-
161
- if selectionIds.count == 1,
162
- let wid = selectionIds.first,
163
- let entry = DesktopModel.shared.windows[wid] {
164
- tileEntry(entry)
165
- return .object([
166
- "ok": .bool(true),
167
- "target": .string("selection"),
168
- "wid": .int(Int(wid)),
169
- "position": .string(posStr)
170
- ])
171
- }
172
-
173
- return try LatticesApi.shared.dispatch(
174
- method: "space.optimize",
175
- params: .object([
176
- "scope": .string("selection"),
177
- "windowIds": .array(selectionIds.map { .int(Int($0)) }),
178
- "region": .string(posStr)
179
- ])
180
- )
181
- }
182
-
183
- // Default: tile frontmost window
184
- DispatchQueue.main.async {
185
- WindowTiler.tileFrontmostViaAX(to: placement)
186
- }
187
- return .object(["ok": .bool(true), "target": .string("frontmost"), "position": .string(posStr)])
188
- }
189
- ))
190
-
191
- // ── Focus Window / App ──────────────────────────────────
192
-
193
- register(IntentDef(
194
- name: "focus",
195
- description: "Focus a window, app, or session",
196
- examples: [
197
- "switch to Chrome",
198
- "focus the terminal",
199
- "go to my frontend project",
200
- "show Slack"
201
- ],
202
- slots: [
203
- IntentSlot(name: "app", type: "string", required: false,
204
- description: "App name to focus", enumValues: nil),
205
- IntentSlot(name: "session", type: "string", required: false,
206
- description: "Session name to focus", enumValues: nil),
207
- IntentSlot(name: "wid", type: "int", required: false,
208
- description: "Window ID to focus", enumValues: nil),
209
- ],
210
- handler: { req in
211
- if let session = req.slots["session"]?.stringValue {
212
- return try LatticesApi.shared.dispatch(
213
- method: "window.focus",
214
- params: .object(["session": .string(session)])
215
- )
216
- }
217
- if let wid = req.slots["wid"]?.intValue {
218
- return try LatticesApi.shared.dispatch(
219
- method: "window.focus",
220
- params: .object(["wid": .int(wid)])
221
- )
222
- }
223
- if let app = req.slots["app"]?.stringValue {
224
- if let entry = DesktopModel.shared.windows.values.first(where: {
225
- $0.app.localizedCaseInsensitiveContains(app)
226
- }) {
227
- return try LatticesApi.shared.dispatch(
228
- method: "window.focus",
229
- params: .object(["wid": .int(Int(entry.wid))])
230
- )
231
- }
232
- // Try launching the app
233
- NSWorkspace.shared.launchApplication(app)
234
- return .object(["ok": .bool(true), "launched": .string(app)])
235
- }
236
- throw IntentError.missingSlot("app, session, or wid")
237
- }
238
- ))
239
-
240
- // ── Launch Session ──────────────────────────────────────
241
-
242
- register(IntentDef(
243
- name: "launch",
244
- description: "Launch a project session",
245
- examples: [
246
- "open my frontend project",
247
- "launch the API",
248
- "start working on lattices",
249
- "open the backend"
250
- ],
251
- slots: [
252
- IntentSlot(name: "project", type: "string", required: true,
253
- description: "Project name or path", enumValues: nil),
254
- ],
255
- handler: { req in
256
- guard let project = req.slots["project"]?.stringValue else {
257
- throw IntentError.missingSlot("project")
258
- }
259
-
260
- // Try matching by name against discovered projects
261
- let projects = try LatticesApi.shared.dispatch(method: "projects.list", params: nil)
262
- if case .array(let list) = projects {
263
- for p in list {
264
- let name = p["name"]?.stringValue ?? ""
265
- let path = p["path"]?.stringValue ?? ""
266
- if name.localizedCaseInsensitiveContains(project) ||
267
- path.localizedCaseInsensitiveContains(project) {
268
- return try LatticesApi.shared.dispatch(
269
- method: "session.launch",
270
- params: .object(["path": .string(path)])
271
- )
272
- }
273
- }
274
- }
275
- throw IntentError.targetNotFound("No project matching '\(project)'")
276
- }
277
- ))
278
-
279
- // ── Switch Layer ────────────────────────────────────────
280
-
281
- register(IntentDef(
282
- name: "switch_layer",
283
- description: "Switch to a workspace layer",
284
- examples: [
285
- "switch to the web layer",
286
- "go to mobile",
287
- "layer 2",
288
- "switch to review"
289
- ],
290
- slots: [
291
- IntentSlot(name: "layer", type: "string", required: true,
292
- description: "Layer name or index", enumValues: nil),
293
- ],
294
- handler: { req in
295
- guard let layer = req.slots["layer"]?.stringValue else {
296
- throw IntentError.missingSlot("layer")
297
- }
298
-
299
- // Try as index first
300
- if let index = Int(layer) {
301
- // Try session layers first, then config layers
302
- let session = SessionLayerStore.shared
303
- if !session.layers.isEmpty && index < session.layers.count {
304
- DispatchQueue.main.async { session.switchTo(index: index) }
305
- return .object(["ok": .bool(true), "type": .string("session"), "index": .int(index)])
306
- }
307
- return try LatticesApi.shared.dispatch(
308
- method: "layer.switch",
309
- params: .object(["index": .int(index)])
310
- )
311
- }
312
-
313
- // Try as name — session layers first
314
- let session = SessionLayerStore.shared
315
- if let idx = session.layers.firstIndex(where: {
316
- $0.name.localizedCaseInsensitiveContains(layer)
317
- }) {
318
- DispatchQueue.main.async { session.switchTo(index: idx) }
319
- return .object(["ok": .bool(true), "type": .string("session"), "name": .string(session.layers[idx].name)])
320
- }
321
-
322
- // Then config layers
323
- return try LatticesApi.shared.dispatch(
324
- method: "layer.switch",
325
- params: .object(["name": .string(layer)])
326
- )
327
- }
328
- ))
329
-
330
- // ── Search Windows ─────────────────────────────────────
331
-
332
- register(IntentDef(
333
- name: "search",
334
- description: "Search for windows by app name, title, session, or screen text",
335
- examples: [
336
- "find the error message",
337
- "search for TODO",
338
- "find all terminal windows",
339
- "find chrome",
340
- "where does it say build failed",
341
- "look for port 3000"
342
- ],
343
- slots: [
344
- IntentSlot(name: "query", type: "query", required: true,
345
- description: "Text to search for", enumValues: nil),
346
- ],
347
- handler: { req in
348
- return try SearchIntent().perform(slots: req.slots)
349
- }
350
- ))
351
-
352
- // ── List Windows ────────────────────────────────────────
353
-
354
- register(IntentDef(
355
- name: "list_windows",
356
- description: "List all visible windows",
357
- examples: [
358
- "what windows are open",
359
- "show me all windows",
360
- "what's on screen"
361
- ],
362
- slots: [],
363
- handler: { _ in
364
- try LatticesApi.shared.dispatch(method: "windows.list", params: nil)
365
- }
366
- ))
367
-
368
- // ── List Sessions ───────────────────────────────────────
369
-
370
- register(IntentDef(
371
- name: "list_sessions",
372
- description: "List active terminal sessions",
373
- examples: [
374
- "what sessions are running",
375
- "show my projects",
376
- "list sessions"
377
- ],
378
- slots: [],
379
- handler: { _ in
380
- try LatticesApi.shared.dispatch(method: "tmux.sessions", params: nil)
381
- }
382
- ))
383
-
384
- // ── Distribute Windows ──────────────────────────────────
385
-
386
- register(IntentDef(
387
- name: "distribute",
388
- description: "Distribute windows evenly in a grid, optionally filtered by app or window type and constrained to a screen region",
389
- examples: [
390
- "spread out the windows",
391
- "distribute everything",
392
- "organize the windows",
393
- "clean up the layout",
394
- "grid the terminals on the right",
395
- "tile all iTerm windows on the left half",
396
- "arrange my chrome windows in the bottom"
397
- ],
398
- slots: [
399
- IntentSlot(name: "app", type: "string", required: false,
400
- description: "Filter to windows of this app (e.g. 'iTerm2', 'Google Chrome')", enumValues: nil),
401
- IntentSlot(name: "type", type: "string", required: false,
402
- description: "Filter to a window type (e.g. 'terminal', 'browser', 'editor')",
403
- enumValues: AppType.allCases.map(\.rawValue)),
404
- IntentSlot(name: "region", type: "position", required: false,
405
- description: "Constrain the grid to a screen region. Uses tile position names.",
406
- enumValues: ["left", "right", "top", "bottom", "top-left", "top-right", "bottom-left", "bottom-right",
407
- "left-third", "center-third", "right-third"]),
408
- IntentSlot(name: "selection", type: "bool", required: false,
409
- description: "Use the active selected windows instead of all visible windows", enumValues: nil),
410
- ],
411
- handler: { req in
412
- var params: [String: JSON] = [:]
413
- if let app = req.slots["app"]?.stringValue {
414
- params["app"] = .string(app)
415
- }
416
- if let type = req.slots["type"]?.stringValue {
417
- params["type"] = .string(type)
418
- }
419
- if let region = req.slots["region"]?.stringValue {
420
- params["region"] = .string(region)
421
- }
422
- if req.slots["selection"]?.boolValue == true {
423
- let selectionIds = WindowSelectionStore.shared.windowIds
424
- guard !selectionIds.isEmpty else {
425
- throw IntentError.targetNotFound("No active window selection")
426
- }
427
- params["scope"] = .string("selection")
428
- params["windowIds"] = .array(selectionIds.map { .int(Int($0)) })
429
- return try LatticesApi.shared.dispatch(
430
- method: "space.optimize",
431
- params: .object(params)
432
- )
433
- }
434
- return try LatticesApi.shared.dispatch(
435
- method: "layout.distribute",
436
- params: params.isEmpty ? nil : .object(params)
437
- )
438
- }
439
- ))
440
-
441
- // ── Create Layer ────────────────────────────────────────
442
-
443
- register(IntentDef(
444
- name: "create_layer",
445
- description: "Create a new session layer from current windows",
446
- examples: [
447
- "save this layout as review",
448
- "create a layer called deploy",
449
- "make a new layer"
450
- ],
451
- slots: [
452
- IntentSlot(name: "name", type: "string", required: true,
453
- description: "Name for the new layer", enumValues: nil),
454
- IntentSlot(name: "capture_visible", type: "bool", required: false,
455
- description: "Auto-capture visible windows into the layer", enumValues: nil),
456
- ],
457
- handler: { req in
458
- guard let name = req.slots["name"]?.stringValue else {
459
- throw IntentError.missingSlot("name")
460
- }
461
-
462
- var windowIds: [JSON] = []
463
- if req.slots["capture_visible"]?.boolValue == true {
464
- for entry in DesktopModel.shared.windows.values where entry.isOnScreen {
465
- windowIds.append(.int(Int(entry.wid)))
466
- }
467
- }
468
-
469
- return try LatticesApi.shared.dispatch(
470
- method: "session.layers.create",
471
- params: .object([
472
- "name": .string(name),
473
- "windowIds": .array(windowIds)
474
- ])
475
- )
476
- }
477
- ))
478
-
479
- // ── Kill Session ────────────────────────────────────────
480
-
481
- register(IntentDef(
482
- name: "kill",
483
- description: "Kill a terminal session",
484
- examples: [
485
- "stop the frontend session",
486
- "kill the API",
487
- "shut down that project"
488
- ],
489
- slots: [
490
- IntentSlot(name: "session", type: "string", required: true,
491
- description: "Session name or project name", enumValues: nil),
492
- ],
493
- handler: { req in
494
- guard let session = req.slots["session"]?.stringValue else {
495
- throw IntentError.missingSlot("session")
496
- }
497
-
498
- // Try direct name first
499
- let sessions = try LatticesApi.shared.dispatch(method: "tmux.sessions", params: nil)
500
- if case .array(let list) = sessions {
501
- for s in list {
502
- let name = s["name"]?.stringValue ?? ""
503
- if name.localizedCaseInsensitiveContains(session) {
504
- return try LatticesApi.shared.dispatch(
505
- method: "session.kill",
506
- params: .object(["name": .string(name)])
507
- )
508
- }
509
- }
510
- }
511
- throw IntentError.targetNotFound("No session matching '\(session)'")
512
- }
513
- ))
514
-
515
- // ── Scan (trigger OCR) ──────────────────────────────────
516
-
517
- register(IntentDef(
518
- name: "scan",
519
- description: "Trigger an immediate screen text scan",
520
- examples: [
521
- "scan the screen",
522
- "read what's on screen",
523
- "update OCR"
524
- ],
525
- slots: [],
526
- handler: { _ in
527
- try LatticesApi.shared.dispatch(method: "ocr.scan", params: nil)
528
- }
529
- ))
530
-
531
- // ── Swap Windows ───────────────────────────────────────
532
-
533
- register(IntentDef(
534
- name: "swap",
535
- description: "Swap the positions of two windows",
536
- examples: [
537
- "swap Chrome and iTerm",
538
- "switch those two",
539
- "swap the left and right windows"
540
- ],
541
- slots: [
542
- IntentSlot(name: "wid_a", type: "int", required: true,
543
- description: "Window ID of the first window", enumValues: nil),
544
- IntentSlot(name: "wid_b", type: "int", required: true,
545
- description: "Window ID of the second window", enumValues: nil),
546
- ],
547
- handler: { req in
548
- guard let widA = req.slots["wid_a"]?.uint32Value,
549
- let widB = req.slots["wid_b"]?.uint32Value else {
550
- throw IntentError.missingSlot("wid_a and wid_b")
551
- }
552
- guard let entryA = DesktopModel.shared.windows[widA],
553
- let entryB = DesktopModel.shared.windows[widB] else {
554
- throw IntentError.targetNotFound("One or both windows not found")
555
- }
556
-
557
- // Read current CG frames (top-left origin) directly from CGWindowList
558
- guard let windowList = CGWindowListCopyWindowInfo([.optionAll, .excludeDesktopElements], kCGNullWindowID) as? [[String: Any]] else {
559
- throw IntentError.targetNotFound("Couldn't read window list")
560
- }
561
- var cgFrames: [UInt32: CGRect] = [:]
562
- for info in windowList {
563
- guard let num = info[kCGWindowNumber as String] as? UInt32,
564
- (num == widA || num == widB),
565
- let dict = info[kCGWindowBounds as String] as? NSDictionary else { continue }
566
- var rect = CGRect.zero
567
- if CGRectMakeWithDictionaryRepresentation(dict, &rect) {
568
- cgFrames[num] = rect
569
- }
570
- }
571
- guard let frameA = cgFrames[widA], let frameB = cgFrames[widB] else {
572
- throw IntentError.targetNotFound("Couldn't read window frames")
573
- }
574
-
575
- // Swap: move A to B's frame, B to A's frame
576
- let moves: [(wid: UInt32, pid: Int32, frame: CGRect)] = [
577
- (wid: widA, pid: entryA.pid, frame: frameB),
578
- (wid: widB, pid: entryB.pid, frame: frameA),
579
- ]
580
- DispatchQueue.main.async {
581
- WindowTiler.batchMoveAndRaiseWindows(moves)
582
- }
583
- return .object([
584
- "ok": .bool(true),
585
- "swapped": .array([.int(Int(widA)), .int(Int(widB))]),
586
- ])
587
- }
588
- ))
589
-
590
- // ── Hide / Minimize ────────────────────────────────────
591
-
592
- register(IntentDef(
593
- name: "hide",
594
- description: "Hide or minimize a window or app",
595
- examples: [
596
- "hide Slack",
597
- "minimize that",
598
- "put away Messages",
599
- "hide the browser"
600
- ],
601
- slots: [
602
- IntentSlot(name: "app", type: "string", required: false,
603
- description: "App name to hide", enumValues: nil),
604
- IntentSlot(name: "wid", type: "int", required: false,
605
- description: "Window ID to minimize", enumValues: nil),
606
- ],
607
- handler: { req in
608
- // Hide by wid — minimize just that window via AX
609
- if let wid = req.slots["wid"]?.uint32Value,
610
- let entry = DesktopModel.shared.windows[wid] {
611
- let appRef = AXUIElementCreateApplication(entry.pid)
612
- var windowsRef: CFTypeRef?
613
- if AXUIElementCopyAttributeValue(appRef, kAXWindowsAttribute as CFString, &windowsRef) == .success,
614
- let axWindows = windowsRef as? [AXUIElement] {
615
- for axWin in axWindows {
616
- var windowId: CGWindowID = 0
617
- if _AXUIElementGetWindow(axWin, &windowId) == .success, windowId == wid {
618
- AXUIElementSetAttributeValue(axWin, kAXMinimizedAttribute as CFString, kCFBooleanTrue)
619
- return .object(["ok": .bool(true), "action": .string("minimized"), "wid": .int(Int(wid))])
620
- }
621
- }
622
- }
623
- throw IntentError.targetNotFound("Couldn't find AX window for wid \(wid)")
624
- }
625
-
626
- // Hide by app name — hide the entire app
627
- if let appName = req.slots["app"]?.stringValue {
628
- let apps = NSWorkspace.shared.runningApplications
629
- if let app = apps.first(where: {
630
- ($0.localizedName ?? "").localizedCaseInsensitiveContains(appName)
631
- }) {
632
- app.hide()
633
- return .object(["ok": .bool(true), "action": .string("hidden"), "app": .string(app.localizedName ?? appName)])
634
- }
635
- throw IntentError.targetNotFound("No running app matching '\(appName)'")
636
- }
637
-
638
- throw IntentError.missingSlot("app or wid")
639
- }
640
- ))
641
-
642
- // ── Highlight ──────────────────────────────────────────
643
-
644
- register(IntentDef(
645
- name: "highlight",
646
- description: "Flash a window's border to identify it visually",
647
- examples: [
648
- "which one is the lattices terminal",
649
- "highlight Chrome",
650
- "show me that window",
651
- "flash the iTerm window"
652
- ],
653
- slots: [
654
- IntentSlot(name: "wid", type: "int", required: false,
655
- description: "Window ID to highlight", enumValues: nil),
656
- IntentSlot(name: "app", type: "string", required: false,
657
- description: "App name to highlight", enumValues: nil),
658
- ],
659
- handler: { req in
660
- if let wid = req.slots["wid"]?.uint32Value {
661
- DispatchQueue.main.async {
662
- WindowTiler.highlightWindowById(wid: wid)
663
- }
664
- return .object(["ok": .bool(true), "wid": .int(Int(wid))])
665
- }
666
-
667
- if let appName = req.slots["app"]?.stringValue {
668
- if let entry = DesktopModel.shared.windows.values.first(where: {
669
- $0.app.localizedCaseInsensitiveContains(appName)
670
- }) {
671
- DispatchQueue.main.async {
672
- WindowTiler.highlightWindowById(wid: entry.wid)
673
- }
674
- return .object(["ok": .bool(true), "wid": .int(Int(entry.wid)), "app": .string(entry.app)])
675
- }
676
- throw IntentError.targetNotFound("No window found for app '\(appName)'")
677
- }
678
-
679
- throw IntentError.missingSlot("wid or app")
680
- }
681
- ))
682
-
683
- // ── Move to Display ────────────────────────────────────
684
-
685
- register(IntentDef(
686
- name: "move_to_display",
687
- description: "Move a window to another monitor/display, optionally positioning it",
688
- examples: [
689
- "put this on the vertical monitor",
690
- "move Chrome to the second display",
691
- "send iTerm to the other screen",
692
- "move that to my main monitor"
693
- ],
694
- slots: [
695
- IntentSlot(name: "wid", type: "int", required: false,
696
- description: "Window ID to move", enumValues: nil),
697
- IntentSlot(name: "app", type: "string", required: false,
698
- description: "App name to move", enumValues: nil),
699
- IntentSlot(name: "display", type: "int", required: true,
700
- description: "Target display index (0 = main, 1 = second, etc.)", enumValues: nil),
701
- IntentSlot(name: "position", type: "position", required: false,
702
- description: "Tile position on the target display (e.g. 'left', 'maximize')",
703
- enumValues: ["left", "right", "top", "bottom", "maximize", "center",
704
- "top-left", "top-right", "bottom-left", "bottom-right"]),
705
- ],
706
- handler: { req in
707
- guard let display = req.slots["display"]?.intValue else {
708
- throw IntentError.missingSlot("display")
709
- }
710
-
711
- // Resolve window target
712
- let wid: UInt32
713
- if let w = req.slots["wid"]?.uint32Value {
714
- wid = w
715
- } else if let appName = req.slots["app"]?.stringValue,
716
- let entry = DesktopModel.shared.windows.values.first(where: {
717
- $0.app.localizedCaseInsensitiveContains(appName)
718
- }) {
719
- wid = entry.wid
720
- } else {
721
- // Frontmost window
722
- guard let frontApp = NSWorkspace.shared.frontmostApplication,
723
- frontApp.bundleIdentifier != "com.arach.lattices" else {
724
- throw IntentError.targetNotFound("No frontmost window")
725
- }
726
- let appRef = AXUIElementCreateApplication(frontApp.processIdentifier)
727
- var focusedRef: CFTypeRef?
728
- guard AXUIElementCopyAttributeValue(appRef, kAXFocusedWindowAttribute as CFString, &focusedRef) == .success else {
729
- throw IntentError.targetNotFound("No focused window")
730
- }
731
- var frontWid: CGWindowID = 0
732
- guard _AXUIElementGetWindow(focusedRef as! AXUIElement, &frontWid) == .success else {
733
- throw IntentError.targetNotFound("Couldn't get frontmost window ID")
734
- }
735
- wid = frontWid
736
- }
737
-
738
- // Use window.present with display + optional position
739
- var params: [String: JSON] = [
740
- "wid": .int(Int(wid)),
741
- "display": .int(display),
742
- ]
743
- if let pos = req.slots["position"]?.stringValue {
744
- params["position"] = .string(pos)
745
- }
746
- return try LatticesApi.shared.dispatch(method: "window.present", params: .object(params))
747
- }
748
- ))
749
-
750
- // ── Find / Summon Mouse ────────────────────────────────
751
-
752
- register(IntentDef(
753
- name: "find_mouse",
754
- description: "Show a sonar pulse at the current mouse cursor position",
755
- examples: [
756
- "where's my mouse",
757
- "find the cursor",
758
- "I lost my mouse",
759
- "find mouse",
760
- "show cursor"
761
- ],
762
- slots: [],
763
- handler: { _ in
764
- DispatchQueue.main.async { MouseFinder.shared.find() }
765
- let pos = NSEvent.mouseLocation
766
- return .object(["ok": .bool(true), "x": .int(Int(pos.x)), "y": .int(Int(pos.y))])
767
- }
768
- ))
769
-
770
- register(IntentDef(
771
- name: "summon_mouse",
772
- description: "Warp the mouse cursor to the center of the screen",
773
- examples: [
774
- "summon mouse",
775
- "bring the cursor here",
776
- "center the mouse",
777
- "mouse come here",
778
- "bring mouse back"
779
- ],
780
- slots: [],
781
- handler: { _ in
782
- DispatchQueue.main.async { MouseFinder.shared.summon() }
783
- return .object(["ok": .bool(true)])
784
- }
785
- ))
786
-
787
- // ── Undo / Restore ─────────────────────────────────────
788
-
789
- register(IntentDef(
790
- name: "undo",
791
- description: "Undo the last window move — restore windows to their previous positions",
792
- examples: [
793
- "put it back",
794
- "undo that",
795
- "restore the windows",
796
- "that was wrong, undo"
797
- ],
798
- slots: [],
799
- handler: { _ in
800
- let history = HandsOffSession.shared.frameHistory
801
- guard !history.isEmpty else {
802
- throw IntentError.targetNotFound("No window moves to undo")
803
- }
804
-
805
- let restores = history.map { (wid: $0.wid, pid: $0.pid, frame: $0.frame) }
806
- DispatchQueue.main.async {
807
- WindowTiler.batchRestoreWindows(restores)
808
- }
809
- HandsOffSession.shared.clearFrameHistory()
810
- return .object([
811
- "ok": .bool(true),
812
- "restored": .int(restores.count),
813
- ])
814
- }
815
- ))
816
- }
817
- }
818
-
819
- // MARK: - Errors
820
-
821
- enum IntentError: LocalizedError {
822
- case unknownIntent(String, available: [String])
823
- case missingSlot(String)
824
- case invalidSlot(String)
825
- case targetNotFound(String)
826
-
827
- var errorDescription: String? {
828
- switch self {
829
- case .unknownIntent(let name, let available):
830
- return "Unknown intent '\(name)'. Available: \(available.joined(separator: ", "))"
831
- case .missingSlot(let name):
832
- return "Missing required slot: \(name)"
833
- case .invalidSlot(let detail):
834
- return detail
835
- case .targetNotFound(let detail):
836
- return detail
837
- }
838
- }
839
- }
840
-
841
- // MARK: - Claude CLI Fallback
842
-
843
- struct ClaudeResolvedIntent {
844
- let intent: String
845
- let slots: [String: JSON]
846
- }
847
-
848
- typealias ResolvedIntent = ClaudeResolvedIntent
849
-
850
- struct ClaudeAgentPlan {
851
- let steps: [ClaudeResolvedIntent]
852
- let reasoning: String
853
- }
854
-
855
- enum ClaudeFallback {
856
-
857
- private static var claudePath: String? { Preferences.resolveClaudePath() }
858
-
859
- /// Shell out to Claude CLI to resolve a voice command transcript into an intent + slots.
860
- /// Runs synchronously — call from a background thread.
861
- static func resolve(
862
- transcript: String,
863
- windows: [WindowEntry],
864
- intentCatalog: JSON
865
- ) -> ClaudeResolvedIntent? {
866
-
867
- let timer = DiagnosticLog.shared.startTimed("Claude fallback")
868
-
869
- // Build window context (compact)
870
- // Compact window list — just app and title, max 20
871
- let windowList = windows.prefix(20).map { "\($0.app): \($0.title)" }.joined(separator: "\n")
872
-
873
- // Compact intent list — just name and slot names
874
- var intentList = ""
875
- if case .array(let intents) = intentCatalog {
876
- for intent in intents {
877
- let name = intent["intent"]?.stringValue ?? ""
878
- var slotNames: [String] = []
879
- if case .array(let slots) = intent["slots"] {
880
- slotNames = slots.compactMap { $0["name"]?.stringValue }
881
- }
882
- let s = slotNames.isEmpty ? "" : "(\(slotNames.joined(separator: ",")))"
883
- intentList += "\(name)\(s), "
884
- }
885
- }
886
-
887
- let prompt = """
888
- Voice command resolver. Whisper transcript (may have typos): "\(transcript)"
889
- Intents: \(intentList.trimmingCharacters(in: .init(charactersIn: ", ")))
890
- Windows: \(windowList)
891
- Return ONLY a JSON object like {"intent":"search","slots":{"query":"dewey"},"reasoning":"user wants to find dewey windows"}. For search, extract the key term. Use window names from the list. If unclear, use intent "unknown".
892
- """
893
-
894
- guard let path = claudePath else {
895
- DiagnosticLog.shared.warn("ClaudeFallback: claude CLI not found")
896
- DiagnosticLog.shared.finish(timer)
897
- return nil
898
- }
899
-
900
- let proc = Process()
901
- proc.executableURL = URL(fileURLWithPath: path)
902
-
903
- proc.arguments = [
904
- "-p", prompt,
905
- "--model", "haiku",
906
- "--output-format", "text",
907
- "--no-session-persistence",
908
- "--max-budget-usd", "0.50",
909
- ]
910
-
911
- // Clear CLAUDECODE env var to allow nested invocation
912
- var env = ProcessInfo.processInfo.environment
913
- env.removeValue(forKey: "CLAUDECODE")
914
- proc.environment = env
915
-
916
- let pipe = Pipe()
917
- let errPipe = Pipe()
918
- proc.standardOutput = pipe
919
- proc.standardError = errPipe
920
-
921
- do {
922
- try proc.run()
923
- } catch {
924
- DiagnosticLog.shared.warn("ClaudeFallback: failed to launch claude CLI — \(error)")
925
- return nil
926
- }
927
-
928
- proc.waitUntilExit()
929
- let exitCode = proc.terminationStatus
930
- DiagnosticLog.shared.finish(timer)
931
- DiagnosticLog.shared.info("ClaudeFallback: exit code \(exitCode)")
932
-
933
- let output = String(data: pipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8)?
934
- .trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
935
- let errOutput = String(data: errPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8)?
936
- .trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
937
-
938
- if !errOutput.isEmpty {
939
- DiagnosticLog.shared.warn("ClaudeFallback: stderr → \(errOutput.prefix(200))")
940
- }
941
- DiagnosticLog.shared.info("ClaudeFallback: raw output → \(output.prefix(300))")
942
-
943
- // Parse JSON from text output
944
- guard let jsonStr = extractJSON(from: output),
945
- let jsonData = jsonStr.data(using: .utf8),
946
- let json = try? JSONSerialization.jsonObject(with: jsonData) as? [String: Any],
947
- let intent = json["intent"] as? String,
948
- intent != "unknown" else {
949
- DiagnosticLog.shared.info("ClaudeFallback: couldn't parse response")
950
- return nil
951
- }
952
-
953
- if let reasoning = json["reasoning"] as? String {
954
- DiagnosticLog.shared.info("ClaudeFallback: reasoning → \(reasoning)")
955
- }
956
-
957
- // Convert slots
958
- var slots: [String: JSON] = [:]
959
- if let rawSlots = json["slots"] as? [String: Any] {
960
- for (key, value) in rawSlots {
961
- if let s = value as? String {
962
- slots[key] = .string(s)
963
- } else if let n = value as? Int {
964
- slots[key] = .int(n)
965
- } else if let b = value as? Bool {
966
- slots[key] = .bool(b)
967
- }
968
- }
969
- }
970
-
971
- return ClaudeResolvedIntent(intent: intent, slots: slots)
972
- }
973
-
974
- private static func extractJSON(from text: String) -> String? {
975
- // Try to find JSON object in the response
976
- // Claude might return it directly, or wrapped in ```json ... ```
977
- let cleaned = text
978
- .replacingOccurrences(of: "```json", with: "")
979
- .replacingOccurrences(of: "```", with: "")
980
- .trimmingCharacters(in: .whitespacesAndNewlines)
981
-
982
- // Find first { and last }
983
- guard let start = cleaned.firstIndex(of: "{"),
984
- let end = cleaned.lastIndex(of: "}") else { return nil }
985
-
986
- return String(cleaned[start...end])
987
- }
988
- }