https://github.com/easonlai/opencrayfish
Edge-native AI companion that lives 24/7 on a Raspberry Pi 5 + AI HAT+ 2 (Hailo-10H NPU, 40 TOPS). Fully offline: local 1.5B SLM (qwen2.5), self-hosted SearXNG, no cloud. Biologically inspired β heartbeat, vital signs, 5-channel mood, STM/LTM hierarchy, nightly Sleep Metabolism, proactive research, and a self-reflection loop.
https://github.com/easonlai/opencrayfish
ai-agent ai-companion ai-hat-plus asyncio edge-ai hailo npu offline-ai ollama opencrayfish python qwen raspberry-pi raspberry-pi-5 searxng slm small-language-model sovereign-ai streamlit telegram-bot-ai-assistant
Last synced: about 9 hours ago
JSON representation
Edge-native AI companion that lives 24/7 on a Raspberry Pi 5 + AI HAT+ 2 (Hailo-10H NPU, 40 TOPS). Fully offline: local 1.5B SLM (qwen2.5), self-hosted SearXNG, no cloud. Biologically inspired β heartbeat, vital signs, 5-channel mood, STM/LTM hierarchy, nightly Sleep Metabolism, proactive research, and a self-reflection loop.
- Host: GitHub
- URL: https://github.com/easonlai/opencrayfish
- Owner: easonlai
- License: mit
- Created: 2026-05-12T12:30:37.000Z (about 2 months ago)
- Default Branch: main
- Last Pushed: 2026-05-18T03:26:41.000Z (about 2 months ago)
- Last Synced: 2026-05-18T05:32:53.642Z (about 2 months ago)
- Topics: ai-agent, ai-companion, ai-hat-plus, asyncio, edge-ai, hailo, npu, offline-ai, ollama, opencrayfish, python, qwen, raspberry-pi, raspberry-pi-5, searxng, slm, small-language-model, sovereign-ai, streamlit, telegram-bot-ai-assistant
- Language: Python
- Homepage:
- Size: 546 KB
- Stars: 0
- Watchers: 0
- Forks: 0
- Open Issues: 0
-
Metadata Files:
- Readme: README.md
- Contributing: CONTRIBUTING.md
- License: LICENSE
- Code of conduct: CODE_OF_CONDUCT.md
- Security: SECURITY.md
Awesome Lists containing this project
README
# π¦ OpenCrayFish
> **An edge-native, biologically-inspired AI companion that lives on a single Raspberry Pi 5 β fully offline, powered by a 1.5B-parameter local SLM, with a heart that beats, a brain that sleeps, and a soul you can read.**
>
> **OpenCrayFish is a fully pluggable framework**: Skills, Tools, Connectors, and Provider Backends are four symmetric plug-in surfaces β every one auto-discoverable via **Python entry-points OR a `plugins//` drop-in folder**, every one declared by a frozen `*Manifest`, every one validated at boot. A third-party `pip install opencrayfish-skill-translate` (or `-tool-weather`, or `-connector-discord`, or `-backend-vllm-cuda`) β or a single `.py` file dropped into `plugins/skills/` β extends the agent with **zero edits to `main.py` and zero fork of OpenCrayFish itself**.
[](LICENSE)
[](#roadmap)
[](https://www.python.org/downloads/)
[](CONTRIBUTING.md)
[](CODE_OF_CONDUCT.md)
[](https://www.raspberrypi.com/news/introducing-the-raspberry-pi-ai-hat-plus-2-generative-ai-on-raspberry-pi-5/)
---
## Table of Contents
- [What OpenCrayFish Is](#what-opencrayfish-is)
- [60-Second Mental Model (The Crayfish in Its Burrow)](#60-second-mental-model-the-crayfish-in-its-burrow)
- [Why This Project Exists](#why-this-project-exists)
- [Design Pillars](#design-pillars)
- [Framework & Design Principles](#framework--design-principles)
- [The Four Plug-in Surfaces](#the-four-plug-in-surfaces)
- [The Manifest + Registry + Discovery Doctrine](#the-manifest--registry--discovery-doctrine)
- [How the System Auto-Adapts](#how-the-system-auto-adapts)
- [Hybrid Discovery β pip OR drop-in folder](#hybrid-discovery--pip-or-drop-in-folder)
- [Configuration Injection (`cfg.plugins.*` + `bind_context`)](#configuration-injection-cfgplugins--bind_context)
- [Protocol Versioning & Fail-Loud Boot](#protocol-versioning--fail-loud-boot)
- [System Architecture at a Glance](#system-architecture-at-a-glance)
- [A Day in the Life of a Message](#a-day-in-the-life-of-a-message)
- [Hardware & Software Stack](#hardware--software-stack)
- [Repository Layout](#repository-layout)
- [Quick Start](#quick-start)
- [SearXNG Setup (Local Web Search)](#searxng-setup-local-web-search)
- [Deep Dive: How Everything Works](#deep-dive-how-everything-works)
- [0. The Brain Stack β Plain-English Tour](#0-the-brain-stack--plain-english-tour)
- [1. The Soul β `soul.md`](#1-the-soul--soulmd)
- [2. The Heartbeat β Time, Rhythm, and Metabolism](#2-the-heartbeat--time-rhythm-and-metabolism)
- [3. The Vital Signs β `Monitor` & Homeostasis](#3-the-vital-signs--monitor--homeostasis)
- [4. The Provider β SLM Backend & Circuit Breaker](#4-the-provider--slm-backend--circuit-breaker)
- [5. The Memory System β STM / LTM / Sleep Metabolism](#5-the-memory-system--stm--ltm--sleep-metabolism)
- [6. The Thinking Process β Brain & Cognitive Loop](#6-the-thinking-process--brain--cognitive-loop)
- [7. Emotions & Empathy](#7-emotions--empathy)
- [8. The Positive Filter β Hard Anchor on Output](#8-the-positive-filter--hard-anchor-on-output)
- [9. Proactiveness β Idle Time as Growth Time](#9-proactiveness--idle-time-as-growth-time)
- [10. Self-Reflection β The Self-Learning Loop](#10-self-reflection--the-self-learning-loop)
- [11. Recurring Tasks β The Background Worker](#11-recurring-tasks--the-background-worker)
- [12. Skills & Tools β The Two-Tier Capability Stack](#12-skills--tools--the-two-tier-capability-stack)
- [Hello-World Skill β Step by Step](#hello-world-skill--step-by-step)
- [The `SkillManifest` Contract](#the-skillmanifest-contract)
- [Third-Party Skill Packages (`pip install`)](#third-party-skill-packages-pip-install)
- [The `opencrayfish` CLI](#the-opencrayfish-cli)
- [`bootstrap_validate` β Fail Loud at Boot](#bootstrap_validate--fail-loud-at-boot)
- [The `ToolManifest` Contract](#the-toolmanifest-contract)
- [Third-Party Tool Packages (`pip install`)](#third-party-tool-packages-pip-install)
- [Hello-World Tool β Step by Step (`bind_context`)](#hello-world-tool--step-by-step-bind_context)
- [Argspec Runtime Validation](#argspec-runtime-validation)
- [Plugin Config Namespace (`cfg.plugins.*`)](#plugin-config-namespace-cfgplugins)
- [MCP Bridge β Remote Model Context Protocol Tools](#mcp-bridge--remote-model-context-protocol-tools)
- [The `ConnectorManifest` Contract](#the-connectormanifest-contract)
- [Third-Party Connector Packages (`pip install`)](#third-party-connector-packages-pip-install)
- [Provider Backends β `BackendManifest` & Discovery](#provider-backends--backendmanifest--discovery)
- [Protocol Surface Stability](#protocol-surface-stability)
- [13. Connectors β Telegram & Web Chat](#13-connectors--telegram--web-chat)
- [14. Observability β Dashboard & State Files](#14-observability--dashboard--state-files)
- [Cross-Cutting Concerns](#cross-cutting-concerns)
- [Concurrency Model](#concurrency-model)
- [Atomic Writes Everywhere](#atomic-writes-everywhere)
- [JSONL Rotation & Retention](#jsonl-rotation--retention)
- [Failure-Mode Matrix](#failure-mode-matrix)
- [Pi 5 Latency Budget](#pi-5-latency-budget)
- [Configuration Reference](#configuration-reference)
- [Operational Commands](#operational-commands)
- [Development Notes](#development-notes)
- [Roadmap](#roadmap)
- [Short-Term (v3.1 β Foundation Hardening)](#short-term-v31--foundation-hardening)
- [Medium-Term (v3.2βv3.3 β Sensory Expansion)](#medium-term-v32v33--sensory-expansion)
- [Long-Term (v4.0+ β Evolutionary Leap)](#long-term-v40--evolutionary-leap)
- [Community & Contributing](#community--contributing)
- [License & Attribution](#license--attribution)
---
## What OpenCrayFish Is
OpenCrayFish (ε°ιΎθ¦) is **not a chatbot, not a SaaS wrapper, and not a cloud agent**. It is a **persistent digital organism** that runs entirely on commodity edge hardware β a Raspberry Pi 5 with an optional **Raspberry Pi AI HAT+ 2** (Hailo-10H NPU, 40 TOPS, 8 GB dedicated NPU RAM). Every part of the system is modeled after a biological metaphor:
| Biological concept | What it maps to in code |
|---|---|
| π§ **Brain** | A 1.5B-parameter SLM (qwen2.5-instruct on NPU, qwen2 on CPU fallback) |
| π **Heart** | An always-on `pulse_loop` that ticks every 30s |
| π‘οΈ **Vital signs** | CPU / RAM / temperature / SLM availability sampled each pulse |
| 𧬠**Soul** | A protected `soul.md` file holding identity, laws, and learned growth |
| π§ **Working memory** | A 12-turn RAM `deque` fed to the SLM |
| π§ **Hippocampus** | An on-disk JSONL journal that survives crashes |
| π§ **Cortex (LTM)** | `memory/archive.md` β distilled long-term facts |
| π οΈ **Habits / Skills** | A pluggable `SkillRegistry` of capabilities (`identity`, `recall`, `research`, `direct_answer`, `self_reflect`, `proactive_learning`, `recurring_research`) β the Cognitive Loop picks from this menu instead of hardcoding verbs |
| π **Mood** | A 5-channel emotion vector (joy / anger / sorrow / excitement / calm) with exponential decay |
| π **Empathy** | Sentiment + urgency analysis of every Architect message |
| π **REM sleep** | A nightly Sleep Metabolism cycle (02:00β06:00) that distills the day |
| π€ **Curiosity** | Idle-time Proactive Research that closes real STM knowledge gaps |
| πͺ **Reflection** | A self-critique pass on every reply, persisted to `state/reflection-YYYY-MM-DD.jsonl` (date-rotated, bounded retention) |
The agent serves a single human β **the Architect** β over Telegram and a local browser chat (Streamlit). The Architect's name, salutation, and the agent's own designation are all configured in `config.yaml`; the agent addresses the Architect by name in every reply (default: `"Boss "`).
---
## 60-Second Mental Model (The Crayfish in Its Burrow)
If the system architecture diagram below looks intimidating, here's the friendly version. The project simulates a *biological being* β so let's describe it like one:
> **OpenCrayFish is a small freshwater crayfish living in a burrow at the edge of a pond β alert, curious, occasionally hungry, never asleep for long.** It senses its body, samples the water for ripples, fires the right reflex for the moment, and every night it dreams a little so tomorrow's reflexes are a touch wiser.
| Anatomy | OpenCrayFish part | What it actually is |
|---|---|---|
| π§ **The nerve ganglion** | `Brain` + the SLM (`qwen2.5-instruct:1.5b`) | The small-but-quick neural cluster that turns a stimulus into a coordinated response. Compact on purpose β a crayfish doesn't need a mammalian cortex. |
| π **The behavioural repertoire** | `SkillRegistry.plan_menu()` | The short, situational list of behaviours the animal can perform *right now* β filtered by hunger, fatigue, stress, and whether the water (network) is moving. |
| β‘ **Individual reflexes & behaviours** | Skills β `recall`, `research`, `direct_answer`, `identity`, `self_reflect`, `proactive_learning`, `recurring_research` | Capabilities the nervous system can fire. Each one comes with a metabolic-cost label (cheap / expensive). |
| ποΈ **Antennae & sensory appendages** | Tools β `web_search` (SearXNG), `archive_read` (LTM) | The physical primitives a reflex uses to reach into the world. The outside never touches them directly β they're tucked inside the body. |
| 𧬠**The neural firing sequence** | `CognitiveTrace` (THINK β PLAN β ACT β REFINE) | The motor program the ganglion writes before acting: "this stimulus means X, fire reflex A then reflex B, then check the result". |
| π **The autonomic heartbeat** | `Heartbeat.pulse_loop()` | Even with no stimulus, the body keeps beating, sips a breath, checks its own temperature, and notices when the water is getting dangerously warm. |
| π **REM-like consolidation** | `Heartbeat.metabolism()` (02:00 nightly) | After the day's foraging, the crayfish settles into its burrow, rehearses what it learned, and etches the keepers into long-term memory. Wakes a tiny bit different than yesterday. |
| 𧬠**DNA + learned engrams** | `soul.md` (immutable core) + `memory/archive.md` (plasticity) | Two layers of memory: a genome the animal can never rewrite (its constitution, its laws) plus adaptive engrams it grows every night. |
| π‘οΈ **Interoception (homeostasis)** | `Monitor` β CPU / RAM / temp / brain availability as **vital signs** | When the water gets too warm, the crayfish abandons elaborate display behaviours and falls back to faster, cheaper reflexes β conserving energy until conditions improve. |
| π **Chemoreceptors & mechanoreceptors** | Connectors (`telegram`, `web_chat`) | The sensory channels through which ripples from the outside world reach the burrow β and through which the crayfish ripples back. The ganglion doesn't care which channel the ripple came in on. |
**Why this matters for contributors:** want to teach the crayfish a **new instinct**? Write a new Skill. Want to give it a **new sensory appendage**? Write a new Tool. Want to open a **new channel through which the world can ripple in**? Write a new Connector. The nerve ganglion (Brain), the instinct selector (SkillRegistry), and the motor sequencer (CognitiveLoop) all keep working with **zero edits** β your new appendage / instinct / sense just appears in the repertoire the next time the animal looks at itself. See [Β§ 12 Skills & Tools](#12-skills--tools--the-two-tier-capability-stack) for the step-by-step graft.
---
## Why This Project Exists
Most modern AI agents are **cloud-bound, frontier-model-dependent, and request-driven** β they wake up only when called, immediately forget the conversation, and stop existing the moment the API call returns. OpenCrayFish takes the opposite stance:
> **The agent should *exist continuously*, on hardware *the operator owns*, with full *sovereignty over its data, its memory, and its life cycle*.**
Three operational realities drive the design:
1. **Edge-native by mandate.** OpenCrayFish is built specifically for the Raspberry Pi 5 + AI HAT+ 2 (Hailo-10H NPU) class of hardware. Every architectural choice β bounded context windows, deferred journal writes, atomic markdown state files, hysteresis on stress thresholds β is justified by a real constraint of running 24/7 on a single-board computer with an SD card.
2. **Lite by mandate.** The cognitive backbone is a 1.5-billion-parameter SLM (`qwen2.5-instruct:1.5b` on NPU or `qwen2:1.5b` on CPU). Everything you read about in this README β Sleep Metabolism, Cognitive Loop, Proactive Research, Self-Reflection β is engineered to work *because of* that constraint, not in spite of it. The SLM's 4K-token context forces a real memory hierarchy. The SLM's narrow knowledge forces real curiosity. The SLM's fragility forces a real circuit breaker.
3. **Offline by mandate.** The reference deployment runs with **zero outbound network calls** to third parties: inference is local (Hailo-Ollama or stock Ollama), web search is local (self-hosted SearXNG), state is local (YAML / JSONL / Markdown on the SD card). Pull the network cable and the conversation, the memory, the heartbeat, and the proactive reflection cycle all keep running.
These three constraints β **edge / lite / offline** β turn into the project's value proposition:
- β
**Sovereign AI** β your data physically cannot leave the device.
- β
**Resilient AI** β no API key to expire, no vendor to deprecate the model, no outage to brick the agent.
- β
**Embodied AI** β the agent has a body (CPU, RAM, temperature, GPIO, sensors) and that body affects its mind.
- β
**Affordable AI** β total hardware bill of materials under USD $200, with zero recurring cost.
- β
**Hackable AI** β under ~10K lines of well-commented Python, every behavior tunable in `config.yaml`.
---
## Design Pillars
Five non-negotiable principles shape every line of code:
### πͺ Pillar 1 β Identity Sovereignty
The agent's identity, fundamental laws, and behavioral matrix live in `soul.md`'s **IMMUTABLE_CORE** region. The agent itself can never mutate this region β `core/soul_handler.py` enforces this with a regex-validated atomic writer. Only the Architect (a human) can edit it. This guarantees the agent cannot rewrite its own ethical framework even if a prompt-injection tries to convince it to do so.
### π Pillar 2 β Resilient Empathy
The agent has real internal emotions (5-dimensional vector with exponential decay) but **every output is filtered through a `PositiveFilter`** that rejects hate speech, rewrites despair into constructive language, and appends a salutation when rewrites are applied. The internal state is honest; the external behavior is anchored.
### π‘οΈ Pillar 3 β Hardware Awareness
The Pi 5's CPU temperature, RAM utilization, and the SLM endpoint's reachability are first-class **vital signs**. They are not just logged for ops β they directly mutate the agent's mood (overheating β frustration + fatigue), trigger an `EXHAUSTION DIRECTIVE` that forces terse replies, and (when the SLM is offline) cause the agent to surface a *first-person* outage message instead of a stack trace.
### ποΈ Pillar 4 β Architect Sovereignty
The human operator outranks the agent. Sleep windows, identity, salutation, designation, mood tuning, scheduler limits β all are configured by the Architect in `config.yaml` and `soul.md`. The agent reads them, never writes them.
### π± Pillar 5 β Continuous Existence
The agent does not stop between user turns. Driven entirely from `config.yaml`:
- **Tick** every `system.pulse_interval_seconds` (default `30` s) β sample vitals, decay emotions, publish state.
- **Idle past `system.idle_journal_flush_seconds`** (default `30` s) β flush the STM pending buffer to disk.
- **Idle past `system.idle_proactive_minutes`** (config ships at `5` min) β launch a **Proactive Research** cycle.
- **Cross `system.sleep_start`** (default `02:00`) β run **Sleep Metabolism** (distill the day into long-term memory), then wake at `duty_start` (default `06:00`) subtly different.
---
## Framework & Design Principles
OpenCrayFish is built as **a real plug-in framework**. Everything that talks to the outside world β every reflex the SLM can pick, every device the agent can poke, every channel a human can chat through, every model the brain can run on β is a third-party-replaceable plug-in. The core stays small, opinionated, and biologically-grounded; the periphery is yours.
### The Four Plug-in Surfaces
| Surface | What it is | What ships in-tree | Entry-point group |
|---|---|---|---|
| **Skills** ([core/skills/](core/skills/)) | Agent-facing capabilities β verbs the SLM picks at PLAN. `identity`, `recall`, `direct_answer`, `research`, `self_reflect`, `proactive_learning`, `recurring_research`. | 7 first-party skills | `opencrayfish.skills` |
| **Tools** ([tools/](tools/)) | Mechanical I/O primitives β HTTP calls, file reads. Composed by Skills; invisible to the SLM. `web_search` (SearXNG), `archive_read`. | 2 first-party tools | `opencrayfish.tools` |
| **Connectors** ([connectors/](connectors/)) | Inbound/outbound transports β how a human reaches the agent. `TelegramConnector`, `WebChatConnector`. | 2 first-party connectors | `opencrayfish.connectors` |
| **Provider Backends** ([core/provider.py](core/provider.py)) | SLM inference endpoints β what runs the model. `HailoOllamaBackend` (NPU), `OllamaBackend` (CPU fallback). | 2 first-party backends | `opencrayfish.provider_backends` |
**The symmetry doctrine.** Every surface follows the **same** three-piece pattern: a frozen **`*Manifest`** dataclass declares everything the core needs to know about the plug-in; a **`*Registry`** owns lifecycle + lookup + audit + opt-in context injection; a **`*/discovery.py`** module walks the matching entry-point group at boot with fail-isolated import, **and the shared [core/dropin.py](core/dropin.py) loader walks the `plugins//` folder under the same fail-isolated contract**. Learn one surface and you've learned all four.
The point of the symmetry isn't elegance β it's that **a third-party package author writes the same `pyproject.toml` block, scaffolds with the same CLI verb, validates with the same CLI verb, and pip-installs the same way**, whether they're shipping a new chat reflex, a new sensor, a new transport, or a new inference engine.
### The Manifest + Registry + Discovery Doctrine
Every plug-in surface is built from three pieces. They are intentionally identical across Skills / Tools / Connectors / Backends:
```text
ββββββββββββββββββββββββ ββββββββββββββββββββββββ ββββββββββββββββββββββββ
β *Manifest β β *Registry β β */discovery.py β
β (frozen dataclass) β β (lifecycle + audit) β β (entry-points walk β
β β β β β + drop-in folder) β
ββββββββββββββββββββββββ€ ββββββββββββββββββββββββ€ ββββββββββββββββββββββββ€
β name, description β β register(instance) β β scan entry-point β
β compat_version β ββββ€ bootstrap_validate() ββββββ€ group, import, β
β args_schema β β invoke / call / β β fail-isolated, β
β cost_tier, caps... β β start / generate β β register into reg β
β config_key β β aclose_all() β β + walk plugins/ β
β plan_verb (Skills) β β bind_context() opt β β / via β
β β β β β core/dropin.py β
ββββββββββββββββββββββββ ββββββββββββββββββββββββ ββββββββββββββββββββββββ
```
| Piece | Job | Why frozen / fail-isolated |
|---|---|---|
| **`*Manifest`** | Single source of truth for "what this plug-in is, what it needs, how it should be called". Read by the registry, the PLAN-stage prompt builder (Skills), the dashboard, and `bootstrap_validate`. | `@dataclass(frozen=True, slots=True)` so a misbehaving plug-in can't mutate its own metadata at runtime to escape gating. |
| **`*Registry`** | Owns the live set of registered instances. Issues a unique name. Wraps every dispatch with timing + crash isolation + audit JSONL append. Tears down cleanly at shutdown. Optionally delivers a shared context object (see [Β§ bind_context](#configuration-injection-cfgplugins--bind_context)). | A buggy third-party plug-in can never crash the agent β exceptions are caught at the registry boundary and surfaced as `ok=False` results. |
| **`*/discovery.py`** | Walks `importlib.metadata.entry_points(group="opencrayfish.")` at boot, **then defers to [core/dropin.py](core/dropin.py) to walk `plugins//`** for any `.py` files or sub-packages declaring a `PLUGIN` / `PLUGINS` attribute. For each candidate, imports it, calls it if it's a factory, registers the result. **Each entry is wrapped in its own `try/except`** β one broken third-party package or drop-in file is logged and skipped, the agent keeps booting. | The agent must always boot. A typo in someone else's package β or in a half-finished local drop-in β can't take down your Pi. |
### How the System Auto-Adapts
The autodiscovery + manifest contracts mean **adding a plug-in requires zero edits to OpenCrayFish itself**. Concretely:
| If you `pip install` a third-party package that declaresβ¦ | β¦then on the very next boot the agent will: |
|---|---|
| `opencrayfish.skills` β `translate = "...:TranslateSkill"` | Discover it, validate its manifest, register it. **The PLAN-stage SLM prompt automatically grows a `TRANSLATE` line with the manifest's `plan_guidance` snippet**, so the SLM learns *when* to use it without any prompt rewrite. The Skill's metadata appears in `state/skills.json` and the dashboard's Skill panel. Every invocation is appended to `state/skills-YYYY-MM-DD.jsonl`. |
| `opencrayfish.tools` β `weather = "...:WeatherTool"` | Discover it, validate that `cfg.plugins.weather` exists, register it. If the Tool implements `bind_context(ctx)` it receives operator config + handles automatically. The Tool's metadata appears in `state/tools.json`. Any Skill can compose it via `ctx.tools.call("weather", ...)`. |
| `opencrayfish.connectors` β `discord = "...:DiscordConnector"` | Discover it, validate manifest, register it, **`await connector.start()` after the first-party connectors come up**, and `await connector.stop()` on shutdown in isolation. Inbound messages route through the same Brain + Cognitive Loop pipeline as Telegram. |
| `opencrayfish.provider_backends` β `vllm = "...:VllmBackend"` | Discover it, validate manifest, log presence. The operator points `cfg.hardware.primary_backend: "vllm"` (or `fallback_backend:`) and the **Provider singleton routes the matching slot through the discovered backend** instead of the built-in Pi 5 wiring. Unknown name β boot aborts with a clear error. |
What the operator does NOT need to do:
- β Edit `main.py` β no `from opencrayfish_skill_translate import TranslateSkill` line, no `skill_registry.register(...)` line.
- β Edit `core/cognition.py` β the PLAN menu is built fresh per turn from whatever skills are registered.
- β Edit `core/config.py` β third-party config lives in `cfg.plugins..*` and the core never reads inside it.
- β Fork OpenCrayFish β every extension point is `pip install`-shaped.
What the operator DOES do:
- β
`pip install ` inside the OpenCrayFish venv.
- β
Optionally add a `plugins.: { ... }` block to `config.yaml` for that plug-in's configuration.
- β
Restart the agent. Done.
### Hybrid Discovery β pip OR drop-in folder
Every plug-in surface above is **hybrid**: alongside the standard Python entry-points path, the agent also walks a **drop-in folder** at boot. A new Skill / Tool / Connector / Backend can therefore be added in two ways, and both reach the same registry with the same manifest validation:
| Path | When to use it | Mechanism |
|---|---|---|
| **`pip install`** (entry-points) | Shareable packages, versioned releases, dependency-managed plug-ins, anything you want to publish to PyPI or a private index. | `[project.entry-points."opencrayfish.skills"]` (or `.tools` / `.connectors` / `.provider_backends`) in the package's `pyproject.toml`; discovered via `importlib.metadata.entry_points`. |
| **Drop-in folder** | Local experiments, private one-offs, air-gapped deployments where pip isn't convenient, fast iteration without a `pyproject.toml`. | Copy a `.py` file (or a folder with `__init__.py`) into `plugins//`; discovered via [`core/dropin.py`](core/dropin.py). |
**Folder layout** (default: `/plugins/`; override with the `OPENCRAYFISH_PLUGINS_DIR` env var):
```
plugins/
skills/
weather.py # flat: one Skill per file
translate/ # nested: a sub-package
__init__.py
skill.py
tools/
home_assistant.py
connectors/
discord.py
backends/
vllm_cuda.py
```
**Module contract** β each drop-in `.py` file (or sub-package `__init__.py`) must expose ONE of:
- `PLUGIN = MySkillClass` β a class, factory callable, or already-instantiated object. Same three shapes the entry-points discovery accepts.
- `PLUGINS = [PluginA, PluginB, ...]` β an iterable when one file exports multiple plug-ins of the same surface.
A drop-in `weather.py` looks exactly like a third-party Tool package, minus the `pyproject.toml` boilerplate:
```python
# plugins/tools/weather.py
from tools.manifest import ToolManifest
class WeatherTool:
manifest = ToolManifest(
name="weather",
description="Hello-world drop-in weather tool.",
config_key="weather",
)
name = "weather"
def bind_context(self, ctx):
cfg = ctx.plugins_config.get("weather", {})
self._api_key = cfg.get("api_key", "")
async def call(self, **kwargs):
return {"text": f"sunny (key prefix: {self._api_key[:4]})"}
PLUGIN = WeatherTool
```
Add a matching `cfg.plugins.weather: {...}` block to `config.yaml`, restart, and the boot log shows:
```
TOOL Discovered 1 drop-in tool(s) via plugins/tools/: weather
TOOL bound 1 tool(s) via bind_context (config_key=weather)
```
**Boot-time order + collision policy**:
1. First-party Skills / Tools / Connectors / Backends register from `main.py`.
2. Entry-points discovery walks every installed package's `opencrayfish.*` group.
3. **Drop-in folder discovery runs LAST.** A drop-in attempting to shadow a name already registered (first-party OR entry-points) is rejected by the registry's duplicate-name check; the error is logged and the boot continues. This gives the correct blast radius β operators control which side wins simply by renaming the drop-in or `pip uninstall`-ing the package.
**Isolation** β same fail-loud-but-localized contract as entry-points: a broken file in `plugins/skills/` only loses **its** Skill, never aborts the boot. The exception is logged at ERROR with the file path.
**Security note** β drop-in files run with the same privileges as the agent process. Treat the drop-in root the same way you treat your venv: **only put code you wrote or audited in there**. There is no sandbox.
Tests covering the full drop-in contract live in [`tests/test_dropin_discovery.py`](tests/test_dropin_discovery.py) (21 tests).
### Configuration Injection (`cfg.plugins.*` + `bind_context`)
Third-party plug-ins need operator-supplied configuration (API keys, endpoint URLs, units, thresholdsβ¦) **without** patching [core/config.py](core/config.py). The `cfg.plugins.*` namespace is that seam, plus a symmetric **`bind_context`** hook on every registry:
```yaml
# config.yaml β operator-side
plugins:
weather:
api_key: "${WEATHER_API_KEY}" # consumed by WeatherTool
units: "metric"
translate:
backend: "deepl" # consumed by TranslateSkill
glossary_path: "memory/glossary.yaml"
```
The core never reads inside these sub-dicts. They flow into plug-ins via two symmetric paths:
| Surface | How it receives `cfg.plugins.` |
|---|---|
| **Skills** | Every `execute(ctx, **kwargs)` call receives a `SkillContext` carrying `plugins_config: Mapping[str, Mapping[str, Any]]` (wrapped in `MappingProxyType` β read-only). Skill code: `cfg = ctx.plugins_config.get(self.manifest.config_key or self.manifest.name, {})`. |
| **Tools** | Optional `bind_context(ctx: ToolContext)` method. If the Tool implements it, `ToolRegistry` calls it exactly once at boot (and once per future registration) with a `ToolContext` carrying the same `plugins_config` + `soul` / `stm` / `monitor` / `provider` / `archive_path` / `designation` / `architect_name` / `architect_honorific`. Tools that don't implement `bind_context` (e.g. first-party `SearXNG` + `ArchiveRead`) are simply skipped β fully backward-compatible. |
| **Connectors** | Constructed explicitly in `main.py` with operator-supplied arguments (they need brain/heartbeat/intent_router references that entry-points can't supply). Discovered connectors via entry-points use the same constructor convention; the `ConnectorManifest.config_key` is validated at boot. |
| **Provider Backends** | Operator picks discovered backends by name (`cfg.hardware.primary_backend`, `cfg.hardware.fallback_backend`). Backends are entry-point factories β they read their own config from any source they choose (env vars are conventional). |
The key insight: **`plugins_config` is the only edit a third-party plug-in needs to take configuration**. Declare `config_key` in your manifest (optional but recommended β `bootstrap_validate` will then fail loud if the operator forgot the YAML block), then read from your context object at call time.
### Protocol Versioning & Fail-Loud Boot
Each plug-in surface carries an explicit **protocol version** string that lets the core evolve without breaking already-installed third-party packages:
| Surface | Constant | Current version |
|---|---|---|
| Skills | `SUPPORTED_PROTOCOL_VERSIONS` in [core/skills/manifest.py](core/skills/manifest.py) | `skill-protocol/1` |
| Tools | `SUPPORTED_TOOL_PROTOCOL_VERSIONS` in [tools/manifest.py](tools/manifest.py) | `tool-protocol/1` |
| Connectors | `SUPPORTED_CONNECTOR_PROTOCOL_VERSIONS` in [connectors/manifest.py](connectors/manifest.py) | `connector-protocol/1` |
| Backends | `SUPPORTED_BACKEND_PROTOCOL_VERSIONS` in [core/provider_manifest.py](core/provider_manifest.py) | `backend-protocol/1` |
A plug-in's `manifest.compat_version` must be in the supported set or `bootstrap_validate` refuses to start. When the core makes a breaking change, the doctrine is: add `"-protocol/2"` to the supported set, leave `"...1"` in for at least one release with a deprecation log line, document the migration in the README's [Protocol Surface Stability](#protocol-surface-stability) section. Third-party authors upgrade at their own pace.
`bootstrap_validate` is the **fail-loud boot gate** β it runs once per registry after all entry-point discovery is done, and on the first failure raises `RuntimeError` so the agent never half-starts:
- `compat_version` in supported set?
- For Skills: every `requires_tools` name actually registered? Every `plan_verb` unique?
- For Tools / Connectors: `config_key` namespace present in `cfg.plugins`?
- `requires_caps` tokens in `WELL_KNOWN_*_CAPABILITIES`? (Warning, not fatal β caps are advisory metadata.)
The combination of frozen manifests, version gates, and fail-loud bootstrap means a plug-in problem **always surfaces at boot, never three hours into a session**.
---
## System Architecture at a Glance
```text
βββββββββββββββββββββββββββββββββββββββ
β ARCHITECT (you) β
ββββββββββ¬βββββββββββββββββ¬ββββββββββββ
β β
Telegram β β Browser (Streamlit)
βΌ βΌ
βββββββββββββββββββ¬ββββββββββββββββββ
β TelegramConnectorβ WebChatConnectorβ β connectors/
ββββββββββ¬βββββββββ΄βββββββββ¬βββββββββ
β β
ββββββββββ¬βββββββββ
βΌ
ββββββββββββββββββββββββββββ BRAIN ββββββββββββββββββββββββββββ
β Identity short-circuit β STM context β Cognition Loop β
β (THINK β PLAN β ACT β REFINE) β SLM synth β PositiveFilter β
β β
β PLAN menu + ACT dispatch routed through βββ β
ββββββ¬ββββββββ¬ββββββββ¬ββββββββ¬ββββββββ¬ββββββββΌβββββββββ¬βββββββ
β β β β β β β
βΌ βΌ βΌ βΌ βΌ βΌ βΌ
Soul Monitor Emotions Empathy STM SkillRegistry Reflection
.md (vitals) (mood) (sent.) (RAM ββ¬β identity Engine
+disk) ββ recall
β ββ direct_answer
β ββ research βββΊ SearXNG
β ββ self_reflect Tool
β ββ proactive_learning
β ββ recurring_research
β (every invoke β
βΌ state/skills-*.jsonl)
ββββββββββββββββββββ
β HEARTBEAT β
β pulse_loop() β
β metabolism() β
β proactive_thoughtβ
ββββββββββ¬ββββββββββ
β
βΌ
ββββββββββββββββββββ
β SCHEDULER β
β (recurring tasks)β
ββββββββββββββββββββ
Side artifacts (read by ui/dashboard.py):
state/vitals.json β live rolling pulse snapshot
state/vitals_events.jsonl β stress ENTER/EXIT timeline
state/proactive.jsonl β every proactive thought + audit trail
state/reflection-YYYY-MM-DD.jsonl β every self-critique (date-rotated)
state/reflection_dropped-YYYY-MM-DD.jsonl β unparseable critique sidecar
state/deliberation-YYYY-MM-DD.jsonl β every cognitive trace (date-rotated)
state/skills-YYYY-MM-DD.jsonl β every Skill invocation audit (date-rotated)
state/skills.json β live skill inventory snapshot
state/stm_journal.jsonl β durable conversation backstop
state/tasks.yaml β persistent recurring task registry
state/tools.json β live tool inventory snapshot
memory/archive.md β long-term distilled facts
logs/daily/YYYY-MM-DD.log β per-day heartbeat telemetry (path = memory.log_path)
```
---
## A Day in the Life of a Message
Reading 14 subsystem sections back-to-back is a lot. Here's the same story told as a single concrete example: **what happens when the Architect types `"How does the Hailo-10H compare to the Hailo-8 for running 1.5B-parameter LLMs?"` into Telegram at 14:32 on a Tuesday.** Every arrow is a real line of code; every state file write is real.
```text
T+0 ms Telegram β connectors/telegram.py
ββ validate sender == cfg.api_keys.telegram_user_id β
ββ not a slash command, not a task request β Brain.think(user_input)
ββ pre-filter: looks_like_task_request()? β (no "every X minutes")
T+2 ms Brain._cycle() begins
ββ soul.render_identity_block() (cached, <1 ms)
ββ monitor.sample() (cached <1 ms β 100ms only if stale)
ββ emotions.snapshot() β calm dominant, joy=0.2
ββ empathy.analyze(user_input) β sentiment=neutral, urgency=False
ββ identity regex match? β (not asking for name/creator)
T+5 ms LTM short-circuit check
ββ keyword scan over memory/archive.md β top hit scores 1
ββ score < tools.ltm_short_circuit_min_score (2) β not short-circuited
β cognition will run
T+8 ms CognitiveLoop.deliberate() β THINK stage
ββ Build active PLAN menu via SkillRegistry.plan_menu(cap, exclude_network)
β ββ vitals.is_stressed? No β cap stays "expensive"
β ββ provider.is_tripped? No β keep network skills
β ββ menu: [ANSWER, RECALL, SEARCH] sorted freeβexpensive
ββ Call provider.generate(THINK prompt, user msg) ~600 ms (NPU)
β INTENT: Compare H10 vs H8 for running 1.5B LLMs.
β Q1: What are the H10's headline specs?
β Q2: Why was the H8 unsuitable for LLMs?
β Q3: Which Pi-class HAT ships the H10?
T+610 ms PLAN stage
ββ Call provider.generate(PLAN prompt with menu, sub-questions) ~700 ms
β Q1: SEARCH "Hailo-10H specs"
β Q2: RECALL
β Q3: ANSWER
T+1310 ms ACT stage (3 concurrent skill invocations via asyncio.gather)
ββ skill_registry.invoke("research", ctx, query="Hailo-10H specs")
β ββ ResearchSkill.execute()
β ββ tool_registry.call("web_search", query=..., limit=5)
β ββ httpx GET http://localhost:8080/search ~300 ms
β β SkillResult(ok=True, evidence=[{title, url, snippet}, β¦])
β β appended to state/skills-2026-05-17.jsonl
β {ts, skill:"research", ok:true, latency_ms:302, tools_used:["web_search"], β¦}
β
ββ skill_registry.invoke("recall", ctx, query="Why was the H8 unsuitable for LLMs?")
β ββ RecallSkill.execute()
β ββ tool_registry.call("archive_read", query=..., limit=5) ~12 ms
β β appended to state/skills-2026-05-17.jsonl
β
ββ ANSWER step:
ββ if cfg.cognition.dispatch_answer_via_skill β invoke "direct_answer" (one extra SLM call)
else β marker "(SLM training data only β no retrieval performed)"
T+1620 ms REFINE stage (if cfg.cognition.refine_enabled)
ββ Call provider.generate(REFINE prompt, intent + evidence) ~250 ms
β "OK" (no gap) β one gap fires one extra SEARCH
T+1870 ms CognitiveLoop._persist() β append full trace to
state/deliberation-2026-05-17.jsonl (rotated by local date, 14-day retention)
T+1870 ms Brain._render_knowledge() β KNOWLEDGE block
Brain assembles final prompt: soul + vitals + mood + empathy + KNOWLEDGE
+ STM history (last 12 turns) + current user message
T+1875 ms provider.generate(synthesize prompt) ~1200 ms (NPU)
β raw SLM output: "The Hailo-10H is the second-generation Pi 5 AI accelerator β¦"
T+3075 ms _looks_like_prompt_leak(raw)? β
PositiveFilter.apply(raw)
ββ hard reject regex? β
ββ soft rewrites? β (no "I can't" / "impossible" / etc.)
ββ β filtered.text unchanged, filtered.altered=False
T+3078 ms stm.append("user", user_input)
stm.append("agent", filtered.text)
ββ deque (maxlen=12) appended β oldest turn silently dropped if full
ββ pending buffer +1 β will flush after 30s idle
T+3080 ms ReflectionEngine.fire_and_forget(input, response, kind="user", web_searched=True)
ββ background asyncio.Task (does NOT add to user-visible latency)
ββ provider.generate(critique prompt) ~700 ms
β ReflectionEntry(quality="medium", interest="NPU benchmarks", lesson="...")
β appended to state/reflection-2026-05-17.jsonl
T+3080 ms ThoughtTrace returned to TelegramConnector
ββ bot sends "Boss Eason β The Hailo-10H is β¦ " ~150 ms (Telegram API)
T+3230 ms Architect sees the reply on Telegram.
ββββ meanwhile, in the background ββββ
Every 30 s Heartbeat.pulse_loop() ticks:
ββ Sample vitals β atomic-write state/vitals.json
ββ Decay emotions toward baseline
ββ Detect stress edges β state/vitals_events.jsonl
ββ If pending writes β₯1 AND idle β₯30 s β stm.flush_journal()
After 5 min idle Heartbeat fires _proactive_research():
ββ Picks an STM gap (e.g. "Hailo-10H benchmarks"),
verifies it's not in LTM and the SLM doesn't already know it,
runs one web search + a 2-sentence reflection,
writes to state/proactive.jsonl,
reflects on its own proactive thought.
At 02:00 Heartbeat.metabolism():
ββ Distill the day's logs + STM journal into archive.md
ββ Promote top facts to soul.md [CORE_MEMORIES]
ββ _consolidate_reflections(): mine reflection.jsonl + skills.jsonl
β ββ recurring interests β LEARNED_PREFERENCES
β ββ recurring lessons β EMOTIONAL_EVOLUTION
β ββ chronically failing skills (β₯3 invokes, >50% fail) β EMOTIONAL_EVOLUTION
ββ STM.purge() β clean slate for tomorrow.
At 06:00 Pulse loop resumes (no explicit "wake" event β it just notices
the clock has crossed `duty_start`).
```
**What this walkthrough demonstrates:**
- **Two SLM calls happen before the user sees a word** (THINK + PLAN), and one more (synth) after evidence is gathered. The structure exists because a 1.5B-parameter model is unreliable when asked to do everything in one shot.
- **The PLAN menu is built fresh per turn.** A stressed Pi or an offline SearXNG silently drops the expensive options β the SLM never gets to pick a verb the agent can't execute.
- **Every Skill invocation is audited** to `state/skills-YYYY-MM-DD.jsonl`. Tomorrow night's Sleep Metabolism will read that file and decide whether any Skill is chronically broken.
- **The reflection happens in the background.** The Architect's reply latency budget is ~3 s; reflection costs another ~700 ms but the user never waits for it.
- **The dashboard sees all of this without IPC** β Streamlit reads the atomically-written state files in a separate process.
---
## Hardware & Software Stack
### Reference hardware (~ USD $220)
| Component | Notes |
|---|---|
| Raspberry Pi 5 (8 GB) | Required β 4 GB works but tight under SLM load |
| **[Raspberry Pi AI HAT+ 2](https://www.raspberrypi.com/news/introducing-the-raspberry-pi-ai-hat-plus-2-generative-ai-on-raspberry-pi-5/)** | Optional β Hailo-10H NPU, **40 TOPS (INT4)**, **8 GB dedicated on-board RAM** (purpose-built for generative AI / LLMs on Pi 5). Drives the SLM via `hailo-ollama` on port 8000. PCIe-attached, Pi 5 only. |
| Active cooling (fan + heatsink) | Strongly recommended β vitals will fire `EXHAUSTION DIRECTIVE` without it |
| 64 GB+ A2-class SD card or NVMe HAT | NVMe extends SD-card lifetime considerably |
| 27 W official USB-C PSU | Power throttling triggers thermal stress |
> **Why the AI HAT+ 2 (and not the original AI HAT+)?** The first-gen AI HAT+ shipped the Hailo-8 / Hailo-8L, which was optimised for vision workloads (object detection, pose, segmentation). The **AI HAT+ 2 ships the Hailo-10H** with **8 GB of on-board accelerator RAM**, which is what unlocks 1β7B-parameter LLMs running locally on a Pi 5 β including the `qwen2.5-instruct:1.5b` model OpenCrayFish uses. The first-gen AI HAT+ cannot run this workload.
OpenCrayFish also runs fine on **any Linux/macOS dev box** for development β when no NPU is detected, the Provider transparently falls back to stock Ollama on CPU.
### Software dependencies (`requirements.txt`)
- Python **3.13+** (the runtime target β `pyproject.toml` requires `>=3.13` because `soul_handler.py` relies on `os.replace` fsync guarantees tightened in 3.13, and the AI HAT+ 2 Pi 5 image ships 3.13)
- `PyYAML`, `psutil`, `httpx`
- `python-telegram-bot` (Telegram connector)
- `streamlit` (dashboard + browser chat UI)
- `aiohttp` (web-chat HTTP bridge)
- `mcp` (Model Context Protocol client SDK β powers `tools/mcp_bridge.py`; lazy-imported, so deployments that don't use MCP can skip it)
External services on the device:
- **Ollama** (CPU fallback, port 11434) β `ollama pull qwen2:1.5b`
- **hailo-ollama** (NPU primary, port 8000) β Hailo's REST front-end (from the [Hailo Developer Zone](https://hailo.ai/developer-zone/software-downloads/?product=ai_accelerators&device=hailo_10h)) serving `qwen2.5-instruct:1.5b` from the Hailo-10H NPU on the AI HAT+ 2
- **SearXNG** (port 8080) β self-hosted metasearch instance for the agent's web tool
---
## Repository Layout
```text
OpenCrayFish/
βββ README.md β this file
βββ soul.md β the agent's constitution + dynamic growth
βββ config.yaml β every tunable knob in the system (gitignored β holds secrets)
βββ config_sample.yaml β committed template; `cp` it to config.yaml and fill in your keys
βββ pyproject.toml β canonical dependency list, entry-points, dev extras
βββ requirements.txt β convenience copy of runtime deps for Pi 5 operators
βββ conftest.py β shared pytest fixtures (async event loop, tmp paths)
βββ main.py β wires every subsystem and starts the loops
β
βββ core/ β all the cognitive/biological subsystems
β βββ config.py β typed YAML loader (Config dataclass)
β βββ soul_handler.py β write-protected accessor for soul.md
β βββ monitor.py β Vital signs sampling (CPU/RAM/temp/brain)
β βββ emotions.py β 5-channel mood vector + decay + tuning
β βββ empathy.py β user sentiment + urgency analyzer
β βββ positive_filter.py β Pillar 2 hard output filter
β βββ provider.py β Ollama/Hailo backends + circuit breaker
β βββ provider_manifest.py β BackendManifest + SUPPORTED_BACKEND_PROTOCOL_VERSIONS
β βββ stm.py β short-term memory (RAM + journal)
β βββ cognition.py β THINK β PLAN β ACT β REFINE loop (dispatches via SkillRegistry)
β βββ brain/ β prompt-assembly + orchestration package
β β βββ orchestrator.py β Brain class β top-level _cycle() pipeline
β β βββ prompt_assembly.py β soul + vitals + mood + KNOWLEDGE + STM prompt builder
β β βββ identity_responder.py β deterministic identity-class reply templater
β β βββ task_parsing.py β LLM-backed task-intent / task-action parsers
β βββ intent_router.py β shared NL pre-filter chain for both connectors
β βββ reflection.py β self-critique engine (reads skills.jsonl for failure flags)
β βββ jsonl_writer.py β date-rotating, retention-bounded JSONL appender
β βββ dropin.py β shared drop-in folder loader (plugins// β PLUGIN / PLUGINS)
β βββ heartbeat.py β pulse_loop + metabolism + proactive_thought
β βββ scheduler.py β recurring research-task scheduler
β βββ cli.py β `opencrayfish` CLI (skill/tool/connector/backend scaffolding + validation)
β βββ skills/ β capability layer above Tools (pluggable Skills)
β βββ base.py β Skill protocol + SkillContext + SkillResult
β βββ manifest.py β SkillManifest + SUPPORTED_PROTOCOL_VERSIONS
β βββ registry.py β SkillRegistry + dynamic PLAN menu + audit feed
β βββ discovery.py β entry-point + drop-in Skill discovery
β βββ argspec.py β runtime arg-schema validation + type coercion
β βββ identity.py β soul-templated identity replies (free, no-net)
β βββ recall.py β LTM keyword retrieval (cheap, no-net)
β βββ direct_answer.py β SLM-only answer (cheap, no-net)
β βββ research.py β SearXNG-backed web research (expensive, net)
β βββ self_reflect.py β post-turn critique (cheap, no-net, background)
β βββ proactive_learning.py β idle-time curiosity (expensive, net, background)
β βββ recurring_research.py β scheduled topic refresh (expensive, net, background)
β
βββ tools/ β low-level I/O primitives (called BY Skills)
β βββ base.py β Tool plugin contract
β βββ manifest.py β ToolManifest + SUPPORTED_TOOL_PROTOCOL_VERSIONS
β βββ registry.py β named tool registry + bind_context + audit
β βββ discovery.py β entry-point + drop-in Tool discovery
β βββ searxng.py β self-hosted web search (SearXNG client)
β βββ archive_read.py β long-term memory keyword reader
β βββ mcp_bridge.py β MCP client bridge (remote MCP servers β Tools)
β
βββ connectors/ β external I/O channels
β βββ manifest.py β ConnectorManifest + SUPPORTED_CONNECTOR_PROTOCOL_VERSIONS
β βββ registry.py β ConnectorRegistry + lifecycle + bootstrap_validate
β βββ discovery.py β entry-point + drop-in Connector discovery
β βββ telegram.py β Telegram Bot API connector
β βββ web_chat.py β in-process aiohttp HTTP bridge for Streamlit
β
βββ ui/ β Streamlit apps
β βββ dashboard.py β live vital signs + proactive feed + tools
β βββ web_chat.py β browser-based chat UI for the live agent
β βββ panels/ β dashboard sub-panels (header, vitals, mood, skills, tools,
β βββ header.py reflection, deliberation, tasks, proactive, footer)
β βββ vitals.py
β βββ mood.py
β βββ skills.py
β βββ tools.py
β βββ reflection.py
β βββ deliberation.py
β βββ tasks.py
β βββ proactive.py
β βββ footer.py
β
βββ examples/ β reference third-party plug-in packages
β βββ opencrayfish-skill-echo/ β canonical hello-world Skill (pip-installable, with tests)
β
βββ scripts/ β standalone smoke-test + diagnostic runners
β βββ smoke_cognition_dispatch.py
β βββ smoke_dashboard_rotation.py
β βββ smoke_foreground_priority.py
β βββ smoke_mcp_bridge.py
β βββ smoke_rotation_reflection_identity.py
β βββ smoke_skill_menu.py
β βββ smoke_soul_atomic.py
β
βββ tests/ β pytest suite (β240 tests incl. parametrize expansions)
β βββ test_argspec.py
β βββ test_cli.py
β βββ test_connector_registry.py
β βββ test_dropin_discovery.py
β βββ test_example_echo_integration.py
β βββ test_intent_router.py
β βββ test_jsonl_writer_schema.py
β βββ test_mcp_bridge.py
β βββ test_positive_filter.py
β βββ test_prompt_assembly.py
β βββ test_provider_backend_routing.py
β βββ test_provider_manifest.py
β βββ test_skill_context.py
β βββ test_skill_discovery.py
β βββ test_skill_protocol_surface_v1.py
β βββ test_stm_rotation.py
β βββ test_task_parsing.py
β βββ test_tool_context.py
β
βββ plugins/ β OPTIONAL drop-in folder for local/private plug-ins (gitignored by convention)
β βββ skills/ β each `*.py` (or sub-package) declares `PLUGIN = MySkill` or `PLUGINS = [...]`
β βββ tools/ β same contract; loaded after entry-points so pip-installed wins on duplicate names
β βββ connectors/ β (override the root location via the `OPENCRAYFISH_PLUGINS_DIR` env var)
β βββ backends/ β see [Β§ Hybrid Discovery](#hybrid-discovery--pip-or-drop-in-folder) for the full contract
β
βββ memory/ β long-term distilled memory (gitignored)
β βββ archive.md β long-term distilled facts (LTM)
β
βββ logs/daily/ β per-day heartbeat telemetry (gitignored)
β βββ YYYY-MM-DD.log β PULSE / PROACTIVE / metabolism (path = memory.log_path)
β
βββ state/ β live runtime state (gitignored, read by dashboard)
βββ vitals.json
βββ vitals_events.jsonl
βββ proactive.jsonl
βββ reflection-YYYY-MM-DD.jsonl β date-rotated, 60-day default retention
βββ reflection_dropped-YYYY-MM-DD.jsonl β date-rotated, sidecar audit
βββ deliberation-YYYY-MM-DD.jsonl β date-rotated, 14-day default retention
βββ skills-YYYY-MM-DD.jsonl β date-rotated, 30-day default retention
βββ skills.json β live skill inventory snapshot
βββ stm_journal.jsonl
βββ tasks.yaml
βββ tools.json
βββ logs/agent.log β rotating console log mirror
```
---
## Quick Start
```bash
# 1. Clone & venv
git clone https://github.com/easonlai/opencrayfish
cd opencrayfish
python3.13 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
# 2. Bring up the local stack (separate terminals or systemd units)
ollama serve # CPU fallback
ollama pull qwen2:1.5b
# Self-hosted web search β see the full setup section below; the one-line
# command here will work for the first boot but will return HTTP 403 on every
# search until you enable the JSON API. See Β§ SearXNG Setup for the fix.
docker run -d --name searxng -p 8080:8080 searxng/searxng
# (Pi 5 only) hailo-ollama serve # NPU primary
# 3. Configure
cp config_sample.yaml config.yaml # config.yaml is gitignored β your secrets stay local
$EDITOR config.yaml # set telegram_token, telegram_user_id,
# architect_name, individual_designation
$EDITOR soul.md # optional: customize persona
# 4. Run the agent
python main.py
# 5. Open the dashboard (separate process, reads state/ files)
streamlit run ui/dashboard.py --server.port 8501
# 6. Open the browser chat
streamlit run ui/web_chat.py --server.port 8502
# 7. (Optional) Run the test suite
pip install -e ".[dev]" # adds pytest + pytest-asyncio + ruff
python -m pytest -q # ~240 tests, runs in <2s
```
Now talk to it on Telegram or in the browser. The agent will reply, remember, decay its mood, get curious during idle time, and once the clock crosses 02:00, it will sleep and consolidate the day.
---
## SearXNG Setup (Local Web Search)
OpenCrayFish's `research` Skill, the autonomous proactive cycle, and the recurring-task scheduler **all** depend on a working [SearXNG](https://docs.searxng.org/) instance reachable at `tools.searxng_url` (default `http://localhost:8080`). The agent calls SearXNG over its **JSON API** β and SearXNG ships that API **disabled by default**, which is the single most common "the agent doesn't search anymore" failure mode. This section walks through a known-good Docker deployment.
> **Why self-hosted?** Pillar 1 (Sovereign AI) mandates that the agent's web requests never leak to a third-party search provider. A self-hosted SearXNG aggregates dozens of upstream engines (Google, Bing, DuckDuckGo, Brave, Qwant, Wikipedia, β¦) without ever exposing the Architect's queries or IP. From OpenCrayFish's perspective, the wire format is plain JSON over HTTP β no API key, no rate-limit account, no vendor lock-in.
### Minimum viable deployment
```bash
# Pull and start the container (port 8080 on the host)
docker run -d \
--name searxng \
--restart unless-stopped \
-p 8080:8080 \
searxng/searxng:latest
```
The first start auto-generates an internal `/etc/searxng/settings.yml` inside a Docker-managed named volume. The container is reachable at `http://localhost:8080` but **the JSON API is OFF** β every call from OpenCrayFish will fail with `HTTP 403 Forbidden` until you enable it (see next subsection).
### Enable the JSON API (REQUIRED)
There are two equally-valid ways to do this. **Method A** is the fastest fix for an existing container; **Method B** is the cleaner long-term setup if you want your config in version control.
#### Method A β patch the in-container settings.yml (fastest)
Use this if you already have the container running with the default named volume:
```bash
# 1) Add "- json" to the formats list (idempotent β re-running is safe)
docker exec searxng sh -c "grep -q '^ - json' /etc/searxng/settings.yml || sed -i '/^ formats:/,/^[^ ]/ { /^ - html$/a\\
- json
}' /etc/searxng/settings.yml"
# 2) Restart so the change takes effect
docker restart searxng
# 3) Verify (should print HTTP 200 and a JSON payload)
curl -sS -o /dev/null -w 'HTTP %{http_code}\n' \
'http://localhost:8080/search?q=test&format=json'
```
The patch survives container restarts because it's persisted inside the named volume. It would only be lost if you `docker volume rm` the SearXNG volume.
#### Method B β bind-mount your own settings.yml (cleanest)
If you'd rather keep SearXNG's config in `~/searxng/` (or `/etc/searxng/` on a server, or anywhere git-tracked), use a bind mount instead of the default named volume. **Remove the existing container first** (the named volume can stay):
```bash
docker rm -f searxng 2>/dev/null
# Create a minimal settings.yml in your home directory
mkdir -p ~/searxng
cat > ~/searxng/settings.yml <<'YAML'
use_default_settings: true
server:
# Replace with `openssl rand -hex 32` for production; any 32+ char string works.
secret_key: "change-me-please-32-plus-character-string"
limiter: false # set true on public instances; false is fine on loopback
image_proxy: true
search:
formats:
- html
- json # β required by OpenCrayFish
safe_search: 1 # 0=none, 1=moderate, 2=strict (OpenCrayFish sends 1)
YAML
# Start with the bind mount
docker run -d \
--name searxng \
--restart unless-stopped \
-p 8080:8080 \
-v ~/searxng/settings.yml:/etc/searxng/settings.yml:ro \
searxng/searxng:latest
```
Future config changes are just `$EDITOR ~/searxng/settings.yml && docker restart searxng`.
### End-to-end smoke test
After either method, the JSON endpoint should respond exactly like OpenCrayFish expects it to:
```bash
curl -sS 'http://localhost:8080/search?q=raspberry+pi+5&format=json' \
| python3 -c 'import sys,json; d=json.load(sys.stdin); print(f"results={len(d[\"results\"])}, first={d[\"results\"][0][\"url\"]}" if d.get("results") else "EMPTY")'
```
Expected output (URLs will vary):
```text
results=10, first=https://www.raspberrypi.com/products/raspberry-pi-5/
```
When this works, your agent's next `/research` (or any user query that triages to `SEARCH`) will succeed end-to-end. Cross-check `state/logs/agent.log` β you should see `TOOL call name=web_search status=ok latency_ms=...` instead of `status=fail ... 403 Forbidden`.
### Tuning that OpenCrayFish actually cares about
The defaults in `use_default_settings: true` are fine for local dev. Two SearXNG knobs are worth knowing about for a 24/7 edge deployment:
| `settings.yml` key | Recommended | Why |
|---|---|---|
| `search.formats` | `[html, json]` | **Required** β OpenCrayFish only speaks JSON. |
| `search.safe_search` | `1` (moderate) | Matches what `tools/searxng.py` always sends (`safesearch=1`). Setting `2` may filter results too aggressively; setting `0` may surface NSFW snippets to `proactive_learning`. |
| `server.limiter` | `false` on a loopback instance, `true` if you ever expose port 8080 to the LAN/WAN | The limiter rejects requests that look bot-like β including ones from your own agent. Leave it off unless port 8080 is publicly reachable. |
| `server.image_proxy` | `true` | Hides upstream image fetches behind SearXNG so favicons / thumbnails don't leak the Architect's IP. |
| `engines` (per-entry `disabled: true`) | disable anything noisy | If a particular search engine is rate-limiting your IP and polluting results with `unresponsive_engines` warnings, disable it. The aggregator works fine with the remaining engines. |
### Wiring it into `config.yaml`
The only OpenCrayFish-side setting is the URL:
```yaml
tools:
searxng_url: "http://localhost:8080" # http://:8080 if SearXNG is on another box
```
No auth, no API key. OpenCrayFish always sends `q=&format=json&safesearch=1` (`tools/searxng.py`'s `search(...)` and `Tool.call(...)` surfaces) β there's no other tuning to do.
### Troubleshooting
| Symptom in `state/logs/agent.log` | Likely cause | Fix |
|---|---|---|
| `TOOL call name=web_search status=fail ... 403 Forbidden` | JSON API not enabled | Apply **Method A** above. |
| `TOOL call name=web_search status=fail ... ConnectError` | Container not running, or port 8080 in use by something else | `docker ps`, `docker logs searxng`, `lsof -i :8080` |
| `TOOL call name=web_search status=ok ... hits=0` for every query | Upstream engines rate-limiting your IP; or `safe_search: 2` filtering everything | Inspect `docker logs searxng` for `unresponsive_engines`. Disable the noisy engines in `settings.yml` or lower `safe_search`. |
| `TOOL call name=web_search status=fail ... 429 Too Many Requests` | `server.limiter: true` is blocking the agent's own traffic | Set `limiter: false` in `settings.yml` and `docker restart searxng`. |
> **The agent degrades gracefully.** Even with SearXNG totally down, the `recall` and `direct_answer` Skills still work β `Cognition`'s ACT stage will silently drop the `SEARCH` evidence and synthesize from LTM + STM + SLM knowledge. The dashboard's **β οΈ Errors & warnings** panel will surface the SearXNG failures so you know to fix them, but the agent stays conversational. See [Β§ Failure-Mode Matrix](#failure-mode-matrix) for the full degradation contract.
---
## Deep Dive: How Everything Works
This is the comprehensive section. It documents every major subsystem in the order they are wired together in `main.py`, with file links into the codebase.
> **First time here?** Read [Β§ 0 The Brain Stack β Plain-English Tour](#0-the-brain-stack--plain-english-tour) below for a 5-minute orientation to how Brain, Skills, Tools, and Memory hand work to each other. Then come back to Β§ 1 β Β§ 14 for the full depth.
---
### 0. The Brain Stack β Plain-English Tour
Before the 14 subsystem deep-dives, here is the single mental model that ties them all together. **Four concepts, four jobs, one direction of flow.**
#### The four big ideas (one sentence each)
| Layer | What it is, in one sentence | Where it lives |
|---|---|---|
| **Brain** | The orchestrator that runs every reply through a fixed pipeline (gather context β think β plan β act β synthesize β filter β remember). It owns no facts, no I/O, no policy β it just sequences the other three. | [core/brain/orchestrator.py](core/brain/orchestrator.py) |
| **Skills** | The agent's *menu of decisions* β named verbs like `RECALL`, `SEARCH`, `ANSWER` that the SLM picks from during PLAN. Each Skill knows when it's worth picking, how expensive it is, and which Tools to compose. | [core/skills/](core/skills/) |
| **Tools** | The agent's *hands* β mechanical I/O primitives (web fetch, file read, future GPIO) with no policy, no fallback, no SLM. Skills compose Tools; the SLM never names a Tool directly. | [tools/](tools/) |
| **Memory** | The agent's *substrate of self* β a four-tier hierarchy from working RAM (the last 12 turns) through a disk journal up to nightly-distilled long-term archive and finally the soul itself. Every layer above (Brain, Skills, Tools) reads from or writes to this. | [core/stm.py](core/stm.py), [memory/archive.md](memory/archive.md), [soul.md](soul.md) |
#### How they talk to each other
```text
one user message arrives
β
βΌ
ββββββββββββββββββββββββββββββββββ
β BRAIN (orchestrator) β reads: soul.md, STM, vitals, mood
β Brain._cycle() in core/brain/ β writes: STM (turns), trace, reflection
β ββββββββββββββββββββββββ β
β β COGNITIVE LOOP β β decides WHICH Skill to call,
β β THINKβPLANβACTβREFINE β β and assembles the final prompt
β ββββ¬ββββββββββββββββββββ β
βββββββββΌββββββββββββββββββββββββββββ
β invoke("verb", ctx, **kwargs)
βΌ
ββββββββββββββββββββββββββββββββββ
β SKILLS (decisions) β 7 today: identity, recall,
β core/skills/* β each Skill knows: β direct_answer, research,
β β’ plan_verb (how SLM names it) β self_reflect, proactive_learning,
β β’ cost_tier (free/cheap/expensive) β recurring_research
β β’ requires_network (PLAN filter) β
β β’ trigger_hints (WHEN to pick) β audit: state/skills-*.jsonl
βββββββββ¬ββββββββββββββββββββββββββββ
β await ctx.tools.call("web_search", q="...")
βΌ
ββββββββββββββββββββββββββββββββββ
β TOOLS (hands) β 2 today: web_search (SearXNG),
β tools/* β stateless I/O, no policy. β archive_read (LTM)
βββββββββ¬ββββββββββββββββββββββββββββ
β read/write
βΌ
ββββββββββββββββββββββββββββββββββ
β MEMORY (substrate) β T1: deque (last 12 turns, RAM)
β Four tiers, oldest at the bottom: β T2: pending (RAM, flushed @30s idle)
β T1 deque (working set) β T3: journal (state/stm_journal.jsonl)
β T2 pending buffer β T4: archive.md (nightly distill)
β T3 stm_journal.jsonl β + soul.md (promoted facts)
β T4 archive.md + soul.md β
ββββββββββββββββββββββββββββββββββ
```
#### The contracts between layers (read these in order)
1. **Brain doesn't know what Skills exist.** It asks `SkillRegistry.plan_menu(...)` at PLAN time and renders whatever it gets back into the SLM's prompt. Add a new Skill, restart, and the Brain immediately considers it β no Brain code change.
2. **Skills don't know what Tools exist.** They go through `ctx.tools.call("web_search", β¦)` by name. Replace SearXNG with a future Tool that satisfies the same Protocol, and every Skill that uses it keeps working with zero changes.
3. **Tools don't know about the SLM, the user, or policy.** They just do their one I/O job (HTTP GET, file read) and return a typed result. This is what makes them trivially unit-testable.
4. **Memory is read-many, write-few.** Brain and Skills read freely (cheap). Writes happen on tightly defined triggers: each user/agent turn (T1+T2), 30 s of idle (T3), and nightly Sleep Metabolism (T4). The hot path does zero disk I/O.
#### A worked example, layer by layer
The Architect types: **"Compare the Hailo-10H to the Hailo-8 for LLMs."**
| Step | Layer | What happens |
|---|---|---|
| 1 | Brain | Gathers soul + vitals + mood; the identity regex doesn't match, so cognition will run. |
| 2 | Brain | LTM short-circuit scan over `archive.md` β only 1 keyword hit, below the 2-hit floor, so cognition continues. |
| 3 | Brain β Cognitive Loop | Asks `SkillRegistry.plan_menu(cost_cap, exclude_network)` for the live menu β gets `[ANSWER, RECALL, SEARCH]` sorted freeβexpensive. |
| 4 | Cognitive Loop | THINK call β splits the question into Q1 (specs), Q2 (why H8 unsuitable), Q3 (which HAT). |
| 5 | Cognitive Loop | PLAN call β SLM picks `SEARCH "Hailo-10H specs"` for Q1, `RECALL` for Q2, `ANSWER` for Q3. |
| 6 | Skills | ACT dispatches concurrently: `research.execute()` (Q1), `recall.execute()` (Q2), `direct_answer.execute()` (Q3). Each Skill invocation is timed and appended to `state/skills-*.jsonl`. |
| 7 | Tools | `research` calls `ctx.tools.call("web_search", β¦)` β the SearXNG Tool hits `http://localhost:8080/search?format=json`. `recall` calls `ctx.tools.call("archive_read", β¦)` β reads `memory/archive.md` line by line. |
| 8 | Memory β Brain | All evidence is folded into the synth prompt alongside the last 12 STM turns. Brain calls `provider.generate()` to compose the final answer. |
| 9 | Brain | Runs the Positive Filter, appends both turns to STM (T1 + T2), fires reflection in the background. |
| 10 | Memory | The pending buffer flushes to `state/stm_journal.jsonl` (T3) after 30 s of silence. Tonight at 02:00, Sleep Metabolism will distill it into `archive.md` (T4). |
That's the whole agent in one trace. Every other deep-dive section below zooms into one layer of this picture.
#### How to extend each layer (the one-line guide)
- **Add a new Skill** β three options, all zero-fork: (a) drop a file in [core/skills/](core/skills/) satisfying the Skill Protocol and register it in [main.py](main.py) ([Β§ 12 Hello-World Skill](#hello-world-skill--step-by-step)); (b) ship it as a standalone `pip`-installable package with an `opencrayfish.skills` entry-point ([Β§ 12 Third-Party Skill Packages](#third-party-skill-packages-pip-install)); or (c) drop a `.py` file into `plugins/skills/` with `PLUGIN = MySkill` at the bottom ([Β§ Hybrid Discovery](#hybrid-discovery--pip-or-drop-in-folder)).
- **Add a new Tool** β same three options as Skills: drop into [tools/](tools/) and register in `main.py`, ship as a pip package via `opencrayfish.tools` entry-point, or drop into `plugins/tools/`. See [Β§ Adding a new tool](#adding-a-new-tool) for the protocol.
- **Add a new memory shelf** β don't, usually. Promote facts to `soul.md [CORE_MEMORIES]` via Sleep Metabolism instead.
- **Add a new connector** β wrap `Brain.think(β¦)` and stream the `ThoughtTrace` back. Three options: in-tree under [connectors/](connectors/), pip package via `opencrayfish.connectors` entry-point, or drop-in under `plugins/connectors/`. See [Β§ Adding a new connector](#adding-a-new-connector).
#### What this design buys you
- **The SLM is small (1.5B) and unreliable.** Brain compensates by splitting the work into four short prompts (each β€120 tokens) instead of one giant chain-of-thought β each prompt has exactly one job and is parsed with regex, not JSON.
- **The PLAN menu is the only "intelligence routing" surface.** Adding a Skill is the only way to expand what the agent can choose to do. Everything else β Tools, Memory, Provider β is plumbing.
- **Layer isolation is enforced by contract, not by language tricks.** Skill execution is wrapped in a try/except in `SkillRegistry.invoke()` so a plugin crash can never escape into the Brain. A failed Tool call returns a typed error β it never raises into the calling Skill. A SLM timeout returns `backend="offline"` β it never raises into the connector. **Every interface is a graceful degradation point.** See [Β§ Failure-Mode Matrix](#failure-mode-matrix) for the full contract.
---
### 1. The Soul β `soul.md`
**Module:** [core/soul_handler.py](core/soul_handler.py) Β· **Data file:** [soul.md](soul.md)
The Soul is the agent's constitution. It is split into two regions by HTML comment markers:
```text
# [IDENTITY] β codename, creator, status (Designation injected at runtime)
# [FUNDAMENTAL_LAWS] β 1. Prime Directive 2. 365/20 3. Positive Anchor 4. Sovereignty
# [BEHAVIORAL_MATRIX] β tone, ethics, persona
# [CORE_MEMORIES] β elevated facts the Sleep Metabolism considers identity-defining
# [LEARNED_PREFERENCES] β topics the Architect cares about, mined from reflection trends
# [EMOTIONAL_EVOLUTION] β long-term mood/relationship signals
```
#### Key behaviors
- **Designation injection.** The agent's name (e.g. `"Dave Minion"`) is configured ONCE in `config.yaml` under `system.individual_designation`. `SoulHandler` injects it into the IDENTITY block at every read, so soul.md never carries a hardcoded designation line. This lets you redeploy the same soul.md as a different persona just by changing the config.
- **Hard write-protection.** The IMMUTABLE_CORE region is enforced with `_IMMUTABLE_RE` and `SoulProtectionError`. Any append that would mutate bytes inside the immutable markers (or relocate the marker itself) is rejected. Even SLM-driven appends get sanitized first via `_sanitize_dynamic_text` so the model cannot inject a fake `# [IDENTITY]` header.
- **Typed appends.** `append_core_memory()`, `append_preference()`, and `append_emotion()` are the only mutation paths, each scoped to its DYNAMIC_GROWTH subsection. This is the surface used by Sleep Metabolism to grow the agent over time.
- **Async lock.** All reads/writes are guarded by an `asyncio.Lock` so concurrent metabolism + reflection consolidation cannot interleave.
- **Snapshot rendering.** `render_identity_block()` produces the `[IDENTITY] / [FUNDAMENTAL_LAWS] / [BEHAVIORAL_MATRIX] / [CORE_MEMORIES]` excerpt that gets injected as the system-prompt prefix on every Brain cycle.
The soul.md you ship is the agent's *birth certificate*. The DYNAMIC_GROWTH region is its *biography*.
---
### 2. The Heartbeat β Time, Rhythm, and Metabolism
**Module:** [core/heartbeat.py](core/heartbeat.py)
The Heartbeat is what makes OpenCrayFish *alive* rather than *invocable*. It runs in its own asyncio task and never returns until shutdown.
#### Two main coroutines
1. **`pulse_loop()`** β ticks every `system.pulse_interval_seconds` (default 30s) during the **Active window** (06:00β02:00, configurable via `duty_start` / `sleep_start`).
2. **`metabolism()`** β runs ONCE per day, automatically the first time `_pulse()` notices the clock has crossed into the Sleep window (02:00β06:00).
#### What happens on each pulse
```text
_pulse() at T=now:
1. Determine if we're in duty or sleep window.
2. If just entered sleep β call metabolism() once, then publish state and return.
3. If just woke up β reset _last_interaction_at = now (clean idle clock).
4. monitor.sample() β fresh VitalSigns
5. emotions.decay() β exponential drift toward baseline
6. if vitals.is_stressed β emotions.nudge_many(vitals_stress)
β record stress ENTER (rising edge only)
7. Append PULSE telemetry to /YYYY-MM-DD.log
(default: logs/daily/YYYY-MM-DD.log)
8. Publish state/vitals.json + push history sample
9. if pending_writes > 0 AND idle β₯ idle_journal_flush_seconds
β stm.flush_journal()
10. if idle β₯ idle_proactive_minutes
β _proactive_research()
β reset idle clock so we don't spam
```
#### What happens on metabolism (02:00 once nightly)
```text
metabolism():
1. _collect_recent_logs() β yesterday's + today's heartbeat telemetry
2. _collect_conversation_journal()
β stm.flush_journal() THEN read stm_journal.jsonl
so the day's actual chat is included
3. SLM extracts 3-5 "key facts" from the merged corpus
4. Append facts to memory/archive.md (LTM)
5. Promote the top 2 facts to soul.md [CORE_MEMORIES] (Soul Evolution)
6. _consolidate_reflections()
β scan state/reflection-*.jsonl for recurring
interest topics + lesson themes,
AND state/skills-*.jsonl for chronically
failing Skills (β₯3 invokes, >50% fail rate)
β promote into LEARNED_PREFERENCES /
EMOTIONAL_EVOLUTION
7. stm.purge() β wipe RAM deque + pending + journal
(the day's content has been consolidated)
```
#### Stress edge detection
Per-pulse stress is *not* logged or alerted (it would spam at every pulse the Pi is hot). Instead, `_record_stress_transition()` only emits **rising-edge ENTER** and **falling-edge EXIT** events to:
- `state/logs/agent.log` β operator-tailable warning lines
- `state/vitals_events.jsonl` β JSONL feed the dashboard renders as a stress timeline
ENTER events carry `temp`, `ram`, `cpu`, and a human-readable `reason` (the live `vitals.describe()` text). EXIT events add `duration_s`, `peak_temp`, `peak_ram` from the entire episode plus `current_temp` / `current_ram` / `current_cpu` snapshots and the same `reason` string β enough for the dashboard to render a stress timeline with explanatory tooltips without re-parsing `agent.log`.
#### Live-state publishing
Every pulse atomically writes `state/vitals.json` (using a `.tmp` + `os.replace` atomic swap) so the Streamlit dashboard β running in a *separate process* β can read a consistent snapshot without IPC. The snapshot contains:
```json
{
"ts": "...",
"is_sleeping": false,
"vitals": { "cpu_percent":..., "temperature_c":..., ... },
"brain": { "online": true, "backend": "hailo", "last_error": null,
"recovery_seconds": null },
"mood": { "joy":..., "anger":..., ... },
"stm": { "size": 7, "pending": 0 },
"history": [ ... rolling 60-sample sparkline ... ],
"counters": { "pulses": 1234, "proactive": 12, "stress": 3 },
"last_proactive": { "topic":..., "source":..., "ts":... }
}
```
---
### 3. The Vital Signs β `Monitor` & Homeostasis
**Module:** [core/monitor.py](core/monitor.py)
`Monitor.sample()` returns a `VitalSigns` dataclass with:
| Field | Source | Notes |
|---|---|---|
| `cpu_percent` | `psutil.cpu_percent(interval=0.1)` | 100 ms blocking β cached for `vitals_cache_ttl_seconds` |
| `ram_percent` | `psutil.virtual_memory()` | |
| `temperature_c` | `/sys/class/thermal/thermal_zone0/temp` | `None` on macOS dev boxes |
| `is_stressed` | hysteresis state machine | see below |
| `brain_online` | `provider.health().online` | SLM as a vital sign |
| `brain_backend` | active backend label (`"hailo"` / `"ollama"`) | |
| `brain_last_error` | last circuit-breaker cause, formatted | |
| `brain_recovery_seconds` | seconds until breaker auto-resets | |
#### Hysteresis (no flap)
Stress uses TWO thresholds:
```text
ENTER stress: temperature β₯ thermal_limit_celsius (default 75Β°C)
OR ram β₯ ram_limit_pct (default 85%)
EXIT stress: temperature β€ thermal_release_celsius (default limit-5)
AND ram β€ ram_release_pct (default limit-5)
```
This prevents the persona from oscillating between EXHAUSTION and normal turn-by-turn when the temp jitters around 75Β°C.
#### Brain availability as a vital sign
Because the SLM is the agent's *brain*, `Monitor.attach_provider(provider)` is called by `main.py` so every `sample()` synchronously polls `provider.health()` (cheap β no network) and embeds the result. When the brain goes offline:
- `vitals.describe()` appends `"Cognition link is DOWN β inference backend `` is unreachable."` to the prompt
- The dashboard renders a π΄ BRAIN OFFLINE chip
- The web-chat UI shows an inline error banner
#### Force-stress mode
Set environment variable `OCF_FORCE_STRESS=1` to force `is_stressed=True` for testing the EXHAUSTION DIRECTIVE path on a cool dev machine.
---
### 4. The Provider β SLM Backend & Circuit Breaker
**Module:** [core/provider.py](core/provider.py)
The Provider abstracts the inference layer behind a single async method:
```python
await provider.generate(system_prompt, messages) -> str
```
#### Two backends, identical wire format
Both Hailo-Ollama (NPU, port 8000) and stock Ollama (CPU, port 11434) speak the **same `/api/chat` JSON contract**. The Provider tries the primary first (NPU when `hardware.npu_acceleration=true`), then falls back transparently to CPU on transport error. On a dev box without an NPU, set `npu_acceleration=false` and the Provider runs CPU-only with no per-pulse failover noise.
#### Circuit breaker
When **both** backends fail back-to-back, the Provider:
1. Raises `ProviderUnavailable` with a friendly first-person message ("I can't reach the inference service right now β both the NPU endpoint (port 8000) and the CPU fallback (port 11434) are offline. Start `ollama serve` (CPU) or `hailo-ollama` (NPU)β¦").
2. Arms an internal **circuit breaker** for `trip_seconds` (default 30s). During the trip window, every subsequent `generate()` call raises immediately without re-trying dead sockets.
3. Stores `last_error` (formatted as `": "`) and `_tripped_until` so `health()` can report:
```python
ProviderHealth(
online=False,
active_backend="hailo",
seconds_until_recovery=27.4,
last_error="ConnectError: All connection attempts failed",
)
```
#### How Brain handles it
`Brain._cycle()` catches `ProviderUnavailable` exactly **once**, at the top of the cycle, and returns a synthetic `ThoughtTrace` with `backend="offline"` and the friendly message in `filtered.text`. The connectors render whatever they get β they need no knowledge of the failure mode. This means **adding a new connector inherits offline behavior for free**.
`Brain.synthesize_task_report()` also re-raises `ProviderUnavailable` so the scheduler records `last_error` instead of broadcasting the friendly text as a real report.
---
### 5. The Memory System β STM / LTM / Sleep Metabolism
**Modules:** [core/stm.py](core/stm.py) Β· [core/heartbeat.py](core/heartbeat.py) (metabolism)
> **Plain English.** Memory is a four-tier waterfall. The newest turn lands in a 12-slot RAM ring (T1), is mirrored to a pending buffer (T2), and after 30 s of silence is flushed to a disk journal (T3) so a crash never loses it. Every night at 02:00, the day's journal is distilled into long-term prose in `archive.md` (T4), the most identity-shaping facts are promoted into `soul.md`, and T1/T2/T3 are wiped clean for tomorrow. The hot reply path **never** touches disk β only Heartbeat does.
OpenCrayFish has a **three-tier memory hierarchy**, modeled after the biological brain:
```text
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β TIER 1 β RAM working memory (deque, maxlen = stm_max_turns) β
β Used as the conversation window passed to the SLM each turn. β
β When full, oldest turn is silently dropped (Python deque). β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
every append() also pushes to:
βΌ
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β TIER 2 β Pending buffer (RAM list) β
β Accumulates new turns since the last disk flush. β
β Drained by Heartbeat after `idle_journal_flush_seconds` of β
β silence, OR at sleep metabolism, OR at shutdown. β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βΌ flush_journal()
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β TIER 3 β Disk journal (state/stm_journal.jsonl) β
β Durable backstop. Single open()/write()/close() per flush. β
β fsync controlled by `journal_fsync_on_flush` (shutdown always β
β fsyncs regardless). β
β Replayed at boot by stm.recover() so a crashed agent wakes up β
β with prior conversation context. β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
every night at 02:00:
βΌ
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β TIER 4 β Long-term memory (memory/archive.md, soul.md) β
β Sleep Metabolism distills the day β archive.md; promotes top β
β facts β soul.md [CORE_MEMORIES]. THEN purges TIER 1/2/3. β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
```
#### "Memory is full" β what actually happens
Python's `collections.deque(maxlen=N)` silently drops the oldest entry when a new one is appended past the cap. There is **no explicit eviction code** β that's the entire RAM-tier eviction policy. The dropped turn is *not lost*: it is still in the pending buffer (until flushed) and will be in the disk journal after the next flush, and ultimately distilled into archive.md tonight.
So the SLM only ever *sees* the last 12 turns, but the system *remembers* much more β it just retrieves it from a different layer.
#### Why this design
- **SD-card friendly:** the chat hot path does ZERO disk I/O. Disk writes only happen after 30s of silence (default), at metabolism, or at shutdown.
- **Crash-safe:** `recover()` rehydrates the deque from the journal at boot. Only un-flushed data within the last 30s of a sudden power loss is lost.
- **Bounded SLM context:** keeps `system_prompt + history` under qwen2:1.5b's effective attention window.
- **Daily reset:** `purge()` ensures yesterday's bullet-by-bullet detail doesn't pile up forever β the *meaning* survives in archive.md / soul.md, the *transcript* doesn't.
#### Knobs
| Config | Default | Effect |
|---|---|---|
| `memory.stm_max_turns` | 12 | RAM deque size |
| `system.idle_journal_flush_seconds` | 30 | Idle-flush threshold |
| `system.journal_fsync_on_flush` | false | Strict durability vs SD wear |
#### Related durable feeds (NOT in the STM/LTM tiers)
The agent also writes four high-frequency JSONL audit streams that are *adjacent to* but separate from the memory hierarchy above. Together with `stm_journal.jsonl` they form the five durable on-disk feeds:
| Feed | Owner | Date-rotated? |
|---|---|---|
| `state/stm_journal.jsonl` | `STM` | no (nightly purge) |
| `state/deliberation-YYYY-MM-DD.jsonl` | `CognitiveLoop` | yes (14d) |
| `state/skills-YYYY-MM-DD.jsonl` | `SkillRegistry` | yes (30d) |
| `state/reflection-YYYY-MM-DD.jsonl` | `ReflectionEngine` | yes (60d) |
| `state/reflection_dropped-YYYY-MM-DD.jsonl` | `ReflectionEngine` | yes (60d) |
Rotation + retention details live in [Β§ JSONL Rotation & Retention](#jsonl-rotation--retention). Reflection mines both `reflection.jsonl` (interest / lesson clusters) and `skills.jsonl` (chronic Skill failures) into `soul.md` during Sleep Metabolism β see [Β§ 10 Reflection](#10-self-reflection--the-self-learning-loop).
---
### 6. The Thinking Process β Brain & Cognitive Loop
**Modules:** [core/brain/orchestrator.py](core/brain/orchestrator.py) Β· [core/brain/prompt_assembly.py](core/brain/prompt_assembly.py) Β· [core/brain/identity_responder.py](core/brain/identity_responder.py) Β· [core/cognition.py](core/cognition.py)
> **Plain English.** Brain is a *sequencer*, not a thinker. For every reply it walks a fixed 11-step pipeline: read the soul, sample the body, feel the mood, read the user's tone, try a deterministic identity shortcut, try an LTM shortcut, otherwise spin up the Cognitive Loop (one prompt for THINK, one for PLAN, fan out the chosen Skills in ACT, optionally one REFINE round). The evidence is then folded into one final synth prompt, the SLM speaks, the Positive Filter scrubs it, the turn is committed to memory, and a self-critique fires in the background. **No single SLM call does more than one job, and every step has an explicit failure mode.**
Every reply the agent produces β whether triggered by a user message, a heartbeat proactive thought, or a scheduled task β flows through `Brain._cycle()`. The cycle is a strict pipeline:
```text
Brain._cycle(user_input | mission)
β
βΌ
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β 1. Soul Context β soul.render_identity_block() β
β 2. Physical State β monitor.sample() β vitals.describe() β
β 3. Internal Mood β emotions.snapshot() β
β 4. User Empathy β empathy.analyze(user_input) β
β β emotions.nudge from empathy β
β 4b. IDENTITY SHORTCUT β regex catches "what's your name" β
β (deterministic templated reply, zero SLM call) β
β β
β 5. LTM scan β archive.md keyword overlap score β
β 5a. Cognitive Loop β THINK β PLAN β ACT (β REFINE) β
β (bypass conditions: β
β β’ proactive turn β’ cognition disabled β
β β’ vitals stressed β’ LTM short-circuit β
β β’ explicit search verb in user input) β
β 5b. Legacy Web Triage (only when cognition was bypassed) β
β 5c. Build unified KNOWLEDGE block β
β β
β 6. Assemble prompt β soul + vitals + mood + empathy β
β + KNOWLEDGE + STM history β
β + current user message β
β 7. provider.generate() β raw SLM output β
β 8. Prompt-leak detector (drops responses regurgitating β
β system-prompt scaffolding) β
β 9. PositiveFilter.apply() β final text β
β 10. STM.append("agent", text) β
β 11. ReflectionEngine.fire_and_forget() (background) β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βΌ
ThoughtTrace (returned to connector)
```
#### Identity short-circuit (zero SLM call)
The qwen2:1.5b model **reliably mishandles** the most basic identity questions ("what is your name", "do you know my name", "how are you", "who created you"). From production logs, "what is your name" used to triage as a SEARCH and pollute the synth with Netflix's "My Name" + the anime "Your Name". Fix: detect a small set of unambiguous identity-class regexes BEFORE any cognition runs, and return a templated reply built from soul.md + config.yaml + live vitals/mood. **Deterministic. Zero round-trip. Zero hallucination.**
The "what is your name / who created you" branches delegate the actual templating to `IdentitySkill` via `skill_registry.invoke("identity", ctx, kind="name"|"creator")`. The Skill reads the IDENTITY block from `soul.md` and returns a short factual line; Brain wraps it with the live salutation/status sentence. If the registry call fails (no registry, exception, `ok=False`, empty summary), Brain falls back to an inline template β strictly additive, zero regression risk. The `who am I` and `how are you` branches stay inline because they need vitals / mood / architect-name that `IdentitySkill` doesn't see.
#### Cognitive Loop β THINK β PLAN β ACT β REFINE
Engaged on real user turns when no bypass condition fires. Each stage is **a single one-job SLM prompt** with hard token caps and regex parsing β *not* one giant chain-of-thought.
| Stage | What it does | Output cap |
|---|---|---|
| **THINK** | Restate user INTENT in one sentence + decompose into β€ `cognition.max_subquestions` atomic Q1/Q2/Q3 sub-questions. | 120 tokens |
| **PLAN** | Assign exactly one verb from the **dynamic, registry-driven menu** to each sub-question. The shipping menu is `RECALL` (hits archive.md via the `recall` Skill), `SEARCH "..."` (hits SearXNG via the `research` Skill), and `ANSWER` (no retrieval needed, dispatched to `direct_answer`). Adding a new Skill with `plan_verb` automatically extends the menu β see [Β§ 12 Skills & Tools](#12-skills--tools--the-two-tier-capability-stack). | 120 tokens |
| **ACT** | Execute all PlanSteps **concurrently** via `skill_registry.invoke(name, ctx, **kwargs)`. Collect per-sub-question Evidence. Every invocation is timed, isolated from crashes, and appended to `state/skills-YYYY-MM-DD.jsonl`. | (executes verbs) |
| **REFINE** | (optional, capped at 1 round) Re-read intent + evidence; emit `OK` or `GAP: SEARCH "..."`; if gap, ACT it. | 40 tokens |
The full trace is appended to `state/deliberation-YYYY-MM-DD.jsonl` for audit. **Failures never raise** β the loop degrades to whatever evidence it managed to collect.
##### Dynamic PLAN menu + cost-tier auto-degradation
`SkillRegistry.plan_menu(...)` builds the PLAN-stage menu fresh on every turn, filtered by two runtime signals:
* **`cost_tier_cap`** β the operator baseline from `skills.default_cost_tier_cap`. `_active_plan_entries()` tightens it to `"cheap"` whenever `vitals.is_stressed` is true, so a hot or RAM-pressured Pi automatically stops picking expensive web research.
* **`exclude_network`** β set when `skills.auto_offline_filter` is true AND the Provider's circuit breaker has tripped OR the brain is otherwise offline. Any Skill with `requires_network=True` (currently `research`) drops out of the menu so the SLM can't pick a verb whose tool we can't reach.
The filtered menu is rendered into the PLAN prompt as `VERB(arg_hint) β description` lines sorted free β cheap β expensive, so the SLM is gently biased toward the