# ambient-companions *Five faces over one backend.* One signal-collection daemon; five thin renderers that subscribe to it. This is closer to **one project with five faces** than five projects: the backend is the project, and each face is thin, disposable, and deletable without touching the others. ## One backend, five faces ```text signald (user LaunchAgent) zsh hooks ───▶ terminal collector ─┐ git/fsevents ▶ git collector ──────┼─▶ normalizer ─▶ ring buffer IOKit/AppKit ▶ system+hw collector ┘ │ ▼ publish: Unix socket + SQLite WAL (history) ┌───────────┬───────────┼───────────┬───────────────┐ ▼ ▼ ▼ ▼ ▼ terminal-pet garden menubar-pet sonifier wallpaperd (TUI) (TUI) (SwiftUI) (AVAudio) (image/window) ``` All five draw from the **same signal bus**. Renderers never poll hardware, never read the shell — they subscribe over a local Unix socket (`$XDG_RUNTIME_DIR/signald.sock`). That buys one privacy boundary to defend instead of five, cheap renderers, and reuse of the collection layer into non-toy outputs (e-ink dashboards, printed posters). ## The privacy boundary, enforced by construction Reading typing during real work is a keylogger unless it is *structurally* content-free — and unless that is a **test**, not a sentence in a README. The invariant (spec §1.5): > No process persists, transmits, or exposes any representation from which the > content or identity of an individual keystroke, command argument, or typed > character can be recovered. Only order-free aggregates (counts, rates, > durations, codes) leave the terminal collector. Made true by construction: - **No content channel exists in the wire format.** The only payload channel is `Value`, a newtype over `f64`. There is no `text`, `bytes`, or `payload` field — a collector *cannot* emit typed content because the record has nowhere to put it. The two audited exceptions are non-content identifiers in `tag` (bundle id, repo path, ssh host), allow-listed per metric name. - **The key counter never stores the key**, there is **no input tap anywhere** (`CGEventTap`, `IOHIDManager` keyboard usage, accessibility observation are all forbidden), and **aggregation happens before transport** (the shell emits counts on `precmd`, never per-key events). And it is tested (`crates/signal-schema/tests/privacy_invariant.rs`): - `value_channel_is_exactly_f64` — the payload is an `f64`, nothing wider. - `wire_format_has_no_content_field` — the `Signal` type declares no content-carrying field beyond the audited `tag`. - `forbidden_symbol_scan` — the tree contains none of the banned keylogger APIs or shell line-buffer references (the static gate). - `differential_secret_typing` — **active (the ship gate).** It drives the real hook (`shell-hooks/signald-hooks.zsh`) through a real interactive zsh under a real pseudo-terminal (zsh's own `zsh/zpty` — no extra dependency), *typing a planted secret*, and asserts the secret never appears — plain, reversed, hex, or base64 — in the shell spool or the `f64`-only wire encoding. The **full pipeline** version (same real typing driven through the terminal collector, the SQLite history store, and the hub) is `crates/signald/tests/differential_secret_typing.rs`. Real `zle` keystroke counting increments a *number* per key and discards the key, so the only thing the pipeline ever receives about the typing is a count — the tests prove that empirically across every downstream artifact. ## Build order Build the backend to the point each renderer needs, then build the renderer that is cheapest *and* most valuable given what exists: 1. **garden** — git is the cleanest signal (discrete, no privacy questions), so it proves the bus first. *(recommended first face)* 2. **gated collector + pet** — add the sensitive terminal collector only after the privacy tests are green and extended to cover it. 3. **menu-bar permadeath pet** — first macOS-native face; ships the one-life variant (ages in wall-clock time, dies permanently) — the emotional hook. 4. **SSH-sonification** — unexpected access becomes *audible*; the utility sonification before the ambient one. 5. **homelab wallpaper** — desktop-as-status-board (Path A: render-to-image + `setDesktopImageURL`); the same frame pipeline later feeds e-ink and posters. ## macOS collector constraint The system + hardware collector is **IOKit-only: no root, no `powermetrics`.** `powermetrics` wants root and would make the suite un-shippable as a plain user agent. Signals come from IOKit (`IOPMPowerSource` / power sources, IORegistry `AppleSmartBattery`), `ProcessInfo`, and Mach `host_processor_info`. Where a metric can't be reached without root, it is simply **absent from the schema** rather than gated behind sudo. Accessibility permission is never requested. It ships as **`macos-collector/`**, a sibling **Swift** package (built with `swift build`, kept out of the cargo workspace — SwiftPM and cargo do not share a build system). It reads aggregate CPU load, battery %, charging, battery draw (W), and thermal state, and emits them as `signal-schema` wire frames — the same byte format `signald` parses. `signald` spawns it as a child process (found on `PATH`, or named with `--collector `) and ingests the frames it writes to stdout, so hardware signals reach the hub, the history store, and every subscriber by the same path as git and terminal signals. GPU/fan are deliberately omitted (no clean root-free IOKit channel). See `macos-collector/README.md` for the full **Swift ↔ Rust wire contract** (the byte layout) and the shared canonical-frame test that pins both sides to the same bytes. ## Workspace layout ```text ambient-companions/ ├── Cargo.toml # Rust workspace ├── crates/ │ ├── signal-schema/ # shared wire format (the privacy boundary) │ │ ├── src/lib.rs │ │ └── tests/privacy_invariant.rs │ ├── signald/ # the daemon (collectors + live fan-out) │ │ ├── src/lib.rs # git + terminal collectors, hardware ingest, publish │ │ ├── src/history.rs # SQLite (WAL) history store + recent() query │ │ ├── src/hub.rs # last-value cache + live fan-out │ │ ├── src/main.rs # CLI, producer loop, self-attestation │ │ ├── tests/git_collector.rs # aggregates vs a temp git repo │ │ ├── tests/streaming.rs # last-value cache + live update │ │ ├── tests/hardware_ingest.rs # collector frames reach a subscriber │ │ └── tests/differential_secret_typing.rs # the full privacy ship-gate │ └── terminal-garden/ # first renderer: a socket subscriber │ ├── src/lib.rs # signals → plots → render (unit-tested) │ └── src/main.rs # live subscribe + redraw loop ├── shell-hooks/ # zsh hooks: aggregate-only terminal collector │ ├── signald-hooks.zsh │ └── README.md └── macos-collector/ # SwiftPM sibling (NOT in the cargo workspace): ├── Package.swift # macOS IOKit hardware collector (Phase 3) ├── README.md # the Swift↔Rust wire contract (byte layout) ├── Sources/CollectorCore/ # wire encoder + IOKit reads (host_processor_ │ # info, power sources, AppleSmartBattery) ├── Sources/macos-collector/ # thin CLI: --once / stream / --hex / --out └── Tests/ # shared canonical-frame wire-contract test ``` ### Language note The daemon is written in **Rust**. The spec allows Rust or Go; Rust is chosen because it makes the "no content field exists" guarantee enforceable in the type system (the `f64`-only `Value` payload), which is the whole point of the privacy boundary. ### Dependencies `signal-schema` is **dependency-free** by design — the wire format is the privacy boundary and carries no third-party code. `signald` has **one** dependency, `rusqlite` (with the `bundled` feature, so SQLite is compiled in-tree and there is no system-library requirement), for the WAL history store. The terminal collector's shell side needs only `zsh` (the `zle`, `datetime`, and — for the differential test — `zpty` modules ship with zsh). ## Phase plan - **M0 — backend skeleton + schema + privacy invariant test. ✅ Done.** Workspace builds; the structural privacy tests pass. - **Phase 1 — git collector + garden. ✅ Done (v0.1).** The wire format (`encode`/`decode` + length-prefixed framing) is real and `f64`-only. `signald`'s git collector shells out to `git` for aggregate scalars — commits-in-window, commits-today, branch count, days-since-last- commit — tagged by the audited repo path. `terminal-garden` subscribes and renders each repo as a plot: growth (🌱→🌿→🌳) tracks recent commits, wilt (🥀→🍂) tracks staleness. - **Persistence + live streaming. ✅ Done (v0.2).** - **SQLite (WAL) history** (`signald/src/history.rs`): every published signal is persisted (aggregate scalars only — same privacy constraints as the wire), and `recent()` / `recent_named()` let a renderer read recent history, not just the live snapshot. - **Live streaming + last-value cache** (`signald/src/hub.rs`, `publish::serve`): the daemon runs a producer loop and continuously publishes. On connect a subscriber is handed the current value of every metric immediately, then streams updates as they change (no longer snapshot-then-close). `terminal-garden` now redraws live. - **Phase 2 — terminal collector. ✅ Done (v0.2).** The aggregate-only terminal path is real: `shell-hooks/signald-hooks.zsh` counts keystrokes with a `zle` widget that increments a number and discards the key, and appends ` ` count records (numbers only) to a spool; `collectors::terminal` consumes the spool and derives `keys_per_min` and `session_seconds`. The **differential secret-typing test is active and passing** — the privacy ship-gate. *The Terminal Pet renderer is still todo.* - **Phase 3 — macOS IOKit hardware collector. ✅ Done (v0.3).** `macos-collector/` (a sibling Swift package, `swift build`) reads aggregate hardware scalars via **IOKit only — no `powermetrics`, no root**: CPU load (Mach `host_processor_info`), battery %/charging (IOKit power sources), battery draw in watts (IORegistry `AppleSmartBattery`), and thermal state (`ProcessInfo`). It emits `signal-schema` wire frames — the schema gained the aggregate `cpu_load` metric and bumped to `SCHEMA_VERSION = 3`. GPU/fan are omitted (no clean root-free channel). The **Swift ↔ Rust byte contract** is documented and pinned by a shared canonical-frame test on both sides (`crates/signal-schema/tests/hardware_wire.rs` decodes the exact bytes the Swift encoder commits to in `macos-collector/Tests/.../WireTests.swift`). *Still todo:* the menu-bar permadeath pet. - **Hardware ingest. ✅ Done (v0.4).** `signald` spawns `macos-collector` as a child (`--collector `, or found on `PATH`) and reads its stdout with the same `wire::read_frame` the socket uses (`collectors::hardware`). The five hardware signals now appear in the hub, the history store, and every subscriber's snapshot (`crates/signald/tests/hardware_ingest.rs`). - **Bounded spool and history. ✅ Done (v0.4).** The terminal spool is consumed each tick (renamed aside, read, deleted) instead of re-read in full forever. Records carry the shell's pid, so `keys_per_min` is each active shell's rate summed rather than a mix of interleaved sessions, and `session_seconds` is the longest active shell. History rows older than `--retention-days` (default 7) are pruned on open and every 1000 inserts. - **Phase 4** — sonification (SSH-utility first, then continuous). *Out of scope.* - **Phase 5** — live wallpaper (homelab, Path A) + e-ink/poster reuse. *Out of scope.* ## Build & test ```sh cargo build # whole Rust workspace cargo test # includes the privacy invariant suite (must be green) # The macOS IOKit hardware collector is a sibling Swift package (macOS only): cd macos-collector swift build # builds the collector swift test # the Swift↔Rust wire-contract test swift run macos-collector --once # one real IOKit read (no root) ``` ### Pre-push checks There is no CI. `.githooks/pre-push` is the gate: it runs `cargo test --locked`, `cargo clippy --all-targets --locked -- -D warnings`, and the `macos-collector` Swift tests, and a failure aborts the push. The differential secret-typing tests need `zsh` with the `zsh/zpty` module; the hook checks for it first and fails rather than skipping the gate. Enable it once per clone: ```sh git config core.hooksPath .githooks ```