agentix-cli 0.50.1 → 0.51.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 (115) hide show
  1. package/apps/mac-voice/Sources/AgentXVoice/AgentClient.swift +67 -14
  2. package/apps/mac-voice/Sources/AgentXVoice/Config.swift +20 -3
  3. package/apps/mac-voice/Sources/AgentXVoice/Panel.swift +24 -31
  4. package/apps/mac-voice/Sources/AgentXVoice/Speech.swift +16 -2
  5. package/apps/mac-voice/Sources/AgentXVoice/StatusMenu.swift +173 -0
  6. package/apps/mac-voice/Sources/AgentXVoice/main.swift +62 -15
  7. package/dist/{App-TDUUFVDV.js → App-QVNQ5BQK.js} +2 -2
  8. package/dist/{ChatApp-IOWDXM3R.js → ChatApp-CC57XC23.js} +2 -2
  9. package/dist/{agent-KRLAT3YJ.js → agent-ILH6ANOM.js} +2 -2
  10. package/dist/{board-dashboard-VA4VSCMQ.js → board-dashboard-XPPBRPQE.js} +114 -114
  11. package/dist/board-dashboard-XPPBRPQE.js.map +1 -0
  12. package/dist/{chunk-ZWGRRFJI.js → chunk-2KQTE4XI.js} +2 -2
  13. package/dist/{chunk-A6IV2VQY.js → chunk-6HAZ6YWI.js} +2 -2
  14. package/dist/{chunk-HHY3MSMN.js → chunk-CAA546XN.js} +2 -2
  15. package/dist/{chunk-X5K3HR4V.js → chunk-F7WY24UZ.js} +2 -2
  16. package/dist/{chunk-FDYGTQVW.js → chunk-H2TWRZC2.js} +2 -2
  17. package/dist/{chunk-F2PEUQW4.js → chunk-JQR3NTHI.js} +2 -2
  18. package/dist/{chunk-ZQSB6IX4.js → chunk-KFALP5NO.js} +2 -2
  19. package/dist/{chunk-JWLTI6NZ.js → chunk-LBMZP7TG.js} +57 -57
  20. package/dist/chunk-LBMZP7TG.js.map +1 -0
  21. package/dist/{chunk-2BRX2EMT.js → chunk-PSDCRZZM.js} +2 -2
  22. package/dist/{chunk-BVRUDRCM.js → chunk-QP6XNTBB.js} +2 -2
  23. package/dist/{chunk-AVJRATR5.js → chunk-ROCUOXCW.js} +2 -2
  24. package/dist/{chunk-FULEQYM6.js → chunk-S7CVSKOX.js} +2 -2
  25. package/dist/chunk-S7CVSKOX.js.map +1 -0
  26. package/dist/{chunk-XSFXAJDA.js → chunk-SRCXUZM2.js} +4 -4
  27. package/dist/{chunk-XSFXAJDA.js.map → chunk-SRCXUZM2.js.map} +1 -1
  28. package/dist/chunk-U67EG5IF.js +19 -0
  29. package/dist/chunk-U67EG5IF.js.map +1 -0
  30. package/dist/{chunk-RP7GLXOA.js → chunk-UBRMOTBH.js} +181 -180
  31. package/dist/chunk-UBRMOTBH.js.map +1 -0
  32. package/dist/chunk-V7XZN3MQ.js +123 -0
  33. package/dist/chunk-V7XZN3MQ.js.map +1 -0
  34. package/dist/{chunk-WW6KNRIR.js → chunk-VMS3HVTT.js} +2 -2
  35. package/dist/{chunk-WUDXUY7H.js → chunk-WCMQRSVU.js} +2 -2
  36. package/dist/{chunk-5KCOH5FQ.js → chunk-WDQ3BPFS.js} +2 -2
  37. package/dist/{chunk-ZN7637EF.js → chunk-XZTR5MBJ.js} +2 -2
  38. package/dist/chunk-YE2BUU3Z.js +28 -0
  39. package/dist/chunk-YE2BUU3Z.js.map +1 -0
  40. package/dist/{chunk-ZKDWQJVS.js → chunk-YEV6W34S.js} +55 -55
  41. package/dist/{chunk-ZKDWQJVS.js.map → chunk-YEV6W34S.js.map} +1 -1
  42. package/dist/cli.js +1 -1
  43. package/dist/client-I7GAAM3T.js +2 -0
  44. package/dist/{config-I4GWI22T.js → config-TVW7UCKG.js} +2 -2
  45. package/dist/config-mutator-JHM53P5A.js +2 -0
  46. package/dist/doctor-CXFMV6PT.js +2 -0
  47. package/dist/index.d.ts +256 -6
  48. package/dist/index.js +1 -1
  49. package/dist/program-ZCFMO4ZD.js +2 -0
  50. package/dist/{promote-EDEG2HQR.js → promote-4T64VW5K.js} +2 -2
  51. package/dist/query-6IKL52WT.js +2 -0
  52. package/dist/registry-E4QUXHMQ.js +2 -0
  53. package/dist/{request-planner-JDBXFNQ4.js → request-planner-BRLDCYU5.js} +2 -2
  54. package/dist/{review-candidates-EBP5CFOB.js → review-candidates-QWW5PDUU.js} +2 -2
  55. package/dist/{routing-Q2EOZ3VW.js → routing-DP6S7OJL.js} +2 -2
  56. package/dist/schedule-tool-TPJ6GR47.js +2 -0
  57. package/dist/scheduler-7QPG6OTY.js +2 -0
  58. package/dist/{seat-LV3STEXI.js → seat-3KUNRM5Z.js} +2 -2
  59. package/dist/{select-5NHY4T5A.js → select-ILXHYJGD.js} +2 -2
  60. package/dist/{tool-2RKPJWDG.js → tool-SF3BCY55.js} +2 -2
  61. package/dist/wiki-WETANN3W.js +2 -0
  62. package/package.json +1 -1
  63. package/dist/board-dashboard-VA4VSCMQ.js.map +0 -1
  64. package/dist/chunk-FULEQYM6.js.map +0 -1
  65. package/dist/chunk-JUJ3TCOE.js +0 -19
  66. package/dist/chunk-JUJ3TCOE.js.map +0 -1
  67. package/dist/chunk-JWLTI6NZ.js.map +0 -1
  68. package/dist/chunk-JY7POTMB.js +0 -123
  69. package/dist/chunk-JY7POTMB.js.map +0 -1
  70. package/dist/chunk-KH6L7GDO.js +0 -28
  71. package/dist/chunk-KH6L7GDO.js.map +0 -1
  72. package/dist/chunk-RP7GLXOA.js.map +0 -1
  73. package/dist/client-PC7OEKSK.js +0 -2
  74. package/dist/config-mutator-3RFKH3OV.js +0 -2
  75. package/dist/doctor-OG6DK52Y.js +0 -2
  76. package/dist/program-RFAHI4SS.js +0 -2
  77. package/dist/query-P5LMGXVS.js +0 -2
  78. package/dist/registry-GJLY5J5E.js +0 -2
  79. package/dist/schedule-tool-N2Z5E6AR.js +0 -2
  80. package/dist/scheduler-WTPERNYJ.js +0 -2
  81. package/dist/wiki-XQMJ2FPU.js +0 -2
  82. /package/dist/{App-TDUUFVDV.js.map → App-QVNQ5BQK.js.map} +0 -0
  83. /package/dist/{ChatApp-IOWDXM3R.js.map → ChatApp-CC57XC23.js.map} +0 -0
  84. /package/dist/{agent-KRLAT3YJ.js.map → agent-ILH6ANOM.js.map} +0 -0
  85. /package/dist/{chunk-ZWGRRFJI.js.map → chunk-2KQTE4XI.js.map} +0 -0
  86. /package/dist/{chunk-A6IV2VQY.js.map → chunk-6HAZ6YWI.js.map} +0 -0
  87. /package/dist/{chunk-HHY3MSMN.js.map → chunk-CAA546XN.js.map} +0 -0
  88. /package/dist/{chunk-X5K3HR4V.js.map → chunk-F7WY24UZ.js.map} +0 -0
  89. /package/dist/{chunk-FDYGTQVW.js.map → chunk-H2TWRZC2.js.map} +0 -0
  90. /package/dist/{chunk-F2PEUQW4.js.map → chunk-JQR3NTHI.js.map} +0 -0
  91. /package/dist/{chunk-ZQSB6IX4.js.map → chunk-KFALP5NO.js.map} +0 -0
  92. /package/dist/{chunk-2BRX2EMT.js.map → chunk-PSDCRZZM.js.map} +0 -0
  93. /package/dist/{chunk-BVRUDRCM.js.map → chunk-QP6XNTBB.js.map} +0 -0
  94. /package/dist/{chunk-AVJRATR5.js.map → chunk-ROCUOXCW.js.map} +0 -0
  95. /package/dist/{chunk-WW6KNRIR.js.map → chunk-VMS3HVTT.js.map} +0 -0
  96. /package/dist/{chunk-WUDXUY7H.js.map → chunk-WCMQRSVU.js.map} +0 -0
  97. /package/dist/{chunk-5KCOH5FQ.js.map → chunk-WDQ3BPFS.js.map} +0 -0
  98. /package/dist/{chunk-ZN7637EF.js.map → chunk-XZTR5MBJ.js.map} +0 -0
  99. /package/dist/{client-PC7OEKSK.js.map → client-I7GAAM3T.js.map} +0 -0
  100. /package/dist/{config-I4GWI22T.js.map → config-TVW7UCKG.js.map} +0 -0
  101. /package/dist/{config-mutator-3RFKH3OV.js.map → config-mutator-JHM53P5A.js.map} +0 -0
  102. /package/dist/{doctor-OG6DK52Y.js.map → doctor-CXFMV6PT.js.map} +0 -0
  103. /package/dist/{program-RFAHI4SS.js.map → program-ZCFMO4ZD.js.map} +0 -0
  104. /package/dist/{promote-EDEG2HQR.js.map → promote-4T64VW5K.js.map} +0 -0
  105. /package/dist/{query-P5LMGXVS.js.map → query-6IKL52WT.js.map} +0 -0
  106. /package/dist/{registry-GJLY5J5E.js.map → registry-E4QUXHMQ.js.map} +0 -0
  107. /package/dist/{request-planner-JDBXFNQ4.js.map → request-planner-BRLDCYU5.js.map} +0 -0
  108. /package/dist/{review-candidates-EBP5CFOB.js.map → review-candidates-QWW5PDUU.js.map} +0 -0
  109. /package/dist/{routing-Q2EOZ3VW.js.map → routing-DP6S7OJL.js.map} +0 -0
  110. /package/dist/{schedule-tool-N2Z5E6AR.js.map → schedule-tool-TPJ6GR47.js.map} +0 -0
  111. /package/dist/{scheduler-WTPERNYJ.js.map → scheduler-7QPG6OTY.js.map} +0 -0
  112. /package/dist/{seat-LV3STEXI.js.map → seat-3KUNRM5Z.js.map} +0 -0
  113. /package/dist/{select-5NHY4T5A.js.map → select-ILXHYJGD.js.map} +0 -0
  114. /package/dist/{tool-2RKPJWDG.js.map → tool-SF3BCY55.js.map} +0 -0
  115. /package/dist/{wiki-XQMJ2FPU.js.map → wiki-WETANN3W.js.map} +0 -0
@@ -63,7 +63,7 @@ enum AgentClient {
63
63
 
64
64
  /// What was speaking when the door opened.
65
65
  struct Hushed {
66
- /// "talk", "lesson" or "narration"; nil when nothing was.
66
+ /// "talk", "lesson", "narration" or "queue"; nil when nothing was.
67
67
  let kind: String?
68
68
  let agentID: String?
69
69
  static let nothing = Hushed(kind: nil, agentID: nil)
@@ -84,6 +84,39 @@ enum AgentClient {
84
84
  _ = try? await post("/voice/stop", [:], timeout: 2)
85
85
  }
86
86
 
87
+ /// The listener said nothing after the hush, so no door follows: the
88
+ /// daemon's speaking queue plays on.
89
+ static func resume() async {
90
+ _ = try? await post("/voice/queue/resume", [:], timeout: 2)
91
+ }
92
+
93
+ // MARK: The speaking queue
94
+ //
95
+ // Everything spoken on this Mac waits in one queue on the daemon, so
96
+ // an answer never plays over another agent, a talk or narration. See
97
+ // src/daemon/voice-queue-api.ts.
98
+
99
+ /// Queue a line in the agent's voice and wait until it has been
100
+ /// spoken, skipped or stopped. False only when the daemon could not
101
+ /// take it, so the caller speaks it here instead. A cancelled wait
102
+ /// (the door opened) is true: the line stays queued and plays after
103
+ /// the listener's turn.
104
+ static func queue(_ text: String, agentID: String, kind: String) async -> Bool {
105
+ do {
106
+ // A line may wait behind others, and a held queue for a minute.
107
+ let (_, response) = try await post("/voice/queue",
108
+ ["text": text, "agentId": agentID, "kind": kind, "wait": true], timeout: 900)
109
+ guard let http = response as? HTTPURLResponse else { return false }
110
+ return (200..<300).contains(http.statusCode)
111
+ } catch let e as URLError where [.cannotConnectToHost, .cannotFindHost, .notConnectedToInternet, .networkConnectionLost].contains(e.code) {
112
+ return false
113
+ } catch {
114
+ // Cancelled or timed out after the daemon took the line:
115
+ // playing it here too would talk over it.
116
+ return true
117
+ }
118
+ }
119
+
87
120
  /// Hand the listener's words through the door. True when an activity
88
121
  /// took them (a talk or lesson answers; "stop" ends it); false means
89
122
  /// nothing did, and they are an ordinary question.
@@ -102,20 +135,40 @@ enum AgentClient {
102
135
  return try await URLSession.shared.data(for: req)
103
136
  }
104
137
 
105
- /// The agent that answers: the configured one, or the daemon's
106
- /// `node.defaultAgent` from /health. "" when neither is known.
138
+ /// One row of GET /agents: who can answer, and whether they are busy.
139
+ struct AgentInfo: Decodable {
140
+ let id: String
141
+ let name: String?
142
+ /// Tasks running now.
143
+ let active: Int?
144
+ var label: String { name ?? id }
145
+ }
146
+
147
+ /// The daemon's agents, or nil when the daemon cannot be reached.
148
+ static func agents() async -> [AgentInfo]? {
149
+ guard let url = URL(string: "\(Config.daemonURL)/agents") else { return nil }
150
+ var req = URLRequest(url: url)
151
+ req.timeoutInterval = 3
152
+ guard let (data, response) = try? await URLSession.shared.data(for: req),
153
+ (response as? HTTPURLResponse)?.statusCode == 200 else { return nil }
154
+ return try? JSONDecoder().decode([AgentInfo].self, from: data)
155
+ }
156
+
157
+ /// The agent that answers: the pinned one, the one picked in the menu,
158
+ /// the daemon's `node.defaultAgent` from /health, or its first agent —
159
+ /// /ask refuses a request with no agent when there is no default.
160
+ /// "" when none is known.
107
161
  static func resolveAgent() async -> String {
108
- if let agent = Config.agentID { return agent }
109
- guard let url = URL(string: "\(Config.daemonURL)/health"),
110
- let (data, _) = try? await URLSession.shared.data(from: url),
111
- let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
112
- let node = obj["node"] as? [String: Any],
113
- let agent = node["defaultAgent"] as? String
114
- else { return "" }
115
- return agent
162
+ if let agent = Config.agentID ?? Config.chosenAgentID { return agent }
163
+ if let url = URL(string: "\(Config.daemonURL)/health"),
164
+ let (data, _) = try? await URLSession.shared.data(from: url),
165
+ let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
166
+ let node = obj["node"] as? [String: Any],
167
+ let agent = node["defaultAgent"] as? String, !agent.isEmpty { return agent }
168
+ return await agents()?.first?.id ?? ""
116
169
  }
117
170
 
118
- static func ask(_ message: String) async throws -> Answer {
171
+ static func ask(_ message: String, agent: String) async throws -> Answer {
119
172
  var req = URLRequest(url: URL(string: "\(Config.daemonURL)/ask")!)
120
173
  req.httpMethod = "POST"
121
174
  // An agent turn routinely takes minutes. The default 60s would cut
@@ -123,8 +176,8 @@ enum AgentClient {
123
176
  req.timeoutInterval = 600
124
177
  req.setValue("application/json", forHTTPHeaderField: "Content-Type")
125
178
  var payload: [String: Any] = ["message": message, "session": Config.voiceSession]
126
- // No agent configured: the daemon answers with its default agent.
127
- if let agent = Config.agentID { payload["agent"] = agent }
179
+ // No agent known: the daemon answers with its default agent.
180
+ if !agent.isEmpty { payload["agent"] = agent }
128
181
  req.httpBody = try JSONSerialization.data(withJSONObject: payload)
129
182
 
130
183
  let (data, response) = try await URLSession.shared.data(for: req)
@@ -8,11 +8,28 @@ import Foundation
8
8
  /// from a terminal and mysteriously fails when you double-click it.
9
9
  enum Config {
10
10
  static let daemonURL = env("AGENTX_DAEMON_URL") ?? "http://127.0.0.1:18800"
11
- /// The agent that answers. Unset: the daemon's `node.defaultAgent`.
11
+ /// The dashboard, for the menu's Settings… and History….
12
+ static let dashboardURL = env("AGENTX_DASHBOARD_URL") ?? "http://127.0.0.1:4202"
13
+ /// An agent pinned by the environment. Wins over the menu's choice.
12
14
  static let agentID = env("AGENTX_VOICE_AGENT")
13
15
 
14
- /// The agent actually answering: `agentID`, or the daemon's default,
15
- /// read once at launch (AgentClient.resolveAgent). Progress follows it.
16
+ /// The agent picked in the menu, remembered across launches. A UI
17
+ /// choice, so UserDefaults rather than agentx.json.
18
+ static var chosenAgentID: String? {
19
+ get { UserDefaults.standard.string(forKey: "targetAgent") }
20
+ set { UserDefaults.standard.set(newValue, forKey: "targetAgent") }
21
+ }
22
+
23
+ /// The floating pill stays on screen when idle. Off by default: the
24
+ /// menu-bar icon shows the state, and the pill appears only while
25
+ /// listening or answering.
26
+ static var showPill: Bool {
27
+ get { UserDefaults.standard.bool(forKey: "showPill") }
28
+ set { UserDefaults.standard.set(newValue, forKey: "showPill") }
29
+ }
30
+
31
+ /// The agent actually answering: `agentID`, else `chosenAgentID`, else
32
+ /// the daemon's default (AgentClient.resolveAgent). Progress follows it.
16
33
  @MainActor static var effectiveAgentID: String = ""
17
34
 
18
35
  /// Who speaks before the daemon has said which voice an agent uses:
@@ -36,43 +36,23 @@ final class Panel: NSPanel {
36
36
  super.mouseDown(with: event)
37
37
  }
38
38
 
39
- /// Right-click opens the only menu the widget has.
40
- ///
41
- /// A hold has to be reversible in one gesture from wherever you are,
42
- /// and it has to SHOW — a switch that silences your notifications
43
- /// without saying so is how people miss things for a day and blame the
44
- /// software.
39
+ /// Right-click opens the same menu as the menu-bar icon.
45
40
  override func rightMouseDown(with event: NSEvent) {
46
- let menu = NSMenu()
47
- let holding = Hold.isOn
48
- let item = NSMenuItem(
49
- title: holding ? "Delivering notifications" : "Hold notifications",
50
- action: #selector(toggleHold), keyEquivalent: "")
51
- item.target = self
52
- item.state = holding ? .on : .off
53
- menu.addItem(item)
54
- let stop = NSMenuItem(title: "Stop speaking ⌘⌥.", action: #selector(stopSpeaking), keyEquivalent: "")
55
- stop.target = self
56
- menu.addItem(stop)
57
- menu.addItem(.separator())
58
- let quit = NSMenuItem(title: "Quit AgentX Voice",
59
- action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q")
60
- menu.addItem(quit)
41
+ guard let menu = contextMenu?() else { return }
61
42
  NSMenu.popUpContextMenu(menu, with: event, for: contentView ?? NSView())
62
43
  }
63
44
 
64
- @objc private func toggleHold() {
65
- let nowOn = Hold.toggle()
66
- onHoldChanged?(nowOn)
67
- }
45
+ /// The menu to show on right-click. Set by the app.
46
+ var contextMenu: (() -> NSMenu)?
68
47
 
69
- /// Told when the hold flips, so the app can redraw and flush.
70
- var onHoldChanged: ((Bool) -> Void)?
48
+ /// The target agent's name, shown while listening or answering.
49
+ var agentName: () -> String = { "" }
71
50
 
72
- @objc private func stopSpeaking() { onStop?() }
51
+ /// Stay on screen when idle. Off: the pill shows only while active.
52
+ var alwaysVisible = false
73
53
 
74
- /// "Stop speaking" was chosen. Set by the app.
75
- var onStop: (() -> Void)?
54
+ /// Told of every state rendered, so the menu-bar icon can follow.
55
+ var onRender: ((State) -> Void)?
76
56
 
77
57
  override func mouseUp(with event: NSEvent) {
78
58
  defer { pressedAt = nil }
@@ -225,8 +205,21 @@ final class Panel: NSPanel {
225
205
  label.frame.origin.x = 0
226
206
  } else {
227
207
  label.textColor = state.color
228
- setText(state.text)
208
+ setText(named(state))
229
209
  }
210
+
211
+ if state.isMeta && !alwaysVisible { orderOut(nil) } else { orderFrontRegardless() }
212
+ onRender?(state)
213
+ }
214
+
215
+ /// "Nadia · Listening": who is listening or answering. Errors are the
216
+ /// widget's own, so they carry no name.
217
+ @MainActor
218
+ private func named(_ state: State) -> String {
219
+ let name = agentName()
220
+ if name.isEmpty { return state.text }
221
+ if case .error = state { return state.text }
222
+ return "\(name) · \(state.text)"
230
223
  }
231
224
 
232
225
  /// Fits, or scrolls. Identical text is left alone so a per-second tick
@@ -112,8 +112,22 @@ enum Speech {
112
112
  await AgentClient.stopVoice()
113
113
  }
114
114
 
115
- /// `voice` is the answering agent's, as the daemon resolved it; nil
116
- /// (no answer yet) means the provider this app is configured with.
115
+ /// Say a line through the daemon's speaking queue, in the agent's
116
+ /// voice, after whatever is already speaking. Played here only when
117
+ /// the daemon cannot be reached. `kind` is "answer", "narration" or
118
+ /// "line".
119
+ @MainActor
120
+ static func say(_ text: String, agentID: String?, kind: String, voice: VoiceChoice?) async {
121
+ guard !text.isEmpty else { return }
122
+ let agent = agentID ?? Config.effectiveAgentID
123
+ if !agent.isEmpty, await AgentClient.queue(text, agentID: agent, kind: kind) { return }
124
+ if Task.isCancelled { return }
125
+ await speak(text, voice: voice)
126
+ }
127
+
128
+ /// Speak here, now. `voice` is the answering agent's, as the daemon
129
+ /// resolved it; nil (no answer yet) means the provider this app is
130
+ /// configured with.
117
131
  static func speak(_ text: String, voice: VoiceChoice? = nil) async {
118
132
  guard !text.isEmpty else { return }
119
133
  if (voice?.provider ?? Config.voiceProvider) == "elevenlabs" {
@@ -0,0 +1,173 @@
1
+ import AppKit
2
+
3
+ /// The menu-bar icon: what the assistant is doing, and who it talks to.
4
+ ///
5
+ /// One menu serves the icon and the pill's right-click, so there is one
6
+ /// place that says which agent answers and one way to change it.
7
+ @MainActor
8
+ final class StatusMenu: NSObject, NSMenuDelegate {
9
+ let menu = NSMenu()
10
+ private let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength)
11
+
12
+ private enum Roster {
13
+ case loading
14
+ /// The daemon did not answer. Said so, with a retry, rather than
15
+ /// an empty list that looks like "you have no agents".
16
+ case down
17
+ case loaded([AgentClient.AgentInfo])
18
+ }
19
+ private var roster = Roster.loading
20
+ /// Reopen the menu once the retry's answer is in.
21
+ private var reopenAfterRefresh = false
22
+
23
+ /// Set by the app.
24
+ var onStop: (() -> Void)?
25
+ var onHoldChanged: ((Bool) -> Void)?
26
+ var onTargetChanged: ((String) -> Void)?
27
+ var onPillChanged: ((Bool) -> Void)?
28
+
29
+ override init() {
30
+ super.init()
31
+ menu.delegate = self
32
+ menu.autoenablesItems = false
33
+ item.menu = menu
34
+ show(.idle)
35
+ rebuild()
36
+ }
37
+
38
+ /// The agent's display name, or its id until /agents has answered.
39
+ func name(of id: String) -> String {
40
+ guard case .loaded(let agents) = roster else { return id }
41
+ return agents.first { $0.id == id }?.label ?? id
42
+ }
43
+
44
+ /// Open the menu from the keyboard.
45
+ func open() { item.button?.performClick(nil) }
46
+
47
+ func refresh() {
48
+ Task {
49
+ let agents = await AgentClient.agents()
50
+ roster = agents.map { .loaded($0) } ?? .down
51
+ rebuild()
52
+ if reopenAfterRefresh { reopenAfterRefresh = false; open() }
53
+ }
54
+ }
55
+
56
+ /// The icon follows the pill's state.
57
+ func show(_ state: Panel.State) {
58
+ let (symbol, label): (String, String) = switch state {
59
+ case .idle: Hold.isOn ? ("bell.slash", "Notifications held") : ("waveform", "Idle")
60
+ case .listening: ("mic.fill", "Listening")
61
+ case .thinking, .working: ("ellipsis.circle", "Thinking")
62
+ case .speaking, .saying: ("speaker.wave.2.fill", "Speaking")
63
+ case .error: ("exclamationmark.triangle", "Error")
64
+ }
65
+ guard item.button?.toolTip != "AgentX Voice — \(label)" else { return }
66
+ let image = NSImage(systemSymbolName: symbol, accessibilityDescription: "AgentX Voice: \(label)")
67
+ image?.isTemplate = true
68
+ item.button?.image = image
69
+ item.button?.toolTip = "AgentX Voice — \(label)"
70
+ }
71
+
72
+ // MARK: NSMenuDelegate
73
+
74
+ /// Show what is known now and refresh underneath; the open menu
75
+ /// updates in place when the answer arrives.
76
+ func menuWillOpen(_ menu: NSMenu) {
77
+ rebuild()
78
+ refresh()
79
+ }
80
+
81
+ // MARK: Building
82
+
83
+ private func rebuild() {
84
+ menu.removeAllItems()
85
+ let target = Config.effectiveAgentID
86
+ let pinned = Config.agentID != nil
87
+
88
+ switch roster {
89
+ case .loading:
90
+ menu.addItem(disabled("Loading agents…"))
91
+ case .down:
92
+ menu.addItem(disabled("AgentX daemon isn't reachable"))
93
+ menu.addItem(action("Retry", #selector(retry), key: "r", modifiers: []))
94
+ case .loaded(let agents):
95
+ menu.addItem(NSMenuItem.sectionHeader(title: pinned ? "Agent (set by AGENTX_VOICE_AGENT)" : "Talk to"))
96
+ if agents.isEmpty { menu.addItem(disabled("No agents configured")) }
97
+ for (i, agent) in agents.enumerated() {
98
+ let busy = (agent.active ?? 0) > 0
99
+ // Digits pick an agent while the menu is open.
100
+ let row = action(agent.label + (busy ? " · working" : " · idle"), #selector(pick(_:)),
101
+ key: i < 9 ? "\(i + 1)" : "", modifiers: [])
102
+ row.representedObject = agent.id
103
+ row.state = agent.id == target ? .on : .off
104
+ row.isEnabled = !pinned
105
+ menu.addItem(row)
106
+ }
107
+ }
108
+
109
+ menu.addItem(.separator())
110
+ menu.addItem(action("Stop speaking", #selector(stop), key: ".", modifiers: [.command, .option]))
111
+ let hold = action("Hold notifications", #selector(toggleHold), key: "")
112
+ hold.state = Hold.isOn ? .on : .off
113
+ menu.addItem(hold)
114
+ let pill = action("Show floating pill", #selector(togglePill), key: "")
115
+ pill.state = Config.showPill ? .on : .off
116
+ menu.addItem(pill)
117
+
118
+ menu.addItem(.separator())
119
+ menu.addItem(action("Settings…", #selector(openSettings), key: ","))
120
+ menu.addItem(action("History…", #selector(openHistory), key: "y"))
121
+ menu.addItem(.separator())
122
+ menu.addItem(NSMenuItem(title: "Quit AgentX Voice",
123
+ action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q"))
124
+ }
125
+
126
+ private func disabled(_ title: String) -> NSMenuItem {
127
+ let row = NSMenuItem(title: title, action: nil, keyEquivalent: "")
128
+ row.isEnabled = false
129
+ return row
130
+ }
131
+
132
+ private func action(_ title: String, _ selector: Selector, key: String,
133
+ modifiers: NSEvent.ModifierFlags = [.command]) -> NSMenuItem {
134
+ let row = NSMenuItem(title: title, action: selector, keyEquivalent: key)
135
+ row.keyEquivalentModifierMask = modifiers
136
+ row.target = self
137
+ return row
138
+ }
139
+
140
+ // MARK: Actions
141
+
142
+ @objc private func pick(_ sender: NSMenuItem) {
143
+ guard let id = sender.representedObject as? String else { return }
144
+ Config.chosenAgentID = id
145
+ onTargetChanged?(id)
146
+ rebuild()
147
+ }
148
+
149
+ @objc private func retry() {
150
+ reopenAfterRefresh = true
151
+ roster = .loading
152
+ refresh()
153
+ }
154
+
155
+ @objc private func stop() { onStop?() }
156
+
157
+ @objc private func toggleHold() {
158
+ onHoldChanged?(Hold.toggle())
159
+ }
160
+
161
+ @objc private func togglePill() {
162
+ Config.showPill.toggle()
163
+ onPillChanged?(Config.showPill)
164
+ }
165
+
166
+ @objc private func openSettings() { openDashboard("/admin") }
167
+ @objc private func openHistory() { openDashboard("/activity") }
168
+
169
+ private func openDashboard(_ path: String) {
170
+ guard let url = URL(string: Config.dashboardURL + path) else { return }
171
+ NSWorkspace.shared.open(url)
172
+ }
173
+ }
@@ -17,11 +17,13 @@ import Carbon.HIToolbox
17
17
  @MainActor
18
18
  final class App: NSObject, NSApplicationDelegate {
19
19
  private let panel = Panel()
20
+ private let statusMenu = StatusMenu()
20
21
  private let card = ResultCard()
21
22
  private let recorder = Recorder()
22
23
  private var hotkey: Hotkey?
23
24
  private var pasteHotkey: Hotkey?
24
25
  private var stopHotkey: Hotkey?
26
+ private var menuHotkey: Hotkey?
25
27
  /// Set by a stop: the answer being spoken ends without reopening the mic.
26
28
  private var silenced = false
27
29
  private var busy = false
@@ -37,6 +39,9 @@ final class App: NSObject, NSApplicationDelegate {
37
39
  /// Set when the door opens: what was speaking (and is now hushed), so
38
40
  /// the words spoken go to that activity rather than to /ask.
39
41
  private var talkCheck: Task<AgentClient.Hushed, Never>?
42
+ /// Waiting for our answer to be spoken by the daemon's queue. The door
43
+ /// lets go of it: the answer stays queued and plays after the turn.
44
+ private var speaking: Task<Void, Never>?
40
45
  /// Words said while our own turn was still running: they go next, and
41
46
  /// the stale answer is not spoken.
42
47
  private var followUp: String?
@@ -89,7 +94,25 @@ final class App: NSObject, NSApplicationDelegate {
89
94
  func applicationDidFinishLaunching(_ note: Notification) {
90
95
  NSApp.setActivationPolicy(.accessory)
91
96
  retireOlderInstances()
92
- panel.orderFrontRegardless()
97
+ panel.onRender = { [weak self] state in self?.statusMenu.show(state) }
98
+ panel.contextMenu = { [weak self] in self?.statusMenu.menu ?? NSMenu() }
99
+ panel.agentName = { [weak self] in
100
+ Config.effectiveAgentID.isEmpty ? "" : self?.statusMenu.name(of: Config.effectiveAgentID) ?? ""
101
+ }
102
+ panel.alwaysVisible = Config.showPill
103
+ panel.render(.idle)
104
+ statusMenu.onPillChanged = { [weak self] on in
105
+ guard let self else { return }
106
+ self.panel.alwaysVisible = on
107
+ if !self.busy && !self.recorder.isRecording { self.panel.render(.idle) }
108
+ }
109
+ statusMenu.onTargetChanged = { [weak self] id in
110
+ Config.effectiveAgentID = id
111
+ // The last answer's voice belongs to the previous agent.
112
+ self?.voice = nil
113
+ Log.info("target agent: \(id)")
114
+ }
115
+ statusMenu.refresh()
93
116
 
94
117
  hotkey = Hotkey(
95
118
  id: 1,
@@ -102,7 +125,7 @@ final class App: NSObject, NSApplicationDelegate {
102
125
  // becomes welcome. Without this the queue is a hole rather than a
103
126
  // delay — the daemon's watcher only notices SYSTEM Focus ending,
104
127
  // and this switch is not that.
105
- panel.onHoldChanged = { [weak self] nowOn in
128
+ statusMenu.onHoldChanged = { [weak self] nowOn in
106
129
  guard let self else { return }
107
130
  self.panel.render(.idle)
108
131
  if !nowOn { Hold.flushHeld() }
@@ -120,7 +143,7 @@ final class App: NSObject, NSApplicationDelegate {
120
143
  pasteHotkey?.register(keyCode: UInt32(kVK_ANSI_V),
121
144
  modifiers: UInt32(cmdKey | optionKey))
122
145
 
123
- // ⌘⌥. (and the pill's menu): stop every voice now. ⌘. is the Mac's
146
+ // ⌘⌥. (and the menu): stop every voice now. ⌘. is the Mac's
124
147
  // own "cancel"; Option keeps it from reaching the app in front.
125
148
  stopHotkey = Hotkey(
126
149
  id: 3,
@@ -128,16 +151,28 @@ final class App: NSObject, NSApplicationDelegate {
128
151
  onRelease: { [weak self] in self?.stopSpeaking() })
129
152
  stopHotkey?.register(keyCode: UInt32(kVK_ANSI_Period),
130
153
  modifiers: UInt32(cmdKey | optionKey))
131
- panel.onStop = { [weak self] in self?.stopSpeaking() }
154
+ statusMenu.onStop = { [weak self] in self?.stopSpeaking() }
155
+
156
+ // ⌘⌥A: open the menu-bar menu without the mouse; arrows, digits
157
+ // and Return work from there.
158
+ menuHotkey = Hotkey(
159
+ id: 4,
160
+ onPress: {},
161
+ onRelease: { [weak self] in self?.statusMenu.open() })
162
+ menuHotkey?.register(keyCode: UInt32(kVK_ANSI_A),
163
+ modifiers: UInt32(cmdKey | optionKey))
132
164
 
165
+ // The target does not wait for the microphone: the menu shows it
166
+ // either way.
167
+ Task { @MainActor in
168
+ Config.effectiveAgentID = await AgentClient.resolveAgent()
169
+ Log.info("agent: \(Config.effectiveAgentID.isEmpty ? "the daemon's default" : Config.effectiveAgentID)")
170
+ }
133
171
  recorder.requestPermission { [weak self] granted in
134
172
  Task { @MainActor in
135
173
  guard let self else { return }
136
174
  if !granted { self.panel.render(.error("Microphone denied")) }
137
- else {
138
- Config.effectiveAgentID = await AgentClient.resolveAgent()
139
- Log.info("ready — hold ⌥Space to talk (agent: \(Config.effectiveAgentID.isEmpty ? "the daemon's default" : Config.effectiveAgentID))")
140
- }
175
+ else { Log.info("ready — hold ⌥Space to talk") }
141
176
  }
142
177
  }
143
178
  }
@@ -264,6 +299,8 @@ final class App: NSObject, NSApplicationDelegate {
264
299
  // ignoring the key while busy is how Anis spoke to a lesson and
265
300
  // nothing listened.
266
301
  Speech.stop()
302
+ speaking?.cancel()
303
+ speaking = nil
267
304
  lastSpokeAt = Date()
268
305
  talkCheck = Task { await AgentClient.hush() }
269
306
  Log.info("door: opened\(busy ? " (a turn is running)" : "")")
@@ -282,6 +319,8 @@ final class App: NSObject, NSApplicationDelegate {
282
319
  let door = talkCheck ?? Task { await AgentClient.hush() }
283
320
  talkCheck = nil
284
321
  guard let wav = recorder.stop() else {
322
+ // Nothing said, so no door follows the hush: let the queue play on.
323
+ Task { _ = await door.value; await AgentClient.resume() }
285
324
  panel.render(.error("Too short — hold while speaking"))
286
325
  resetSoon()
287
326
  return
@@ -295,19 +334,23 @@ final class App: NSObject, NSApplicationDelegate {
295
334
  do { heard = try await Speech.transcribe(wav: wav) }
296
335
  catch { Log.warn("transcription failed: \(error.localizedDescription)") }
297
336
  guard !heard.isEmpty else {
337
+ _ = await door.value
338
+ await AgentClient.resume()
298
339
  if !midTurn {
299
340
  panel.render(.error("Didn't catch that"))
300
341
  // Say it aloud: someone not looking would think it was sent.
301
- await Speech.speak("Sorry, I didn't hear that. Please say it again.", voice: voice)
342
+ await Speech.say("Sorry, I didn't hear that. Please say it again.", agentID: nil, kind: "line", voice: voice)
302
343
  busy = false; resetSoon()
303
344
  }
304
345
  return
305
346
  }
306
347
  Log.info("heard: \(heard)")
307
348
  let hushed = await door.value
308
- if hushed.kind != nil, await AgentClient.door(heard) {
349
+ // Always through the door, even with nothing hushed: the
350
+ // listener's turn is over, so the daemon's queue plays on.
351
+ if await AgentClient.door(heard) {
309
352
  // The talk or lesson answers out loud through the daemon.
310
- Log.info("door: \"\(heard)\" → \(hushed.kind!)\(hushed.agentID.map { " (\($0))" } ?? "")")
353
+ Log.info("door: \"\(heard)\" → \(hushed.kind ?? "?")\(hushed.agentID.map { " (\($0))" } ?? "")")
311
354
  if !midTurn { panel.render(.idle); busy = false }
312
355
  return
313
356
  }
@@ -336,7 +379,7 @@ final class App: NSObject, NSApplicationDelegate {
336
379
  do {
337
380
  beginNarration()
338
381
 
339
- let answer = try await AgentClient.ask(heard)
382
+ let answer = try await AgentClient.ask(heard, agent: Config.effectiveAgentID)
340
383
  endNarration()
341
384
  if abandoned {
342
385
  abandoned = false
@@ -367,7 +410,11 @@ final class App: NSObject, NSApplicationDelegate {
367
410
  // Scroll the sentence being spoken, so it can be read as
368
411
  // well as heard — and re-read after, which speech cannot do.
369
412
  panel.render(.saying(answer.text))
370
- await Speech.speak(answer.text, voice: voice)
413
+ // Queued on the daemon behind whatever is already speaking.
414
+ let line = Task { await Speech.say(answer.text, agentID: answer.agentID, kind: "answer", voice: voice) }
415
+ speaking = line
416
+ await line.value
417
+ if speaking == line { speaking = nil }
371
418
  // Cut off by the door: the new words are being recorded.
372
419
  if recorder.isRecording { busy = false; return }
373
420
  // Stopped: no follow-up window, the listener asked for quiet.
@@ -390,7 +437,7 @@ final class App: NSObject, NSApplicationDelegate {
390
437
  panel.render(.error(short(error.localizedDescription)))
391
438
  // Say it aloud too — a voice assistant that fails only in
392
439
  // a 230px label has failed silently for anyone not looking.
393
- await Speech.speak("Sorry, that didn't work.", voice: voice)
440
+ await Speech.say("Sorry, that didn't work.", agentID: nil, kind: "line", voice: voice)
394
441
  resetSoon()
395
442
  }
396
443
  busy = false
@@ -465,7 +512,7 @@ final class App: NSObject, NSApplicationDelegate {
465
512
  tool: tool, detail: detail, elapsed: Int(elapsed)) else { return }
466
513
  guard !self.spokenSteps.contains(phrase) else { return }
467
514
  self.spokenSteps.insert(phrase)
468
- await Speech.speak(phrase.prefix(1).capitalized + phrase.dropFirst() + ".", voice: self.voice)
515
+ await Speech.say(phrase.prefix(1).capitalized + phrase.dropFirst() + ".", agentID: nil, kind: "narration", voice: self.voice)
469
516
  }
470
517
  }
471
518