diff --git a/backend/app/context/__init__.py b/backend/app/context/__init__.py index 4145c6e..cd3be11 100644 --- a/backend/app/context/__init__.py +++ b/backend/app/context/__init__.py @@ -1,11 +1,18 @@ 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 __all__ = [ "build_context", "count_tokens", "history", + "match_cards", "render_persona", "story_actions", "truncate_to_last_tokens", diff --git a/backend/app/context/builder.py b/backend/app/context/builder.py index ca96a63..98afc63 100644 --- a/backend/app/context/builder.py +++ b/backend/app/context/builder.py @@ -209,11 +209,15 @@ def _visible_npcs(actions: list[models.Action], stat_schema: dict) -> dict[str, 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. Matching follows AI Dungeon's rules. It ignores case, respects spaces, and 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() matched = [] @@ -352,7 +356,7 @@ def build_context( # ----- 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) - triggered = _match_cards(adventure.story_cards, trigger_window) + triggered = match_cards(adventure.story_cards, trigger_window) card_budget = int(available * CARD_BUDGET_SHARE) card_records = [] diff --git a/backend/app/memorybank.py b/backend/app/memorybank.py index 9323f02..9f740e1 100644 --- a/backend/app/memorybank.py +++ b/backend/app/memorybank.py @@ -33,7 +33,14 @@ from sqlalchemy import func, select, update from sqlalchemy.orm import Session, object_session 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 .providers import OpenAICompatibleProvider, ProviderError 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 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 = ( "You compress interactive-fiction story excerpts into memories. Respond with " "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 = ( "You maintain the running summary of an interactive-fiction story. Respond " "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 " - "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). @@ -224,6 +261,97 @@ def forget_node(db: Session, adventure: models.Adventure, action: models.Action) 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) ---------- async def retrieve_memories( @@ -411,9 +539,14 @@ async def _create_due_memories( if len(block) < MEMORY_INTERVAL: return 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: 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: 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) 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 = ( f"Current story summary:\n{current or '(none yet)'}\n\n" f"New events since the last update:\n{events_text}\n\n" "Updated summary:" ) + if brief: + user_prompt = f"{brief}\n\n{user_prompt}" try: text = await summary_provider(settings).complete( SUMMARY_SYSTEM_PROMPT, user_prompt, max_tokens=600 diff --git a/backend/tests/test_cast_brief.py b/backend/tests/test_cast_brief.py new file mode 100644 index 0000000..1a7e157 --- /dev/null +++ b/backend/tests/test_cast_brief.py @@ -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." diff --git a/plan/18-persona-and-memory-quality.md b/plan/18-persona-and-memory-quality.md index 0303d9e..d3025be 100644 --- a/plan/18-persona-and-memory-quality.md +++ b/plan/18-persona-and-memory-quality.md @@ -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; 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 -(21/21 checks). Phase 2 is not started.** +**Both phases are built and green (610 backend tests). Phase 1 was driven in a +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.** @@ -293,25 +295,6 @@ after: Kaelen bribed Gwen with fifty silver to hold the north door while 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 @@ -385,3 +368,106 @@ decode it, write ` ` per line sorted by rank, and the result matches the SHA-256 that `tiktoken_ext/openai_public.py` hardcodes, so the reconstruction is verifiable rather than trusted. Drop it at `$TIKTOKEN_CACHE_DIR/`. + +--- + +# 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.