agentix-cli 0.58.0 → 0.60.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 (130) hide show
  1. package/apps/mac-voice/Sources/AgentXVoice/AgentClient.swift +4 -0
  2. package/apps/mac-voice/Sources/AgentXVoice/AnswerView.swift +228 -0
  3. package/apps/mac-voice/Sources/AgentXVoice/HistoryClient.swift +55 -0
  4. package/apps/mac-voice/Sources/AgentXVoice/HistoryModel.swift +153 -0
  5. package/apps/mac-voice/Sources/AgentXVoice/HistoryWindow.swift +301 -0
  6. package/apps/mac-voice/Sources/AgentXVoice/LocalSTT.swift +79 -0
  7. package/apps/mac-voice/Sources/AgentXVoice/ModelStore.swift +184 -0
  8. package/apps/mac-voice/Sources/AgentXVoice/Orb.swift +36 -9
  9. package/apps/mac-voice/Sources/AgentXVoice/Panel.swift +126 -55
  10. package/apps/mac-voice/Sources/AgentXVoice/PanelAnswer.swift +113 -0
  11. package/apps/mac-voice/Sources/AgentXVoice/Parakeet.swift +264 -0
  12. package/apps/mac-voice/Sources/AgentXVoice/PillPlacement.swift +28 -17
  13. package/apps/mac-voice/Sources/AgentXVoice/Recorder.swift +81 -3
  14. package/apps/mac-voice/Sources/AgentXVoice/SettingsLook.swift +90 -0
  15. package/apps/mac-voice/Sources/AgentXVoice/SettingsWindow.swift +20 -5
  16. package/apps/mac-voice/Sources/AgentXVoice/SileroVAD.swift +92 -0
  17. package/apps/mac-voice/Sources/AgentXVoice/Speech.swift +7 -5
  18. package/apps/mac-voice/Sources/AgentXVoice/StatusMenu.swift +14 -1
  19. package/apps/mac-voice/Sources/AgentXVoice/Surface.swift +105 -0
  20. package/apps/mac-voice/Sources/AgentXVoice/TurnEnd.swift +110 -0
  21. package/apps/mac-voice/Sources/AgentXVoice/VoiceSettings.swift +54 -0
  22. package/apps/mac-voice/Sources/AgentXVoice/main.swift +35 -36
  23. package/apps/mac-voice/build.sh +10 -1
  24. package/dist/{App-Z5VOAUXB.js → App-LQXWKBVY.js} +2 -2
  25. package/dist/{ChatApp-BE7ONQKS.js → ChatApp-75TL4LKW.js} +2 -2
  26. package/dist/{agent-Q74EISKP.js → agent-DH4FMMMY.js} +2 -2
  27. package/dist/{board-dashboard-A7PQOI6C.js → board-dashboard-BVRRDYAG.js} +223 -138
  28. package/dist/board-dashboard-BVRRDYAG.js.map +1 -0
  29. package/dist/{chunk-NKMFHEGV.js → chunk-27P5NLTC.js} +2 -2
  30. package/dist/{chunk-5LLGIX5M.js → chunk-2VQIPJH5.js} +2 -2
  31. package/dist/{chunk-V7ZK6ZIO.js → chunk-3XVTFNYM.js} +2 -2
  32. package/dist/{chunk-KW7TIOS3.js → chunk-B3DCYBP4.js} +2 -2
  33. package/dist/{chunk-547G3HYQ.js → chunk-CRSXKQLZ.js} +2 -2
  34. package/dist/{chunk-5W7NJHCS.js → chunk-DTEQNXK3.js} +2 -2
  35. package/dist/{chunk-XTD3SZIP.js → chunk-E2BJVHBR.js} +2 -2
  36. package/dist/{chunk-RXEAACVH.js → chunk-E2PWCU2N.js} +2 -2
  37. package/dist/{chunk-I2LYULR4.js → chunk-JO3PSLWL.js} +5 -5
  38. package/dist/{chunk-U5YBQJ5Q.js → chunk-JORXRF4E.js} +2 -2
  39. package/dist/{chunk-2MIOMYT2.js → chunk-LXUG2X26.js} +2 -2
  40. package/dist/{chunk-Q6SMLSRO.js → chunk-N5KD3TVN.js} +2 -2
  41. package/dist/{chunk-VI7GBDKV.js → chunk-NBGNPKAF.js} +2 -2
  42. package/dist/{chunk-6QNPCVRN.js → chunk-PAOV37KQ.js} +3 -3
  43. package/dist/chunk-PAOV37KQ.js.map +1 -0
  44. package/dist/{chunk-OZZPJ2YG.js → chunk-RYFUFX6F.js} +194 -181
  45. package/dist/chunk-RYFUFX6F.js.map +1 -0
  46. package/dist/chunk-S7TCVIET.js +15 -0
  47. package/dist/chunk-S7TCVIET.js.map +1 -0
  48. package/dist/{chunk-POL7RAJQ.js → chunk-TM762AXU.js} +159 -153
  49. package/dist/chunk-TM762AXU.js.map +1 -0
  50. package/dist/{chunk-6OABTCRA.js → chunk-WBGC7MWX.js} +2 -2
  51. package/dist/{chunk-CQUVUVXR.js → chunk-WIBMNQKJ.js} +2 -2
  52. package/dist/{chunk-KKGFZEXS.js → chunk-WOPFHKBF.js} +37 -36
  53. package/dist/chunk-WOPFHKBF.js.map +1 -0
  54. package/dist/{chunk-VVBGPT6W.js → chunk-YQCOX4R3.js} +2 -2
  55. package/dist/cli.js +1 -1
  56. package/dist/client-QVZIZFV4.js +2 -0
  57. package/dist/config-3XC7EYB7.js +2 -0
  58. package/dist/config-mutator-HKU63FJM.js +2 -0
  59. package/dist/doctor-YFA4ZSRH.js +2 -0
  60. package/dist/index.d.ts +66 -7
  61. package/dist/index.js +1 -1
  62. package/dist/program-BOCLU6ES.js +2 -0
  63. package/dist/{promote-KVPLDSKT.js → promote-UR2GSHKC.js} +2 -2
  64. package/dist/query-XUP3BMVS.js +2 -0
  65. package/dist/{registry-AUOI4TW2.js → registry-PGALUW5L.js} +2 -2
  66. package/dist/{request-planner-LMZ5ZAXC.js → request-planner-NHE3IRJN.js} +2 -2
  67. package/dist/{review-candidates-WCBXLLDS.js → review-candidates-6GKRSRBQ.js} +2 -2
  68. package/dist/{routing-FBASYKUL.js → routing-AQKHAU3Z.js} +2 -2
  69. package/dist/schedule-tool-6NMCLTIH.js +2 -0
  70. package/dist/scheduler-RAYGNDWT.js +2 -0
  71. package/dist/{seat-DBFW3Z5P.js → seat-6FMW2QI4.js} +2 -2
  72. package/dist/{select-6CNVVFA5.js → select-5VONJMLH.js} +2 -2
  73. package/dist/{set-enabled-AYZH3KSB.js → set-enabled-YHD3IJJZ.js} +2 -2
  74. package/dist/{tool-PKS4NBZ6.js → tool-MXJGLJZ5.js} +2 -2
  75. package/dist/wiki-ZQLEYY24.js +2 -0
  76. package/package.json +1 -1
  77. package/apps/mac-voice/Sources/AgentXVoice/ResultCard.swift +0 -198
  78. package/dist/board-dashboard-A7PQOI6C.js.map +0 -1
  79. package/dist/chunk-6QNPCVRN.js.map +0 -1
  80. package/dist/chunk-ERL6AS3O.js +0 -15
  81. package/dist/chunk-ERL6AS3O.js.map +0 -1
  82. package/dist/chunk-KKGFZEXS.js.map +0 -1
  83. package/dist/chunk-OZZPJ2YG.js.map +0 -1
  84. package/dist/chunk-POL7RAJQ.js.map +0 -1
  85. package/dist/client-EFP2PKA3.js +0 -2
  86. package/dist/config-P6YXC6XZ.js +0 -2
  87. package/dist/config-mutator-3GKXLTHH.js +0 -2
  88. package/dist/doctor-JEWMHXYH.js +0 -2
  89. package/dist/program-7DUQJDSQ.js +0 -2
  90. package/dist/query-AGOMCYTT.js +0 -2
  91. package/dist/schedule-tool-RJRJ63SS.js +0 -2
  92. package/dist/scheduler-ATA7Z44S.js +0 -2
  93. package/dist/wiki-QW6BDNEQ.js +0 -2
  94. /package/dist/{App-Z5VOAUXB.js.map → App-LQXWKBVY.js.map} +0 -0
  95. /package/dist/{ChatApp-BE7ONQKS.js.map → ChatApp-75TL4LKW.js.map} +0 -0
  96. /package/dist/{agent-Q74EISKP.js.map → agent-DH4FMMMY.js.map} +0 -0
  97. /package/dist/{chunk-NKMFHEGV.js.map → chunk-27P5NLTC.js.map} +0 -0
  98. /package/dist/{chunk-5LLGIX5M.js.map → chunk-2VQIPJH5.js.map} +0 -0
  99. /package/dist/{chunk-V7ZK6ZIO.js.map → chunk-3XVTFNYM.js.map} +0 -0
  100. /package/dist/{chunk-KW7TIOS3.js.map → chunk-B3DCYBP4.js.map} +0 -0
  101. /package/dist/{chunk-547G3HYQ.js.map → chunk-CRSXKQLZ.js.map} +0 -0
  102. /package/dist/{chunk-5W7NJHCS.js.map → chunk-DTEQNXK3.js.map} +0 -0
  103. /package/dist/{chunk-XTD3SZIP.js.map → chunk-E2BJVHBR.js.map} +0 -0
  104. /package/dist/{chunk-RXEAACVH.js.map → chunk-E2PWCU2N.js.map} +0 -0
  105. /package/dist/{chunk-I2LYULR4.js.map → chunk-JO3PSLWL.js.map} +0 -0
  106. /package/dist/{chunk-U5YBQJ5Q.js.map → chunk-JORXRF4E.js.map} +0 -0
  107. /package/dist/{chunk-2MIOMYT2.js.map → chunk-LXUG2X26.js.map} +0 -0
  108. /package/dist/{chunk-Q6SMLSRO.js.map → chunk-N5KD3TVN.js.map} +0 -0
  109. /package/dist/{chunk-VI7GBDKV.js.map → chunk-NBGNPKAF.js.map} +0 -0
  110. /package/dist/{chunk-6OABTCRA.js.map → chunk-WBGC7MWX.js.map} +0 -0
  111. /package/dist/{chunk-CQUVUVXR.js.map → chunk-WIBMNQKJ.js.map} +0 -0
  112. /package/dist/{chunk-VVBGPT6W.js.map → chunk-YQCOX4R3.js.map} +0 -0
  113. /package/dist/{client-EFP2PKA3.js.map → client-QVZIZFV4.js.map} +0 -0
  114. /package/dist/{config-P6YXC6XZ.js.map → config-3XC7EYB7.js.map} +0 -0
  115. /package/dist/{config-mutator-3GKXLTHH.js.map → config-mutator-HKU63FJM.js.map} +0 -0
  116. /package/dist/{doctor-JEWMHXYH.js.map → doctor-YFA4ZSRH.js.map} +0 -0
  117. /package/dist/{program-7DUQJDSQ.js.map → program-BOCLU6ES.js.map} +0 -0
  118. /package/dist/{promote-KVPLDSKT.js.map → promote-UR2GSHKC.js.map} +0 -0
  119. /package/dist/{query-AGOMCYTT.js.map → query-XUP3BMVS.js.map} +0 -0
  120. /package/dist/{registry-AUOI4TW2.js.map → registry-PGALUW5L.js.map} +0 -0
  121. /package/dist/{request-planner-LMZ5ZAXC.js.map → request-planner-NHE3IRJN.js.map} +0 -0
  122. /package/dist/{review-candidates-WCBXLLDS.js.map → review-candidates-6GKRSRBQ.js.map} +0 -0
  123. /package/dist/{routing-FBASYKUL.js.map → routing-AQKHAU3Z.js.map} +0 -0
  124. /package/dist/{schedule-tool-RJRJ63SS.js.map → schedule-tool-6NMCLTIH.js.map} +0 -0
  125. /package/dist/{scheduler-ATA7Z44S.js.map → scheduler-RAYGNDWT.js.map} +0 -0
  126. /package/dist/{seat-DBFW3Z5P.js.map → seat-6FMW2QI4.js.map} +0 -0
  127. /package/dist/{select-6CNVVFA5.js.map → select-5VONJMLH.js.map} +0 -0
  128. /package/dist/{set-enabled-AYZH3KSB.js.map → set-enabled-YHD3IJJZ.js.map} +0 -0
  129. /package/dist/{tool-PKS4NBZ6.js.map → tool-MXJGLJZ5.js.map} +0 -0
  130. /package/dist/{wiki-QW6BDNEQ.js.map → wiki-ZQLEYY24.js.map} +0 -0
@@ -213,7 +213,11 @@ enum AgentClient {
213
213
  /// "#RRGGBB": presence.color, else derived from the id. Older
214
214
  /// daemons send none, and the app derives it the same way.
215
215
  let color: String?
216
+ /// The orb's nature palette. Older daemons send none, and the orb
217
+ /// uses shades of `color`.
218
+ let palette: Palette?
216
219
  var label: String { name ?? id }
220
+ struct Palette: Decodable { let id: String; let colors: [String] }
217
221
  }
218
222
 
219
223
  /// The daemon's agents, or nil when the daemon cannot be reached.
@@ -0,0 +1,228 @@
1
+ import AppKit
2
+ import WebKit
3
+
4
+ /// What the agent said, in a form you can read, select and copy, inside
5
+ /// the pill.
6
+ ///
7
+ /// Speech is lossy in one specific way that matters: a URL cannot be
8
+ /// spoken usefully, an image cannot be spoken at all, and a name you have
9
+ /// not seen written is a name you cannot act on. The spoken answer is
10
+ /// deliberately short and stripped of exactly those things, so without a
11
+ /// visual surface everything worth keeping from a turn is the part that
12
+ /// gets thrown away.
13
+ ///
14
+ /// Rich content arrives through the same `agentx:ui` directive Telegram
15
+ /// and WhatsApp already use, parsed server-side, so an agent has one way to
16
+ /// attach a link or a picture regardless of where it is speaking.
17
+ final class AnswerView: NSView {
18
+ // A web view, because agents write markdown: headings, bullets, fenced
19
+ // code, tables. An NSTextView shows that as a mess of asterisks and
20
+ // hashes, which defeats the answer's only purpose: being MORE readable
21
+ // than the spoken answer, not less. Text in it is selectable, and it
22
+ // scrolls once the answer is taller than the pill lets it grow.
23
+ private let web = WKWebView()
24
+ private let links = NSStackView()
25
+ private let thumb = NSImageView()
26
+ private let actions = NSStackView()
27
+ private var source = ""
28
+
29
+ /// The answer's natural height, measured after it renders. The pill
30
+ /// grows to it, up to its maximum.
31
+ var onHeight: ((CGFloat) -> Void)?
32
+ /// "Open in chat". Set by the app.
33
+ var onOpenChat: (() -> Void)?
34
+
35
+ private static let linkRow: CGFloat = 34
36
+ private static let imageHeight: CGFloat = 120
37
+
38
+ override init(frame: NSRect) {
39
+ super.init(frame: frame)
40
+ web.setValue(false, forKey: "drawsBackground")
41
+ web.navigationDelegate = self
42
+ addSubview(web)
43
+
44
+ thumb.imageScaling = .scaleProportionallyUpOrDown
45
+ thumb.isHidden = true
46
+ addSubview(thumb)
47
+
48
+ links.orientation = .horizontal
49
+ links.spacing = 8
50
+ links.edgeInsets = NSEdgeInsets(top: 4, left: 12, bottom: 8, right: 12)
51
+ addSubview(links)
52
+
53
+ // Copy and Open in chat, shown while the pointer is over the answer.
54
+ actions.orientation = .horizontal
55
+ actions.spacing = 2
56
+ actions.addArrangedSubview(actionButton("doc.on.doc", "Copy the answer", #selector(copyAnswer)))
57
+ actions.addArrangedSubview(actionButton("bubble.left.and.bubble.right", "Open in chat", #selector(openChat)))
58
+ actions.isHidden = true
59
+ actions.edgeInsets = NSEdgeInsets(top: 1, left: 3, bottom: 1, right: 3)
60
+ // A backing of its own, so the buttons read over the text below.
61
+ actions.wantsLayer = true
62
+ actions.layer?.cornerRadius = 7
63
+ actions.layer?.cornerCurve = .continuous
64
+ addSubview(actions)
65
+
66
+ addTrackingArea(NSTrackingArea(rect: .zero, options: [.mouseEnteredAndExited, .activeAlways, .inVisibleRect],
67
+ owner: self, userInfo: nil))
68
+ }
69
+
70
+ @available(*, unavailable)
71
+ required init?(coder: NSCoder) { fatalError("not from a nib") }
72
+
73
+ private func actionButton(_ symbol: String, _ label: String, _ action: Selector) -> NSButton {
74
+ let b = NSButton()
75
+ b.isBordered = false
76
+ b.bezelStyle = .regularSquare
77
+ b.imagePosition = .imageOnly
78
+ b.image = NSImage(systemSymbolName: symbol, accessibilityDescription: label)
79
+ b.contentTintColor = .secondaryLabelColor
80
+ b.toolTip = label
81
+ b.setAccessibilityLabel(label)
82
+ b.target = self
83
+ b.action = action
84
+ b.widthAnchor.constraint(equalToConstant: 24).isActive = true
85
+ b.heightAnchor.constraint(equalToConstant: 22).isActive = true
86
+ return b
87
+ }
88
+
89
+ override func resizeSubviews(withOldSize oldSize: NSSize) {
90
+ let w = bounds.width, h = bounds.height
91
+ let linkH: CGFloat = links.arrangedSubviews.isEmpty ? 0 : Self.linkRow
92
+ let imgH: CGFloat = thumb.isHidden ? 0 : Self.imageHeight
93
+ thumb.frame = NSRect(x: 12, y: h - imgH - 10, width: w - 24, height: imgH)
94
+ web.frame = NSRect(x: 0, y: linkH, width: w, height: max(0, h - linkH - imgH - (imgH > 0 ? 16 : 0)))
95
+ links.frame = NSRect(x: 0, y: 0, width: w, height: linkH)
96
+ actions.frame = NSRect(x: w - 64, y: h - 32, width: 56, height: 26)
97
+ }
98
+
99
+ /// Does this turn have anything the spoken answer did not already
100
+ /// deliver?
101
+ ///
102
+ /// Opening on every turn, including "Ok.", shows one word you have
103
+ /// already heard. A surface that adds nothing trains you to ignore it,
104
+ /// so the turn where it DOES carry a link gets ignored too.
105
+ ///
106
+ /// Worth showing when there is rich content, a URL you could click, or
107
+ /// materially more written than was spoken. Otherwise the speech was
108
+ /// the whole answer.
109
+ static func isWorthShowing(spoken: String, written: String?, buttons: [(String, String)], imageURL: String?) -> Bool {
110
+ if !buttons.isEmpty || imageURL != nil { return true }
111
+ let text = (written?.isEmpty == false ? written! : spoken)
112
+ if text.range(of: #"https?://"#, options: .regularExpression) != nil { return true }
113
+ // Speech drops formatting, so written is often a little longer for
114
+ // the same content. Only a real difference counts.
115
+ let spokenLen = spoken.trimmingCharacters(in: .whitespacesAndNewlines).count
116
+ let writtenLen = text.trimmingCharacters(in: .whitespacesAndNewlines).count
117
+ if writtenLen > spokenLen + 80 { return true }
118
+ // Long enough that re-reading it beats re-hearing it.
119
+ return writtenLen > 280
120
+ }
121
+
122
+ /// `spoken` is what was said; `written` is the fuller answer with the
123
+ /// URLs and formatting the spoken form had to drop. `onHeight` follows
124
+ /// once it has rendered.
125
+ @MainActor
126
+ func show(spoken: String, written: String?, buttons: [(String, String)], imageURL: String?) {
127
+ source = (written?.isEmpty == false ? written! : spoken)
128
+ web.loadHTMLString(Markdown.page(Markdown.toHTML(source), css: Brand.cardCSS), baseURL: nil)
129
+
130
+ links.arrangedSubviews.forEach { links.removeArrangedSubview($0); $0.removeFromSuperview() }
131
+ for (label, url) in buttons.prefix(4) {
132
+ let b = NSButton(title: label, target: self, action: #selector(openLink(_:)))
133
+ b.bezelStyle = .push
134
+ b.controlSize = .small
135
+ b.toolTip = url
136
+ b.identifier = NSUserInterfaceItemIdentifier(url)
137
+ links.addArrangedSubview(b)
138
+ }
139
+
140
+ thumb.isHidden = true
141
+ thumb.image = nil
142
+ if let s = imageURL, let u = URL(string: s) {
143
+ // Off the main thread: a slow image must never stall the UI,
144
+ // and the answer is useful without it.
145
+ URLSession.shared.dataTask(with: u) { [weak self] data, _, _ in
146
+ guard let data, let img = NSImage(data: data) else { return }
147
+ Task { @MainActor in
148
+ guard let self, self.source.isEmpty == false else { return }
149
+ self.thumb.image = img
150
+ self.thumb.isHidden = false
151
+ self.measure()
152
+ }
153
+ }.resume()
154
+ }
155
+ resizeSubviews(withOldSize: bounds.size)
156
+ }
157
+
158
+ /// Empty again, once the pill has collapsed.
159
+ @MainActor
160
+ func clear() {
161
+ source = ""
162
+ web.loadHTMLString("", baseURL: nil)
163
+ thumb.isHidden = true
164
+ links.arrangedSubviews.forEach { links.removeArrangedSubview($0); $0.removeFromSuperview() }
165
+ }
166
+
167
+ /// The rendered text's height plus the image and links around it.
168
+ @MainActor
169
+ private func measure() {
170
+ let js = "(() => { const b = document.body, s = getComputedStyle(b);"
171
+ + " return b.scrollHeight + parseFloat(s.marginTop) + parseFloat(s.marginBottom) })()"
172
+ web.evaluateJavaScript(js) { [weak self] value, _ in
173
+ MainActor.assumeIsolated {
174
+ guard let self, !self.source.isEmpty else { return }
175
+ let text = CGFloat((value as? NSNumber)?.doubleValue ?? 120)
176
+ let extra = (self.links.arrangedSubviews.isEmpty ? 0 : Self.linkRow)
177
+ + (self.thumb.isHidden ? 0 : Self.imageHeight + 16)
178
+ self.resizeSubviews(withOldSize: self.bounds.size)
179
+ self.onHeight?(ceil(text + extra))
180
+ }
181
+ }
182
+ }
183
+
184
+ override func mouseEntered(with event: NSEvent) {
185
+ effectiveAppearance.performAsCurrentDrawingAppearance {
186
+ actions.layer?.backgroundColor = NSColor.windowBackgroundColor.cgColor
187
+ actions.layer?.borderColor = NSColor.separatorColor.cgColor
188
+ actions.layer?.borderWidth = 1 / (window?.backingScaleFactor ?? 2)
189
+ }
190
+ actions.isHidden = source.isEmpty
191
+ }
192
+ override func mouseExited(with event: NSEvent) { actions.isHidden = true }
193
+
194
+ @objc private func copyAnswer() {
195
+ NSPasteboard.general.clearContents()
196
+ NSPasteboard.general.setString(source, forType: .string)
197
+ }
198
+
199
+ @objc private func openChat() { onOpenChat?() }
200
+
201
+ /// Select-and-⌘C: the pill has no Edit menu to route the shortcut.
202
+ func copySelection() { NSApp.sendAction(#selector(NSText.copy(_:)), to: web, from: nil) }
203
+
204
+ @objc private func openLink(_ sender: NSButton) {
205
+ guard let s = sender.identifier?.rawValue, let u = URL(string: s) else { return }
206
+ NSWorkspace.shared.open(u)
207
+ }
208
+ }
209
+
210
+ extension AnswerView: WKNavigationDelegate {
211
+ func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
212
+ MainActor.assumeIsolated { if !source.isEmpty { measure() } }
213
+ }
214
+
215
+ /// Links open in the real browser. A pill is not a place to read a web
216
+ /// page, and navigating away would replace the answer it exists to hold.
217
+ func webView(_ webView: WKWebView,
218
+ decidePolicyFor navigationAction: WKNavigationAction,
219
+ decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
220
+ if navigationAction.navigationType == .linkActivated,
221
+ let url = navigationAction.request.url {
222
+ NSWorkspace.shared.open(url)
223
+ decisionHandler(.cancel)
224
+ return
225
+ }
226
+ decisionHandler(.allow)
227
+ }
228
+ }
@@ -0,0 +1,55 @@
1
+ import Foundation
2
+
3
+ /// The daemon's voice history (src/daemon/voice-history-api.ts).
4
+ ///
5
+ /// The list is summaries only; an answer in full is fetched when one is
6
+ /// opened. A replay is queued by the daemon in the agent's voice, at the
7
+ /// back of the speaking queue, so it never talks over what is playing.
8
+ /// Loopback needs no token, as for the rest of AgentClient.
9
+ enum HistoryClient {
10
+ /// One page, newest first; `before` is the previous page's `next`.
11
+ static func page(agent: String?, limit: Int, before: String? = nil) async -> Result<VoiceHistoryPage, HistoryFailure> {
12
+ let q = HistoryLogic.query(agent: agent, limit: limit, before: before)
13
+ guard let url = URL(string: "\(Config.daemonURL)/voice/history?\(q)") else { return .failure(.init(message: "Bad daemon address.")) }
14
+ var req = URLRequest(url: url)
15
+ req.timeoutInterval = 5
16
+ do {
17
+ let (data, response) = try await URLSession.shared.data(for: req)
18
+ let status = (response as? HTTPURLResponse)?.statusCode ?? 0
19
+ if status == 200, let page = try? JSONDecoder().decode(VoiceHistoryPage.self, from: data) { return .success(page) }
20
+ return .failure(.init(message: errorText(data) ?? "The daemon answered \(status)."))
21
+ } catch {
22
+ return .failure(.init(message: "The AgentX daemon isn't reachable."))
23
+ }
24
+ }
25
+
26
+ /// One exchange in full, or nil when it cannot be loaded.
27
+ static func detail(_ id: String) async -> VoiceExchangeDetail? {
28
+ guard let url = URL(string: "\(Config.daemonURL)/voice/history/\(id)") else { return nil }
29
+ var req = URLRequest(url: url)
30
+ req.timeoutInterval = 5
31
+ guard let (data, response) = try? await URLSession.shared.data(for: req),
32
+ (response as? HTTPURLResponse)?.statusCode == 200 else { return nil }
33
+ return try? JSONDecoder().decode(VoiceExchangeDetail.self, from: data)
34
+ }
35
+
36
+ /// Say the answer again. Nil when queued, else why not.
37
+ static func replay(_ id: String) async -> String? {
38
+ guard let url = URL(string: "\(Config.daemonURL)/voice/history/\(id)/replay") else { return "Bad daemon address." }
39
+ var req = URLRequest(url: url)
40
+ req.httpMethod = "POST"
41
+ req.timeoutInterval = 5
42
+ guard let (data, response) = try? await URLSession.shared.data(for: req) else {
43
+ return "The AgentX daemon isn't reachable."
44
+ }
45
+ if (response as? HTTPURLResponse)?.statusCode == 202 { return nil }
46
+ return errorText(data) ?? "The answer wasn't queued."
47
+ }
48
+
49
+ struct HistoryFailure: Error { let message: String }
50
+
51
+ private static func errorText(_ data: Data) -> String? {
52
+ struct Reply: Decodable { let error: String? }
53
+ return (try? JSONDecoder().decode(Reply.self, from: data))?.error
54
+ }
55
+ }
@@ -0,0 +1,153 @@
1
+ import Foundation
2
+
3
+ /// Past voice exchanges as the daemon serves them (GET /voice/history),
4
+ /// and the small decisions the History window and the menu make about
5
+ /// them. Foundation only, so Tests/History can run it without a window.
6
+
7
+ /// One row of the list: a bounded summary, never the whole answer.
8
+ struct VoiceExchange: Decodable, Identifiable, Equatable {
9
+ let id: String
10
+ let agentId: String
11
+ /// When it was asked, ms since the epoch.
12
+ let at: Double
13
+ let durationMs: Int?
14
+ /// "ok", "error", "in-flight", "canceled" or "timeout".
15
+ let status: String
16
+ let question: String
17
+ let answerPreview: String
18
+ let answerChars: Int
19
+ let error: String?
20
+
21
+ var date: Date { Date(timeIntervalSince1970: at / 1000) }
22
+ var failed: Bool { status == "error" || status == "timeout" || status == "canceled" }
23
+ /// Something to say again.
24
+ var canReplay: Bool { answerChars > 0 && !failed }
25
+ }
26
+
27
+ struct VoiceHistoryPage: Decodable {
28
+ let exchanges: [VoiceExchange]
29
+ let next: String?
30
+ }
31
+
32
+ /// One exchange in full (GET /voice/history/:id), loaded when opened.
33
+ struct VoiceExchangeDetail: Decodable {
34
+ struct Button: Decodable, Equatable { let label: String; let url: String }
35
+ struct Media: Decodable, Equatable { let type: String; let url: String; let caption: String? }
36
+ struct UI: Decodable, Equatable { let buttons: [Button]?; let media: Media? }
37
+
38
+ let id: String
39
+ let agentId: String
40
+ let at: Double
41
+ let durationMs: Int?
42
+ let status: String
43
+ let question: String
44
+ let answer: String?
45
+ let truncated: Bool
46
+ let ui: UI?
47
+ let error: String?
48
+ /// The task page, relative to the dashboard's address.
49
+ let taskPath: String
50
+
51
+ /// Buttons and a non-image media link, as (label, url).
52
+ var links: [(String, String)] {
53
+ var out = (ui?.buttons ?? []).map { ($0.label, $0.url) }
54
+ if let m = ui?.media { out.append((m.caption ?? m.type.capitalized, m.url)) }
55
+ return out
56
+ }
57
+ }
58
+
59
+ /// A day's exchanges under one heading.
60
+ struct HistoryDay: Equatable {
61
+ let title: String
62
+ let exchanges: [VoiceExchange]
63
+ }
64
+
65
+ enum HistoryLogic {
66
+ /// Newest day first, each day newest first; "Today", "Yesterday", then
67
+ /// the date. Input order does not matter.
68
+ static func groupByDay(_ items: [VoiceExchange], now: Date = Date(),
69
+ calendar: Calendar = .current, locale: Locale = .current) -> [HistoryDay] {
70
+ let sorted = items.sorted { $0.at != $1.at ? $0.at > $1.at : $0.id > $1.id }
71
+ var days: [HistoryDay] = []
72
+ var current: (Date, [VoiceExchange])?
73
+ for e in sorted {
74
+ let day = calendar.startOfDay(for: e.date)
75
+ if let c = current, c.0 == day {
76
+ current = (day, c.1 + [e])
77
+ } else {
78
+ if let c = current { days.append(HistoryDay(title: dayTitle(c.0, now: now, calendar: calendar, locale: locale), exchanges: c.1)) }
79
+ current = (day, [e])
80
+ }
81
+ }
82
+ if let c = current { days.append(HistoryDay(title: dayTitle(c.0, now: now, calendar: calendar, locale: locale), exchanges: c.1)) }
83
+ return days
84
+ }
85
+
86
+ static func dayTitle(_ day: Date, now: Date, calendar: Calendar = .current, locale: Locale = .current) -> String {
87
+ if calendar.isDate(day, inSameDayAs: now) { return "Today" }
88
+ if let y = calendar.date(byAdding: .day, value: -1, to: now), calendar.isDate(day, inSameDayAs: y) { return "Yesterday" }
89
+ let f = DateFormatter()
90
+ f.locale = locale
91
+ f.calendar = calendar
92
+ f.timeZone = calendar.timeZone
93
+ let sameYear = calendar.component(.year, from: day) == calendar.component(.year, from: now)
94
+ f.setLocalizedDateFormatFromTemplate(sameYear ? "EEEEdMMMM" : "EEEEdMMMMyyyy")
95
+ return f.string(from: day)
96
+ }
97
+
98
+ /// How long the answer took: "0.8 s", "12 s", "2 min 5 s"; "—" unknown.
99
+ static func duration(_ ms: Int?) -> String {
100
+ guard let ms, ms >= 0 else { return "—" }
101
+ if ms < 10_000 { return String(format: "%.1f s", Double(ms) / 1000) }
102
+ let s = Int((Double(ms) / 1000).rounded())
103
+ if s < 60 { return "\(s) s" }
104
+ let m = s / 60, r = s % 60
105
+ return r == 0 ? "\(m) min" : "\(m) min \(r) s"
106
+ }
107
+
108
+ /// A menu row: the question, clipped, and who answered.
109
+ static func menuTitle(_ e: VoiceExchange, agentName: String, max: Int = 44) -> String {
110
+ let q = e.question.split(whereSeparator: \.isWhitespace).joined(separator: " ")
111
+ let clipped = q.count > max ? String(q.prefix(max - 1)).trimmingCharacters(in: .whitespaces) + "…" : q
112
+ return "\u{201C}\(clipped)\u{201D} · \(agentName)"
113
+ }
114
+
115
+ /// Add a page to what is shown: no duplicates, newest first.
116
+ static func merge(_ shown: [VoiceExchange], _ page: [VoiceExchange]) -> [VoiceExchange] {
117
+ var seen = Set(shown.map(\.id))
118
+ var out = shown
119
+ for e in page where seen.insert(e.id).inserted { out.append(e) }
120
+ return out.sorted { $0.at != $1.at ? $0.at > $1.at : $0.id > $1.id }
121
+ }
122
+
123
+ /// Bare http(s) URLs as markdown links, so the written answer shows
124
+ /// them as links. URLs already inside a markdown link or <…> are left.
125
+ static func linkify(_ text: String) -> String {
126
+ guard let re = try? NSRegularExpression(pattern: #"(?<![\(\[<])\bhttps?://[^\s<>()\[\]]+"#) else { return text }
127
+ let ns = text as NSString
128
+ var out = ""
129
+ var last = 0
130
+ for m in re.matches(in: text, range: NSRange(location: 0, length: ns.length)) {
131
+ var url = ns.substring(with: m.range)
132
+ // A sentence's full stop or comma is not part of the link.
133
+ var tail = ""
134
+ while let c = url.last, ".,;:!?\"'".contains(c) { tail = String(c) + tail; url.removeLast() }
135
+ out += ns.substring(with: NSRange(location: last, length: m.range.location - last))
136
+ out += "[\(url)](\(url))"
137
+ out += tail
138
+ last = m.range.location + m.range.length
139
+ }
140
+ out += ns.substring(from: last)
141
+ return out
142
+ }
143
+
144
+ /// The query string for a page of history.
145
+ static func query(agent: String?, limit: Int, before: String?) -> String {
146
+ var items = [URLQueryItem(name: "limit", value: String(limit))]
147
+ if let agent, !agent.isEmpty { items.append(URLQueryItem(name: "agent", value: agent)) }
148
+ if let before, !before.isEmpty { items.append(URLQueryItem(name: "before", value: before)) }
149
+ var c = URLComponents()
150
+ c.queryItems = items
151
+ return c.percentEncodedQuery ?? ""
152
+ }
153
+ }