Skip to content

Memory and Learning

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

Memory & Learning

How shelldon remembers what you tell it, reads that memory back into every reply, and consolidates the noise into durable knowledge while it "sleeps."

Memory in shelldon is a closed loop:

memory  →  prompt  →  reply (+ proposed ops)  →  core applies ops  →  memory

The worker (the brain) only ever reads memory and proposes changes. Core is the sole writer of every store. That single rule — workers propose, core applies — is what keeps a prompt-injected or hallucinating model from corrupting what shelldon knows.

This page covers the two stores (sqlite conversation history + the curated markdown tree), the vault that's OS-isolated from the worker, how memory is assembled into each prompt in a fixed order, and the learning loop (learnings table + the dream cycle).

Related pages: Architecture · How a Turn Works · Personality & Autonomy


The two stores

shelldon keeps memory in two physically separate places, by design:

Store Location Shape What lives there
Conversation history ~/.shelldon/history.db sqlite (WAL + FTS5) every owner/pet message, the learnings table, and a few op-bookkeeping tables
Curated memory ~/.shelldon/memory/ a markdown tree about.md, facts/, people/, preferences/, episodes.md, summary.md, the owner's DIRECTIVE.md, and the locked vault/

The sqlite store is the firehose — append-only, queryable, never edited. The markdown tree is the keepsakes — a small, human-readable, LLM-curated set of files that say who you are and what matters. The dream cycle (below) is what moves the durable signal from the firehose into the keepsakes.

Both stores are core-owned, single-writer, rooted under ~/.shelldon/, and the default paths are injectable so tests never touch a real $HOME.

Code: shelldon/core/history.py, shelldon/core/memory.py.


Conversation history (sqlite, WAL, FTS5)

Every completed turn writes both halves — the owner message and the pet's reply — to the messages table in ~/.shelldon/history.db.

The schema

CREATE TABLE messages (
    id      INTEGER PRIMARY KEY,   -- stable insertion order
    turn_id TEXT,
    role    TEXT NOT NULL CHECK (role IN ('owner', 'pet')),
    content TEXT NOT NULL,
    ts      TEXT NOT NULL          -- ISO-8601 UTC
);

An external-content FTS5 index mirrors content, kept in sync by an insert trigger, so keyword recall over everything ever said is a single MATCH query:

CREATE VIRTUAL TABLE messages_fts
    USING fts5(content, content='messages', content_rowid='id');
CREATE TRIGGER messages_ai AFTER INSERT ON messages BEGIN
    INSERT INTO messages_fts(rowid, content) VALUES (new.id, new.content);
END;

If the sqlite build lacks FTS5, the store raises a clear RuntimeError at open rather than silently shipping without recall. (FTS5 ships with the CPython-bundled sqlite on the Pi OS target.)

Writes: one transaction per turn

HistoryStore.record_turn(turn_id, owner_text, pet_text, now) inserts the owner row then the pet row inside a single transaction:

def record_turn(self, turn_id, owner_text, pet_text, now):
    ts = now.isoformat()
    with self._conn:                 # one commit per turn — both rows or neither
        self._conn.execute("INSERT INTO messages (turn_id, role, content, ts) "
                            "VALUES (?, 'owner', ?, ?)", (turn_id, owner_text, ts))
        self._conn.execute("INSERT INTO messages (turn_id, role, content, ts) "
                            "VALUES (?, 'pet', ?, ?)", (turn_id, pet_text, ts))

This is the batched-commit discipline: one commit per turn, not per row. sqlite runs in WAL mode so the per-turn fsync is cheap and concurrent readers (the workers) never block the writer. WAL + per-turn batching is also an SD-card wear concession — the Pi's flash storage does not want a commit per row.

Core records history as a side effect after the reply is emitted, and the write is best-effort: if a sqlite failure happens after the reply has already gone out, it is logged and the turn loop continues rather than crashing. A turn that never produced a reply (a spawn failure) records nothing; a degraded turn records (prompt, DEGRADE_TEXT) — the pet's actual reply that turn.

Reads: recent + recall

Workers open the store through a read-only handle and get exactly two query shapes:

reader = open_readonly(path)        # file:…?mode=ro — a write raises sqlite3.OperationalError
reader.recent(n)                    # last n messages, oldest → newest
reader.search(query, n)             # FTS5 keyword recall, most-relevant first

open_readonly connects with file:…?mode=ro, so even a raw INSERT through that connection raises at the sqlite layer — the worker has read access and physically no write path. (This is connection-level read-only; the stronger uid-level isolation is the vault's job, below.)

The schema is single-owner today but shaped so a chat_id/user_id key is a non-breaking ALTER TABLE ADD COLUMN later — nothing is NOT NULL that would force a destructive migration.


Curated markdown memory & the memory-op contract

The curated tree under ~/.shelldon/memory/ is the durable, human-readable half of memory. Every file in it is plain markdown you can open and read.

~/.shelldon/memory/
├── DIRECTIVE.md        # owner-authored, authoritative — bot NEVER writes it
├── about.md            # bot's self-summary (bot-owned)
├── summary.md          # running conversation summary (bot-owned, from the dream)
├── episodes.md         # appended episode log
├── facts/<slug>.md     # one file per remembered fact
├── people/<slug>.md    # one file per person you mention
├── preferences/<slug>.md
├── capabilities/<slug>.md
└── vault/              # OS-locked — the worker uid can't read this (see below)

Memory-ops: the closed contract

The model never writes files directly. Instead it proposes memory-ops — a closed, validated set of structured commands defined in shelldon/contracts/. Each is a frozen, tagged msgspec struct with forbid_unknown_fields=True, so a typo'd op name or an unknown field is a hard decode error, not a silent mistake.

The markdown ops (the MemoryOp union):

Op Arg schema Writes
rewrite_about content: str replaces about.md
remember collection: Literal[...], name: str, content: str facts/<slug>.md / people/<slug>.md / etc.
log_episode content: str, optional tags appends to episodes.md
rewrite_summary content: str replaces summary.md (dream-written)

Closed arg schemas — "fixed args, no free-text deltas" — are the point: the model can only ask for changes the contract anticipated.

Core applies, atomically, as the sole writer

CuratedMemory.apply_memory_op(op) validates the op against its schema, then writes the tree using the atomic-write idiom (temp file in the same dir → flushos.fsyncos.replace). An invalid op is rejected without touching disk — there is never a half-written file.

def apply_memory_op(self, op: MemoryOp) -> None:
    if isinstance(op, RewriteAbout):     self._apply_rewrite_about(op)
    elif isinstance(op, Remember):       self._apply_remember(op)
    elif isinstance(op, LogEpisode):     self._apply_log_episode(op)
    elif isinstance(op, RewriteSummary): self._apply_rewrite_summary(op)
    else: raise ValueError(f"unknown memory-op {type(op).__name__!r}")

about.md and summary.md are bot-owned — you don't hand-edit them; the bot curates them through ops.

Path safety

A remember's name becomes a filename, so it's sanitized to a path-safe stem (_safe_filename): NFC-normalized, casefolded, with every run that isn't a Unicode word char or - collapsed to a single -. That preserves non-ASCII names (José, CJK) while making ../etc collapse to etc — a memory-op is physically unable to write outside its collection directory. A belt-and-suspenders path.parent == collection_dir check makes that structural.

DIRECTIVE.md — the owner's constitution

DIRECTIVE.md is the one file you own. It is your authoritative instructions to shelldon, read first on every turn and never written by the bot. It is not a memory-op target — there is no dispatch branch that can name it. Core's write set (about.md/facts//…) is disjoint from the owner's (DIRECTIVE.md), so the two writers can never conflict. That disjointness is structural, not a convention:

def read_directive(self) -> str | None:
    """Read-only — there is no write path to this file anywhere in this module."""
    path = self._root / "DIRECTIVE.md"
    return path.read_text() if path.is_file() else None

To change shelldon's standing instructions, edit DIRECTIVE.md by hand. To change what it believes about you, talk to it and let it curate about.md.

Code: shelldon/core/memory.py, shelldon/contracts/.


The vault — OS-level isolation

Some memory is sensitive enough that you don't want the brain to be able to leak it even if the model is prompt-injected. That's ~/.shelldon/memory/vault/, and its protection is the operating system, not a self-policed path filter.

The model is the untrusted part of the system. A path-filter check ("don't read vault/") lives in code the model's output can influence. So shelldon doesn't rely on one — it uses real uid separation:

  1. The worker drops privilege. Each turn forks a worker. In the fork child, before the turn runs, it does os.setgid(gid) then os.setuid(uid) (gid first — once you drop uid you can't change gid) to a configured less-privileged uid. The privileged parent never elevates; the dropped child dies at turn end.
  2. vault/ is owned by the service uid at 0700. Created with explicit mode (a chmod to defeat umask), so the worker's uid has no read or traverse permission at all.
  3. A worker read of vault/ raises PermissionError — from the kernel, not from any app check.

The drop is fail-closed: after setuid, the child verifies os.getuid() == target and refuses to run the turn if a configured drop silently failed. When no worker uid is configured or the process is unprivileged (a dev box, or macOS which can't safely setuid), the drop is a no-op with a loud warning — "vault isolation OFF, dev mode" — never a crash. Real kernel denial is verified by a Linux+root integration test; on the macOS dev box that test is skipped, never faked green.

Who can read the vault: the broker

If the worker can't read the vault, who surfaces a secret when one is genuinely needed? Only the broker. The broker is shelldon's egress/safety boundary — a separate process running as the privileged service uid. It holds the sole authorized vault-read path:

# shelldon/broker/vault.py — the ONLY module that exports a vault read
surface_vault(root, key) -> str | None     # reads vault/<key>.md, traversal-rejected

There is no equivalent read path anywhere in worker/ — neither an API nor (on Linux) the OS permission. Surfacing a vault secret into a prompt is therefore a deliberate, broker-gated decision at egress, not something the brain can reach for on its own.

The composition root that wires this multi-process privilege model is shelldon/app.py (run via python -m shelldon): it launches core + broker as real OS processes, creates the memory tree including vault/ with correct perms, and configures the fork-server with the worker uid so forked workers drop.

Code: shelldon/app.py, shelldon/worker/forkserver.py, shelldon/core/vault.py, shelldon/broker/vault.py.


How memory shapes the turn

Storing memory is only half of it — memory is real only if it changes the reply. The worker assembles each prompt by reading the durable memory it can see, in a fixed, binding order, before proxying the call to the broker.

The assembly is split into a pure function (assemble_prompt, data in → prompt string out, trivially unit-tested) and an I/O wrapper (gather_context, which opens the read-only handles). Both live in shelldon/worker/prompt.py.

The binding order

SYSTEM_INSTRUCTION
→ DIRECTIVE.md          (authoritative — the owner's constitution, first if present)
→ about.md              (the bot's self-summary)
→ summary.md            (running conversation summary, "# Conversation so far")
→ curated collections   (facts/people/preferences/capabilities the bot filed)
→ recent window         (last N turns from sqlite, oldest → newest)
→ FTS5 recall           (top-k history matches for this message, de-duped vs recent)
→ the current owner message   (always last)

The order is intentional: broad durable context first (constitution → who you are → the gist), then the raw recent window, then targeted recall, then the live message. The model reads everything through the read-only handles (open_readonly, read_about, read_directive, read_summary, read_all_collections) — it never writes and it never reads vault/.

Recall safety and bounds

Two things make this robust:

  • FTS5 injection safety. The owner's raw message can contain FTS operators or punctuation that would make a MATCH query throw. gather_context tokenizes the message to bare \w+ terms, quotes each, ORs them, and caps the term count — so hostile punctuation can't crash recall. The search call is also wrapped in try/except sqlite3.OperationalError -> [].
  • Everything is bounded. The recent window (recent_n) and recall (recall_k) are capped constants — the 416MB Pi never assembles an unbounded backlog. Recall is de-duped against the recent window by row id so the same message isn't shown twice.

Fail-soft

Every read can fail — a missing file, a locked sqlite, a decode error. Assembly is best-effort: a missing DIRECTIVE.md/about.md simply omits that section (no placeholder noise), empty history yields no recent/recall section, and any read failure degrades to a smaller prompt (worst case: just the system instruction + the current message). It is logged, never raised into the turn. The turn always completes.

The proof: CAP-6

The verifiable claim is "the fact reached the brain" — a fact established in an earlier turn (recorded to history, or written to about.md) shows up in the assembled prompt of a later, related turn, via the recent window or FTS5 recall. Tests assert on the prompt the worker sent (deterministic), not on a real model's wording (non-deterministic).

Code: shelldon/worker/prompt.py.


Learnings — cheap self-observation on the hot path

The first half of learning is capture. As shelldon talks, the model can jot down a private observation worth remembering later — a capture_learning op — and it costs no extra LLM call. It rides the same propose→apply wire as memory-ops: the worker parses it off its reply, core applies it on the turn that already happened.

capture_learning is not a markdown op — it routes to sqlite, into the learnings table in history.db:

CREATE TABLE learnings (
    id               INTEGER PRIMARY KEY,
    pattern_key      TEXT,                    -- the dedup identity (nullable)
    observation      TEXT NOT NULL,
    recurrence_count INTEGER NOT NULL DEFAULT 1,
    status           TEXT NOT NULL DEFAULT 'pending'
                       CHECK (status IN ('pending', 'promoted', 'pruned')),
    first_seen       TEXT NOT NULL,
    last_seen        TEXT NOT NULL
);

HistoryStore.capture_learning(observation, pattern_key, now) is an atomic UPSERT:

  • A pattern_key that matches an existing row → increment recurrence_count, refresh last_seen, and reset status='pending'. So a recurring observation accumulates a recurrence count instead of duplicating — and a learning the dream already pruned, if it keeps recurring, re-enters the queue and gets another chance. Recurrence is the durability signal.
  • A None (or blank) pattern_keyalways a fresh insert (anonymous observations never collapse together). A blank key normalizes to None; an empty/whitespace observation is skipped (logged, never written).

The dedup is enforced by a unique partial index (WHERE pattern_key IS NOT NULL), so the insert-or-increment is a single atomic statement — not a racy SELECT-then-UPDATE. This matters because the dream cycle becomes a second writer; the index guarantees neither writer can duplicate or lose a row in a check-to-write gap.

status is pending after capture. Only the dream transitions it to promoted or pruned.

Code: shelldon/core/history.py (capture_learning), shelldon/contracts/ (CaptureLearning).


The dream cycle — classify, promote, prune

The second half of learning is consolidation. Periodically, shelldon dreams: it reviews the pending learnings, promotes the durable/high-value ones into curated markdown, prunes the rest, and refreshes a running conversation summary so context stays bounded. This is how the pet improves over time — a learning captured today shapes a reply tomorrow.

The dream is just a turn

The dream is not a separate subsystem. It's a scheduled introspective worker turn that reuses the fork-server, broker, and arbiter exactly like a normal turn — the same lifecycle, the same propose→apply wire. It's registered as an Idle-cadence turn job (~6h of owner silence) with a heavier cost weight (it counts as 3 against the daily turn budget) and an Idle trigger, so it rides the same budget/battery gates as any proactive turn. See Personality & Autonomy for the scheduler and arbiter.

How a dream runs

  1. Core builds the directive. Core owns the sqlite store, so _build_dream_prompt reads the pending learnings directly — pending_learnings() returns status='pending' rows ordered recurrence_count DESC (impact first), bounded. Each is baked into the prompt tagged with its row id:

    - [id=14] owner prefers terse replies (seen 5×)
    - [id=22] owner is in the Pacific timezone (seen 2×)
    

    If there are no pending learnings, the builder returns an empty string → the turn is skipped — no fork, no spend. The dream only costs budget when there's something to consolidate.

  2. The model classifies and proposes ops. It decides what's durable. For each learning it keeps, it proposes a markdown op to write the knowledge and a resolve_learning(id, "promoted") to mark the source consumed. For each it discards, it proposes resolve_learning(id, "pruned"). It also proposes a rewrite_summary(...) with a short running summary, then replies with a brief owner-facing note ("💤 tidied my thoughts").

  3. Core applies the ops. As always, the worker only proposes; core (sole writer) validates and applies. Two new ops drive the lifecycle:

    • rewrite_summary(content) — a MemoryOp, written to summary.md (which the prompt assembly injects into later turns).

    • resolve_learning(id, status) — a sqlite op. Core applies it as a soft status change, never a DELETE:

      def resolve_learning(self, id, status):
          with self._conn:
              cur = self._conn.execute(
                  "UPDATE learnings SET status = ? WHERE id = ? AND status = 'pending'",
                  (status, id))
          # a stale/hallucinated/already-resolved id → 0-row no-op, logged, never raised

      Note WHERE id = ? AND status = 'pending': core places no trust in the model's id. A hallucinated or stale id simply updates zero rows. Promotion ≠ deletion — a pruned-but-recurring learning resets to pending (via the capture_learning UPSERT) and gets reconsidered next dream.

Promote = two ops, deliberately

Promotion is two ops, not one: an existing markdown op (rewrite_about / remember) writes the durable knowledge, and resolve_learning(id, "promoted") marks the firehose row consumed. This keeps the markdown writer and the learnings lifecycle cleanly separate. Durable knowledge that should shape replies goes to the surfaced docs (about.md / summary.md / the curated collections) — exactly the files the prompt assembly reads back.

The summary keeps context bounded

rewrite_summary overwrites summary.md with a short running summary of recent conversation. The prompt assembly injects it as # Conversation so far, so later turns carry the gist without replaying the full backlog. This is light-scope consolidation — it writes a summary doc; it does not delete or truncate the messages table.

Code: shelldon/core/runtime.py (_build_dream_prompt, dream job registration, resolve_learning routing), shelldon/core/history.py (pending_learnings, resolve_learning), shelldon/core/memory.py (rewrite_summary / read_summary).


The whole loop, end to end

Putting it together — a fact you mention today reaching a reply tomorrow:

  1. You say something. The turn runs; core's record_turn writes the owner message + pet reply to history.db. If the model noticed something worth keeping, it proposed a capture_learning op → core wrote a pending row to the learnings table (no extra LLM call).
  2. Next time you talk, the worker assembles the prompt — DIRECTIVE + about + summary + collections + recent window + FTS5 recall over history + your message. Your earlier fact reaches the brain through recent or recall.
  3. Some idle stretch later, shelldon dreams. It reads the pending learnings, decides the recurring/high-value ones are durable, and proposes rewrite_about / remember + resolve_learning(promoted) for them, prunes the rest, and rewrites summary.md. Core applies all of it.
  4. From then on, that promoted knowledge is in about.md (or a curated collection / the summary) — surfaced into every later prompt, no longer dependent on it happening to fall in the recall window.

Workers propose, core writes, the OS guards the secrets, and the dream turns the firehose into keepsakes. That's memory and learning in shelldon.

Related pages: Architecture · How a Turn Works · Personality & Autonomy

Clone this wiki locally