Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 13 additions & 25 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,30 +1,18 @@
# Agent Operations Guide
# AGENTS.md

## Voice Automation Flow
- Use `bin/run-voice-loop` to launch the Codex supervisor. By default it streams prompts through `codex exec --full-auto -`; override `CODEX_CHAT_CMD` before launch if you need a different model or options.
- The loop reads `state/session.json` for context. Flip `listening: true` with `bin/px-wake --set on` (or `--keyboard`) before speaking; the loop idles until that flag is raised.
- Audio feedback is produced through `tool-voice` (`espeak` fallback). Logs are appended to `logs/tool-voice-loop.log` and `logs/tool-voice-transcript.log`. Inspect quick stats with `bin/px-voice-report --json`.
This repository's agent guidance lives in **[CLAUDE.md](CLAUDE.md)** — the
cross-cutting invariants you must not break, and the map to where current truth
for each subsystem lives.

## Diagnostics & Safety
- Run `bin/px-diagnostics` at the start of a session. It narrates every check (status, sensors, weather, circle motion unless `--no-motion`, camera capture, speaker/microphone tests) and records a JSON summary in `logs/tool-diagnostics.log`.
- Keep wheels on blocks for live runs; dry-run (`PX_DRY=1`) skips motion and still plays the spoken announcements so you can verify the speaker.
- `bin/px-stop` remains the emergency halt; it is safe to call repeatedly.
Read it first. It applies to every agent working here, not only Claude Code.

## Automation Toolbox
- `bin/px-dance` performs a narrated demo routine (voice intro → circle → figure-eight → finale). Use `PX_DRY=1` to rehearse without motion.
- `bin/px-frigate-stream` pushes an RTSP feed (`rpicam-vid` → `ffmpeg`) to Frigate/go2rtc (`pi5-hailo.local` by default). Test first with `--dry-run` to confirm command lines.
- `bin/px-session` bootstraps a tmux workspace (voice loop, wake console, log tail). `--plan` prints the layout before launching.
Start with:

## Development Workflow
1. Activate the virtualenv: `source .venv/bin/activate`.
2. Implement helpers under `bin/` and keep logic in Python for easier testing.
3. Add or update pytest coverage in `tests/`; set `PX_BYPASS_SUDO=1` and `LOG_DIR=logs_test` (relative paths resolve under `PROJECT_ROOT`) in the test environment to avoid privileged operations.
4. Run `python -m pytest` before every commit (current suite covers voice tools, diagnostics, tmux plan, and streaming helpers).
5. Update documentation (`README.md`, `docs/TOOLS.md`, roadmap/strategy docs) alongside new features so operators have fresh instructions.
- [CLAUDE.md](CLAUDE.md) — the invariants and the navigation map
- [docs/architecture/overview.md](docs/architecture/overview.md) — the system map
- [docs/git-workflow.md](docs/git-workflow.md) — **never blanket-stage**; stage exact owned paths
- [docs/testing.md](docs/testing.md) — the suite runs on the robot it controls

## Lessons Learned
- Treat every helper as a modular tool Codex can invoke; build consistent JSON outputs and summaries to keep transcripts clean.
- State persistence is critical: always update `state/session.json` when a tool runs so the next Codex turn has context.
- Keep audio pathways live even in dry-run; it surfaced a muted speaker regression immediately.
- Use structured logging (`logs/tool-voice-transcript.log`, `logs/tool-*.log`) to audit behaviour and drive reporting tools.
- Leverage tmux (`bin/px-session`) during development to survive SSH drops and keep wake/log panes visible.
> This file previously carried its own operations guide, written when the voice
> loop was Codex-only. It described a system that no longer exists and is
> preserved in git history rather than here.
617 changes: 167 additions & 450 deletions CLAUDE.md

Large diffs are not rendered by default.

6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ bin/px-spark [--dry-run] [--input-mode voice|text]

## Architecture

> This section is an orientation sketch. The **canonical** architecture docs —
> and the invariants that must not be broken — are
> [CLAUDE.md](CLAUDE.md) and [docs/architecture/overview.md](docs/architecture/overview.md).

```
┌─────────────────────────────────────────────┐
│ Voice Backends │
Expand Down Expand Up @@ -481,7 +485,7 @@ source .venv/bin/activate
# 4. Dry-run a tool to verify the setup
PX_DRY=1 bin/tool-status

# 5. Run tests (1070 dry-run, no hardware needed)
# 5. Run tests (~1460 collected; use -m 'not live' to skip hardware)
python -m pytest tests/ -m "not live"

# 6. Launch SPARK (Claude voice companion)
Expand Down
84 changes: 84 additions & 0 deletions docs/architecture/memory-and-learning.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
# Memory and Learning

**Owns:** what SPARK keeps between turns and between days —
`src/pxh/memory.py`, `src/pxh/intention.py`,
`src/pxh/contextual_preference.py`, and the conversation buffer.

Where those records get their trust from is
[architecture/provenance](provenance.md).

---

## Invariant

### Four stores, four different lifetimes

| Store | File | Lifetime |
|---|---|---|
| Conversation buffer | `state/conversation-{persona}.jsonl` | rolling window, `PX_CONVERSATION_TURNS` (default 10) |
| Notes | `state/notes[-persona].jsonl` | append-only, durable |
| Consolidated memories | `state/memories-{persona}.jsonl` | append-only, distilled nightly |
| Active goal | `state/intention-{persona}.json` | one at a time, 7-day expiry |
| Contextual experience | persona-scoped experience store | append-only, never rewritten |

**Every store is persona-scoped** so GREMLIN, VIXEN and SPARK histories never
bleed into each other. A shared store would let a jailbroken persona's output
re-enter SPARK's cognition as if SPARK had thought it.

### The conversation buffer is short-term memory, and it is separate from state

Each turn appends to the buffer and is injected into the next prompt as a
"Recent conversation" section. This is what gives SPARK continuity across turns
without depending solely on file-injected session state. SPARK's own utterance
is the action's `params.text`, falling back to `(tool_name)` for non-speech
actions.

### Consolidation is nightly, capped, and metered

A Haiku pass between 02:00–06:00 Hobart, at most 2 attempts per day
(`state/consolidation_meta.json`), distils the last 24h of thoughts into
`state/memories-{persona}.jsonl`.

**Consolidation allowlists its input fields.** It does not copy arbitrary
thought keys forward, which is what stops a location-bearing field surviving
into a store that reflection reads. See
[architecture/privacy](privacy.md).

### Retrieval is by relevance, and never pads

Reflection retrieves the top-3 relevant memories by keyword/tag overlap. A
populated store with no relevant hit returns nothing rather than falling back
to recency or to raw notes — see
[architecture/provenance](provenance.md).

### Goals are singular and expire

`intention.py` holds one active goal per persona, with a 7-day expiry.
`set_goal` archives any previous active goal rather than overwriting it. A
robot with five simultaneous goals has none.

### Lived-experience adaptation is append-only and narrow

`contextual_preference.py` records one system-attributed experience per line
and **never updates an earlier line**. Corruption is reported without
rewriting the file. Adaptation is deliberately bounded to choosing between
options SPARK already had, not to inventing new behaviour.

`load_experiences()` reports corrupt lines rather than silently skipping them,
because a store that quietly drops what it cannot parse is a store whose size
tells you nothing.

---

## Why it looks like this

*History, not rule.*

Persona scoping came after cross-persona bleed: SPARK retrieved a GREMLIN note
and reflected on it in SPARK's voice.

The retrieval-never-pads rule came from an observed fixation. SPARK's thoughts
circled Obi far more than the topic seeds explained, and the cause turned out
to be retrieval bias rather than the seeds — the memory query was built almost
entirely from human-presence signals, so nearly every query matched
human-presence memories, and padding with recent records made it worse.
111 changes: 111 additions & 0 deletions docs/architecture/overview.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
# Architecture Overview

**Owns:** the map. What the parts are, which direction they depend on, and
where each one is documented in full. No subsystem detail lives here — this
page exists so you can find the page that has it.

---

## Invariant

### One robot, four concentric layers

```
hardware picarx / robot_hat / GPIO / I2C / ALSA
^
daemons px-alive, px-wake-listen, px-battery-poll, px-mind,
px-brain, px-post, px-api-server, px-blog, px-evolve
^
library src/pxh/ — the logic; importable and testable
^
surfaces bin/px-* (humans), bin/tool-* (LLM dispatchers),
REST API, MCP server, site
```

**Logic lives in `src/pxh/`, never in a `bin/` script.** `bin/` scripts are
thin: they source `bin/px-env`, resolve privileges, and call into the library
or a tool. The rule exists so behaviour can be tested in-process; a rule
implemented inside a bash heredoc can only be tested by spawning a subprocess,
and subprocess tests are the slowest and flakiest part of the suite.

`src/pxh/wander.py` is the worked example: `bin/px-wander` is a thin wrapper
(privilege elevation, calibration guard) around an importable engine.

### The three cognitive layers

SPARK's autonomy is `px-mind` (`src/pxh/mind.py`), and it is three layers with
different costs and different failure postures:

| Layer | Cadence | LLM? | Writes |
|---|---|---|---|
| 1 — Awareness | every 60s | no | `state/awareness.json` |
| 2 — Reflection | on transition, or every 5 min idle | yes | `state/thoughts.jsonl` |
| 3 — Expression | gated by cooldown + suppressors | no | dispatches a `bin/tool-*` |

Layer 1 must never call an LLM: it is the layer that still works when the
network is down, and everything above it reads its snapshot.

Layer 3 is **budgeted and suppressed**, not free-running:
`EXPRESSION_COOLDOWN_S` (1800s) is the minimum gap between spontaneous
utterances. `greet_arrival` bypasses that budget on a real arrival, with its
own 120s anti-flap. Expression is suppressed entirely during school, quiet
time and bedtime (all calendar-driven), and during night silence — see
[policy-and-authority](policy-and-authority.md).

Layer 3 never touches GPIO directly. Every physical act routes through a
`bin/tool-*`, which is what makes the policy sink in
[policy-and-authority](policy-and-authority.md) a real chokepoint rather
than an advisory one.

### Dependency direction is one-way

`policy.py` imports neither `mind.py` nor `voice_loop.py`. Both dispatchers
import `policy.py`. The same holds for `provenance.py`, `health.py`,
`state.py`, and `runtime_paths.py` — leaf modules that many callers share.

A new module that needs to import a dispatcher is a design smell: it means the
rule you are writing belongs in the dispatcher, or the shared part belongs in a
new leaf.

---

## Where each subsystem is documented

| Subsystem | Doc |
|---|---|
| Resident Claude session (the brain) | [architecture/resident-brain](resident-brain.md) |
| Behavioural policy, trust boundaries, self-evolution limits | [architecture/policy-and-authority](policy-and-authority.md) |
| Epistemic provenance of durable claims | [architecture/provenance](provenance.md) |
| Location, private messages, chat sanitisation | [architecture/privacy](privacy.md) |
| Memories, goals, lived-experience adaptation | [architecture/memory-and-learning](memory-and-learning.md) |
| GPIO exclusivity, px-alive, leases | [hardware/gpio-and-alive](../hardware/gpio-and-alive.md) |
| Cliff guard and wander safety | [hardware/wander-safety](../hardware/wander-safety.md) |
| Capture, playback, TTS | [hardware/audio-and-mic](../hardware/audio-and-mic.md) |
| Battery, charge detection, emergency shutdown | [hardware/power](../hardware/power.md) |
| Session state, atomic writes, tmpfs runtime | [operations/state-and-runtime](../operations/state-and-runtime.md) |
| Daemon health reporting | [operations/health](../operations/health.md) |
| LLM tiers, budgets, metering | [operations/llm-routing](../operations/llm-routing.md) |
| Services, install, tunnel | [operations/deployment](../operations/deployment.md) |
| Test isolation and hermeticity | [testing](../testing.md) |

Per-script and per-module reference — every file in `bin/` and `src/pxh/` —
lives in [docs/SCRIPTS.md](../SCRIPTS.md). This page does not duplicate it.

A plain-language explanation written for Obi is
[docs/how-sparks-brain-works.md](../how-sparks-brain-works.md).

---

## Why it looks like this

*History, not rule.*

The layering was not designed up front. `px-mind` began as a single loop that
called an LLM on every tick; splitting awareness out was forced by network
outages, during which the robot went completely blind rather than merely
quiet. The "layer 1 never calls an LLM" rule is that outage written down.

The `bin/` → `src/pxh/` extraction happened subsystem by subsystem, and
`wander.py` was the one that made the case: the cliff guard could not be
regression-tested at all while it lived inside a bash heredoc, and it was the
part of the robot most likely to drive itself off a table.
Loading