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,92 @@
1
+ import CoreML
2
+ import Foundation
3
+
4
+ /// Silero VAD on Core ML: how likely each 32 ms of 16 kHz audio is speech.
5
+ ///
6
+ /// Core ML rather than ONNX Runtime: the model is 0.9 MB, macOS already
7
+ /// ships the runtime, and the app keeps building with plain swiftc and no
8
+ /// package dependency. The weights come from ModelStore.vad on first use.
9
+ ///
10
+ /// Streaming: the model is an LSTM, so each call carries the previous
11
+ /// call's state and the last 64 samples as context, exactly as Silero's
12
+ /// own ONNX wrapper does. One `SileroVAD` per recording; the loaded model
13
+ /// is shared.
14
+ final class SileroVAD {
15
+ static let chunk = 512
16
+ static let context = 64
17
+ static let sampleRate = 16_000.0
18
+ static var frameDuration: TimeInterval { Double(chunk) / sampleRate }
19
+
20
+ private static let lock = NSLock()
21
+ nonisolated(unsafe) private static var cached: MLModel?
22
+
23
+ /// The shared model, loaded once. Nil when it is not installed or will
24
+ /// not load; the caller then falls back to the volume threshold.
25
+ static func loadModel() throws -> MLModel? {
26
+ lock.lock(); defer { lock.unlock() }
27
+ if let cached { return cached }
28
+ guard ModelStore.vad.isInstalled else { return nil }
29
+ let config = MLModelConfiguration()
30
+ // Tiny and called every 32 ms: the CPU answers faster than a trip
31
+ // to the Neural Engine.
32
+ config.computeUnits = .cpuOnly
33
+ let url = ModelStore.vad.url("silero-vad-unified-v6.0.0.mlmodelc")
34
+ let model = try MLModel(contentsOf: url, configuration: config)
35
+ cached = model
36
+ return model
37
+ }
38
+
39
+ private let model: MLModel
40
+ private let audio: MLMultiArray
41
+ private var hidden: MLMultiArray
42
+ private var cell: MLMultiArray
43
+ private var pending: [Float] = []
44
+ private var tail = [Float](repeating: 0, count: SileroVAD.context)
45
+
46
+ init(model: MLModel) throws {
47
+ self.model = model
48
+ audio = try MLMultiArray(shape: [1, NSNumber(value: Self.context + Self.chunk)], dataType: .float32)
49
+ hidden = try Self.zeros()
50
+ cell = try Self.zeros()
51
+ }
52
+
53
+ private static func zeros() throws -> MLMultiArray {
54
+ let a = try MLMultiArray(shape: [1, 128], dataType: .float32)
55
+ for i in 0..<a.count { a[i] = 0 }
56
+ return a
57
+ }
58
+
59
+ /// Feeds samples (16 kHz, -1…1) and returns one probability per
60
+ /// complete 512-sample frame; leftovers wait for the next call.
61
+ func process(_ samples: [Float]) throws -> [Float] {
62
+ pending.append(contentsOf: samples)
63
+ var out: [Float] = []
64
+ var start = 0
65
+ while pending.count - start >= Self.chunk {
66
+ let frame = pending[start..<start + Self.chunk]
67
+ out.append(try score(frame))
68
+ start += Self.chunk
69
+ }
70
+ pending.removeFirst(start)
71
+ return out
72
+ }
73
+
74
+ private func score(_ frame: ArraySlice<Float>) throws -> Float {
75
+ let ptr = audio.dataPointer.bindMemory(to: Float.self, capacity: audio.count)
76
+ for i in 0..<Self.context { ptr[i] = tail[i] }
77
+ var i = Self.context
78
+ for s in frame { ptr[i] = s; i += 1 }
79
+ tail = Array(frame.suffix(Self.context))
80
+
81
+ let input = try MLDictionaryFeatureProvider(dictionary: [
82
+ "audio_input": MLFeatureValue(multiArray: audio),
83
+ "hidden_state": MLFeatureValue(multiArray: hidden),
84
+ "cell_state": MLFeatureValue(multiArray: cell),
85
+ ])
86
+ let result = try model.prediction(from: input)
87
+ if let h = result.featureValue(for: "new_hidden_state")?.multiArrayValue { hidden = h }
88
+ if let c = result.featureValue(for: "new_cell_state")?.multiArrayValue { cell = c }
89
+ guard let p = result.featureValue(for: "vad_output")?.multiArrayValue, p.count > 0 else { return 0 }
90
+ return p[0].floatValue
91
+ }
92
+ }
@@ -38,16 +38,18 @@ enum Speech {
38
38
  // MARK: Speech to text
39
39
 
40
40
  /// `engine` is agentx.json's `voice.stt`: "auto" and "elevenlabs" use
41
- /// ElevenLabs when there is a key, falling back to mlx-whisper; "local"
42
- /// never sends audio off the Mac.
43
- static func transcribe(wav: Data, engine: String = "auto") async throws -> String {
41
+ /// ElevenLabs when there is a key, falling back to the local engine;
42
+ /// "local" never sends audio off the Mac. `local` is `voice.localStt`:
43
+ /// "mlx-whisper" or "parakeet".
44
+ static func transcribe(wav: Data, engine: String = "auto", local: String = "mlx-whisper") async throws -> String {
44
45
  if engine == "elevenlabs" && Config.elevenLabsKey == nil {
45
- Log.warn("speech to text is set to ElevenLabs but no key is set; using mlx-whisper")
46
+ Log.warn("speech to text is set to ElevenLabs but no key is set; using \(local)")
46
47
  }
47
48
  if engine != "local", let key = Config.elevenLabsKey {
48
49
  do { return try await elevenLabsSTT(wav: wav, key: key) }
49
- catch { Log.warn("ElevenLabs STT failed (\(error.localizedDescription)); falling back to mlx-whisper") }
50
+ catch { Log.warn("ElevenLabs STT failed (\(error.localizedDescription)); falling back to \(local)") }
50
51
  }
52
+ if local == "parakeet", let text = await LocalSTT.parakeet(wav: wav) { return text }
51
53
  return try await mlxWhisper(wav: wav)
52
54
  }
53
55
 
@@ -23,6 +23,8 @@ final class StatusMenu: NSObject, NSMenuDelegate {
23
23
  /// The daemon's speaking queue, polled while anything is in flight.
24
24
  private var queue: AgentClient.QueueState?
25
25
  private var queuePoll: Timer?
26
+ /// The last three voice exchanges, for a quick replay.
27
+ private let recent = RecentExchanges()
26
28
 
27
29
  /// Questions this widget has in flight, per agent. Set by the app.
28
30
  var thinking: [String: Int] = [:] {
@@ -68,6 +70,15 @@ final class StatusMenu: NSObject, NSMenuDelegate {
68
70
  return NSColor(srgbRed: c.r, green: c.g, blue: c.b, alpha: 1)
69
71
  }
70
72
 
73
+ /// The agent's orb palette, five colours deep to light, or nil when
74
+ /// the daemon sent none.
75
+ func palette(of id: String) -> [NSColor]? {
76
+ guard case .loaded(let agents) = roster,
77
+ let hexes = agents.first(where: { $0.id == id })?.palette?.colors, hexes.count == 5 else { return nil }
78
+ let colors = hexes.compactMap(OrbMath.parseHex).map { NSColor(srgbRed: $0.r, green: $0.g, blue: $0.b, alpha: 1) }
79
+ return colors.count == 5 ? colors : nil
80
+ }
81
+
71
82
  /// Open the menu from the keyboard.
72
83
  func open() { item.button?.performClick(nil) }
73
84
 
@@ -76,6 +87,7 @@ final class StatusMenu: NSObject, NSMenuDelegate {
76
87
  let agents = await AgentClient.agents()
77
88
  roster = agents.map { .loaded($0) } ?? .down
78
89
  queue = await AgentClient.queueState()
90
+ await recent.refresh()
79
91
  rebuild()
80
92
  showBadge()
81
93
  if reopenAfterRefresh { reopenAfterRefresh = false; open() }
@@ -175,6 +187,7 @@ final class StatusMenu: NSObject, NSMenuDelegate {
175
187
  menu.addItem(row)
176
188
  }
177
189
  }
190
+ recent.addItems(to: menu, name: name(of:))
178
191
 
179
192
  menu.addItem(.separator())
180
193
  menu.addItem(action("Stop speaking", #selector(stop), key: ".", modifiers: [.command, .option]))
@@ -251,7 +264,7 @@ final class StatusMenu: NSObject, NSMenuDelegate {
251
264
 
252
265
  @objc private func openSettings() { onSettings?() }
253
266
  @objc private func openDashboardHome() { openDashboard("/admin") }
254
- @objc private func openHistory() { openDashboard("/activity") }
267
+ @objc private func openHistory() { HistoryWindow.shared.show() }
255
268
 
256
269
  private func openDashboard(_ path: String) {
257
270
  guard let url = URL(string: Config.dashboardURL + path) else { return }
@@ -0,0 +1,105 @@
1
+ import AppKit
2
+
3
+ /// The pill's background, drawn the way macOS draws its own floating
4
+ /// surfaces: popover vibrancy, a continuous-corner shape, a hairline
5
+ /// border, and the window's soft shadow around it.
6
+ ///
7
+ /// It follows light and dark, Increase Contrast and Reduce Transparency as
8
+ /// they change, since layer colours are fixed values that do not update
9
+ /// themselves.
10
+ final class Surface: NSVisualEffectView {
11
+ /// An opaque-ish wash over the blur, and it is not a style preference.
12
+ ///
13
+ /// `blendingMode = .behindWindow` composites whatever is behind the
14
+ /// pill, so an editor's panel dividers came through it as a faint
15
+ /// rectangle that read as a border the widget was drawing. The wash
16
+ /// keeps enough blur to feel native while making the pill read as one
17
+ /// solid object wherever it is parked.
18
+ private let wash = NSView()
19
+
20
+ /// Lays out the pill's row and its answer on every size change. Set by
21
+ /// the panel: the row stays put while the widget grows around it, which
22
+ /// autoresizing masks cannot say.
23
+ var layoutContent: ((NSRect) -> Void)?
24
+
25
+ override init(frame: NSRect) {
26
+ super.init(frame: frame)
27
+ material = .popover
28
+ blendingMode = .behindWindow
29
+ state = .active
30
+ wantsLayer = true
31
+ layer?.cornerRadius = Brand.Radius.lg
32
+ layer?.cornerCurve = .continuous
33
+ layer?.masksToBounds = true
34
+ // The layer's corners clip the wash and the answer, but not the
35
+ // blur: the window server draws that behind the whole rectangle
36
+ // unless the view has a mask image of the same shape.
37
+ maskImage = Self.mask(radius: Brand.Radius.lg)
38
+
39
+ wash.wantsLayer = true
40
+ wash.frame = bounds
41
+ wash.autoresizingMask = [.width, .height]
42
+ addSubview(wash)
43
+
44
+ NSWorkspace.shared.notificationCenter.addObserver(
45
+ self, selector: #selector(displayOptionsChanged),
46
+ name: NSWorkspace.accessibilityDisplayOptionsDidChangeNotification, object: nil)
47
+ restyle()
48
+ }
49
+
50
+ @available(*, unavailable)
51
+ required init?(coder: NSCoder) { fatalError("not from a nib") }
52
+
53
+ override func resizeSubviews(withOldSize oldSize: NSSize) {
54
+ super.resizeSubviews(withOldSize: oldSize)
55
+ layoutContent?(bounds)
56
+ }
57
+
58
+ override func viewDidChangeEffectiveAppearance() {
59
+ super.viewDidChangeEffectiveAppearance()
60
+ restyle()
61
+ }
62
+
63
+ override func viewDidChangeBackingProperties() {
64
+ super.viewDidChangeBackingProperties()
65
+ restyle()
66
+ }
67
+
68
+ @objc private func displayOptionsChanged() { restyle() }
69
+
70
+ /// A stretchable continuous-corner shape: the corners stay, the middle
71
+ /// stretches to any size.
72
+ private static func mask(radius: CGFloat) -> NSImage {
73
+ // A continuous corner starts bending before `radius` from the edge.
74
+ let cap = ceil(radius * 1.6)
75
+ let side = cap * 2 + 1
76
+ let image = NSImage(size: NSSize(width: side, height: side), flipped: false) { rect in
77
+ guard let ctx = NSGraphicsContext.current?.cgContext else { return false }
78
+ let shape = CALayer()
79
+ shape.frame = rect
80
+ shape.backgroundColor = NSColor.black.cgColor
81
+ shape.cornerRadius = radius
82
+ shape.cornerCurve = .continuous
83
+ shape.render(in: ctx)
84
+ return true
85
+ }
86
+ image.capInsets = NSEdgeInsets(top: cap, left: cap, bottom: cap, right: cap)
87
+ image.resizingMode = .stretch
88
+ return image
89
+ }
90
+
91
+ /// Colours for the current appearance and accessibility settings.
92
+ private func restyle() {
93
+ let ws = NSWorkspace.shared
94
+ let contrast = ws.accessibilityDisplayShouldIncreaseContrast
95
+ let solid = ws.accessibilityDisplayShouldReduceTransparency
96
+ effectiveAppearance.performAsCurrentDrawingAppearance {
97
+ wash.layer?.backgroundColor = NSColor.windowBackgroundColor
98
+ .withAlphaComponent(solid || contrast ? 0.96 : 0.72).cgColor
99
+ // One device pixel: a hairline, as system popovers have. With
100
+ // Increase Contrast it becomes a clear line in the text colour.
101
+ layer?.borderColor = (contrast ? NSColor.labelColor.withAlphaComponent(0.6) : NSColor.separatorColor).cgColor
102
+ layer?.borderWidth = (contrast ? 1.5 : 1) / (window?.backingScaleFactor ?? 2)
103
+ }
104
+ }
105
+ }
@@ -0,0 +1,110 @@
1
+ import Foundation
2
+
3
+ /// When a hands-free turn has ended: the person spoke, then stopped for
4
+ /// long enough that the sentence is over.
5
+ ///
6
+ /// It is fed one audio frame at a time with how likely that frame is to be
7
+ /// speech (0…1) and how long it lasted. Two sources feed it:
8
+ ///
9
+ /// - Silero VAD, 32 ms frames with a real probability. A voice model
10
+ /// tells a word from a fan, a keyboard or a café, so the noise of a room
11
+ /// neither holds the microphone open nor counts as talking.
12
+ /// - The old volume check when the VAD model is not available: 1 above
13
+ /// `speechLevel`, 0 below, per audio buffer.
14
+ ///
15
+ /// Foundation only and time passed in, so the tests can drive it with
16
+ /// synthetic frames.
17
+ struct TurnEnd {
18
+ struct Tuning: Equatable {
19
+ /// A frame at or above this starts speech (Silero's own default).
20
+ var start: Float
21
+ /// Once talking, a frame at or above this still counts as speech.
22
+ /// Lower than `start`: the soft end of a word is not a pause.
23
+ var keep: Float
24
+ /// Speech must last this long in one go before the turn counts as
25
+ /// started, so a cough, a door or a click does not open it.
26
+ var minSpeech: TimeInterval
27
+ /// A burst this short inside a pause does not restart the pause:
28
+ /// one noisy frame must not hold the microphone open.
29
+ var resume: TimeInterval
30
+ /// A pause this long after speech ends the turn. People pause
31
+ /// inside sentences; this is longer than those pauses.
32
+ var endSilence: TimeInterval
33
+
34
+ /// For Silero VAD probabilities.
35
+ static let vad = Tuning(start: 0.5, keep: 0.35, minSpeech: 0.25, resume: 0.1, endSilence: 1.2)
36
+
37
+ /// The volume threshold as it always worked: any buffer above the
38
+ /// level is speech, and 1.2 s below it ends the turn.
39
+ static let level = Tuning(start: 0.5, keep: 0.5, minSpeech: 0, resume: 0, endSilence: 1.2)
40
+ }
41
+
42
+ enum State: Equatable {
43
+ /// Nothing said yet.
44
+ case waiting
45
+ /// Speech heard; the turn is open (possibly in a short pause).
46
+ case speaking
47
+ /// Speech, then `endSilence` of quiet: send it.
48
+ case ended
49
+ }
50
+
51
+ /// RMS above which the microphone is hearing a voice rather than a
52
+ /// room. The fallback's whole notion of speech.
53
+ static let speechLevel: Float = 0.02
54
+
55
+ let tuning: Tuning
56
+ private(set) var state: State = .waiting
57
+ /// How long the current run of speech frames has lasted.
58
+ private var speechRun: TimeInterval = 0
59
+ /// How long the current pause has lasted (a short burst does not
60
+ /// reset it).
61
+ private var silence: TimeInterval = 0
62
+
63
+ init(_ tuning: Tuning) { self.tuning = tuning }
64
+
65
+ /// One frame: `probability` that it is speech, `duration` it covers.
66
+ @discardableResult
67
+ mutating func feed(probability: Float, duration: TimeInterval) -> State {
68
+ guard state != .ended else { return state }
69
+ let threshold = state == .speaking && silence == 0 ? tuning.keep : tuning.start
70
+ let speech = probability >= threshold
71
+
72
+ switch state {
73
+ case .waiting:
74
+ speechRun = speech ? speechRun + duration : 0
75
+ if speech && speechRun >= tuning.minSpeech {
76
+ state = .speaking
77
+ silence = 0
78
+ }
79
+ case .speaking:
80
+ if speech {
81
+ speechRun += duration
82
+ if silence == 0 || speechRun >= tuning.resume {
83
+ silence = 0
84
+ } else {
85
+ silence += duration
86
+ }
87
+ } else {
88
+ speechRun = 0
89
+ silence += duration
90
+ }
91
+ if silence >= tuning.endSilence { state = .ended }
92
+ case .ended:
93
+ break
94
+ }
95
+ return state
96
+ }
97
+
98
+ /// The fallback's frame: the smoothed microphone level.
99
+ @discardableResult
100
+ mutating func feed(level: Float, duration: TimeInterval) -> State {
101
+ feed(probability: level > Self.speechLevel ? 1 : 0, duration: duration)
102
+ }
103
+
104
+ /// The microphone level after one buffer of loudness `rms`. Attack
105
+ /// fast, release slow: a level that drops instantly makes a pause
106
+ /// between words look like the end of a sentence.
107
+ static func smooth(level: Float, rms: Float) -> Float {
108
+ rms > level ? rms : level * 0.82 + rms * 0.18
109
+ }
110
+ }
@@ -8,11 +8,43 @@ struct VoiceSettings: Codable, Equatable {
8
8
  var stop: String
9
9
  var paste: String
10
10
  }
11
+ /// The answer shown in the pill: seconds open once spoken (0: until
12
+ /// closed), and its tallest height in points.
13
+ struct Card: Codable, Equatable {
14
+ var timeout: Double
15
+ var maxHeight: Double
16
+ static let standard = Card(timeout: 30, maxHeight: 320)
17
+ }
11
18
  struct General: Codable, Equatable {
12
19
  var provider: String
13
20
  var fallback: String
14
21
  var stt: String
22
+ /// The on-device engine: "mlx-whisper" or "parakeet".
23
+ var localStt: String
24
+ /// How a hands-free turn's end is heard: "vad" or "volume".
25
+ var endOfTurn: String
15
26
  var hotkeys: Hotkeys
27
+ /// Nil from a daemon older than the card settings.
28
+ var card: Card?
29
+
30
+ init(provider: String, fallback: String, stt: String, localStt: String = "mlx-whisper",
31
+ endOfTurn: String = "vad", hotkeys: Hotkeys, card: Card? = nil) {
32
+ self.provider = provider; self.fallback = fallback; self.stt = stt
33
+ self.localStt = localStt; self.endOfTurn = endOfTurn; self.hotkeys = hotkeys
34
+ self.card = card
35
+ }
36
+
37
+ /// A daemon from before these fields leaves them out: the defaults.
38
+ init(from decoder: Decoder) throws {
39
+ let c = try decoder.container(keyedBy: CodingKeys.self)
40
+ provider = try c.decode(String.self, forKey: .provider)
41
+ fallback = try c.decode(String.self, forKey: .fallback)
42
+ stt = try c.decode(String.self, forKey: .stt)
43
+ localStt = try c.decodeIfPresent(String.self, forKey: .localStt) ?? "mlx-whisper"
44
+ endOfTurn = try c.decodeIfPresent(String.self, forKey: .endOfTurn) ?? "vad"
45
+ hotkeys = try c.decode(Hotkeys.self, forKey: .hotkeys)
46
+ card = try c.decodeIfPresent(Card.self, forKey: .card)
47
+ }
16
48
  }
17
49
  struct Voice: Codable, Equatable {
18
50
  var provider: String?
@@ -33,6 +65,10 @@ struct VoiceSettings: Codable, Equatable {
33
65
  let name: String
34
66
  var color: String
35
67
  var colorSet: Bool
68
+ /// The chosen orb palette; nil follows the colour.
69
+ var palette: String?
70
+ /// The palette the colour gives when none is chosen.
71
+ let paletteDefault: String?
36
72
  var voice: Voice
37
73
  let speaks: Speaks
38
74
  }
@@ -42,9 +78,18 @@ struct VoiceSettings: Codable, Equatable {
42
78
  let locale: String
43
79
  }
44
80
 
81
+ /// One of the orb's nature palettes: five colours, deep to light.
82
+ struct Palette: Codable, Equatable, Identifiable {
83
+ let id: String
84
+ let label: String
85
+ let colors: [String]
86
+ }
87
+
45
88
  var general: General
46
89
  var agents: [Agent]
47
90
  let systemVoices: [SystemVoice]
91
+ /// Nil from a daemon older than the palettes.
92
+ let palettes: [Palette]?
48
93
  let menuHotkey: String
49
94
 
50
95
  /// The change from `old` to this, as POST /voice/settings takes it:
@@ -55,11 +100,19 @@ struct VoiceSettings: Codable, Equatable {
55
100
  var general: [String: Any] = [:]
56
101
  if self.general.provider != old.general.provider { general["provider"] = self.general.provider }
57
102
  if self.general.stt != old.general.stt { general["stt"] = self.general.stt }
103
+ if self.general.localStt != old.general.localStt { general["localStt"] = self.general.localStt }
104
+ if self.general.endOfTurn != old.general.endOfTurn { general["endOfTurn"] = self.general.endOfTurn }
58
105
  var keys: [String: Any] = [:]
59
106
  if self.general.hotkeys.talk != old.general.hotkeys.talk { keys["talk"] = self.general.hotkeys.talk }
60
107
  if self.general.hotkeys.stop != old.general.hotkeys.stop { keys["stop"] = self.general.hotkeys.stop }
61
108
  if self.general.hotkeys.paste != old.general.hotkeys.paste { keys["paste"] = self.general.hotkeys.paste }
62
109
  if !keys.isEmpty { general["hotkeys"] = keys }
110
+ if let card = self.general.card {
111
+ var c: [String: Any] = [:]
112
+ if card.timeout != old.general.card?.timeout { c["timeout"] = card.timeout }
113
+ if card.maxHeight != old.general.card?.maxHeight { c["maxHeight"] = card.maxHeight }
114
+ if !c.isEmpty { general["card"] = c }
115
+ }
63
116
  if !general.isEmpty { out["general"] = general }
64
117
 
65
118
  var agents: [String: Any] = [:]
@@ -77,6 +130,7 @@ struct VoiceSettings: Codable, Equatable {
77
130
  field("narrate", agent.voice.narrate, before.voice.narrate)
78
131
  field("priority", agent.voice.priority, before.voice.priority)
79
132
  field("hotkey", agent.voice.hotkey, before.voice.hotkey)
133
+ field("palette", agent.palette, before.palette)
80
134
  if agent.colorSet != before.colorSet || (agent.colorSet && agent.color != before.color) {
81
135
  a["color"] = agent.colorSet ? agent.color as Any : NSNull()
82
136
  }
@@ -18,7 +18,6 @@ import Carbon.HIToolbox
18
18
  final class App: NSObject, NSApplicationDelegate {
19
19
  private let panel = Panel()
20
20
  private let statusMenu = StatusMenu()
21
- private let card = ResultCard()
22
21
  /// The agent whose by-name answer is on screen now, while no turn runs.
23
22
  private var asideSpeaker: String?
24
23
  private let recorder = Recorder()
@@ -155,6 +154,9 @@ final class App: NSObject, NSApplicationDelegate {
155
154
  /// Settings from the daemon: shortcuts now, the rest on next use.
156
155
  private func apply(_ saved: VoiceSettings) {
157
156
  settings = saved
157
+ let card = saved.general.card ?? .standard
158
+ panel.cardTimeout = card.timeout
159
+ panel.cardMaxHeight = card.maxHeight
158
160
  if settingsWindow.model.recording == nil { registerHotkeys() }
159
161
  // Colours may have changed.
160
162
  statusMenu.refresh()
@@ -170,6 +172,14 @@ final class App: NSObject, NSApplicationDelegate {
170
172
  return busy && !turnAgent.isEmpty ? turnAgent : Config.effectiveAgentID
171
173
  }
172
174
 
175
+ /// The answer's "Open in chat": the agent's page in the dashboard, with
176
+ /// its chat open.
177
+ private func openChat() {
178
+ let who = shownAgent
179
+ guard !who.isEmpty, let url = URL(string: "\(Config.dashboardURL)/admin/agents/\(who)#chat") else { return }
180
+ NSWorkspace.shared.open(url)
181
+ }
182
+
173
183
  /// Close, Esc or "Hide pill": the pill goes and the voice stops, the
174
184
  /// same stop as ⌘⌥. An open microphone closes without sending. The
175
185
  /// next talk key brings the pill back.
@@ -246,8 +256,11 @@ final class App: NSObject, NSApplicationDelegate {
246
256
  panel.orb.setAnimated(Config.animatedOrb)
247
257
  panel.orb.levelSource = { [weak self] in self?.recorder.level ?? 0 }
248
258
  panel.onDismiss = { [weak self] in self?.dismissPill() }
249
- panel.onMove = { [weak self] in self?.card.follow() }
250
- card.anchor = { [weak self] in self?.panel.frame ?? .zero }
259
+ panel.agentPalette = { [weak self] in
260
+ guard let self else { return nil }
261
+ return self.statusMenu.palette(of: self.shownAgent)
262
+ }
263
+ panel.answer.onOpenChat = { [weak self] in self?.openChat() }
251
264
  statusMenu.pillVisible = { [weak self] in self?.panel.isVisible ?? false }
252
265
  statusMenu.onHidePill = { [weak self] in self?.dismissPill() }
253
266
  statusMenu.onResetPosition = { [weak self] in self?.panel.resetPosition() }
@@ -321,20 +334,14 @@ final class App: NSObject, NSApplicationDelegate {
321
334
  // Ending on SILENCE rather than a timer is the whole trick. A fixed
322
335
  // window either cuts people off mid-sentence or leaves the mic open
323
336
  // staring at them; the only honest signal that a sentence has finished
324
- // is the room going quiet.
337
+ // is the voice stopping. The recorder judges that (TurnEnd.swift).
325
338
 
326
- /// RMS above which the microphone is hearing a voice rather than a room.
327
- private let speechLevel: Float = 0.02
328
- /// Quiet this long after speech ends the turn.
329
- private let endSilence: TimeInterval = 1.2
330
339
  /// How long an unprompted follow-up window waits before giving up.
331
340
  private let followUpPatience: TimeInterval = 4.0
332
341
  /// How long a clicked session waits for you to start talking.
333
342
  private let clickPatience: TimeInterval = 8.0
334
343
 
335
344
  private var listenPoll: Timer?
336
- private var heardSpeech = false
337
- private var quietSince: Date?
338
345
  private var openedAt = Date()
339
346
  private var patience: TimeInterval = 8.0
340
347
  /// True when nothing was said and the window should close in silence.
@@ -343,14 +350,13 @@ final class App: NSObject, NSApplicationDelegate {
343
350
  /// Open the microphone with no key held.
344
351
  private func listenHandsFree(followUp: Bool) {
345
352
  guard !busy, !recorder.isRecording else { return }
353
+ recorder.endOfTurn = settings?.general.endOfTurn ?? "vad"
346
354
  do {
347
355
  try recorder.start()
348
356
  } catch {
349
357
  panel.render(.error(error.localizedDescription))
350
358
  return
351
359
  }
352
- heardSpeech = false
353
- quietSince = nil
354
360
  openedAt = Date()
355
361
  patience = followUp ? followUpPatience : clickPatience
356
362
  silentClose = followUp
@@ -369,23 +375,13 @@ final class App: NSObject, NSApplicationDelegate {
369
375
 
370
376
  private func pollLevel() {
371
377
  guard recorder.isRecording else { stopPolling(); return }
372
- let level = recorder.level
373
-
374
- if level > speechLevel {
375
- heardSpeech = true
376
- quietSince = nil
377
- return
378
- }
379
- if heardSpeech {
380
- // Speech has stopped. Give it a beat before deciding the
381
- // sentence is over — people pause inside sentences.
382
- let since = quietSince ?? Date()
383
- quietSince = since
384
- if Date().timeIntervalSince(since) >= endSilence {
385
- stopPolling()
386
- stopAndSend()
387
- }
378
+ switch recorder.turnState {
379
+ case .speaking: return
380
+ case .ended:
381
+ stopPolling()
382
+ stopAndSend()
388
383
  return
384
+ case .waiting: break
389
385
  }
390
386
  // Nothing said yet. Close quietly rather than making the person
391
387
  // dismiss a window they did not ask for.
@@ -444,6 +440,8 @@ final class App: NSObject, NSApplicationDelegate {
444
440
  lastSpokeAt = Date()
445
441
  talkCheck = Task { await AgentClient.hush() }
446
442
  Log.info("door: opened\(busy ? " (a turn is running)" : "")")
443
+ // Push-to-talk ends on key release; no end-of-turn detection needed.
444
+ recorder.endOfTurn = "hold"
447
445
  do {
448
446
  try recorder.start()
449
447
  asideSpeaker = nil
@@ -474,7 +472,8 @@ final class App: NSObject, NSApplicationDelegate {
474
472
 
475
473
  Task { @MainActor in
476
474
  var heard = ""
477
- do { heard = try await Speech.transcribe(wav: wav, engine: settings?.general.stt ?? "auto") }
475
+ do { heard = try await Speech.transcribe(wav: wav, engine: settings?.general.stt ?? "auto",
476
+ local: settings?.general.localStt ?? "mlx-whisper") }
478
477
  catch { Log.warn("transcription failed: \(error.localizedDescription)") }
479
478
  guard !heard.isEmpty else {
480
479
  _ = await door.value
@@ -566,10 +565,10 @@ final class App: NSObject, NSApplicationDelegate {
566
565
  switch result {
567
566
  case .success(let answer):
568
567
  Log.info("aside answer (\(answer.agentID ?? agent)): \(answer.text)")
569
- if ResultCard.isWorthShowing(spoken: answer.text, written: answer.written,
568
+ if AnswerView.isWorthShowing(spoken: answer.text, written: answer.written,
570
569
  buttons: answer.buttons, imageURL: answer.imageURL) {
571
- card.show(spoken: answer.text, written: answer.written,
572
- buttons: answer.buttons, imageURL: answer.imageURL)
570
+ panel.showAnswer(spoken: answer.text, written: answer.written,
571
+ buttons: answer.buttons, imageURL: answer.imageURL)
573
572
  }
574
573
  // Nothing else on screen: the pill shows this answer, its
575
574
  // orb in this agent's colour, while it is spoken.
@@ -631,12 +630,12 @@ final class App: NSObject, NSApplicationDelegate {
631
630
  // the speech cannot deliver — a link, an image, or more
632
631
  // text than was read aloud. A card that opens on every
633
632
  // "Ok." teaches you to ignore it.
634
- if ResultCard.isWorthShowing(spoken: answer.text, written: answer.written,
633
+ if AnswerView.isWorthShowing(spoken: answer.text, written: answer.written,
635
634
  buttons: answer.buttons, imageURL: answer.imageURL) {
636
- card.show(spoken: answer.text, written: answer.written,
637
- buttons: answer.buttons, imageURL: answer.imageURL)
635
+ panel.showAnswer(spoken: answer.text, written: answer.written,
636
+ buttons: answer.buttons, imageURL: answer.imageURL)
638
637
  } else {
639
- card.orderOut(nil)
638
+ panel.collapse()
640
639
  }
641
640
  // Stopped while it was thinking: the answer is not spoken.
642
641
  if silenced { silenced = false; panel.render(.idle); busy = false; return }
@@ -18,9 +18,18 @@ rm -rf build && mkdir -p "$BIN" "$APP/Contents/Resources"
18
18
  swiftc -O \
19
19
  -o "$BIN/AgentXVoice" \
20
20
  Sources/AgentXVoice/*.swift \
21
- -framework AppKit -framework AVFoundation -framework Carbon -framework SwiftUI \
21
+ -framework AppKit -framework AVFoundation -framework Carbon -framework CoreML -framework SwiftUI \
22
22
  -target arm64-apple-macosx14.0
23
23
 
24
+ # The on-device models from a terminal: fetch them, transcribe a file, see
25
+ # where a turn would end. Same sources as the app; the models themselves
26
+ # are downloaded on first use into ~/.agentx/models, never bundled.
27
+ swiftc -O \
28
+ -o "$BIN/agentx-voice-local" \
29
+ Sources/AgentXVoice/ModelStore.swift Sources/AgentXVoice/SileroVAD.swift \
30
+ Sources/AgentXVoice/Parakeet.swift Sources/AgentXVoice/TurnEnd.swift Tools/LocalSTT/main.swift \
31
+ -framework CoreML -target arm64-apple-macosx14.0
32
+
24
33
  cp Resources/Info.plist "$APP/Contents/Info.plist"
25
34
 
26
35
  # Ad-hoc signature. Unsigned bundles get a fresh TCC identity on every