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
+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;
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 `<base64> <rank>` 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/<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.