agentix-cli 0.34.0 → 0.36.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/apps/mac-helper/Sources/AgentXHelper/Presence.swift +294 -0
- package/apps/mac-helper/Sources/AgentXHelper/PresenceTag.swift +147 -0
- package/apps/mac-helper/Sources/AgentXHelper/main.swift +9 -1
- package/apps/mac-voice/Sources/AgentXVoice/AgentClient.swift +33 -19
- package/apps/mac-voice/Sources/AgentXVoice/Speech.swift +9 -0
- package/apps/mac-voice/Sources/AgentXVoice/main.swift +111 -62
- package/dist/{App-QHPZXA42.js → App-WY6GOC3O.js} +2 -2
- package/dist/{ChatApp-EAHHH365.js → ChatApp-H7W5V2QL.js} +2 -2
- package/dist/{agent-KBBO7OZS.js → agent-6MW3J5FN.js} +2 -2
- package/dist/{board-dashboard-74OLMLAD.js → board-dashboard-FKT5GEYE.js} +4 -4
- package/dist/{chunk-CY24RXE4.js → chunk-2IK2SIFN.js} +2 -2
- package/dist/{chunk-33G4TIZT.js → chunk-3CGB43RZ.js} +221 -202
- package/dist/chunk-3CGB43RZ.js.map +1 -0
- package/dist/{chunk-IUNCX6BG.js → chunk-4LDSMWKQ.js} +2 -2
- package/dist/{chunk-5H5L75RN.js → chunk-AOVH27BF.js} +2 -2
- package/dist/{chunk-QXAUKNMW.js → chunk-AXYREZLV.js} +2 -2
- package/dist/{chunk-JDC35PWM.js → chunk-BTMNOTTO.js} +2 -2
- package/dist/{chunk-U5P7XGCB.js → chunk-CHWSVTY7.js} +2 -2
- package/dist/{chunk-MJYE5ADH.js → chunk-COY76JUG.js} +2 -2
- package/dist/{chunk-IVAGDL5M.js → chunk-CRKOJZVK.js} +2 -2
- package/dist/{chunk-PG7BAZIZ.js → chunk-DPNBGANY.js} +2 -2
- package/dist/chunk-EBWWNNJF.js +13 -0
- package/dist/chunk-EBWWNNJF.js.map +1 -0
- package/dist/{chunk-6WGTLDME.js → chunk-EXBIM6WZ.js} +133 -142
- package/dist/chunk-EXBIM6WZ.js.map +1 -0
- package/dist/{chunk-UJF5JUFJ.js → chunk-GM5PFIJU.js} +2 -2
- package/dist/{chunk-QKNAGTJK.js → chunk-TRAVSDZN.js} +2 -2
- package/dist/{chunk-X6HLI6XB.js → chunk-YAER4PD6.js} +2 -2
- package/dist/{chunk-ZMFUBIEQ.js → chunk-ZA7FEJKR.js} +5 -5
- package/dist/cli.js +1 -1
- package/dist/client-OC7COXM6.js +2 -0
- package/dist/config-KAMUYXO3.js +2 -0
- package/dist/config-mutator-SMGYCOYW.js +2 -0
- package/dist/doctor-RNQDJCCI.js +2 -0
- package/dist/index.d.ts +133 -0
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/program-Q3URXEEE.js +2 -0
- package/dist/query-PINMYFMF.js +2 -0
- package/dist/{registry-3RN7I6YW.js → registry-LANHP7WG.js} +2 -2
- package/dist/{request-planner-JX7M3PTD.js → request-planner-S4YWZEOG.js} +2 -2
- package/dist/{review-candidates-UTS4RX4U.js → review-candidates-3E3GW4K7.js} +2 -2
- package/dist/{routing-D7APOAP6.js → routing-QT4Z2ORA.js} +2 -2
- package/dist/scheduler-JUYBJLLK.js +2 -0
- package/dist/{seat-UA2ROH3V.js → seat-VFYCLEMH.js} +2 -2
- package/dist/{select-WQXHYEPD.js → select-SIJHCD54.js} +2 -2
- package/dist/wiki-NY6V6OAT.js +2 -0
- package/package.json +1 -1
- package/dist/chunk-33G4TIZT.js.map +0 -1
- package/dist/chunk-6WGTLDME.js.map +0 -1
- package/dist/chunk-SPJWXIAV.js +0 -13
- package/dist/chunk-SPJWXIAV.js.map +0 -1
- package/dist/client-CAGQYJML.js +0 -2
- package/dist/config-NPLE42XY.js +0 -2
- package/dist/config-mutator-FFTODESU.js +0 -2
- package/dist/doctor-X4KHQFRF.js +0 -2
- package/dist/program-ISXECD4J.js +0 -2
- package/dist/query-XLJ6I34S.js +0 -2
- package/dist/scheduler-EGR5AJAQ.js +0 -2
- package/dist/wiki-RNANPNUX.js +0 -2
- /package/dist/{App-QHPZXA42.js.map → App-WY6GOC3O.js.map} +0 -0
- /package/dist/{ChatApp-EAHHH365.js.map → ChatApp-H7W5V2QL.js.map} +0 -0
- /package/dist/{agent-KBBO7OZS.js.map → agent-6MW3J5FN.js.map} +0 -0
- /package/dist/{board-dashboard-74OLMLAD.js.map → board-dashboard-FKT5GEYE.js.map} +0 -0
- /package/dist/{chunk-CY24RXE4.js.map → chunk-2IK2SIFN.js.map} +0 -0
- /package/dist/{chunk-IUNCX6BG.js.map → chunk-4LDSMWKQ.js.map} +0 -0
- /package/dist/{chunk-5H5L75RN.js.map → chunk-AOVH27BF.js.map} +0 -0
- /package/dist/{chunk-QXAUKNMW.js.map → chunk-AXYREZLV.js.map} +0 -0
- /package/dist/{chunk-JDC35PWM.js.map → chunk-BTMNOTTO.js.map} +0 -0
- /package/dist/{chunk-U5P7XGCB.js.map → chunk-CHWSVTY7.js.map} +0 -0
- /package/dist/{chunk-MJYE5ADH.js.map → chunk-COY76JUG.js.map} +0 -0
- /package/dist/{chunk-IVAGDL5M.js.map → chunk-CRKOJZVK.js.map} +0 -0
- /package/dist/{chunk-PG7BAZIZ.js.map → chunk-DPNBGANY.js.map} +0 -0
- /package/dist/{chunk-UJF5JUFJ.js.map → chunk-GM5PFIJU.js.map} +0 -0
- /package/dist/{chunk-QKNAGTJK.js.map → chunk-TRAVSDZN.js.map} +0 -0
- /package/dist/{chunk-X6HLI6XB.js.map → chunk-YAER4PD6.js.map} +0 -0
- /package/dist/{chunk-ZMFUBIEQ.js.map → chunk-ZA7FEJKR.js.map} +0 -0
- /package/dist/{client-CAGQYJML.js.map → client-OC7COXM6.js.map} +0 -0
- /package/dist/{config-NPLE42XY.js.map → config-KAMUYXO3.js.map} +0 -0
- /package/dist/{config-mutator-FFTODESU.js.map → config-mutator-SMGYCOYW.js.map} +0 -0
- /package/dist/{doctor-X4KHQFRF.js.map → doctor-RNQDJCCI.js.map} +0 -0
- /package/dist/{program-ISXECD4J.js.map → program-Q3URXEEE.js.map} +0 -0
- /package/dist/{query-XLJ6I34S.js.map → query-PINMYFMF.js.map} +0 -0
- /package/dist/{registry-3RN7I6YW.js.map → registry-LANHP7WG.js.map} +0 -0
- /package/dist/{request-planner-JX7M3PTD.js.map → request-planner-S4YWZEOG.js.map} +0 -0
- /package/dist/{review-candidates-UTS4RX4U.js.map → review-candidates-3E3GW4K7.js.map} +0 -0
- /package/dist/{routing-D7APOAP6.js.map → routing-QT4Z2ORA.js.map} +0 -0
- /package/dist/{scheduler-EGR5AJAQ.js.map → scheduler-JUYBJLLK.js.map} +0 -0
- /package/dist/{seat-UA2ROH3V.js.map → seat-VFYCLEMH.js.map} +0 -0
- /package/dist/{select-WQXHYEPD.js.map → select-SIJHCD54.js.map} +0 -0
- /package/dist/{wiki-RNANPNUX.js.map → wiki-NY6V6OAT.js.map} +0 -0
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import AppKit
|
|
2
|
+
|
|
3
|
+
/// An agent's own cursor on screen: its colour, its initial, its name.
|
|
4
|
+
///
|
|
5
|
+
/// Drawn, never driven. `point` moves the person's real mouse, which is
|
|
6
|
+
/// right for a lesson the person watches and wrong for one they take part
|
|
7
|
+
/// in — their hand is on that mouse. This cursor is a click-through arrow
|
|
8
|
+
/// that glides to what the agent means, highlights it, and says a line in
|
|
9
|
+
/// a bubble, while the real pointer stays exactly where the person left it.
|
|
10
|
+
///
|
|
11
|
+
/// Driven over stdin, one JSON object per line, like the HUD:
|
|
12
|
+
///
|
|
13
|
+
/// {"cmd":"move","x":120,"y":80,"w":90,"h":24,"highlight":true}
|
|
14
|
+
/// {"cmd":"say","text":"The export button is top right."}
|
|
15
|
+
/// {"cmd":"clear"} drop the highlight
|
|
16
|
+
/// {"cmd":"park"} rest in the bottom-right corner, no highlight
|
|
17
|
+
/// {"cmd":"ping"} nothing; keeps an idle overlay alive
|
|
18
|
+
///
|
|
19
|
+
/// Coordinates are accessibility coordinates (top-left origin, global),
|
|
20
|
+
/// the same as `read`, `ocr` and `point`.
|
|
21
|
+
///
|
|
22
|
+
/// Only the arrow and highlight are click-through. The name tag and bubble
|
|
23
|
+
/// (PresenceTag) can be dragged out of the way; with `--pos-file` the spot
|
|
24
|
+
/// is remembered for this agent and the tag stays there on later turns.
|
|
25
|
+
///
|
|
26
|
+
/// It never outlives its owner: EOF, the owner's death (`--parent`, or
|
|
27
|
+
/// being re-parented to launchd) or `--idle` seconds without a command
|
|
28
|
+
/// fade everything out and exit. One process draws one cursor, on one
|
|
29
|
+
/// screen; it follows across Spaces but is never duplicated per display.
|
|
30
|
+
enum Presence {
|
|
31
|
+
|
|
32
|
+
// MARK: Geometry
|
|
33
|
+
|
|
34
|
+
/// Accessibility → AppKit. Global AX coordinates are measured from the
|
|
35
|
+
/// top of the PRIMARY display, whichever screen the point is on.
|
|
36
|
+
static func toAppKit(_ p: CGPoint) -> CGPoint {
|
|
37
|
+
let h = NSScreen.screens.first?.frame.maxY ?? 0
|
|
38
|
+
return CGPoint(x: p.x, y: h - p.y)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
static func toAppKit(_ r: CGRect) -> CGRect {
|
|
42
|
+
let o = toAppKit(CGPoint(x: r.minX, y: r.maxY))
|
|
43
|
+
return CGRect(x: o.x, y: o.y, width: r.width, height: r.height)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// MARK: Windows
|
|
47
|
+
|
|
48
|
+
/// Floating, not screen-saver level: above the windows the person works
|
|
49
|
+
/// in, below menus, alerts and Spotlight.
|
|
50
|
+
fileprivate static func panel(_ rect: NSRect, clickThrough: Bool = true) -> NSPanel {
|
|
51
|
+
let p = NSPanel(contentRect: rect, styleMask: [.borderless, .nonactivatingPanel],
|
|
52
|
+
backing: .buffered, defer: false)
|
|
53
|
+
p.level = .floating
|
|
54
|
+
p.isOpaque = false
|
|
55
|
+
p.backgroundColor = .clear
|
|
56
|
+
p.hasShadow = false
|
|
57
|
+
// The arrow and the highlight are click-through: the person's clicks
|
|
58
|
+
// land on the app underneath. Only the name tag takes the mouse.
|
|
59
|
+
p.ignoresMouseEvents = clickThrough
|
|
60
|
+
p.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary]
|
|
61
|
+
return p
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/// The arrow alone: the system cursor's silhouette, in the agent's colour.
|
|
65
|
+
private final class ArrowView: NSView {
|
|
66
|
+
let color: NSColor
|
|
67
|
+
static let size = NSSize(width: 30, height: 40)
|
|
68
|
+
/// The arrow tip, in view coordinates (bottom-left origin).
|
|
69
|
+
static let tip = NSPoint(x: 4, y: size.height - 4)
|
|
70
|
+
|
|
71
|
+
init(color: NSColor) {
|
|
72
|
+
self.color = color
|
|
73
|
+
super.init(frame: NSRect(origin: .zero, size: ArrowView.size))
|
|
74
|
+
}
|
|
75
|
+
required init?(coder: NSCoder) { fatalError() }
|
|
76
|
+
|
|
77
|
+
override func draw(_ dirtyRect: NSRect) {
|
|
78
|
+
let t = ArrowView.tip
|
|
79
|
+
let arrow = NSBezierPath()
|
|
80
|
+
arrow.move(to: t)
|
|
81
|
+
arrow.line(to: NSPoint(x: t.x, y: t.y - 26))
|
|
82
|
+
arrow.line(to: NSPoint(x: t.x + 7, y: t.y - 20))
|
|
83
|
+
arrow.line(to: NSPoint(x: t.x + 12, y: t.y - 30))
|
|
84
|
+
arrow.line(to: NSPoint(x: t.x + 16, y: t.y - 28))
|
|
85
|
+
arrow.line(to: NSPoint(x: t.x + 11, y: t.y - 18))
|
|
86
|
+
arrow.line(to: NSPoint(x: t.x + 20, y: t.y - 18))
|
|
87
|
+
arrow.close()
|
|
88
|
+
NSGraphicsContext.current?.saveGraphicsState()
|
|
89
|
+
let shadow = NSShadow()
|
|
90
|
+
shadow.shadowBlurRadius = 4
|
|
91
|
+
shadow.shadowOffset = NSSize(width: 0, height: -1)
|
|
92
|
+
shadow.shadowColor = NSColor.black.withAlphaComponent(0.35)
|
|
93
|
+
shadow.set()
|
|
94
|
+
color.setFill(); arrow.fill()
|
|
95
|
+
NSGraphicsContext.current?.restoreGraphicsState()
|
|
96
|
+
NSColor.white.setStroke(); arrow.lineWidth = 1.5; arrow.stroke()
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// MARK: Pinned position
|
|
101
|
+
|
|
102
|
+
/// Where the person dragged this agent's tag: its top-left, AppKit
|
|
103
|
+
/// coordinates, as {"x":…,"y":…}. Ignored if no screen shows it any more.
|
|
104
|
+
static func loadPin(_ file: String?) -> NSPoint? {
|
|
105
|
+
guard let file, let data = FileManager.default.contents(atPath: file),
|
|
106
|
+
let o = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
107
|
+
let x = o["x"] as? Double, let y = o["y"] as? Double else { return nil }
|
|
108
|
+
let p = NSPoint(x: x, y: y)
|
|
109
|
+
// The badge row hangs below the top-left; it must be on a screen.
|
|
110
|
+
let row = NSPoint(x: x + 12, y: y - 12)
|
|
111
|
+
return NSScreen.screens.contains(where: { $0.frame.contains(row) }) ? p : nil
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
static func savePin(_ p: NSPoint?, to file: String?) {
|
|
115
|
+
guard let file else { return }
|
|
116
|
+
guard let p else { try? FileManager.default.removeItem(atPath: file); return }
|
|
117
|
+
let url = URL(fileURLWithPath: file)
|
|
118
|
+
do {
|
|
119
|
+
try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
|
|
120
|
+
let data = try JSONSerialization.data(withJSONObject: ["x": Double(p.x), "y": Double(p.y)])
|
|
121
|
+
try data.write(to: url, options: .atomic)
|
|
122
|
+
} catch {
|
|
123
|
+
FileHandle.standardError.write("presence: could not save position: \(error)\n".data(using: .utf8)!)
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// MARK: Behaviour
|
|
128
|
+
|
|
129
|
+
private final class Controller {
|
|
130
|
+
let arrow: NSPanel
|
|
131
|
+
let tagPanel: NSPanel
|
|
132
|
+
let tag: PresenceTag
|
|
133
|
+
let ring: NSPanel
|
|
134
|
+
let color: NSColor
|
|
135
|
+
let posFile: String?
|
|
136
|
+
var at: CGPoint // tip position, AppKit coordinates
|
|
137
|
+
/// The tag's top-left once the person has dragged it; nil rides with the arrow.
|
|
138
|
+
var pinned: NSPoint?
|
|
139
|
+
var timer: Timer?
|
|
140
|
+
|
|
141
|
+
init(color: NSColor, initial: String, name: String, posFile: String?) {
|
|
142
|
+
self.color = color
|
|
143
|
+
self.posFile = posFile
|
|
144
|
+
arrow = Presence.panel(NSRect(origin: .zero, size: ArrowView.size))
|
|
145
|
+
arrow.contentView = ArrowView(color: color)
|
|
146
|
+
tag = PresenceTag(color: color, initial: initial, name: name)
|
|
147
|
+
tagPanel = Presence.panel(NSRect(origin: .zero, size: tag.frame.size), clickThrough: false)
|
|
148
|
+
tagPanel.contentView = tag
|
|
149
|
+
ring = Presence.panel(.zero)
|
|
150
|
+
let ringView = NSView()
|
|
151
|
+
ringView.wantsLayer = true
|
|
152
|
+
ringView.layer?.borderWidth = 3
|
|
153
|
+
ringView.layer?.borderColor = color.cgColor
|
|
154
|
+
ringView.layer?.cornerRadius = 8
|
|
155
|
+
ringView.layer?.backgroundColor = color.withAlphaComponent(0.10).cgColor
|
|
156
|
+
ring.contentView = ringView
|
|
157
|
+
pinned = Presence.loadPin(posFile)
|
|
158
|
+
at = Controller.parking()
|
|
159
|
+
tag.onPin = { [weak self] p in self?.pinned = p; Presence.savePin(p, to: posFile) }
|
|
160
|
+
tag.onUnpin = { [weak self] in
|
|
161
|
+
guard let self else { return }
|
|
162
|
+
self.pinned = nil
|
|
163
|
+
Presence.savePin(nil, to: posFile)
|
|
164
|
+
self.place(self.at)
|
|
165
|
+
}
|
|
166
|
+
place(at)
|
|
167
|
+
for p in [arrow, tagPanel] {
|
|
168
|
+
p.alphaValue = 0
|
|
169
|
+
p.orderFrontRegardless()
|
|
170
|
+
}
|
|
171
|
+
NSAnimationContext.runAnimationGroup { ctx in
|
|
172
|
+
ctx.duration = 0.2
|
|
173
|
+
arrow.animator().alphaValue = 1
|
|
174
|
+
tagPanel.animator().alphaValue = 1
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
static func parking() -> CGPoint {
|
|
179
|
+
let v = NSScreen.main?.visibleFrame ?? NSRect(x: 0, y: 0, width: 1440, height: 900)
|
|
180
|
+
return CGPoint(x: v.maxX - 380, y: v.minY + 170)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
func place(_ tip: CGPoint) {
|
|
184
|
+
arrow.setFrameOrigin(NSPoint(x: tip.x - ArrowView.tip.x, y: tip.y - ArrowView.tip.y))
|
|
185
|
+
if tag.dragged { return }
|
|
186
|
+
let size = tagPanel.frame.size
|
|
187
|
+
var top = pinned ?? NSPoint(x: tip.x + 20, y: tip.y - 26)
|
|
188
|
+
if pinned == nil {
|
|
189
|
+
// Near the right edge the tag sits to the left of the arrow.
|
|
190
|
+
let screen = NSScreen.screens.first(where: { $0.frame.contains(tip) }) ?? NSScreen.main
|
|
191
|
+
if let edge = screen?.visibleFrame.maxX, top.x + size.width > edge { top.x = tip.x - 4 - size.width }
|
|
192
|
+
}
|
|
193
|
+
tagPanel.setFrameOrigin(NSPoint(x: top.x, y: top.y - size.height))
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/// Glide with minimum-jerk easing; duration follows distance, as in
|
|
197
|
+
/// Pointer, so a long move reads as a reach and a short one as a nudge.
|
|
198
|
+
func glide(to target: CGPoint) {
|
|
199
|
+
timer?.invalidate()
|
|
200
|
+
let from = at
|
|
201
|
+
let d = hypot(target.x - from.x, target.y - from.y)
|
|
202
|
+
let duration = min(0.9, max(0.2, 0.12 + d / 2200))
|
|
203
|
+
let start = Date()
|
|
204
|
+
timer = Timer.scheduledTimer(withTimeInterval: 1.0 / 60, repeats: true) { [weak self] t in
|
|
205
|
+
guard let self else { t.invalidate(); return }
|
|
206
|
+
let u = min(1, Date().timeIntervalSince(start) / duration)
|
|
207
|
+
let e = 10 * pow(u, 3) - 15 * pow(u, 4) + 6 * pow(u, 5)
|
|
208
|
+
self.at = CGPoint(x: from.x + (target.x - from.x) * e, y: from.y + (target.y - from.y) * e)
|
|
209
|
+
self.place(self.at)
|
|
210
|
+
if u >= 1 { t.invalidate() }
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
func highlight(_ rect: CGRect?) {
|
|
215
|
+
guard let rect else { ring.orderOut(nil); return }
|
|
216
|
+
ring.setFrame(Presence.toAppKit(rect).insetBy(dx: -6, dy: -6), display: true)
|
|
217
|
+
ring.orderFrontRegardless()
|
|
218
|
+
arrow.orderFrontRegardless()
|
|
219
|
+
tagPanel.orderFrontRegardless()
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
func handle(_ obj: [String: Any]) {
|
|
223
|
+
switch obj["cmd"] as? String {
|
|
224
|
+
case "move":
|
|
225
|
+
guard let x = obj["x"] as? Double, let y = obj["y"] as? Double else { return }
|
|
226
|
+
let w = obj["w"] as? Double ?? 0, h = obj["h"] as? Double ?? 0
|
|
227
|
+
let rect = CGRect(x: x, y: y, width: w, height: h)
|
|
228
|
+
// Aim just inside the control's lower-left third, so the
|
|
229
|
+
// arrow points AT it rather than covering its label.
|
|
230
|
+
let aim = CGPoint(x: x + min(w * 0.5, 24), y: y + h * 0.7)
|
|
231
|
+
glide(to: Presence.toAppKit(aim))
|
|
232
|
+
highlight((obj["highlight"] as? Bool ?? false) && w > 0 && h > 0 ? rect : nil)
|
|
233
|
+
case "say":
|
|
234
|
+
tag.setBubble(obj["text"] as? String ?? "")
|
|
235
|
+
place(at)
|
|
236
|
+
case "clear":
|
|
237
|
+
highlight(nil)
|
|
238
|
+
case "park":
|
|
239
|
+
highlight(nil)
|
|
240
|
+
glide(to: Controller.parking())
|
|
241
|
+
default:
|
|
242
|
+
break
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
func fadeOut(_ done: @escaping () -> Void) {
|
|
247
|
+
ring.orderOut(nil)
|
|
248
|
+
NSAnimationContext.runAnimationGroup { ctx in
|
|
249
|
+
ctx.duration = 0.25
|
|
250
|
+
arrow.animator().alphaValue = 0
|
|
251
|
+
tagPanel.animator().alphaValue = 0
|
|
252
|
+
} completionHandler: { done() }
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
static func color(hex: String?) -> NSColor {
|
|
257
|
+
guard let hex, hex.count == 7, hex.hasPrefix("#"), let v = UInt32(hex.dropFirst(), radix: 16) else {
|
|
258
|
+
return Brand.primaryBright
|
|
259
|
+
}
|
|
260
|
+
return NSColor(srgbRed: CGFloat((v >> 16) & 0xff) / 255, green: CGFloat((v >> 8) & 0xff) / 255,
|
|
261
|
+
blue: CGFloat(v & 0xff) / 255, alpha: 1)
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/// Read commands until stdin closes, the owner dies, or it goes idle.
|
|
265
|
+
static func run(name: String, initial: String, colorHex: String?, parent: pid_t?, idle: TimeInterval,
|
|
266
|
+
posFile: String? = nil) {
|
|
267
|
+
let app = NSApplication.shared
|
|
268
|
+
app.setActivationPolicy(.accessory)
|
|
269
|
+
let ctl = Controller(color: color(hex: colorHex), initial: initial, name: name, posFile: posFile)
|
|
270
|
+
var lastCommand = Date()
|
|
271
|
+
var quitting = false
|
|
272
|
+
// Main thread only. The fade is a courtesy: exit follows regardless.
|
|
273
|
+
func quit() {
|
|
274
|
+
if quitting { return }
|
|
275
|
+
quitting = true
|
|
276
|
+
ctl.fadeOut { exit(0) }
|
|
277
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 1) { exit(0) }
|
|
278
|
+
}
|
|
279
|
+
DispatchQueue(label: "tn.acme.agentx.presence.stdin").async {
|
|
280
|
+
while let line = readLine(strippingNewline: true) {
|
|
281
|
+
guard let data = line.data(using: .utf8),
|
|
282
|
+
let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { continue }
|
|
283
|
+
DispatchQueue.main.async { lastCommand = Date(); ctl.handle(obj) }
|
|
284
|
+
}
|
|
285
|
+
DispatchQueue.main.async { quit() }
|
|
286
|
+
}
|
|
287
|
+
Timer.scheduledTimer(withTimeInterval: 2, repeats: true) { _ in
|
|
288
|
+
let orphaned = getppid() == 1 || (parent.map { kill($0, 0) != 0 && errno == ESRCH } ?? false)
|
|
289
|
+
let idled = idle > 0 && Date().timeIntervalSince(lastCommand) > idle
|
|
290
|
+
if orphaned || idled { quit() }
|
|
291
|
+
}
|
|
292
|
+
app.run()
|
|
293
|
+
}
|
|
294
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import AppKit
|
|
2
|
+
|
|
3
|
+
/// The part of an agent's presence the person can touch: the initial
|
|
4
|
+
/// badge, the name, and the speech bubble under them.
|
|
5
|
+
///
|
|
6
|
+
/// It sits in its own panel, apart from the click-through arrow, so it can
|
|
7
|
+
/// take the mouse without the arrow ever stealing a click:
|
|
8
|
+
///
|
|
9
|
+
/// drag anywhere move it; it stays there (pinned) for this agent
|
|
10
|
+
/// double-click bubble dismiss the bubble for the rest of this overlay
|
|
11
|
+
/// double-click name unpin; it rides next to the arrow again
|
|
12
|
+
///
|
|
13
|
+
/// The view is top-anchored: the badge row stays put as the bubble comes
|
|
14
|
+
/// and goes, and the window grows or shrinks underneath it.
|
|
15
|
+
final class PresenceTag: NSView {
|
|
16
|
+
let color: NSColor
|
|
17
|
+
let initial: String
|
|
18
|
+
let name: String
|
|
19
|
+
let bubble = NSTextField(wrappingLabelWithString: "")
|
|
20
|
+
/// The bubble's background, so the text gets padding.
|
|
21
|
+
let box = NSView()
|
|
22
|
+
|
|
23
|
+
static let rowHeight: CGFloat = 24
|
|
24
|
+
static let gap: CGFloat = 8
|
|
25
|
+
|
|
26
|
+
/// Called with the window's new top-left once a drag ends.
|
|
27
|
+
var onPin: ((NSPoint) -> Void)?
|
|
28
|
+
var onUnpin: (() -> Void)?
|
|
29
|
+
/// A double-click on the bubble hides it; later lines stay hidden too.
|
|
30
|
+
private(set) var dismissed = false
|
|
31
|
+
|
|
32
|
+
private var dragFrom: NSPoint?
|
|
33
|
+
private var windowFrom: NSPoint = .zero
|
|
34
|
+
/// True mid-drag, so a glide doesn't pull the tag out of the hand.
|
|
35
|
+
private(set) var dragged = false
|
|
36
|
+
|
|
37
|
+
init(color: NSColor, initial: String, name: String) {
|
|
38
|
+
self.color = color; self.initial = initial; self.name = name
|
|
39
|
+
super.init(frame: .zero)
|
|
40
|
+
wantsLayer = true
|
|
41
|
+
bubble.font = Brand.body(size: 13)
|
|
42
|
+
bubble.textColor = Brand.ink
|
|
43
|
+
bubble.maximumNumberOfLines = 4
|
|
44
|
+
box.wantsLayer = true
|
|
45
|
+
box.layer?.backgroundColor = Brand.paper.cgColor
|
|
46
|
+
box.layer?.cornerRadius = Brand.Radius.md
|
|
47
|
+
box.layer?.borderWidth = 2
|
|
48
|
+
box.layer?.borderColor = color.cgColor
|
|
49
|
+
box.isHidden = true
|
|
50
|
+
box.addSubview(bubble)
|
|
51
|
+
addSubview(box)
|
|
52
|
+
setFrameSize(fittingSize)
|
|
53
|
+
}
|
|
54
|
+
required init?(coder: NSCoder) { fatalError() }
|
|
55
|
+
|
|
56
|
+
private static let nameAttrs: [NSAttributedString.Key: Any] = [.font: NSFont.systemFont(ofSize: 11, weight: .semibold)]
|
|
57
|
+
private var rowWidth: CGFloat { 24 + 4 + (name as NSString).size(withAttributes: PresenceTag.nameAttrs).width + 14 }
|
|
58
|
+
|
|
59
|
+
override var fittingSize: NSSize {
|
|
60
|
+
guard !box.isHidden else { return NSSize(width: rowWidth, height: PresenceTag.rowHeight) }
|
|
61
|
+
return NSSize(width: max(rowWidth, box.frame.width),
|
|
62
|
+
height: PresenceTag.rowHeight + PresenceTag.gap + box.frame.height)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
func setBubble(_ text: String) {
|
|
66
|
+
box.isHidden = text.isEmpty || dismissed
|
|
67
|
+
bubble.stringValue = text
|
|
68
|
+
let textW: CGFloat = 300
|
|
69
|
+
bubble.preferredMaxLayoutWidth = textW
|
|
70
|
+
let fit = bubble.sizeThatFits(NSSize(width: textW, height: 90))
|
|
71
|
+
let w = min(textW, ceil(fit.width)), h = min(90, ceil(fit.height))
|
|
72
|
+
bubble.frame = NSRect(x: 10, y: 6, width: w, height: h)
|
|
73
|
+
box.frame = NSRect(x: 0, y: 0, width: w + 20, height: h + 12)
|
|
74
|
+
refit()
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/// Resize the window to the content, keeping its top-left where it is.
|
|
78
|
+
private func refit() {
|
|
79
|
+
let size = fittingSize
|
|
80
|
+
setFrameSize(size)
|
|
81
|
+
needsDisplay = true
|
|
82
|
+
guard let w = window else { return }
|
|
83
|
+
let top = NSPoint(x: w.frame.minX, y: w.frame.maxY)
|
|
84
|
+
w.setFrame(NSRect(x: top.x, y: top.y - size.height, width: size.width, height: size.height), display: true)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
override func draw(_ dirtyRect: NSRect) {
|
|
88
|
+
let badge = NSRect(x: 0, y: bounds.maxY - 24, width: 24, height: 24)
|
|
89
|
+
color.setFill(); NSBezierPath(ovalIn: badge).fill()
|
|
90
|
+
NSColor.white.setStroke()
|
|
91
|
+
let ring = NSBezierPath(ovalIn: badge.insetBy(dx: 0.75, dy: 0.75)); ring.lineWidth = 1.5; ring.stroke()
|
|
92
|
+
draw(initial, in: badge, size: 11, weight: .bold)
|
|
93
|
+
|
|
94
|
+
let nameW = (name as NSString).size(withAttributes: PresenceTag.nameAttrs).width + 14
|
|
95
|
+
let pill = NSRect(x: badge.maxX + 4, y: badge.minY + 3, width: nameW, height: 18)
|
|
96
|
+
color.setFill(); NSBezierPath(roundedRect: pill, xRadius: 9, yRadius: 9).fill()
|
|
97
|
+
draw(name, in: pill, size: 11, weight: .semibold)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
private func draw(_ s: String, in r: NSRect, size: CGFloat, weight: NSFont.Weight) {
|
|
101
|
+
let a: [NSAttributedString.Key: Any] = [.font: NSFont.systemFont(ofSize: size, weight: weight), .foregroundColor: NSColor.white]
|
|
102
|
+
let sz = (s as NSString).size(withAttributes: a)
|
|
103
|
+
(s as NSString).draw(at: NSPoint(x: r.midX - sz.width / 2, y: r.midY - sz.height / 2), withAttributes: a)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// MARK: Mouse
|
|
107
|
+
|
|
108
|
+
/// The label would swallow clicks meant for the drag.
|
|
109
|
+
override func hitTest(_ point: NSPoint) -> NSView? {
|
|
110
|
+
frame.contains(point) ? self : nil
|
|
111
|
+
}
|
|
112
|
+
/// The panel never activates this app, so the first click must count.
|
|
113
|
+
override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true }
|
|
114
|
+
|
|
115
|
+
override func mouseDown(with event: NSEvent) {
|
|
116
|
+
if event.clickCount == 2 {
|
|
117
|
+
let p = convert(event.locationInWindow, from: nil)
|
|
118
|
+
if !box.isHidden && box.frame.contains(p) {
|
|
119
|
+
dismissed = true
|
|
120
|
+
box.isHidden = true
|
|
121
|
+
refit()
|
|
122
|
+
} else if p.y >= bounds.maxY - PresenceTag.rowHeight {
|
|
123
|
+
onUnpin?()
|
|
124
|
+
}
|
|
125
|
+
dragFrom = nil
|
|
126
|
+
return
|
|
127
|
+
}
|
|
128
|
+
dragFrom = NSEvent.mouseLocation
|
|
129
|
+
windowFrom = window?.frame.origin ?? .zero
|
|
130
|
+
dragged = false
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
override func mouseDragged(with event: NSEvent) {
|
|
134
|
+
guard let from = dragFrom, let w = window else { return }
|
|
135
|
+
let now = NSEvent.mouseLocation
|
|
136
|
+
let dx = now.x - from.x, dy = now.y - from.y
|
|
137
|
+
if !dragged && hypot(dx, dy) < 3 { return }
|
|
138
|
+
dragged = true
|
|
139
|
+
w.setFrameOrigin(NSPoint(x: windowFrom.x + dx, y: windowFrom.y + dy))
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
override func mouseUp(with event: NSEvent) {
|
|
143
|
+
defer { dragFrom = nil; dragged = false }
|
|
144
|
+
guard dragged, let w = window else { return }
|
|
145
|
+
onPin?(NSPoint(x: w.frame.minX, y: w.frame.maxY))
|
|
146
|
+
}
|
|
147
|
+
}
|
|
@@ -256,10 +256,18 @@ case "hud":
|
|
|
256
256
|
// Long-lived: reads update lines on stdin until EOF.
|
|
257
257
|
HUD.run()
|
|
258
258
|
|
|
259
|
+
case "presence":
|
|
260
|
+
// Long-lived: the agent's own drawn cursor. Never moves the real mouse.
|
|
261
|
+
let name = flag("name") ?? "Agent"
|
|
262
|
+
Presence.run(name: name, initial: flag("initial") ?? String(name.prefix(1)).uppercased(),
|
|
263
|
+
colorHex: flag("color"), parent: flag("parent").flatMap { pid_t($0) },
|
|
264
|
+
idle: flag("idle").flatMap { TimeInterval($0) } ?? 60,
|
|
265
|
+
posFile: flag("pos-file"))
|
|
266
|
+
|
|
259
267
|
case "trusted":
|
|
260
268
|
let payload = ["ok": true, "trusted": AXTree.trusted()] as [String: Any]
|
|
261
269
|
FileHandle.standardOutput.write(try! JSONSerialization.data(withJSONObject: payload))
|
|
262
270
|
|
|
263
271
|
default:
|
|
264
|
-
fail("unknown verb \"\(verb)\" — expected read, screens, point, click, type, key, scroll, drag, ocr, capture, hittest, focused, hud or trusted")
|
|
272
|
+
fail("unknown verb \"\(verb)\" — expected read, screens, point, click, type, key, scroll, drag, ocr, capture, hittest, focused, hud, presence or trusted")
|
|
265
273
|
}
|
|
@@ -25,6 +25,8 @@ enum AgentClient {
|
|
|
25
25
|
/// The agent that answered and its ElevenLabs voice, when it has one.
|
|
26
26
|
let agentID: String?
|
|
27
27
|
let voiceID: String?
|
|
28
|
+
/// Presence mode the daemon chose for this turn (talk, teach, …).
|
|
29
|
+
var presenceMode: String? = nil
|
|
28
30
|
}
|
|
29
31
|
|
|
30
32
|
/// What, if anything, to say about the step now running.
|
|
@@ -52,28 +54,37 @@ enum AgentClient {
|
|
|
52
54
|
return (try? JSONDecoder().decode(Reply.self, from: data))?.say
|
|
53
55
|
}
|
|
54
56
|
|
|
55
|
-
// MARK:
|
|
57
|
+
// MARK: The door
|
|
56
58
|
//
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
//
|
|
59
|
+
// Option-Space is one door for everything spoken: a talk, a live
|
|
60
|
+
// lesson, task narration, an agent's bubble. Pressing it hushes all of
|
|
61
|
+
// them at once; what is then said goes to the activity that was
|
|
62
|
+
// speaking, which answers it first. See src/daemon/voice-talk-api.ts.
|
|
60
63
|
|
|
61
|
-
///
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
return (try? JSONDecoder().decode(Reply.self, from: data))?.active ?? false
|
|
64
|
+
/// What was speaking when the door opened.
|
|
65
|
+
struct Hushed {
|
|
66
|
+
/// "talk", "lesson" or "narration"; nil when nothing was.
|
|
67
|
+
let kind: String?
|
|
68
|
+
let agentID: String?
|
|
69
|
+
static let nothing = Hushed(kind: nil, agentID: nil)
|
|
68
70
|
}
|
|
69
71
|
|
|
70
|
-
///
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
72
|
+
/// Silence everything the daemon is saying. Never throws: no daemon
|
|
73
|
+
/// means nothing was speaking.
|
|
74
|
+
static func hush() async -> Hushed {
|
|
75
|
+
guard let (data, _) = try? await post("/voice/hush", [:], timeout: 2) else { return .nothing }
|
|
76
|
+
struct Reply: Decodable { let kind: String?; let agentId: String? }
|
|
77
|
+
let r = try? JSONDecoder().decode(Reply.self, from: data)
|
|
78
|
+
return Hushed(kind: r?.kind, agentID: r?.agentId)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/// Hand the listener's words through the door. True when an activity
|
|
82
|
+
/// took them (a talk or lesson answers; "stop" ends it); false means
|
|
83
|
+
/// nothing did, and they are an ordinary question.
|
|
84
|
+
static func door(_ text: String) async -> Bool {
|
|
85
|
+
guard let (_, response) = try? await post("/voice/door", ["text": text], timeout: 5),
|
|
86
|
+
let http = response as? HTTPURLResponse else { return false }
|
|
87
|
+
return (200..<300).contains(http.statusCode)
|
|
77
88
|
}
|
|
78
89
|
|
|
79
90
|
private static func post(_ path: String, _ body: [String: Any], timeout: TimeInterval) async throws -> (Data, URLResponse) {
|
|
@@ -103,9 +114,11 @@ enum AgentClient {
|
|
|
103
114
|
struct UiMedia: Decodable { let type: String; let url: String; let caption: String? }
|
|
104
115
|
struct Ui: Decodable { let buttons: [UiButton]?; let media: UiMedia? }
|
|
105
116
|
struct Voice: Decodable { let elevenlabsVoiceId: String? }
|
|
117
|
+
struct Presence: Decodable { let mode: String? }
|
|
106
118
|
struct Reply: Decodable {
|
|
107
119
|
let agentId: String?
|
|
108
120
|
let voice: Voice?
|
|
121
|
+
let presence: Presence?
|
|
109
122
|
let text: String?
|
|
110
123
|
let full: String?
|
|
111
124
|
let ui: Ui?
|
|
@@ -144,6 +157,7 @@ enum AgentClient {
|
|
|
144
157
|
|
|
145
158
|
return Answer(text: text, written: reply?.full,
|
|
146
159
|
buttons: buttons + extra, imageURL: image,
|
|
147
|
-
durationMs: reply?.duration, agentID: reply?.agentId, voiceID: voiceID
|
|
160
|
+
durationMs: reply?.duration, agentID: reply?.agentId, voiceID: voiceID,
|
|
161
|
+
presenceMode: reply?.presence?.mode)
|
|
148
162
|
}
|
|
149
163
|
}
|
|
@@ -72,6 +72,9 @@ enum Speech {
|
|
|
72
72
|
|
|
73
73
|
// MARK: Text to speech
|
|
74
74
|
|
|
75
|
+
/// Silence whatever this app is saying (an answer or a step line).
|
|
76
|
+
static func stop() { Player.shared.stop() }
|
|
77
|
+
|
|
75
78
|
/// `voiceID` is the answering agent's voice; nil means the global default.
|
|
76
79
|
static func speak(_ text: String, voiceID: String? = nil) async {
|
|
77
80
|
guard !text.isEmpty else { return }
|
|
@@ -154,6 +157,12 @@ final class Player: NSObject, AVAudioPlayerDelegate {
|
|
|
154
157
|
func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) {
|
|
155
158
|
finished?.resume(); finished = nil; self.player = nil
|
|
156
159
|
}
|
|
160
|
+
|
|
161
|
+
/// Cut the line off now; the `play` awaiting it returns.
|
|
162
|
+
func stop() {
|
|
163
|
+
player?.stop()
|
|
164
|
+
finished?.resume(); finished = nil; player = nil
|
|
165
|
+
}
|
|
157
166
|
}
|
|
158
167
|
|
|
159
168
|
/// Logs to stderr AND to a file.
|