README.md
331 lines · 17745 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 planned planned planned
24```
25
26**Two faces are built.** The other three 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├── shell-hooks/ # zsh hooks: aggregate-only terminal collector
141│ ├── signald-hooks.zsh
142│ └── README.md
143└── macos-collector/ # SwiftPM sibling (NOT in the cargo workspace):
144 ├── Package.swift # macOS IOKit hardware collector (Phase 3)
145 ├── README.md # the Swift↔Rust wire contract (byte layout)
146 ├── Sources/CollectorCore/ # wire encoder + IOKit reads (host_processor_
147 │ # info, power sources, AppleSmartBattery)
148 ├── Sources/macos-collector/ # thin CLI: --once / stream / --hex / --out
149 └── Tests/ # shared canonical-frame wire-contract test
150```
151
152### Language note
153
154The daemon is written in **Rust**, chosen
155because it makes the "no content field exists" guarantee enforceable in the
156type system (the `f64`-only `Value` payload), which is the whole point of the
157privacy boundary.
158
159### Dependencies
160
161`signal-schema` is **dependency-free** by design — the wire format is the
162privacy boundary and carries no third-party code. `signald` has **one**
163dependency, `rusqlite` (with the `bundled` feature, so SQLite is compiled
164in-tree and there is no system-library requirement), for the WAL history store.
165The terminal collector's shell side needs only `zsh` (the `zle`, `datetime`,
166and — for the differential test — `zpty` modules ship with zsh).
167
168## Phase plan
169
170- **M0 — backend skeleton + schema + privacy invariant test. ✅ Done.**
171 Workspace builds; the structural privacy tests pass.
172- **Phase 1 — git collector + garden. ✅ Done (v0.1).**
173 The wire format (`encode`/`decode` + length-prefixed framing) is real and
174 `f64`-only. `signald`'s git collector shells out to `git` for aggregate
175 scalars — commits-in-window, commits-today, branch count, days-since-last-
176 commit — tagged by the audited repo path. `terminal-garden` subscribes and
177 renders each repo as a plot: growth (🌱→🌿→🌳) tracks recent commits, wilt
178 (🥀→🍂) tracks staleness.
179- **Persistence + live streaming. ✅ Done (v0.2).**
180 - **SQLite (WAL) history** (`signald/src/history.rs`): every published signal
181 is persisted (aggregate scalars only — same privacy constraints as the
182 wire), and `recent()` / `recent_named()` let a renderer read recent history,
183 not just the live snapshot.
184 - **Live streaming + last-value cache** (`signald/src/hub.rs`,
185 `publish::serve`): the daemon runs a producer loop and continuously
186 publishes. On connect a subscriber is handed the current value of every
187 metric immediately, then streams updates as they change (no longer
188 snapshot-then-close). `terminal-garden` now redraws live.
189- **Phase 2 — terminal collector. ✅ Done (v0.2).**
190 The aggregate-only terminal path is real: `shell-hooks/signald-hooks.zsh`
191 counts keystrokes with a `zle` widget that increments a number and discards
192 the key, and appends `<epoch_ms> <keys> <session_seconds> <session_id>` count
193 records (numbers only) to a spool; `collectors::terminal` consumes the spool
194 and derives `keys_per_min` and `session_seconds`. The **differential
195 secret-typing test is active and passing** — the privacy ship-gate. The
196 Terminal Pet renderer landed in v1.0.
197- **Phase 3 — macOS IOKit hardware collector. ✅ Done (v0.3).**
198 `macos-collector/` (a sibling Swift package, `swift build`) reads aggregate
199 hardware scalars via **IOKit only — no `powermetrics`, no root**: CPU load
200 (Mach `host_processor_info`), battery %/charging (IOKit power sources), battery
201 draw in watts (IORegistry `AppleSmartBattery`), and thermal state
202 (`ProcessInfo`). It emits `signal-schema` wire frames — the schema gained the
203 aggregate `cpu_load` metric and bumped to `SCHEMA_VERSION = 3`. GPU/fan are
204 omitted (no clean root-free channel). The **Swift ↔ Rust byte contract** is
205 documented and pinned by a shared canonical-frame test on both sides
206 (`crates/signal-schema/tests/hardware_wire.rs` decodes the exact bytes the
207 Swift encoder commits to in `macos-collector/Tests/.../WireTests.swift`).
208 *The menu-bar permadeath pet is post-1.0.*
209- **Hardware ingest. ✅ Done (v0.4).**
210 `signald` spawns `macos-collector` as a child (`--collector <path>`, or found
211 on `PATH`) and reads its stdout with the same `wire::read_frame` the socket
212 uses (`collectors::hardware`). The five hardware signals now appear in the
213 hub, the history store, and every subscriber's snapshot
214 (`crates/signald/tests/hardware_ingest.rs`).
215- **Bounded spool and history. ✅ Done (v0.4).**
216 The terminal spool is consumed each tick (renamed aside, read, deleted)
217 instead of re-read in full forever. Records carry the shell's pid, so
218 `keys_per_min` is each active shell's rate summed rather than a mix of
219 interleaved sessions, and `session_seconds` is the longest active shell.
220 History rows older than `--retention-days` (default 7) are pruned on open
221 and every 1000 inserts.
222- **Contract freeze. ✅ Done (v0.5).**
223 `SignalName` is cut to the eleven metrics that have a producer, renumbered
224 from zero, and `SCHEMA_VERSION` is **4 — the 1.0 contract**. Discriminants
225 are append-only from 1.0, so v4 was the last chance to renumber. The only
226 audited tag identifier left is the repo path, confined by the daemon to the
227 roots it was told to watch. A reader now **skips** a frame it cannot decode
228 instead of dying on it (`wire::Frame::Skipped`), so an older renderer keeps
229 working against a newer daemon. References to an uncommitted spec are gone.
230- **v1.0 — trust it unattended. ✅ Done (v1.0.0).**
231 1.0 is not feature completeness and not an API promise to anyone else. It is
232 one claim: **the daemon runs unattended for weeks, recovers from its own
233 failures, and says so when it cannot.**
234 `signald/src/supervisor.rs` owns collector liveness. A panic in the git or
235 terminal collector costs one tick instead of the producer thread; the
236 hardware child is respawned with backoff; `run_git` kills a `git` that hangs,
237 because a Rust thread cannot be. Health rides the bus as
238 `SignalName::CollectorUp`, one per `Source` — the schema's `v5` addition —
239 so a dead collector reaches a face rather than a log file. `terminal-pet`
240 renders it: a pet that cannot feel its own hardware looks sick.
241- **Phase 3 renderer — menu-bar permadeath pet.** *Post-1.0.*
242- **Phase 4** — sonification (SSH-utility first, then continuous). *Post-1.0.*
243- **Phase 5** — live wallpaper (homelab, Path A) + e-ink/poster reuse.
244 *Post-1.0.*
245
246## Install
247
248```sh
249brew install krz/tap/ambient-companions
250brew services start ambient-companions
251```
252
253Name the repositories to watch — signald takes none by default, and a login
254agent has no useful working directory:
255
256```sh
257mkdir -p ~/.config/signald
258cat > ~/.config/signald/repos <<'EOF'
259# one repository path per line; # comments and blank lines are ignored
260~/git/some-repo
261EOF
262brew services restart ambient-companions
263```
264
265Then add one line to `.zshrc` for the terminal collector:
266
267```sh
268source "$(brew --prefix)/share/ambient-companions/signald-hooks.zsh"
269```
270
271Open a new shell and watch a face:
272
273```sh
274terminal-garden # your repos, as plants that grow and wilt
275terminal-pet # the shell and the machine, as a mood
276```
277
278`brew services` logs to `$(brew --prefix)/var/log/`. To run the agent by hand
279instead, `packaging/net.krz.signald.plist` is a launchd template; its header
280comment carries the `sed` line that fills in the paths and the `launchctl load`
281that starts it.
282
283### Paths
284
285Defaults. `crates/signal-client` resolves the socket path once for the daemon
286and every renderer, and its test pins the values below;
287`shell-hooks/signald-hooks.zsh` derives the spool the same way in shell.
288
289| | `$XDG_RUNTIME_DIR` set | otherwise |
290|---|---|---|
291| socket | `$XDG_RUNTIME_DIR/signald.sock` | `~/.local/state/signald/sock` |
292| history db | `$XDG_RUNTIME_DIR/signald.sqlite` | `~/.local/state/signald/signald.sqlite` |
293| terminal spool | `$XDG_RUNTIME_DIR/terminal.spool` | `~/.local/state/signald/terminal.spool` |
294
295The db and the spool are derived from the socket's directory, so `--socket`
296moves all three together. Override individually with `--db` and `--spool`, and
297the spool from the shell side with `$SIGNALD_SPOOL` — it must match whatever
298the daemon uses, or the terminal metrics stay silently empty. launchd sets no
299`XDG_RUNTIME_DIR`, so an installed agent lands in `~/.local/state/signald`.
300
301The repository list is `$XDG_CONFIG_HOME/signald/repos`, else
302`~/.config/signald/repos`. Positional arguments to `signald` override it; with
303neither, the working directory is watched.
304
305Unix socket paths are limited to about 104 bytes. A deep scratch directory
306will hit `SUN_LEN`; use `mktemp -d` when testing by hand.
307
308## Build & test
309
310```sh
311cargo build # whole Rust workspace
312cargo test # includes the privacy invariant suite (must be green)
313
314# The macOS IOKit hardware collector is a sibling Swift package (macOS only):
315cd macos-collector
316swift build # builds the collector
317swift test # the Swift↔Rust wire-contract test
318swift run macos-collector --once # one real IOKit read (no root)
319```
320
321### Pre-push checks
322
323There is no CI. `.githooks/pre-push` is the gate: it runs `cargo test
324--locked`, `cargo clippy --all-targets --locked -- -D warnings`, and the
325`macos-collector` Swift tests, and a failure aborts the push. The differential
326secret-typing tests need `zsh` with the `zsh/zpty` module; the hook checks for
327it first and fails rather than skipping the gate. Enable it once per clone:
328
329```sh
330git config core.hooksPath .githooks
331```