Skip to content

Personality and Autonomy

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

Personality & Autonomy

This page documents how shelldon has an inner life. The pet carries a small persistent personality state (mood, energy, when it last talked to you) that survives reboots, drifts that state on its own with resident reflexes that need no network and no language model, and runs a core-resident scheduler that gives the pet a self-directed life: nightly dreams, periodic checkpoints, and the occasional unprompted "hey, I was just thinking about…" — all bounded so it can never run away on API cost or drain the battery.

Everything described here lives in shelldon/core/ and is LLM-free (an import-linter contract enforces that no provider/worker code ever enters core/). The pet's inner life keeps running with the network down.

Related pages: The Screen (how mood becomes a face), Memory & Learning (the dream cycle that consolidates what the pet noticed), How a Turn Works (the turn lifecycle every self-initiated turn reuses).


The shape of a personality

The pet's whole inner state is a tiny mutable struct, defined in shelldon/core/state.py:

class Mood(msgspec.Struct):       # MUTABLE — core mutates it in place
    valence: float = 0.0          # pleasant(+) ↔ unpleasant(-)
    arousal: float = 0.0          # activated(+) ↔ calm(-)

class PersonalityState(msgspec.Struct):
    mood: Mood
    energy: float = 0.5           # 0.0 depleted .. 1.0 full
    last_interaction: str | None  # ISO-8601 UTC; None until the first message
    budget: TurnBudget            # the daily self-driven-spend ledger (see below)

Mood is deliberately just two dimensionsvalence (how good it feels) and arousal (how activated it is) — borrowed from the circumplex model of affect. It is not a psychology engine; it is the minimum needed for the face to have something to express and for the pet to feel like it has weather inside it. energy is a separate 0–1 scale that the idle reflex settles toward a resting baseline.

This struct is mutable RAM state, unlike the frozen wire contracts in contracts/. It is the pet's working copy of itself, mutated in place by the core loop.

One writer, a closed set of paths

Every mutation goes through a single sparse-patch writer, and core is the sole caller of it (architecture invariant AD-5/NFR11). You don't set attributes directly — you hand apply_patch a dict of dotted.path → value:

state.apply_patch({"mood.valence": 0.4, "energy": 0.55})

The writer validates every key against a closed set before applying any of them:

WRITABLE_PATHS = frozenset({
    "mood.valence", "mood.arousal", "energy", "last_interaction",
    "budget.date", "budget.turns_used", "budget.last_turn_at",
})

A patch targeting a path outside this set is rejected wholesale — it raises rather than silently minting a new attribute. So a typo like mood.valnce is a loud failure, not a quietly-lost write (the same typo-rejection principle as the Region enum elsewhere in the codebase). And the patch is all-or-nothing: an unknown key in the dict rejects the whole patch, never a half-apply.

A successful patch flips a _dirty flag. That flag is what lets the pet checkpoint periodically instead of on every change — important because the reflex loop produces a high churn of tiny writes that must stay off the SD card (NFR7).

Surviving a reboot (and a power cut)

State lives in RAM as the working copy; it is checkpointed to one small JSON file at ~/.shelldon/state.json (injectable for tests, which never touch real $HOME). RAM is never the source of truth across a restart — the file is.

The checkpoint is the first and canonical atomic write in the codebase (invariant AD-10):

# write to a temp file in the SAME directory → flush → fsync → os.replace
fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=path.name + ".", suffix=".tmp")
with os.fdopen(fd, "wb") as f:
    f.write(data); f.flush(); os.fsync(f.fileno())
os.replace(tmp, path)         # atomic same-filesystem rename

A crash before the rename leaves the prior good checkpoint byte-for-byte intact and cleans up the stray temp — you never see a half-written file. On the worst-case abrupt power cut, the pet loses only the drift since the last checkpoint.

Restore tolerates damage. PersistentState.load(path):

On start it finds… It does…
no file (first run) clean defaults, no write, no crash
a valid checkpoint restores the values
corrupt / truncated / schema-mismatched / unreadable logs a warning, falls back to defaults — never raises

That last row matters on a Pi: a checkpoint half-written by an earlier crash, or a hand-edited file, must not brick the boot. It degrades to a fresh personality instead.


Resident reflexes — an inner life with no brain

Between messages, the pet visibly lives: its mood drifts. This is driven by resident reflexes that read and write the personality struct directly — no LLM call, no worker, no network, no broker (invariant CAP-2 / AD-1). The reflex tick runs identically whether you're holding a conversation or the WiFi is down.

The reflex policy is a single pure function in shelldon/core/reflexes.py:

def compute_reflex_patch(state: PersonalityState, now: datetime) -> dict:
    """Pure: (state, now) → a sparse patch over the closed mood paths. No mutation, no I/O."""

It computes two gentle, hard-clamped drifts and returns a sparse patch (an empty dict when nothing should change, so an at-rest tick marks nothing dirty):

  1. Time-of-day arousal drift. Arousal nudges toward a target that depends on the UTC hour — calm and sleepy at night (10pm–6am → target -0.5), lively midday (10am–4pm → +0.5), neutral at the shoulders. The pet winds down in the evening and perks up in the afternoon on its own.

  2. Idle settling. Once the owner has been silent past IDLE_SETTLE_AFTER_S (300s), valence and energy drift back toward their resting baselines (0.0 and 0.5). The pet "settles" when left alone — neither agitated nor delighted, just at rest.

Each drift is a single step of current + (target - current) * DRIFT_RATE (rate 0.1), clamped to range (valence/arousal ∈ [-1, 1], energy ∈ [0, 1]). A computed change smaller than EPSILON (1e-3) is dropped, so a settled pet produces {} and stays quiet — this caps write-churn at the policy level for the SD-card budget.

Policy vs. driver

Note the split: reflexes.py is what to drift (a pure, deterministic, fully-unit-testable function), while when to run it lives in the runtime as a scheduled job. This separation is deliberate — it is what let the scheduler later subsume the standalone reflex loop without changing any drift behavior. The same policy/driver shape recurs in budget.py, power.py, and proactive.py.

The idle signal the reflex reads is real: the core loop writes state.last_interaction (ISO-8601 UTC) through the same single-writer apply_patch whenever an owner message arrives. So the turn path and the reflex tick both mutate state through one serialized API — and because apply_patch is synchronous (no await), a reflex and a turn can never interleave mid-mutation on the single core event loop. They coexist without a lock and without fighting.

A second affect input: plugin nudges

Reflexes are the autonomous drift, but mood can also be moved by plugin-emitted affect nudges (shelldon/core/reactions.py). A plugin emits a meaningNUDGE_POSITIVE, NUDGE_NEGATIVE, NUDGE_EXCITED, NUDGE_CALM — and core, not the plugin, owns how far the soul moves via a closed delta table:

_NUDGE_DELTAS = {
    EventKind.NUDGE_POSITIVE: (0.3, 0.0),   # (valence_delta, arousal_delta)
    EventKind.NUDGE_NEGATIVE: (-0.3, 0.0),
    EventKind.NUDGE_EXCITED:  (0.1, 0.3),
    EventKind.NUDGE_CALM:     (0.0, -0.3),
}

compute_nudge_patch returns a clamped sparse patch (or None for a non-affect kind or a no-op at the bounds), which flows through the same apply_patch. A per-kind cooldown in the runtime debounces a flood. A nudge moves mood but deliberately does not touch last_interaction — it changes how the pet feels, not its idle clock.


How mood reaches the face

The drifting mood is rendered as an expression on the E-Ink panel. A pure function maps the mood coordinate to a face token:

# shelldon/core/faces.py
def select_face(faces, valence, arousal, energy) -> str:
    """First face whose valence/arousal/energy ranges all contain the mood, else the default."""

Each face declares the box of mood-space it owns; select returns the first match. The runtime pushes this token to the screen only between turns (_maybe_push_mood_face) — while a turn is in flight the lifecycle faces (thinking / reply) own the screen — and only on a change, to avoid spamming identical snapshots. So as the night reflex pulls arousal down, the ambient face quietly shifts from lively to sleepy with no model in the loop.

Full detail on the face registry, tokens, and panel rendering is on The Screen.


The scheduler — one resident task, many cadences

v1 had a single heartbeat. shelldon replaces it with a core-resident scheduler (shelldon/core/scheduler.py, invariant AD-14) that runs named jobs, each on its own cadence and cost tier, as one parkable in-core task. "Heartbeat" is now just one job among several.

A Job carries a name, a Cadence (when it's due), a CostTier, and a tier-specific payload. There are three cadence kinds:

Cadence Due when… Used by
Interval(period_s) period_s elapsed since last run (due immediately on first run) reflex drift, checkpoint flush, prune
Idle(period_s) period_s elapsed since state.last_interaction; fires once per idle stretch, re-arms on a fresh interaction proactive musing, dream
Daily(at) once per calendar day at/after a UTC time (available; minimal cron — one time-of-day, no weekday rules)

Due-ness is computed from an injected clock, so the whole thing is deterministic and testable without sleep. Daily is intentionally not a 5-field cron grammar — AD-14's named jobs only need a daily at-time trigger, and a full parser would be speculative scope.

Cost tiers: reflex vs. turn

Every job is tagged REFLEX or TURN:

  • REFLEX jobs run in-core, no LLM, cheap CPU. The scheduler executes them directly on its tick. They are stretched on battery but never skipped — they carry the pet's aliveness.
  • TURN jobs each cost a fork + an LLM call. The scheduler never forks directly (AD-14). Instead it routes a due turn job to a dispatch_turn hook that admits it through the same arbiter gate every owner turn passes (≤1 turn in flight). This is the seam where the cost and battery guardrails live.

The jobs registered today

These are wired in Core.__init__ (composition is explicit; a general plugin job-registration API is a separate Epic):

Job Cadence Tier What it does
reflex Interval REFLEX mood/energy drift + the between-turn mood-face push
checkpoint Interval REFLEX flush dirty state to disk (only if dirty — NFR7)
prune Interval REFLEX drop expired parked approvals / promotions (housekeeping)
proactive Idle (default 1h) TURN the unprompted "musing" — see below
dream Idle (default 6h) TURN review pending learnings, consolidate memory (Memory & Learning)

The scheduler runs as one task (_scheduler_task) in its own slot — never the transient per-turn task bag — and is parkable: its base interval is injectable so tests can push it far out of a measurement window. Each job runs under its own guard: one bad job logs and the scheduler keeps ticking. And critically, incoming messages bypass the scheduler entirely (AC3) — the inbox consumer handles them immediately; the scheduler is a parallel driver, never a gate in front of your messages.

incoming owner message ──────────────► run() consumer ──► arbiter ──► turn   (immediate, never queued)

scheduler tick ──► due jobs ─┬─ REFLEX ──► run in-core (no LLM)
                             └─ TURN  ──► dispatch hook ──► [battery gate] ──► [cooldown+budget gate] ──► arbiter ──► turn

Keeping autonomy cheap: the spend budget

A mind that can wake itself up must not spend uncontrollably. Every scheduler-initiated turn passes a spend gate before it can fork (shelldon/core/budget.py, invariant AD-9). Two independent checks, both must pass:

  • a daily turn-COUNT budget (default 12 self-driven turns/day) caps total spend;
  • a minimum-interval cooldown (default 30 min) between scheduler turns prevents a stampede.

The gate is a pure policy returning one of three verdicts:

class Decision(enum.Enum):
    ADMIT  # slot free, cooldown elapsed, budget has room → start the turn
    DEFER  # inside the cooldown window → re-proposed next cadence
    SKIP   # daily budget exhausted → don't run today

evaluate checks budget first (SKIP if admitting this job's cost would exceed the daily cap), then cooldown (DEFER if too soon), else ADMIT. On admission it records the spend through apply_patch.

Two design choices worth knowing:

  1. The ledger is persistent. turns_used, the day it belongs to, and the cooldown stamp live in PersonalityState.budget and ride the normal checkpoint. This is the whole point — a crash-loop or restart cannot reset the daily cap and overspend. The budget you spent this morning is still spent after a reboot.

  2. Per-job cost weight. A Job carries a cost (default 1); admission requires used_today + cost ≤ daily_turn_budget. The dream job declares cost=3 so one heavy nightly review counts as several pings against the cap, while the proactive musing stays cost=1. (The budget rations turns; a separate per-turn ceiling in the worker bounds tool-calls-per-turn, so worst-case spend is bounded regardless of any job's cost.)

The day rolls over on the owner's local calendar day (now.astimezone().date()), not UTC, so "daily" means your day. Budget is a turn count, not a dollar/token figure — true cost accounting needs the broker's token detail and is a noted future refinement.

Reflexes are never gated. The budget/cooldown/arbiter gate lives only in the turn-tier dispatch path. When the budget is exhausted, mood drift, the checkpoint flush, and the mood-face push all keep running on their normal cadence — the pet stays alive, it just stops spending on self-initiated LLM turns.


Easing off on battery

The other autonomy guardrail is power-awareness (shelldon/core/power.py, invariant AD-14). The scheduler reads a power state each tick and resolves it to one of three backoff levels:

class BackoffLevel(enum.Enum):
    LIVELY  # plugged in / charging — normal cadences, nothing skipped
    EASED   # on battery, charge OK or unknown — stretch cadences, skip non-essential turns
    LOW     # on battery, charge < threshold — deeper stretch, skip ALL turns

The level is computed purely from one reading (PowerState(on_battery, charge)), with sensible conservatism: plugged in ⇒ LIVELY regardless of charge (a low battery that's charging is recovering, not backing off), and an unknown charge on battery ⇒ EASED, never LOW (a missing reading never escalates to the deepest backoff).

The level drives two levers:

  • Cadence stretch multiplies every Interval/Idle job's period by a per-level scale (1.0 LIVELY, 3.0 EASED, 6.0 LOW by default — Daily is exempt). On battery the pet wakes up less often: mood drifts slower, the proactive musing waits longer. Fewer wakeups is the real battery saving, and it applies to reflex jobs too.
  • Turn-skip applies only to turn-tier jobs: under EASED a non-essential turn is skipped, under LOW all turns are skipped. Job.essential (default False) is the carve-out for a future critical turn. Reflex jobs are stretched but never skipped.

This is an outer gate over the budget gate — the scheduler decides battery-skip before the dispatch hook is ever called, so a battery-skipped turn never even reaches the cooldown/budget check. The two gates answer different questions (power vs. credit) and a self-initiated turn must clear both.

due TURN job ──► [battery: skip on LOW / non-essential on EASED] ──► [cooldown + daily budget] ──► arbiter ──► _start_turn

The real PiSugar2 hardware read is a plugin-host plugin (a later Epic). Until then the scheduler reads an injected power stub that defaults to plugged-in (LIVELY), so an un-instrumented deployment behaves exactly as if the battery logic weren't there. The backoff policy itself is fully live and tested against a controllable reader — only the hardware read is deferred, and swapping the stub for the real reading is a zero-policy-change edit.


Proactive action — the pet speaks first

The capstone of the autonomous mind is the proactive turn: the pet initiating a turn with no preceding owner message (invariant CAP-4). It reaches out on its own sometimes, so it feels like a companion with initiative, not just a responder.

It is registered as an Idle-cadence TURN job named proactive. After proactive_idle_interval (default 1h) of owner silence, the idle cadence fires it once — then stays quiet until a fresh interaction re-arms the clock, so the pet muses once when you go quiet rather than nagging. On battery the idle threshold stretches automatically, so it waits longer to speak up when unplugged.

Three things make a no-owner-input turn work:

  1. The prompt is built at dispatch from live mood. A proactive turn has no static text. The job carries a prompt_builder callable resolved at admission time, which reads current mood/energy, derives a feeling word from the same face vocabulary the screen uses (faces.select — one mood-label source, no second classifier), and weaves it into an open-ended directive (shelldon/core/proactive.py):

    (Self-prompt: there's no owner message to reply to right now — you're speaking up on your own. You're feeling {feeling}. Share whatever's on your mind: a passing thought, something you noticed, or just a hello. It doesn't have to be a question.)

    The framing is share-a-thought, not a forced question (an owner decision). A missing feeling degrades to a feeling-agnostic directive — it never emits the literal "None".

  2. History records a synthetic marker, not a fake owner message (AC3). The worker runs the real directive, but conversation history stores the pet's reply paired with "(shelldon spoke up on its own)" on the owner side. So the next turn's recent-window knows the pet reached out, without a self-prompt masquerading as something you typed.

  3. It reuses the whole turn lifecycle. A proactive turn opens the fence, pushes the thinking face, spawns the worker, arms the timeout, and records the reply exactly like an owner turn — it is the normal lifecycle (How a Turn Works), just self-initiated.

Crucially, 5.4 added no new gating. The proactive job goes through the exact battery gate, cooldown, and daily-budget gate described above. When the cooldown hasn't elapsed, the budget is exhausted, a turn is already in flight, or the pet is backed off on battery, the proactive job is simply deferred or skipped — and the reflex jobs carry the in-between aliveness. The pet doesn't initiate, it just keeps quietly drifting until it's allowed to speak again.

The dream job is a proactive-turn variant on a longer cadence with a heavier cost: instead of musing, it reviews what the pet noticed and consolidates memory. That's covered on Memory & Learning.


Putting it together

A day in shelldon's inner life, all in core/, all LLM-free except the few gated turn jobs:

  1. State loads from ~/.shelldon/state.json (or clean defaults). Mood, energy, last-interaction, and the spend ledger are restored — the pet remembers who it was.
  2. The scheduler ticks. Every tick: reads power → picks a backoff level → stretches cadences.
  3. The reflex job drifts mood toward the time-of-day target and settles energy when you're idle — no network needed. The face quietly follows.
  4. The checkpoint job flushes dirty state to disk periodically (not per change), atomically.
  5. After an hour of silence, the proactive job comes due — if the cooldown, daily budget, and battery all allow it, the pet builds a mood-tinted self-prompt and speaks up unbidden.
  6. Your reply arrives and bypasses the scheduler — handled immediately, re-arming the idle clock and resetting the pet's sense of "last talked to you."

The pet has continuity of self across reboots, an inner life that runs offline, and a self-directed schedule that can never quietly burn your credits or flatten the battery.

Clone this wiki locally