Activities¶
The familiar can get up from the screen. Via the start_activity
tool she decides to go do something — a walk, an errand, whatever the
operator authors in her catalog — goes absent for a rolled duration,
and comes back with a generated experience. The mental model is a
person at a screen who gets up and comes back: while out she may miss
messages, a real @ping might pull her back early, and a long absence
means she returns with fresh eyes rather than perfect recall.
Absence is global (away from the screen), not per-channel: pings from any subscribed channel count, and the return lands at the focused text channel. Voice is out of scope in v1 — the engine refuses to start an activity while a voice subscription is active.
The whole feature is gated on the per-familiar catalog sidecar
data/familiars/<id>/activities.toml. The ActivityEngine is
constructed only when the catalog is non-empty; with the file missing
or the catalog empty, nothing is wired and behavior is byte-for-byte
unchanged. All policy lives in the tool description and injected
state lines — zero growth to character.md or directives.
Lifecycle¶
start_activity tool call → ActivityEngine.defer_start(type_id, note)
→ roll duration from catalog [lo, hi]
↳ returns {ack, label, duration_minutes}
reply ships → engine.end_turn()
→ INSERT INTO activities (...)
→ presence: idle (dnd if unreachable) + activity label
→ remember departure turn id
→ arm return timer
(absence — gate decides per inbound message)
timer fires / cut short → generate experience (background slot)
→ mechanical event-fact
→ marked assistant turn in focused channel
→ archive watermark (long absences)
→ staged-turn promotion (reads the screen)
→ missed-ping wake (only with cause)
→ presence: online
Starting¶
The model calls start_activity(activity, note?) — a text-registry
tool whose activity enum is built from the catalog at
registry-build time, so each familiar's sidecar shapes the schema.
The start is deferred: the tool stages it and the responder
applies it via end_turn() after the current reply ships (same
deferral pattern as shift_focus), so she says her goodbye in the
same message. Duration is rolled uniformly from the catalog entry's
duration_minutes = [lo, hi] at start. The optional note records
intent and seeds the experience later.
The engine also runs an idle-nudge loop (started in
ActivityEngine.start(), ~60s tick): when should_nudge is
eligible — the focused channel quiet for idle_nudge_minutes, no
activity active, at least min_gap_minutes since the last return,
local time inside active_hours (in the familiar's display_tz) —
it publishes a synthetic wake event into the focused text channel
whose content mentions the quiet and offers start_activity. The
quiet clock is fed by the responder (note_traffic on every handled
text event); firing marks the nudge pending so the debounce window
starts. The nudge only earns the model a turn — going out remains
its decision.
While out — absence gating¶
The TextResponder consults ActivityEngine.gate(payload) at the
top of handle(), before any prompt assembly. Three outcomes:
- Suppress — non-ping messages are recorded as staged turns
(history stays complete; at return they are promoted into the
cross-channel window — see Returning) but produce no
typing indicator, no LLM call, and no reply. She's away from the
screen. Suppressed turns whose payload carried
pings_botare noted live (note_missed_ping) for the at-return wake — this is what catches cross-channel pings and reply-pings, since the gate runs for every subscribed channel. - Judgment — a real @ping in the focused text channel while
the active type is
reachableearns one normal prose-tier turn with a state line appended to the trailing system message, this turn only: how long she's been out, who pinged, and that replying means heading back whilesilent()means staying out. A real reply triggers a cut-short return (notify_reply_sent); a silent verdict keeps her out. One judgment turn per author per absence: after an author's verdict, their further pings are suppressed and noted like any other; the latch clears when the next activity starts. Real pings from unfocused channels never earn judgment — they are suppressed and noted, and surface in the return wake. - Unreachable — a real @ping on a non-
reachabletype is suppressed like any other message; it surfaces in the missed-ping wake at return.
Her own alarms pierce any absence: a synthetic wake payload carrying
alarm: True (the alarm waker's marker) always gates normal —
reachable or not — and replying to it does not cut the absence short.
"Real @ping" means the bot user appears in the message's mentions —
ingest computes a pings_bot payload flag covering both <@id>
mentions and reply-pings, while role/@everyone mentions and bare
name-mentions never count. Payloads without the flag (synthetic
events) fall back to a raw-content mention scan.
Returning¶
The return timer (or a cut-short reply) drives one flow:
- Experience generation — the background LLM slot writes a
short first-person account from the catalog
seed, the optional tool-callnote, and a cut-short hint. The prompt carries a provenance rail: experiences are about places, things, and herself — never invented claims, conversations, or encounters involving real people. An LLM failure degrades to a stock one-liner; the return flow always finishes. - Mechanical event-fact — a direct fact row, no LLM: "Sapphire spent Jun 12 afternoon out for a creek walk". The event is durable memory even though the rich experience text is not.
- Marked return turn — the experience persists as an assistant
turn (her own narration, not system authority) in the channel
focused at return, prefixed
[returned from <label>]and taggedmode = "activity_return"(ACTIVITY_RETURN_MODE). It is visible to history and RAG but skipped by the fact extractor, keyed on themodecolumn — the prefix is display-only (see Memory below). - Archive watermark — an absence of at least
archive_after_minutessets the watermark for every channel (channel_archive_watermarktable) at the global departure turn id: absence is from the whole screen, so one departure point breaks every channel's window. The prompt window resets there; scrollback does not (see Context interaction). - Staged promotion — staged turns stored since departure are
promoted across all channels, but capped: only the last
[focus].catch_up_limit(default 20) turns per channel — plus any that @-mention her, always caught — are marked consumed; older absence backlog is markedmissed_atand dropped from her window and rolling summary. She reads the top of the screen when she gets back, not an unbounded scroll. Pre-absence staged turns in never-attended channels keep their attentional semantics. - Missed-ping wake — live-noted pings are merged (deduped) with
a content scan of turns stored since departure in the focused
channel. If any exist, a synthetic
discord.textwake event fires with auto-injected context: a one-line list of missed pings, where the newest three getturns_aroundexcerpts (~5 turns each), older pings collapse to one-liners, and pings already inside the visible last-few window skip excerpts. With no missed pings the return is silent — no turn, no announcement without cause. - Presence — status returns to online; while out it was idle
(yellow) with the catalog
labelas activity text. Unreachable types (reachable = false) show do-not-disturb (red) instead of idle.
Restart safety¶
The activities table is append-only; the active row has
actual_return_at NULL until finished (status completed or
cut_short). On boot the engine reloads the active row and re-arms
the return timer with a short floor delay (~20s) so bus consumers
and the Discord session exist first — a past-due return fires at
boot + floor rather than inline, which means the return flow (and
its missed-ping wake) lands on a live bus and survives a restart.
The departure turn id is recomputed from turn timestamps (same
precedent as AlarmScheduler).
Away presence survives restarts and reconnects via on_ready: the
engine starts before Discord login, so its own boot-time presence
call never reaches Discord. After the focus presence sync, on_ready
calls the engine's resync_presence(), which re-issues idle/dnd plus
label when an activity is still in flight (a no-op when idle or when
activities are disabled). Gateway reconnects re-fire on_ready, so a
presence reset mid-activity heals the same way.
Configuration¶
Catalog sidecar data/familiars/<id>/activities.toml (same pattern
as lorebook.toml). The _default skeleton ships fully commented
out, i.e. disabled. Missing file or empty catalog disables the
feature; a present-but-invalid file fails loudly with ConfigError
so a typo never silently drops a knob.
The shipped skeleton (uncomment and adapt to enable):
# Activities catalog + knobs. Sidecar per familiar:
# data/familiars/<id>/activities.toml (lorebook precedent).
# Missing file or empty catalog => activities disabled.
#
# Knobs (all optional):
# archive_after_minutes absence >= this sets per-channel archive
# watermark at departure turn (default 45)
# idle_nudge_minutes focused-channel quiet time before synthetic
# idle nudge may fire (default 20)
# min_gap_minutes minimum gap after a return before the next
# idle nudge (default 90); gates nudges only,
# never start_activity tool calls
# active_hours "HH:MM-HH:MM" in display_tz; may wrap
# midnight; omit => always
#
# Catalog entry ([[catalog]], one per activity type):
# id stable identifier used by start_activity tool
# label Discord presence text while out
# duration_minutes [lo, hi] roll range, 0 < lo <= hi
# reachable real @ping while out triggers judgment turn
# (default true)
# content_source "authored" (default; "adapter" reserved for
# future adapter-backed types)
# seed authored prompt seed for experience generation
#
# Reserved sleep entry (id must be "sleep") — the window-scheduled
# sleep activity. The wall-clock SCHEDULE lives in character.toml
# [sleep] (window + grace_minutes), not here; this entry only marks
# WHICH activity the schedule drives. With a schedule, wake is fixed at
# the window END (alarm-style), so duration_minutes is optional and
# ignored. Sleep departure runs the hygiene + dream passes in the
# background; the seed retunes the dream prose narrated at wake. A
# one-shot hand-authored first dream can be
# placed at data/familiars/<id>/seed_dream.md (consumed on first sleep).
#
# Ships disabled — uncomment and adapt to enable.
# archive_after_minutes = 45
# idle_nudge_minutes = 20
# min_gap_minutes = 90
# active_hours = "10:00-23:00"
# [[catalog]]
# id = "creek_walk"
# label = "out for a creek walk"
# duration_minutes = [20, 45]
# reachable = true
# content_source = "authored"
# seed = "A short walk along the creek: weather, water, small wildlife, passing thoughts. Experiences are about places, things, and yourself — never invented claims about real people."
# [[catalog]]
# id = "sleep" # schedule (window/grace_minutes) in character.toml [sleep]
# label = "asleep"
# reachable = false
# seed = "The night's dream, told on waking: vivid, a little strange, woven from the day's leftovers — openly a dream, never reported as real events."
Top-level knobs (all optional):
| Knob | Default | Purpose |
|---|---|---|
archive_after_minutes |
45 |
Absence at/above this sets the archive watermark for all channels at the departure turn. |
idle_nudge_minutes |
20 |
Focused-channel quiet time before an idle nudge may fire; also the nudge debounce window. |
min_gap_minutes |
90 |
Minimum gap after a return before the next nudge. Gates nudges only — never blocks a start_activity call. |
active_hours |
unset (always) | "HH:MM-HH:MM" in display_tz; may wrap midnight. Nudges fire only inside this window. Keep it disjoint from the sleep window — overlap lets an idle "do something" nudge and the bedtime "go to bed" nudge co-fire in the pre-grace stretch (once force-sleep fires, the active state suppresses the idle nudge). |
Catalog entry ([[catalog]], one per activity type):
| Field | Required | Purpose |
|---|---|---|
id |
yes | Stable identifier; becomes a start_activity enum value. Must be unique. sleep is reserved (below). |
label |
yes | Discord presence text while out; also names the activity in turns and facts. |
duration_minutes |
yes* | [lo, hi] roll range in minutes, 0 < lo <= hi. *Optional and ignored on the scheduled sleep entry — return is fixed at window end. |
reachable |
no (true) |
A real @ping while out earns a judgment turn; false means nothing until return. |
content_source |
no ("authored") |
Where experience text comes from. Only "authored" is valid today; "adapter" is a reserved seam for future adapter-backed types (e.g. actually watching a video and reporting on it) and is rejected with an explicit message until implemented. |
seed |
yes | Authored prompt seed for experience generation (dream prose for the sleep entry). |
The reserved sleep entry¶
The catalog id sleep is reserved for the sleep cycle.
Its wall-clock schedule — window = "HH:MM-HH:MM" (in display_tz,
may wrap midnight) and grace_minutes (default 30) — lives in
character.toml [sleep], not on the catalog entry; the entry only
marks which activity the schedule drives. While the entry is otherwise
an ordinary catalog row (the model can start_activity into it at the
bedtime nudge), the engine's tick loop owns its schedule: a
once-per-occurrence bedtime nudge at window start, a force-start past
grace_minutes, and a wake fixed at the window's end regardless of
start time. Because the wake is fixed,
start_activity("sleep") is refused more than an hour before the
window — a midday call would otherwise mean a ~20-hour absence. Sleep
departure fires the hygiene + dream passes in the background, and the
return turn carries the dream prose under mode = "sleep_return" — see
sleep.md for the full semantics.
The sleep entry in the shipped skeleton above shows the reserved row:
reachable = false, an authored dream seed, and a comment pointing at
character.toml [sleep] for the schedule keys.
Interaction with the context pipeline¶
RecentHistoryLayer— fetches withrecent_cross_channel(respect_archive=True), which drops turns at/below their channel's archive watermark inside the window query, strictly per-channel, before voice coalescing and the silence-gap fold so merged turns never smuggle archived text. The window may shrink rather than backfill — the archive marks a break. The silence-gap fold is independent: a long absence is also a wall-clock gap, so on channels with the fold enabled the window may already reset at the return boundary even below the archive threshold.read_channelscrollback — deliberately ignores the watermark: fresh eyes are not a memory hole, and she can scroll back through what she missed. The tool grew two paging parameters:before_id(turns withid < before_id) andaround_id(aturns_aroundwindow centred on a turn id); the two are mutually exclusive.- Store —
recent(..., before_id=)extends the existing query;recent_cross_channel(..., respect_archive=)applies the watermark filter as a single-SQL correlated subquery outside the latest-N window (shrink, not backfill);turns_around(...)backs botharound_idpaging and the missed-ping wake excerpts.
Memory¶
The return turn is visible to history and RAG like any assistant
turn, but FactExtractor filters it out of every extraction batch, keyed
on the turns mode column (ACTIVITY_RETURN_MODE,
"activity_return"); the RETURN_TURN_MARKER_PREFIX content prefix
([returned from) stays as a display marker only. Experience text
is self-generated fiction — the same claim/fiction discipline that
keeps in-character narration out of the fact store applies, and the
mechanical event-fact already records that the activity happened.
The extractor watermark still advances over skipped turns.
The sleep return turn is the exception: tagged mode = "sleep_return"
(SLEEP_RETURN_MODE), it is processed — with a code-enforced rail
that dream-grounded facts land dream-framed under the ego: subject
only. See sleep.md.
Deliberately v1¶
- Experiences never enter the fact store. Only the mechanical event-fact is durable; consolidating experience texts into memory is deferred to future sleep-cycle work.
- Voice excluded.
defer_startrefuses while a voice subscription is active; there is no voice-side gating. - Live ping capture is in-memory. While the process runs, the
gate notes every suppressed
pings_botturn (covers cross-channel and reply-pings). A restart loses that list; the fallback is the content scan of the focused channel, which only finds<@id>mention strings — a reply-ping landing across a restart can be missed. Known gap, accepted for v1. content_source = "adapter"is a reserved seam, not a feature: real-content activity types plug in there later without a schema change.