-
Notifications
You must be signed in to change notification settings - Fork 0
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.
-
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-pythoninpyproject.tomlis>=3.13,<3.14, and the repo's.python-versionreads3.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.
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.
uv run pytestThe 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.
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 liveThere's also a soak marker for long-running endurance proofs; those are likewise opt-in.
shelldon's core architectural rules aren't documentation — they're mechanically enforced by import-linter. Run them locally before pushing:
uv run lint-importsThree forbidden contracts are defined in pyproject.toml under [tool.importlinter]:
-
core is LLM-free —
shelldon.coremay not importopenai,anthropic,google,litellm,zhipuai, orollama. The brain never lives in core; core orchestrates, the broker and worker talk to models. -
transport holds no model/tool creds —
shelldon.transportmay not import any provider SDK orshelldon.broker. A chat adapter owns only its own connection credential (e.g. a Telegram token); the broker is the sole holder of model credentials. -
plugins never import core —
shelldon.pluginsmay not import a provider SDK or anything fromshelldon.core, with one allowed exception: the shared bus clientshelldon.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.
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
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.
The largest package and the heart of the system. It is mechanically barred from importing any LLM library (contract #1 above). Notable modules:
-
runtime.py—Core, 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.
shelldon/broker/ is the only place model credentials live and the only code that calls a model.
-
chain.py— builds the ordered provider chain fromPROVIDER_CHAIN(defaultglm). 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— theLLMProviderinterface the adapters implement. -
broker.py,service.py— the broker actor that runs the chain. -
vault.py— credential handling on the broker side.
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.
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.
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.
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.
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__.py → app.main().
-
Pinned dependencies + committed lockfile. Runtime deps are exact-pinned in
pyproject.tomlanduv.lockis committed. Useuv 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 inworker/orbroker/.uv run lint-importswill 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.
The fastest way in is a recipe: Extending shelldon walks through the common edits — adding an LLM provider, a memory operation, a face, a scheduled job, a broadcast event kind, or a plugin — with the exact files and a verification step for each.
The contribution loop:
-
Branch off
main. - Make the change, keeping it surgical and within the import boundaries above.
-
Add or update tests. Tests live in
tests/and mirror the package; the suite is the project's safety net (there were zero tests in v1 — that's a non-negotiable here). Offline by default; mark anything that hits the networklive. -
Run the gates locally — the same two commands CI runs:
uv run lint-imports # architectural import contracts uv run pytest # full suite, network excluded
-
Open a PR against
mainwith a clear description of what changed and why.
There's no separate CONTRIBUTING.md — this section is it.
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 contractsshelldon — an E-Ink AI desk pet · docs generated from the project's design + implementation notes