Ambient system companions over one privacy-preserving signal daemon (aggregate-only, no keystroke content): a git-driven terminal garden and IOKit hardware collectors. ambient daemon macos privacy terminal

crates/terminal-pet/src/lib.rs

355 lines · 12444 bytes

  1//! # terminal-pet
  2//!
  3//! The second face: a pet whose mood follows the shell and the machine.
  4//!
  5//! The garden renders the four git aggregates. Everything else on the bus —
  6//! the shell's keystroke rate and session length, the five hardware scalars —
  7//! had no reader at all, which meant the collectors producing them could stop
  8//! without anything visibly changing. This is their reader.
  9//!
 10//! It is also where the daemon's own health becomes visible.
 11//! [`SignalName::CollectorUp`] at `0.0` makes the pet sick, and that outranks
 12//! every other mood: a pet that cannot feel its own hardware should look wrong
 13//! rather than look calm.
 14//!
 15//! Stateless by design — [`PetState`] is a pure function of the current
 16//! snapshot, so there is no persistence to corrupt and nothing to migrate.
 17
 18use std::collections::BTreeMap;
 19
 20use signal_schema::{Signal, SignalName, Source};
 21
 22/// A signal older than this is treated as stale: the collector may still be up
 23/// while the value behind it stopped moving. Comfortably past the daemon's 2s
 24/// default tick, so a healthy system never flickers.
 25pub const STALE_AFTER_MS: u64 = 30_000;
 26
 27/// Keystrokes per minute at or above which the pet reads as busy.
 28pub const BUSY_KEYS_PER_MIN: f64 = 60.0;
 29/// `ProcessInfo` thermal state at or above which the pet reads as overheating.
 30pub const HOT_THERMAL_STATE: f64 = 2.0;
 31/// Battery percentage below which an unplugged machine reads as flagging.
 32pub const LOW_BATTERY_PCT: f64 = 20.0;
 33
 34/// One metric as the pet sees it.
 35#[derive(Debug, Clone, Copy, PartialEq)]
 36pub struct Reading {
 37    pub value: f64,
 38    /// The value has not been refreshed within [`STALE_AFTER_MS`]. Rendered
 39    /// dimmed: "up but stalled" is not the same as "down".
 40    pub stale: bool,
 41}
 42
 43/// What the pet is doing, in precedence order.
 44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
 45pub enum Mood {
 46    /// A collector is down. Outranks everything else.
 47    Sick,
 48    Overheating,
 49    LowBattery,
 50    Busy,
 51    Calm,
 52    Sleeping,
 53}
 54
 55impl Mood {
 56    pub fn face(self) -> &'static str {
 57        match self {
 58            Mood::Sick => "(x_x)",
 59            Mood::Overheating => "(>_<)",
 60            Mood::LowBattery => "(-_-)",
 61            Mood::Busy => "(o_o)",
 62            Mood::Calm => "(^_^)",
 63            Mood::Sleeping => "(-.-)",
 64        }
 65    }
 66
 67    pub fn label(self) -> &'static str {
 68        match self {
 69            Mood::Sick => "sick",
 70            Mood::Overheating => "overheating",
 71            Mood::LowBattery => "flagging",
 72            Mood::Busy => "busy",
 73            Mood::Calm => "calm",
 74            Mood::Sleeping => "asleep",
 75        }
 76    }
 77}
 78
 79/// The pet, derived from one snapshot.
 80#[derive(Debug, Clone, PartialEq)]
 81pub struct PetState {
 82    pub mood: Mood,
 83    /// Collectors reporting down, in wire order.
 84    pub down: Vec<Source>,
 85    pub keys_per_min: Option<Reading>,
 86    pub session_seconds: Option<Reading>,
 87    pub cpu_load: Option<Reading>,
 88    pub thermal_state: Option<Reading>,
 89    pub battery_pct: Option<Reading>,
 90    pub charging: Option<Reading>,
 91    pub battery_draw_w: Option<Reading>,
 92}
 93
 94/// Fold a snapshot into the pet.
 95///
 96/// `now_ms` is passed rather than read so the staleness rule is testable.
 97pub fn pet_from_signals(signals: &[Signal], now_ms: u64) -> PetState {
 98    let mut latest: BTreeMap<(u8, u8), &Signal> = BTreeMap::new();
 99    for s in signals {
100        latest.insert((s.name.to_u8(), s.source.to_u8()), s);
101    }
102
103    let read = |name: SignalName| -> Option<Reading> {
104        latest
105            .iter()
106            .find(|((n, _), _)| *n == name.to_u8())
107            .map(|(_, s)| Reading {
108                value: s.value.0,
109                stale: now_ms.saturating_sub(s.ts) > STALE_AFTER_MS,
110            })
111    };
112
113    let mut down: Vec<Source> = latest
114        .iter()
115        .filter(|((n, _), s)| *n == SignalName::CollectorUp.to_u8() && s.value.0 == 0.0)
116        .filter_map(|((_, src), _)| Source::from_u8(*src))
117        .collect();
118    down.sort_by_key(|s| s.to_u8());
119
120    let keys_per_min = read(SignalName::KeysPerMin);
121    let thermal_state = read(SignalName::ThermalState);
122    let battery_pct = read(SignalName::BatteryPct);
123    let charging = read(SignalName::Charging);
124
125    // Mood is driven only by readings that are still current. A cached value
126    // outlives what it described: the terminal collector stops publishing once
127    // a session falls outside its active window, so the last keystroke rate
128    // sits in the daemon's cache indefinitely and would otherwise leave the pet
129    // looking busy long after the typing stopped.
130    let fresh = |r: Option<Reading>| r.filter(|r| !r.stale).map(|r| r.value);
131
132    let plugged_in = fresh(charging).map(|c| c >= 1.0).unwrap_or(false);
133    let mood = if !down.is_empty() {
134        Mood::Sick
135    } else if fresh(thermal_state).map(|t| t >= HOT_THERMAL_STATE).unwrap_or(false) {
136        Mood::Overheating
137    } else if fresh(battery_pct).map(|b| b < LOW_BATTERY_PCT).unwrap_or(false) && !plugged_in {
138        Mood::LowBattery
139    } else {
140        match fresh(keys_per_min) {
141            Some(k) if k >= BUSY_KEYS_PER_MIN => Mood::Busy,
142            Some(k) if k > 0.0 => Mood::Calm,
143            _ => Mood::Sleeping,
144        }
145    };
146
147    PetState {
148        mood,
149        down,
150        keys_per_min,
151        session_seconds: read(SignalName::SessionSeconds),
152        cpu_load: read(SignalName::CpuLoad),
153        thermal_state,
154        battery_pct,
155        charging,
156        battery_draw_w: read(SignalName::BatteryDrawW),
157    }
158}
159
160/// Render the pet as a text block.
161pub fn render(state: &PetState) -> String {
162    let mut out = String::from("terminal-pet\n============\n\n");
163    out.push_str(&format!("  {}   {}\n\n", state.mood.face(), state.mood.label()));
164
165    let row = |label: &str, reading: Option<Reading>, unit: &str| -> String {
166        match reading {
167            None => format!("  {label:<16}\n"),
168            Some(r) => {
169                let mark = if r.stale { "  (stale)" } else { "" };
170                format!("  {label:<16} {:.2}{unit}{mark}\n", r.value)
171            }
172        }
173    };
174    out.push_str(&row("keys/min", state.keys_per_min, ""));
175    out.push_str(&row("session", state.session_seconds, "s"));
176    out.push_str(&row("cpu load", state.cpu_load, ""));
177    out.push_str(&row("thermal", state.thermal_state, ""));
178    out.push_str(&row("battery", state.battery_pct, "%"));
179    out.push_str(&row("draw", state.battery_draw_w, "W"));
180
181    if !state.down.is_empty() {
182        out.push_str("\n  collectors down:");
183        for source in &state.down {
184            out.push_str(&format!(" {source:?}"));
185        }
186        out.push('\n');
187    }
188    out
189}
190
191/// Render the pet as a single line for a shell prompt: the face, and the
192/// keystroke rate when the terminal collector has one.
193///
194/// Deliberately short and fixed-ish in width — it shares a prompt with
195/// everything else, and a segment that changes width every keystroke moves the
196/// rest of the prompt around.
197pub fn oneline(state: &PetState) -> String {
198    match state.keys_per_min {
199        Some(k) if !k.stale => format!("{} {:.0}k/m", state.mood.face(), k.value),
200        // A stale or absent rate is not worth a number: it would read as
201        // current and be minutes old.
202        _ => state.mood.face().to_string(),
203    }
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209    use signal_schema::{Value, SCHEMA_VERSION};
210
211    fn sig(source: Source, name: SignalName, value: f64, ts: u64) -> Signal {
212        Signal {
213            schema_version: SCHEMA_VERSION,
214            ts,
215            source,
216            name,
217            value: Value(value),
218            tag: None,
219        }
220    }
221
222    const NOW: u64 = 1_000_000;
223
224    fn typing(keys: f64) -> Signal {
225        sig(Source::Terminal, SignalName::KeysPerMin, keys, NOW)
226    }
227
228    #[test]
229    fn oneline_is_the_face_and_the_rate() {
230        let pet = pet_from_signals(&[typing(42.4)], NOW);
231        assert_eq!(oneline(&pet), "(^_^) 42k/m");
232    }
233
234    #[test]
235    fn oneline_drops_a_rate_it_cannot_vouch_for() {
236        let old = sig(Source::Terminal, SignalName::KeysPerMin, 99.0, NOW - STALE_AFTER_MS - 1);
237        assert_eq!(oneline(&pet_from_signals(&[old], NOW)), "(-.-)", "stale rate omitted");
238        assert_eq!(oneline(&pet_from_signals(&[], NOW)), "(-.-)", "no rate at all");
239    }
240
241    #[test]
242    fn oneline_shows_a_sick_face() {
243        let signals = [
244            typing(10.0),
245            sig(Source::Hardware, SignalName::CollectorUp, 0.0, NOW),
246        ];
247        assert_eq!(oneline(&pet_from_signals(&signals, NOW)), "(x_x) 10k/m");
248    }
249
250    #[test]
251    fn typing_drives_energy() {
252        assert_eq!(pet_from_signals(&[typing(0.0)], NOW).mood, Mood::Sleeping);
253        assert_eq!(pet_from_signals(&[typing(10.0)], NOW).mood, Mood::Calm);
254        assert_eq!(pet_from_signals(&[typing(120.0)], NOW).mood, Mood::Busy);
255    }
256
257    #[test]
258    fn no_terminal_signal_at_all_is_asleep_not_sick() {
259        assert_eq!(pet_from_signals(&[], NOW).mood, Mood::Sleeping);
260    }
261
262    #[test]
263    fn heat_outranks_typing() {
264        let signals = [
265            typing(120.0),
266            sig(Source::Macos, SignalName::ThermalState, 3.0, NOW),
267        ];
268        assert_eq!(pet_from_signals(&signals, NOW).mood, Mood::Overheating);
269    }
270
271    #[test]
272    fn a_low_battery_only_counts_when_unplugged() {
273        let low = sig(Source::Macos, SignalName::BatteryPct, 5.0, NOW);
274        let plugged = sig(Source::Macos, SignalName::Charging, 1.0, NOW);
275        let unplugged = sig(Source::Macos, SignalName::Charging, 0.0, NOW);
276
277        assert_eq!(
278            pet_from_signals(&[typing(1.0), low.clone(), unplugged], NOW).mood,
279            Mood::LowBattery
280        );
281        assert_eq!(
282            pet_from_signals(&[typing(1.0), low, plugged], NOW).mood,
283            Mood::Calm,
284            "on the charger a low battery is not a worry"
285        );
286    }
287
288    /// The point of putting health on the bus: a dead collector is visible in
289    /// the face, not just in a log nobody reads.
290    #[test]
291    fn a_down_collector_outranks_every_other_mood() {
292        let signals = [
293            typing(120.0),
294            sig(Source::Macos, SignalName::ThermalState, 3.0, NOW),
295            sig(Source::Hardware, SignalName::CollectorUp, 0.0, NOW),
296        ];
297        let pet = pet_from_signals(&signals, NOW);
298        assert_eq!(pet.mood, Mood::Sick);
299        assert_eq!(pet.down, vec![Source::Hardware]);
300        assert!(render(&pet).contains("collectors down: Hardware"));
301    }
302
303    #[test]
304    fn a_healthy_collector_is_not_listed_as_down() {
305        let signals = [
306            typing(5.0),
307            sig(Source::Hardware, SignalName::CollectorUp, 1.0, NOW),
308        ];
309        let pet = pet_from_signals(&signals, NOW);
310        assert_eq!(pet.mood, Mood::Calm);
311        assert!(pet.down.is_empty());
312    }
313
314    #[test]
315    fn health_is_tracked_per_source() {
316        let signals = [
317            sig(Source::Git, SignalName::CollectorUp, 1.0, NOW),
318            sig(Source::Terminal, SignalName::CollectorUp, 0.0, NOW),
319            sig(Source::Hardware, SignalName::CollectorUp, 0.0, NOW),
320        ];
321        let pet = pet_from_signals(&signals, NOW);
322        assert_eq!(pet.down, vec![Source::Terminal, Source::Hardware]);
323    }
324
325    /// The terminal collector stops publishing once a session falls outside
326    /// its active window, so the last rate sits in the cache. The pet must not
327    /// keep looking busy on the strength of it.
328    #[test]
329    fn a_stale_rate_does_not_keep_the_pet_busy() {
330        let old = sig(
331            Source::Terminal,
332            SignalName::KeysPerMin,
333            300.0,
334            NOW - STALE_AFTER_MS - 1,
335        );
336        assert_eq!(pet_from_signals(&[old], NOW).mood, Mood::Sleeping);
337    }
338
339    /// Up but stalled is not the same as down: the collector never reported a
340    /// failure, the number behind it simply stopped moving.
341    #[test]
342    fn an_old_value_is_stale_but_not_sick() {
343        let old = sig(
344            Source::Hardware,
345            SignalName::CpuLoad,
346            0.5,
347            NOW - STALE_AFTER_MS - 1,
348        );
349        let pet = pet_from_signals(&[typing(5.0), old], NOW);
350        assert!(pet.cpu_load.expect("cpu load").stale);
351        assert!(!pet.keys_per_min.expect("keys").stale);
352        assert_eq!(pet.mood, Mood::Calm, "stale is not sick");
353        assert!(render(&pet).contains("(stale)"));
354    }
355}