Tell the summarizer who the characters are

`_create_due_memories` sent six actions of second-person prose and
nothing else — no protagonist, no cast, no setting, and no instruction
about what person to write in. So for `You push the door open. She grabs
your arm.` the only honest memory was "You entered a room and she
stopped you", which names nobody when it is retrieved forty turns later.
The framing wandered too: with no rule, the model picked a person per
call, and one bank ended up holding "You entered the crypt", "The player
entered the crypt" and "He entered the crypt" for the same kind of
event.

Both prompts now carry a cast brief and a framing rule: third person,
the protagonist by name, other characters named rather than left as bare
pronouns. The rule states its reason, because a memory really is read in
isolation much later and a model told why complies far more
consistently than one handed a bare instruction.

The cast comes from the story cards, not from `stat_schema`. Every
schema NPC is already turned into a card at adventure creation,
deduplicated against the hand-written ones by name, so the cards cover
schema NPCs, an author's own cards, and an adventure with no RPG layer
at all through one path instead of three.

Keyword matching alone was not enough, and finding that out changed the
design. Built that way first, the brief for "She grabs your arm" listed
the protagonist and nobody else: the block that most needs a cast is
exactly the one written in bare pronouns, and Gwen's trigger keys
include "her" but the text says "she". So matched cards come first and
the remaining slots are filled with the other character cards. Places
and items are not topped up — an unmentioned tavern is not who "she"
was — though a place that is mentioned still matches normally. The
asymmetry with the turn prompt is deliberate: an untriggered card is
wrong as lore and right in a roster, because the roster answers "who
could these pronouns be" rather than "what is on stage".

Fixed descriptions only, never live values. `Gwen: trust 40 (wary)` in
the brief would make the same event summarized at two different times
come out framed differently, which is the fault this removes.

An adventure with no persona still gets the cast and the setting, and
the model is told to write "the player". One with nothing to say sends
byte for byte the prompt it sent before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NPyQN926gkZTAYfgugcaok
This commit is contained in:
Claude
2026-08-31 15:03:20 +05:30
committed by Parth
parent dfb56d4569
commit 1b5e4c56cd
5 changed files with 499 additions and 28 deletions
+8 -1
View File
@@ -1,11 +1,18 @@
from . import history from . import history
from .builder import build_context, count_tokens, render_persona, truncate_to_last_tokens from .builder import (
build_context,
count_tokens,
match_cards,
render_persona,
truncate_to_last_tokens,
)
from .history import story_actions from .history import story_actions
__all__ = [ __all__ = [
"build_context", "build_context",
"count_tokens", "count_tokens",
"history", "history",
"match_cards",
"render_persona", "render_persona",
"story_actions", "story_actions",
"truncate_to_last_tokens", "truncate_to_last_tokens",
+6 -2
View File
@@ -209,11 +209,15 @@ def _visible_npcs(actions: list[models.Action], stat_schema: dict) -> dict[str,
return visible return visible
def _match_cards(cards: list[models.StoryCard], window_text: str) -> list[dict]: def match_cards(cards: list[models.StoryCard], window_text: str) -> list[dict]:
"""Returns one record per matched story card, naming the keyword that matched. """Returns one record per matched story card, naming the keyword that matched.
Matching follows AI Dungeon's rules. It ignores case, respects spaces, and Matching follows AI Dungeon's rules. It ignores case, respects spaces, and
matches partial words, so "boat" matches "boats". matches partial words, so "boat" matches "boats".
Public since Phase 18b: `memorybank.cast_brief` runs the same rule over the
block it is about to summarize, so that the summarizer is told who the
characters in that stretch of story are. One rule, one implementation.
""" """
haystack = window_text.lower() haystack = window_text.lower()
matched = [] matched = []
@@ -352,7 +356,7 @@ def build_context(
# ----- Story cards: triggered by recent story text (the window history could fill) ----- # ----- Story cards: triggered by recent story text (the window history could fill) -----
trigger_window = truncate_to_last_tokens(SEPARATOR.join(a.text for a in actions), available) trigger_window = truncate_to_last_tokens(SEPARATOR.join(a.text for a in actions), available)
triggered = _match_cards(adventure.story_cards, trigger_window) triggered = match_cards(adventure.story_cards, trigger_window)
card_budget = int(available * CARD_BUDGET_SHARE) card_budget = int(available * CARD_BUDGET_SHARE)
card_records = [] card_records = []
+144 -4
View File
@@ -33,7 +33,14 @@ from sqlalchemy import func, select, update
from sqlalchemy.orm import Session, object_session from sqlalchemy.orm import Session, object_session
from . import models, tree, vectors from . import models, tree, vectors
from .context import cursors, history, lineage, story_actions, truncate_to_last_tokens from .context import (
cursors,
history,
lineage,
match_cards,
story_actions,
truncate_to_last_tokens,
)
from .database import SessionLocal from .database import SessionLocal
from .providers import OpenAICompatibleProvider, ProviderError from .providers import OpenAICompatibleProvider, ProviderError
from .vectors import cosine # re-exported: the ranking lives here, the maths there from .vectors import cosine # re-exported: the ranking lives here, the maths there
@@ -47,16 +54,46 @@ RETRIEVAL_WINDOW_TOKENS = 600 # recent story text used as the similarity query
RETRIEVAL_WINDOW_ACTIONS = 4 # ...taken from this many of the newest actions RETRIEVAL_WINDOW_ACTIONS = 4 # ...taken from this many of the newest actions
SUMMARY_MAX_WORDS = 250 SUMMARY_MAX_WORDS = 250
# ---- The cast brief (Phase 18b) ----
# How many characters the brief names, and how much of each description it
# carries. The cast is authored content rather than generated, so it is small in
# practice; these are ceilings against a scenario with a very large cast, not a
# budget anyone is expected to reach.
MAX_CAST_MEMBERS = 8
CAST_ENTRY_CHARS = 240
SETTING_TOKENS = 300 # of `adventure.memory`, the plot essentials
# The framing rule is the larger half of this prompt, and it is worth the
# tokens. Without it the model chooses a person per call, so one bank ends up
# holding "You entered the crypt", "The player entered the crypt" and "He
# entered the crypt" for the same kind of event.
#
# The reason is stated, not just the rule. A memory is retrieved in isolation
# months of story later, and a model told why bare pronouns fail complies far
# more consistently than one handed a bare instruction.
#
# The protagonist's name lives in the user message, in the Cast, rather than
# here. That keeps this prompt constant across every adventure and every call.
MEMORY_SYSTEM_PROMPT = ( MEMORY_SYSTEM_PROMPT = (
"You compress interactive-fiction story excerpts into memories. Respond with " "You compress interactive-fiction story excerpts into memories. Respond with "
"1-2 plain sentences in past tense stating the concrete facts and events " "1-2 plain sentences in past tense stating the concrete facts and events "
"(names, places, items, promises, injuries). No preamble, no commentary." "(names, places, items, promises, injuries).\n\n"
"Write in the third person. The excerpt is written in the second person: "
'"you" is the protagonist, who is named in the Cast. Refer to the '
'protagonist by that name, never as "you". If the Cast gives no name for '
'them, call them "the player". Name the other characters too rather than '
'writing "he", "she" or "they" on their own — this memory will be read on '
"its own, much later, with nothing around it to say who a pronoun meant.\n\n"
"No preamble, no commentary."
) )
SUMMARY_SYSTEM_PROMPT = ( SUMMARY_SYSTEM_PROMPT = (
"You maintain the running summary of an interactive-fiction story. Respond " "You maintain the running summary of an interactive-fiction story. Respond "
"with only the updated summary: a single plain-prose overview of the plot " "with only the updated summary: a single plain-prose overview of the plot "
f"so far, at most {SUMMARY_MAX_WORDS} words. Preserve important established " f"so far, at most {SUMMARY_MAX_WORDS} words. Preserve important established "
"facts; compress older events harder than recent ones." "facts; compress older events harder than recent ones.\n\n"
"Write in the third person, and name the characters. Refer to the "
'protagonist by the name given in the Cast, or as "the player" if the Cast '
'gives no name. Never address them as "you".'
) )
# Adventures with a post-turn task currently running (single-process app). # Adventures with a post-turn task currently running (single-process app).
@@ -224,6 +261,97 @@ def forget_node(db: Session, adventure: models.Adventure, action: models.Action)
return len(doomed) return len(doomed)
# ---------- The cast brief ----------
def _cast_line(name: str, entry: str, *, protagonist: bool = False) -> str:
"""One roster line: who they are, and nothing about where they stand now."""
who = f"{name} — the protagonist" if protagonist else f"{name} —"
entry = " ".join(entry.split()) # collapse newlines: this is a one-line roster
if len(entry) > CAST_ENTRY_CHARS:
entry = entry[:CAST_ENTRY_CHARS].rsplit(" ", 1)[0] + "…"
if protagonist:
return f"- {who}." if not entry else f"- {who}. {entry}"
return f"- {name} — {entry}" if entry else f"- {name}"
def cast_brief(adventure: models.Adventure, text: str) -> str:
"""Returns who appears in `text`, and what the story is about.
This is the context the summarizer never had. It was handed six actions of
second-person prose and nothing else, so the only honest memory it could
write for `You push the door open. She grabs your arm.` was "You entered a
room and she stopped you." — which, retrieved forty turns later, names
nobody.
Three rules hold this together.
**Fixed descriptions only, never live values.** It is tempting to add
`Gwen: trust 40 (wary)`. That would make the same event summarized at two
different times come out framed differently, which is the fault this whole
change exists to remove.
**The cast comes from the story cards, not from `stat_schema`.** Every NPC a
scenario defines is already turned into a story card at adventure creation
(`scenario_text.scenario_card_specs`), deduplicated against the hand-written
ones by name. Reading the cards therefore covers the schema NPCs, the
author's own cards, and an adventure with no RPG layer at all, through one
path instead of three.
**Keyword matching alone is not enough here, which is why the roster is
topped up.** The turn prompt includes a card only when its trigger words
appear, and that is right for lore: a card nobody mentioned is not relevant
to the next sentence. It is wrong for this brief. The block that most needs
a cast is exactly the one written in bare pronouns — "she grabs your arm"
matches no keyword, and the summarizer is then left guessing at precisely
the moment it was given this brief to stop guessing. So matched cards come
first, and any remaining slots are filled with the other **character**
cards. Places and items are not topped up: an unmentioned tavern is not
who "she" was.
Walking `adventure.story_cards` is a relationship load, which this module
otherwise avoids. It is affordable here for two reasons the memory bank's
own reads were not: a card is five short text columns with no vector, and
this runs once per `MEMORY_INTERVAL` actions in the background task rather
than on every turn. `build_context` already walks the same collection.
"""
lines: list[str] = []
name = adventure.persona_name.strip()
pronouns = adventure.persona_pronouns.strip()
if name or adventure.persona_desc.strip():
who = f"{name} ({pronouns})" if name and pronouns else (name or "The player")
lines.append(_cast_line(who, adventure.persona_desc.strip(), protagonist=True))
seen = {name.lower()} if name else set()
def add(card_name: str, entry: str) -> bool:
"""Adds one roster line. Returns False once the roster is full."""
card_name = (card_name or "").strip()
if card_name and card_name.lower() not in seen:
seen.add(card_name.lower())
lines.append(_cast_line(card_name, (entry or "").strip()))
return len(lines) < MAX_CAST_MEMBERS
room = True
for card in match_cards(adventure.story_cards, text):
room = add(card["name"], card["entry"])
if not room:
break
if room:
for card in adventure.story_cards:
if (card.type or "").strip().lower() != "character":
continue
if not add(card.name, card.entry):
break
parts = []
if lines:
parts.append("Cast:\n" + "\n".join(lines))
setting = adventure.memory.strip()
if setting:
parts.append("Setting:\n" + truncate_to_last_tokens(setting, SETTING_TOKENS))
return "\n\n".join(parts)
# ---------- Retrieval (runs inside the turn, before build_context) ---------- # ---------- Retrieval (runs inside the turn, before build_context) ----------
async def retrieve_memories( async def retrieve_memories(
@@ -411,9 +539,14 @@ async def _create_due_memories(
if len(block) < MEMORY_INTERVAL: if len(block) < MEMORY_INTERVAL:
return return
excerpt = truncate_to_last_tokens("\n\n".join(a.text for a in block), 2000) excerpt = truncate_to_last_tokens("\n\n".join(a.text for a in block), 2000)
# Match the cast against the untruncated block. The excerpt is what the
# model reads, but a character named in the part that was trimmed is
# still one the memory may have to name.
brief = cast_brief(adventure, "\n\n".join(a.text for a in block))
prompt = f"Story excerpt:\n\n{excerpt}\n\nMemory:"
try: try:
text = await provider.complete( text = await provider.complete(
MEMORY_SYSTEM_PROMPT, f"Story excerpt:\n\n{excerpt}\n\nMemory:" MEMORY_SYSTEM_PROMPT, f"{brief}\n\n{prompt}" if brief else prompt
) )
except ProviderError: except ProviderError:
return # Logged on the debug page. The cursor is unchanged, so the return # Logged on the debug page. The cursor is unchanged, so the
@@ -473,11 +606,18 @@ async def _update_story_summary(
events_text = truncate_to_last_tokens("\n\n".join(a.text for a in block), 2000) events_text = truncate_to_last_tokens("\n\n".join(a.text for a in block), 2000)
current = adventure.story_summary.strip() current = adventure.story_summary.strip()
# The summary is built from the memories, so it inherits their framing for
# free once they are named and third-person. It still gets the brief of its
# own, because the fallback above hands it raw second-person story text
# whenever memory creation has fallen behind.
brief = cast_brief(adventure, f"{current}\n\n{events_text}")
user_prompt = ( user_prompt = (
f"Current story summary:\n{current or '(none yet)'}\n\n" f"Current story summary:\n{current or '(none yet)'}\n\n"
f"New events since the last update:\n{events_text}\n\n" f"New events since the last update:\n{events_text}\n\n"
"Updated summary:" "Updated summary:"
) )
if brief:
user_prompt = f"{brief}\n\n{user_prompt}"
try: try:
text = await summary_provider(settings).complete( text = await summary_provider(settings).complete(
SUMMARY_SYSTEM_PROMPT, user_prompt, max_tokens=600 SUMMARY_SYSTEM_PROMPT, user_prompt, max_tokens=600
+234
View File
@@ -0,0 +1,234 @@
"""Phase 18b: what the summarizer is told about who the characters are.
Before this, `_create_due_memories` sent six actions of second-person prose and
nothing else. The model had no way to know who "you" was or who "she" was, so
neither did any memory it wrote, and the summary built from those memories
inherited the problem.
The rules this file holds in place:
* The brief carries fixed descriptions only. A live stat in it would make the
same event, summarized twice, come out framed differently — the exact fault
the change exists to remove.
* Places and items are not topped up into the roster. Only characters are.
* An adventure with no persona and no cards still summarizes, with the prompt
it had before.
python -m pytest tests/test_cast_brief.py -v
"""
import asyncio
import pytest
from app import memorybank, models
from app.database import Base, SessionLocal, engine
GWEN = "A loyal ranger and the player's ally. Quick with a bow, fiercely protective."
LEADER = "Scarred, patient, and the one holding the strongbox key."
TAVERN = "A tavern three days south of the camp."
class StubSummarizer:
"""Records every (system, user) pair handed to the summarizer."""
def __init__(self):
self.calls: list[tuple[str, str]] = []
async def complete(self, system, user, **kwargs):
self.calls.append((system, user))
return f"Memory {len(self.calls)}."
@pytest.fixture()
def db():
Base.metadata.create_all(bind=engine)
session = SessionLocal()
try:
yield session
finally:
session.close()
Base.metadata.drop_all(bind=engine)
def make_adventure(db, *, actions=0, persona=True, cards=True, memory=True):
user = models.User(is_guest=False, email="cast@example.com")
db.add(user)
db.flush()
db.add(models.Settings(user_id=user.id, api_key="enc:dummy", model="test-model"))
adventure = models.Adventure(
user_id=user.id, title="Camp", script_state={}, auto_summarize=True,
memory=("The player and Gwen are raiding a bandit camp to recover a "
"stolen strongbox.") if memory else "",
persona_name="Kaelen" if persona else "",
persona_pronouns="he/him" if persona else "",
persona_desc="A half-elf ranger, exiled from the northern holds." if persona else "",
)
db.add(adventure)
db.flush()
if cards:
db.add(models.StoryCard(adventure_id=adventure.id, name="Gwen",
keys="Gwen, ranger, her", type="character", entry=GWEN))
db.add(models.StoryCard(adventure_id=adventure.id, name="Bandit Leader",
keys="Bandit Leader, leader", type="character", entry=LEADER))
db.add(models.StoryCard(adventure_id=adventure.id, name="The Rusted Tankard",
keys="tankard, tavern", type="location", entry=TAVERN))
for i in range(actions):
db.add(models.Action(adventure_id=adventure.id,
type="ai" if i % 2 else "do",
text=f"You walk on. Action {i}."))
db.commit()
db.refresh(adventure)
return adventure
# ------------------------------------------------------------ the brief itself
def test_the_protagonist_is_named_and_marked_as_such(db):
adventure = make_adventure(db)
brief = memorybank.cast_brief(adventure, "You walk into the camp.")
assert "- Kaelen (he/him) — the protagonist." in brief
assert "A half-elf ranger" in brief
def test_a_named_character_in_the_text_is_in_the_roster(db):
adventure = make_adventure(db)
brief = memorybank.cast_brief(adventure, "Gwen checks her bowstring.")
assert "- Gwen — " in brief and "Quick with a bow" in brief
def test_a_character_referred_to_only_by_pronoun_is_still_in_the_roster(db):
"""The block that most needs a cast is the one written in bare pronouns.
Keyword matching alone finds nothing here, so the roster is topped up."""
adventure = make_adventure(db)
brief = memorybank.cast_brief(adventure, "She grabs your arm before you step through.")
assert "- Gwen — " in brief
assert "- Bandit Leader — " in brief
def test_places_are_not_topped_up(db):
"""An unmentioned tavern is not who "she" was."""
adventure = make_adventure(db)
brief = memorybank.cast_brief(adventure, "She grabs your arm.")
assert "Rusted Tankard" not in brief
def test_a_place_that_is_mentioned_does_appear(db):
"""Topping up is limited to characters. Matching is not."""
adventure = make_adventure(db)
brief = memorybank.cast_brief(adventure, "You push into the tavern, breathless.")
assert "The Rusted Tankard" in brief
def test_the_setting_is_the_plot_essentials(db):
adventure = make_adventure(db)
brief = memorybank.cast_brief(adventure, "You walk on.")
assert "Setting:\nThe player and Gwen are raiding a bandit camp" in brief
def test_no_persona_and_no_cards_gives_no_brief(db):
adventure = make_adventure(db, persona=False, cards=False, memory=False)
assert memorybank.cast_brief(adventure, "You walk on.") == ""
def test_a_persona_alone_is_enough_for_a_brief(db):
adventure = make_adventure(db, cards=False, memory=False)
brief = memorybank.cast_brief(adventure, "You walk on.")
assert brief.startswith("Cast:\n- Kaelen (he/him) — the protagonist.")
def test_an_unnamed_protagonist_is_called_the_player(db):
adventure = make_adventure(db, cards=False, memory=False)
adventure.persona_name = ""
adventure.persona_pronouns = ""
db.commit()
assert "- The player — the protagonist." in memorybank.cast_brief(adventure, "x")
def test_the_roster_is_capped(db, monkeypatch):
monkeypatch.setattr(memorybank, "MAX_CAST_MEMBERS", 2)
adventure = make_adventure(db)
brief = memorybank.cast_brief(adventure, "She grabs your arm.")
assert len([ln for ln in brief.splitlines() if ln.startswith("- ")]) == 2
def test_a_long_description_is_trimmed(db):
adventure = make_adventure(db, cards=False, memory=False)
adventure.persona_desc = "word " * 400
db.commit()
brief = memorybank.cast_brief(adventure, "x")
assert "…" in brief
assert len(brief) < 600
def test_a_character_is_never_listed_twice(db):
"""The persona and a card can carry the same name."""
adventure = make_adventure(db, cards=False, memory=False)
db.add(models.StoryCard(adventure_id=adventure.id, name="Kaelen",
keys="Kaelen", type="character", entry="Also Kaelen."))
db.commit()
db.refresh(adventure)
brief = memorybank.cast_brief(adventure, "Kaelen walks on.")
assert brief.lower().count("kaelen") == 1
def test_the_brief_holds_no_live_values(db):
"""Fixed descriptions only. A stat here would frame the same event two ways
depending on when it happened to be summarized."""
adventure = make_adventure(db)
brief = memorybank.cast_brief(adventure, "Gwen checks her bowstring.")
for live in ("hp", "trust", "/100", "wary", "healthy"):
assert live not in brief.lower()
# ------------------------------------------------- what actually gets sent
def _run_memory_pass(db, adventure, monkeypatch, stub):
monkeypatch.setattr(memorybank, "MEMORY_START", 0)
monkeypatch.setattr(memorybank, "MEMORY_INTERVAL", 4)
monkeypatch.setattr(memorybank, "MAX_MEMORIES_PER_RUN", 1)
monkeypatch.setattr(memorybank, "summary_provider", lambda s: stub)
settings = db.query(models.Settings).first()
asyncio.run(memorybank._create_due_memories(adventure, settings, db))
assert stub.calls, "the summarizer was never called"
return stub.calls[0]
def test_the_memory_prompt_carries_the_brief_above_the_excerpt(db, monkeypatch):
adventure = make_adventure(db, actions=6)
system, user = _run_memory_pass(db, adventure, monkeypatch, StubSummarizer())
assert user.index("Cast:") < user.index("Story excerpt:")
assert "Kaelen (he/him) — the protagonist" in user
assert "Setting:" in user
def test_the_memory_prompt_demands_third_person_and_names(db, monkeypatch):
adventure = make_adventure(db, actions=6)
system, _ = _run_memory_pass(db, adventure, monkeypatch, StubSummarizer())
assert "third person" in system
assert 'never as "you"' in system
def test_an_adventure_with_nothing_to_say_sends_the_old_prompt(db, monkeypatch):
"""No persona, no cards, no plot essentials: the user message is exactly
what it was before this change, with no stray blank lines."""
adventure = make_adventure(db, actions=6, persona=False, cards=False, memory=False)
_, user = _run_memory_pass(db, adventure, monkeypatch, StubSummarizer())
assert user.startswith("Story excerpt:")
assert "Cast:" not in user
def test_the_summary_prompt_carries_the_brief_too(db, monkeypatch):
"""It builds from the memories, so it inherits their framing — but the
fallback hands it raw second-person story text, which needs the brief."""
adventure = make_adventure(db, actions=20)
stub = StubSummarizer()
monkeypatch.setattr(memorybank, "SUMMARY_INTERVAL", 1)
monkeypatch.setattr(memorybank, "summary_provider", lambda s: stub)
settings = db.query(models.Settings).first()
asyncio.run(memorybank._update_story_summary(adventure, settings, db))
assert stub.calls, "the summarizer was never called"
system, user = stub.calls[0]
assert user.index("Cast:") < user.index("Current story summary:")
assert "Kaelen (he/him) — the protagonist" in user
assert "third person" in system
assert adventure.story_summary == "Memory 1."
+107 -21
View File
@@ -4,8 +4,10 @@ Two changes, in order. Phase 1 gives the adventure a persona. Phase 2 uses it,
along with the cast, to fix the memories. Phase 1 is worth shipping on its own; along with the cast, to fix the memories. Phase 1 is worth shipping on its own;
Phase 2 depends on it and is much smaller once it lands. Phase 2 depends on it and is much smaller once it lands.
**Phase 1 is built, green (593 backend tests), and driven in a browser **Both phases are built and green (610 backend tests). Phase 1 was driven in a
(21/21 checks). Phase 2 is not started.** browser (21/21 checks). Phase 2's prompts were verified against the real seeded
scenario — see "Phase 2 as built" — but no memory has been generated by a real
model yet, because that needs an API key.**
**Last updated: 2026-08-31.** **Last updated: 2026-08-31.**
@@ -293,25 +295,6 @@ after: Kaelen bribed Gwen with fifty silver to hold the north door while
he went down alone. he went down alone.
``` ```
## Open questions for Phase 2
**Existing memories.** A bank written under the old prompt will sit alongside
new ones, mixing "you" and "Kaelen" in the same context. Options: leave them and
let eviction age them out at `memory_bank_capacity` (80), or clear
`memory_cursor` and re-summarize from the start. Re-summarizing costs one AI
call per 6 actions and duplicates whatever is still in the bank, since nothing
deletes the old rows. **Leaning: leave them.** Decide when the framing change is
measured, not before.
**Retrieval framing mismatch.** `retrieve_memories` embeds the last 4 actions
raw — second person, "you". New memories will be third person and named.
Embeddings handle paraphrase well, so this is probably minor, but if retrieval
quality visibly dips after Phase 2 this is the first place to look. A fix would
be to prepend the same cast brief to the query text.
**Token cost.** The brief adds roughly 100–200 tokens to one call per 6 actions
and one per 15. Negligible against a turn.
--- ---
# Phase 1 as built # Phase 1 as built
@@ -385,3 +368,106 @@ decode it, write `<base64> <rank>` per line sorted by rank, and the result
matches the SHA-256 that `tiktoken_ext/openai_public.py` hardcodes, so the matches the SHA-256 that `tiktoken_ext/openai_public.py` hardcodes, so the
reconstruction is verifiable rather than trusted. Drop it at reconstruction is verifiable rather than trusted. Drop it at
`$TIKTOKEN_CACHE_DIR/<sha1 of the URL>`. `$TIKTOKEN_CACHE_DIR/<sha1 of the URL>`.
---
# Phase 2 as built
## The cast comes from the story cards, not from `stat_schema`
This is the one thing the sketch above got wrong, and it made the change much
smaller. `scenario_text.scenario_card_specs` already turns **every schema NPC
into a story card** on the adventure at creation, deduplicated against the
hand-written cards by name. So the cards are a single unified cast source that
covers schema NPCs, an author's own cards, and an adventure with no RPG layer,
through one path instead of three. `memorybank` never reads `stat_schema`.
## Keyword matching alone was not enough
The sketch said to run `_match_cards` over the block. Built that way first, and
it failed the exact case the change exists for.
The block `You push the door open. She grabs your arm.` matches **no** card
keyword, so the brief listed the protagonist and nobody else — leaving the
summarizer guessing at precisely the moment it was handed a brief to stop
guessing. Seed 04 gives Gwen the trigger keys `"Gwen, ranger, her"`, and even
that does not save it: the text says "she", not "her".
So the roster is **matched cards first, then topped up with the other
`character` cards** to `MAX_CAST_MEMBERS`. Places and items are not topped up —
an unmentioned tavern is not who "she" was — but a place that *is* mentioned
still matches normally.
That asymmetry with the turn prompt is deliberate. Including an untriggered card
as lore would be wrong: it is not relevant to the next sentence. Including an
untriggered character in a roster is right: the question the roster answers is
"who could these pronouns be", not "what is on stage".
## `_match_cards` became `match_cards`
Two callers now run the same rule, so it is public and exported from
`app.context`. One rule, one implementation.
## What actually gets sent
Verified against the seeded Bandit Camp scenario, with the real
`_create_due_memories` path and a stub provider:
```
Cast:
- Kaelen (he/him) — the protagonist. A half-elf ranger, exiled from the
northern holds for a killing he still won't explain.
- Bandit Camp — A rough camp of bandits in a forest clearing, holding a
stolen caravan strongbox.
- Gwen — A loyal ranger and the player's ally. Quick with a bow,
dry-humoured, fiercely protective. …
- Bandit Leader — The scarred leader of the bandit camp, guarding the
strongbox. …
Setting:
The player and Gwen, a loyal ranger ally, are raiding a bandit camp to
recover a stolen strongbox. …
Story excerpt:
… You are six paces from the strongbox when she hisses a warning. …
Memory:
```
That last line is the case in miniature: "she" is now resolvable.
**With no persona set**, the roster still lists the NPCs and the setting, and
the system prompt tells the model to call the protagonist "the player". Phase 2
therefore improves adventures that never set a persona at all.
**With no persona, no cards and no plot essentials**, the user message is byte
for byte what it was before this change — `Story excerpt:` first, no stray blank
lines. A test holds that.
## Decided, from the sketch's open questions
**Existing memories: left alone.** A bank written under the old prompt will mix
"you" and named framing for a while. Re-summarizing costs one call per 6 actions
and duplicates whatever is still in the bank, because nothing deletes the old
rows. Eviction ages them out at `memory_bank_capacity` (80). Revisit only if the
mixture visibly hurts.
**Retrieval framing mismatch: watched, not fixed.** `retrieve_memories` still
embeds the last 4 actions raw, in second person, while new memories are third
person and named. Embeddings handle paraphrase well, so this is speculative.
If retrieval quality visibly dips, prepending the same brief to the query text
is the first thing to try.
## Still to check with a real model
Everything above is the prompt, not the output. Nobody has yet run a real
provider over it and read the memories that come back. That needs an API key,
and it is the only way to know whether the framing rule actually holds across a
whole adventure. What to look for:
1. Every new memory names the protagonist and uses no bare pronouns.
2. The framing is the same across memories written many turns apart.
3. The rewritten story summary inherits it.
4. Memories do not get noticeably longer — the brief is context, not content to
be repeated back.