Sleep¶
The sleep cycle is the familiar's nightly consolidation. It has four parts — memory hygiene, a dream pass that forms her opinions and writes a dream artifact, a behavioral silent window, and conversation-log archiving — all driven as a catalog activity. The authored sleep description seeds the dream prose; the backend passes are coupled to the activity's lifecycle.
All four parts are built. Hygiene and the dream pass run automatically
on the sleep activity's departure. The silent
window is the sleep activity itself: reachable = false suppresses
pings, though her alarms still pierce. Log archiving comes free from the
activity return's archive watermark, since a full night is well past
archive_after_minutes.
One sleep runs hygiene then dream. Hygiene cleans the fact base; dream forms opinions on the cleaned base, and hygiene's retirements feed the dream prompt as a known-bits deny-list. The two passes own separate watermark axes (below).
Memory hygiene¶
Fact extraction works in small batches (default 10 turns per tick), so it can't see day-level patterns — a claim asserted nine times by one speaker and denied by its subject every time reads, turn by turn, like nine facts. The hygiene pass shows the LLM the whole window at once, so it can catch the bit, the near-duplicate pile-up, and the claim misfiled under the wrong person.
The pass proposes; code decides. Every LLM proposal is validated against safety rails before acceptance, and an accepted plan mutates the store only on the apply pass.
Two action verbs¶
Both are supersede-only — facts are never deleted, only retired (the row survives for audit).
- retire — drop a fact with no replacement: noise, a duplicate of a kept fact, or a bit recorded as a fact.
- rewrite — merge near-duplicates or re-attribute a misfiled claim
into one consolidated fact that supersedes the listed old facts.
Provenance (
source_turn_ids) is the union of the merged facts.
Both are plan-level proposals. At the store they resolve to a single
supersede(obsolete_facts, new_fact) operation: retire passes
new_fact=None, rewrite passes the consolidated draft (N→1, the new
fact owning ancestry and the provenance union). See the fact
store.
These cover the design's hygiene functions: supersede junk (retire), merge duplicates (rewrite N→1), re-attribute claims (rewrite with corrected subject). Run-on dossiers heal on their own — the people-dossier worker rebuilds a subject's dossier from scratch whenever one of its facts is retired or superseded.
Rails (enforced in code)¶
| Rail | Meaning |
|---|---|
unknown_id |
Every referenced id must be a current fact in the window. |
self_subject |
A fact under the familiar's own ego: key (an opinion) is never touched. Hygiene does not adjudicate feelings; an opinion changes only through the dream pass, never the janitor. |
duplicate_target |
A fact may be targeted by at most one action. |
subject_introduced |
A rewrite may not introduce a person-subject absent from its source facts; the only exception is the ego: key. A bit about a person can be re-attributed to the familiar, never minted as a new fact about the person. |
subject_lost |
A rewrite of facts that had subjects may not drop them all, which would orphan the fact and rebuild the sources' dossiers without its content. |
noop |
A single-source rewrite that restates the fact unchanged is rejected. |
empty_text |
A rewrite must carry non-empty new text. |
cap |
Facts mutated per run ≤ the cap (default 50). Excess actions are deferred and recorded. |
The rails gate subject attribution, not free text. new_text is
unvalidated prose, so a rewrite can mention a new person: they get no
subject key, so dossiers and per-subject queries stay clean, but the
text still reaches FTS/semantic recall. Hygiene polices what facts are
about, not every word they contain.
subject_introduced and the self-key exception are the contamination
guard established by the KaillaDame disengagement work and the
self-dossier substrate: opinions and bits about
people inherit the same discipline as real-time extraction.
Prompt text is config, rails are code¶
The static instruction text the passes send the model lives in
_default/character.toml [prompt], overridable per familiar via the
same _default → override deep-merge as every other config field:
| Key | Used by |
|---|---|
sleep_consolidation_system |
consolidation system message |
sleep_stance_system |
per-day stance-moment pass ({self_name}) |
sleep_synthesis_system |
opinion synthesis pass ({self_name}) |
dream_extraction_clause |
fact-extractor dream-framing clause ({self_name}, {self_key}, {ids}) |
The prose lives only in _default/character.toml — no second in-code
copy to drift against (mirrors post_history_instructions). Production
always merges _default, so the real text always resolves; empty config
yields an empty prompt, never a stale duplicate.
Only the phrasing is configurable. The dynamic window data (facts,
turns, cited ids, the known-bits deny-list) and the machine-parsed JSON
reply shape are assembled in code. Placeholders are filled by literal
{key} substitution, so a stray brace or unknown placeholder degrades
gracefully (passes through verbatim) rather than crashing. A prompt
override can never weaken a rail: the validation rails run after the
model replies, regardless of what the prompt said.
Authored facts¶
There is no "untouchable" flag. Authored lore (her tastes, description,
nature, seeded from seed_turns.toml) is protected only by the
code-enforced rails, the same as every other retirement — a binary pin
proved too blunt to tell canonical lore from a redundant restatement of
it. Since hygiene runs automatically on sleep departure with no
human-in-the-loop review, those rails are the entire safety story; any
stronger lore protection has to be a code rail, not an operator step.
The pass¶
gather_window → current facts (cap facts_max, newest) + turns since
the sleep watermark (cap turns_max) + high-water marks
build_prompt → whole window rendered, facts grouped with subjects
LLM (background)→ proposes {retire, rewrite}
parse_actions → permissive JSON parse (garbage → empty plan)
validate → rails filter proposals → HygienePlan (accepted + rejected)
(read-only to here — no DB writes)
apply_hygiene → execute accepted actions, advance sleep watermark
log rejections → rail-blocked proposals logged (WARNING) for review
Supersede: the single store operation¶
Both action verbs resolve to one store method,
supersede(obsolete_facts, new_fact), which owns merge lineage and
provenance:
new_fact=None— retire: each obsolete row getssuperseded_atset,superseded_byleft NULL. Per-id skip-and-record.- a fact draft — merge: atomic. Pre-flight every obsolete row; if
any is unknown or already superseded, decline the whole merge (nothing
minted). Only when every row is current does it mint the replacement
and point every obsolete row at it (N→1 lineage). The minted fact's
source_turn_idsis the union of the obsolete rows' provenance — the caller never supplies it, and ancestry resolves from the store alone viaancestors_of.
The atomic merge means a phantom merge superseding nothing is impossible by construction, and provenance-union equals ancestry exactly.
Sleep watermark (two axes)¶
sleep_watermark (one row per familiar) has two independently-owned
axes: hygiene owns last_fact_id (how far the fact base is
consolidated), dream owns last_turn_id (how far the log is formed into
opinions). advance_sleep_watermark is a partial update — each pass
touches only its own axis, so neither clobbers the other even within one
sleep run. Each axis defines the window its pass covers; a missed night
just widens it. A watermark advances only on apply; a pass that makes no
changes leaves it untouched.
Concurrency and idempotence¶
The live bot shares the DB, and the plan→apply gap spans an LLM call
over the whole window. If a writer supersedes a planned fact in that
gap, apply skips that action (per-action ValueError handling) rather
than crashing, and records it in the report. A rewrite pre-flights its
sources and skips the whole action if any is no longer current, so it
never strands a half-merged fact. This also makes apply idempotent:
re-running a partially-applied plan skips what already happened. No
separate crash-safety artifact is needed — a mutated row carries
superseded_at/superseded_by, so the fact store records what a run
did.
Rejection logging¶
The passes stay read-only until they apply; what they reject is the
signal worth keeping. Every rail-blocked proposal — the rail that caught
it and the raw payload — is logged at WARNING under the Sleep tag,
so a model that keeps proposing rule-violating actions is visible without
an on-disk artifact. Accepted mutations and their lineage stay
recoverable from the fact store (superseded_at/superseded_by,
source_turn_ids, ancestors_of).
Dream / opinion formation¶
The dream pass forms the familiar's opinions — her own stances — from
the conversation log, so she stays consistent with how she acts. An
opinion is a fact routed to her ego: subject (always injected via the
self-dossier), grounded in her log through source_turn_ids pointing at
the turns that demonstrate it. The model proposes; code decides, as in
hygiene.
Two-pass, grounded by construction¶
The window is the whole log up to the dream watermark, bucketed by
calendar day in display_tz. Steady-state (one day's delta) is a single
small pass; the first run is a catch-up over the whole backlog:
- Per-day candidates — each day, the model surfaces stance-moments carrying that day's cited turn-ids. Code keeps only ids that belong to the day, so contamination stays local with no cross-day drift.
- One synthesis over all candidates — merges restatements, sees the
whole arc at once. Code enforces every synthesized opinion's
source_turn_ids⊆ the union of its input candidates' ids, so the synthesis cannot invent grounding.
Rails¶
| Rail | Meaning |
|---|---|
ungrounded |
Source ids must be non-empty and ⊆ the candidate union. An opinion citing nothing real is rejected. |
empty_text |
Opinion text must be non-empty. |
duplicate |
Normalized-text dedup across the plan — catch-up restates the same stance on different days; only the first is kept. |
cap |
Total opinions ≤ the opinion cap (default 60). |
Plus a flag, not a rejection: an opinion grounded in no turn the
familiar herself authored surfaces as no_self_authored. She's a bot,
so everything she did is in her own turns; a stance grounded only in
others' messages is the room's, not hers. valid_from is set to the
earliest grounding day, so backlog opinions carry an honest timeline even
though they're all written tonight — and a change of mind lands as two
facts with ordered valid_from, no supersession needed.
Each minted opinion also carries an LLM-assigned importance (1–10), rated by how durable/central the stance is to who she is, not how strongly worded: 7–9 a durable core stance, 4–6 characteristic but situational, 2–3 momentary texture. Out-of-range or missing values clamp to a neutral 5 rather than rejecting the opinion. Importance feeds the same rank-aware self-dossier as hygiene's facts.
Contamination still in the log (bits, impersonation) is handled without scrubbing it: hygiene's retirements from the same run feed the dream prompt as a known-bits deny-list — she may have a take on a bit (finding it tedious is real characterization) but never treats it as a true event.
Sleep as an activity¶
Sleep is a reserved catalog activity — the entry with
id sleep. It reuses the whole activity machinery (departure, return,
presence, archive watermark, staged-turn promotion, missed-ping wake)
and adds a behavioral schedule the engine owns; no separate scheduler
process exists.
The window¶
The wall-clock schedule lives in character.toml [sleep]: window =
"HH:MM-HH:MM" (in display_tz, may wrap midnight) and grace_minutes
(default 30). It is not on the catalog entry — the reserved sleep entry
only marks which activity the schedule drives, while the engine reads
window/grace from character config and identifies the sleep activity by
id. With a configured window, duration_minutes on the entry is ignored:
the wake is fixed at the window's end (alarm-style), regardless of when
sleep started. Omitting the [sleep] table disarms the schedule. The
engine's existing ~60s tick drives it:
- Bedtime nudge — when local time enters the window and she is not
already out, one synthetic bedtime nudge (debounced per window
occurrence) offers
start_activitywith sleep framing. Going to bed willingly stays the model's call. - Grace backstop — past window start +
grace_minutes, if no sleep has started this occurrence, the engine force-starts the sleep activity directly, no LLM choice. This also covers booting mid-window. "Already slept this occurrence" is judged by the newest sleep row's start time. - While asleep —
reachable = falsesuppresses pings like any unreachable activity; her own alarms still pierce. A restart mid-sleep re-arms the fixed wake from the persisted row.
Keep the catalog's active_hours disjoint from the sleep window: they
are independent checks on the same tick, so an overlap lets an idle nudge
and the bedtime nudge co-fire in the pre-grace stretch. Once force-sleep
fires, the active state suppresses the idle nudge.
Lifecycle-coupled passes¶
Sleep departure fires hygiene then dream (apply=True) as a background
task. The watermarks define each pass's window, so a missed night just
means a wider window next sleep — one dream, no catch-up worker.
Hygiene's retirements feed the dream's deny-list. A pass failure is
logged and degrades the wake to seed-only prose; it never blocks the
return or crashes the engine.
Right after the passes finish, the engine produces and persists the
dream prose: it generates the narration from the seed plus freshly minted
opinions, mints the dream-journal ego: fact, then writes the prose
onto the sleep row's experience_text. That column's presence is the
single "dream fully produced" signal, so the dream is durable within
minutes of bedtime and survives a mid-sleep restart — the reloaded active
row already carries the prose, and the return reuses it verbatim. A
prose-gen failure degrades to the wake fallback rather than killing the
passes. (Residual window: a crash after the journal append but before the
experience_text write re-journals seed-only prose at the next return, a
rare possible duplicate that hygiene reconciles.)
The dream artifact¶
At wake, the return turn is the dream: [returned from asleep]
<dream prose>. Normally the prose was already produced and persisted at
pass-completion, so the return reads experience_text straight off the
row. If the passes failed before persisting, the return falls back to
generating prose at wake: the background LLM, prompted with the sleep
entry's authored seed plus the night's minted opinions when available.
A one-shot hand-authored first dream can be placed at
data/familiars/<id>/seed_dream.md: used verbatim instead of
generation, then renamed seed_dream.consumed.md.
The prose is also minted as a durable, dream-framed ego: fact — the
dream-journal stopgap; a real dreams table is deferred. It is journaled
once: at pass-completion on the normal path, or at wake on the fallback
path. The mechanical event-fact ("spent … night asleep") is written as
for any activity.
Dream-aware extraction¶
The sleep return turn carries mode = "sleep_return"
(SLEEP_RETURN_MODE), not activity_return. FactExtractor keeps
skipping activity_return but processes sleep_return turns with dream
framing, and the claim-discipline rail is enforced in code, not just
prompt: any fact grounded in a dream turn is forced to the ego:
subject and dream-framed ("she dreamed that …"), so dream content never
lands under a person's key. Unsourced facts fall back to the batch minus
dream turns, so real facts about people stay person-attributable.
Not yet built¶
A real dreams table (the journal currently rides the fact store as
dream-framed ego: facts), and retiring the rolling summary in favor of
facts + RAG as the continuity bridge. See the roadmap.