Run the new memory prompt back over an old bank
The prompt change only reaches memories written after it. plan/18 decided to leave the existing ones alone and let eviction age them out at memory_bank_capacity, on the grounds that re-summarizing would duplicate whatever was still in the bank because nothing deletes the old rows. That was wrong about the only option. A memory can be rewritten in place. The row carries more than its text — whether it is pinned, how often it has been retrieved, and the node it hangs off, which is what makes a fork inherit the right memories — and rewriting `text` keeps all of it. Deleting the bank and rewinding the cursor would lose that, and would trickle memories back at MAX_MEMORIES_PER_RUN per turn, so an adventure nobody is playing would never recover. tools/rewrite_memories.py does it. Without --write it makes no model calls and only reports the scope; --write rewrites, --embed re-embeds in the run rather than leaving it to the app's post-turn pass. It reads whichever database the app reads, so it works against the hosted Postgres as well as a local file. Two things it needed from the app. `summarize_block` is now the one place a memory prompt is assembled, and the post-turn pass calls it too — a backfill that built its own prompt would be writing memories with a prompt that never shipped, and nothing would report the drift. `source_block` reads a memory's block back out of the story, which nothing has ever had to do: it reads on the lineage of the branch the memory was written on, not the branch being played, because after a fork the same depths hold different actions on each side and a read through the adventure's path would summarize the wrong story silently. It also excludes the discarded attempts at a retried turn, and tolerates a block an action has since been deleted from. Left alone: a memory with no source range, which is hand-written or migrated by 62 and may be the player's own words; a memory whose actions are gone; and an adventure whose owner has no API key, because summarization spends the user's own key by construction and never the shared demo key. --api-key/--model/ --endpoint override that, the last of them aiming a run at claude_shim.py. The vector is cleared for every rewrite, because the stored one describes wording that no longer exists. Re-embedding always uses the owner's own embedding model, never --endpoint: a vector only means anything against the vectors it is ranked beside. 17 tests, 627 green. The fork case is the one that would fail quietly, so the test builds a fork whose depths hold different actions on each side. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Tqgupw5CZGjSZrUTNUd4fW
This commit is contained in:
+77
-10
@@ -30,7 +30,7 @@ from array import array
|
||||
from collections import OrderedDict
|
||||
|
||||
from sqlalchemy import func, select, update
|
||||
from sqlalchemy.orm import Session, object_session
|
||||
from sqlalchemy.orm import Session, defer, object_session
|
||||
|
||||
from . import models, tree, vectors
|
||||
from .context import (
|
||||
@@ -53,6 +53,7 @@ MAX_EMBED_BATCH = 32
|
||||
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
|
||||
MEMORY_EXCERPT_TOKENS = 2000 # of the block, when a block is longer than this
|
||||
|
||||
# ---- The cast brief (Phase 18b) ----
|
||||
# How many characters the brief names, and how much of each description it
|
||||
@@ -271,6 +272,51 @@ def forget_node(db: Session, adventure: models.Adventure, action: models.Action)
|
||||
return len(doomed)
|
||||
|
||||
|
||||
def source_block(db: Session, memory: models.Memory) -> list[models.Action]:
|
||||
"""The actions a memory was written from, oldest first.
|
||||
|
||||
The inverse of what `_create_due_memories` recorded. `source_start` and
|
||||
`source_end` are depths, and `branch_id` says which path they are depths
|
||||
on — that branch's own lineage, not the adventure's current path. A memory
|
||||
written before a fork must still read back from the branch it was written
|
||||
on, whichever branch the adventure has since moved to.
|
||||
|
||||
Returns `[]` for a memory that describes no stretch of story. Those are
|
||||
hand-written, or migrated from before memories had coordinates (see
|
||||
`lineage.ROOT_DEPTH`), and there is no block to read.
|
||||
|
||||
The range may come back shorter than `MEMORY_INTERVAL`. An action inside it
|
||||
can have been deleted since, and a memory whose block is now partial still
|
||||
describes the actions that remain.
|
||||
"""
|
||||
if memory.source_start is None or memory.source_end is None:
|
||||
return []
|
||||
if memory.branch_id is None:
|
||||
return [] # A pre-tree row: no path contains it.
|
||||
branch = db.get(models.Branch, memory.branch_id)
|
||||
if branch is None:
|
||||
return []
|
||||
path = lineage.Path(lineage.entries_of(branch))
|
||||
rows = (
|
||||
db.query(models.Action)
|
||||
.filter(
|
||||
models.Action.adventure_id == memory.adventure_id,
|
||||
# This clause excludes the sibling attempts at a retried turn, so
|
||||
# the block holds the one text the story used.
|
||||
path.clause(models.Action),
|
||||
models.Action.depth >= memory.source_start,
|
||||
models.Action.depth <= memory.source_end,
|
||||
)
|
||||
# `id` breaks a tie on `depth`, as everywhere else that orders actions.
|
||||
.order_by(models.Action.depth, models.Action.id)
|
||||
# Reasoning traces are never part of an excerpt and can outweigh the
|
||||
# narration on a reasoning model.
|
||||
.options(defer(models.Action.reasoning))
|
||||
.all()
|
||||
)
|
||||
return [a for a in rows if history.is_story_text(a.text)]
|
||||
|
||||
|
||||
# ---------- The cast brief ----------
|
||||
|
||||
def _cast_line(name: str, entry: str, *, protagonist: bool = False) -> str:
|
||||
@@ -529,6 +575,35 @@ async def run_post_turn(adventure_id: int) -> None:
|
||||
_running.discard(adventure_id)
|
||||
|
||||
|
||||
async def summarize_block(
|
||||
adventure: models.Adventure,
|
||||
provider: OpenAICompatibleProvider,
|
||||
block: list[models.Action],
|
||||
) -> str:
|
||||
"""Writes one memory from one block of story.
|
||||
|
||||
Both callers come through here, which is the point of the function. The
|
||||
pass below writes a memory as the story reaches it; `tools/rewrite_memories`
|
||||
rewrites one an older prompt produced. Assembling the prompt in two places
|
||||
would mean a rewritten memory was written by a prompt that never shipped,
|
||||
and nothing would report the difference.
|
||||
|
||||
Raises `ProviderError`, which each caller handles its own way: the pass
|
||||
below leaves the cursor alone and retries next turn, and the tool leaves the
|
||||
old text in place and moves on.
|
||||
"""
|
||||
raw = "\n\n".join(a.text for a in block)
|
||||
excerpt = truncate_to_last_tokens(raw, MEMORY_EXCERPT_TOKENS)
|
||||
# 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, raw)
|
||||
prompt = f"Story excerpt:\n\n{excerpt}\n\nMemory:"
|
||||
return await provider.complete(
|
||||
MEMORY_SYSTEM_PROMPT, f"{brief}\n\n{prompt}" if brief else prompt
|
||||
)
|
||||
|
||||
|
||||
async def _create_due_memories(
|
||||
adventure: models.Adventure, settings: models.Settings, db: Session
|
||||
) -> None:
|
||||
@@ -548,16 +623,8 @@ async def _create_due_memories(
|
||||
block = history.after(adventure, anchor, MEMORY_INTERVAL)
|
||||
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"{brief}\n\n{prompt}" if brief else prompt
|
||||
)
|
||||
text = await summarize_block(adventure, provider, block)
|
||||
except ProviderError:
|
||||
return # Logged on the debug page. The cursor is unchanged, so the
|
||||
# next turn retries this block.
|
||||
|
||||
Reference in New Issue
Block a user