@lattices/cli 0.4.14 → 0.6.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 (181) hide show
  1. package/README.md +5 -7
  2. package/apps/mac/Info.plist +4 -4
  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/proposals/LAT-007-unified-app-shell.md +128 -0
  19. package/docs/reference/dewey.config.ts +2 -2
  20. package/docs/release.md +171 -0
  21. package/docs/repo-structure.md +5 -5
  22. package/docs/voice.md +11 -27
  23. package/package.json +11 -10
  24. package/apps/mac/Package.swift +0 -27
  25. package/apps/mac/Sources/AppShell/App.swift +0 -26
  26. package/apps/mac/Sources/AppShell/AppActivationCoordinator.swift +0 -27
  27. package/apps/mac/Sources/AppShell/AppDelegate.swift +0 -189
  28. package/apps/mac/Sources/AppShell/AppServicesBootstrap.swift +0 -25
  29. package/apps/mac/Sources/AppShell/AppShellView.swift +0 -171
  30. package/apps/mac/Sources/AppShell/AppUpdater.swift +0 -305
  31. package/apps/mac/Sources/AppShell/CliActionLauncher.swift +0 -50
  32. package/apps/mac/Sources/AppShell/HomeDashboardView.swift +0 -133
  33. package/apps/mac/Sources/AppShell/HotkeyBootstrap.swift +0 -87
  34. package/apps/mac/Sources/AppShell/KeyRecorderView.swift +0 -210
  35. package/apps/mac/Sources/AppShell/LatticesRuntime.swift +0 -104
  36. package/apps/mac/Sources/AppShell/MainView.swift +0 -847
  37. package/apps/mac/Sources/AppShell/MainWindow.swift +0 -83
  38. package/apps/mac/Sources/AppShell/MenuBarController.swift +0 -177
  39. package/apps/mac/Sources/AppShell/OnboardingView.swift +0 -483
  40. package/apps/mac/Sources/AppShell/PermissionsAssistantView.swift +0 -366
  41. package/apps/mac/Sources/AppShell/PermissionsAssistantWindow.swift +0 -70
  42. package/apps/mac/Sources/AppShell/Preferences.swift +0 -297
  43. package/apps/mac/Sources/AppShell/SettingsView.swift +0 -3163
  44. package/apps/mac/Sources/AppShell/SettingsWindow.swift +0 -34
  45. package/apps/mac/Sources/AppShell/WorkspaceInspectorPresenter.swift +0 -13
  46. package/apps/mac/Sources/Core/Actions/HotkeyManager.swift +0 -256
  47. package/apps/mac/Sources/Core/Actions/HotkeyStore.swift +0 -399
  48. package/apps/mac/Sources/Core/Actions/IntentEngine.swift +0 -988
  49. package/apps/mac/Sources/Core/Actions/IntentSchema.swift +0 -94
  50. package/apps/mac/Sources/Core/Actions/Intents/CreateLayerIntent.swift +0 -54
  51. package/apps/mac/Sources/Core/Actions/Intents/DistributeIntent.swift +0 -56
  52. package/apps/mac/Sources/Core/Actions/Intents/FocusIntent.swift +0 -69
  53. package/apps/mac/Sources/Core/Actions/Intents/HelpIntent.swift +0 -41
  54. package/apps/mac/Sources/Core/Actions/Intents/KillIntent.swift +0 -47
  55. package/apps/mac/Sources/Core/Actions/Intents/LatticeIntent.swift +0 -53
  56. package/apps/mac/Sources/Core/Actions/Intents/LaunchIntent.swift +0 -67
  57. package/apps/mac/Sources/Core/Actions/Intents/ListSessionsIntent.swift +0 -32
  58. package/apps/mac/Sources/Core/Actions/Intents/ListWindowsIntent.swift +0 -30
  59. package/apps/mac/Sources/Core/Actions/Intents/ScanIntent.swift +0 -52
  60. package/apps/mac/Sources/Core/Actions/Intents/SearchIntent.swift +0 -190
  61. package/apps/mac/Sources/Core/Actions/Intents/SwitchLayerIntent.swift +0 -50
  62. package/apps/mac/Sources/Core/Actions/Intents/TileIntent.swift +0 -61
  63. package/apps/mac/Sources/Core/Actions/PaletteCommand.swift +0 -439
  64. package/apps/mac/Sources/Core/Actions/VoiceIntentResolver.swift +0 -713
  65. package/apps/mac/Sources/Core/Companion/CompanionActivityLog.swift +0 -70
  66. package/apps/mac/Sources/Core/Companion/CompanionKeyboardController.swift +0 -141
  67. package/apps/mac/Sources/Core/Companion/LatticesCompanionBridgeServer.swift +0 -454
  68. package/apps/mac/Sources/Core/Companion/LatticesCompanionCockpit.swift +0 -555
  69. package/apps/mac/Sources/Core/Companion/LatticesCompanionSecurityCoordinator.swift +0 -629
  70. package/apps/mac/Sources/Core/Companion/LatticesCompanionTrackpadController.swift +0 -204
  71. package/apps/mac/Sources/Core/Companion/LatticesDeckHost.swift +0 -1463
  72. package/apps/mac/Sources/Core/Daemon/DaemonProtocol.swift +0 -114
  73. package/apps/mac/Sources/Core/Daemon/DaemonServer.swift +0 -427
  74. package/apps/mac/Sources/Core/Daemon/LatticesApi.swift +0 -2965
  75. package/apps/mac/Sources/Core/Desktop/AccessibilityTextExtractor.swift +0 -111
  76. package/apps/mac/Sources/Core/Desktop/AppTypeClassifier.swift +0 -106
  77. package/apps/mac/Sources/Core/Desktop/DesktopModel.swift +0 -331
  78. package/apps/mac/Sources/Core/Desktop/DesktopModelTypes.swift +0 -73
  79. package/apps/mac/Sources/Core/Desktop/InventoryManager.swift +0 -35
  80. package/apps/mac/Sources/Core/Desktop/InventoryPath.swift +0 -43
  81. package/apps/mac/Sources/Core/Desktop/MouseFinder.swift +0 -527
  82. package/apps/mac/Sources/Core/Desktop/OcrModel.swift +0 -467
  83. package/apps/mac/Sources/Core/Desktop/OcrStore.swift +0 -329
  84. package/apps/mac/Sources/Core/Desktop/PlacementSpec.swift +0 -195
  85. package/apps/mac/Sources/Core/Desktop/SessionWindowLocator.swift +0 -139
  86. package/apps/mac/Sources/Core/Desktop/TilePickerView.swift +0 -209
  87. package/apps/mac/Sources/Core/Desktop/WindowCapture.swift +0 -33
  88. package/apps/mac/Sources/Core/Desktop/WindowDragSnapController.swift +0 -429
  89. package/apps/mac/Sources/Core/Desktop/WindowPreviewCard.swift +0 -100
  90. package/apps/mac/Sources/Core/Desktop/WindowPreviewStore.swift +0 -112
  91. package/apps/mac/Sources/Core/Desktop/WindowSelectionStore.swift +0 -76
  92. package/apps/mac/Sources/Core/Desktop/WindowTiler.swift +0 -2222
  93. package/apps/mac/Sources/Core/Input/EventTapBreaker.swift +0 -124
  94. package/apps/mac/Sources/Core/Input/EventTapThread.swift +0 -54
  95. package/apps/mac/Sources/Core/Input/InputCaptureResetCenter.swift +0 -20
  96. package/apps/mac/Sources/Core/Input/KeyboardRemapConfig.swift +0 -69
  97. package/apps/mac/Sources/Core/Input/KeyboardRemapController.swift +0 -346
  98. package/apps/mac/Sources/Core/Input/KeyboardRemapStore.swift +0 -141
  99. package/apps/mac/Sources/Core/Input/MouseGestureConfig.swift +0 -499
  100. package/apps/mac/Sources/Core/Input/MouseGestureController.swift +0 -2583
  101. package/apps/mac/Sources/Core/Input/MouseInputDeviceStore.swift +0 -98
  102. package/apps/mac/Sources/Core/Input/MouseInputEventViewer.swift +0 -272
  103. package/apps/mac/Sources/Core/Input/MouseShortcutStore.swift +0 -170
  104. package/apps/mac/Sources/Core/Input/SecureEventInputMonitor.swift +0 -39
  105. package/apps/mac/Sources/Core/Input/ShapeRecognizer.swift +0 -624
  106. package/apps/mac/Sources/Core/Input/TapBudgetMeter.swift +0 -56
  107. package/apps/mac/Sources/Core/Overlays/AppWindowShell.swift +0 -63
  108. package/apps/mac/Sources/Core/Overlays/CommandMode/CommandModeState.swift +0 -1566
  109. package/apps/mac/Sources/Core/Overlays/CommandMode/CommandModeView.swift +0 -1927
  110. package/apps/mac/Sources/Core/Overlays/CommandMode/CommandModeWindow.swift +0 -196
  111. package/apps/mac/Sources/Core/Overlays/CommandPalette/CommandPaletteView.swift +0 -307
  112. package/apps/mac/Sources/Core/Overlays/CommandPalette/CommandPaletteWindow.swift +0 -67
  113. package/apps/mac/Sources/Core/Overlays/HUD/CheatSheetHUD.swift +0 -576
  114. package/apps/mac/Sources/Core/Overlays/HUD/HUDBottomBar.swift +0 -279
  115. package/apps/mac/Sources/Core/Overlays/HUD/HUDController.swift +0 -1158
  116. package/apps/mac/Sources/Core/Overlays/HUD/HUDLeftBar.swift +0 -849
  117. package/apps/mac/Sources/Core/Overlays/HUD/HUDMinimap.swift +0 -179
  118. package/apps/mac/Sources/Core/Overlays/HUD/HUDRightBar.swift +0 -596
  119. package/apps/mac/Sources/Core/Overlays/HUD/HUDState.swift +0 -367
  120. package/apps/mac/Sources/Core/Overlays/HUD/HUDTopBar.swift +0 -243
  121. package/apps/mac/Sources/Core/Overlays/HUD/LauncherHUD.swift +0 -334
  122. package/apps/mac/Sources/Core/Overlays/HUD/LayerBezel.swift +0 -203
  123. package/apps/mac/Sources/Core/Overlays/OmniSearch/OmniSearchState.swift +0 -280
  124. package/apps/mac/Sources/Core/Overlays/OmniSearch/OmniSearchView.swift +0 -422
  125. package/apps/mac/Sources/Core/Overlays/OmniSearch/OmniSearchWindow.swift +0 -94
  126. package/apps/mac/Sources/Core/Overlays/OverlayPanelShell.swift +0 -241
  127. package/apps/mac/Sources/Core/Overlays/ScreenMap/ScreenMapState.swift +0 -3135
  128. package/apps/mac/Sources/Core/Overlays/ScreenMap/ScreenMapView.swift +0 -3977
  129. package/apps/mac/Sources/Core/Overlays/ScreenMap/ScreenMapWindowController.swift +0 -119
  130. package/apps/mac/Sources/Core/Overlays/ScreenOverlayCanvasController.swift +0 -1217
  131. package/apps/mac/Sources/Core/Overlays/Voice/VoiceCommandWindow.swift +0 -1575
  132. package/apps/mac/Sources/Core/Pi/PiAuthNextStepCard.swift +0 -148
  133. package/apps/mac/Sources/Core/Pi/PiAuthPromptCard.swift +0 -90
  134. package/apps/mac/Sources/Core/Pi/PiChatDock.swift +0 -564
  135. package/apps/mac/Sources/Core/Pi/PiChatSession.swift +0 -1948
  136. package/apps/mac/Sources/Core/Pi/PiInstallCallout.swift +0 -86
  137. package/apps/mac/Sources/Core/Pi/PiProviderSetupCallout.swift +0 -99
  138. package/apps/mac/Sources/Core/Pi/PiWorkspaceView.swift +0 -510
  139. package/apps/mac/Sources/Core/System/Capability.swift +0 -79
  140. package/apps/mac/Sources/Core/System/DiagnosticLog.swift +0 -373
  141. package/apps/mac/Sources/Core/System/EventBus.swift +0 -31
  142. package/apps/mac/Sources/Core/System/PermissionChecker.swift +0 -224
  143. package/apps/mac/Sources/Core/System/ProcessModel.swift +0 -199
  144. package/apps/mac/Sources/Core/System/ProcessQuery.swift +0 -151
  145. package/apps/mac/Sources/Core/System/SystemTelemetryMonitor.swift +0 -273
  146. package/apps/mac/Sources/Core/Voice/AdvisorLearningStore.swift +0 -90
  147. package/apps/mac/Sources/Core/Voice/AgentSession.swift +0 -377
  148. package/apps/mac/Sources/Core/Voice/AudioProvider.swift +0 -555
  149. package/apps/mac/Sources/Core/Voice/HandsOffSession.swift +0 -839
  150. package/apps/mac/Sources/Core/Voice/VoiceChatView.swift +0 -192
  151. package/apps/mac/Sources/Core/Voice/VoxClient.swift +0 -454
  152. package/apps/mac/Sources/Core/Workspace/Project.swift +0 -28
  153. package/apps/mac/Sources/Core/Workspace/ProjectScanner.swift +0 -141
  154. package/apps/mac/Sources/Core/Workspace/SessionLayerStore.swift +0 -285
  155. package/apps/mac/Sources/Core/Workspace/SessionManager.swift +0 -75
  156. package/apps/mac/Sources/Core/Workspace/Terminal/Terminal.swift +0 -259
  157. package/apps/mac/Sources/Core/Workspace/Terminal/TerminalQuery.swift +0 -156
  158. package/apps/mac/Sources/Core/Workspace/Terminal/TerminalSynthesizer.swift +0 -200
  159. package/apps/mac/Sources/Core/Workspace/Tmux/TmuxModel.swift +0 -60
  160. package/apps/mac/Sources/Core/Workspace/Tmux/TmuxQuery.swift +0 -105
  161. package/apps/mac/Sources/Core/Workspace/WorkspaceManager.swift +0 -1027
  162. package/apps/mac/Sources/UI/ActionRow.swift +0 -78
  163. package/apps/mac/Sources/UI/OrphanRow.swift +0 -129
  164. package/apps/mac/Sources/UI/ProjectRow.swift +0 -368
  165. package/apps/mac/Sources/UI/TabGroupRow.swift +0 -178
  166. package/apps/mac/Sources/UI/Theme.swift +0 -164
  167. package/apps/mac/Tests/StageDragTests.swift +0 -333
  168. package/apps/mac/Tests/StageJoinTests.swift +0 -313
  169. package/apps/mac/Tests/StageManagerTests.swift +0 -280
  170. package/apps/mac/Tests/StageTileTests.swift +0 -353
  171. package/swift/Package.swift +0 -20
  172. package/swift/Sources/DeckKit/DeckAction.swift +0 -51
  173. package/swift/Sources/DeckKit/DeckBridgeSecurity.swift +0 -152
  174. package/swift/Sources/DeckKit/DeckCockpit.swift +0 -82
  175. package/swift/Sources/DeckKit/DeckHost.swift +0 -7
  176. package/swift/Sources/DeckKit/DeckManifest.swift +0 -145
  177. package/swift/Sources/DeckKit/DeckRuntimeSnapshot.swift +0 -533
  178. package/swift/Sources/DeckKit/DeckTrackpad.swift +0 -63
  179. package/swift/Sources/DeckKit/DeckValue.swift +0 -93
  180. package/swift/Sources/DeckKit/DeckVoiceError.swift +0 -88
  181. 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
- }