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
@@ -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
+ }
@@ -0,0 +1,184 @@
1
+ import CryptoKit
2
+ import Foundation
3
+
4
+ /// On-device models, fetched on first use into ~/.agentx/models and never
5
+ /// shipped with the app or the repository.
6
+ ///
7
+ /// Every file is pinned twice: by the Hugging Face revision it comes from
8
+ /// and by its SHA-256. A file that does not match is deleted, so a
9
+ /// truncated download or a changed upstream repo can never be loaded. A
10
+ /// model counts as installed only when a `.verified` stamp sits next to
11
+ /// it, written after every file checked out, so a half-finished download
12
+ /// is simply "not installed" and the next attempt starts over.
13
+ struct LocalModel: Sendable {
14
+ struct File: Sendable {
15
+ let path: String
16
+ let size: Int64
17
+ let sha256: String
18
+ }
19
+
20
+ /// Short name for the command line and the logs: "vad", "parakeet".
21
+ let name: String
22
+ let repo: String
23
+ let revision: String
24
+ let files: [File]
25
+
26
+ var totalBytes: Int64 { files.reduce(0) { $0 + $1.size } }
27
+
28
+ /// ~/.agentx/models/<repo name>/
29
+ var directory: URL {
30
+ ModelStore.root.appendingPathComponent(repo.split(separator: "/").last.map(String.init) ?? repo)
31
+ }
32
+
33
+ func url(_ path: String) -> URL { directory.appendingPathComponent(path) }
34
+
35
+ private var stamp: URL { directory.appendingPathComponent(".verified") }
36
+
37
+ /// Every file is present and was verified for this revision.
38
+ var isInstalled: Bool {
39
+ guard let s = try? String(contentsOf: stamp, encoding: .utf8) else { return false }
40
+ return s.trimmingCharacters(in: .whitespacesAndNewlines) == revision
41
+ && files.allSatisfy { FileManager.default.fileExists(atPath: url($0.path).path) }
42
+ }
43
+
44
+ fileprivate func markInstalled() throws {
45
+ try revision.write(to: stamp, atomically: true, encoding: .utf8)
46
+ }
47
+ }
48
+
49
+ enum ModelStore {
50
+ static let root: URL = {
51
+ if let dir = ProcessInfo.processInfo.environment["AGENTX_MODELS_DIR"], !dir.isEmpty {
52
+ return URL(fileURLWithPath: dir)
53
+ }
54
+ return URL(fileURLWithPath: NSHomeDirectory()).appendingPathComponent(".agentx/models")
55
+ }()
56
+
57
+ /// Silero VAD v6, converted to Core ML by FluidInference (MIT). 0.9 MB.
58
+ /// One call scores 32 ms of 16 kHz audio (512 samples plus 64 of context).
59
+ static let vad = LocalModel(
60
+ name: "vad",
61
+ repo: "FluidInference/silero-vad-coreml",
62
+ revision: "b419383c55c110e2c9271fa6ee0ea83d03c70d96",
63
+ files: [
64
+ .init(path: "silero-vad-unified-v6.0.0.mlmodelc/analytics/coremldata.bin", size: 243, sha256: "2141be60ea0adf7acb1232fbcfaffb2be308ae02e6672d3762aedf36611ea9fd"),
65
+ .init(path: "silero-vad-unified-v6.0.0.mlmodelc/coremldata.bin", size: 593, sha256: "f460dcdf796b19c04bc38ab6e69601831f634e5e74d499487f0c8fe17ca12f0f"),
66
+ .init(path: "silero-vad-unified-v6.0.0.mlmodelc/metadata.json", size: 3232, sha256: "282b1b788b5d4d66d6a70d468464a7ada71d2469a8705526b741fd9ab841295a"),
67
+ .init(path: "silero-vad-unified-v6.0.0.mlmodelc/model.mil", size: 25126, sha256: "79eea23c368f3e7edf85af798a953f5b0910e4b08ff48e55fff8545dd05fd047"),
68
+ .init(path: "silero-vad-unified-v6.0.0.mlmodelc/weights/weight.bin", size: 882304, sha256: "853cf34740d3f5061f977ebe2976f7c921b064261c9c4753b3a1196f2dba42b4"),
69
+ ])
70
+
71
+ /// NVIDIA Parakeet TDT 0.6B v3 (CC-BY-4.0), converted to Core ML by
72
+ /// FluidInference. 483 MB, almost all of it the encoder. The same build
73
+ /// FluidAudio loads by default: 6-bit palettized encoder, 15 s window.
74
+ static let parakeet = LocalModel(
75
+ name: "parakeet",
76
+ repo: "FluidInference/parakeet-tdt-0.6b-v3-coreml",
77
+ revision: "7dd20fe6b1797d35f5e3307e8b1732d9a178edfe",
78
+ files: [
79
+ .init(path: "Preprocessor.mlmodelc/analytics/coremldata.bin", size: 243, sha256: "c9beeb989c8d66f8be11df59bc6df277ec76cee404f6865b46243835ef562f6d"),
80
+ .init(path: "Preprocessor.mlmodelc/coremldata.bin", size: 486, sha256: "dbde3f2300842c1fd51ef3ff948a0bcffe65ffd2dca10707f2509f32c1d65b1d"),
81
+ .init(path: "Preprocessor.mlmodelc/metadata.json", size: 2841, sha256: "2a98699e22d279dd37fa1d238aeb1c6db1df0d6fad687775324157689d8f3acf"),
82
+ .init(path: "Preprocessor.mlmodelc/model.mil", size: 28181, sha256: "4b8518a956450fec57f06c2a21bdffc26973f7f1fa6842fb38fe917f896b6b93"),
83
+ .init(path: "Preprocessor.mlmodelc/weights/weight.bin", size: 491072, sha256: "129b76e3aeafa8afa3ea76d995b964b145fe83700d579f6ff42c4c38fa0968ea"),
84
+ .init(path: "Encoder.mlmodelc/analytics/coremldata.bin", size: 243, sha256: "42e638870d73f26b332918a3496ce36793fbb413a81cbd3d16ba01328637a105"),
85
+ .init(path: "Encoder.mlmodelc/coremldata.bin", size: 485, sha256: "d48034a167a82e88fc3df64f60af963ab3983538271175b8319e7d5720a0fb86"),
86
+ .init(path: "Encoder.mlmodelc/metadata.json", size: 2921, sha256: "da24da9cca943fb29d7fa8e376d57fca7cb3aa08ca51b956b0b0e56813f087e9"),
87
+ .init(path: "Encoder.mlmodelc/model.mil", size: 959769, sha256: "ed7b19156ca29fa7dfd6891deb9fda4b0e8893f68597c985d135736546a43808"),
88
+ .init(path: "Encoder.mlmodelc/weights/weight.bin", size: 445187200, sha256: "e2020f323703477a5b21d7c2d282c403e371afb5962e79877e3033e73ba6f421"),
89
+ .init(path: "Decoder.mlmodelc/analytics/coremldata.bin", size: 243, sha256: "4238c4e81ecd0dc94bd7dfbb60f7e2cc824107c1ffe0387b8607b72833dba350"),
90
+ .init(path: "Decoder.mlmodelc/coremldata.bin", size: 554, sha256: "18647af085d87bd8f3121c8a9b4d4564c1ede038dab63d295b4e745cf2d7fb99"),
91
+ .init(path: "Decoder.mlmodelc/metadata.json", size: 3427, sha256: "a39e93cd8371b8ded92635c7804fcd0590f0d1dd9415c6d19a0484be073077d9"),
92
+ .init(path: "Decoder.mlmodelc/model.mil", size: 13110, sha256: "ef2a0a281695398a62fde86ac269c68f73d5b578d7ed3b31f2ba91a2d1ea1f35"),
93
+ .init(path: "Decoder.mlmodelc/weights/weight.bin", size: 23604992, sha256: "48adf0f0d47c406c8253d4f7fef967436a39da14f5a65e66d5a4b407be355d41"),
94
+ .init(path: "JointDecisionv3.mlmodelc/analytics/coremldata.bin", size: 243, sha256: "26def4bf73dd56d29dee21c8ef97cb8969e62f6120ed1adc91e46828e2737b6c"),
95
+ .init(path: "JointDecisionv3.mlmodelc/coremldata.bin", size: 521, sha256: "f5fc08b741400f0088492c9e839418b1e18522f19cba28d361dd030c5f398342"),
96
+ .init(path: "JointDecisionv3.mlmodelc/metadata.json", size: 3453, sha256: "d9307211b9a37e0f0ac260c7660b1571a3de25841035cfdf9b58fd40425f890f"),
97
+ .init(path: "JointDecisionv3.mlmodelc/model.mil", size: 11775, sha256: "be60732943389a047175111a83f8839f3eb39d4803adafa828a0871b2f39818d"),
98
+ .init(path: "JointDecisionv3.mlmodelc/weights/weight.bin", size: 12642764, sha256: "4e0e63d840032f7f07ddb1d64446051166281e5491bf22da8a945c41f6eedb3e"),
99
+ .init(path: "parakeet_vocab.json", size: 151122, sha256: "7ec60e05f1b24480736ec0eed40900f4626bce1fa9a60fd700ec7e2a59198735"),
100
+ ])
101
+
102
+ static let all = [vad, parakeet]
103
+
104
+ enum StoreError: LocalizedError {
105
+ case http(String, Int)
106
+ case mismatch(String)
107
+ var errorDescription: String? {
108
+ switch self {
109
+ case .http(let path, let code): return "download of \(path) failed with HTTP \(code)"
110
+ case .mismatch(let path): return "\(path) did not match its checksum and was deleted"
111
+ }
112
+ }
113
+ }
114
+
115
+ /// Downloads whatever is missing or wrong, checks every file, then
116
+ /// stamps the model installed. `progress` gets bytes done and total.
117
+ static func install(_ model: LocalModel, progress: ((Int64, Int64) -> Void)? = nil) async throws {
118
+ if model.isInstalled { return }
119
+ let fm = FileManager.default
120
+ try fm.createDirectory(at: model.directory, withIntermediateDirectories: true)
121
+ var done: Int64 = 0
122
+ for file in model.files {
123
+ let dest = model.url(file.path)
124
+ if fm.fileExists(atPath: dest.path), (try? sha256(of: dest)) == file.sha256 {
125
+ done += file.size
126
+ progress?(done, model.totalBytes)
127
+ continue
128
+ }
129
+ try? fm.removeItem(at: dest)
130
+ try fm.createDirectory(at: dest.deletingLastPathComponent(), withIntermediateDirectories: true)
131
+ let source = URL(string: "https://huggingface.co/\(model.repo)/resolve/\(model.revision)/\(file.path)")!
132
+ let (tmp, response) = try await URLSession.shared.download(from: source)
133
+ if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) {
134
+ try? fm.removeItem(at: tmp)
135
+ throw StoreError.http(file.path, http.statusCode)
136
+ }
137
+ guard (try? sha256(of: tmp)) == file.sha256 else {
138
+ try? fm.removeItem(at: tmp)
139
+ throw StoreError.mismatch(file.path)
140
+ }
141
+ try fm.moveItem(at: tmp, to: dest)
142
+ done += file.size
143
+ progress?(done, model.totalBytes)
144
+ }
145
+ try model.markInstalled()
146
+ }
147
+
148
+ /// Streams the file, so the 445 MB encoder is never held in memory.
149
+ static func sha256(of url: URL) throws -> String {
150
+ let handle = try FileHandle(forReadingFrom: url)
151
+ defer { try? handle.close() }
152
+ var hasher = SHA256()
153
+ while let chunk = try handle.read(upToCount: 1 << 20), !chunk.isEmpty {
154
+ hasher.update(data: chunk)
155
+ }
156
+ return hasher.finalize().map { String(format: "%02x", $0) }.joined()
157
+ }
158
+
159
+ private static let background = BackgroundInstalls()
160
+
161
+ /// Starts one download of `model` in the background unless it is
162
+ /// installed or already downloading. For first use: the turn that
163
+ /// found it missing falls back, and a later one gets the model.
164
+ static func installInBackground(_ model: LocalModel, log: @escaping @Sendable (String) -> Void) {
165
+ guard !model.isInstalled, background.begin(model.name) else { return }
166
+ log("downloading the \(model.name) model (\(model.totalBytes / 1_000_000) MB) into \(model.directory.path)")
167
+ Task.detached {
168
+ do {
169
+ try await install(model)
170
+ log("the \(model.name) model is installed")
171
+ } catch {
172
+ log("the \(model.name) model could not be downloaded: \(error.localizedDescription)")
173
+ }
174
+ background.end(model.name)
175
+ }
176
+ }
177
+ }
178
+
179
+ private final class BackgroundInstalls: @unchecked Sendable {
180
+ private let lock = NSLock()
181
+ private var running = Set<String>()
182
+ func begin(_ name: String) -> Bool { lock.lock(); defer { lock.unlock() }; return running.insert(name).inserted }
183
+ func end(_ name: String) { lock.lock(); running.remove(name); lock.unlock() }
184
+ }
@@ -1,8 +1,8 @@
1
1
  import AppKit
2
2
  import SwiftUI
3
3
 
4
- /// The Siri-style orb at the head of the pill: the agent's colour, and it
5
- /// listens, thinks and speaks.
4
+ /// The Siri-style orb at the head of the pill: the agent's nature palette
5
+ /// (lagoon, forest, dusk…), and it listens, thinks and speaks.
6
6
  ///
7
7
  /// Built from SwiftUI's own gradients, no animation library. It only moves
8
8
  /// while it has something to show and is on screen: idle or hidden, its
@@ -18,6 +18,9 @@ final class OrbModel: ObservableObject {
18
18
  /// 0…1: the microphone while listening.
19
19
  @Published var level: Double = 0
20
20
  @Published var tint = NSColor(srgbRed: 0.078, green: 0.722, blue: 0.651, alpha: 1)
21
+ /// The agent's palette, five stops deep to light, from the daemon.
22
+ /// Nil: shades of `tint` (an error, held notifications, an older daemon).
23
+ @Published var colors: [NSColor]?
21
24
  /// The pill is on screen. Off, nothing moves.
22
25
  @Published var onScreen = false
23
26
  @Published var reduceMotion = NSWorkspace.shared.accessibilityDisplayShouldReduceMotion
@@ -43,8 +46,8 @@ struct PillOrbView: View {
43
46
  let paused = model.paused
44
47
  TimelineView(.animation(minimumInterval: 1.0 / 30, paused: paused)) { context in
45
48
  OrbBody(t: paused ? 0 : context.date.timeIntervalSinceReferenceDate,
46
- phase: model.phase, level: model.level, tint: model.tint, still: model.still,
47
- ringWidth: 5)
49
+ phase: model.phase, level: model.level, tint: model.tint, colors: model.colors,
50
+ still: model.still, ringWidth: 5)
48
51
  .frame(width: Self.designSize, height: Self.designSize)
49
52
  .scaleEffect(diameter / Self.designSize)
50
53
  }
@@ -89,11 +92,12 @@ final class PillOrb: NSHostingView<PillOrbView> {
89
92
 
90
93
  override func hitTest(_ point: NSPoint) -> NSView? { nil }
91
94
 
92
- /// Show `phase` in `tint`. Cheap to call on every render: SwiftUI only
93
- /// redraws what changed.
94
- func show(_ phase: OrbModel.Phase, tint: NSColor) {
95
+ /// Show `phase` in `colors`, or in shades of `tint` without them.
96
+ /// Cheap to call on every render: SwiftUI only redraws what changed.
97
+ func show(_ phase: OrbModel.Phase, tint: NSColor, colors: [NSColor]?) {
95
98
  let model = driver.model
96
99
  if model.tint != tint { model.tint = tint }
100
+ if model.colors != colors { model.colors = colors }
97
101
  if model.phase != phase {
98
102
  model.phase = phase
99
103
  if phase != .listening { model.level = 0 }
@@ -160,6 +164,8 @@ struct OrbBody: View {
160
164
  let phase: OrbModel.Phase
161
165
  let level: Double
162
166
  let tint: NSColor
167
+ /// The agent's palette, deep to light; nil for shades of `tint`.
168
+ var colors: [NSColor]? = nil
163
169
  /// Reduce Motion: a still orb, sized and shaded by state alone.
164
170
  let still: Bool
165
171
  /// The thinking ring's stroke at the 96-point design size. The pill
@@ -190,12 +196,17 @@ struct OrbBody: View {
190
196
  let scale = 0.78 + 0.22 * energy
191
197
  ZStack {
192
198
  Circle()
193
- .fill(Color(nsColor: tint).opacity(0.28 + 0.32 * energy))
199
+ .fill(glow.opacity(0.28 + 0.32 * energy))
194
200
  .blur(radius: 10 + 12 * energy)
195
201
  .scaleEffect(scale * 1.08)
196
202
  fill
197
203
  .clipShape(Circle())
198
204
  .scaleEffect(scale)
205
+ // Glass: a soft light from the top left, as on a marble.
206
+ Circle()
207
+ .fill(RadialGradient(colors: [.white.opacity(0.42), .white.opacity(0)],
208
+ center: UnitPoint(x: 0.34, y: 0.26), startRadius: 0, endRadius: 34))
209
+ .scaleEffect(scale)
199
210
  // Thinking reads as a slow ring going round; the other states
200
211
  // as a soft rim.
201
212
  if phase == .thinking {
@@ -247,9 +258,25 @@ struct OrbBody: View {
247
258
  ]
248
259
  }
249
260
 
261
+ /// The halo's colour: the palette's middle stop, or the tint.
262
+ private var glow: Color {
263
+ if let colors, colors.count == 5 { return Color(nsColor: colors[2]) }
264
+ return Color(nsColor: tint)
265
+ }
266
+
267
+ /// Five colours for the mesh: the agent's palette when it has one,
268
+ /// with its light stop brightening into a core as the voice grows.
269
+ private var palette: [Color] {
270
+ guard let colors, colors.count == 5 else { return shades }
271
+ let core = CGFloat(0.1 + 0.35 * energy)
272
+ let light = colors[4].blended(withFraction: core, of: .white) ?? colors[4]
273
+ // Spread so neighbouring mesh points differ: deep beside light.
274
+ return [colors[1], colors[3], colors[0], colors[2], light].map { Color(nsColor: $0) }
275
+ }
276
+
250
277
  /// Five shades around the agent's colour: lighter, deeper and two
251
278
  /// neighbouring hues, and a bright core that grows with the voice.
252
- private var palette: [Color] {
279
+ private var shades: [Color] {
253
280
  let base = tint.usingColorSpace(.sRGB) ?? tint
254
281
  var h: CGFloat = 0, s: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0
255
282
  base.getHue(&h, saturation: &s, brightness: &b, alpha: &a)