Context pipeline¶
How a user turn becomes a system prompt, and how side-indices stay in sync with the raw source of truth.
Source of truth vs side-indices¶
The turns table in data/familiars/<id>/history.db (Turso) is the
durable, append-only source of truth. Every derived artifact —
summaries, FTS indexes (sibling fts/turns/
and fts/facts/ tantivy dirs on disk) — is regenerable from
turns alone. Deleting any side-index row (or the whole table) is
safe; the next worker tick rebuilds it. Tantivy indexes auto-rebuild
on HistoryStore.__init__ when missing.
flowchart TB
subgraph SoT["Source of truth"]
turns[(turns)]
end
subgraph Indices["Side-indices (regenerable)"]
summaries[(summaries)]
fts[(fts/turns/ tantivy)]
facts[(facts)]
fts_facts[(fts/facts/ tantivy)]
dossiers[(people_dossiers)]
reflections[(reflections)]
end
turns -->|sync write| fts
turns -->|SummaryWorker| summaries
turns -->|FactExtractor| facts
facts -->|FactSupersedeWorker| facts
facts -->|sync write| fts_facts
facts -->|PeopleDossierWorker| dossiers
turns -->|ReflectionWorker| reflections
facts -->|ReflectionWorker| reflections
subgraph Assembler[Prompt assembly]
card[CharacterCardLayer]
mode[OperatingModeLayer]
lore[LorebookLayer]
sum[ConversationSummaryLayer]
ref[ReflectionLayer]
ppl[PeopleDossierLayer]
rag[RagContextLayer]
hist[RecentHistoryLayer]
end
summaries --> sum
dossiers --> ppl
reflections --> ref
fts --> rag
fts_facts --> rag
turns --> hist
turns --> ppl
Layers¶
Each layer implements a narrow Protocol:
class Layer(Protocol):
name: str
async def build(self, ctx: AssemblyContext) -> str: ...
def invalidation_key(self, ctx: AssemblyContext) -> str: ...
build returns the layer's text contribution (empty string opts out).
Assembler memoises build results keyed on
(layer.name, invalidation_key). Two assemble calls with the same
context re-run build only for layers whose key changed.
Static, file-sourced¶
| Layer | Source | Invalidation |
|---|---|---|
CharacterCardLayer |
data/familiars/<id>/character.md (persona plus operational essentials — <silent> token, first-person, conciseness) |
BLAKE2b content hash — catches sub-second edits |
OperatingModeLayer |
in-memory modes dict, keyed on viewer_mode |
viewer_mode |
LorebookLayer |
data/familiars/<id>/lorebook.toml (optional) |
file content hash + matched entry indices |
Dynamic¶
| Layer | Source | Invalidation |
|---|---|---|
ConversationSummaryLayer |
summaries table — the single per-familiar focus-stream row at FOCUS_STREAM_CHANNEL_ID (ctx.channel_id ignored) |
focus:<last_consumed_at>:<last_summarised_id> |
PeopleDossierLayer |
people_dossiers table, candidate set from recent authors + turn_mentions, plus the always-present ego:<id> subject |
t<latest_id>:cap<n>:<key>:f<last_fact_id> concatenated |
ReflectionLayer |
reflections table, channel-scoped (channel-agnostic rows always surface) |
ch<id>:r<latest_reflection_id>:cap<n> |
RagContextLayer |
fts/turns/ + fts/facts/ tantivy search |
(current_cue, latest_fts_id, latest_fact_id) |
RecentHistoryLayer |
turns.recent_cross_channel(window_size) — all consumed turns across all channels, ordered by arrived_at, id |
not cached — it is the query |
The recent_history layer does not contribute to the system prompt.
It populates AssembledPrompt.recent_history, which the responder
appends as Message objects.
recent_messages calls recent_cross_channel(familiar_id,
window_size) — the last window_size consumed turns across
every channel, ordered by arrived_at, id. This is the read half
of the attentional stream (see below): a focus
shift consumes the target channel's staged backlog, so its messages
interleave into one cross-channel transcript instead of living in a
separate per-channel summary. Each user turn renders with a
[HH:MM speaker #channel_id] prefix — the #channel_id disambiguates
which channel a line came from once multiple channels share the
window.
Past tool turns (e.g. view_image results) are folded into
user-side narration text, not replayed as protocol tool
messages. Recent-history replay carries no tool-call linkage, so a bare
role=tool message would orphan — no preceding matching tool_use —
which upstream providers (Anthropic) reject with HTTP 500. Rendering as
[tool result] … text preserves the context without the invalid
protocol shape.
The narration uses role=user, not role=assistant: an
assistant-side replay teaches the model to open fresh replies with the
[tool result] … prefix (and even fabricate tool results inline) by
mimicking its own apparent past output — the same mimicry trap the
[#id] message-id tag avoids by being dropped from assistant turns.
Watermark-driven workers¶
SummaryWorker¶
Background task on tick_interval_s (default 5s). Per tick:
- Focus-stream rolling summary — one per-familiar summary of the
consumed cross-channel stream (the conversation the familiar
actually attended to), stored in
summariesunderFOCUS_STREAM_CHANNEL_ID. Fetch consumed turns past the composite(consumed_at, id)watermark viaconsumed_turns_after; if>= turns_threshold(default 10) accumulated, build a prompt with(prior summary, new turns), callLLMClient.chat, write back with the new watermark. Watermarking onconsumed_at— notid— is load-bearing: a focus shift promotes a dormant channel's staged backlog with an oldidbut a freshconsumed_at(promote_staged_turnssets it to now), so an id cursor would skip it forever. First run is bounded bybackfill_cap(default 200) then compounds forward. This tiles withRecentHistoryLayer: both describe the same consumed attentional thread (summary = older turns, recent history = the tail).
The summary compounds: each new summary builds on the prior one rather than recomputing from raw turns each time. Bounds token cost. A periodic full recompute (every M compounding cycles) is reserved for later refinement once drift data demands it.
Tantivy FTS indexes¶
Two on-disk tantivy indexes — fts/turns/ and fts/facts/ under the
familiar root — sit beside the Turso DB. They live outside the
database because pyturso wheels don't ship Turso's own FTS module yet,
and because tantivy queries outside Turso don't queue behind SQL
writes (the original "FTS5 blocks the Discord heartbeat" bug was a
single slow tokeniser query gating every other DB call).
Updates are synchronous from HistoryStore:
append_turnwrites the row to Turso, commits, then upserts the(id, content)doc intofts/turns/.append_factdoes the same againstfts/facts/.update_turn_content_by_message_idre-adds the row (tantivy treats same-idaddas upsert).
Supersession isn't surfaced through the index — facts keep their docs,
and the SQL validity filter
(superseded_at IS NULL AND (valid_to IS NULL OR valid_to > now))
strips retired rows after the FTS join. See
Fact discipline
below.
HistoryStore.rebuild_fts() drops and repopulates the turns index
from turns. Run it after deleting fts/ (or whenever tantivy drifts
from the relational tables).
FactExtractor¶
Watermark-driven off memory_writer_watermark. Every tick_interval_s
(default 15 s), turns_since_watermark(limit=batch_size) returns up
to batch_size un-processed turns; if fewer than batch_size are
available, the tick is a no-op. Otherwise a single LLM call extracts
a JSON list of {text, source_turn_ids} facts, persisted with
provenance pointing back to the originating turn ids. The watermark
advances to the last processed turn id whether or not extraction
produced facts — otherwise a malformed response would stall the
worker on the same batch forever.
A post-extraction filter drops self-capability "facts" (e.g.,
I cannot remember names, the assistant has no internet access)
before they hit the store. See
Fact discipline
for the rationale.
FactSupersedeWorker¶
Retires prior facts replaced by newer ones about the same subject.
Watermark-driven off facts.id. Ticks every tick_interval_s
(default 60 s) — much slower than FactExtractor since supersession
isn't latency-critical and adds one LLM call per new fact.
Per tick:
recent_facts(familiar_id, include_superseded=False)returns up tobatch_size(default 5) current facts. Facts newer than the internal watermark are evaluated oldest-first.- For each new fact, for each subject, pull prior current facts for that subject (capped at
priors_max, default 20). Ask the LLM which priors the new fact contradicts or directly replaces. - Call
supersede(obsolete_facts=retired_ids, new_fact=<the new fact>)to repoint each retired prior at the new fact (existing-id form mints nothing). Already-superseded priors (retired by an earlier subject this tick) land in the result'sskippedrather than raising. - Advance the watermark to the highest fact id seen this tick — even on bad LLM output, preventing a loop on a fact the model can't parse.
Facts without subjects are skipped (FactExtractor must resolve at
least one canonical_key for a fact to be eligible). Logs one line
per tick only on retirements:
[Supersede] evaluated=<n> retired=<n> watermark=<id>.
PeopleDossierWorker¶
Compounds per-person summaries off the facts watermark. Same shape as
SummaryWorker (compound prior + new evidence with one LLM call) but
keyed by canonical_key instead of channel_id. Cadence is
intentionally a quarter of SummaryWorker's tick
(tick_interval_s = 20 s): people-level evidence churns slower than
turn-by-turn summaries, and the read path (PeopleDossierLayer) is a
cheap SQLite lookup that doesn't wait on the worker.
Per tick:
subjects_with_facts(familiar_id)returns{canonical_key: max(facts.id)}across non-superseded facts whosesubjects_jsonlists each key.- For each subject, compare against its
people_dossiers.last_fact_idwatermark. Skip when nothing is new. facts_for_subject(canonical_key, min_id_exclusive=watermark)pulls new evidence; the worker feeds prior dossier + new facts to the LLM and writes the result back with the updated watermark.
Empty LLM replies are dropped — a blank response must not blow away an existing dossier. Subjects whose only facts are superseded drop from the candidate set; the dossier row stays put.
ReflectionWorker¶
Writes higher-order syntheses over recent turns + facts (M3). Ticks
every tick_interval_s (default 60 s) — slower than
PeopleDossierWorker because reflections capture themes and patterns,
not turn-by-turn updates.
Per tick:
- Read
latest_id(turns)for the familiar; compare to the newest reflection row'slast_turn_idwatermark. Skip if the gap is< turns_threshold(default 20). - Pull turns since the watermark plus the most recent N facts, so the reflection can cite evidence even when the turns themselves don't surface it.
- Ask the background-tier LLM for at most
max_reflections_per_tick(default 3) reflections, each withcited_turn_ids/cited_fact_ids. - Persist rows that cite at least one valid id; drop rows that
hallucinate everything. The row's
last_turn_id/last_fact_idcolumns snapshot the worker's view at write time and serve as the next tick's watermark — no separate watermark table.
ReflectionLayer reads recent rows on assemble and renders citation
breadcrumbs [T#42, F#7]. Rows citing at least one superseded fact
are flagged (stale); the row is never deleted.
Fact discipline: supersession and self-capability¶
The facts store holds observations about the world, with provenance back to the source turns. Two policies keep it from silently rotting:
No self-capability statements¶
A "fact" like the assistant cannot remember names or faces is a self-description, not an observation — it expires the instant the underlying capability changes (e.g., once entity resolution lands). Such statements belong in the system prompt or a runtime-computed self-description, not in a persistent facts table where they'd silently mislead the model long after they stopped being true.
FactExtractor handles this in two layers:
- Prompt-side: the extractor's system message explicitly instructs the LLM not to emit facts about itself, the assistant, or its own limitations.
- Post-filter:
_is_self_capability(text)matches a small set of first-person and "the assistant/AI/model" patterns at the start of the fact. Matched facts are dropped (logged at DEBUG) beforeappend_fact. Belt-and-braces — even if the model ignores the prompt, the row never lands.
The capability ban is narrow: it drops capabilities/limitations,
not the familiar's narrative. The familiar's own bits/performances,
choices, and relational stances/feelings get a home — the
self-dossier — keyed to a reserved ego:<familiar_id>
subject instead of poisoning whichever person the bit was about. The
distinction the extractor is taught: capabilities/limits → dropped;
narrative/feelings/choices → self-subject.
Supersession instead of overwrite¶
For facts that legitimately go stale (job changes, shifting
preferences, emerging contradictions): replace the old fact with a
new one and mark the old row superseded_at = now,
superseded_by = <new_id>. The old row stays in the table.
recent_factsandsearch_factsdefault toWHERE superseded_at IS NULL— reads see "what's currently true".- Pass
include_superseded=Truefor audit, contradiction inspection, or future provenance UIs. supersede(obsolete_facts, new_fact)is the unified write API (alongsideappend_fact). It retires (new_fact=None), repoints obsolete rows at an existing fact (aFact/id, mints nothing), or atomically mints and points a merge (new_factaFactDraft). It returns aSupersedeResultwhoseskippedrecords any obsolete row already superseded by a concurrent writer — a tolerated skip, not a raise.
The fts_facts index covers all rows including superseded ones (the
FTS triggers don't filter); read paths apply the
superseded_at IS NULL filter via the JOIN to facts. Keeping
superseded text indexed means re-superseding (e.g., reverting an
incorrect supersession via a new fact) doesn't require an FTS rebuild.
Cache invalidation: latest_fact_id counts all rows including
superseded ones, so the RagContextLayer cache key flips whenever a
new fact is appended — and supersession-by-replacement always
appends, so the key naturally moves. (A future "manual supersede
without replacement" path would need to track supersession state in
the key directly; not built today.)
Subject metadata: surviving nickname rot¶
Display names appear verbatim in fact text ("Cass likes pho"), but Discord and Twitch users can rebrand freely. Without an out-of-band link to a stable identifier, every fact about a renamed user becomes referentially orphaned — FTS keeps matching the stale name, and the model has no way to know the new nickname is the same person.
The fix is a soft annotation on each fact: an optional subjects_json
column storing [{canonical_key, display_at_write}] for each person
the extractor identified. Author.canonical_key (platform:user_id)
is stable across renames; display_at_write is the name the LLM saw
when the fact was authored.
Write path. FactExtractor builds a participants manifest
(canonical_key → current display name) from two sources, batch-first:
authors of the current batch (with per-turn guild_id for label
resolution), then recent_distinct_authors per channel touched by the
batch — capped at participants_max (default 30). Widening matters
because a batch where only one user speaks otherwise forecloses on
linking other names in the turn text; including recent prior speakers
lets the LLM resolve "what about Aria?" to her canonical key even when
she didn't speak in this batch. Cap keeps prompt size bounded.
The manifest is injected into the LLM prompt alongside the turns. The
LLM is asked to optionally tag each fact with subject_keys — a list
of canonical keys from the manifest. The extractor validates those
keys against the manifest (unknowns dropped silently), pairs each with
the current display name, and persists via
HistoryStore.append_fact(subjects=...).
Read path. RagContextLayer renders fact text verbatim and
appends a soft annotation when any subject's current display name
differs from display_at_write:
- Cass likes pho. (Cass is now known as peeks)
Resolution goes through HistoryStore.resolve_label(canonical_key,
guild_id), which prefers per-guild nick → global_name → username →
turn snapshot → user_id. If the canonical key resolves to the bare
user_id (nothing else found) or matches display_at_write, no
annotation is added.
Why annotation, not substitution. Identity consolidation is
provisional. Mic-sharing on Discord, relayed quotes ("Bob says hi"),
and plain ambiguity all break a clean 1:1 mapping from a mentioned
name to a canonical key. Treating the extractor's hint as
authoritative and rewriting fact text would launder a guess into
source-of-truth. Appending (was: …; now: …) keeps the original
observation intact and makes the link visible as a hint.
Forward-only. Existing facts have subjects_json = NULL and
render unchanged. Readers live with the unannotated tail; backfilling
is theoretically possible (walk each fact's source_turn_ids, pull
the originating Author) but not worth the migration code for a bounded
dev-test corpus.
People dossiers¶
Combines the prompt-layer pattern with the summary-caching pattern:
per-person summaries are compounded off the facts watermark by
PeopleDossierWorker and stitched into the system prompt by
PeopleDossierLayer. The two halves are decoupled through the
people_dossiers table, so the read path stays a cheap SQLite lookup
and LLM-driven refresh stays off the hot path.
Storage¶
people_dossiers (
familiar_id TEXT NOT NULL,
canonical_key TEXT NOT NULL,
last_fact_id INTEGER NOT NULL,
dossier_text TEXT NOT NULL,
created_at TEXT NOT NULL,
PRIMARY KEY (familiar_id, canonical_key)
)
One row per person. last_fact_id is the watermark over facts.id
that the worker has already folded into dossier_text —
PeopleDossierWorker skips refresh when nothing in facts has moved
past it. Same shape as summaries.
Layer (read path)¶
PeopleDossierLayer walks the active channel's last window_size
turns newest-first. For each turn it appends the author's
canonical_key and any turn_mentions rows to an ordered candidate
list, deduping on first sight (most-recent occurrence wins). The
people list is truncated to max_people — same hard-count budgeting
style as RecentHistoryLayer.window_size. The familiar's own
ego:<id> subject always leads the candidate list
and is exempt from the max_people cap. Candidates without a stored
dossier are skipped silently; the worker fills them in within one tick.
The render is one Markdown block:
## People in this conversation
### Cass
@cass_login · she/her
Bio: Lover of pho.
Cass enjoys pho. Lives in Toronto.
### Aria
@aria_codes
Bio: Runs a bakery on Queen St.
Aria runs a bakery on Queen St.
Display names come from HistoryStore.resolve_label, so per-guild
nicknames win over snapshot labels — symmetric with the rest of the
read path. The optional second line carries @username and profile
pronouns (omitted when missing); the Bio: line is capped at 240
characters to keep the header lightweight. Profile fields flow in via
Author.from_discord_member (read defensively via getattr —
pronouns/bio aren't always populated on bot tokens) and are persisted
by HistoryStore.upsert_account. accounts.pronouns and
accounts.bio columns are added by an idempotent migration on
existing DBs.
Cache invalidation key: t<latest_id>:cap<n>:<key>:f<wm>,…. New turns
flip latest_id (changing the candidate set); a worker refresh flips
f<wm> for that key.
Self-dossier¶
The familiar is a subject too. Its own narrative — bits/performances,
choices, relational stances/feelings — is recorded under a reserved
ego:<familiar_id> canonical key (identity.ego_canonical_key /
identity.is_ego_key; the ego: platform can never collide with
discord: / twitch: keys). This gives the familiar a home for its
narrative instead of misfiling it under whichever person the bit was
about.
- Extractor.
FactExtractorinjects the self key + the familiar's display name into the participants manifest and a dedicated prompt clause, so the model tags its own actions/feelings with the self key. Self-capability statements stay dropped (see No self-capability statements) — the exception is narrative only. - Worker. An
ego:-keyed fact yields a dossier automatically (the worker already iterates every subject with facts).resolve_labelhas no account row for the self key, so the worker substitutes the familiar's display name for the dossier header. The self-record uses a distinct compaction prompt: it preserves settled opinions, stances, and feelings (the views the familiar holds consistently) and drops only momentary reactions — unlike the person-dossier prompt, which sheds transient feelings wholesale. The self-record also drops low-importance "texture" facts, orders the kept facts by importance (descending), and annotates each line with its score. The prompt tells the writer to weight higher-importance stances more heavily when space is tight, so durable core stances win the limited 3-5 sentences. This makes the self-dossier the substrate for consistently-forming opinions (feeds the planned sleep cycle). - Layer.
PeopleDossierLayertreats the self key as an always-present candidate (prepended, exempt from themax_peoplecap), so the self-dossier injects every assemble even when no one has spoken — unlike person dossiers, which are gated on channel activity.
The familiar's display name flows from Familiar.display_name (first
configured alias, else title-cased id) → ProjectorContext → the
extractor/worker, and directly into the layer at assembler build time.
The reserved key is a convention, not a stored row; the existing
mis-filed facts predating this are not migrated automatically.
Why a separate worker¶
Folding dossier refresh into FactExtractor would couple two unrelated
cadences (extracting new facts vs compounding per-person summaries)
and double the LLM cost on every batch. Splitting keeps each worker's
prompt narrowly scoped, lets the dossier worker tick on its own clock
(4× slower), and preserves the single-responsibility shape of the
worker family.
Discord identity, replies, and mentions¶
A Discord account exposes four name fields — id, username,
global_name, per-guild nick — plus message-level relations
(reference for replies, mentions for pings). The pipeline
navigates all of them so the bot can understand who's speaking to
whom and act by threading replies and pinging users deliberately.
Identity model¶
Two new tables sit alongside the existing turns snapshot:
accounts(canonical_key PK, platform, user_id, username, global_name, pronouns, bio, last_seen_at)— stable per-account row, last-write wins on identity columns; profile columns (pronouns,bio) preserve the prior non-NULL value viaCOALESCEso a profile-less re-observation doesn't clobber an earlier richer one. One row per(platform, user_id).account_guild_nicks(canonical_key, guild_id, nick, last_seen_at)— per-guild override, primary-keyed by both columns. NULLnickis meaningful: "we observed them with no override".
turns.author_* columns stay as a self-contained snapshot — the
historical receipt of what the bot saw at write time. The accounts
tables are the live identity cache. resolve_label walks them in
preference order:
account_guild_nicks.nickfor the active(canonical_key, guild_id)accounts.global_nameaccounts.username- The latest turn's
Author.label(snapshot fallback for pre-feature rows) - The bare
user_idportion ofcanonical_key
So the read path always shows the freshest per-guild display name even when the snapshot baked into older turns is stale.
Replies (read + write)¶
Each turn carries two new TEXT columns: platform_message_id (the
Discord snowflake) and reply_to_message_id (the parent snowflake
when discord.Message.reference was set). A
(familiar_id, platform_message_id) index makes parent lookup O(1).
- Read.
RecentHistoryLayerresolves each turn'sreply_to_message_idthroughHistoryStore.lookup_turn_by_platform_message_id. Render depth is adaptive: when the parent is already inside the same recent-history window, the child gets a short marker plus a ≤80-char snippet ([14:32 Alice ↩ Bob: parent…] child) — the full parent renders anyway. When the parent is outside the window, the child carries the full parent content (capped at ~400 chars) so the reply stays intelligible without scrolling. Unknown parent ids drop the marker silently. - Write — opt-in.
bot.send_textaccepts an optionalreply_to_message_id; when set, the post threads viadiscord.MessageReference(message_id=…, fail_if_not_exists=False). Threading is not the default: a normal reply just posts.TextResponderonly threads when the LLM deliberately asks by emitting a[↩](or[reply]) marker anywhere in its output. The marker is stripped before sending; its presence flipsreply_to_message_idfromNoneto the inbound message id. The bot reaches for this in busy channels where it isn't obvious which message it's responding to. The returned platform message id is stored on the assistant turn so future user replies to the bot can be linked back.
Reactions (read)¶
A message_reactions(familiar_id, platform_message_id, emoji, count,
updated_at) table mirrors live emoji counts on every message we care
about, keyed by the platform-native message id (so it can update
without touching the turns row). Population is gateway-driven — no
REST polling:
- Add / remove —
bot.on_raw_reaction_addandon_raw_reaction_removetranslate per-user toggles intoHistoryStore.bump_reaction(±1). The bump floors at zero so a stray remove (bot was offline when the original add fired) leaves no negative residue. Subscription-checked: only channels with/subscribe-textactive accumulate rows. - Clear —
on_raw_reaction_clear/on_raw_reaction_clear_emojiroute toHistoryStore.clear_reactions, scoped to the whole message or one emoji.
RecentHistoryLayer batch-fetches reactions for every
platform_message_id in its window with a single
reactions_for_messages call, then appends a
[reactions: 👍 x3 ❤️ x1] suffix on each rendered turn (user or
assistant — the bot reads its own reactions too). One SQL roundtrip
per assemble, ordered by descending count then emoji asc for stable
ties.
Embed unfurls (read)¶
URL previews arrive on a Discord message as message.embeds —
sometimes pre-attached on on_message, more often via a follow-up
on_message_edit once Discord finishes unfurling (typical lag 1–2 s).
The bot flattens these into the message's stored content so the LLM
sees the same body humans see in the client.
- Formatter —
familiar_connect.sources.discord_embed_text.format_embedsis duck-typed overdiscord.Embed(any object withtitle,description,author,provider,fields,footer,urlattributes works). Each rendered embed is tagged[embed]so the LLM can tell unfurl content apart from typed text; multi-embed messages join with a blank line. Image-only embeds fall back to[link: <url>]when there's no other text; attribute-less embeds drop entirely. - Inbound (
on_message) —bot.compose_content_with_embedsappends formatted embed text tomessage.contentbefore the source publishes onto the bus. Most messages arrive withembeds == []here; the merge is a no-op. - Edit (
on_message_edit) —bot.apply_message_editre-runs the merge once embeds appear and rewritesturns.contentfor the originalplatform_message_idviaHistoryStore.update_turn_content_by_message_id. Theturns_au_ftstrigger keeps the FTS index in sync; reactions / replies stay attached because the row id never changes. Pure text edits aren't tracked — the handler only fires when the embed list changes.
The bot's first reply to a URL-bearing message often races the unfurl and posts before the embed lands; subsequent prompts assemble recent history from the updated row and see the unfurled text. Bot- authored edits skip — the responder owns its own turn writes.
Mentions (read + write)¶
A turn_mentions(turn_id, canonical_key) junction table records who
is salient in each turn. Two writers populate it:
- Discord pings — on intake,
bot.on_messagereadsmessage.mentions, the source publishes them asAuthorobjects in the event payload, andTextResponderupserts each one intoaccounts(keeping the identity cache fresh) and inserts theturn_mentionsrows. - Fact-extractor subjects — when
FactExtractorresolves a fact'ssubject_keysagainst the participants manifest, it mirrors the canonical keys intoturn_mentionsfor each of the fact'ssource_turn_ids. This bridges bare-text references ("what about Aria?") that never raised a Discord ping but that the LLM successfully linked to a known canonical key. Inserts are PK-deduped, so a turn both pinged and fact-extracted ends up with the union of keys.
Downstream, PeopleDossierLayer reads mentions_for_turn and treats
every recorded canonical key as a candidate for dossier inclusion —
the layer doesn't care which writer added the row.
In rendered prompts, Discord's raw <@USER_ID> markers in turn
content are rewritten to [@DisplayName] via resolve_label —
symmetric with the form the LLM is asked to emit on output.
Bot-emitted pings¶
A short, channel-agnostic addendum is appended to the system prompt on every text reply:
## Output controls
- Ping a user by writing `[@DisplayName]` using a name that
appears in recent messages. Unrecognised names render as
plain text without pinging.
- Optionally prefix your message with `[↩]` to thread it as a
reply to the message you're responding to. …
- To reply to a *specific* earlier message, write
`[↩ <message_id>]` using the `#<id>` shown next to that message
in recent history. Unknown ids fall back to the triggering
message id.
RecentHistoryLayer surfaces platform_message_id next to each
turn's speaker ([14:32 Alice #1234567890] hi) when present, so
the model can target a specific earlier message via
[↩ 1234567890]. The marker parser captures the optional id and
TextResponder validates it against
HistoryStore.lookup_turn_by_platform_message_id; unknown ids
silently degrade to threading on the inbound message.
Final reminder¶
Every system prompt closes with a small block restating current time
(YYYY-MM-DD H:MMpm UTC) and the literal sentinels the responder
honours. Rebuilt per-call (cheap), so the model never sees a stale
clock — useful when the prompt cache lives across long-tailed turns.
Voice channels see only <silent>; text channels also list
[@DisplayName] and [↩ <message_id>]. Source:
src/familiar_connect/context/final_reminder.py.
When a FocusManager is wired, the block also carries a prose
focus + unread digest line built from focus_channel_id,
unread_digest ({channel_id: staged_count}, counts > 0 only), and
channel_names:
Your attention is currently on #general. There is a new message in
other-channel — use shift_focus if it pulls your attention.¶
The focus clause names the active channel; the unread clause lists
channels with staged (unconsumed) turns and nudges toward the
shift_focus tool. Counts > 1 render as #channel (N). Both clauses
omit when their source is empty. This is the model-facing surface of
the attentional stream (see below).
Both responders also append a second copy of the same block as a
trailing system message, after recent history, with
include_mode_instruction=True. This appends the per-mode operating
directive ("You are speaking aloud. Keep replies short (one or two
sentences). Avoid markdown." for voice; the text-channel equivalent
for text) to the tail copy. The directive is still set up-front by
OperatingModeLayer — the trailing copy is recency insurance: long
contexts make models drift away from format gates buried at the top
of the system prompt, and a final-position reminder is the cheapest
fix.
The trailing copy also carries the per-familiar post-history
instructions ([prompt].post_history_instructions), appended last
— the deepest position in the context, right before the model's next
turn, where behavioral nudges land hardest. It is rendered verbatim
(markdown fine) and only in the trailing copy, never the up-front one,
so "post-history" stays literal. Empty string omits it. The shipped
default is a short roleplay-etiquette note steering the familiar
toward <silent> so it doesn't over-talk on voice. Source of the
default: data/familiars/_default/character.toml [prompt].
There is no per-channel enumeration of pingable users. The LLM
grounds on the names already visible in recent history (where
<@USER_ID> markers were rewritten to [@DisplayName] on intake);
the responder then tries to resolve whatever the model emits.
Resolution uses a label → canonical_key map built from
recent_distinct_authors + resolve_label — enough to pin the right
user when guild nicknames keep names unambiguous, and to silently drop
anything else.
Known markers become <@user_id> and contribute to
AllowedMentions(users=…). Unknown markers degrade to plain
@DisplayName (no ping). send_text always passes
discord.AllowedMentions(everyone=False, roles=False, users=[…])
restricted to the resolved ids — even if the LLM smuggles a raw
<@123> for someone outside the active set, Discord won't deliver
the notification.
Ambiguous labels (two participants sharing a display name in the same channel) keep first-write and log a warning. Guild nicknames usually prevent this; the AllowedMentions guard makes any misping recoverable.
What we deliberately don't do¶
- No
@everyone/ role-mention support on the bot's output side. Discord's bot/role permissions are the gate. - No mention-of-bot / addressivity heuristics. The decision of when the bot speaks is unchanged by this work; only how.
- No backfill of legacy turns or facts. Legacy rows just don't
participate in reply lookups (orphan markers drop silently) and
fall back to their
author_*snapshot for label resolution.
RAG retrieval quality¶
RagContextLayer runs the inbound user turn through the tantivy
indexes (fts/turns/, fts/facts/). Two policies keep recall
acceptable on free-text chat cues:
- Disjunctive parse + English analyzer. Tantivy's query parser
defaults to OR semantics, so multi-token chat cues match on any
substantive term ("hey, do you know about cat toys?" still hits a
6-word fact via
cat/toy). The analyzer chain — lowercase, ascii-fold (socaféandcafematch), custom stopword filter (same ~90-word English list the old_FTS_STOPWORDScarried), english stemmer (sofox/foxesshare a stem) — is applied at both index and query time. Seesrc/familiar_connect/history/fts.py. - Recent-window exclusion. The user turn that seeded the cue is,
by construction, the highest-BM25 match against itself — and it's
already shown verbatim by
RecentHistoryLayer. RAG passesmax_id = latest_in_channel - recent_window_sizetosearch_turns, scoping retrieval to turns older than the recent-history window.recent_window_sizeis wired incommands/run.pyto the same value asRecentHistoryLayer.
Both surface as RagContextLayer constructor parameters
(recent_window_size defaulting to 0 for tests / callers that don't
opt in; production wiring sets it).
Rendering. Retrieved hits are no longer flat - [Alice] text
lines — each hit pulls id ± context_window neighbours from the
same channel (default 1, dropping any neighbour the recent-history
window already shows) and the result is grouped by UTC date:
## Possibly relevant earlier turns
2026-05-03:
> [2:29PM Peebo]: i can't understand what you guys are saying
> [2:30PM Peebo]: my brain's dying
> [2:33PM Cassidy]: Dude maybe you should take a break
Date headers, 12-hour clock, and the surrounding turns make each hit interpretable on its own — the model doesn't have to guess when or in what tone the line landed.
Open work the current retrieval doesn't address:
- Embeddings would beat keyword matching on semantic recall (e.g., cue "What did Aria order at lunch?" → fact "Aria likes pho"). Out of scope for the present pipeline.
- Cue extraction — using the raw user turn as the cue is noisy. Pulling named entities or topic words out of the turn first would improve precision without embeddings.
Cold-cache signals (research-phase)¶
familiar_connect.diagnostics.cold_cache provides three detectors:
detect_topic_shift— Jaccard overlap between the new turn's content words and the focus-stream rolling summary (read at the sentinel key); fires below 0.15. Skipped when the new turn has fewer thanmin_tokens(default 4) content tokens, since short voice fragments would otherwise fire on every utterance regardless of topic continuity.detect_unknown_proper_noun— capitalized tokens (3+ chars) in the new turn that don't appear in prior context. A small built-in stopword list filters common sentence-starters (Which,But,Okay,Yeah, …) so the signal isn't dominated by discourse markers from voice transcripts.detect_silence_gap— wall-clock gap abovethreshold_seconds(default 300 s).
log_signals() runs all three and emits one ColdCache log line per
firing signal. Currently instrumentation only — no cache is
invalidated on a signal. After collecting a corpus of (signal-fired,
retrieval-failed) pairs, the most-predictive signals will be wired to
force rebuilds of stale layers.
Single-writer pattern¶
Each responder owns user-turn writes for its own topic. TextResponder
appends the user turn (from a discord.text event) before calling
Assembler.assemble, and VoiceResponder does the same for voice
finals. Single-writer-in-the-same-task gives RecentHistoryLayer
read-after-write consistency: the new turn lands in SQLite before
the LLM prompt is built, so the model always sees the message it's
being asked to respond to. A separate writer task (e.g. an earlier
HistoryWriter design) would race the responder and produce stale
prompts.
HistoryWriter (processors/history_writer.py) is kept as a
reference implementation of the single-writer + dedup pattern, but is
no longer wired into the run loop.
Multi-party addressivity¶
Every channel the familiar joins is multi-party — humans talk to each other, not just to the bot. Two pieces collaborate to handle "is this turn for me?" without a separate gating LLM call:
- Message format carries speaker + time.
RecentHistoryLayerrenders user turns as[HH:MM Display Name #channel_id] content(UTC). Different speakers get different prefixes; the rhythm of timestamps tells the model whether a conversation is flowing between humans. The#channel_iddisambiguates source once the cross-channel window mixes multiple channels. - Silent sentinel in the reply. The system prompt instructs the
model to emit the literal token
<silent>as its entire reply when the latest message isn't for it.SilentDetector(familiar_connect.silence) inspects the streaming reply delta-by-delta; on a prefix match it short-circuits the stream, the responder skips Discord posting / TTS, and no assistant turn is appended. The user turn is still recorded — observation is not gated by response.
The sentinel is best-effort: it relies on the model following the
system-prompt instruction. A stray <silent> mid-reply is treated as
content (prefix-only match); the decision latches once made and
subsequent deltas don't re-open it.
Under tool calling the same decision is also reachable as a tool: the
silent(reasoning) tool returns a sentinel that makes agentic_loop
return AgenticResult(is_silent=True) without re-prompting, and the
responder bails exactly as it does on the <silent> text token. The
two coexist — <silent> gates the bare streaming path, silent()
gates the agentic path.
Attentional stream¶
Earlier designs gave each channel its own recent-history window and stitched other channels in through per-channel cross-channel summaries. The attentional stream replaces that with a single focus model: the familiar attends to one text channel and one voice channel at a time, and only the focused channel's traffic flows through the normal reply loop. Everything else is staged — stored but not yet consumed — until the model deliberately shifts focus.
Turn lifecycle (staged → consumed / missed)¶
Three turns columns drive it:
arrived_at— immutable ingest timestamp.consumed_at—NULLwhile staged; set when the turn enters the familiar's attention.recent_cross_channelreturns consumed turns only, ordered byarrived_at, id.missed_at— terminal "she never saw it" state, set at promotion when a staged turn falls outside the catch-up window. It keepsconsumed_atNULL, so everyconsumed_at IS NOT NULLread path (visible window + rolling focus-stream summary) excludes it, andcount_staged/staged_channelsaddAND missed_at IS NULLso it no longer counts as pending. This is what lets the familiar genuinely miss messages instead of silently absorbing a whole backlog.
TextResponder checks FocusManager.is_focused(channel_id) per
inbound message:
- Focused channel — the user turn is appended with
consumed=True, the normal reply loop runs (assemble → stream → post), andFocusManager.end_turn()fires afterward (idle-clock bookkeeping only). - Unfocused channel — the user turn is appended with
consumed=False(staged), a[📥 Staged]line is logged, and the responder returns early: no assembly, no LLM call, no reply. The message surfaces only as an unread digest entry on the next focused turn.
VoiceResponder calls end_turn() after each completed voice turn
too.
FocusManager¶
familiar_connect.focus.FocusManager holds two independent pointers
(text_focus, voice_focus), each guarded by its own
asyncio.Lock. Shifts are model-decided and applied immediately:
- The
shift_focus(channel_id)tool first guards the target: ifchannel_idis not in theSubscriptionRegistrythe shift is rejected and the tool returns anavailable_channelslist (every subscribed channel_id + label) so the model can retry a live target instead of stranding attention on a dead channel. Valid targets callshift_now— modality (text/voice) inferred from the registry. The tool also eagerly fetches the target channel's recent turns (the[focus].catch_up_limitpreview, default 20) and returns them in the tool result, so the agentic loop feeds the channel's content back into the same turn — the model sees the channel before it responds rather than narrating one it can't see. Voice/empty channels yield an empty list. shift_nowapplies the move at tool-call time, under the per-modality lock. For a text shift it callspromote_staged_turns(channel_id, catch_up_limit)— flipping only the lastcatch_up_limitstaged turns (the preview she actually saw), plus any that @-mention her, to consumed (consumed_at = now) so they interleave into the next cross-channel window, while older staged backlog is markedmissed_atand dropped (perception matches consumption) — then moves the pointer, persists both pointers viaset_focus_pointers, and fireson_shift(presence). Because the move is immediate, any reply later in the same turn posts to the new channel (the responder sends to the current text focus), and a turn that goes silent still leaves her where she went — there is no deferred state to leak into a later turn. (Earlier designs deferred the shift toend_turn; an uncommitted deferral could leak and misroute the next turn's reply — e.g. a#generalreply posting into#media. Immediate application removes that bug class.)
Because shift_focus applies for real, it is not a way to peek: it
moves her off her current channel until she shifts back. The unread
digest (and the unread nudge) is the mechanism for noticing
other channels without leaving.
Pointers persist in the focus_pointers table
(familiar_id PK, text_channel_id, voice_channel_id, updated_at); on
startup initialize() loads them, dropping any pointer whose channel
is no longer subscribed (a since-removed subscription would otherwise
strand focus on a dead channel), then falling back to the first text and
first voice subscription as defaults (set_focus_immediately). The
channel_names map (channel_id → display name) is populated from
Discord on on_ready purely for readable logs and the unread digest.
Logs: [Focus] loaded/default on init, [🔀 Focus] text=… promoted=N
missed=N on a text shift, [👁️ Focus] once names are known on ready.
Unread nudge¶
Staging assumes a next focused turn will surface the unread digest —
but a staged arrival shouldn't have to wait for unrelated traffic on
the focused channel before it's noticed. FocusManager closes that
gap: it exposes should_wake(channel_id) — true when the arriving
channel is unfocused, unread_nudge_enabled is set (default on), and
no nudge is already pending within nudge_debounce_seconds. The
arrival itself trips it; there is no idle-silence requirement. When a
staged arrival trips should_wake, the text responder publishes a
synthetic discord.text wake event (wake: True) routed at the
focused channel. That event earns the model one focused turn — it
sees the unread digest and can choose to shift_focus — but the nudge
never moves focus itself; only the model's shift_focus does. Wake
events skip the user-turn persist (no transcript pollution) and
mark_nudge_pending() dedupes arrival bursts for the
nudge_debounce_seconds window. Logs [⏰ Nudge].
Unread digest¶
staged_channels(familiar_id) returns {channel_id: staged_count}
for channels with unconsumed turns. The text responder passes it to
the final reminder as unread_digest, so the
model sees a prose nudge naming channels with pending messages and the
shift_focus tool. /diagnostics renders the same counts
(Unreads: #<id> (<count>), …) alongside the live focus pointers
(Focus: text=#<id> voice=#<id>).
read_channel tool¶
read_channel(limit?, before_id?) is a read-only peek into the
focused text channel's history (capped at 50 turns). It does
not touch consumed_at — the familiar can inspect a channel's
recent traffic without committing to a focus shift. Voice focus isn't
supported. Logs [📖 read_channel].
Data flow per user turn¶
Discord text on channel C
→ DiscordTextSource publishes discord.text
→ TextResponder:
focused = FocusManager.is_focused(C) (True when no FocusManager)
appends user turn to `turns` with consumed=focused
(fts_turns trigger fires; row indexed)
if not focused: log [📥 Staged]; return (no assembly, no LLM, no reply)
seeds RagContextLayer cue = content
Assembler.assemble(ctx, viewer_mode="text")
LLMClient.chat_stream (cancellable via scope; SilentDetector watches deltas)
(shift_focus, if called, already moved focus + promoted staged)
if `<silent>` detected: bail (no send, no assistant turn)
else: BotHandle.send_text(current text focus, reply); append assistant turn
router.end_turn(scope)
FocusManager.end_turn() (idle-clock bookkeeping only)
Voice transcript final on channel C (voice:C)
→ VoiceSource publishes voice.transcript.final
→ VoiceResponder:
logs cold-cache signals (prior summary vs new text, silence gap)
appends user turn directly
seeds RagContextLayer cue = text
Assembler.assemble(ctx)
→ cached CharacterCard / OperatingMode
→ ConversationSummaryLayer: read focus-stream summary (sentinel)
→ PeopleDossierLayer: read from people_dossiers, capped at max_people
→ RagContextLayer: FTS search on cue
→ RecentHistoryLayer: last N consumed turns across all channels
LLMClient.chat_stream (cancellable via scope; SilentDetector watches deltas)
if `<silent>` detected: bail (no TTS, no assistant turn)
else: TTSPlayer.speak; append assistant turn
router.end_turn(scope); FocusManager.end_turn()
Background: SummaryWorker tick (every 5 s)
→ maybe regenerate focus-stream summary (consumed cross-channel stream)
Background: PeopleDossierWorker tick (every 20 s)
→ subjects_with_facts(familiar_id) → {canonical_key: max_fact_id}
→ for each subject whose watermark moved: compound prior dossier + new facts
Configuration¶
Per-channel [channels.<id>] overrides, worker cadences
([providers.memory.<name>]), and the per-tier budget caps that size
each layer are the operator surface for this pipeline. All live in
Tuning — see
History / context layers,
Per-channel overrides, and
Memory projectors — worker tuning.