Skip to content

Development

Elliot Boney edited this page Jun 23, 2026 · 3 revisions

Development

How to set up a local checkout of shelldon, run its tests, and work on the code. For deploying to actual hardware see Running on the Pi; for the runtime layout see Architecture; for environment variables see Configuration.

Prerequisites

  • uv — the project's package manager and runner. Everything below goes through uv run, so you don't manage a virtualenv by hand.
  • Python 3.13 — pinned. requires-python in pyproject.toml is >=3.13,<3.14, and the repo's .python-version reads 3.13. uv will fetch a matching interpreter automatically if you don't have one. The pin tracks the Raspberry Pi OS target (3.13.x).
  • Git.

You can develop on macOS. The full test suite runs on any platform. The live application does not — the per-turn worker needs a real os.fork(), so running the pet end to end is Linux-only (a Pi, a Linux server, or WSL). Day-to-day editing and testing on a Mac is fine; you just can't boot the actual pet there.

Install / sync

Clone and sync against the committed lockfile:

git clone https://github.com/elliotboney/shelldon.git
cd shelldon
uv sync --locked

--locked installs the exact versions pinned in uv.lock and fails rather than silently re-resolving — this is what CI uses, so your local environment matches it. Drop --locked only when you're intentionally changing dependencies.

The runtime dependency set is deliberately tiny (see pyproject.toml):

  • msgspec — typed contracts and bus framing
  • anthropic — broker-only; GLM (via Z.ai's Anthropic-compatible endpoint) and native Claude
  • openai — broker-only; every OpenAI-compatible provider (Ollama, OpenAI, OpenRouter, Groq, Cerebras, NVIDIA, Mistral, GitHub, Gemini)
  • tomlkit — core-only; comment-preserving read/write of the editable faces registry

Dev tooling (import-linter, pytest, pytest-asyncio) lives in the dev dependency group and installs by default with uv sync.

Running the tests

uv run pytest

The suite is large (700+ test functions) and runs everywhere, including macOS. It covers contract round-trips, the single-worker-in-flight invariant, atomic-write crash safety, the broker provider chain, memory/history, and the self-coding tool path.

Live tests are opt-in

Tests that hit a real network LLM are marked live and excluded by default. The pyproject.toml config sets addopts = "-m 'not live'", so a plain uv run pytest never touches the network and never breaks just because an API key happens to be in your environment.

To run them on purpose (you'll need real credentials in the environment — see Configuration):

uv run pytest -m live

There's also a soak marker for long-running endurance proofs; those are likewise opt-in.

Import contracts (enforced in CI)

shelldon's core architectural rules aren't documentation — they're mechanically enforced by import-linter. Run them locally before pushing:

uv run lint-imports

Three forbidden contracts are defined in pyproject.toml under [tool.importlinter]:

  1. core is LLM-freeshelldon.core may not import openai, anthropic, google, litellm, zhipuai, or ollama. The brain never lives in core; core orchestrates, the broker and worker talk to models.
  2. transport holds no model/tool credsshelldon.transport may not import any provider SDK or shelldon.broker. A chat adapter owns only its own connection credential (e.g. a Telegram token); the broker is the sole holder of model credentials.
  3. plugins never import coreshelldon.plugins may not import a provider SDK or anything from shelldon.core, with one allowed exception: the shared bus client shelldon.core.bus. A plugin is a bus client speaking the Envelope contract — not part of the brain.

If you add an import that breaks one of these, CI fails. That's intentional: the rules that matter are impossible to break accidentally.

Repository layout

The application package is shelldon/. A guided tour:

shelldon/
  __main__.py        # `python -m shelldon` entrypoint → app.main()
  app.py             # composition root — wires the actors, forks the processes
  contracts/         # the typed Envelope/Job/Result message contracts (msgspec)
  core/              # the LLM-free core (state, memory, arbiter, reflexes, scheduler…)
    bus/             # the Envelope message bus over Unix domain sockets
  broker/            # the only egress to an LLM — creds + ordered provider chain
  worker/            # the ephemeral, forked-per-turn brain
  transport/         # pluggable chat surfaces — CLI, Telegram
  display/           # the E-Ink face surface (+ vendored Waveshare driver)
    drivers/         # vendored panel driver (epd2in13_V4, epdconfig)
  plugins/           # optional bus-client plugins — XP, battery, sensors

contracts/ — the wire

shelldon/contracts/__init__.py defines the typed messages every actor exchanges: the Envelope that frames the bus, plus the Job/Result payloads. These are msgspec structs (versioned, fast to encode). Everything else is built around honoring these contracts.

core/ — the LLM-free brain stem

The largest package and the heart of the system. It is mechanically barred from importing any LLM library (contract #1 above). Notable modules:

  • runtime.pyCore, the central actor; owns the bus and the fork-server.
  • bus/ — the message bus (server.py, frame.py): Envelopes over Unix domain sockets.
  • memory.py, history.py — hybrid memory: a human-readable markdown tree for curated knowledge (memory.py) and a WAL/FTS5 sqlite store for conversation history (history.py). Both default to ~/.shelldon/.
  • arbiter.py, scheduler.py, proactive.py, budget.py — when the pet acts on its own: arbitration, scheduling, the proactive-message logic, and the daily spend/credit budget.
  • reflexes.py, reactions.py, state.py, faces.py — the "feels alive" layer: resident reflexes (blink, idle, mood drift) and the personality/mood/face state that runs between turns.
  • selfcode.py — the core side of self-coding: staging, the static import check, promotion of approved tools, and the workspace layout (DEFAULT_WORKSPACE_ROOT).
  • power.py, limits.py, vault.py, dispatch.py, turn.py — power/battery state, resource limits, the OS-locked credential vault, dispatch, and per-turn bookkeeping.

broker/ — the single LLM egress

shelldon/broker/ is the only place model credentials live and the only code that calls a model.

  • chain.py — builds the ordered provider chain from PROVIDER_CHAIN (default glm). Adding or reordering a provider is a config line, not code.
  • anthropic_provider.py, openai_provider.py — the two wire-format adapters (Anthropic SDK for GLM/Claude; OpenAI SDK for everything OpenAI-compatible).
  • provider.py — the LLMProvider interface the adapters implement.
  • broker.py, service.py — the broker actor that runs the chain.
  • vault.py — credential handling on the broker side.

worker/ — the ephemeral brain

shelldon/worker/ is what core forks for each turn. The fork-server (forkserver.py) preloads once; each turn forks a child that assembles its prompt (prompt.py), runs the tool loop (tools.py, worker.py) against the broker, and dies. Forking per turn is why RAM never accumulates across turns — the design answer to v1's OOM crashes.

transport/ — pluggable chat

shelldon/transport/ holds the chat adapters: cli.py (stdin/stdout, the zero-hardware default) and telegram.py (the bot). runner.py is the shared adapter runner. Selection is by SHELLDON_TRANSPORT (Configuration). No transport is wired into core — they're bus clients.

display/ — the face

shelldon/display/ renders the E-Ink face. renderer.py defines the renderer seam (StubRenderer records draws for headless/dev; WaveshareRenderer drives the panel), waveshare.py paints the faces and status zones, and drivers/ is the vendored Waveshare 2.13"V4 driver. Selection is by SHELLDON_DISPLAY.

plugins/ — optional capabilities

shelldon/plugins/ are optional bus-client plugins loaded by host.py: xp.py (leveling), battery.py, and the sensing plugins (sensing_ble.py, sensing_button.py). A plugin only speaks the Envelope contract — it can't reach into core or the brain.

app.py — the composition root

shelldon/app.py is where it all comes together. It creates the memory tree (including the OS-locked vault and the self-coding workspace), resolves the worker privilege-drop identity, builds the fork-server, and launches the actors. In production it forks real OS processes: core (owning the bus and fork-server) in the main process, with broker / display / transport / plugin-host as multiprocessing children. The launcher is injected behind a launch_actors seam so the smoke test exercises the same composition in-process and cross-platform, while production gets the real multi-process model. Entry is python -m shelldon__main__.pyapp.main().

Conventions that matter to a contributor

  • Pinned dependencies + committed lockfile. Runtime deps are exact-pinned in pyproject.toml and uv.lock is committed. Use uv sync --locked. Don't bump a version without intending to.
  • The LLM-free-core rule is law. Keep model code out of shelldon.core. If you need the brain, you're in worker/ or broker/. uv run lint-imports will catch a violation, but design around it from the start.
  • Respect the credential boundary. The broker is the sole holder of model credentials. Transports and plugins never import the broker or a provider SDK.
  • Match the existing style. The codebase favors small, well-documented modules and mechanically-enforced invariants over comments-as-policy.

Quick reference

uv sync --locked         # install exactly what CI installs
uv run pytest            # full suite, network excluded
uv run pytest -m live    # opt-in live-LLM tests (needs real creds)
uv run lint-imports      # enforce the architectural import contracts

Clone this wiki locally