agentix-cli 0.94.1 → 0.95.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 (106) hide show
  1. package/apps/mac-voice/Sources/AgentXVoice/Character.swift +4 -0
  2. package/apps/mac-voice/Sources/AgentXVoice/Config.swift +7 -0
  3. package/apps/mac-voice/Sources/AgentXVoice/PlayMath.swift +250 -0
  4. package/apps/mac-voice/Sources/AgentXVoice/PlayRead.swift +98 -0
  5. package/apps/mac-voice/Sources/AgentXVoice/PlayStage.swift +164 -0
  6. package/apps/mac-voice/Sources/AgentXVoice/StatusMenu.swift +14 -0
  7. package/apps/mac-voice/Sources/AgentXVoice/main.swift +57 -0
  8. package/apps/mac-voice/build.sh +1 -1
  9. package/dist/{App-Y62EOIB4.js → App-6PNLFYAK.js} +2 -2
  10. package/dist/{ChatApp-VIGYSGG4.js → ChatApp-YYBPNTIA.js} +2 -2
  11. package/dist/{agent-AGBADNWK.js → agent-THOKXUGD.js} +2 -2
  12. package/dist/{board-dashboard-MHMYOBKA.js → board-dashboard-URZW2L6F.js} +4 -4
  13. package/dist/{chunk-IWEN3YNQ.js → chunk-24EKSYR7.js} +2 -2
  14. package/dist/{chunk-IBXHC3ZV.js → chunk-3BD54ASS.js} +171 -171
  15. package/dist/chunk-3BD54ASS.js.map +1 -0
  16. package/dist/{chunk-5EOFTPQB.js → chunk-3DB2X334.js} +2 -2
  17. package/dist/{chunk-DS7CDASP.js → chunk-7V7GH57E.js} +2 -2
  18. package/dist/{chunk-Q4R4OS5S.js → chunk-AP4HOAUV.js} +2 -2
  19. package/dist/{chunk-C6PWAEVX.js → chunk-DE4SOF37.js} +2 -2
  20. package/dist/{chunk-KA6K25T4.js → chunk-FQHY4A5H.js} +2 -2
  21. package/dist/{chunk-T2IDDOSX.js → chunk-GE3ISITN.js} +2 -2
  22. package/dist/{chunk-EWE2TJMN.js → chunk-HZJ4S2LT.js} +5 -5
  23. package/dist/{chunk-HJUBCA7O.js → chunk-IEEZ3HJW.js} +2 -2
  24. package/dist/{chunk-MIRBKQHA.js → chunk-KE3FB3SK.js} +2 -2
  25. package/dist/{chunk-VUSDWZ6L.js → chunk-KFMGQ6S7.js} +2 -2
  26. package/dist/{chunk-VUSDWZ6L.js.map → chunk-KFMGQ6S7.js.map} +1 -1
  27. package/dist/{chunk-PS3UB5PH.js → chunk-NARH4ZMY.js} +2 -2
  28. package/dist/{chunk-73NSSC6K.js → chunk-PNSAL5NB.js} +2 -2
  29. package/dist/{chunk-OQHC7TJL.js → chunk-RQVRKBLH.js} +2 -2
  30. package/dist/{chunk-BP52VVCW.js → chunk-VJEO7EOS.js} +2 -2
  31. package/dist/{chunk-5PFTUGP4.js → chunk-VNVKNVGF.js} +2 -2
  32. package/dist/{chunk-LKBUTSYP.js → chunk-VUUDZFRR.js} +2 -2
  33. package/dist/{chunk-2MMC5LHF.js → chunk-WFZHADE2.js} +2 -2
  34. package/dist/{chunk-M56MKNAG.js → chunk-ZGUC3EG6.js} +2 -2
  35. package/dist/{chunk-74S7PKIX.js → chunk-ZGWDZ7X7.js} +2 -2
  36. package/dist/cli.js +1 -1
  37. package/dist/{client-RMUEJMV5.js → client-EKGV6UYH.js} +2 -2
  38. package/dist/{config-M2Z4HTZI.js → config-SF2U4WSJ.js} +2 -2
  39. package/dist/config-mutator-XTYVB2LB.js +2 -0
  40. package/dist/{doctor-W6OQ7JI2.js → doctor-3BOJJYBX.js} +2 -2
  41. package/dist/index.d.ts +7 -0
  42. package/dist/index.js +1 -1
  43. package/dist/{program-6G2IS3WF.js → program-T4RVQKES.js} +152 -152
  44. package/dist/program-T4RVQKES.js.map +1 -0
  45. package/dist/{promote-YH3MNZFF.js → promote-L2VBS3X2.js} +2 -2
  46. package/dist/{query-BJC5VVS5.js → query-Y5XOM7X6.js} +2 -2
  47. package/dist/{registry-EJTSQEBJ.js → registry-ID2KMJCU.js} +2 -2
  48. package/dist/{request-planner-MKVEPQGA.js → request-planner-LHVZFBYR.js} +2 -2
  49. package/dist/{review-candidates-OAG3DMQR.js → review-candidates-ZNCQQKQS.js} +2 -2
  50. package/dist/{routing-U4VI4PAH.js → routing-OAYXRJ7K.js} +2 -2
  51. package/dist/schedule-tool-K7UHGVQ2.js +2 -0
  52. package/dist/scheduler-VQFLXG4Y.js +2 -0
  53. package/dist/{seat-CVOTWLEN.js → seat-Y7QTFQDR.js} +2 -2
  54. package/dist/{select-FNERAHZI.js → select-OC4FXEIP.js} +2 -2
  55. package/dist/{set-enabled-3C7AVZ65.js → set-enabled-QBPTP6I4.js} +2 -2
  56. package/dist/{tool-H7CDGZJ6.js → tool-MEAHEWD2.js} +2 -2
  57. package/dist/{tool-KR4MDO2R.js → tool-P257FZXB.js} +2 -2
  58. package/dist/wiki-SZH2KSLQ.js +2 -0
  59. package/package.json +1 -1
  60. package/dist/chunk-IBXHC3ZV.js.map +0 -1
  61. package/dist/config-mutator-L2LCW5ES.js +0 -2
  62. package/dist/program-6G2IS3WF.js.map +0 -1
  63. package/dist/schedule-tool-ZTTZBTXA.js +0 -2
  64. package/dist/scheduler-4RLL7YOW.js +0 -2
  65. package/dist/wiki-HA3U33SO.js +0 -2
  66. /package/dist/{App-Y62EOIB4.js.map → App-6PNLFYAK.js.map} +0 -0
  67. /package/dist/{ChatApp-VIGYSGG4.js.map → ChatApp-YYBPNTIA.js.map} +0 -0
  68. /package/dist/{agent-AGBADNWK.js.map → agent-THOKXUGD.js.map} +0 -0
  69. /package/dist/{board-dashboard-MHMYOBKA.js.map → board-dashboard-URZW2L6F.js.map} +0 -0
  70. /package/dist/{chunk-IWEN3YNQ.js.map → chunk-24EKSYR7.js.map} +0 -0
  71. /package/dist/{chunk-5EOFTPQB.js.map → chunk-3DB2X334.js.map} +0 -0
  72. /package/dist/{chunk-DS7CDASP.js.map → chunk-7V7GH57E.js.map} +0 -0
  73. /package/dist/{chunk-Q4R4OS5S.js.map → chunk-AP4HOAUV.js.map} +0 -0
  74. /package/dist/{chunk-C6PWAEVX.js.map → chunk-DE4SOF37.js.map} +0 -0
  75. /package/dist/{chunk-KA6K25T4.js.map → chunk-FQHY4A5H.js.map} +0 -0
  76. /package/dist/{chunk-T2IDDOSX.js.map → chunk-GE3ISITN.js.map} +0 -0
  77. /package/dist/{chunk-EWE2TJMN.js.map → chunk-HZJ4S2LT.js.map} +0 -0
  78. /package/dist/{chunk-HJUBCA7O.js.map → chunk-IEEZ3HJW.js.map} +0 -0
  79. /package/dist/{chunk-MIRBKQHA.js.map → chunk-KE3FB3SK.js.map} +0 -0
  80. /package/dist/{chunk-PS3UB5PH.js.map → chunk-NARH4ZMY.js.map} +0 -0
  81. /package/dist/{chunk-73NSSC6K.js.map → chunk-PNSAL5NB.js.map} +0 -0
  82. /package/dist/{chunk-OQHC7TJL.js.map → chunk-RQVRKBLH.js.map} +0 -0
  83. /package/dist/{chunk-BP52VVCW.js.map → chunk-VJEO7EOS.js.map} +0 -0
  84. /package/dist/{chunk-5PFTUGP4.js.map → chunk-VNVKNVGF.js.map} +0 -0
  85. /package/dist/{chunk-LKBUTSYP.js.map → chunk-VUUDZFRR.js.map} +0 -0
  86. /package/dist/{chunk-2MMC5LHF.js.map → chunk-WFZHADE2.js.map} +0 -0
  87. /package/dist/{chunk-M56MKNAG.js.map → chunk-ZGUC3EG6.js.map} +0 -0
  88. /package/dist/{chunk-74S7PKIX.js.map → chunk-ZGWDZ7X7.js.map} +0 -0
  89. /package/dist/{client-RMUEJMV5.js.map → client-EKGV6UYH.js.map} +0 -0
  90. /package/dist/{config-M2Z4HTZI.js.map → config-SF2U4WSJ.js.map} +0 -0
  91. /package/dist/{config-mutator-L2LCW5ES.js.map → config-mutator-XTYVB2LB.js.map} +0 -0
  92. /package/dist/{doctor-W6OQ7JI2.js.map → doctor-3BOJJYBX.js.map} +0 -0
  93. /package/dist/{promote-YH3MNZFF.js.map → promote-L2VBS3X2.js.map} +0 -0
  94. /package/dist/{query-BJC5VVS5.js.map → query-Y5XOM7X6.js.map} +0 -0
  95. /package/dist/{registry-EJTSQEBJ.js.map → registry-ID2KMJCU.js.map} +0 -0
  96. /package/dist/{request-planner-MKVEPQGA.js.map → request-planner-LHVZFBYR.js.map} +0 -0
  97. /package/dist/{review-candidates-OAG3DMQR.js.map → review-candidates-ZNCQQKQS.js.map} +0 -0
  98. /package/dist/{routing-U4VI4PAH.js.map → routing-OAYXRJ7K.js.map} +0 -0
  99. /package/dist/{schedule-tool-ZTTZBTXA.js.map → schedule-tool-K7UHGVQ2.js.map} +0 -0
  100. /package/dist/{scheduler-4RLL7YOW.js.map → scheduler-VQFLXG4Y.js.map} +0 -0
  101. /package/dist/{seat-CVOTWLEN.js.map → seat-Y7QTFQDR.js.map} +0 -0
  102. /package/dist/{select-FNERAHZI.js.map → select-OC4FXEIP.js.map} +0 -0
  103. /package/dist/{set-enabled-3C7AVZ65.js.map → set-enabled-QBPTP6I4.js.map} +0 -0
  104. /package/dist/{tool-H7CDGZJ6.js.map → tool-MEAHEWD2.js.map} +0 -0
  105. /package/dist/{tool-KR4MDO2R.js.map → tool-P257FZXB.js.map} +0 -0
  106. /package/dist/{wiki-HA3U33SO.js.map → wiki-SZH2KSLQ.js.map} +0 -0
@@ -51,6 +51,10 @@ final class CharacterHost {
51
51
  private var rest = CGPoint.zero
52
52
  /// Its body on screen: where it can be taken hold of.
53
53
  private var body = NSRect.zero
54
+ /// Its colours, and the point under it in screen points: play mode
55
+ /// (#505) takes it from there.
56
+ var stops: [NSColor] { view.stops }
57
+ var foot: NSPoint { NSPoint(x: CGFloat(sim.x), y: rest.y) }
54
58
  /// Carried by the pointer: where the pointer took hold, and where it
55
59
  /// stood then, which is not where it rests once it has stepped aside.
56
60
  private var carried: (from: NSPoint, rest: CGPoint)?
@@ -35,6 +35,13 @@ enum Config {
35
35
  set { UserDefaults.standard.set(newValue, forKey: "animatedOrb") }
36
36
  }
37
37
 
38
+ /// "Play mode" in the menu (#505): the character may play on a frozen
39
+ /// picture of the screen when asked. Off by default.
40
+ static var playMode: Bool {
41
+ get { UserDefaults.standard.bool(forKey: "playMode") }
42
+ set { UserDefaults.standard.set(newValue, forKey: "playMode") }
43
+ }
44
+
38
45
  /// Where the pill was last dragged to (its bottom-left corner, in
39
46
  /// screen points). Nil: the default corner.
40
47
  static var pillOrigin: CGPoint? {
@@ -0,0 +1,250 @@
1
+ import Foundation
2
+
3
+ /// Play mode (#505): the character leaves the bottom edge and plays on a
4
+ /// frozen picture of the screen. It walks along a line of text as on a
5
+ /// floor, jumps to another line, eats words and wipes a line. These are
6
+ /// the numbers only: where the words are, what it does and when, where it
7
+ /// is at a given moment and which letters are gone. Foundation only, so
8
+ /// the tests can run it without a window.
9
+ ///
10
+ /// Everything is in the picture's points, origin top-left, y downwards.
11
+ enum PlayMath {
12
+ typealias M = CharacterMath
13
+
14
+ struct Point: Equatable { var x = 0.0, y = 0.0 }
15
+
16
+ struct Rect: Equatable {
17
+ var x = 0.0, y = 0.0, w = 0.0, h = 0.0
18
+ var maxX: Double { x + w }
19
+ var maxY: Double { y + h }
20
+ var midX: Double { x + w / 2 }
21
+ var midY: Double { y + h / 2 }
22
+ }
23
+
24
+ struct Word: Equatable {
25
+ var text: String
26
+ var rect: Rect
27
+ }
28
+
29
+ /// One line of text as it was read, left to right, and the colour of
30
+ /// the page around it (0xRRGGBB): what shows where a word is gone.
31
+ struct Line: Equatable {
32
+ var words: [Word]
33
+ var rect: Rect
34
+ var paper: UInt32 = 0xFFFFFF
35
+ }
36
+
37
+ /// One move of a script.
38
+ enum Step: Equatable {
39
+ /// Jump onto the start of a line.
40
+ case jump(line: Int)
41
+ /// Walk along the top of a line to its end.
42
+ case walk(line: Int)
43
+ /// Go through a line and eat these words, letter by letter.
44
+ case eat(line: Int, words: Range<Int>)
45
+ /// Wipe a whole line with a cloth.
46
+ case wipe(line: Int)
47
+ /// Stand still, facing you, for so many seconds.
48
+ case rest(Double)
49
+ }
50
+
51
+ /// Points per drawing unit on the page: a body 40 points across,
52
+ /// smaller than on the bottom edge so it fits between lines.
53
+ static let unit = 0.4
54
+ /// The middle of its body above its feet, and its top, in points.
55
+ static let middle = (M.Pose().lift + 50) * unit
56
+ static let top = (M.Pose().lift + 100) * unit + 8
57
+ /// Points a second, and seconds a letter.
58
+ static let walkSpeed = 220.0
59
+ static let wipeSpeed = 260.0
60
+ static let perLetter = 0.09
61
+ /// Its mouth, ahead of its middle.
62
+ static let mouth = 10.0
63
+
64
+ /// A word's letters: the word's rectangle cut into equal parts, one a
65
+ /// character. An estimate, close enough to take them one at a time.
66
+ static func letters(of word: Word) -> [Rect] {
67
+ let n = max(word.text.count, 1), w = word.rect.w / Double(n)
68
+ return (0..<n).map { Rect(x: word.rect.x + Double($0) * w, y: word.rect.y, w: w, h: word.rect.h) }
69
+ }
70
+
71
+ /// The colour most of `samples` share: the page around a line. Close
72
+ /// shades count as one, so the soft edge of a letter does not win.
73
+ static func common(_ samples: [UInt32]) -> UInt32? {
74
+ var count: [UInt32: Int] = [:]
75
+ for s in samples { count[s & 0xF8F8F8, default: 0] += 1 }
76
+ guard let best = count.max(by: { ($0.value, $0.key) < ($1.value, $1.key) })?.key else { return nil }
77
+ return samples.first { $0 & 0xF8F8F8 == best }
78
+ }
79
+
80
+ /// The built-in script: the line nearest the middle of the page and
81
+ /// the two under it in the same column. It walks the first, eats the
82
+ /// start of the second and wipes the third. Lines too close to the top
83
+ /// to stand on, too short or of one word are left alone.
84
+ static func demo(_ lines: [Line], width: Double, height: Double) -> [Step] {
85
+ let fit = lines.indices.filter { lines[$0].words.count >= 2 && lines[$0].rect.w >= 120 && lines[$0].rect.y >= top + 40 }
86
+ func far(_ i: Int) -> Double { hypot(lines[i].rect.midX - width / 2, lines[i].rect.midY - height / 2) }
87
+ guard let first = fit.min(by: { far($0) < far($1) }) else { return [] }
88
+ let a = lines[first].rect
89
+ let under = fit.filter { lines[$0].rect.y > a.y + a.h / 2 && lines[$0].rect.x < a.maxX && lines[$0].rect.maxX > a.x }
90
+ .sorted { lines[$0].rect.y < lines[$1].rect.y }
91
+ let picked = [first] + under.prefix(2)
92
+ var steps: [Step] = [.jump(line: first), .walk(line: first)]
93
+ if picked.count > 1 { steps += [.eat(line: picked[1], words: 0..<min(3, lines[picked[1]].words.count))] }
94
+ else { steps += [.eat(line: first, words: 0..<min(2, lines[first].words.count))] }
95
+ if picked.count > 2 { steps += [.wipe(line: picked[2])] }
96
+ return steps + [.rest(1.2)]
97
+ }
98
+ }
99
+
100
+ /// A script laid out in time: every move has its start and its end, so
101
+ /// where the character is, and what is gone, depends on the time alone.
102
+ /// The same script gives the same frames on every run.
103
+ struct Play {
104
+ typealias P = PlayMath
105
+ typealias M = CharacterMath
106
+
107
+ enum Kind { case jump, walk, eat, wipe, rest }
108
+
109
+ struct Move {
110
+ var kind: Kind
111
+ var from, to: P.Point
112
+ var start, duration: Double
113
+ var line = 0
114
+ var words = 0..<0
115
+ var end: Double { start + duration }
116
+ }
117
+
118
+ struct Frame {
119
+ /// Its feet.
120
+ var at = P.Point()
121
+ /// -1 turned left, 0 facing you, 1 turned right.
122
+ var face = 0.0
123
+ var pose = M.Pose()
124
+ var t = 0.0
125
+ /// What is covered with the page's colour, and which colour.
126
+ var gone: [(rect: P.Rect, paper: UInt32)] = []
127
+ /// The cloth, while it wipes.
128
+ var cloth: P.Rect?
129
+ var done = false
130
+ }
131
+
132
+ let lines: [P.Line]
133
+ private(set) var moves: [Move] = []
134
+ var duration: Double { moves.last?.end ?? 0 }
135
+
136
+ /// `start` is where its feet are when play begins. Steps that name a
137
+ /// line or a word that is not there are skipped.
138
+ init(lines: [P.Line], steps: [P.Step], start: P.Point) {
139
+ self.lines = lines
140
+ var at = start, now = 0.0
141
+ func add(_ kind: Kind, to: P.Point, _ seconds: Double, line: Int = 0, words: Range<Int> = 0..<0) -> Move {
142
+ Move(kind: kind, from: at, to: to, start: now, duration: seconds, line: line, words: words)
143
+ }
144
+ /// Get to `to` first: a jump, unless it is already there.
145
+ func reach(_ to: P.Point, into list: inout [Move]) {
146
+ guard at != to else { return }
147
+ let m = add(.jump, to: to, 0.5 + hypot(to.x - at.x, to.y - at.y) / 900)
148
+ list.append(m); at = to; now = m.end
149
+ }
150
+ var list: [Move] = []
151
+ for step in steps {
152
+ switch step {
153
+ case .jump(let i):
154
+ guard lines.indices.contains(i) else { continue }
155
+ reach(P.Point(x: lines[i].rect.x, y: lines[i].rect.y), into: &list)
156
+ case .walk(let i):
157
+ guard lines.indices.contains(i) else { continue }
158
+ let r = lines[i].rect
159
+ if at.y != r.y || at.x < r.x || at.x > r.maxX { reach(P.Point(x: r.x, y: r.y), into: &list) }
160
+ let m = add(.walk, to: P.Point(x: r.maxX, y: r.y), (r.maxX - at.x) / P.walkSpeed, line: i)
161
+ list.append(m); at = m.to; now = m.end
162
+ case .eat(let i, let words):
163
+ guard lines.indices.contains(i), !words.isEmpty, words.upperBound <= lines[i].words.count else { continue }
164
+ // In the line, its middle at the middle of the letters.
165
+ let y = lines[i].rect.midY + P.middle
166
+ let first = lines[i].words[words.lowerBound].rect, last = lines[i].words[words.upperBound - 1].rect
167
+ reach(P.Point(x: first.x - P.mouth - 4, y: y), into: &list)
168
+ let count = lines[i].words[words].reduce(0) { $0 + max($1.text.count, 1) }
169
+ let m = add(.eat, to: P.Point(x: last.maxX, y: y), Double(count) * P.perLetter, line: i, words: words)
170
+ list.append(m); at = m.to; now = m.end
171
+ case .wipe(let i):
172
+ guard lines.indices.contains(i) else { continue }
173
+ let r = lines[i].rect
174
+ reach(P.Point(x: r.x, y: r.y), into: &list)
175
+ let m = add(.wipe, to: P.Point(x: r.maxX, y: r.y), r.w / P.wipeSpeed, line: i)
176
+ list.append(m); at = m.to; now = m.end
177
+ case .rest(let seconds):
178
+ let m = add(.rest, to: at, max(seconds, 0))
179
+ list.append(m); now = m.end
180
+ }
181
+ }
182
+ moves = list
183
+ }
184
+
185
+ func frame(at t: Double) -> Frame {
186
+ guard let i = moves.lastIndex(where: { $0.start <= t }) else { return Frame(done: true) }
187
+ let m = moves[i], p = M.span(t, m.start, m.end)
188
+ var f = Frame(t: t, done: t >= duration)
189
+
190
+ f.at = P.Point(x: m.from.x + (m.to.x - m.from.x) * p, y: m.from.y + (m.to.y - m.from.y) * p)
191
+ if m.kind == .jump {
192
+ // An arc over both ends, kept inside the picture.
193
+ let rise = 30 + hypot(m.to.x - m.from.x, m.to.y - m.from.y) * 0.12
194
+ f.at.y -= min(rise, max(min(m.from.y, m.to.y) - P.top, 0)) * 4 * p * (1 - p)
195
+ }
196
+
197
+ // The look of this move, reached from the look of the one before
198
+ // in a fifth of a second: the eyes and the body do not snap.
199
+ let into = M.ease(M.span(t, m.start, m.start + 0.2))
200
+ let before = i > 0 ? look(moves[i - 1]) : look(m)
201
+ let now = look(m)
202
+ f.face = before.face + (now.face - before.face) * into
203
+ f.pose = M.blend(before.pose, now.pose, eyes: into, body: into, marks: into)
204
+ switch m.kind {
205
+ case .walk: f.pose.lift += 3 * into * abs(sin(2 * .pi * t * 2.2))
206
+ case .jump:
207
+ let air = sin(.pi * p)
208
+ f.pose.sy *= 1 + 0.12 * air; f.pose.sx *= 1 - 0.08 * air
209
+ case .eat:
210
+ let bite = into * (0.5 + 0.5 * sin(2 * .pi * t / P.perLetter))
211
+ f.pose.sy *= 1 - 0.08 * bite; f.pose.sx *= 1 + 0.05 * bite
212
+ case .wipe: f.pose.tilt += 5 * into * sin(2 * .pi * t * 5)
213
+ case .rest: break
214
+ }
215
+
216
+ for done in moves[...i] {
217
+ let x = done.end <= t ? done.to.x : f.at.x
218
+ switch done.kind {
219
+ case .eat:
220
+ for word in lines[done.line].words[done.words] {
221
+ let gone = P.letters(of: word).filter { $0.maxX <= x + P.mouth }
222
+ guard let last = gone.last else { continue }
223
+ f.gone.append((cover(P.Rect(x: word.rect.x, y: word.rect.y, w: last.maxX - word.rect.x, h: word.rect.h)),
224
+ lines[done.line].paper))
225
+ }
226
+ case .wipe:
227
+ let r = lines[done.line].rect
228
+ if x > r.x { f.gone.append((cover(P.Rect(x: r.x, y: r.y, w: min(x, r.maxX) - r.x, h: r.h)), lines[done.line].paper)) }
229
+ if done.end > t { f.cloth = P.Rect(x: x - 9, y: r.y - 2, w: 18, h: r.h + 4) }
230
+ default: break
231
+ }
232
+ }
233
+ return f
234
+ }
235
+
236
+ /// A little more than the letters, so nothing of them shows at the edges.
237
+ private func cover(_ r: P.Rect) -> P.Rect { P.Rect(x: r.x - 1, y: r.y - 2, w: r.w + 2, h: r.h + 4) }
238
+
239
+ /// Which way it faces during a move, and its pose without the motion.
240
+ private func look(_ m: Move) -> (face: Double, pose: M.Pose) {
241
+ let way: Double = m.to.x >= m.from.x ? 1 : -1
242
+ switch m.kind {
243
+ case .jump: return (way, M.Pose(tilt: 6 * way, gy: -2))
244
+ case .walk: return (way, M.Pose(tilt: 7 * way))
245
+ case .eat: return (way, M.Pose(size: 1.12))
246
+ case .wipe: return (way, M.Pose(tilt: 12 * way, gy: 4, lid: 0.3))
247
+ case .rest: return (0, M.Pose())
248
+ }
249
+ }
250
+ }
@@ -0,0 +1,98 @@
1
+ import AppKit
2
+ import Vision
3
+
4
+ /// Play mode's stage (#505): one picture of the screen and the lines of
5
+ /// text on it. The picture is read here, on this Mac, by Apple's Vision
6
+ /// framework; it is kept in memory only and nothing is uploaded.
7
+ ///
8
+ /// Taking the picture needs the Screen Recording permission. Without it
9
+ /// macOS gives a picture of the desktop alone rather than an error, so the
10
+ /// permission is checked first.
11
+ enum PlayRead {
12
+ static var allowed: Bool { CGPreflightScreenCaptureAccess() }
13
+
14
+ /// Shows the system's request, once; after that macOS only answers no.
15
+ static func ask() { CGRequestScreenCaptureAccess() }
16
+
17
+ /// What is on `screen` now.
18
+ static func picture(of screen: NSScreen) -> CGImage? {
19
+ // Screens are measured from the bottom of the first one; the
20
+ // picture is asked for from its top.
21
+ guard let first = NSScreen.screens.first else { return nil }
22
+ let f = screen.frame
23
+ let rect = CGRect(x: f.minX, y: first.frame.maxY - f.maxY, width: f.width, height: f.height)
24
+ return CGWindowListCreateImage(rect, .optionOnScreenOnly, kCGNullWindowID, [.bestResolution])
25
+ }
26
+
27
+ /// The lines of text in `image`, top to bottom, in points of a picture
28
+ /// `size` across with its origin top-left. Slow (a second or so for a
29
+ /// whole screen): call it off the main thread.
30
+ static func lines(in image: CGImage, size: CGSize) -> [PlayMath.Line] {
31
+ let request = VNRecognizeTextRequest()
32
+ request.recognitionLevel = .accurate
33
+ request.recognitionLanguages = ["fr-FR", "en-US"]
34
+ // Where the letters are matters here, not what they spell.
35
+ request.usesLanguageCorrection = false
36
+ guard (try? VNImageRequestHandler(cgImage: image, options: [:]).perform([request])) != nil,
37
+ let found = request.results else { return [] }
38
+ let pixels = Pixels(image, size: size)
39
+
40
+ var lines: [PlayMath.Line] = []
41
+ for observation in found {
42
+ guard let text = observation.topCandidates(1).first else { continue }
43
+ let s = text.string
44
+ var words: [PlayMath.Word] = []
45
+ var i = s.startIndex
46
+ while i < s.endIndex {
47
+ guard !s[i].isWhitespace else { i = s.index(after: i); continue }
48
+ let end = s[i...].firstIndex(where: \.isWhitespace) ?? s.endIndex
49
+ // Vision's boxes are 0…1 from the bottom-left of the image.
50
+ if let b = (try? text.boundingBox(for: i..<end))?.boundingBox {
51
+ words.append(PlayMath.Word(text: String(s[i..<end]),
52
+ rect: PlayMath.Rect(x: b.minX * size.width, y: (1 - b.maxY) * size.height,
53
+ w: b.width * size.width, h: b.height * size.height)))
54
+ }
55
+ i = end
56
+ }
57
+ guard let first = words.first, let last = words.last else { continue }
58
+ let top = words.map(\.rect.y).min()!, bottom = words.map(\.rect.maxY).max()!
59
+ let rect = PlayMath.Rect(x: first.rect.x, y: top, w: last.rect.maxX - first.rect.x, h: bottom - top)
60
+ lines.append(PlayMath.Line(words: words, rect: rect, paper: pixels?.paper(around: rect) ?? 0xFFFFFF))
61
+ }
62
+ return lines.sorted { abs($0.rect.y - $1.rect.y) > 6 ? $0.rect.y < $1.rect.y : $0.rect.x < $1.rect.x }
63
+ }
64
+
65
+ /// The picture at one pixel a point, to read colours from.
66
+ private struct Pixels {
67
+ let data: [UInt8]
68
+ let width, height: Int
69
+
70
+ init?(_ image: CGImage, size: CGSize) {
71
+ let w = Int(size.width), h = Int(size.height)
72
+ guard w > 0, h > 0 else { return nil }
73
+ var bytes = [UInt8](repeating: 0, count: w * h * 4)
74
+ let drawn = bytes.withUnsafeMutableBytes { buffer -> Bool in
75
+ guard let ctx = CGContext(data: buffer.baseAddress, width: w, height: h, bitsPerComponent: 8,
76
+ bytesPerRow: w * 4, space: CGColorSpace(name: CGColorSpace.sRGB)!,
77
+ bitmapInfo: CGImageAlphaInfo.noneSkipLast.rawValue) else { return false }
78
+ ctx.draw(image, in: CGRect(x: 0, y: 0, width: w, height: h))
79
+ return true
80
+ }
81
+ guard drawn else { return nil }
82
+ data = bytes; width = w; height = h
83
+ }
84
+
85
+ /// The page's colour around a line: what most points just above
86
+ /// and just under it share.
87
+ func paper(around r: PlayMath.Rect) -> UInt32? {
88
+ var samples: [UInt32] = []
89
+ for y in [Int(r.y) - 3, Int(r.maxY) + 3] where y >= 0 && y < height {
90
+ for x in stride(from: max(Int(r.x), 0), to: min(Int(r.maxX), width), by: 3) {
91
+ let i = (y * width + x) * 4
92
+ samples.append(UInt32(data[i]) << 16 | UInt32(data[i + 1]) << 8 | UInt32(data[i + 2]))
93
+ }
94
+ }
95
+ return PlayMath.common(samples)
96
+ }
97
+ }
98
+ }
@@ -0,0 +1,164 @@
1
+ import AppKit
2
+
3
+ /// What play mode (#505) draws over the picture: the page's colour where
4
+ /// words are gone, the cloth, and the character. The context is flipped,
5
+ /// in the picture's points.
6
+ enum PlayDraw {
7
+ static func draw(_ frame: Play.Frame, in ctx: CGContext, stops: [NSColor]) {
8
+ for gone in frame.gone {
9
+ let c = gone.paper
10
+ ctx.setFillColor(CGColor(srgbRed: CGFloat(c >> 16 & 0xFF) / 255, green: CGFloat(c >> 8 & 0xFF) / 255,
11
+ blue: CGFloat(c & 0xFF) / 255, alpha: 1))
12
+ ctx.fill(cg(gone.rect))
13
+ }
14
+ if let cloth = frame.cloth {
15
+ let path = CGPath(roundedRect: cg(cloth), cornerWidth: 4, cornerHeight: 4, transform: nil)
16
+ ctx.addPath(path)
17
+ ctx.setFillColor(stops[4].cgColor)
18
+ ctx.fillPath()
19
+ ctx.addPath(path)
20
+ ctx.setStrokeColor(stops[1].cgColor)
21
+ ctx.setLineWidth(1)
22
+ ctx.strokePath()
23
+ }
24
+ let shown = CharacterSim.Frame(pose: frame.pose, x: frame.at.x, face: frame.face, t: frame.t)
25
+ CharacterDraw.draw(shown, in: ctx, edge: CGPoint(x: frame.at.x, y: frame.at.y), origin: 0,
26
+ unit: CGFloat(PlayMath.unit), stops: stops)
27
+ }
28
+
29
+ private static func cg(_ r: PlayMath.Rect) -> CGRect { CGRect(x: r.x, y: r.y, width: r.w, height: r.h) }
30
+ }
31
+
32
+ /// The stage: a frozen picture of the screen laid over everything, with
33
+ /// the character playing on it. The real page is never touched: nothing
34
+ /// is clicked, typed or changed, and what looks eaten or wiped is only
35
+ /// painted over on the picture.
36
+ ///
37
+ /// The page behind goes stale while the picture is up, so play ends on
38
+ /// the first real input: any key, a click, a scroll, another app or Space
39
+ /// coming forward. Unlike the character on its edge, this window does
40
+ /// take keys and clicks, for that one purpose.
41
+ @MainActor
42
+ final class PlayHost {
43
+ private var window: NSPanel?
44
+ private var timer: Timer?
45
+ private var observers: [(NotificationCenter, NSObjectProtocol)] = []
46
+ private var script: Play?
47
+ private var began = 0.0
48
+ /// Raised by each `start`, so a text read that outlives its play can
49
+ /// tell that the picture now up is not the one it read.
50
+ private(set) var number = 0
51
+ /// Told each time a play is over.
52
+ var onEnd: (() -> Void)?
53
+
54
+ var running: Bool { window != nil }
55
+
56
+ /// Lay `picture` over `screen`, the character waiting at `foot` (the
57
+ /// picture's points) until `play(_:)` gives it a script: reading the
58
+ /// text takes a moment, and the page must not move meanwhile.
59
+ func start(picture: CGImage, on screen: NSScreen, stops: [NSColor], at foot: PlayMath.Point) {
60
+ guard !running else { return }
61
+ script = nil
62
+ number += 1
63
+ let panel = PlayWindow(contentRect: screen.frame, styleMask: [.borderless, .nonactivatingPanel],
64
+ backing: .buffered, defer: false)
65
+ panel.level = .screenSaver
66
+ panel.collectionBehavior = [.fullScreenAuxiliary]
67
+ panel.hasShadow = false
68
+ panel.hidesOnDeactivate = false
69
+
70
+ let bounds = NSRect(origin: .zero, size: screen.frame.size)
71
+ let backdrop = NSView(frame: bounds)
72
+ backdrop.wantsLayer = true
73
+ backdrop.layer?.contents = picture
74
+ let view = PlayView(frame: bounds)
75
+ view.stops = stops
76
+ view.shown = Play.Frame(at: foot, pose: CharacterMath.pose(.working))
77
+ view.onInput = { [weak self] in self?.end("a key or a click") }
78
+ backdrop.addSubview(view)
79
+ panel.contentView = backdrop
80
+ panel.setFrame(screen.frame, display: true)
81
+ window = panel
82
+ panel.makeKeyAndOrderFront(nil)
83
+ panel.makeFirstResponder(view)
84
+
85
+ func watch(_ center: NotificationCenter, _ name: Notification.Name, _ object: Any?, _ why: String) {
86
+ observers.append((center, center.addObserver(forName: name, object: object, queue: .main) { [weak self] _ in
87
+ MainActor.assumeIsolated { self?.end(why) }
88
+ }))
89
+ }
90
+ watch(.default, NSWindow.didResignKeyNotification, panel, "another window came forward")
91
+ watch(NSWorkspace.shared.notificationCenter, NSWorkspace.activeSpaceDidChangeNotification, nil, "the Space changed")
92
+ watch(.default, NSApplication.didChangeScreenParametersNotification, nil, "the screens changed")
93
+
94
+ let shownAt = ProcessInfo.processInfo.systemUptime
95
+ let frames = Timer(timeInterval: 1.0 / 30, repeats: true) { [weak self, weak view] _ in
96
+ MainActor.assumeIsolated {
97
+ guard let self, let view else { return }
98
+ let now = ProcessInfo.processInfo.systemUptime
99
+ if let script = self.script {
100
+ view.shown = script.frame(at: now - self.began)
101
+ } else {
102
+ // Its thinking dots hop while it waits.
103
+ view.shown.t = now - shownAt
104
+ }
105
+ view.needsDisplay = true
106
+ if view.shown.done { self.end("the script ended") }
107
+ }
108
+ }
109
+ RunLoop.main.add(frames, forMode: .common)
110
+ timer = frames
111
+ }
112
+
113
+ /// The script to play on the picture that is up. Nothing happens if
114
+ /// the play has ended meanwhile.
115
+ func play(_ script: Play) {
116
+ guard running, self.script == nil else { return }
117
+ self.script = script
118
+ began = ProcessInfo.processInfo.systemUptime
119
+ Log.info("play: started, \(script.lines.count) lines read, \(String(format: "%.1f", script.duration)) s")
120
+ }
121
+
122
+ /// Take the picture away and drop it. Safe to call when no play runs.
123
+ func end(_ why: String) {
124
+ guard let panel = window else { return }
125
+ window = nil
126
+ timer?.invalidate()
127
+ timer = nil
128
+ for (center, observer) in observers { center.removeObserver(observer) }
129
+ observers = []
130
+ panel.orderOut(nil)
131
+ panel.contentView?.layer?.contents = nil
132
+ panel.contentView = nil
133
+ Log.info("play: ended, \(why)")
134
+ onEnd?()
135
+ }
136
+ }
137
+
138
+ /// Borderless, and still able to take the keys that end the play.
139
+ private final class PlayWindow: NSPanel {
140
+ override var canBecomeKey: Bool { true }
141
+ }
142
+
143
+ /// The drawing over the picture. Every key, click and scroll ends the play.
144
+ private final class PlayView: NSView {
145
+ var shown = Play.Frame()
146
+ var stops = CharacterDraw.stops(tint: Brand.accent, colors: nil)
147
+ var onInput: (() -> Void)?
148
+
149
+ override var isFlipped: Bool { true }
150
+ override var acceptsFirstResponder: Bool { true }
151
+ override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true }
152
+
153
+ override func draw(_ dirtyRect: NSRect) {
154
+ guard let ctx = NSGraphicsContext.current?.cgContext else { return }
155
+ PlayDraw.draw(shown, in: ctx, stops: stops)
156
+ }
157
+
158
+ override func keyDown(with event: NSEvent) { onInput?() }
159
+ override func performKeyEquivalent(with event: NSEvent) -> Bool { onInput?(); return true }
160
+ override func mouseDown(with event: NSEvent) { onInput?() }
161
+ override func rightMouseDown(with event: NSEvent) { onInput?() }
162
+ override func otherMouseDown(with event: NSEvent) { onInput?() }
163
+ override func scrollWheel(with event: NSEvent) { onInput?() }
164
+ }
@@ -55,6 +55,9 @@ final class StatusMenu: NSObject, NSMenuDelegate {
55
55
  /// Bring a hidden character back, and let its bubble show again.
56
56
  var onShowCharacter: (() -> Void)?
57
57
  var onSettings: (() -> Void)?
58
+ /// Play mode (#505): whether a play can start now, and start one.
59
+ var playReady: () -> Bool = { false }
60
+ var onPlay: (() -> Void)?
58
61
 
59
62
  override init() {
60
63
  super.init()
@@ -248,6 +251,14 @@ final class StatusMenu: NSObject, NSMenuDelegate {
248
251
  let orb = action("Animated orb", #selector(toggleOrb), key: "")
249
252
  orb.state = Config.animatedOrb ? .on : .off
250
253
  menu.addItem(orb)
254
+ let mode = action("Play mode", #selector(togglePlayMode), key: "")
255
+ mode.state = Config.playMode ? .on : .off
256
+ menu.addItem(mode)
257
+ if Config.playMode {
258
+ let play = action("Play on this page", #selector(startPlay), key: "")
259
+ play.isEnabled = playReady()
260
+ menu.addItem(play)
261
+ }
251
262
  // The character hides with its bubble, and has no bubble at rest.
252
263
  switch characterVisible() {
253
264
  case true?: menu.addItem(action("Hide character", #selector(hidePill), key: ""))
@@ -313,6 +324,9 @@ final class StatusMenu: NSObject, NSMenuDelegate {
313
324
  onAnimatedOrbChanged?(Config.animatedOrb)
314
325
  }
315
326
 
327
+ @objc private func togglePlayMode() { Config.playMode.toggle() }
328
+ @objc private func startPlay() { onPlay?() }
329
+
316
330
  @objc private func hidePill() { onHidePill?() }
317
331
  @objc private func showCharacter() { onShowCharacter?() }
318
332
  @objc private func resetPosition() { onResetPosition?() }
@@ -20,6 +20,8 @@ final class App: NSObject, NSApplicationDelegate {
20
20
  private let statusMenu = StatusMenu()
21
21
  /// Stands in for the pill's orb when voice.look is "character".
22
22
  private let character = CharacterHost()
23
+ /// The character playing on a frozen picture of the screen (#505).
24
+ private let play = PlayHost()
23
25
  /// The agent whose by-name answer is on screen now, while no turn runs.
24
26
  private var asideSpeaker: String?
25
27
  private let recorder = Recorder()
@@ -216,6 +218,52 @@ final class App: NSObject, NSApplicationDelegate {
216
218
  Log.info("settings: talk \(saved.general.hotkeys.talk), speech to text \(saved.general.stt)")
217
219
  }
218
220
 
221
+ /// A play can start: the character shows and may move, and the
222
+ /// assistant has nothing to say or hear.
223
+ private var canPlay: Bool {
224
+ character.onScreen == true && Config.animatedOrb
225
+ && !NSWorkspace.shared.accessibilityDisplayShouldReduceMotion
226
+ && !busy && !recorder.isRecording && !play.running
227
+ }
228
+
229
+ /// "Play on this page" (#505): one picture of the character's screen, the
230
+ /// lines of text read on it, and the character plays the built-in
231
+ /// script on the picture. The page itself is never touched.
232
+ private func startPlay() {
233
+ let foot = character.foot
234
+ guard canPlay, let screen = NSScreen.screens.first(where: { $0.frame.contains(foot) }) ?? NSScreen.screens.first
235
+ else { return }
236
+ guard PlayRead.allowed else {
237
+ PlayRead.ask()
238
+ Log.warn("play: no Screen Recording permission; allow AgentX Voice in System Settings, then quit and reopen it")
239
+ panel.render(.error("Allow Screen Recording"))
240
+ resetSoon()
241
+ return
242
+ }
243
+ let stops = character.stops, size = screen.frame.size
244
+ let start = PlayMath.Point(x: foot.x - screen.frame.minX, y: screen.frame.maxY - foot.y)
245
+ // Off its edge, so it is not in the picture twice.
246
+ character.setShown(false)
247
+ Task { @MainActor in
248
+ try? await Task.sleep(nanoseconds: 150_000_000)
249
+ guard let picture = PlayRead.picture(of: screen), !busy, !recorder.isRecording else {
250
+ character.setShown(settings?.general.look == "character")
251
+ return
252
+ }
253
+ play.start(picture: picture, on: screen, stops: stops, at: start)
254
+ let number = play.number
255
+ let asked = ProcessInfo.processInfo.systemUptime
256
+ let lines = await Task.detached { PlayRead.lines(in: picture, size: size) }.value
257
+ Log.info("play: \(lines.count) lines read in \(Int((ProcessInfo.processInfo.systemUptime - asked) * 1000)) ms")
258
+ // Ended while its text was read, and maybe started again since:
259
+ // these lines belong to a picture that is gone.
260
+ guard play.running, play.number == number else { return }
261
+ let steps = PlayMath.demo(lines, width: size.width, height: size.height)
262
+ if steps.isEmpty { play.end("no line of text to play on") }
263
+ else { play.play(Play(lines: lines, steps: steps, start: start)) }
264
+ }
265
+ }
266
+
219
267
  /// Who the pill names and whose colour its orb wears: the agent a
220
268
  /// shortcut is asking, a by-name answer being spoken, else the agent
221
269
  /// our turn is asking, else the target.
@@ -337,6 +385,14 @@ final class App: NSObject, NSApplicationDelegate {
337
385
  }
338
386
  panel.onLook = { [weak self] state, tint, colors in
339
387
  self?.character.show(state.activity, tint: tint, colors: colors)
388
+ // The assistant has something to show: play gives way.
389
+ if state.activity != .idle { self?.play.end("the assistant is busy") }
390
+ }
391
+ statusMenu.playReady = { [weak self] in self?.canPlay ?? false }
392
+ statusMenu.onPlay = { [weak self] in self?.startPlay() }
393
+ play.onEnd = { [weak self] in
394
+ guard let self else { return }
395
+ self.character.setShown(self.settings?.general.look == "character")
340
396
  }
341
397
  panel.onShown = { [weak self] in self?.character.redraw() }
342
398
  panel.onDismiss = { [weak self] in self?.dismissPill() }
@@ -548,6 +604,7 @@ final class App: NSObject, NSApplicationDelegate {
548
604
  // A held key overrides any hands-free window that is open, so the
549
605
  // two ways of talking never fight over the microphone.
550
606
  stopPolling()
607
+ play.end("the talk key")
551
608
  guard !recorder.isRecording else { return }
552
609
  // A dismissed pill comes back with the talk key.
553
610
  summonPill()
@@ -18,7 +18,7 @@ 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 CoreML -framework SwiftUI \
21
+ -framework AppKit -framework AVFoundation -framework Carbon -framework CoreML -framework SwiftUI -framework Vision \
22
22
  -target arm64-apple-macosx14.0
23
23
 
24
24
  # The on-device models from a terminal: fetch them, transcribe a file, see