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

Commit 146b67f808

146b67f808c3bf2246b3506908612b0e2d6dc019

parent: 3fa625fed2

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-04 17:17 UTC

History: read-only open and last_activity_ms

The menu-bar pet needs to know when activity last happened, which means
reading signald's SQLite. A second copy of the schema in another crate would be
a coupling that breaks silently, so the query lives with the store.

open_read_only exists because open prunes, creates and migrates on the way in.
A face polling every five seconds must not delete the daemon's history as a
side effect of looking at it.

last_activity_ms counts a keystroke rate or a commit count above zero.
SessionSeconds is excluded: a shell left open is not attention, and counting it
would let a forgotten terminal stand in for a person indefinitely. The hardware
signals are excluded too, since a sleeping machine still reports a battery
percentage. Zero-valued rows are not activity either, or a quiet day would keep
the pet alive forever.

Verified against the live database: the read returns the expected timestamp and
the row count is unchanged.

Closes #16
crates/signald/src/history.rs +121 −1
@@ -26,7 +26,7 @@
2626use std::path::Path;
2727use std::time::Duration;
2828
29use rusqlite::{params, Connection};
29use rusqlite::{params, Connection, OpenFlags};
3030use signal_schema::{wire, Signal};
3131
3232use crate::now_millis;
@@ -64,6 +64,41 @@ impl History {
6464 Ok(h)
6565 }
6666
67 /// Open an existing database without writing to it.
68 ///
69 /// [`History::open`] prunes, creates and migrates. A reader that polls —
70 /// the menu-bar pet asks every few seconds when activity last happened —
71 /// must not mutate the daemon's store as a side effect of looking at it.
72 /// Returns an error if the database does not exist yet.
73 pub fn open_read_only(path: &Path) -> rusqlite::Result<History> {
74 let conn = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY)?;
75 Ok(History {
76 conn,
77 retention_ms: None,
78 since_prune: 0,
79 })
80 }
81
82 /// When activity was last seen, as unix millis, or `None` if there is
83 /// none on record.
84 ///
85 /// Activity is a keystroke rate or a commit-count above zero. Deliberately
86 /// not `SessionSeconds`: a shell left open is not attention, and counting
87 /// it would let a forgotten terminal stand in for a person indefinitely.
88 /// Deliberately not the hardware signals either — a sleeping machine still
89 /// reports a battery percentage.
90 pub fn last_activity_ms(&self) -> rusqlite::Result<Option<u64>> {
91 let ts: Option<i64> = self.conn.query_row(
92 "SELECT MAX(ts) FROM signals WHERE name IN (?1, ?2) AND value > 0.0",
93 params![
94 signal_schema::SignalName::KeysPerMin.to_u8(),
95 signal_schema::SignalName::CommitsToday.to_u8(),
96 ],
97 |row| row.get(0),
98 )?;
99 Ok(ts.map(|t| t as u64))
100 }
101
67102 /// An ephemeral in-memory store (used by tests). No retention.
68103 pub fn open_in_memory() -> rusqlite::Result<History> {
69104 Self::init(Connection::open_in_memory()?, None)
@@ -269,6 +304,91 @@ mod tests {
269304 let _ = std::fs::remove_dir_all(&dir);
270305 }
271306
307 /// Activity is what the menu-bar pet lives on, so what counts is exact.
308 #[test]
309 fn last_activity_is_typing_and_commits_only() {
310 let mut h = History::open_in_memory().unwrap();
311 // A shell sitting open, and a machine reporting hardware while asleep.
312 // Neither is a person showing up.
313 for (name, value, ts) in [
314 (SignalName::SessionSeconds, 9000.0, 500_u64),
315 (SignalName::BatteryPct, 80.0, 600),
316 (SignalName::CpuLoad, 0.4, 700),
317 ] {
318 h.record(&Signal { ts, ..sig(name, value, None) }).unwrap();
319 }
320 assert_eq!(h.last_activity_ms().unwrap(), None, "none of that is attention");
321
322 h.record(&Signal { ts: 1_000, ..sig(SignalName::KeysPerMin, 30.0, None) })
323 .unwrap();
324 assert_eq!(h.last_activity_ms().unwrap(), Some(1_000));
325
326 h.record(&Signal { ts: 2_000, ..sig(SignalName::CommitsToday, 3.0, None) })
327 .unwrap();
328 assert_eq!(h.last_activity_ms().unwrap(), Some(2_000), "the most recent wins");
329 }
330
331 /// A quiet day publishes zeroes rather than nothing, so a zero must not
332 /// read as activity or the pet would never age.
333 #[test]
334 fn a_zero_is_not_activity() {
335 let mut h = History::open_in_memory().unwrap();
336 h.record(&Signal { ts: 1_000, ..sig(SignalName::KeysPerMin, 0.0, None) })
337 .unwrap();
338 h.record(&Signal { ts: 1_100, ..sig(SignalName::CommitsToday, 0.0, None) })
339 .unwrap();
340 assert_eq!(h.last_activity_ms().unwrap(), None);
341 }
342
343 /// The reason this exists: `open` prunes on the way in. A pet polling every
344 /// five seconds must not be quietly deleting the daemon's history.
345 #[test]
346 fn read_only_open_does_not_prune() {
347 let dir = std::env::temp_dir().join(format!("signald-ro-{}", std::process::id()));
348 let _ = std::fs::remove_dir_all(&dir);
349 std::fs::create_dir_all(&dir).unwrap();
350 let db = dir.join("history.db");
351
352 {
353 // Retention of an hour, and a row far older than that.
354 let mut h =
355 History::open_with_retention(&db, std::time::Duration::from_secs(3600)).unwrap();
356 h.record(&Signal { ts: 1, ..sig(SignalName::KeysPerMin, 5.0, None) })
357 .unwrap();
358 }
359
360 let ro = History::open_read_only(&db).unwrap();
361 assert_eq!(ro.last_activity_ms().unwrap(), Some(1), "the old row is readable");
362 drop(ro);
363
364 // Still there: a read-only open left it alone. A normal open would have
365 // pruned it on the way in.
366 let ro = History::open_read_only(&db).unwrap();
367 assert_eq!(
368 ro.conn
369 .query_row("SELECT COUNT(*) FROM signals", [], |r| r.get::<_, i64>(0))
370 .unwrap(),
371 1,
372 "read-only open must not have deleted anything"
373 );
374 drop(ro);
375
376 // And prove the contrast: a normal open does prune it.
377 let h = History::open_with_retention(&db, std::time::Duration::from_secs(3600)).unwrap();
378 assert_eq!(h.recent(10).unwrap().len(), 0, "a writing open prunes");
379 let _ = std::fs::remove_dir_all(&dir);
380 }
381
382 #[test]
383 fn read_only_open_refuses_a_database_that_is_not_there() {
384 let missing = std::env::temp_dir().join(format!("signald-absent-{}.db", std::process::id()));
385 let _ = std::fs::remove_file(&missing);
386 assert!(
387 History::open_read_only(&missing).is_err(),
388 "read-only must not conjure a database"
389 );
390 }
391
272392 /// A row written under an older schema cannot be rebuilt from its frame, so
273393 /// reopening drops it rather than leaving it to be skipped silently.
274394 #[test]