README.md
369 lines · 19811 bytes
1# ambient-companions
2
3*Five faces over one backend.*
4
5One signal-collection daemon; five thin renderers that subscribe to it. This is
6closer to **one project with five faces** than five projects: the backend is
7the project, and each face is thin, disposable, and deletable without touching
8the others.
9
10## One backend, five faces
11
12```text
13 signald (user LaunchAgent)
14 zsh hooks ───▶ terminal collector ─┐
15 git/fsevents ▶ git collector ──────┼─▶ normalizer ─▶ ring buffer
16 IOKit/AppKit ▶ system+hw collector ┘ │
17 ▼
18 publish: Unix socket + SQLite WAL (history)
19 ┌───────────┬───────────┼───────────┬───────────────┐
20 ▼ ▼ ▼ ▼ ▼
21 terminal-pet garden menubar-pet sonifier wallpaperd
22 (TUI) (TUI) (SwiftUI) (AVAudio) (image/window)
23 ✅ built ✅ built ✅ built planned planned
24```
25
26**Three faces are built.** The other two are the roadmap, not the product —
27see "Phase plan". Every face draws from the **same signal bus**: renderers
28never poll hardware, never read the shell, they subscribe over a local Unix
29socket (`$XDG_RUNTIME_DIR/signald.sock`). That buys one privacy boundary to
30defend instead of five, cheap renderers, and reuse of the collection layer into
31non-toy outputs (e-ink dashboards, printed posters).
32
33## The privacy boundary, enforced by construction
34
35Reading typing during real work is a keylogger unless it is *structurally*
36content-free — and unless that is a **test**, not a sentence in a README.
37
38The invariant:
39
40> No process persists, transmits, or exposes any representation from which the
41> content or identity of an individual keystroke, command argument, or typed
42> character can be recovered. Only order-free aggregates (counts, rates,
43> durations, codes) leave the terminal collector.
44
45Made true by construction:
46
47- **No content channel exists in the wire format.** The only payload channel is
48 `Value`, a newtype over `f64`. There is no `text`, `bytes`, or `payload`
49 field — a collector *cannot* emit typed content because the record has
50 nowhere to put it. The one audited exception is a non-content identifier in
51 `tag` — an absolute repo path, allow-listed to the four git metric names,
52 and confined by the daemon to the roots it was told to watch.
53- **The key counter never stores the key**, there is **no input tap anywhere**
54 (`CGEventTap`, `IOHIDManager` keyboard usage, accessibility observation are
55 all forbidden), and **aggregation happens before transport** (the shell emits
56 counts on `precmd`, never per-key events).
57
58And it is tested (`crates/signal-schema/tests/privacy_invariant.rs`):
59
60- `value_channel_is_exactly_f64` — the payload is an `f64`, nothing wider.
61- `wire_format_has_no_content_field` — the `Signal` type declares no
62 content-carrying field beyond the audited `tag`.
63- `forbidden_symbol_scan` — the tree contains none of the banned keylogger APIs
64 or shell line-buffer references (the static gate).
65- `differential_secret_typing` — **active (the ship gate).** It drives the real
66 hook (`shell-hooks/signald-hooks.zsh`) through a real interactive zsh under a
67 real pseudo-terminal (zsh's own `zsh/zpty` — no extra dependency), *typing a
68 planted secret*, and asserts the secret never appears — plain, reversed, hex,
69 or base64 — in the shell spool or the `f64`-only wire encoding. The **full
70 pipeline** version (same real typing driven through the terminal collector,
71 the SQLite history store, and the hub) is
72 `crates/signald/tests/differential_secret_typing.rs`. Real `zle` keystroke
73 counting increments a *number* per key and discards the key, so the only thing
74 the pipeline ever receives about the typing is a count — the tests prove that
75 empirically across every downstream artifact.
76
77## Build order
78
79Build the backend to the point each renderer needs, then build the renderer
80that is cheapest *and* most valuable given what exists:
81
821. **garden** — git is the cleanest signal (discrete, no privacy questions), so
83 it proves the bus first. *(recommended first face)*
842. **gated collector + pet** — add the sensitive terminal collector only after
85 the privacy tests are green and extended to cover it.
863. **menu-bar permadeath pet** — first macOS-native face; ships the one-life
87 variant (ages in wall-clock time, dies permanently) — the emotional hook.
884. **SSH-sonification** — unexpected access becomes *audible*; the utility
89 sonification before the ambient one.
905. **homelab wallpaper** — desktop-as-status-board (Path A: render-to-image +
91 `setDesktopImageURL`); the same frame pipeline later feeds e-ink and posters.
92
93## macOS collector constraint
94
95The system + hardware collector is **IOKit-only: no root, no `powermetrics`.**
96`powermetrics` wants root and would make the suite un-shippable as a plain user
97agent. Signals come from IOKit (`IOPMPowerSource` / power sources, IORegistry
98`AppleSmartBattery`), `ProcessInfo`, and Mach `host_processor_info`. Where a
99metric can't be reached without root, it is simply **absent from the schema**
100rather than gated behind sudo. Accessibility permission is never requested.
101
102It ships as **`macos-collector/`**, a sibling **Swift** package (built with
103`swift build`, kept out of the cargo workspace — SwiftPM and cargo do not share
104a build system). It reads aggregate CPU load, battery %, charging, battery draw
105(W), and thermal state, and emits them as `signal-schema` wire frames — the
106same byte format `signald` parses. `signald` spawns it as a child process
107(found on `PATH`, or named with `--collector <path>`) and ingests the frames it
108writes to stdout, so hardware signals reach the hub, the history store, and
109every subscriber by the same path as git and terminal signals. GPU/fan are
110deliberately omitted (no clean root-free IOKit channel). See `macos-collector/README.md` for the full **Swift ↔
111Rust wire contract** (the byte layout) and the shared canonical-frame test that
112pins both sides to the same bytes.
113
114## Workspace layout
115
116```text
117ambient-companions/
118├── Cargo.toml # Rust workspace
119├── crates/
120│ ├── signal-schema/ # shared wire format (the privacy boundary)
121│ │ ├── src/lib.rs
122│ │ └── tests/privacy_invariant.rs
123│ ├── signald/ # the daemon (collectors + live fan-out)
124│ │ ├── src/lib.rs # git + terminal collectors, hardware ingest, publish
125│ │ ├── src/supervisor.rs # collector liveness, restart, CollectorUp
126│ │ ├── src/history.rs # SQLite (WAL) history store + recent() query
127│ │ ├── src/hub.rs # last-value cache + live fan-out
128│ │ ├── src/main.rs # CLI, producer loop, self-attestation
129│ │ ├── tests/git_collector.rs # aggregates vs a temp git repo
130│ │ ├── tests/streaming.rs # last-value cache + live update
131│ │ ├── tests/hardware_ingest.rs # collector frames reach a subscriber
132│ │ └── tests/differential_secret_typing.rs # the full privacy ship-gate
133│ ├── signal-client/ # socket path + frame iteration (shared)
134│ ├── terminal-garden/ # first renderer: git aggregates as plots
135│ │ ├── src/lib.rs # signals → plots → render (unit-tested)
136│ │ └── src/main.rs # live subscribe + redraw loop
137│ ├── terminal-pet/ # second renderer: shell + machine + health
138│ │ ├── src/lib.rs # signals → PetState → render (unit-tested)
139│ │ └── src/main.rs # live subscribe + redraw loop
140│ └── pet-life/ # the one-life mechanic: ageing, death, cemetery
141│ ├── src/lib.rs # pure over timestamps (unit-tested)
142│ └── src/main.rs # one-shot: prints state as JSON
143├── shell-hooks/ # zsh hooks: aggregate-only terminal collector
144│ ├── signald-hooks.zsh
145│ └── README.md
146├── menubar-pet/ # SwiftPM sibling: the third face
147│ ├── Sources/PetKit/ # the pet-life JSON contract (tested)
148│ ├── Sources/menubar-pet/ # status item + timer + menu
149│ └── scripts/bundle.sh # assembles the .app (LSUIElement)
150└── macos-collector/ # SwiftPM sibling (NOT in the cargo workspace):
151 ├── Package.swift # macOS IOKit hardware collector (Phase 3)
152 ├── README.md # the Swift↔Rust wire contract (byte layout)
153 ├── Sources/CollectorCore/ # wire encoder + IOKit reads (host_processor_
154 │ # info, power sources, AppleSmartBattery)
155 ├── Sources/macos-collector/ # thin CLI: --once / stream / --hex / --out
156 └── Tests/ # shared canonical-frame wire-contract test
157```
158
159### Language note
160
161The daemon is written in **Rust**, chosen
162because it makes the "no content field exists" guarantee enforceable in the
163type system (the `f64`-only `Value` payload), which is the whole point of the
164privacy boundary.
165
166### Dependencies
167
168`signal-schema` is **dependency-free** by design — the wire format is the
169privacy boundary and carries no third-party code. `signald` has **one**
170dependency, `rusqlite` (with the `bundled` feature, so SQLite is compiled
171in-tree and there is no system-library requirement), for the WAL history store.
172The terminal collector's shell side needs only `zsh` (the `zle`, `datetime`,
173and — for the differential test — `zpty` modules ship with zsh).
174
175## Phase plan
176
177- **M0 — backend skeleton + schema + privacy invariant test. ✅ Done.**
178 Workspace builds; the structural privacy tests pass.
179- **Phase 1 — git collector + garden. ✅ Done (v0.1).**
180 The wire format (`encode`/`decode` + length-prefixed framing) is real and
181 `f64`-only. `signald`'s git collector shells out to `git` for aggregate
182 scalars — commits-in-window, commits-today, branch count, days-since-last-
183 commit — tagged by the audited repo path. `terminal-garden` subscribes and
184 renders each repo as a plot: growth (🌱→🌿→🌳) tracks recent commits, wilt
185 (🥀→🍂) tracks staleness.
186- **Persistence + live streaming. ✅ Done (v0.2).**
187 - **SQLite (WAL) history** (`signald/src/history.rs`): every published signal
188 is persisted (aggregate scalars only — same privacy constraints as the
189 wire), and `recent()` / `recent_named()` let a renderer read recent history,
190 not just the live snapshot.
191 - **Live streaming + last-value cache** (`signald/src/hub.rs`,
192 `publish::serve`): the daemon runs a producer loop and continuously
193 publishes. On connect a subscriber is handed the current value of every
194 metric immediately, then streams updates as they change (no longer
195 snapshot-then-close). `terminal-garden` now redraws live.
196- **Phase 2 — terminal collector. ✅ Done (v0.2).**
197 The aggregate-only terminal path is real: `shell-hooks/signald-hooks.zsh`
198 counts keystrokes with a `zle` widget that increments a number and discards
199 the key, and appends `<epoch_ms> <keys> <session_seconds> <session_id>` count
200 records (numbers only) to a spool; `collectors::terminal` consumes the spool
201 and derives `keys_per_min` and `session_seconds`. The **differential
202 secret-typing test is active and passing** — the privacy ship-gate. The
203 Terminal Pet renderer landed in v1.0.
204- **Phase 3 — macOS IOKit hardware collector. ✅ Done (v0.3).**
205 `macos-collector/` (a sibling Swift package, `swift build`) reads aggregate
206 hardware scalars via **IOKit only — no `powermetrics`, no root**: CPU load
207 (Mach `host_processor_info`), battery %/charging (IOKit power sources), battery
208 draw in watts (IORegistry `AppleSmartBattery`), and thermal state
209 (`ProcessInfo`). It emits `signal-schema` wire frames — the schema gained the
210 aggregate `cpu_load` metric and bumped to `SCHEMA_VERSION = 3`. GPU/fan are
211 omitted (no clean root-free channel). The **Swift ↔ Rust byte contract** is
212 documented and pinned by a shared canonical-frame test on both sides
213 (`crates/signal-schema/tests/hardware_wire.rs` decodes the exact bytes the
214 Swift encoder commits to in `macos-collector/Tests/.../WireTests.swift`).
215 *The menu-bar permadeath pet is post-1.0.*
216- **Hardware ingest. ✅ Done (v0.4).**
217 `signald` spawns `macos-collector` as a child (`--collector <path>`, or found
218 on `PATH`) and reads its stdout with the same `wire::read_frame` the socket
219 uses (`collectors::hardware`). The five hardware signals now appear in the
220 hub, the history store, and every subscriber's snapshot
221 (`crates/signald/tests/hardware_ingest.rs`).
222- **Bounded spool and history. ✅ Done (v0.4).**
223 The terminal spool is consumed each tick (renamed aside, read, deleted)
224 instead of re-read in full forever. Records carry the shell's pid, so
225 `keys_per_min` is each active shell's rate summed rather than a mix of
226 interleaved sessions, and `session_seconds` is the longest active shell.
227 History rows older than `--retention-days` (default 7) are pruned on open
228 and every 1000 inserts.
229- **Contract freeze. ✅ Done (v0.5).**
230 `SignalName` is cut to the eleven metrics that have a producer, renumbered
231 from zero, and `SCHEMA_VERSION` is **4 — the 1.0 contract**. Discriminants
232 are append-only from 1.0, so v4 was the last chance to renumber. The only
233 audited tag identifier left is the repo path, confined by the daemon to the
234 roots it was told to watch. A reader now **skips** a frame it cannot decode
235 instead of dying on it (`wire::Frame::Skipped`), so an older renderer keeps
236 working against a newer daemon. References to an uncommitted spec are gone.
237- **v1.0 — trust it unattended. ✅ Done (v1.0.0).**
238 1.0 is not feature completeness and not an API promise to anyone else. It is
239 one claim: **the daemon runs unattended for weeks, recovers from its own
240 failures, and says so when it cannot.**
241 `signald/src/supervisor.rs` owns collector liveness. A panic in the git or
242 terminal collector costs one tick instead of the producer thread; the
243 hardware child is respawned with backoff; `run_git` kills a `git` that hangs,
244 because a Rust thread cannot be. Health rides the bus as
245 `SignalName::CollectorUp`, one per `Source` — the schema's `v5` addition —
246 so a dead collector reaches a face rather than a log file. `terminal-pet`
247 renders it: a pet that cannot feel its own hardware looks sick.
248- **Phase 3 renderer — menu-bar permadeath pet. ✅ Done.**
249 `menubar-pet/` is a sibling SwiftPM package: a status item, a timer and a
250 menu. One life — it ages, neglect kills it over a week through visible
251 stages, and death is permanent for that pet. A new one arrives when you come
252 back, and the old one is remembered in a cemetery that is never pruned.
253 The mechanic lives in the `pet-life` Rust crate, which the app runs on a
254 timer; the app decodes no wire and opens no socket, so there is still one
255 decoder and one privacy boundary. Everything derives from timestamps, so
256 sleep, reboots and the app not running change nothing.
257- **Phase 4** — sonification (SSH-utility first, then continuous). *Post-1.0.*
258- **Phase 5** — live wallpaper (homelab, Path A) + e-ink/poster reuse.
259 *Post-1.0.*
260
261## Install
262
263```sh
264brew install krz/tap/ambient-companions
265brew services start ambient-companions
266```
267
268Name the repositories to watch — signald takes none by default, and a login
269agent has no useful working directory:
270
271```sh
272mkdir -p ~/.config/signald
273cat > ~/.config/signald/repos <<'EOF'
274# one repository path per line; # comments and blank lines are ignored
275~/git/some-repo
276EOF
277brew services restart ambient-companions
278```
279
280Then add one line to `.zshrc` for the terminal collector:
281
282```sh
283source "$(brew --prefix)/share/ambient-companions/signald-hooks.zsh"
284```
285
286Open a new shell and watch a face:
287
288```sh
289terminal-garden # your repos, as plants that grow and wilt
290terminal-pet # the shell and the machine, as a mood
291```
292
293The third face lives in the menu bar. Launch `menubar-pet.app` from the
294Homebrew prefix, and add it under System Settings → General → Login Items to
295have it there every day:
296
297```sh
298open "$(brew --prefix)/opt/ambient-companions/menubar-pet.app"
299```
300
301It has one life. Neglect it for a week — no typing, no commits — and it dies
302for good, and the next one starts when you come back.
303
304`brew services` logs to `$(brew --prefix)/var/log/`. To run the agent by hand
305instead, `packaging/net.krz.signald.plist` is a launchd template; its header
306comment carries the `sed` line that fills in the paths and the `launchctl load`
307that starts it.
308
309### Paths
310
311Defaults. `crates/signal-client` resolves the socket path once for the daemon
312and every renderer, and its test pins the values below;
313`shell-hooks/signald-hooks.zsh` derives the spool the same way in shell.
314
315| | `$XDG_RUNTIME_DIR` set | otherwise |
316|---|---|---|
317| socket | `$XDG_RUNTIME_DIR/signald.sock` | `~/.local/state/signald/sock` |
318| history db | `$XDG_RUNTIME_DIR/signald.sqlite` | `~/.local/state/signald/signald.sqlite` |
319| terminal spool | `$XDG_RUNTIME_DIR/terminal.spool` | `~/.local/state/signald/terminal.spool` |
320
321The menu-bar pet keeps its own state at
322`$XDG_DATA_HOME/ambient-companions/pet.state`, else
323`~/.local/share/ambient-companions/pet.state`. Deliberately not beside the
324socket: those paths follow `$XDG_RUNTIME_DIR` where it is set, which is a tmpfs
325wiped every reboot, and a graveyard a reboot can erase is not a graveyard.
326
327The db and the spool are derived from the socket's directory, so `--socket`
328moves all three together. Override individually with `--db` and `--spool`, and
329the spool from the shell side with `$SIGNALD_SPOOL` — it must match whatever
330the daemon uses, or the terminal metrics stay silently empty. launchd sets no
331`XDG_RUNTIME_DIR`, so an installed agent lands in `~/.local/state/signald`.
332
333The repository list is `$XDG_CONFIG_HOME/signald/repos`, else
334`~/.config/signald/repos`. Positional arguments to `signald` override it; with
335neither, the working directory is watched.
336
337Unix socket paths are limited to about 104 bytes. A deep scratch directory
338will hit `SUN_LEN`; use `mktemp -d` when testing by hand.
339
340## Changelog
341
342Notable changes per release are in [`CHANGELOG.org`](CHANGELOG.org), including
343every `SCHEMA_VERSION` move — the wire contract between the daemon, the Swift
344collector and every renderer.
345
346## Build & test
347
348```sh
349cargo build # whole Rust workspace
350cargo test # includes the privacy invariant suite (must be green)
351
352# The macOS IOKit hardware collector is a sibling Swift package (macOS only):
353cd macos-collector
354swift build # builds the collector
355swift test # the Swift↔Rust wire-contract test
356swift run macos-collector --once # one real IOKit read (no root)
357```
358
359### Pre-push checks
360
361There is no CI. `.githooks/pre-push` is the gate: it runs `cargo test
362--locked`, `cargo clippy --all-targets --locked -- -D warnings`, and the
363`macos-collector` Swift tests, and a failure aborts the push. The differential
364secret-typing tests need `zsh` with the `zsh/zpty` module; the hook checks for
365it first and fails rather than skipping the gate. Enable it once per clone:
366
367```sh
368git config core.hooksPath .githooks
369```