@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.
- package/README.md +85 -9
- package/app/Package.swift +8 -1
- package/app/Sources/AdvisorLearningStore.swift +90 -0
- package/app/Sources/AgentSession.swift +377 -0
- package/app/Sources/AppDelegate.swift +44 -12
- package/app/Sources/AppShellView.swift +81 -8
- package/app/Sources/AudioProvider.swift +386 -0
- package/app/Sources/CheatSheetHUD.swift +261 -19
- package/app/Sources/DaemonProtocol.swift +13 -0
- package/app/Sources/DaemonServer.swift +8 -0
- package/app/Sources/DesktopModel.swift +164 -5
- package/app/Sources/DesktopModelTypes.swift +2 -0
- package/app/Sources/DiagnosticLog.swift +104 -2
- package/app/Sources/EventBus.swift +1 -0
- package/app/Sources/HUDBottomBar.swift +279 -0
- package/app/Sources/HUDController.swift +1158 -0
- package/app/Sources/HUDLeftBar.swift +849 -0
- package/app/Sources/HUDMinimap.swift +179 -0
- package/app/Sources/HUDRightBar.swift +774 -0
- package/app/Sources/HUDState.swift +367 -0
- package/app/Sources/HUDTopBar.swift +243 -0
- package/app/Sources/HandsOffSession.swift +733 -0
- package/app/Sources/HomeDashboardView.swift +125 -0
- package/app/Sources/HotkeyManager.swift +2 -0
- package/app/Sources/HotkeyStore.swift +45 -9
- package/app/Sources/IntentEngine.swift +925 -0
- package/app/Sources/Intents/CreateLayerIntent.swift +54 -0
- package/app/Sources/Intents/DistributeIntent.swift +56 -0
- package/app/Sources/Intents/FocusIntent.swift +69 -0
- package/app/Sources/Intents/HelpIntent.swift +41 -0
- package/app/Sources/Intents/KillIntent.swift +47 -0
- package/app/Sources/Intents/LatticeIntent.swift +78 -0
- package/app/Sources/Intents/LaunchIntent.swift +67 -0
- package/app/Sources/Intents/ListSessionsIntent.swift +32 -0
- package/app/Sources/Intents/ListWindowsIntent.swift +30 -0
- package/app/Sources/Intents/ScanIntent.swift +52 -0
- package/app/Sources/Intents/SearchIntent.swift +190 -0
- package/app/Sources/Intents/SwitchLayerIntent.swift +50 -0
- package/app/Sources/Intents/TileIntent.swift +61 -0
- package/app/Sources/LatticesApi.swift +1235 -30
- package/app/Sources/LauncherHUD.swift +348 -0
- package/app/Sources/MainView.swift +147 -44
- package/app/Sources/OcrModel.swift +34 -1
- package/app/Sources/OmniSearchState.swift +99 -102
- package/app/Sources/OnboardingView.swift +457 -0
- package/app/Sources/PermissionChecker.swift +2 -12
- package/app/Sources/PiChatDock.swift +454 -0
- package/app/Sources/PiChatSession.swift +815 -0
- package/app/Sources/PiWorkspaceView.swift +364 -0
- package/app/Sources/PlacementSpec.swift +195 -0
- package/app/Sources/Preferences.swift +59 -0
- package/app/Sources/ProjectScanner.swift +1 -1
- package/app/Sources/ScreenMapState.swift +701 -55
- package/app/Sources/ScreenMapView.swift +843 -103
- package/app/Sources/ScreenMapWindowController.swift +22 -0
- package/app/Sources/SessionLayerStore.swift +285 -0
- package/app/Sources/SessionManager.swift +4 -1
- package/app/Sources/SettingsView.swift +186 -3
- package/app/Sources/Theme.swift +9 -8
- package/app/Sources/TmuxModel.swift +7 -0
- package/app/Sources/TmuxQuery.swift +27 -3
- package/app/Sources/VoiceChatView.swift +192 -0
- package/app/Sources/VoiceCommandWindow.swift +1594 -0
- package/app/Sources/VoiceIntentResolver.swift +671 -0
- package/app/Sources/VoxClient.swift +454 -0
- package/app/Sources/WindowTiler.swift +348 -87
- package/app/Sources/WorkspaceManager.swift +127 -18
- package/bin/client.ts +16 -0
- package/bin/{daemon-client.js → daemon-client.ts} +49 -30
- package/bin/handsoff-infer.ts +280 -0
- package/bin/handsoff-worker.ts +731 -0
- package/bin/{lattices-app.js → lattices-app.ts} +67 -32
- package/bin/lattices-dev +160 -0
- package/bin/{lattices.js → lattices.ts} +600 -137
- package/bin/project-twin.ts +645 -0
- package/docs/agent-execution-plan.md +562 -0
- package/docs/agents.md +142 -0
- package/docs/api.md +153 -34
- package/docs/app.md +29 -1
- package/docs/config.md +5 -1
- package/docs/handsoff-test-scenarios.md +84 -0
- package/docs/layers.md +20 -20
- package/docs/ocr.md +14 -5
- package/docs/overview.md +5 -1
- package/docs/presentation-execution-review.md +491 -0
- package/docs/prompts/hands-off-system.md +374 -0
- package/docs/prompts/hands-off-turn.md +30 -0
- package/docs/prompts/voice-advisor.md +31 -0
- package/docs/prompts/voice-fallback.md +23 -0
- package/docs/tiling-reference.md +167 -0
- package/docs/twins.md +138 -0
- package/docs/voice-command-protocol.md +278 -0
- package/docs/voice.md +219 -0
- package/package.json +21 -10
- package/bin/client.js +0 -4
package/README.md
CHANGED
|
@@ -4,16 +4,62 @@
|
|
|
4
4
|
|
|
5
5
|
# lattices
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
|
81
|
-
lattices 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
|
|
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
|
-
|
|
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
|
+
}
|