# Sénia a personal butler/coder AI assistant. runs in a docker container with persistent state, an HTTP API for chat, autonomous heartbeat behavior, and a memory system designed to mimic human-style recall (granular recent, fuzzy distant). ## current state ### running - **docker container, debian trixie**, `restart: unless-stopped` - **senia user** (uid 750) with narrow `sudo` (apt/apt-get only) - **claude code** as the current LLM backend (subscription-auth via mounted `~/.claude/`), installed system-wide so updates happen via container rebuild rather than self-update - **read-only `:ro` mounts** enforce sandbox boundaries even against in-container root - **HTTP API** on port 8000, OpenAI-compatible (`/v1/chat/completions`, `/v1/models`) ### the agent loop (`senia/agent/`) - **fastapi service** running inside the container as the main process (uvicorn) - **backend-agnostic design** via a `ModelBackend` strategy pattern: swapping models is a one-line change - **current backend:** shells out to `claude -p` for non-interactive turns - **dynamic per-turn context** assembled fresh: current time + today's diary entries - **persona** handled by the backend natively (claude code autodetects `CLAUDE.md` from the workspace) - **dependency management with `uv`**, a single static rust binary, dramatically faster than pip/poetry, uses standard PEP 621 metadata in pyproject.toml ### memory - **diary log MCP tool:** short timestamped entries in sqlite, agent-authored throughout the day - **task tracking MCP tool:** categorized by owner `agent` (to be done autonomously during heartbeats) or `user` (to periodically remind the user of) - **single shared db** at `/etc/senia/data/senia.db`, migration-tracked - **persona files** (SOUL.md / USER.md / AGENTS.md) for stable identity across model swaps ### autonomous behavior - **variable heartbeat script** runs every 30 min via host cron + `docker exec` - wakes Sénia when there are pending tasks OR more than 2.5 hours since the last wake (5 hours since last wake during the night) - captures her response and delivers it via **ntfy** push notification - runs orthogonal to the HTTP API; the heartbeat directly invokes claude code, doesn't pass through the loop ### tooling for the agent herself - `uv` (preferred) and `pip --user` for python packages - `npm install -g` for node tools (configured to her user-local npm prefix) - `sudo apt install` for system packages, narrowly allowed ### public / private repo split the persona content (`CLAUDE.md`) and saved conversation contexts contain real personal data that shouldn't live in a public portfolio. they're tracked in a separate private companion repository (`senia-private`), referenced as a git submodule at `private/`. the setup script handles both cases: - **with access to the private repo:** the submodule pulls down, real persona and contexts are mounted into the container - **without access:** placeholder files are generated locally, the container runs in a demonstrable state with a generic persona both cases produce a fully working system; only the content differs. this preserves portfolio-readability while protecting personal data. ## planned ### chat interface - **Open WebUI** behind authentik for the primary chat surface (browser + mobile) - **Conduit** as the native mobile client (FOSS, flutter, authentik-aware) - the loop's HTTP API registered with Open WebUI as a custom OpenAI provider - **ntfy** kept for asynchronous Sénia-initiated push (heartbeats, alerts) ### memory expansion - **daily markdown summaries:** end-of-day compaction job consolidates raw logs into structured daily entries with frontmatter - **monthly markdown summaries:** same pattern, dailies into a monthly digest - **embeddings layer:** semantic retrieval via `sqlite-vec`, multilingual embedding model running locally (e.g. multilingual-e5-base via ollama) - **session-start context injection:** already partially done via the loop; will expand as compaction lands ### model strategy - **swap the backend to a local-first hybrid:** Qwen3.6 quantized via ollama as the default for english butler tasks - **delegation pattern:** catalan inputs + coding tasks routed to a strong API model (Claude Sonnet) - **OpenCode** as a tool Sénia can shell out to for LSP-aware code work - expected to be a single new `OllamaBackend` class in `agent/orchestrator/models/` + routing logic in the loop ### knowledge - **DokuWiki** as the private wiki (deferred for later) - **Sénia reads via HTTP API**, not direct filesystem; DokuWiki handles link resolution and rendering - **drafts to a staging area** for documentation help; user commits to wiki manually, preserving curation quality ## project structure ``` ~/senia/ ├── .gitmodules # references the private submodule ├── .gitignore ├── .dockerignore # at project root (the docker build context) ├── README.md ├── scripts/ │ └── setup.sh # bridges public + private repos with fallback ├── docker/ │ ├── compose.yml │ └── Dockerfile ├── claude/ │ └── CLAUDE.example.md # placeholder persona, tracked publicly ├── workspace/ # mounted as /workspace, Sénia's per-turn cwd ├── data/ # runtime state, owned by senia user │ ├── senia.db # single sqlite db (diary, tasks, future tables) │ ├── memory/ # future: daily and monthly summaries │ ├── heartbeat/ # heartbeat state + logs │ └── scripts/ │ └── heartbeat.py # variable cron-driven wake-up ├── mcp/ # MCP server source, mounted :ro │ └── senia/ │ ├── server.py │ ├── db.py │ ├── memory/ │ │ └── diary.py │ ├── tasks.py │ └── migrations/ ├── private/ # submodule: real CLAUDE.md + contexts/ │ # (gitignored except for .gitmodules entry) └── agent/ # the orchestration loop, baked into the image ├── pyproject.toml ├── uv.lock ├── README.md └── orchestrator/ ├── api.py # fastapi entrypoint ├── loop.py # per-turn orchestration ├── context.py # dynamic per-turn context builder └── models/ ├── base.py # ModelBackend ABC, ModelRequest/Response types └── claude_code.py # current backend ``` ## working with the project ### first-time setup ```bash git clone git@github.com:vylion/senia.git cd senia ./scripts/setup.sh # initializes private submodule or generates placeholders cd docker docker compose build docker compose up -d docker compose logs -f senia ``` ### updating agent code the agent code (everything under `agent/`) is **baked into the image at build time**, not mounted at runtime. so changes to `agent/` require rebuilding: ```bash cd ~/senia/docker docker compose build senia docker compose up -d ``` thanks to docker's layer caching (dependencies are copied/installed in a separate step from application code), rebuilds where only python code changed complete in seconds. only changes to `pyproject.toml` or `uv.lock` trigger a full dependency reinstall. other things that don't require rebuild: - the persona files, mcp server code, data, and workspace: all mounted at runtime - the heartbeat script (lives on the host, runs via `docker exec`) ## design principles - **own the loop where dynamic context injection matters; use mature tools where they earn their keep** (e.g. OpenCode for LSP-aware coding when added, Open WebUI for chat UI polish) - **memory hierarchy mirrors human recall:** granular recent, compressed distant, semantic retrieval on demand - **model is swappable; system stays the same:** persona, tools, and memory all model-agnostic - **container is the security boundary:** in-container non-root user + `:ro` mounts give Sénia freedom in her sandbox without compromising host - **diary as audit trail:** installations, decisions, and observations all logged, so the user can review what's been changed - **orthogonal channels:** chat goes through the loop's HTTP API; heartbeats invoke claude code directly. each channel uses the simplest path that works - **separation of public code and private data:** architecture is open, content is private, both versioned