README.md
213 lines · 11961 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```
24
25All five draw from the **same signal bus**. Renderers never poll hardware,
26never read the shell — they subscribe over a local Unix socket
27(`$XDG_RUNTIME_DIR/signald.sock`). That buys one privacy boundary to defend
28instead of five, cheap renderers, and reuse of the collection layer into
29non-toy outputs (e-ink dashboards, printed posters).
30
31## The privacy boundary, enforced by construction
32
33Reading typing during real work is a keylogger unless it is *structurally*
34content-free — and unless that is a **test**, not a sentence in a README.
35
36The invariant (spec §1.5):
37
38> No process persists, transmits, or exposes any representation from which the
39> content or identity of an individual keystroke, command argument, or typed
40> character can be recovered. Only order-free aggregates (counts, rates,
41> durations, codes) leave the terminal collector.
42
43Made true by construction:
44
45- **No content channel exists in the wire format.** The only payload channel is
46 `Value`, a newtype over `f64`. There is no `text`, `bytes`, or `payload`
47 field — a collector *cannot* emit typed content because the record has
48 nowhere to put it. The two audited exceptions are non-content identifiers in
49 `tag` (bundle id, repo path, ssh host), allow-listed per metric name.
50- **The key counter never stores the key**, there is **no input tap anywhere**
51 (`CGEventTap`, `IOHIDManager` keyboard usage, accessibility observation are
52 all forbidden), and **aggregation happens before transport** (the shell emits
53 counts on `precmd`, never per-key events).
54
55And it is tested (`crates/signal-schema/tests/privacy_invariant.rs`):
56
57- `value_channel_is_exactly_f64` — the payload is an `f64`, nothing wider.
58- `wire_format_has_no_content_field` — the `Signal` type declares no
59 content-carrying field beyond the audited `tag`.
60- `forbidden_symbol_scan` — the tree contains none of the banned keylogger APIs
61 or shell line-buffer references (the static CI gate).
62- `differential_secret_typing` — **active (the ship gate).** It drives the real
63 hook (`shell-hooks/signald-hooks.zsh`) through a real interactive zsh under a
64 real pseudo-terminal (zsh's own `zsh/zpty` — no extra dependency), *typing a
65 planted secret*, and asserts the secret never appears — plain, reversed, hex,
66 or base64 — in the shell spool or the `f64`-only wire encoding. The **full
67 pipeline** version (same real typing driven through the terminal collector,
68 the SQLite history store, and the hub) is
69 `crates/signald/tests/differential_secret_typing.rs`. Real `zle` keystroke
70 counting increments a *number* per key and discards the key, so the only thing
71 the pipeline ever receives about the typing is a count — the tests prove that
72 empirically across every downstream artifact.
73
74## Build order
75
76Build the backend to the point each renderer needs, then build the renderer
77that is cheapest *and* most valuable given what exists:
78
791. **garden** — git is the cleanest signal (discrete, no privacy questions), so
80 it proves the bus first. *(recommended first face)*
812. **gated collector + pet** — add the sensitive terminal collector only after
82 the privacy tests are green and extended to cover it.
833. **menu-bar permadeath pet** — first macOS-native face; ships the one-life
84 variant (ages in wall-clock time, dies permanently) — the emotional hook.
854. **SSH-sonification** — unexpected access becomes *audible*; the utility
86 sonification before the ambient one.
875. **homelab wallpaper** — desktop-as-status-board (Path A: render-to-image +
88 `setDesktopImageURL`); the same frame pipeline later feeds e-ink and posters.
89
90## macOS collector constraint
91
92The system + hardware collector is **IOKit-only: no root, no `powermetrics`.**
93`powermetrics` wants root and would make the suite un-shippable as a plain user
94agent. Signals come from IOKit (`IOPMPowerSource` / power sources, IORegistry
95`AppleSmartBattery`), `ProcessInfo`, and Mach `host_processor_info`. Where a
96metric can't be reached without root, it is simply **absent from the schema**
97rather than gated behind sudo. Accessibility permission is never requested.
98
99It ships in v0.3 as **`macos-collector/`**, a sibling **Swift** package (built
100with `swift build`, kept out of the cargo workspace — SwiftPM and cargo do not
101share a build system). It reads aggregate CPU load, battery %, charging, battery
102draw (W), and thermal state, and emits them as `signal-schema` wire frames — the
103same byte format `signald` parses. GPU/fan are deliberately omitted (no clean
104root-free IOKit channel). See `macos-collector/README.md` for the full **Swift ↔
105Rust wire contract** (the byte layout) and the shared canonical-frame test that
106pins both sides to the same bytes.
107
108## Workspace layout
109
110```text
111ambient-companions/
112├── Cargo.toml # Rust workspace
113├── crates/
114│ ├── signal-schema/ # shared wire format (the privacy boundary)
115│ │ ├── src/lib.rs
116│ │ └── tests/privacy_invariant.rs
117│ ├── signald/ # the daemon (collectors + live fan-out)
118│ │ ├── src/lib.rs # git + terminal collectors, publish
119│ │ ├── src/history.rs # SQLite (WAL) history store + recent() query
120│ │ ├── src/hub.rs # last-value cache + live fan-out
121│ │ ├── src/main.rs # CLI, producer loop, self-attestation
122│ │ ├── tests/git_collector.rs # aggregates vs a temp git repo
123│ │ ├── tests/streaming.rs # last-value cache + live update
124│ │ └── tests/differential_secret_typing.rs # the full privacy ship-gate
125│ └── terminal-garden/ # first renderer: a socket subscriber
126│ ├── src/lib.rs # signals → plots → render (unit-tested)
127│ └── src/main.rs # live subscribe + redraw loop
128├── shell-hooks/ # zsh hooks: aggregate-only terminal collector
129│ ├── signald-hooks.zsh
130│ └── README.md
131└── macos-collector/ # SwiftPM sibling (NOT in the cargo workspace):
132 ├── Package.swift # macOS IOKit hardware collector (Phase 3)
133 ├── README.md # the Swift↔Rust wire contract (byte layout)
134 ├── Sources/CollectorCore/ # wire encoder + IOKit reads (host_processor_
135 │ # info, power sources, AppleSmartBattery)
136 ├── Sources/macos-collector/ # thin CLI: --once / stream / --hex / --out
137 └── Tests/ # shared canonical-frame wire-contract test
138```
139
140### Language note
141
142The daemon is written in **Rust**. The spec allows Rust or Go; Rust is chosen
143because it makes the "no content field exists" guarantee enforceable in the
144type system (the `f64`-only `Value` payload), which is the whole point of the
145privacy boundary.
146
147### Dependencies
148
149`signal-schema` is **dependency-free** by design — the wire format is the
150privacy boundary and carries no third-party code. `signald` has **one**
151dependency, `rusqlite` (with the `bundled` feature, so SQLite is compiled
152in-tree and there is no system-library requirement), for the WAL history store.
153The terminal collector's shell side needs only `zsh` (the `zle`, `datetime`,
154and — for the differential test — `zpty` modules ship with zsh).
155
156## Phase plan
157
158- **M0 — backend skeleton + schema + privacy invariant test. ✅ Done.**
159 Workspace builds; the structural privacy tests pass.
160- **Phase 1 — git collector + garden. ✅ Done (v0.1).**
161 The wire format (`encode`/`decode` + length-prefixed framing) is real and
162 `f64`-only. `signald`'s git collector shells out to `git` for aggregate
163 scalars — commits-in-window, commits-today, branch count, days-since-last-
164 commit — tagged by the audited repo path. `terminal-garden` subscribes and
165 renders each repo as a plot: growth (🌱→🌿→🌳) tracks recent commits, wilt
166 (🥀→🍂) tracks staleness.
167- **Persistence + live streaming. ✅ Done (v0.2).**
168 - **SQLite (WAL) history** (`signald/src/history.rs`): every published signal
169 is persisted (aggregate scalars only — same privacy constraints as the
170 wire), and `recent()` / `recent_named()` let a renderer read recent history,
171 not just the live snapshot.
172 - **Live streaming + last-value cache** (`signald/src/hub.rs`,
173 `publish::serve`): the daemon runs a producer loop and continuously
174 publishes. On connect a subscriber is handed the current value of every
175 metric immediately, then streams updates as they change (no longer
176 snapshot-then-close). `terminal-garden` now redraws live.
177- **Phase 2 — terminal collector. ✅ Done (v0.2).**
178 The aggregate-only terminal path is real: `shell-hooks/signald-hooks.zsh`
179 counts keystrokes with a `zle` widget that increments a number and discards
180 the key, and appends `<epoch_ms> <keys> <session_seconds>` count records
181 (numbers only) to a spool; `collectors::terminal` reads the spool and derives
182 `keys_per_min` and `session_seconds`. The **differential secret-typing test is
183 active and passing** — the privacy ship-gate. *The Terminal Pet renderer is
184 still todo.*
185- **Phase 3 — macOS IOKit hardware collector. ✅ Done (v0.3).**
186 `macos-collector/` (a sibling Swift package, `swift build`) reads aggregate
187 hardware scalars via **IOKit only — no `powermetrics`, no root**: CPU load
188 (Mach `host_processor_info`), battery %/charging (IOKit power sources), battery
189 draw in watts (IORegistry `AppleSmartBattery`), and thermal state
190 (`ProcessInfo`). It emits `signal-schema` wire frames — the schema gained the
191 aggregate `cpu_load` metric and bumped to `SCHEMA_VERSION = 3`. GPU/fan are
192 omitted (no clean root-free channel). The **Swift ↔ Rust byte contract** is
193 documented and pinned by a shared canonical-frame test on both sides
194 (`crates/signal-schema/tests/hardware_wire.rs` decodes the exact bytes the
195 Swift encoder commits to in `macos-collector/Tests/.../WireTests.swift`).
196 *Still todo:* the menu-bar permadeath pet, and the live socket handshake by
197 which `signald` ingests the collector's frames (minimal/documented in v0.3).
198- **Phase 4** — sonification (SSH-utility first, then continuous). *Out of scope.*
199- **Phase 5** — live wallpaper (homelab, Path A) + e-ink/poster reuse.
200 *Out of scope.*
201
202## Build & test
203
204```sh
205cargo build # whole Rust workspace
206cargo test # includes the privacy invariant suite (must be green)
207
208# The macOS IOKit hardware collector is a sibling Swift package (macOS only):
209cd macos-collector
210swift build # builds the collector
211swift test # the Swift↔Rust wire-contract test
212swift run macos-collector --once # one real IOKit read (no root)
213```