@lattices/cli 0.4.0 → 0.4.1

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/app/Info.plist ADDED
@@ -0,0 +1,30 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
+ <plist version="1.0">
4
+ <dict>
5
+ <key>CFBundleIdentifier</key>
6
+ <string>com.arach.lattices</string>
7
+ <key>CFBundleName</key>
8
+ <string>Lattices</string>
9
+ <key>CFBundleDisplayName</key>
10
+ <string>Lattices</string>
11
+ <key>CFBundleExecutable</key>
12
+ <string>Lattices</string>
13
+ <key>CFBundleIconFile</key>
14
+ <string>AppIcon</string>
15
+ <key>CFBundlePackageType</key>
16
+ <string>APPL</string>
17
+ <key>CFBundleVersion</key>
18
+ <string>0.4.1</string>
19
+ <key>CFBundleShortVersionString</key>
20
+ <string>0.4.1</string>
21
+ <key>LSMinimumSystemVersion</key>
22
+ <string>13.0</string>
23
+ <key>LSUIElement</key>
24
+ <true/>
25
+ <key>NSHighResolutionCapable</key>
26
+ <true/>
27
+ <key>NSSupportsAutomaticTermination</key>
28
+ <true/>
29
+ </dict>
30
+ </plist>
@@ -6,6 +6,8 @@
6
6
  <string>com.arach.lattices</string>
7
7
  <key>CFBundleName</key>
8
8
  <string>Lattices</string>
9
+ <key>CFBundleDisplayName</key>
10
+ <string>Lattices</string>
9
11
  <key>CFBundleExecutable</key>
10
12
  <string>Lattices</string>
11
13
  <key>CFBundleIconFile</key>
@@ -13,11 +15,15 @@
13
15
  <key>CFBundlePackageType</key>
14
16
  <string>APPL</string>
15
17
  <key>CFBundleVersion</key>
16
- <string>1</string>
18
+ <string>0.4.1</string>
17
19
  <key>CFBundleShortVersionString</key>
18
- <string>0.1.0</string>
20
+ <string>0.4.1</string>
21
+ <key>LSMinimumSystemVersion</key>
22
+ <string>13.0</string>
19
23
  <key>LSUIElement</key>
20
24
  <true/>
25
+ <key>NSHighResolutionCapable</key>
26
+ <true/>
21
27
  <key>NSSupportsAutomaticTermination</key>
22
28
  <true/>
23
29
  </dict>
@@ -0,0 +1,139 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
+ <plist version="1.0">
4
+ <dict>
5
+ <key>files</key>
6
+ <dict>
7
+ <key>Resources/AppIcon.icns</key>
8
+ <data>
9
+ 3sIZmtGJHMo2S/XvIMl46SOHcFY=
10
+ </data>
11
+ <key>Resources/tap.wav</key>
12
+ <data>
13
+ eOpp5td/ovQGMumXPpwy4Vyt/uc=
14
+ </data>
15
+ </dict>
16
+ <key>files2</key>
17
+ <dict>
18
+ <key>Resources/AppIcon.icns</key>
19
+ <dict>
20
+ <key>hash2</key>
21
+ <data>
22
+ LZsztS/9I1hmuQmDOk+anfxOpqVryB3y4a1kwSaUK4s=
23
+ </data>
24
+ </dict>
25
+ <key>Resources/tap.wav</key>
26
+ <dict>
27
+ <key>hash2</key>
28
+ <data>
29
+ K4QV08FuKEJR29hhgUbEG7Em3J6zHYpGKmGWdnZopzs=
30
+ </data>
31
+ </dict>
32
+ </dict>
33
+ <key>rules</key>
34
+ <dict>
35
+ <key>^Resources/</key>
36
+ <true/>
37
+ <key>^Resources/.*\.lproj/</key>
38
+ <dict>
39
+ <key>optional</key>
40
+ <true/>
41
+ <key>weight</key>
42
+ <real>1000</real>
43
+ </dict>
44
+ <key>^Resources/.*\.lproj/locversion.plist$</key>
45
+ <dict>
46
+ <key>omit</key>
47
+ <true/>
48
+ <key>weight</key>
49
+ <real>1100</real>
50
+ </dict>
51
+ <key>^Resources/Base\.lproj/</key>
52
+ <dict>
53
+ <key>weight</key>
54
+ <real>1010</real>
55
+ </dict>
56
+ <key>^version.plist$</key>
57
+ <true/>
58
+ </dict>
59
+ <key>rules2</key>
60
+ <dict>
61
+ <key>.*\.dSYM($|/)</key>
62
+ <dict>
63
+ <key>weight</key>
64
+ <real>11</real>
65
+ </dict>
66
+ <key>^(.*/)?\.DS_Store$</key>
67
+ <dict>
68
+ <key>omit</key>
69
+ <true/>
70
+ <key>weight</key>
71
+ <real>2000</real>
72
+ </dict>
73
+ <key>^(Frameworks|SharedFrameworks|PlugIns|Plug-ins|XPCServices|Helpers|MacOS|Library/(Automator|Spotlight|LoginItems))/</key>
74
+ <dict>
75
+ <key>nested</key>
76
+ <true/>
77
+ <key>weight</key>
78
+ <real>10</real>
79
+ </dict>
80
+ <key>^.*</key>
81
+ <true/>
82
+ <key>^Info\.plist$</key>
83
+ <dict>
84
+ <key>omit</key>
85
+ <true/>
86
+ <key>weight</key>
87
+ <real>20</real>
88
+ </dict>
89
+ <key>^PkgInfo$</key>
90
+ <dict>
91
+ <key>omit</key>
92
+ <true/>
93
+ <key>weight</key>
94
+ <real>20</real>
95
+ </dict>
96
+ <key>^Resources/</key>
97
+ <dict>
98
+ <key>weight</key>
99
+ <real>20</real>
100
+ </dict>
101
+ <key>^Resources/.*\.lproj/</key>
102
+ <dict>
103
+ <key>optional</key>
104
+ <true/>
105
+ <key>weight</key>
106
+ <real>1000</real>
107
+ </dict>
108
+ <key>^Resources/.*\.lproj/locversion.plist$</key>
109
+ <dict>
110
+ <key>omit</key>
111
+ <true/>
112
+ <key>weight</key>
113
+ <real>1100</real>
114
+ </dict>
115
+ <key>^Resources/Base\.lproj/</key>
116
+ <dict>
117
+ <key>weight</key>
118
+ <real>1010</real>
119
+ </dict>
120
+ <key>^[^/]+$</key>
121
+ <dict>
122
+ <key>nested</key>
123
+ <true/>
124
+ <key>weight</key>
125
+ <real>10</real>
126
+ </dict>
127
+ <key>^embedded\.provisionprofile$</key>
128
+ <dict>
129
+ <key>weight</key>
130
+ <real>20</real>
131
+ </dict>
132
+ <key>^version\.plist$</key>
133
+ <dict>
134
+ <key>weight</key>
135
+ <real>20</real>
136
+ </dict>
137
+ </dict>
138
+ </dict>
139
+ </plist>
@@ -0,0 +1,15 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
+ <plist version="1.0">
4
+ <dict>
5
+ <!-- App sandbox disabled — Lattices needs direct access to tmux, processes, and the filesystem -->
6
+ <key>com.apple.security.app-sandbox</key>
7
+ <false/>
8
+
9
+ <!-- Network: localhost WebSocket daemon for CLI/agent communication -->
10
+ <key>com.apple.security.network.server</key>
11
+ <true/>
12
+ <key>com.apple.security.network.client</key>
13
+ <true/>
14
+ </dict>
15
+ </plist>
Binary file
@@ -99,6 +99,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
99
99
  }
100
100
  }
101
101
  store.register(action: .hud) { HUDController.shared.toggle() }
102
+ store.register(action: .mouseFinder) { MouseFinder.shared.find() }
102
103
 
103
104
  // Pre-render HUD panels off-screen for instant first open
104
105
  DispatchQueue.main.async { HUDController.shared.warmUp() }
@@ -134,7 +134,24 @@ final class DesktopModel: ObservableObject {
134
134
 
135
135
  // MARK: - Polling
136
136
 
137
+ private var lastPollTime: Date = .distantPast
138
+ private static let minPollInterval: TimeInterval = 1.0
139
+
140
+ /// Poll only if stale. Call `forcePoll()` to bypass the freshness check.
137
141
  func poll() {
142
+ let now = Date()
143
+ guard now.timeIntervalSince(lastPollTime) >= Self.minPollInterval else { return }
144
+ lastPollTime = now
145
+ performPoll()
146
+ }
147
+
148
+ /// Force a poll regardless of freshness — use sparingly.
149
+ func forcePoll() {
150
+ lastPollTime = Date()
151
+ performPoll()
152
+ }
153
+
154
+ private func performPoll() {
138
155
  guard let list = CGWindowListCopyWindowInfo(
139
156
  [.optionAll, .excludeDesktopElements],
140
157
  kCGNullWindowID
@@ -225,8 +242,11 @@ final class DesktopModel: ObservableObject {
225
242
  if markFrontmost, let frontmostWid {
226
243
  interactions[frontmostWid] = interactionTime
227
244
  }
228
- self.windows = fresh
229
- self.interactionDates = interactions
245
+ // Only publish if something actually changed — avoids unnecessary SwiftUI re-renders
246
+ if changed || markFrontmost {
247
+ self.windows = fresh
248
+ self.interactionDates = interactions
249
+ }
230
250
  self.lastFrontmostWid = frontmostWid
231
251
  }
232
252
 
@@ -255,6 +275,10 @@ final class DesktopModel: ObservableObject {
255
275
 
256
276
  for (pid, wids) in byPid {
257
277
  let axApp = AXUIElementCreateApplication(pid)
278
+
279
+ // Set a timeout so unresponsive apps (video calls, etc.) don't block the poll
280
+ AXUIElementSetMessagingTimeout(axApp, 0.3)
281
+
258
282
  var axWindowsRef: CFTypeRef?
259
283
  guard AXUIElementCopyAttributeValue(axApp, kAXWindowsAttribute as CFString, &axWindowsRef) == .success,
260
284
  let axWindows = axWindowsRef as? [AXUIElement] else { continue }
@@ -251,6 +251,8 @@ final class HandsOffSession: ObservableObject {
251
251
  // MARK: - Worker communication
252
252
 
253
253
  private var pendingCallback: (([String: Any]) -> Void)?
254
+ private var turnTimeoutWork: DispatchWorkItem?
255
+ private static let turnTimeoutSeconds: TimeInterval = 30
254
256
 
255
257
  private func sendToWorker(_ dict: [String: Any]) {
256
258
  guard let data = try? JSONSerialization.data(withJSONObject: dict),
@@ -335,17 +337,34 @@ final class HandsOffSession: ObservableObject {
335
337
  case .listening:
336
338
  finishListening()
337
339
  case .thinking:
338
- DiagnosticLog.shared.info("HandsOff: busy, ignoring toggle")
340
+ cancelTurn()
339
341
  case .connecting:
340
342
  cancel()
341
343
  }
342
344
  }
343
345
 
344
346
  func cancel() {
347
+ cancelVoxSession()
345
348
  state = .idle
346
349
  DiagnosticLog.shared.info("HandsOff: cancelled")
347
350
  }
348
351
 
352
+ private func cancelTurn() {
353
+ turnTimeoutWork?.cancel()
354
+ turnTimeoutWork = nil
355
+ pendingCallback = nil
356
+ state = .idle
357
+ DiagnosticLog.shared.warn("HandsOff: turn cancelled by user")
358
+ playSound("Funk")
359
+ }
360
+
361
+ /// Cancel any active Vox recording session without transcribing.
362
+ private func cancelVoxSession() {
363
+ guard VoxClient.shared.activeSessionId != nil else { return }
364
+ DiagnosticLog.shared.info("HandsOff: cancelling Vox session")
365
+ VoxClient.shared.cancelSession()
366
+ }
367
+
349
368
  // MARK: - Voice capture
350
369
 
351
370
  private func beginListening() {
@@ -377,24 +396,46 @@ final class HandsOffSession: ObservableObject {
377
396
  }
378
397
  }
379
398
 
399
+ /// Guard against double-processing the transcript (session.final + completion can both deliver it).
400
+ private var turnProcessed = false
401
+
380
402
  private func startDictation() {
381
403
  state = .listening
382
404
  lastTranscript = nil
405
+ turnProcessed = false
383
406
  playSound("Tink")
384
407
 
385
408
  DiagnosticLog.shared.info("HandsOff: listening...")
386
409
 
387
410
  // Vox live session: startSession opens the mic, events flow on the start call ID.
388
- // No partial transcripts Vox transcribes after recording stops.
411
+ // session.final arrives via onProgress, then the same data arrives via completion.
412
+ // We process the transcript from whichever arrives first to be resilient against
413
+ // connection drops between the two.
389
414
  VoxClient.shared.startSession(
390
415
  onProgress: { [weak self] event, data in
391
416
  DispatchQueue.main.async {
392
- if event == "session.state" {
417
+ guard let self else { return }
418
+ switch event {
419
+ case "session.state":
393
420
  let sessionState = data["state"] as? String ?? ""
394
421
  DiagnosticLog.shared.info("HandsOff: session → \(sessionState)")
395
- }
396
- if event == "session.final", let text = data["text"] as? String {
397
- self?.lastTranscript = text
422
+ // Vox cancelled the session (e.g. recording timeout)
423
+ if sessionState == "cancelled" {
424
+ let reason = data["reason"] as? String ?? "unknown"
425
+ DiagnosticLog.shared.warn("HandsOff: Vox cancelled session — \(reason)")
426
+ if self.state == .listening {
427
+ self.state = .idle
428
+ self.playSound("Basso")
429
+ }
430
+ }
431
+ case "session.final":
432
+ // Primary transcript delivery — process immediately
433
+ if let text = data["text"] as? String, !text.isEmpty {
434
+ self.lastTranscript = text
435
+ self.deliverTranscript(text)
436
+ }
437
+ default:
438
+ break
398
439
  }
399
440
  }
400
441
  },
@@ -405,24 +446,36 @@ final class HandsOffSession: ObservableObject {
405
446
  case .success(let data):
406
447
  let text = data["text"] as? String ?? ""
407
448
  if text.isEmpty {
408
- self.state = .idle
409
- DiagnosticLog.shared.info("HandsOff: no speech detected")
449
+ if !self.turnProcessed {
450
+ self.state = .idle
451
+ DiagnosticLog.shared.info("HandsOff: no speech detected")
452
+ }
410
453
  } else {
454
+ // Fallback — deliver if session.final didn't already
411
455
  self.lastTranscript = text
412
- DiagnosticLog.shared.info("HandsOff: heard → '\(text)'")
413
- self.appendChat(.user, text: text)
414
- self.processTurn(text)
456
+ self.deliverTranscript(text)
415
457
  }
416
458
  case .failure(let error):
417
- self.state = .idle
418
- DiagnosticLog.shared.warn("HandsOff: session error — \(error.localizedDescription)")
419
- self.playSound("Basso")
459
+ if !self.turnProcessed {
460
+ self.state = .idle
461
+ DiagnosticLog.shared.warn("HandsOff: session error — \(error.localizedDescription)")
462
+ self.playSound("Basso")
463
+ }
420
464
  }
421
465
  }
422
466
  }
423
467
  )
424
468
  }
425
469
 
470
+ /// Deliver transcript exactly once — called from both session.final and completion.
471
+ private func deliverTranscript(_ text: String) {
472
+ guard !turnProcessed else { return }
473
+ turnProcessed = true
474
+ DiagnosticLog.shared.info("HandsOff: heard → '\(text)'")
475
+ appendChat(.user, text: text)
476
+ processTurn(text)
477
+ }
478
+
426
479
  func finishListening() {
427
480
  guard state == .listening else { return }
428
481
  playSound("Tink")
@@ -449,9 +502,25 @@ final class HandsOffSession: ObservableObject {
449
502
  "history": conversationHistory,
450
503
  ]
451
504
 
505
+ // Start turn timeout — forcibly reset if worker never responds
506
+ turnTimeoutWork?.cancel()
507
+ let timeout = DispatchWorkItem { [weak self] in
508
+ guard let self, self.state == .thinking else { return }
509
+ DiagnosticLog.shared.warn("HandsOff: ⏱ turn \(self.turnCount) timed out after \(Int(Self.turnTimeoutSeconds))s")
510
+ self.pendingCallback = nil
511
+ self.state = .idle
512
+ self.playSound("Basso")
513
+ }
514
+ turnTimeoutWork = timeout
515
+ DispatchQueue.main.asyncAfter(deadline: .now() + Self.turnTimeoutSeconds, execute: timeout)
516
+
452
517
  sendToWorkerWithCallback(turnCmd) { [weak self] response in
453
518
  guard let self else { return }
454
519
 
520
+ // Cancel the timeout — we got a response
521
+ self.turnTimeoutWork?.cancel()
522
+ self.turnTimeoutWork = nil
523
+
455
524
  let turnMs = Int(Date().timeIntervalSince(turnStart) * 1000)
456
525
  DiagnosticLog.shared.info("HandsOff: ⏱ turn \(self.turnCount) complete — \(turnMs)ms")
457
526
 
@@ -24,6 +24,7 @@ enum HotkeyAction: String, CaseIterable, Codable {
24
24
  case handsOff
25
25
  case unifiedWindow
26
26
  case hud
27
+ case mouseFinder
27
28
  // Layers
28
29
  case layer1, layer2, layer3, layer4, layer5, layer6, layer7, layer8, layer9
29
30
  case layerNext, layerPrev, layerTag
@@ -45,6 +46,7 @@ enum HotkeyAction: String, CaseIterable, Codable {
45
46
  case .handsOff: return "Hands-Off Mode"
46
47
  case .unifiedWindow: return "Unified Window"
47
48
  case .hud: return "HUD"
49
+ case .mouseFinder: return "Find Mouse"
48
50
  case .layer1: return "Layer 1"
49
51
  case .layer2: return "Layer 2"
50
52
  case .layer3: return "Layer 3"
@@ -76,7 +78,7 @@ enum HotkeyAction: String, CaseIterable, Codable {
76
78
 
77
79
  var group: HotkeyGroup {
78
80
  switch self {
79
- case .palette, .screenMap, .bezel, .cheatSheet, .desktopInventory, .omniSearch, .voiceCommand, .handsOff, .unifiedWindow, .hud: return .app
81
+ case .palette, .screenMap, .bezel, .cheatSheet, .desktopInventory, .omniSearch, .voiceCommand, .handsOff, .unifiedWindow, .hud, .mouseFinder: return .app
80
82
  case .layer1, .layer2, .layer3, .layer4, .layer5,
81
83
  .layer6, .layer7, .layer8, .layer9,
82
84
  .layerNext, .layerPrev, .layerTag: return .layers
@@ -96,6 +98,7 @@ enum HotkeyAction: String, CaseIterable, Codable {
96
98
  case .handsOff: return 206
97
99
  case .unifiedWindow: return 207
98
100
  case .hud: return 208
101
+ case .mouseFinder: return 209
99
102
  case .layer1: return 101
100
103
  case .layer2: return 102
101
104
  case .layer3: return 103
@@ -231,6 +234,7 @@ class HotkeyStore: ObservableObject {
231
234
  bind(.handsOff, 46, cmdCtrl) // Ctrl+Cmd+M
232
235
  bind(.omniSearch, 23, hyper) // Hyper+5
233
236
  bind(.cheatSheet, 22, hyper) // Hyper+6
237
+ bind(.mouseFinder, 26, hyper) // Hyper+7
234
238
 
235
239
  // Layers: Cmd+Option+1-9
236
240
  let layerKeyCodes: [UInt32] = [18, 19, 20, 21, 23, 22, 26, 28, 25]
@@ -723,6 +723,43 @@ final class IntentEngine {
723
723
  }
724
724
  ))
725
725
 
726
+ // ── Find / Summon Mouse ────────────────────────────────
727
+
728
+ register(IntentDef(
729
+ name: "find_mouse",
730
+ description: "Show a sonar pulse at the current mouse cursor position",
731
+ examples: [
732
+ "where's my mouse",
733
+ "find the cursor",
734
+ "I lost my mouse",
735
+ "find mouse",
736
+ "show cursor"
737
+ ],
738
+ slots: [],
739
+ handler: { _ in
740
+ DispatchQueue.main.async { MouseFinder.shared.find() }
741
+ let pos = NSEvent.mouseLocation
742
+ return .object(["ok": .bool(true), "x": .int(Int(pos.x)), "y": .int(Int(pos.y))])
743
+ }
744
+ ))
745
+
746
+ register(IntentDef(
747
+ name: "summon_mouse",
748
+ description: "Warp the mouse cursor to the center of the screen",
749
+ examples: [
750
+ "summon mouse",
751
+ "bring the cursor here",
752
+ "center the mouse",
753
+ "mouse come here",
754
+ "bring mouse back"
755
+ ],
756
+ slots: [],
757
+ handler: { _ in
758
+ DispatchQueue.main.async { MouseFinder.shared.summon() }
759
+ return .object(["ok": .bool(true)])
760
+ }
761
+ ))
762
+
726
763
  // ── Undo / Restore ─────────────────────────────────────
727
764
 
728
765
  register(IntentDef(
@@ -1818,6 +1818,46 @@ final class LatticesApi {
1818
1818
 
1819
1819
  // ── Meta endpoint ───────────────────────────────────────
1820
1820
 
1821
+ // ── Mouse Finder ────────────────────────────────────────
1822
+
1823
+ api.register(Endpoint(
1824
+ method: "mouse.find",
1825
+ description: "Show a sonar pulse at the current mouse cursor position",
1826
+ access: .read,
1827
+ params: [],
1828
+ returns: .ok,
1829
+ handler: { _ in
1830
+ DispatchQueue.main.async { MouseFinder.shared.find() }
1831
+ let pos = NSEvent.mouseLocation
1832
+ return .object(["ok": .bool(true), "x": .int(Int(pos.x)), "y": .int(Int(pos.y))])
1833
+ }
1834
+ ))
1835
+
1836
+ api.register(Endpoint(
1837
+ method: "mouse.summon",
1838
+ description: "Warp the mouse cursor to screen center (or a given point) and show a sonar pulse",
1839
+ access: .mutate,
1840
+ params: [
1841
+ Param(name: "x", type: "int", required: false, description: "Target X coordinate (screen, bottom-left origin)"),
1842
+ Param(name: "y", type: "int", required: false, description: "Target Y coordinate (screen, bottom-left origin)"),
1843
+ ],
1844
+ returns: .ok,
1845
+ handler: { params in
1846
+ let target: CGPoint?
1847
+ if let x = params?["x"]?.intValue, let y = params?["y"]?.intValue {
1848
+ target = CGPoint(x: CGFloat(x), y: CGFloat(y))
1849
+ } else {
1850
+ target = nil
1851
+ }
1852
+ DispatchQueue.main.async { MouseFinder.shared.summon(to: target) }
1853
+ let pos = target ?? {
1854
+ let screen = NSScreen.main ?? NSScreen.screens[0]
1855
+ return CGPoint(x: screen.frame.midX, y: screen.frame.midY)
1856
+ }()
1857
+ return .object(["ok": .bool(true), "x": .int(Int(pos.x)), "y": .int(Int(pos.y))])
1858
+ }
1859
+ ))
1860
+
1821
1861
  api.register(Endpoint(
1822
1862
  method: "api.schema",
1823
1863
  description: "Get the full API schema including all methods and models",