@lattices/cli 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/README.md +85 -9
  2. package/app/Package.swift +8 -1
  3. package/app/Sources/AdvisorLearningStore.swift +90 -0
  4. package/app/Sources/AgentSession.swift +377 -0
  5. package/app/Sources/AppDelegate.swift +44 -12
  6. package/app/Sources/AppShellView.swift +81 -8
  7. package/app/Sources/AudioProvider.swift +386 -0
  8. package/app/Sources/CheatSheetHUD.swift +261 -19
  9. package/app/Sources/DaemonProtocol.swift +13 -0
  10. package/app/Sources/DaemonServer.swift +8 -0
  11. package/app/Sources/DesktopModel.swift +164 -5
  12. package/app/Sources/DesktopModelTypes.swift +2 -0
  13. package/app/Sources/DiagnosticLog.swift +104 -2
  14. package/app/Sources/EventBus.swift +1 -0
  15. package/app/Sources/HUDBottomBar.swift +279 -0
  16. package/app/Sources/HUDController.swift +1158 -0
  17. package/app/Sources/HUDLeftBar.swift +849 -0
  18. package/app/Sources/HUDMinimap.swift +179 -0
  19. package/app/Sources/HUDRightBar.swift +774 -0
  20. package/app/Sources/HUDState.swift +367 -0
  21. package/app/Sources/HUDTopBar.swift +243 -0
  22. package/app/Sources/HandsOffSession.swift +733 -0
  23. package/app/Sources/HomeDashboardView.swift +125 -0
  24. package/app/Sources/HotkeyManager.swift +2 -0
  25. package/app/Sources/HotkeyStore.swift +45 -9
  26. package/app/Sources/IntentEngine.swift +925 -0
  27. package/app/Sources/Intents/CreateLayerIntent.swift +54 -0
  28. package/app/Sources/Intents/DistributeIntent.swift +56 -0
  29. package/app/Sources/Intents/FocusIntent.swift +69 -0
  30. package/app/Sources/Intents/HelpIntent.swift +41 -0
  31. package/app/Sources/Intents/KillIntent.swift +47 -0
  32. package/app/Sources/Intents/LatticeIntent.swift +78 -0
  33. package/app/Sources/Intents/LaunchIntent.swift +67 -0
  34. package/app/Sources/Intents/ListSessionsIntent.swift +32 -0
  35. package/app/Sources/Intents/ListWindowsIntent.swift +30 -0
  36. package/app/Sources/Intents/ScanIntent.swift +52 -0
  37. package/app/Sources/Intents/SearchIntent.swift +190 -0
  38. package/app/Sources/Intents/SwitchLayerIntent.swift +50 -0
  39. package/app/Sources/Intents/TileIntent.swift +61 -0
  40. package/app/Sources/LatticesApi.swift +1235 -30
  41. package/app/Sources/LauncherHUD.swift +348 -0
  42. package/app/Sources/MainView.swift +147 -44
  43. package/app/Sources/OcrModel.swift +34 -1
  44. package/app/Sources/OmniSearchState.swift +99 -102
  45. package/app/Sources/OnboardingView.swift +457 -0
  46. package/app/Sources/PermissionChecker.swift +2 -12
  47. package/app/Sources/PiChatDock.swift +454 -0
  48. package/app/Sources/PiChatSession.swift +815 -0
  49. package/app/Sources/PiWorkspaceView.swift +364 -0
  50. package/app/Sources/PlacementSpec.swift +195 -0
  51. package/app/Sources/Preferences.swift +59 -0
  52. package/app/Sources/ProjectScanner.swift +1 -1
  53. package/app/Sources/ScreenMapState.swift +701 -55
  54. package/app/Sources/ScreenMapView.swift +843 -103
  55. package/app/Sources/ScreenMapWindowController.swift +22 -0
  56. package/app/Sources/SessionLayerStore.swift +285 -0
  57. package/app/Sources/SessionManager.swift +4 -1
  58. package/app/Sources/SettingsView.swift +186 -3
  59. package/app/Sources/Theme.swift +9 -8
  60. package/app/Sources/TmuxModel.swift +7 -0
  61. package/app/Sources/TmuxQuery.swift +27 -3
  62. package/app/Sources/VoiceChatView.swift +192 -0
  63. package/app/Sources/VoiceCommandWindow.swift +1594 -0
  64. package/app/Sources/VoiceIntentResolver.swift +671 -0
  65. package/app/Sources/VoxClient.swift +454 -0
  66. package/app/Sources/WindowTiler.swift +348 -87
  67. package/app/Sources/WorkspaceManager.swift +127 -18
  68. package/bin/client.ts +16 -0
  69. package/bin/{daemon-client.js → daemon-client.ts} +49 -30
  70. package/bin/handsoff-infer.ts +280 -0
  71. package/bin/handsoff-worker.ts +731 -0
  72. package/bin/{lattices-app.js → lattices-app.ts} +67 -32
  73. package/bin/lattices-dev +160 -0
  74. package/bin/{lattices.js → lattices.ts} +600 -137
  75. package/bin/project-twin.ts +645 -0
  76. package/docs/agent-execution-plan.md +562 -0
  77. package/docs/agents.md +142 -0
  78. package/docs/api.md +153 -34
  79. package/docs/app.md +29 -1
  80. package/docs/config.md +5 -1
  81. package/docs/handsoff-test-scenarios.md +84 -0
  82. package/docs/layers.md +20 -20
  83. package/docs/ocr.md +14 -5
  84. package/docs/overview.md +5 -1
  85. package/docs/presentation-execution-review.md +491 -0
  86. package/docs/prompts/hands-off-system.md +374 -0
  87. package/docs/prompts/hands-off-turn.md +30 -0
  88. package/docs/prompts/voice-advisor.md +31 -0
  89. package/docs/prompts/voice-fallback.md +23 -0
  90. package/docs/tiling-reference.md +167 -0
  91. package/docs/twins.md +138 -0
  92. package/docs/voice-command-protocol.md +278 -0
  93. package/docs/voice.md +219 -0
  94. package/package.json +21 -10
  95. package/bin/client.js +0 -4
package/README.md CHANGED
@@ -4,16 +4,62 @@
4
4
 
5
5
  # lattices
6
6
 
7
- A workspace control plane for macOS. Manage persistent terminal sessions,
8
- tile and organize your windows, and index the text on your screen — all
9
- controllable from the CLI or a 30-method daemon API.
7
+ The agentic window manager for macOS.
8
+
9
+ Tile windows with hotkeys, manage persistent tmux sessions, index screen
10
+ text with OCR, and give AI agents a 35-method desktop API — all from a
11
+ native menu bar app and CLI.
12
+
13
+ **[lattices.dev](https://lattices.dev)** · [Docs](https://lattices.dev/docs/overview) · [Download](https://github.com/arach/lattices/releases/latest)
10
14
 
11
15
  ## Install
12
16
 
17
+ ### Download the app
18
+
19
+ Grab the signed DMG from the [latest release](https://github.com/arach/lattices/releases/latest):
20
+
21
+ ```sh
22
+ # Or direct download:
23
+ curl -LO https://github.com/arach/lattices/releases/latest/download/Lattices.dmg
24
+ open Lattices.dmg
25
+ ```
26
+
27
+ Drag **Lattices.app** into Applications. On first launch, a setup wizard
28
+ walks you through granting Accessibility, Screen Recording, and choosing
29
+ your project directory.
30
+
31
+ ### Install the CLI
32
+
13
33
  ```sh
14
34
  npm install -g @lattices/cli
15
35
  ```
16
36
 
37
+ The CLI and app work independently — use either or both.
38
+
39
+ ### Build from source
40
+
41
+ ```sh
42
+ git clone https://github.com/arach/lattices.git
43
+ cd lattices
44
+
45
+ # Build the menu bar app (requires Swift 5.9+ / Xcode 15+)
46
+ cd app && swift build -c release && cd ..
47
+
48
+ # Install CLI dependencies
49
+ npm install
50
+
51
+ # Launch
52
+ node bin/lattices-app.js build # bundle the .app
53
+ node bin/lattices-app.js # launch it
54
+ ```
55
+
56
+ To build a signed, notarized DMG for distribution:
57
+
58
+ ```sh
59
+ # Requires a Developer ID certificate and notarytool keychain profile
60
+ ./scripts/build-dmg.sh
61
+ ```
62
+
17
63
  ## Quick start
18
64
 
19
65
  ```sh
@@ -77,8 +123,8 @@ Bundle related repos as tabs in one session. Each tab gets its own
77
123
  pane layout from its `.lattices.json`.
78
124
 
79
125
  ```sh
80
- lattices group talkie # Launch iOS, macOS, Web, API as tabs
81
- lattices tab talkie iOS # Switch to the iOS tab
126
+ lattices group vox # Launch iOS, macOS, Web, API as tabs
127
+ lattices tab vox iOS # Switch to the iOS tab
82
128
  ```
83
129
 
84
130
  ## Window tiling and awareness
@@ -97,16 +143,31 @@ lattices scan recent # Browse scan history
97
143
  lattices scan deep # Trigger a Vision OCR scan now
98
144
  ```
99
145
 
146
+ ## Voice commands (beta)
147
+
148
+ Speak to control your workspace — tile windows, search, focus apps,
149
+ and launch projects with natural language. Powered by
150
+ [Vox](https://github.com/arach/vox) for transcription and
151
+ local NLEmbedding for intent matching, with Claude fallback for
152
+ ambiguous commands.
153
+
154
+ Trigger with `Hyper+3` (configurable). Press Space to speak, Space to
155
+ stop. The panel shows what was heard, the matched intent, extracted
156
+ parameters, and execution results.
157
+
100
158
  ## A programmable desktop
101
159
 
102
- The menu bar app runs a daemon with 30 RPC methods and 5 real-time
160
+ The menu bar app runs a daemon with 35 RPC methods and 5 real-time
103
161
  events over WebSocket. Anything you can do from the app, an agent or
104
162
  script can do over the API.
105
163
 
106
164
  ```js
107
165
  import { daemonCall } from '@lattices/cli/daemon-client'
108
166
 
109
- const windows = await daemonCall('windows.list')
167
+ // Search windows by content — title, app, session tags, OCR
168
+ const results = await daemonCall('windows.search', { query: 'myproject' })
169
+
170
+ // Launch and tile
110
171
  await daemonCall('session.launch', { path: '/Users/you/dev/frontend' })
111
172
  await daemonCall('window.tile', { session: 'frontend-a1b2c3', position: 'left' })
112
173
 
@@ -115,6 +176,14 @@ await daemonCall('ocr.scan')
115
176
  const errors = await daemonCall('ocr.search', { query: 'error OR failed' })
116
177
  ```
117
178
 
179
+ Or from the CLI:
180
+
181
+ ```sh
182
+ lattices search myproject # Find windows by content
183
+ lattices search myproject --deep # Include terminal tab/process data
184
+ lattices place myproject left # Search + focus + tile in one step
185
+ ```
186
+
118
187
  Claude Code skills, MCP servers, or your own scripts can drive your
119
188
  desktop the same way you do.
120
189
 
@@ -125,12 +194,15 @@ lattices Create or reattach to session
125
194
  lattices init Generate .lattices.json
126
195
  lattices ls List active sessions
127
196
  lattices kill [name] Kill a session
197
+ lattices search <query> Search windows by title, app, session, OCR
198
+ lattices search <q> --deep Deep search: index + terminal inspection
199
+ lattices place <query> [pos] Deep search + focus + tile
200
+ lattices focus <session> Raise a session's window
128
201
  lattices tile <position> Tile frontmost window
129
202
  lattices group [id] Launch or attach a tab group
130
203
  lattices tab <group> [tab] Switch tab within a group
131
204
  lattices scan View current screen text
132
205
  lattices scan search <q> Search indexed text
133
- lattices scan recent [n] Browse scan history
134
206
  lattices scan deep Trigger Vision OCR now
135
207
  lattices app Launch the menu bar app
136
208
  lattices help Show help
@@ -148,7 +220,11 @@ lattices help Show help
148
220
 
149
221
  ## Docs
150
222
 
151
- [lattices.dev/docs](https://lattices.dev/docs/overview)
223
+ Full documentation at [lattices.dev/docs](https://lattices.dev/docs/overview), including:
224
+
225
+ - [API reference](https://lattices.dev/docs/api) — all 35 daemon methods
226
+ - [Layers](https://lattices.dev/docs/layers) — workspace layers and tab groups
227
+ - [Voice commands](https://lattices.dev/docs/voice) — Vox integration
152
228
 
153
229
  ## License
154
230
 
package/app/Package.swift CHANGED
@@ -7,7 +7,14 @@ let package = Package(
7
7
  targets: [
8
8
  .executableTarget(
9
9
  name: "Lattices",
10
- path: "Sources"
10
+ path: "Sources",
11
+ resources: [
12
+ .copy("../Resources/tap.wav"),
13
+ ]
14
+ ),
15
+ .testTarget(
16
+ name: "LatticesTests",
17
+ path: "Tests"
11
18
  )
12
19
  ]
13
20
  )
@@ -0,0 +1,90 @@
1
+ import Foundation
2
+
3
+ /// Captures moments where the Claude advisor resolved something the local matcher couldn't.
4
+ /// Each entry records the transcript, what the local system matched (or missed), and what
5
+ /// the advisor suggested that the user accepted.
6
+ ///
7
+ /// For now this is append-only — just growing the dataset. Future work can use it to
8
+ /// improve local matching without needing the advisor.
9
+
10
+ final class AdvisorLearningStore {
11
+ static let shared = AdvisorLearningStore()
12
+
13
+ struct Entry: Codable {
14
+ let timestamp: String
15
+ let transcript: String
16
+ let localIntent: String?
17
+ let localSlots: [String: String]
18
+ let localResultCount: Int
19
+ let advisorIntent: String
20
+ let advisorSlots: [String: String]
21
+ let advisorLabel: String
22
+ }
23
+
24
+ private let fileURL: URL
25
+ private let queue = DispatchQueue(label: "com.lattices.advisor-learning")
26
+ private static let isoFmt: ISO8601DateFormatter = {
27
+ let f = ISO8601DateFormatter()
28
+ f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
29
+ return f
30
+ }()
31
+
32
+ private init() {
33
+ let dir = FileManager.default.homeDirectoryForCurrentUser
34
+ .appendingPathComponent(".lattices")
35
+ try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
36
+ fileURL = dir.appendingPathComponent("advisor-learning.jsonl")
37
+ }
38
+
39
+ /// Record that the user engaged with an advisor suggestion.
40
+ func record(
41
+ transcript: String,
42
+ localIntent: String?,
43
+ localSlots: [String: String],
44
+ localResultCount: Int,
45
+ advisorIntent: String,
46
+ advisorSlots: [String: String],
47
+ advisorLabel: String
48
+ ) {
49
+ let entry = Entry(
50
+ timestamp: Self.isoFmt.string(from: Date()),
51
+ transcript: transcript,
52
+ localIntent: localIntent,
53
+ localSlots: localSlots,
54
+ localResultCount: localResultCount,
55
+ advisorIntent: advisorIntent,
56
+ advisorSlots: advisorSlots,
57
+ advisorLabel: advisorLabel
58
+ )
59
+
60
+ queue.async {
61
+ guard let data = try? JSONEncoder().encode(entry),
62
+ var line = String(data: data, encoding: .utf8) else { return }
63
+ line += "\n"
64
+
65
+ if let handle = try? FileHandle(forWritingTo: self.fileURL) {
66
+ handle.seekToEndOfFile()
67
+ handle.write(line.data(using: .utf8)!)
68
+ handle.closeFile()
69
+ } else {
70
+ try? line.data(using: .utf8)?.write(to: self.fileURL)
71
+ }
72
+
73
+ DiagnosticLog.shared.info("AdvisorLearning: captured [\(transcript)] → \(advisorIntent)(\(advisorSlots))")
74
+ }
75
+ }
76
+
77
+ /// Read all entries (for analysis).
78
+ func allEntries() -> [Entry] {
79
+ guard let data = try? String(contentsOf: fileURL, encoding: .utf8) else { return [] }
80
+ return data.components(separatedBy: "\n").compactMap { line in
81
+ guard !line.isEmpty, let d = line.data(using: .utf8) else { return nil }
82
+ return try? JSONDecoder().decode(Entry.self, from: d)
83
+ }
84
+ }
85
+
86
+ var entryCount: Int {
87
+ guard let data = try? String(contentsOf: fileURL, encoding: .utf8) else { return 0 }
88
+ return data.components(separatedBy: "\n").filter { !$0.isEmpty }.count
89
+ }
90
+ }
@@ -0,0 +1,377 @@
1
+ import Foundation
2
+
3
+ // MARK: - Persistent Claude CLI Agent
4
+
5
+ /// Manages a persistent Claude conversation via `--session-id` + `--resume`.
6
+ /// Each query spawns a `claude -p` process that resumes the same session.
7
+ /// Uses `--output-format stream-json` for structured response parsing.
8
+ /// The conversation context carries over between calls via session persistence.
9
+
10
+ final class AgentSession: ObservableObject {
11
+ let model: String
12
+ let label: String
13
+ private(set) var sessionId: UUID
14
+
15
+ @Published var isReady = false
16
+ @Published var lastResponse: AgentResponse?
17
+ @Published var sessionStats: SessionStats = .empty
18
+
19
+ struct SessionStats {
20
+ let inputTokens: Int
21
+ let outputTokens: Int
22
+ let cacheReadTokens: Int
23
+ let cacheCreationTokens: Int
24
+ let contextWindow: Int
25
+ let costUSD: Double
26
+ let numTurns: Int
27
+
28
+ static let empty = SessionStats(inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreationTokens: 0, contextWindow: 0, costUSD: 0, numTurns: 0)
29
+
30
+ /// How full is the context? 0.0–1.0
31
+ var contextUsage: Double {
32
+ guard contextWindow > 0 else { return 0 }
33
+ let totalInput = inputTokens + cacheReadTokens + cacheCreationTokens
34
+ return Double(totalInput) / Double(contextWindow)
35
+ }
36
+ }
37
+
38
+ private var claudePath: String?
39
+ private let queue = DispatchQueue(label: "agent-session", qos: .userInitiated)
40
+ private var callCount = 0
41
+ private var busy = false
42
+
43
+ /// Optional override for the system prompt. If set, used instead of the default advisor prompt.
44
+ var customSystemPrompt: (() -> String)?
45
+
46
+ init(model: String, label: String) {
47
+ self.model = model
48
+ self.label = label
49
+ self.sessionId = UUID()
50
+ }
51
+
52
+ // MARK: - Lifecycle
53
+
54
+ func start() {
55
+ guard let resolved = Preferences.resolveClaudePath() else {
56
+ DiagnosticLog.shared.warn("AgentSession[\(label)]: claude CLI not found")
57
+ return
58
+ }
59
+ claudePath = resolved
60
+ DiagnosticLog.shared.info("AgentSession[\(label)]: ready (model=\(model), claude=\(resolved), session=\(sessionId.uuidString.prefix(8)))")
61
+ DispatchQueue.main.async { self.isReady = true }
62
+ }
63
+
64
+ func stop() {
65
+ DispatchQueue.main.async {
66
+ self.isReady = false
67
+ self.callCount = 0
68
+ }
69
+ }
70
+
71
+ // MARK: - Communication
72
+
73
+ /// Send a message and get a response via callback (main thread).
74
+ func send(message: String, callback: @escaping (AgentResponse?) -> Void) {
75
+ guard isReady else {
76
+ callback(nil)
77
+ return
78
+ }
79
+ guard !busy else {
80
+ DiagnosticLog.shared.info("AgentSession[\(label)]: busy, skipping")
81
+ callback(nil)
82
+ return
83
+ }
84
+
85
+ queue.async { [weak self] in
86
+ guard let self = self else { return }
87
+ self.busy = true
88
+ let response = self.call(prompt: message)
89
+ self.busy = false
90
+
91
+ DispatchQueue.main.async {
92
+ self.lastResponse = response
93
+ callback(response)
94
+ }
95
+ }
96
+ }
97
+
98
+ // MARK: - Claude CLI call
99
+
100
+ private func call(prompt: String) -> AgentResponse? {
101
+ let timer = DiagnosticLog.shared.startTimed("AgentSession[\(label)] call")
102
+
103
+ guard let claudePath = claudePath else { return nil }
104
+
105
+ let proc = Process()
106
+ proc.executableURL = URL(fileURLWithPath: claudePath)
107
+
108
+ var args = [
109
+ "-p", prompt,
110
+ "--model", model,
111
+ "--output-format", "stream-json",
112
+ "--max-budget-usd", String(format: "%.2f", Preferences.shared.advisorBudgetUSD),
113
+ "--permission-mode", "plan",
114
+ "--no-chrome",
115
+ ]
116
+
117
+ if callCount == 0 {
118
+ // First call: create session with system prompt
119
+ args.append(contentsOf: [
120
+ "--session-id", sessionId.uuidString,
121
+ "--system-prompt", customSystemPrompt?() ?? buildSystemPrompt(),
122
+ ])
123
+ } else {
124
+ // Subsequent calls: resume existing session (context carries over)
125
+ args.append(contentsOf: ["--resume", sessionId.uuidString])
126
+ }
127
+
128
+ proc.arguments = args
129
+
130
+ var env = ProcessInfo.processInfo.environment
131
+ env.removeValue(forKey: "CLAUDECODE")
132
+ proc.environment = env
133
+
134
+ let outPipe = Pipe()
135
+ let errPipe = Pipe()
136
+ proc.standardOutput = outPipe
137
+ proc.standardError = errPipe
138
+
139
+ do {
140
+ try proc.run()
141
+ } catch {
142
+ DiagnosticLog.shared.warn("AgentSession[\(label)]: launch failed — \(error)")
143
+ DiagnosticLog.shared.finish(timer)
144
+ return nil
145
+ }
146
+
147
+ proc.waitUntilExit()
148
+ DiagnosticLog.shared.finish(timer)
149
+
150
+ let output = String(data: outPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8)?
151
+ .trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
152
+ let stderr = String(data: errPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8)?
153
+ .trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
154
+
155
+ if !stderr.isEmpty {
156
+ DiagnosticLog.shared.info("AgentSession[\(label)] stderr: \(stderr.prefix(200))")
157
+ }
158
+
159
+ guard !output.isEmpty else {
160
+ DiagnosticLog.shared.info("AgentSession[\(label)]: empty response")
161
+ return nil
162
+ }
163
+
164
+ // Parse stream-json output — extract text and stats
165
+ let parsed = parseStreamJSON(output)
166
+
167
+ // Update session stats
168
+ let stats = parsed.stats
169
+ DispatchQueue.main.async {
170
+ self.sessionStats = stats
171
+ }
172
+
173
+ if stats.contextWindow > 0 {
174
+ let pct = Int(stats.contextUsage * 100)
175
+ DiagnosticLog.shared.info("AgentSession[\(label)]: context \(pct)% (\(stats.inputTokens + stats.cacheReadTokens + stats.cacheCreationTokens)/\(stats.contextWindow)) cost=$\(String(format: "%.4f", stats.costUSD))")
176
+ }
177
+
178
+ guard let text = parsed.text, !text.isEmpty else {
179
+ DiagnosticLog.shared.info("AgentSession[\(label)]: no text in response")
180
+ return nil
181
+ }
182
+
183
+ // Auto-reset session if context usage > 75%
184
+ if stats.contextUsage > 0.75 {
185
+ DiagnosticLog.shared.warn("AgentSession[\(label)]: context at \(Int(stats.contextUsage * 100))%, resetting session")
186
+ sessionId = UUID() // Fresh session ID
187
+ callCount = 0 // Next call will create a fresh session
188
+ } else {
189
+ callCount += 1
190
+ }
191
+
192
+ DiagnosticLog.shared.info("AgentSession[\(label)]: \(text.prefix(120))")
193
+ return AgentResponse.parse(text: text)
194
+ }
195
+
196
+ struct ParsedResponse {
197
+ let text: String?
198
+ let stats: SessionStats
199
+ }
200
+
201
+ /// Parse stream-json output lines, extract text and session stats from the result line.
202
+ private func parseStreamJSON(_ output: String) -> ParsedResponse {
203
+ let lines = output.components(separatedBy: "\n")
204
+ var resultText: String?
205
+ var stats = SessionStats.empty
206
+
207
+ for line in lines {
208
+ guard let data = line.data(using: .utf8),
209
+ let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { continue }
210
+
211
+ let type = json["type"] as? String
212
+
213
+ if type == "result" {
214
+ resultText = json["result"] as? String
215
+ let numTurns = json["num_turns"] as? Int ?? 0
216
+ let costUSD = json["total_cost_usd"] as? Double ?? 0
217
+
218
+ // Usage stats
219
+ let usage = json["usage"] as? [String: Any] ?? [:]
220
+ let inputTokens = usage["input_tokens"] as? Int ?? 0
221
+ let outputTokens = usage["output_tokens"] as? Int ?? 0
222
+ let cacheRead = usage["cache_read_input_tokens"] as? Int ?? 0
223
+ let cacheCreation = usage["cache_creation_input_tokens"] as? Int ?? 0
224
+
225
+ // Context window from modelUsage
226
+ var contextWindow = 0
227
+ if let modelUsage = json["modelUsage"] as? [String: Any] {
228
+ for (_, v) in modelUsage {
229
+ if let m = v as? [String: Any], let cw = m["contextWindow"] as? Int {
230
+ contextWindow = cw
231
+ }
232
+ }
233
+ }
234
+
235
+ stats = SessionStats(
236
+ inputTokens: inputTokens,
237
+ outputTokens: outputTokens,
238
+ cacheReadTokens: cacheRead,
239
+ cacheCreationTokens: cacheCreation,
240
+ contextWindow: contextWindow,
241
+ costUSD: costUSD,
242
+ numTurns: numTurns
243
+ )
244
+ }
245
+
246
+ // Fallback: accumulate text from assistant content blocks
247
+ if resultText == nil, type == "assistant",
248
+ let message = json["message"] as? [String: Any],
249
+ let content = message["content"] as? [[String: Any]] {
250
+ var text = ""
251
+ for block in content {
252
+ if block["type"] as? String == "text",
253
+ let t = block["text"] as? String {
254
+ text += t
255
+ }
256
+ }
257
+ if !text.isEmpty { resultText = text }
258
+ }
259
+ }
260
+
261
+ return ParsedResponse(text: resultText, stats: stats)
262
+ }
263
+
264
+ // MARK: - System prompt
265
+
266
+ private func buildSystemPrompt() -> String {
267
+ let windowSummary = DesktopModel.shared.windows.values
268
+ .prefix(20)
269
+ .map { "\($0.app): \($0.title)" }
270
+ .joined(separator: "\n")
271
+
272
+ let intentList = PhraseMatcher.shared.catalog()
273
+ var intentSummary = ""
274
+ if case .array(let intents) = intentList {
275
+ intentSummary = intents.compactMap { intent -> String? in
276
+ guard let name = intent["intent"]?.stringValue else { return nil }
277
+ var slotNames: [String] = []
278
+ if case .array(let slots) = intent["slots"] {
279
+ slotNames = slots.compactMap { $0["name"]?.stringValue }
280
+ }
281
+ let s = slotNames.isEmpty ? "" : "(\(slotNames.joined(separator: ", ")))"
282
+ return "\(name)\(s)"
283
+ }.joined(separator: ", ")
284
+ }
285
+
286
+ return """
287
+ You are an advisor for Lattices, a macOS workspace manager. You run alongside voice commands, providing commentary and follow-up suggestions.
288
+
289
+ Available commands: \(intentSummary)
290
+
291
+ Current windows:
292
+ \(windowSummary)
293
+
294
+ For each user message, you receive a voice transcript and what command was matched.
295
+
296
+ Respond with ONLY a JSON object:
297
+ {"commentary": "short observation or null", "suggestion": {"label": "button text", "intent": "intent_name", "slots": {"key": "value"}} or null}
298
+
299
+ Rules:
300
+ - commentary: 1 sentence max. null if the matched command fully covers the request.
301
+ - suggestion: a follow-up action. null if none needed.
302
+ - Never suggest what was already executed.
303
+ - Suggestions MUST include all required slots. e.g. search requires {"query": "..."}.
304
+ - Be terse and useful, not chatty.
305
+ """
306
+ }
307
+ }
308
+
309
+ // MARK: - Response types
310
+
311
+ struct AgentResponse {
312
+ let commentary: String?
313
+ let suggestion: AgentSuggestion?
314
+ let raw: String
315
+
316
+ struct AgentSuggestion {
317
+ let label: String
318
+ let intent: String
319
+ let slots: [String: String]
320
+ }
321
+
322
+ static func parse(text: String) -> AgentResponse {
323
+ guard let jsonStr = extractJSON(from: text),
324
+ let data = jsonStr.data(using: .utf8),
325
+ let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
326
+ return AgentResponse(commentary: text, suggestion: nil, raw: text)
327
+ }
328
+
329
+ let commentary = json["commentary"] as? String
330
+
331
+ var suggestion: AgentSuggestion?
332
+ if let s = json["suggestion"] as? [String: Any],
333
+ let label = s["label"] as? String,
334
+ let intent = s["intent"] as? String {
335
+ let slots = (s["slots"] as? [String: String]) ?? [:]
336
+ suggestion = AgentSuggestion(label: label, intent: intent, slots: slots)
337
+ }
338
+
339
+ return AgentResponse(commentary: commentary, suggestion: suggestion, raw: text)
340
+ }
341
+
342
+ private static func extractJSON(from text: String) -> String? {
343
+ let cleaned = text
344
+ .replacingOccurrences(of: "```json", with: "")
345
+ .replacingOccurrences(of: "```", with: "")
346
+ .trimmingCharacters(in: .whitespacesAndNewlines)
347
+ guard let start = cleaned.firstIndex(of: "{"),
348
+ let end = cleaned.lastIndex(of: "}") else { return nil }
349
+ return String(cleaned[start...end])
350
+ }
351
+ }
352
+
353
+ // MARK: - Agent Pool
354
+
355
+ /// Manages the Haiku (fast advisor) and Sonnet (deep thinker) agent sessions.
356
+ final class AgentPool {
357
+ static let shared = AgentPool()
358
+
359
+ let haiku = AgentSession(model: "haiku", label: "haiku")
360
+ let sonnet = AgentSession(model: "sonnet", label: "sonnet")
361
+
362
+ private init() {}
363
+
364
+ func start() {
365
+ DiagnosticLog.shared.info("AgentPool: starting haiku + sonnet sessions")
366
+ haiku.start()
367
+ // Stagger sonnet start
368
+ DispatchQueue.global().asyncAfter(deadline: .now() + 5) {
369
+ self.sonnet.start()
370
+ }
371
+ }
372
+
373
+ func stop() {
374
+ haiku.stop()
375
+ sonnet.stop()
376
+ }
377
+ }