agentix-cli 0.98.0 → 0.99.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.
@@ -0,0 +1,49 @@
1
+ import CoreGraphics
2
+ import Foundation
3
+
4
+ /// Guiding (#482): the answering agent sends the character to something
5
+ /// on screen. Where it stands to show it, and the mark it leaves there.
6
+ /// Kept free of AppKit so the tests can check it without a window.
7
+ /// Rectangles are in screen coordinates, origin bottom-left, as AppKit
8
+ /// uses them, once toAppKit has turned them.
9
+ enum GuideMath {
10
+ enum Mark: String {
11
+ case box, circle, underline, none
12
+ }
13
+
14
+ /// Accessibility → AppKit: the daemon's rectangles are measured from
15
+ /// the top of the primary display, whichever screen they are on.
16
+ static func toAppKit(_ r: CGRect, primaryHeight: CGFloat) -> CGRect {
17
+ CGRect(x: r.minX, y: primaryHeight - r.maxY, width: r.width, height: r.height)
18
+ }
19
+
20
+ /// The point under the character while it shows `rect`: beside it,
21
+ /// its body level with the middle. On the left, away from which its
22
+ /// bubble reaches, so the bubble does not cover what it shows; on the
23
+ /// right when the left has no room in `visible`.
24
+ static func stand(beside rect: CGRect, in visible: CGRect, body: CGFloat, tall: CGFloat,
25
+ inset: CGFloat, gap: CGFloat = 14) -> CGPoint {
26
+ let left = rect.minX - gap - body / 2
27
+ let x = left >= visible.minX + inset ? left : rect.maxX + gap + body / 2
28
+ return CGPoint(x: x, y: rect.midY - tall / 2)
29
+ }
30
+
31
+ /// What the mark is drawn along, around `rect`: a box a little wider
32
+ /// than it, an oval that clears its corners, or a line under it.
33
+ static func outline(_ mark: Mark, around rect: CGRect) -> CGRect {
34
+ switch mark {
35
+ case .box: return rect.insetBy(dx: -5, dy: -5)
36
+ // An oval through a rectangle's corners is √2 times its size.
37
+ case .circle: return rect.insetBy(dx: -(rect.width * 0.21 + 6), dy: -(rect.height * 0.21 + 6))
38
+ case .underline: return CGRect(x: rect.minX - 2, y: rect.minY - 5, width: rect.width + 4, height: 0)
39
+ case .none: return .null
40
+ }
41
+ }
42
+
43
+ /// One frame of its way up or down to where it is sent: most of the
44
+ /// way in a third of a second, and there once it is within a point.
45
+ static func glide(_ y: CGFloat, toward goal: CGFloat, dt: Double) -> CGFloat {
46
+ let next = y + (goal - y) * CGFloat(1 - exp(-7 * min(max(dt, 0), 0.1)))
47
+ return abs(goal - next) < 1 ? goal : next
48
+ }
49
+ }
@@ -0,0 +1,39 @@
1
+ import CoreGraphics
2
+ import Foundation
3
+
4
+ /// What the character meets on the live screen (#539): the sides of the
5
+ /// windows that stand on its line. Read from the window list, which gives
6
+ /// each window's rectangle and no picture, title or text, so it needs no
7
+ /// permission.
8
+ enum Meets {
9
+ /// A window smaller than this is not something to stop at.
10
+ static let least = CGSize(width: 80, height: 40)
11
+
12
+ /// Where the sides of `windows`, front to back, cross `band`: each
13
+ /// side only where no window in front covers it. One set of
14
+ /// coordinates for both.
15
+ static func edges(of windows: [CGRect], band: ClosedRange<CGFloat>) -> [Double] {
16
+ var found: [Double] = []
17
+ for (i, window) in windows.enumerated() {
18
+ let low = max(window.minY, band.lowerBound), high = min(window.maxY, band.upperBound)
19
+ guard window.width >= least.width, window.height >= least.height, low < high else { continue }
20
+ let y = (low + high) / 2
21
+ for x in [window.minX, window.maxX] {
22
+ let covered = windows[..<i].contains { $0.minX < x && x < $0.maxX && $0.minY <= y && y < $0.maxY }
23
+ if !covered { found.append(Double(x)) }
24
+ }
25
+ }
26
+ return found
27
+ }
28
+
29
+ /// The ordinary windows on screen, front to back, measured from the
30
+ /// top left of the main screen.
31
+ static func windows() -> [CGRect] {
32
+ let list = CGWindowListCopyWindowInfo([.optionOnScreenOnly, .excludeDesktopElements], kCGNullWindowID) as? [[String: Any]] ?? []
33
+ return list.compactMap { window in
34
+ guard window[kCGWindowLayer as String] as? Int == 0, window[kCGWindowAlpha as String] as? Double != 0,
35
+ let bounds = window[kCGWindowBounds as String] else { return nil }
36
+ return CGRect(dictionaryRepresentation: bounds as! CFDictionary)
37
+ }
38
+ }
39
+ }
@@ -570,6 +570,15 @@ final class Panel: NSPanel {
570
570
  onRender?(state)
571
571
  }
572
572
 
573
+ /// On screen with something to use: an answer, an error or the call
574
+ /// buttons. The character does not play with the pointer then (#537).
575
+ @MainActor
576
+ var holdsSomething: Bool {
577
+ guard isVisible else { return false }
578
+ if case .error = current { return true }
579
+ return expanded || callBar.mode != .hidden
580
+ }
581
+
573
582
  /// The call buttons for this moment of a call; `.hidden` outside one.
574
583
  @MainActor
575
584
  func showCall(_ mode: CallBar.Mode) {
@@ -0,0 +1,137 @@
1
+ import Foundation
2
+
3
+ /// Play with the pointer (#505): with "Play mode" ticked, an idle
4
+ /// character does more than step aside. When the pointer moves within
5
+ /// its sight it starts a game: it follows it along the edge, crouches
6
+ /// and jumps at it like a cat, or runs away from it. Between games it
7
+ /// rests, and the usual rules hold. A click in its sight makes it jump.
8
+ ///
9
+ /// It only decides where the character wants to be and how it crouches
10
+ /// and hops; `CharacterSim` moves it. Foundation only, and the same
11
+ /// games in the same order on every run, so the tests can count on them.
12
+ struct PointerPlay {
13
+ typealias M = CharacterMath
14
+
15
+ enum Game: Equatable { case follow, pounce, flee }
16
+
17
+ struct Out: Equatable {
18
+ /// Where it wants to be along the edge. Nil: no game, the usual rules.
19
+ var target: Double?
20
+ /// 0…1 crouched, and the height of a hop in drawing units.
21
+ var crouch = 0.0
22
+ var hop = 0.0
23
+ /// It came down on the pointer just now.
24
+ var caught = false
25
+ }
26
+
27
+ /// How far along the edge and above it the pointer is seen, in points.
28
+ static let sight = (x: 520.0, y: 360.0)
29
+ /// A pointer slower than this, in points a second, starts no game.
30
+ static let stir = 80.0
31
+ /// Following, it stops this short of the pointer.
32
+ static let gap = 70.0
33
+ /// It jumps at a pointer no further than this, and no higher.
34
+ static let leap = (x: 220.0, y: 150.0)
35
+ /// It has caught a pointer this close to where it comes down.
36
+ static let grip = 45.0
37
+ /// Running away, it puts this much between the pointer and itself.
38
+ static let far = 320.0
39
+ /// Seconds: the crouch before a jump, the jump, running away, and
40
+ /// the jump a click gives it.
41
+ static let wind = 0.5, air = 0.6, flight = 2.5, start = 0.35
42
+ /// Drawing units: how high it jumps at the pointer, and at a click.
43
+ static let high = 70.0, jolt = 34.0
44
+
45
+ private(set) var game: Game?
46
+ private(set) var games = 0
47
+ private var began = 0.0, until = 0.0, next = 0.0
48
+ private var jumpTo: Double?
49
+ private var seen: (x: Double, y: Double)?
50
+ private var down = false
51
+ private var jolted: Double?
52
+
53
+ /// One frame. `x` is where the character is; `pointer` along the edge
54
+ /// and above it, nil on another screen; `down` the mouse button.
55
+ mutating func step(_ now: Double, _ dt: Double, x: Double, pointer: (x: Double, y: Double)?,
56
+ down: Bool, range: ClosedRange<Double>) -> Out {
57
+ let clicked = down && !self.down
58
+ self.down = down
59
+ let before = seen
60
+ seen = pointer
61
+ guard let p = pointer, abs(p.x - x) < Self.sight.x, abs(p.y) < Self.sight.y else {
62
+ if game != nil { end(now) }
63
+ return Out(hop: hop(now))
64
+ }
65
+ func inside(_ v: Double) -> Double { min(max(v, range.lowerBound), range.upperBound) }
66
+
67
+ // A click startles it: a small jump, and the game is over.
68
+ if clicked && jumpTo == nil {
69
+ jolted = now
70
+ if game != nil { end(now) }
71
+ }
72
+
73
+ if game == nil, now >= next, jolted == nil, let was = before, dt > 0,
74
+ hypot(p.x - was.x, p.y - was.y) / dt > Self.stir {
75
+ games += 1
76
+ let pick = M.rnd(games, 7)
77
+ let reach = abs(p.x - x) < Self.leap.x && abs(p.y) < Self.leap.y
78
+ game = pick < 0.5 ? .follow : pick < 0.8 ? (reach ? .pounce : .follow) : .flee
79
+ began = now
80
+ jumpTo = nil
81
+ until = now + (game == .follow ? 4 + 3 * M.rnd(games, 2) : game == .flee ? Self.flight : Self.wind + Self.air)
82
+ }
83
+
84
+ var out = Out(hop: hop(now))
85
+ switch game {
86
+ case nil:
87
+ break
88
+ case .follow:
89
+ // On the side it is on, a little short of the pointer.
90
+ out.target = inside(p.x + (x >= p.x ? Self.gap : -Self.gap))
91
+ case .pounce:
92
+ let t = now - began
93
+ if t < Self.wind {
94
+ out.target = x
95
+ out.crouch = M.ease(t / (Self.wind * 0.8))
96
+ } else {
97
+ // It jumps at where the pointer was when it let go.
98
+ if jumpTo == nil { jumpTo = inside(p.x) }
99
+ let a = M.span(t, Self.wind, Self.wind + Self.air)
100
+ out.target = jumpTo
101
+ out.crouch = 1 - M.ease(a / 0.25)
102
+ out.hop = Self.high * sin(.pi * a)
103
+ }
104
+ case .flee:
105
+ out.target = abs(p.x - x) < Self.far ? M.aside(x: x, pointer: p.x, clear: Self.far, range: range) : x
106
+ }
107
+ if game != nil && now >= until {
108
+ out.caught = game == .pounce && abs(p.x - x) < Self.grip && abs(p.y) < Self.leap.y
109
+ end(now)
110
+ }
111
+ return out
112
+ }
113
+
114
+ /// The assistant has work, or play is off: no game, and none at once
115
+ /// after. The button is still followed, so one held through it is no click.
116
+ mutating func stop(_ now: Double, down: Bool) {
117
+ if game != nil { end(now) }
118
+ self.down = down
119
+ jolted = nil
120
+ seen = nil
121
+ }
122
+
123
+ /// A game is over: a rest of five to thirteen seconds before the next.
124
+ private mutating func end(_ now: Double) {
125
+ game = nil
126
+ jumpTo = nil
127
+ next = now + 5 + 8 * M.rnd(games, 3)
128
+ }
129
+
130
+ /// The jump a click gives it.
131
+ private mutating func hop(_ now: Double) -> Double {
132
+ guard let at = jolted else { return 0 }
133
+ let a = (now - at) / Self.start
134
+ if a >= 1 { jolted = nil; return 0 }
135
+ return Self.jolt * sin(.pi * a)
136
+ }
137
+ }
@@ -85,6 +85,7 @@ final class App: NSObject, NSApplicationDelegate {
85
85
 
86
86
  // An agent ringing the owner (#321).
87
87
  private let callWatcher = CallWatcher()
88
+ private let guideWatcher = GuideWatcher()
88
89
  private let ringer = Ringer()
89
90
  /// The call ringing on the pill now.
90
91
  private var ringingCall: IncomingCall?
@@ -392,6 +393,7 @@ final class App: NSObject, NSApplicationDelegate {
392
393
  panel.attach(head: head, visible: visible)
393
394
  return panel.isVisible ? panel.frame : nil
394
395
  }
396
+ character.bubbleShows = { [weak self] in self?.panel.holdsSomething ?? false }
395
397
  panel.onLook = { [weak self] state, tint, colors in
396
398
  self?.character.show(state.activity, tint: tint, colors: colors)
397
399
  // The assistant has something to show: play gives way.
@@ -507,6 +509,11 @@ final class App: NSObject, NSApplicationDelegate {
507
509
  return CallModel.pollQuery(ringing: self.ringingCall?.id ?? self.answeringCall, canRing: self.canRing)
508
510
  }
509
511
  callWatcher.start()
512
+ // The answering agent sends the character to something (#482).
513
+ guideWatcher.wanted = { [weak self] in self?.character.onScreen == true }
514
+ guideWatcher.onCommand = { [weak self] rect, mark in self?.character.guide(to: rect, mark: mark) }
515
+ guideWatcher.start()
516
+ character.onScreenChanged = { [weak self] in self?.guideWatcher.start() }
510
517
 
511
518
  // The target does not wait for the microphone: the menu shows it
512
519
  // either way.