agentix-cli 0.58.0 → 0.59.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 (118) hide show
  1. package/apps/mac-voice/Sources/AgentXVoice/HistoryClient.swift +55 -0
  2. package/apps/mac-voice/Sources/AgentXVoice/HistoryModel.swift +153 -0
  3. package/apps/mac-voice/Sources/AgentXVoice/HistoryWindow.swift +301 -0
  4. package/apps/mac-voice/Sources/AgentXVoice/LocalSTT.swift +79 -0
  5. package/apps/mac-voice/Sources/AgentXVoice/ModelStore.swift +184 -0
  6. package/apps/mac-voice/Sources/AgentXVoice/Parakeet.swift +264 -0
  7. package/apps/mac-voice/Sources/AgentXVoice/Recorder.swift +81 -3
  8. package/apps/mac-voice/Sources/AgentXVoice/SettingsWindow.swift +10 -2
  9. package/apps/mac-voice/Sources/AgentXVoice/SileroVAD.swift +92 -0
  10. package/apps/mac-voice/Sources/AgentXVoice/Speech.swift +7 -5
  11. package/apps/mac-voice/Sources/AgentXVoice/StatusMenu.swift +5 -1
  12. package/apps/mac-voice/Sources/AgentXVoice/TurnEnd.swift +110 -0
  13. package/apps/mac-voice/Sources/AgentXVoice/VoiceSettings.swift +23 -0
  14. package/apps/mac-voice/Sources/AgentXVoice/main.swift +12 -26
  15. package/apps/mac-voice/build.sh +10 -1
  16. package/dist/{App-Z5VOAUXB.js → App-QEJGXZTY.js} +2 -2
  17. package/dist/{ChatApp-BE7ONQKS.js → ChatApp-VKWQN5GM.js} +2 -2
  18. package/dist/{agent-Q74EISKP.js → agent-MJKLI2L5.js} +2 -2
  19. package/dist/{board-dashboard-A7PQOI6C.js → board-dashboard-2KVT72J7.js} +221 -138
  20. package/dist/board-dashboard-2KVT72J7.js.map +1 -0
  21. package/dist/{chunk-I2LYULR4.js → chunk-2XZGHIVP.js} +5 -5
  22. package/dist/{chunk-5LLGIX5M.js → chunk-3EVCHRJF.js} +2 -2
  23. package/dist/{chunk-6QNPCVRN.js → chunk-4ZTV6JV7.js} +3 -3
  24. package/dist/chunk-4ZTV6JV7.js.map +1 -0
  25. package/dist/{chunk-VI7GBDKV.js → chunk-5OF3EGCZ.js} +2 -2
  26. package/dist/{chunk-RXEAACVH.js → chunk-6DUTIPTY.js} +2 -2
  27. package/dist/{chunk-CQUVUVXR.js → chunk-6ZV2QFXE.js} +2 -2
  28. package/dist/{chunk-2MIOMYT2.js → chunk-DAAWUFO4.js} +2 -2
  29. package/dist/{chunk-547G3HYQ.js → chunk-EJQOKSKP.js} +2 -2
  30. package/dist/{chunk-POL7RAJQ.js → chunk-ILXW3ERG.js} +117 -117
  31. package/dist/chunk-ILXW3ERG.js.map +1 -0
  32. package/dist/{chunk-ERL6AS3O.js → chunk-IW4XIZAZ.js} +8 -8
  33. package/dist/{chunk-ERL6AS3O.js.map → chunk-IW4XIZAZ.js.map} +1 -1
  34. package/dist/{chunk-Q6SMLSRO.js → chunk-JUY77L7C.js} +2 -2
  35. package/dist/{chunk-KW7TIOS3.js → chunk-K62U5EJJ.js} +2 -2
  36. package/dist/{chunk-KKGFZEXS.js → chunk-L5OCF33B.js} +37 -36
  37. package/dist/chunk-L5OCF33B.js.map +1 -0
  38. package/dist/{chunk-XTD3SZIP.js → chunk-N5GOBLUI.js} +2 -2
  39. package/dist/{chunk-VVBGPT6W.js → chunk-OIDMPPV5.js} +2 -2
  40. package/dist/{chunk-OZZPJ2YG.js → chunk-SDBU6747.js} +190 -177
  41. package/dist/chunk-SDBU6747.js.map +1 -0
  42. package/dist/{chunk-NKMFHEGV.js → chunk-WFPRVZBA.js} +2 -2
  43. package/dist/{chunk-6OABTCRA.js → chunk-WI5R64WO.js} +2 -2
  44. package/dist/{chunk-5W7NJHCS.js → chunk-YCIZ2WHX.js} +2 -2
  45. package/dist/{chunk-U5YBQJ5Q.js → chunk-YQU74AOL.js} +2 -2
  46. package/dist/{chunk-V7ZK6ZIO.js → chunk-YRB75GMN.js} +2 -2
  47. package/dist/cli.js +1 -1
  48. package/dist/client-X2PTYKVX.js +2 -0
  49. package/dist/{config-P6YXC6XZ.js → config-XB2ORKWS.js} +2 -2
  50. package/dist/config-mutator-KMXR3XDG.js +2 -0
  51. package/dist/doctor-6BIMNEGU.js +2 -0
  52. package/dist/index.d.ts +17 -0
  53. package/dist/index.js +1 -1
  54. package/dist/program-DHDC6XYB.js +2 -0
  55. package/dist/{promote-KVPLDSKT.js → promote-GBR6YSQW.js} +2 -2
  56. package/dist/query-672RGMXO.js +2 -0
  57. package/dist/{registry-AUOI4TW2.js → registry-P535FNLB.js} +2 -2
  58. package/dist/{request-planner-LMZ5ZAXC.js → request-planner-KHTCHF7N.js} +2 -2
  59. package/dist/{review-candidates-WCBXLLDS.js → review-candidates-Y5KCRUNU.js} +2 -2
  60. package/dist/{routing-FBASYKUL.js → routing-RWCCDJ4D.js} +2 -2
  61. package/dist/schedule-tool-H46AK53H.js +2 -0
  62. package/dist/scheduler-3XLWBCX7.js +2 -0
  63. package/dist/{seat-DBFW3Z5P.js → seat-PJPKLH6L.js} +2 -2
  64. package/dist/{select-6CNVVFA5.js → select-VMTJAX5X.js} +2 -2
  65. package/dist/{set-enabled-AYZH3KSB.js → set-enabled-LPXH3ZQP.js} +2 -2
  66. package/dist/{tool-PKS4NBZ6.js → tool-MWXG44OT.js} +2 -2
  67. package/dist/wiki-QHMHVFWU.js +2 -0
  68. package/package.json +1 -1
  69. package/dist/board-dashboard-A7PQOI6C.js.map +0 -1
  70. package/dist/chunk-6QNPCVRN.js.map +0 -1
  71. package/dist/chunk-KKGFZEXS.js.map +0 -1
  72. package/dist/chunk-OZZPJ2YG.js.map +0 -1
  73. package/dist/chunk-POL7RAJQ.js.map +0 -1
  74. package/dist/client-EFP2PKA3.js +0 -2
  75. package/dist/config-mutator-3GKXLTHH.js +0 -2
  76. package/dist/doctor-JEWMHXYH.js +0 -2
  77. package/dist/program-7DUQJDSQ.js +0 -2
  78. package/dist/query-AGOMCYTT.js +0 -2
  79. package/dist/schedule-tool-RJRJ63SS.js +0 -2
  80. package/dist/scheduler-ATA7Z44S.js +0 -2
  81. package/dist/wiki-QW6BDNEQ.js +0 -2
  82. /package/dist/{App-Z5VOAUXB.js.map → App-QEJGXZTY.js.map} +0 -0
  83. /package/dist/{ChatApp-BE7ONQKS.js.map → ChatApp-VKWQN5GM.js.map} +0 -0
  84. /package/dist/{agent-Q74EISKP.js.map → agent-MJKLI2L5.js.map} +0 -0
  85. /package/dist/{chunk-I2LYULR4.js.map → chunk-2XZGHIVP.js.map} +0 -0
  86. /package/dist/{chunk-5LLGIX5M.js.map → chunk-3EVCHRJF.js.map} +0 -0
  87. /package/dist/{chunk-VI7GBDKV.js.map → chunk-5OF3EGCZ.js.map} +0 -0
  88. /package/dist/{chunk-RXEAACVH.js.map → chunk-6DUTIPTY.js.map} +0 -0
  89. /package/dist/{chunk-CQUVUVXR.js.map → chunk-6ZV2QFXE.js.map} +0 -0
  90. /package/dist/{chunk-2MIOMYT2.js.map → chunk-DAAWUFO4.js.map} +0 -0
  91. /package/dist/{chunk-547G3HYQ.js.map → chunk-EJQOKSKP.js.map} +0 -0
  92. /package/dist/{chunk-Q6SMLSRO.js.map → chunk-JUY77L7C.js.map} +0 -0
  93. /package/dist/{chunk-KW7TIOS3.js.map → chunk-K62U5EJJ.js.map} +0 -0
  94. /package/dist/{chunk-XTD3SZIP.js.map → chunk-N5GOBLUI.js.map} +0 -0
  95. /package/dist/{chunk-VVBGPT6W.js.map → chunk-OIDMPPV5.js.map} +0 -0
  96. /package/dist/{chunk-NKMFHEGV.js.map → chunk-WFPRVZBA.js.map} +0 -0
  97. /package/dist/{chunk-6OABTCRA.js.map → chunk-WI5R64WO.js.map} +0 -0
  98. /package/dist/{chunk-5W7NJHCS.js.map → chunk-YCIZ2WHX.js.map} +0 -0
  99. /package/dist/{chunk-U5YBQJ5Q.js.map → chunk-YQU74AOL.js.map} +0 -0
  100. /package/dist/{chunk-V7ZK6ZIO.js.map → chunk-YRB75GMN.js.map} +0 -0
  101. /package/dist/{client-EFP2PKA3.js.map → client-X2PTYKVX.js.map} +0 -0
  102. /package/dist/{config-P6YXC6XZ.js.map → config-XB2ORKWS.js.map} +0 -0
  103. /package/dist/{config-mutator-3GKXLTHH.js.map → config-mutator-KMXR3XDG.js.map} +0 -0
  104. /package/dist/{doctor-JEWMHXYH.js.map → doctor-6BIMNEGU.js.map} +0 -0
  105. /package/dist/{program-7DUQJDSQ.js.map → program-DHDC6XYB.js.map} +0 -0
  106. /package/dist/{promote-KVPLDSKT.js.map → promote-GBR6YSQW.js.map} +0 -0
  107. /package/dist/{query-AGOMCYTT.js.map → query-672RGMXO.js.map} +0 -0
  108. /package/dist/{registry-AUOI4TW2.js.map → registry-P535FNLB.js.map} +0 -0
  109. /package/dist/{request-planner-LMZ5ZAXC.js.map → request-planner-KHTCHF7N.js.map} +0 -0
  110. /package/dist/{review-candidates-WCBXLLDS.js.map → review-candidates-Y5KCRUNU.js.map} +0 -0
  111. /package/dist/{routing-FBASYKUL.js.map → routing-RWCCDJ4D.js.map} +0 -0
  112. /package/dist/{schedule-tool-RJRJ63SS.js.map → schedule-tool-H46AK53H.js.map} +0 -0
  113. /package/dist/{scheduler-ATA7Z44S.js.map → scheduler-3XLWBCX7.js.map} +0 -0
  114. /package/dist/{seat-DBFW3Z5P.js.map → seat-PJPKLH6L.js.map} +0 -0
  115. /package/dist/{select-6CNVVFA5.js.map → select-VMTJAX5X.js.map} +0 -0
  116. /package/dist/{set-enabled-AYZH3KSB.js.map → set-enabled-LPXH3ZQP.js.map} +0 -0
  117. /package/dist/{tool-PKS4NBZ6.js.map → tool-MWXG44OT.js.map} +0 -0
  118. /package/dist/{wiki-QW6BDNEQ.js.map → wiki-QHMHVFWU.js.map} +0 -0
@@ -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
+ }
@@ -0,0 +1,301 @@
1
+ import AppKit
2
+ import SwiftUI
3
+
4
+ /// History: past voice exchanges by day, filterable by agent.
5
+ ///
6
+ /// The daemon keeps the records (every /ask turn is a task trace on the
7
+ /// voice channel); the window reads them and keeps no copy. The list holds
8
+ /// summaries only, and an answer in full is fetched when it is selected.
9
+ /// Replay goes through the daemon's speaking queue, like any answer.
10
+ @MainActor
11
+ final class HistoryStore: ObservableObject {
12
+ static let pageSize = 30
13
+
14
+ @Published var exchanges: [VoiceExchange] = []
15
+ @Published var next: String?
16
+ /// nil shows every agent.
17
+ @Published var agent: String? { didSet { if agent != oldValue { reload() } } }
18
+ @Published var agents: [AgentClient.AgentInfo] = []
19
+ @Published var selected: String? { didSet { if selected != oldValue { loadDetail() } } }
20
+ @Published var detail: VoiceExchangeDetail?
21
+ @Published var loading = false
22
+ @Published var error = ""
23
+ @Published var notice = ""
24
+
25
+ /// Swapped for fixed data when rendering screenshots.
26
+ var fetchPage: (String?, Int, String?) async -> Result<VoiceHistoryPage, HistoryClient.HistoryFailure> = HistoryClient.page
27
+ var fetchDetail: (String) async -> VoiceExchangeDetail? = HistoryClient.detail
28
+ var fetchAgents: () async -> [AgentClient.AgentInfo]? = AgentClient.agents
29
+
30
+ var days: [HistoryDay] { HistoryLogic.groupByDay(exchanges) }
31
+
32
+ func name(of id: String) -> String { agents.first { $0.id == id }?.label ?? id }
33
+
34
+ func color(of id: String) -> Color {
35
+ Color(hex: OrbMath.colorHex(agentID: id, configured: agents.first { $0.id == id }?.color))
36
+ }
37
+
38
+ func reload() {
39
+ loading = true
40
+ error = ""
41
+ Task {
42
+ if agents.isEmpty, let a = await fetchAgents() { agents = a }
43
+ let result = await fetchPage(agent, Self.pageSize, nil)
44
+ loading = false
45
+ switch result {
46
+ case .success(let page):
47
+ exchanges = page.exchanges
48
+ next = page.next
49
+ if selected == nil || !exchanges.contains(where: { $0.id == selected }) { selected = exchanges.first?.id }
50
+ case .failure(let f):
51
+ exchanges = []
52
+ next = nil
53
+ error = f.message
54
+ }
55
+ }
56
+ }
57
+
58
+ func loadOlder() {
59
+ guard let before = next, !loading else { return }
60
+ loading = true
61
+ Task {
62
+ let result = await fetchPage(agent, Self.pageSize, before)
63
+ loading = false
64
+ switch result {
65
+ case .success(let page):
66
+ exchanges = HistoryLogic.merge(exchanges, page.exchanges)
67
+ next = page.next
68
+ case .failure(let f):
69
+ error = f.message
70
+ }
71
+ }
72
+ }
73
+
74
+ private func loadDetail() {
75
+ detail = nil
76
+ guard let id = selected else { return }
77
+ Task {
78
+ let d = await fetchDetail(id)
79
+ if selected == id { detail = d }
80
+ }
81
+ }
82
+
83
+ func replay(_ id: String) {
84
+ notice = ""
85
+ error = ""
86
+ Task {
87
+ if let problem = await HistoryClient.replay(id) { error = problem }
88
+ else { notice = "Queued. It plays after anything already speaking." }
89
+ }
90
+ }
91
+
92
+ func copy(_ d: VoiceExchangeDetail) {
93
+ NSPasteboard.general.clearContents()
94
+ NSPasteboard.general.setString(d.answer ?? "", forType: .string)
95
+ notice = "Answer copied."
96
+ }
97
+
98
+ func openTask(_ d: VoiceExchangeDetail) {
99
+ guard let url = URL(string: Config.dashboardURL + d.taskPath) else { return }
100
+ NSWorkspace.shared.open(url)
101
+ }
102
+ }
103
+
104
+ struct HistoryView: View {
105
+ @ObservedObject var store: HistoryStore
106
+
107
+ var body: some View {
108
+ VStack(spacing: 0) {
109
+ toolbar
110
+ Divider()
111
+ HStack(spacing: 0) {
112
+ list.frame(width: 320)
113
+ Divider()
114
+ detailPane.frame(maxWidth: .infinity, maxHeight: .infinity)
115
+ }
116
+ if !store.error.isEmpty || !store.notice.isEmpty { footer }
117
+ }
118
+ .frame(minWidth: 760, minHeight: 480)
119
+ .background(Color(nsColor: .windowBackgroundColor))
120
+ }
121
+
122
+ private var toolbar: some View {
123
+ HStack(spacing: 10) {
124
+ Picker("Agent", selection: $store.agent) {
125
+ Text("All agents").tag(String?.none)
126
+ ForEach(store.agents, id: \.id) { a in Text(a.label).tag(String?.some(a.id)) }
127
+ }
128
+ .frame(width: 220)
129
+ Spacer()
130
+ if store.loading { ProgressView().controlSize(.small) }
131
+ Button("Refresh") { store.reload() }
132
+ .keyboardShortcut("r", modifiers: .command)
133
+ }
134
+ .padding(10)
135
+ }
136
+
137
+ @ViewBuilder private var list: some View {
138
+ if store.exchanges.isEmpty {
139
+ Text(store.loading ? "Loading…" : store.error.isEmpty ? "No voice exchanges yet." : "Nothing to show.")
140
+ .foregroundStyle(.secondary)
141
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
142
+ } else {
143
+ List(selection: $store.selected) {
144
+ ForEach(store.days, id: \.title) { day in
145
+ Section(day.title) {
146
+ ForEach(day.exchanges) { e in row(e).tag(e.id) }
147
+ }
148
+ }
149
+ if store.next != nil {
150
+ Button("Load older") { store.loadOlder() }
151
+ .frame(maxWidth: .infinity)
152
+ }
153
+ }
154
+ .listStyle(.inset)
155
+ }
156
+ }
157
+
158
+ private func row(_ e: VoiceExchange) -> some View {
159
+ VStack(alignment: .leading, spacing: 3) {
160
+ HStack(spacing: 6) {
161
+ Circle().fill(store.color(of: e.agentId)).frame(width: 8, height: 8)
162
+ Text(store.name(of: e.agentId)).font(.caption.weight(.semibold))
163
+ Spacer()
164
+ Text(e.date, style: .time).font(.caption).foregroundStyle(.secondary)
165
+ Text(HistoryLogic.duration(e.durationMs)).font(.caption.monospacedDigit()).foregroundStyle(.secondary)
166
+ }
167
+ Text(e.question).fontWeight(.medium).lineLimit(2)
168
+ Text(e.failed ? (e.error ?? "No answer") : e.answerPreview)
169
+ .font(.callout)
170
+ .foregroundStyle(e.failed ? Color(nsColor: Brand.alert) : .secondary)
171
+ .lineLimit(2)
172
+ }
173
+ .padding(.vertical, 3)
174
+ .accessibilityElement(children: .combine)
175
+ }
176
+
177
+ @ViewBuilder private var detailPane: some View {
178
+ if let d = store.detail {
179
+ ScrollView {
180
+ VStack(alignment: .leading, spacing: 14) {
181
+ HStack(spacing: 6) {
182
+ Circle().fill(store.color(of: d.agentId)).frame(width: 10, height: 10)
183
+ Text(store.name(of: d.agentId)).font(.headline)
184
+ Spacer()
185
+ Text("Answered in \(HistoryLogic.duration(d.durationMs))")
186
+ .font(.caption).foregroundStyle(.secondary)
187
+ }
188
+ VStack(alignment: .leading, spacing: 4) {
189
+ Text("YOU SAID").font(.caption2.monospaced()).tracking(0.8).foregroundStyle(.secondary)
190
+ Text(d.question).textSelection(.enabled)
191
+ }
192
+ VStack(alignment: .leading, spacing: 4) {
193
+ Text("ANSWER").font(.caption2.monospaced()).tracking(0.8).foregroundStyle(.secondary)
194
+ if let answer = d.answer, !answer.isEmpty {
195
+ Text(written(answer)).textSelection(.enabled)
196
+ if d.truncated { Text("The answer is longer; open the task for all of it.").font(.caption).foregroundStyle(.secondary) }
197
+ } else {
198
+ Text(d.error ?? "No answer was recorded.").foregroundStyle(Color(nsColor: Brand.alert))
199
+ }
200
+ }
201
+ if !d.links.isEmpty {
202
+ HStack(spacing: 8) {
203
+ ForEach(d.links, id: \.1) { link in
204
+ if let url = URL(string: link.1) { Link(link.0, destination: url) }
205
+ }
206
+ }
207
+ }
208
+ Divider()
209
+ HStack(spacing: 8) {
210
+ Button("Replay") { store.replay(d.id) }
211
+ .disabled(!(d.answer?.isEmpty == false))
212
+ .keyboardShortcut(.return, modifiers: .command)
213
+ Button("Copy") { store.copy(d) }
214
+ .disabled(!(d.answer?.isEmpty == false))
215
+ .keyboardShortcut("c", modifiers: [.command, .shift])
216
+ Button("Open task in dashboard") { store.openTask(d) }
217
+ }
218
+ }
219
+ .padding(16)
220
+ .frame(maxWidth: .infinity, alignment: .leading)
221
+ }
222
+ } else {
223
+ Text(store.selected == nil ? "Select an exchange." : "Loading…")
224
+ .foregroundStyle(.secondary)
225
+ }
226
+ }
227
+
228
+ /// The answer as written: markdown links and bare URLs as links.
229
+ private func written(_ answer: String) -> AttributedString {
230
+ let md = HistoryLogic.linkify(answer)
231
+ return (try? AttributedString(markdown: md, options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace)))
232
+ ?? AttributedString(answer)
233
+ }
234
+
235
+ private var footer: some View {
236
+ HStack {
237
+ Text(store.error.isEmpty ? store.notice : store.error)
238
+ .foregroundStyle(store.error.isEmpty ? Color.secondary : Color(nsColor: .systemRed))
239
+ .font(.callout)
240
+ Spacer()
241
+ }
242
+ .padding(10)
243
+ }
244
+ }
245
+
246
+ @MainActor
247
+ final class HistoryWindow: NSObject {
248
+ static let shared = HistoryWindow()
249
+ let store = HistoryStore()
250
+ private var window: NSWindow?
251
+
252
+ func show() {
253
+ if window == nil {
254
+ let w = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 860, height: 560),
255
+ styleMask: [.titled, .closable, .miniaturizable, .resizable],
256
+ backing: .buffered, defer: false)
257
+ w.title = "AgentX Voice History"
258
+ w.isReleasedWhenClosed = false
259
+ w.contentView = NSHostingView(rootView: HistoryView(store: store))
260
+ w.center()
261
+ window = w
262
+ }
263
+ store.agents = []
264
+ store.reload()
265
+ NSApp.activate(ignoringOtherApps: true)
266
+ window?.makeKeyAndOrderFront(nil)
267
+ }
268
+ }
269
+
270
+ /// The menu's last three exchanges, for a quick replay.
271
+ @MainActor
272
+ final class RecentExchanges: NSObject {
273
+ private(set) var items: [VoiceExchange] = []
274
+
275
+ func refresh() async {
276
+ if case .success(let page) = await HistoryClient.page(agent: nil, limit: 3) { items = page.exchanges }
277
+ }
278
+
279
+ /// A "Recent" section, or nothing when there is no history.
280
+ func addItems(to menu: NSMenu, name: (String) -> String) {
281
+ guard !items.isEmpty else { return }
282
+ menu.addItem(.separator())
283
+ menu.addItem(NSMenuItem.sectionHeader(title: "Recent · click to replay"))
284
+ for e in items {
285
+ let row = NSMenuItem(title: HistoryLogic.menuTitle(e, agentName: name(e.agentId)),
286
+ action: #selector(replay(_:)), keyEquivalent: "")
287
+ row.target = self
288
+ row.representedObject = e.id
289
+ row.isEnabled = e.canReplay
290
+ row.toolTip = e.answerPreview.isEmpty ? nil : e.answerPreview
291
+ menu.addItem(row)
292
+ }
293
+ }
294
+
295
+ @objc private func replay(_ sender: NSMenuItem) {
296
+ guard let id = sender.representedObject as? String else { return }
297
+ Task {
298
+ if let problem = await HistoryClient.replay(id) { Log.warn("history replay: \(problem)") }
299
+ }
300
+ }
301
+ }
@@ -0,0 +1,79 @@
1
+ import Foundation
2
+
3
+ /// Parakeet as the app uses it: never makes a turn wait for a download or
4
+ /// for the first model load, and answers nil whenever mlx-whisper should
5
+ /// take this turn instead.
6
+ ///
7
+ /// The first load of Parakeet on a Mac takes about half a minute (Core ML
8
+ /// prepares the encoder for the Neural Engine and caches the result);
9
+ /// later loads take about half a second. A turn waits a few seconds for
10
+ /// the load at most, then falls back while the load carries on.
11
+ enum LocalSTT {
12
+ /// How long a turn waits for Parakeet to finish loading.
13
+ static let loadPatience: TimeInterval = 4
14
+
15
+ private static let loader = ParakeetLoader()
16
+
17
+ static func parakeet(wav: Data) async -> String? {
18
+ guard ModelStore.parakeet.isInstalled else {
19
+ Log.warn("Parakeet is not downloaded yet; using mlx-whisper for now")
20
+ ModelStore.installInBackground(ModelStore.parakeet) { Log.info($0) }
21
+ return nil
22
+ }
23
+ guard let engine = await loader.engine(within: loadPatience) else {
24
+ Log.warn("Parakeet is still loading (the first load takes about 30 s); using mlx-whisper for this turn")
25
+ return nil
26
+ }
27
+ return await Task.detached(priority: .userInitiated) { () -> String? in
28
+ do {
29
+ let t = Date()
30
+ let text = try engine.transcribe(wav: wav)
31
+ Log.info(String(format: "parakeet: %.2f s", Date().timeIntervalSince(t)))
32
+ return text
33
+ } catch {
34
+ Log.warn("Parakeet failed (\(error.localizedDescription)); falling back to mlx-whisper")
35
+ return nil
36
+ }
37
+ }.value
38
+ }
39
+ }
40
+
41
+ /// Loads Parakeet once, in the background, and lets callers wait for it
42
+ /// with a limit.
43
+ private actor ParakeetLoader {
44
+ private var loading: Task<Parakeet?, Never>?
45
+
46
+ func engine(within seconds: TimeInterval) async -> Parakeet? {
47
+ let task = loading ?? start()
48
+ return await withTaskGroup(of: Parakeet?.self) { group in
49
+ group.addTask { await task.value }
50
+ group.addTask {
51
+ try? await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
52
+ return nil
53
+ }
54
+ let first = await group.next() ?? nil
55
+ group.cancelAll()
56
+ return first
57
+ }
58
+ }
59
+
60
+ private func start() -> Task<Parakeet?, Never> {
61
+ let task = Task.detached(priority: .userInitiated) { () -> Parakeet? in
62
+ let t = Date()
63
+ do {
64
+ let p = try Parakeet.loaded()
65
+ Log.info(String(format: "parakeet: loaded in %.1f s", Date().timeIntervalSince(t)))
66
+ return p
67
+ } catch {
68
+ Log.warn("Parakeet would not load (\(error.localizedDescription))")
69
+ return nil
70
+ }
71
+ }
72
+ loading = task
73
+ Task { if await task.value == nil { self.forget() } }
74
+ return task
75
+ }
76
+
77
+ /// A failed load is tried again on the next turn.
78
+ private func forget() { loading = nil }
79
+ }