Mark the story with a node, not with a count

The memory bank and the story summary each kept a cursor: how many story
actions they had already covered. A count is a position in a list, and this
list moves — delete an action in front of the mark and every later one slides
down a slot, so the mark now covers one it has never read. All the cursor
bookkeeping existed to patch that up.

Both marks are now (branch_id, depth): the node up to and including which the
work is done. A depth is a coordinate along a path, not an offset into a list,
so nothing in front of it can move it. That deletes rather than rewrites
`position_of_index`, `note_action_removed`, `_rewind_cursors_to_index`,
`prune_dangling_memories` and the every-pass clamp in `run_post_turn`.

A memory hangs off the node its block ends on, so a fork inherits its
ancestors' memories without copying any, and retrieval selects through the
branch clause over the *whole* lineage — recall is long-range by definition and
cannot be windowed. Measured: 1,807 B on a story forked twenty times against
1,823 B on a flat one of the same length.

Migrations 53-56 translate the old counts into nodes. They rewrite `adventures`
and not `actions`, so this one needs no VACUUM FULL.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Dvvqn9ZDR4ixeFPHNbww7
This commit is contained in:
parththakkar106
2026-08-18 19:14:07 +05:30
committed by Parth
co-authored by Claude Opus 5
parent c7b6a46a8a
commit c51531709d
16 changed files with 1334 additions and 260 deletions
+30 -12
View File
@@ -10,7 +10,7 @@ from sqlalchemy.orm import Session, load_only, undefer
from sqlalchemy.orm.attributes import set_committed_value
from .. import auth, images, limits, memorybank, models, schemas, tree, worldstate
from ..context import build_context
from ..context import build_context, cursors
from ..context import history as context_history
from ..context import lineage
from ..database import get_db
@@ -1102,19 +1102,18 @@ def undo_turn(
preceding = newest[1] if len(newest) > 1 else None
# The earliest action removed in this turn holds the pre-turn scoreboard.
first_removed = last
memorybank.note_action_removed(adventure, last)
memorybank.forget_node(db, adventure, last)
db.delete(last)
if last.type == "ai" and preceding is not None and preceding.type in ("do", "say", "story"):
first_removed = preceding
memorybank.note_action_removed(adventure, first_removed)
memorybank.forget_node(db, adventure, first_removed)
db.delete(first_removed)
if first_removed.state_before is not None:
adventure.script_state = copy.deepcopy(first_removed.state_before)
if first_removed.world_state_before is not None:
adventure.world_state = copy.deepcopy(first_removed.world_state_before)
db.flush() # apply deletes so pruning sees the shrunken action list
db.flush() # apply the deletes before anything reads the story back
db.expire(adventure, ["actions"])
memorybank.prune_dangling_memories(adventure, db)
# The tip moved back with them.
tree.refresh_head(db, adventure)
db.commit()
@@ -1168,8 +1167,12 @@ def export_adventure(
"worldState": adv.world_state,
"autoSummarize": adv.auto_summarize,
"memoryBankEnabled": adv.memory_bank_enabled,
"memoryCursor": adv.memory_cursor,
"summaryCursor": adv.summary_cursor,
# The bundle's coordinate system is a position in the story, and the
# cursors are nodes now, so they are counted back into one. A v1 bundle
# has to stay readable by builds that never heard of a depth — SP6's v2
# format carries the anchors themselves.
"memoryCursor": cursors.position_of(adv, cursors.MEMORY.depth(db, adv)),
"summaryCursor": cursors.position_of(adv, cursors.SUMMARY.depth(db, adv)),
"memories": [
{
"text": m.text, "pinned": m.pinned, "forgotten": m.forgotten,
@@ -1312,6 +1315,16 @@ def import_adventure(
tree.place_action(db, adventure, action)
db.add(action)
# The bundle's cursors are positions in a flat story and the marks are
# nodes, so the translation waits until the actions exist — this is the
# only moment the two coordinate systems can be lined up against each
# other. The legacy columns keep the numbers the bundle gave: they are what
# a rolled-back build would read.
db.flush()
db.expire(adventure, ["actions"])
cursors.anchor_at_position(adventure, cursors.MEMORY, adventure.memory_cursor)
cursors.anchor_at_position(adventure, cursors.SUMMARY, adventure.summary_cursor)
db.commit()
db.refresh(adventure)
return adventure
@@ -1658,6 +1671,11 @@ def list_memories(
# a relationship load takes whole entities, so it picks up whatever the
# model happens to carry. `embedding_blob` is deferred and so would stay
# out today — this is about the next wide column, not that one.
#
# Adventure-wide, not path-scoped, and that is the split: retrieval reads
# the story being played, the drawer manages the bank. Hiding a branch's
# memories from the drawer would mean memories nobody can find to delete,
# in a phase whose rule is that nothing is ever removed automatically.
return (
db.query(models.Memory)
.options(load_only(*MEMORY_LIST_COLUMNS))
@@ -1788,13 +1806,13 @@ def delete_action(
action = db.get(models.Action, action_id)
if action is None or action.adventure_id != adventure_id:
raise HTTPException(404, "Action not found")
# Cursor bookkeeping, same as undo: slide the cursors down if this action
# sits before them, then drop any memory left describing a deleted action.
memorybank.note_action_removed(adventure, action)
# Same as undo: withdraw whatever this node produced. Nothing else needs
# doing — the marks are depths, and a depth does not move because an action
# in front of it went away.
memorybank.forget_node(db, adventure, action)
db.delete(action)
db.flush() # apply the delete so pruning sees the shrunken action list
db.flush()
db.expire(adventure, ["actions"])
memorybank.prune_dangling_memories(adventure, db)
# Deleting the newest action moves the tip; deleting a middle one leaves a
# gap in the depths, deliberately — see _backfill_tree.
tree.refresh_head(db, adventure)