diff --git a/README.md b/README.md index cdad915..e1b2c70 100644 --- a/README.md +++ b/README.md @@ -25,13 +25,23 @@ model are all runtime settings, and OpenRouter's free-tier models make the whole  *The play screen. The left rail is live world state β the AI proposes changes each turn and a -Python engine decides what actually sticks. The chip under the narration reports what changed.* +Python engine decides what actually sticks. The chip under the narration reports what changed. +The `βΉ 2/2 βΊ` under a turn steps between the takes it has; writing below one that isn't the +live one is what starts a new branch.* ## Features - **The full play loop** β Do / Say / Story / Continue actions, streamed AI responses (SSE), retry, undo, and edit. Reasoning models supported: "thinking" streams into a collapsible π panel with its own token budget. +- **A branching story tree** β the story is a tree, not a list. Any turn can hold more than one + **take**; `βΉ 2/4 βΊ` steps between them, and stepping is free β the story below simply empties, + and the server is told nothing. **Writing below a take that isn't the live one is what makes a + branch.** Branches borrow their ancestors' turns instead of copying them, so a fork costs about + 100 bytes and a 20-fork story loads within 1% of the same story flat; switching restores that + line's world state, script state and cooldown clocks. A branch panel switches, renames and + deletes; **β See the tree** draws every line against the story's own clock + (`backend/app/tree.py`, `backend/app/context/lineage.py`). - **An RPG world-state engine** β a scenario can declare stats, flags, milestones and a named cast; the adventure carries their live values. The design is **the AI proposes deltas and a Python engine referees them**: it clamps to range, enforces per-turn caps and cooldowns, keeps @@ -53,10 +63,13 @@ Python engine decides what actually sticks. The chip under the narration reports pulls old-but-relevant facts back into context, with similarity scores visible in Insights (`backend/app/memorybank.py`). - **Undo and retry that actually rewind** β undo and retry roll back the world state and script - state to a per-action snapshot, not just the text, and prune the memories that covered the - removed turns. Retries are kept as browsable variants (`βΉ 2/3 βΊ`) rather than thrown away. + state to a per-node snapshot, not just the text, and prune the memories that covered the + removed turns. Nothing a retry replaces is thrown away: the old attempt stays as another take + of that turn, and is one keystroke and one click from being a branch of its own. - **Import/export** β AI Dungeon-compatible formats for scripts and scenarios; JSON for - everything. + everything. An adventure exports as `ai-dnd-adventure-v2`, which carries the whole tree β + every branch, every take, and the fork points, because those were chosen rather than computed. + Files saved in the old single-line format still import. - **Optional accounts for hosted deployments** β by default the app is single-user with zero auth friction; set `AIDND_MULTI_USER=1` and visitors play instantly as guests (signed session cookie), can register (email + password) at any point to keep their data, and each @@ -72,6 +85,8 @@ Python engine decides what actually sticks. The chip under the narration reports | **Insights** β the exact prompt for the next turn, broken into components with token counts and the trigger word that pulled each story card in. | **Authoring** β stats with ranges, per-turn caps, cooldowns and word-labelled bands; NPCs the AI addresses by id. | |  |  | | **Scripting** β the three AI Dungeon hooks with shared persistent `state`, run in a quickjs sandbox. | **Home** β continue a story in progress or start from a scenario. | +|  |  | +| **The tree** β one lane per line, from the moment it left its parent to the moment it ends. The horizontal axis is the story's own clock, so a short branch reads as short. | **Branches** β every line the story has taken, and the three things you can do to one. A line the one you're reading was forked from can't be deleted, and says so. | ## Quick start @@ -122,7 +137,7 @@ player input β onInput script modifier β assemble context: [narrator prompt] + [world state + stat guide] + [AI instructions] + [plot essentials] + [story summary] + [retrieved memories] - + [triggered story cards] + [story history, token-budgeted] + + [triggered story cards] + [history along this branch, token-budgeted] + [author's note] + [player action] β onModelContext script modifier β snapshot context (Insights) @@ -137,14 +152,17 @@ player input ``` frontend/ React + Vite SPA ββHTTP/SSEβββΊ backend/ FastAPI ββ routers/ auth, scenarios, adventures, story cards, scripts, chat, settings, debug - ββ models.py SQLAlchemy: User, Scenario, Adventure, Action, StoryCard, Script, Settings, Memory - ββ migrations.py hand-rolled, versioned via PRAGMA user_version (37 and counting) + ββ models.py SQLAlchemy: User, Scenario, Adventure, Branch, Action, StoryCard, Script, Settings, Memory + ββ migrations.py hand-rolled, versioned via PRAGMA user_version (64 and counting) ββ auth.py guest/registered users, sessions, shared demo key ββ security.py password hashing, cookie signing, API-key encryption - ββ context/ prompt assembly under a token budget + windowed history queries + ββ tree.py forking, promotion, and where a node is placed + ββ attempts.py the takes of one turn, grouped by parent + ββ context/ prompt assembly under a token budget + lineage/history windowing ββ worldstate/ the stat engine: clamps, cooldowns, bands, milestones ββ scripting/ quickjs sandbox + AI Dungeon API surface ββ memorybank.py auto-summarization + embedding retrieval + ββ bundle.py the export/import formats, v2 (tree) and a v1 reader ββ providers/ OpenAI-compatible adapter, streaming ββ data.db SQLite (path overridable via AIDND_DB_PATH) ``` @@ -154,7 +172,7 @@ development Vite proxies `/api` to FastAPI. ## Tests -151 backend tests β unit plus full HTTP integration through the real quickjs scripting engine, +440 backend tests β unit plus full HTTP integration through the real quickjs scripting engine, with the LLM provider mocked. CI runs them on every push, alongside the frontend lint/build and a Docker image build. @@ -178,6 +196,11 @@ the most interesting engineering in the repo: β 839 KB of reads at turn 200. `backend/app/context/history.py` now serves tails and slices from SQL and measures what it fetched; the same turn costs 129 KB and stops growing at around turn 50. +- **Branching that doesn't cost anything to read.** A branch stores no turns β it stores where it + left its parent, and borrows everything above that. A 40-turn story forked twenty times loads in + 31,652 B against 31,433 B for the same story flat: **1.007Γ**, or about 103 B per branch. Reads + stay cheap because the lineage is windowed like the history is, so the number of SQL clauses is + bounded by the context window rather than by the number of forks. ## Deploy (Render) @@ -199,9 +222,10 @@ is worth. ## Repo notes -- `plan/` β the phased implementation plan this was built from, kept as a build log. All twelve - phases are complete; the later files (11, 12) double as design notes for the state-revert and - world-state work. +- `plan/` β the phased implementation plan this was built from, kept as a build log. All fourteen + phases are complete; the later files (11, 12, 14) double as design notes for the state-revert, + world-state and story-tree work. [`plan/STATUS.md`](plan/STATUS.md) is the running thread β + what shipped, what was measured, and what is owed next. - [`docs/GUIDE.md`](docs/GUIDE.md) β design notes: how each subsystem works and why it was built that way, with the measurements behind the decisions. Also rendered as a [reading page](https://parththakkar106.github.io/AI-DnD/guide.html). diff --git a/backend/tools/shots_fixture.py b/backend/tools/shots_fixture.py new file mode 100644 index 0000000..23790c8 --- /dev/null +++ b/backend/tools/shots_fixture.py @@ -0,0 +1,312 @@ +"""The story the README screenshots are taken of. + +`tools.tree_fixture` builds a tree with a shape worth drawing, on a scenario +with one stat, because a map only cares about the shape. A screenshot cares +about everything else: the world-state rail wants a scenario with bands, flags, +milestones and a named cast, and the story wants prose somebody would read. + +So this drives the seeded **Bandit Camp** demo β the same scenario the older +images were shot on, so the set stays one product β through eight turns of +written prose and written deltas, and forks three discarded takes onto branches +of their own β one of them off a branch, so the map has to nest. It leaves the reader on the first telling, on a +turn that has a second take, so one screen shows the world state, the take +pager and the branch rail at once. + + cd backend + .venv/Scripts/python.exe -m tools.shots_fixture /tmp/shots.db + AIDND_DB_PATH=/tmp/shots.db .venv/Scripts/python.exe \ + -m uvicorn app.main:app --port 8010 + +Then open http://127.0.0.1:8010/play/1. The SPA is served out of +`frontend/dist`, so run `npm run build` first if it is stale. + +No LLM is called: the provider is scripted, and every delta below is the one +the referee is being shown clamping. +""" +import os +import sys + +sys.path.insert(0, os.getcwd()) + +OUT = sys.argv[1] if len(sys.argv) > 1 else "shotsfixture.db" +if os.path.exists(OUT): + os.remove(OUT) +os.environ["AIDND_DB_PATH"] = OUT +os.environ.pop("AIDND_DATABASE_URL", None) +os.environ.pop("DATABASE_URL", None) +os.environ.pop("AIDND_MULTI_USER", None) + +import json # noqa: E402 + +from fastapi.testclient import TestClient # noqa: E402 +from sqlalchemy import text # noqa: E402 + +from app import auth, limits, models # noqa: E402 +from app.database import SessionLocal # noqa: E402 +from app.database import engine # noqa: E402 +from app.main import app # noqa: E402 +from app.migrations import LATEST_VERSION # noqa: E402 +from app.routers import adventures # noqa: E402 + +SCENARIO_TITLE = "[Demo] The Bandit Camp (RPG world state)" + +# (what the player typed, what the model said, what it claimed changed). +# +# The deltas are chosen so the rail has something to show at every level: a +# counter that only climbs, two flags that flip both ways, a text stat that is +# rewritten rather than added to, a milestone that sticks, and one delta the +# per-turn cap has to cut down (-60 hp against a max_delta_per_turn of 35). +TURNS = [ + ( + "Signal Gwen to circle left, and keep my eyes on the fire.", + "Gwen goes without a sound, low along the ditch, and the fire keeps its " + "own counsel. Two bedrolls, not three. Whoever owns the third is awake " + "somewhere in the dark, and that is the one worth knowing about.", + {"npc.gwen.trust": 8}, + ), + ( + "Cut the horse line so they scatter through the camp.", + "The rope parts under the knife and eleven hands of frightened horse go " + "through the fire pit sideways. Somebody shouts your name for a thing " + "you have not done yet. The camp is awake now, all of it at once.", + {"flags.alarm_raised": True, "flags.player_hidden": False, + "npc.bandit_leader.aggression": 20}, + ), + ( + "Get behind the wagon before anyone finds the ditch.", + "You make the wagon's shadow with a spear-length to spare and put your " + "back against a wheel that has not turned in a season. Gwen's arrow " + "answers from the treeline β once, and then not again, which is her way " + "of saying she is fine and busy.", + {"flags.player_hidden": True, "player.mana": -6}, + ), + ( + "Wait for the leader to pass, then take him from behind.", + "You wait a beat too long. He turns for a noise Gwen is making and walks " + "onto the knife himself, and it is the least honourable thing you have " + "ever done well. He is dead before the surprise finishes crossing his " + "face.", + {"npc.bandit_leader.health": -120, "npc.gwen.trust": -10, + "milestones.camp_cleared": True}, + ), + ( + "Fall back to Gwen and let her finish it.", + "You give ground the way she taught you, keeping him square to the " + "treeline, and the arrow takes him through the shoulder blade at " + "eleven paces. He sits down in the ash of his own fire and does not " + "get up. The camp goes quiet in pieces.", + {"npc.bandit_leader.health": -70, "npc.gwen.trust": 15, + "milestones.camp_cleared": True, "flags.alarm_raised": False}, + ), + ( + "Search the wagon for the strongbox.", + "It is under the false floor, of course it is, and it is heavier than " + "the caravan master implied. The lock has been worked at by somebody " + "patient and unsuccessful. You take the dead man's coat as well; yours " + "is one long tear from armpit to hip.", + {"milestones.strongbox_found": True, + "player.outfit": "a bandit's tarred coat over torn leather, strongbox under one arm"}, + ), + ( + "Bind my side before we move.", + "Gwen does it, badly and fast, with a strip off the same coat. \"You " + "went in alone,\" she says, in the voice she uses when she has decided " + "not to have the argument. The bleeding stops. The rest of it does not.", + {"player.hp": 18, "npc.gwen.trust": -5}, + ), + ( + "Take the north road while it's still dark.", + "You are two miles out when the sky starts telling on you, and the camp " + "behind is only smoke by then. Gwen walks ahead where she can see the " + "road, which means she is still angry, which means she is still here.", + {"world.day": 1, "milestones.gwen_survives": True, "player.mana": 9}, + ), +] + +# The takes the story did not keep. Each is retried at the turn of the same +# index below, and then forked onto a branch of its own β a discarded take is +# the only thing a fork can be made of. +RETAKES = { + 3: ( + "He passes close enough that you can smell the tar on his coat, and the " + "knife goes in under the arm where the plate is not. He is not a man who " + "goes down for it. He turns inside the blow and opens your side with the " + "back-swing, and the two of you come apart bleeding.", + {"player.hp": -60, "npc.bandit_leader.health": -45, + "npc.bandit_leader.aggression": 25}, + ), + 6: ( + "You tell her you will do it yourself and she lets you, which is worse " + "than the argument. The knot is bad. You will feel it in the morning, " + "and she will watch you feel it and say nothing at all.", + {"player.hp": 9, "npc.gwen.trust": -18}, + ), +} + + +class ScriptedProvider: + """Serves whatever the driver loaded, so a retry can differ from the take + it replaces β which is the whole thing being photographed.""" + + next_reply = ("", {}) + + def __init__(self, *a, **k): + pass + + async def generate(self, parts, *, temperature, max_tokens): + prose, delta = ScriptedProvider.next_reply + block = f"\n```state\n{json.dumps(delta)}\n```" if delta else "" + yield ("text", prose + block) + + +adventures.OpenAICompatibleProvider = ScriptedProvider +auth.resolve_provider_config = lambda s: auth.ProviderConfig( + "http://fake", "k", "test-model", False) +limits.rate_limit = lambda *a, **k: None +limits.check_row_cap = lambda *a, **k: None + +# `bootstrap()` runs at import and seeds the public demo scenarios, so the +# Bandit Camp is already here β the point of shooting on it is that it is the +# scenario a visitor actually meets. +client = TestClient(app) + +db = SessionLocal() +user = models.User(is_guest=False, email=None) +db.add(user) +db.flush() +db.add(models.Settings(user_id=user.id, api_key="enc:dummy", model="test-model")) +scenario = db.query(models.Scenario).filter( + models.Scenario.title == SCENARIO_TITLE).one() +db.commit() +scenario_id = scenario.id +db.close() + +r = client.post("/api/adventures", json={ + "scenario_id": scenario_id, + "title": "The Bandit Camp", + "placeholders": {}, +}) +assert r.status_code == 201, r.text +adv_id = r.json()["id"] +base = f"/api/adventures/{adv_id}" + + +def play(i): + """One turn of the written story.""" + player, prose, delta = TURNS[i] + ScriptedProvider.next_reply = (prose, delta) + r = client.post(f"{base}/actions", json={"type": "do", "text": player}) + assert r.status_code == 200, r.text + + +def retake(i): + """Retry turn `i` with the other reply, and hand back the take it left.""" + ScriptedProvider.next_reply = RETAKES[i] + return retry_last() + + +def retry_last(): + """Retry whatever is at the tip, with whatever reply is loaded, and hand + back the take the story just walked away from β the only thing a fork can + be made of.""" + before = live_ai_ids() + r = client.post(f"{base}/retry") + assert r.status_code == 200, r.text + dead = [a for a in all_ai() if not a.live and a.id in before] + assert dead, "retry left no discarded take" + return dead[-1].id + + +def all_ai(): + db = SessionLocal() + out = db.query(models.Action).filter( + models.Action.adventure_id == adv_id, + models.Action.type == "ai").order_by(models.Action.id).all() + db.expunge_all() + db.close() + return out + + +def live_ai_ids(): + return [a.id for a in all_ai() if a.live] + + +def branches(): + return client.get(f"{base}/branches").json() + + +def name(branch_id, label): + r = client.patch(f"{base}/branches/{branch_id}", json={"name": label}) + assert r.status_code == 200, r.text + + +# ---- the first telling, with the knife-in-the-back turn retried ---- +for i in range(4): + play(i) +knife = retake(3) +for i in range(4, 7): + play(i) +binding = retake(6) +play(7) +root = branches()[0]["id"] +name(root, "The quiet way in") + +# ---- the take where he died easy, given a line of its own ---- +r = client.post(f"{base}/actions/{knife}/fork") +assert r.status_code == 200, r.text +ScriptedProvider.next_reply = ( + "The camp finds him before you have finished wiping the knife, and there " + "is nothing quiet about the next four minutes.", {"flags.alarm_raised": True, + "player.hp": -22}) +r = client.post(f"{base}/actions", json={"type": "do", "text": "Take his horn and blow it."}) +assert r.status_code == 200, r.text +name([b for b in branches() if b["is_head"]][0]["id"], "Loud, and early") + +# ---- and a line that left that one again, so the map has to nest ---- +# +# Forking the take at the tip only switches to it β the attempts there are +# still leaves nobody has built on. So the story is moved one turn past it +# first, and only then is the take it walked away from worth a branch. +ScriptedProvider.next_reply = ( + "Nobody comes. The horn was the wrong horn, or the camp has been empty of " + "anyone who cares since before you got here.", {"npc.gwen.trust": -4}) +horn = retry_last() +ScriptedProvider.next_reply = ( + "The tents are as empty as the sound was. Somebody left in a hurry and did " + "not take the good rope.", {"flags.player_hidden": True}) +r = client.post(f"{base}/actions", json={"type": "do", "text": "Search the tents."}) +assert r.status_code == 200, r.text + +r = client.post(f"{base}/actions/{horn}/fork") +assert r.status_code == 200, r.text +ScriptedProvider.next_reply = ( + "They come at the sound the way water finds a crack, and you spend the next " + "hour learning the camp by running through it.", + {"player.hp": -28, "npc.bandit_leader.aggression": 15}) +r = client.post(f"{base}/actions", json={"type": "do", "text": "Run for the treeline."}) +assert r.status_code == 200, r.text +name([b for b in branches() if b["is_head"]][0]["id"], "Through the camp") + +# ---- and the argument that was never had ---- +client.post(f"{base}/branches/{root}/switch") +r = client.post(f"{base}/actions/{binding}/fork") +assert r.status_code == 200, r.text +ScriptedProvider.next_reply = ( + "She lets it go, and the road out is quieter for it than either of you " + "wanted.", {"npc.gwen.trust": -6}) +r = client.post(f"{base}/actions", json={"type": "do", "text": "Say nothing and walk."}) +assert r.status_code == 200, r.text +name([b for b in branches() if b["is_head"]][0]["id"], "Said nothing") + +# Leave the reader on the first telling, standing on the turn that has two +# takes β the one screen that shows the rail, the pager and the branches. +client.post(f"{base}/branches/{root}/switch") + +with engine.begin() as conn: + conn.execute(text(f"PRAGMA user_version = {LATEST_VERSION}")) + +for b in branches(): + print(f" branch {b['id']}: name={b['name']!r} parent={b['parent_branch_id']} " + f"fork_depth={b['fork_depth']} depth={b['depth']} own={b['own_actions']} " + f"head={b['is_head']}") +print(f"fixture written: {OUT} (adventure {adv_id})") diff --git a/docs/GUIDE.md b/docs/GUIDE.md index 3921315..1718c01 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -36,13 +36,16 @@ An AI Dungeon clone. You write a scenario, then play an open-ended text adventur language model narrates the world. You type "I open the door", the model writes what happens next, and it remembers what came before. -Three things make it more than a chat wrapper: +Four things make it more than a chat wrapper: 1. **A context engine.** The model has a limited input window. The app decides, every single turn, which pieces of the story get to be in the prompt and which get dropped. 2. **A world-state engine.** The scenario declares stats (`hp`, `trust`, `day`). The model proposes changes to them each turn; a Python engine decides what actually sticks. -3. **A scripting sandbox.** Real AI Dungeon JavaScript scripts import and run, inside an +3. **A story tree.** The story is not a list. Any turn can hold more than one take, and + writing below one that isn't the live one starts a branch that borrows every turn above + the fork rather than copying it. +4. **A scripting sandbox.** Real AI Dungeon JavaScript scripts import and run, inside an embedded QuickJS interpreter. Runs locally against Ollama for free, or hosted against any OpenAI-compatible endpoint. @@ -91,14 +94,13 @@ player input β onInput script hook (user JS may rewrite or block it) β store the player action β retrieve memories (embed recent story, cosine-rank the bank) - β snapshot script + world state (so undo/retry can roll back) β build_context() (the budget allocator) β onModelContext script hook (user JS may rewrite the whole prompt) β snapshot the exact prompt (for the Insights panel) β provider.generate() (streamed, token by token) β onOutput script hook β extract the fenced state block, referee the delta, strip it from the prose - β save the action + β save the action, stamping on it the script + world state it leaves behind β fire-and-forget: summarize + embed in the background ``` @@ -110,10 +112,14 @@ each context component, its token cost, and why it was included. It's also what prompt bugs findable. The cost is storage (~74 KB per turn), which turns into a real performance problem later β see [2.5](#25-the-189x-egress-fix). -**Snapshots happen before the model call, not after.** `state_before` and -`world_state_before` are stapled onto the action *before* the hooks and the delta run. -That's the entire mechanism behind undo and retry actually rewinding rather than just -deleting text. +**Every node records the state it leaves behind.** `state_after` and `world_state_after` +are stapled onto the action once its hooks and its delta have run, so a node carries the +scoreboard and the RPG stats as they stood when that turn finished. Rewinding to *before* +a turn is then a read of the node in front of it, which is the same move as switching to +another branch β one mechanism, and it is the whole reason undo, retry and branch +switching all put the numbers back rather than only rewriting text. (These were `*_before` +pictures originally; a tree wants the *after*, because a branch's tip is what a reader +standing on it should see.) --- @@ -394,11 +400,19 @@ back into the prompt. ### The decisions inside it -**Only *settled* actions get summarized.** The newest action is always held back one turn. -Reason: only the last action can be retried. If a memory summarized the newest action and -the player then retried it, the memory would describe narration that no longer exists β and -because its cursor has already advanced, it would never be regenerated. Holding one action -back costs a turn of latency and makes that state unreachable. +**A memory hangs off the node whose block it ends on.** Not off the adventure, and not off +a *position* in a list of actions β off a `(branch_id, depth)` coordinate. That is what +makes "which memories described this turn?" an indexed lookup rather than a scan for rows +whose covered range has fallen off the end of the story, and it is what makes memories +inherit correctly across a fork: the ones above the fork point already sit on ancestors +both lines read. + +It is also the repair. When a turn's text is replaced or removed β a retry, an undo, a +deleted action β `forget_node` withdraws the memory hanging off that coordinate *and* +rewinds both marks to just before the stretch it covered, so the ground is summarized again +from what the story now says. An earlier version instead held the newest action back a turn +so it could never be summarized before it stopped being retryable; that is no longer needed, +because the repair exists whether or not the invalidation happens at the tip. **Cursors only advance on success.** Every AI call in this module is best-effort. If summarization fails, the function returns and the cursor is unchanged, so the same block is @@ -536,10 +550,12 @@ ugly fast. User ββ Scenario (the template) ββ stat_schema, prompt, memory, author's note β ββ StoryCard, Script - ββ Adventure (the playthrough) ββ world_state, script_state, cursors - ββ Action (one story entry) ββ text, context_snapshot, variants, state_before + ββ Adventure (the playthrough) ββ world_state, script_state, head_branch_id/head_depth + ββ Branch (one line of it) ββ parent_branch_id, fork_depth, lineage, name + ββ Action (one node) ββ branch_id, depth, parent_id, live, + β text, context_snapshot, state_after ββ StoryCard (its own copy) - ββ Memory (text, embedding, source_start/end, use_count) + ββ Memory (text, embedding, branch_id, depth, use_count) ββ AdventureScript ``` @@ -551,37 +567,225 @@ for when you *do* want that, which diffs the two and shows you what would change Same reasoning as instantiating a class: shared definition, independent state. -## 2.2 Two coordinate systems, and the bug class they create +## 2.2 The story is a tree -The subtlest thing in the codebase. +The largest structural change the project has had, and the one with the most reasoning +behind it. -There are two ways to identify an action: +### The problem -- **`Action.index`** β a stable number stored on the row. Gaps appear when actions are - deleted. -- **Position** β where an action sits in the filtered, index-ordered list of *story* - actions (non-empty text only). Shifts whenever anything before it is deleted. +The story used to be a list, and a mutable one. Retry rewrote the last entry in place; +undo and delete removed entries from the middle. Everything derived from the story β +the memories, the running summary, the two marks saying how far each had got β was +indexed by *position in that list*, and a position means something different after +anything in front of it is deleted. -The memory cursors (`memory_cursor`, `summary_cursor`) are **positions**. -`Memory.source_start` / `source_end` are **`Action.index` values**. +That single fact produced a family of bugs that all looked different: -The two spaces are identical until the first deletion, and diverge forever after. Mixing -them means summarization silently skips or duplicates blocks β no crash, no error, just a -memory describing the wrong turns. +- Deleting a middle action slid a never-summarized action down into the "already + covered" range, so a *recent* action silently never became a memory. +- Discarding a memory left its actions behind the mark, describing nothing. +- Retry rewrote an action's text after the mark had passed it, so its memory described + narration that was no longer in the story. +- The retried row was still attached to the adventure while its replacement was being + written, so the model was shown the attempt it was meant to replace and wrote a + *continuation* of it. That exclusion had to be threaded through four separate readers. +- Attempts lived in a JSON array on the row with a mirrored copy of the live one in the + ordinary columns, which is a repeating group and a denormalisation in one. -Three things hold it together: +Each was fixed where it was found. The pattern only becomes visible when you line them +up: **they are all the same bug, and it is that the story is a list nobody may reorder.** -1. `history.position_of_index()` is the explicit translation between the spaces, and every - crossing goes through it. -2. `note_action_removed()` is called *before* a delete: if the removed action sat before a - cursor, the cursor decrements, so an unsummarized action can't slide into the - "already covered" range and be skipped forever. -3. One definition of "story action", written twice β `_STORY_TEXT` in SQL and - `is_story_text()` in Python β with a comment on both saying to keep them in step. The - SQL version folds newlines and tabs into spaces before `trim()`, because SQLite's and - Postgres' single-argument `trim()` only strips spaces while Python's `.strip()` also - drops newlines. An action of nothing but a newline would otherwise count as story text - in one and not the other, and every cursor after it would be off by one. +### The shape + +Make the story a tree, and none of them are reachable. + +Every action is a **node** with a `branch_id` and a `depth`. A **branch** is one line +through the tree; it holds the nodes played on it and *borrows* everything before its +fork point from its ancestors. Nothing is ever copied and β apart from an explicit +delete β nothing is ever removed. + +``` +branches(id, adventure_id, parent_branch_id, fork_depth, lineage, name) +actions(id, adventure_id, branch_id, depth, parent_id, live, text, β¦, state_after) +memories(β¦, branch_id, depth) +adventures(β¦, head_branch_id, head_depth) +``` + +`depth` is a position along *a* path, not a global turn number: `A4` and `B4` are two +alternatives, not two turns. Reading branch C, whose tip is at depth 7 and which left B +at 5, which left A at 3: + +```sql +SELECT * FROM actions +WHERE (branch_id = 'C') + OR (branch_id = 'B' AND depth <= 5) + OR (branch_id = 'A' AND depth <= 3) +ORDER BY depth DESC LIMIT 32 +``` + +β `A0 A1 A2 A3 B4 B5 C6 C7`. + +**Why `branch_id` + `depth` rather than parent pointers alone.** Parent pointers are the +obvious way to store a tree and the wrong way to read one: reading a story would be N +round trips up a chain, which throws away the windowed history work (Β§1.2) that made a +turn's read cost flat. Depth replaces the old `index` as the ordering key, so the reads +keep the shape they already had. + +### The lineage, and why fork count doesn't cost anything + +The OR-clause above is not reconstructed per read. It is stored on the branch row as +`lineage` β `[(C, β), (B, 5), (A, 3)]` β computed once when the fork happens, from the +parent's lineage plus one entry. `context/lineage.py` is the only module that knows how +to turn it into a query, which is deliberate: one forgotten clause shows the wrong story +and reports nothing. + +Two properties of the shape do the real work: + +- **The ranges are disjoint and descending.** A branch's own nodes always sit deeper than + its fork point, and each ancestor is capped at the fork depth of the branch beneath it. + So ordering the whole clause by `depth DESC` reads entry 0's nodes, then entry 1's, + then entry 2's β which means a tail read can use the newest few entries and stop. +- **Clause count is bounded by the context window, not by fork count.** A 200-fork story + whose newest branch is 40 turns long reads with *one* clause, because the window is + covered before the second entry is reached. + +A branch stores no story of its own, so a fork costs an id, a parent, a fork depth and a +cached ancestry. Measured on a 40-turn story forked twenty times against the same story +flat: a page load of **31,652 B against 31,433 B β 1.007Γ**, or about **103 bytes per +branch**. No migration, no vacuum, no copy. + +### What a player actually does + +None of the above is what the screen shows. In the player's words: + +> Any turn can gain another **take**. On an AI turn that means regenerate; on your own +> message it means type something else. Stepping between takes with `βΉ 2/4 βΊ` is free β +> the story below simply empties, because that take has no children yet. **A branch is +> created when you write below a take that is not the live one**, never before. + +That rule collapses two operations into one and deletes a distinction from the UI. The +first version of this screen had a chip that *switched* at the tip and only *previewed* +above it, with a second button to take that line β one control whose meaning depended on +where the reader was standing. The rule above replaced it with a pager that only ever +steps, a fork button on every turn, and no tip-versus-past distinction at all. The +distinction survives in the implementation, where it decides whether a write needs a +branch: at the tip the attempts are still leaves nobody has built on, so taking one is a +switch and no branch is created. + +### Takes are grouped by parent, not by coordinate + +The load-bearing detail, and the one that is not obvious. + +The natural way to find "the other takes of this turn" is by coordinate β same branch, +same depth. It is wrong in both directions: + +``` +B ββ C C1 C2 <- three takes, one parent (B) + β βββ D1' D2' <- two takes, parent C2 + βββ D1 D2 D3 <- three takes, parent C1 +``` + +Standing on the C2 path at that depth must read `2/2`, not `5`. Coordinate grouping gets +that one right by accident, because writing under a non-live take forks and the two sets +land on different branches. It gets `C` wrong: once C has been forked onto a branch of +its own it is alone at its coordinate and reads `1/1`, having lost C1 and C2 from a pager +that must still say `1/3`. + +So a node carries `parent_id`, read for nothing but this. The alternative β making a +branch's fork point a *node* rather than a depth, so a promoted take never moves β was +rejected: the whole point of `lineage` is that a read is an OR-clause per branch instead +of a walk up parent pointers, and re-pointing the fork at a node changes path resolution +itself, dragging in the cursors, memory depths and both bundle formats. `parent_id` is +one indexed lookup, never a walk, and nothing about how a path resolves changes. + +### Cursors become anchors + +The two marks β how far the memory bank has got, how far the summary has got β used to +be counts. A count is a position in a list, and every rule about sliding them, rewinding +them and translating between positions and `Action.index` existed to patch up the fact +that the list moves. + +A cursor is now an **anchor**: `(branch_id, depth)`, the node up to and including which +the work is done. Deleting an action does not move it, because a depth is a coordinate +along a path rather than a slot in a list. "What is not covered yet" becomes a question +about the story instead of about a list index, and it answers correctly whatever has been +deleted in front of it. The branch half is what makes it survive forking: a depth alone +is ambiguous once two branches both have a node 41. + +`position_of_index`, `note_action_removed`, `settled_story_actions` and the cursor-rewind +machinery were **deleted**, not left unused. So was the one-turn memory holdback that +existed because a retry could rewrite an action the mark had already passed. + +### Derived work attaches to the node that produced it + +Generalise the rule and a lot falls out: *anything derived hangs off the node that +produced it*. A memory covering depths 37β42 hangs off that branch's node 42 and is +invisible to any path that does not run through it. Shared ancestors are therefore shared +automatically, so **a fork needs nothing recreated** β the memories above the fork point +are already on the ancestors both lines read. + +The subtle case is the memory sitting *at* the forked coordinate. The first cut moved it +onto the new branch and re-anchored the marks naming it. Both are wrong for the same +reason: that memory describes whichever attempt was live at that coordinate, which is the +one staying on the parent. The right answer needs no code β the lineage caps the parent +one depth short of the fork, so the memory is simply out of range from the new branch, +invisible to both the retrieval clause and the anchor read. The new line summarizes that +ground again, from the text it actually tells. + +Hand-written memories obey the same rule. One used to carry a NULL depth, described as +"belongs to the adventure rather than to a path" β which sounds harmless and is not: a +NULL is a coordinate no fork can cap, so a note typed on one line followed the reader onto +branches whose events it never described. They are anchored at the head instead: *the +story you were reading when you wrote it*. + +### Deleting, and why the branch UI was a hard dependency + +Nothing is ever auto-pruned. That is the guarantee the whole design rests on, and it is +also why branch management could not be a nice-to-have: without a way to delete a line, +storage grows without limit. + +The delete rule has two halves and the second is easy to miss. Refusing to delete the +line being read is obvious. The other half is refusing any line it was **forked from** β +`parent_branch_id` cascades, so deleting an ancestor takes the head with it and leaves +`head_branch_id` pointing at a row that is gone. One membership test against the head's +own lineage covers both, because a lineage already names itself and every branch it +borrows from. The server is the authority; the client computes the same set only so a +button can say so before it is pressed. + +### The migration, and what it deliberately did not do + +There is no feature flag. **A linear story is a tree with one branch**, so the +intermediate states were not half-migrated β they were the same product with a superset +schema underneath, which made "existing adventures are unaffected" a literal, testable +pass condition at every step. A flag would have bought two live code paths through the +context builder, the memory bank, undo and retry at once. + +The legacy columns (`index`, `variants`, `variant_index`, the two `*_before` snapshots) +were kept unread for a release rather than dropped with the migration that stopped using +them, so that a redeploy of the previous build is still a way out. Dropping columns is the +one step that isn't. + +One operational note that generalises: on Postgres, a migration that rewrites every row of +`actions` roughly doubles the table and only `VACUUM FULL` gives it back β 79 MB reclaimed +in 5.5 s on one occasion. But bloat scales with the **heap**, and `context_snapshot` is 94% +of this table and lives out of line, so a migration touching only small columns reuses the +existing TOAST pointer and costs a tenth of that. Read the sizes from `sum(octet_length())` +per column, not from `n_live_tup`, which is a stale estimate in exactly the direction that +makes bloat look smaller. + +### What this is honest about + +- **The two marks are one pair on the adventure**, not one per branch. Switching branches + makes the mark on the line being left unreadable from the new one, and that ground is + summarized again. It answers "nothing covered", which is the safe direction β redo the + work, never skip it β but switching back and forth costs AI calls. Per-branch cursors + are the fix if it ever matters. +- **Story cards stay adventure-wide.** A card invented on branch B shows on branch A. + Event-sourcing card changes onto nodes was considered and rejected. +- **Editing an already-summarized action still leaves its memory stale.** The machinery to + fix it now exists β an edit could write a sibling take and switch to it, which is a retry + the player typed β but it does not do that yet. ## 2.3 Undo and retry that actually rewind @@ -589,31 +793,39 @@ Most implementations of undo delete the last message. That's wrong here, because mutates three things: the text, the scripting scoreboard (`script_state`), and the RPG stats (`world_state`). -**The mechanism:** every action carries `state_before` and `world_state_before` β deep -copies taken before the turn's hooks ran. Undo restores from them. Retry rolls back to -them, then regenerates. +**The mechanism:** every node carries `state_after` and `world_state_after` β deep copies +of what the adventure looked like once that turn had played. Rewinding to before a turn is +a read of the node in front of it, so undo, retry and a branch switch are the same +restore. The cooldown clock comes along for free: it lives inside the world state, in +`_meta.last_changed`, so each line of the story carries its own without anything having to +know there is one. -**Retry keeps every attempt.** Instead of deleting and replacing, the row survives and each -attempt is appended to `Action.variants`; `variant_index` names the live one. The UI shows -`βΉ 2/3 βΊ` and you can page back to a discarded take. A variant stores only what differs -between attempts β the narration, its reasoning trace, and the state it produced β never -the assembled prompt, which is identical across attempts of the same turn and is by far the -biggest thing in the snapshot. +**Nothing a retry replaces is thrown away.** The old attempt stays as another **take** of +that turn β a sibling node at the same coordinate, `live` false β and the pager steps +between them. Which is to say retry is not a special case: it is the tree, with the branch +not yet created. See [2.2](#22-the-story-is-a-tree). Three details that are easy to get wrong: -**The row being retried is excluded from its own context.** It's still attached to the -adventure (it holds the variant history), so without `exclude_action_id` the model would be -shown the attempt it's replacing as established story and would write a continuation of it -instead of a replacement. +**The turn being retried is excluded from its own context.** Its takes are still attached +to the adventure, so without `exclude_action_id` the model would be shown the attempt it is +replacing as established story and would write a continuation of it. The exclusion had +leaked into four readers, not one: history replay, story-card trigger matching, in-scene +NPC detection, and the memory-bank similarity query. The invariant is worth stating flatly: +*anything reading the story during generation takes the exclusion.* -**A retry reuses the turn's index**, not the next one. Cooldowns are measured in action -indexes, so using `next_index()` would advance the clock the cooldown rules run on and a -retry would quietly unlock stats that should still be on cooldown. +**A retry reuses the turn's depth**, not the next one. Cooldowns are measured along the +path, so allocating a new depth would advance the clock the cooldown rules run on and a +retry would quietly unlock stats that should still be waiting. + +**`delete_turn` used to mean "every take at this coordinate".** Once a take can be forked +onto a branch of its own, the group spans branches, and undo reached across and deleted a +take belonging to a line nobody asked about. Anything that reads a take group and then +*writes* has to say whether it means the turn or the coordinate. **If the regeneration fails, the rollback is reversed.** `generate_turn` wraps the generator in a `try/finally`: if it ends without saving β a provider error, an empty reply, -a script `stop`, or the browser hanging up β the previous variant is put back in charge. +a script `stop`, or the browser hanging up β the previous take is put back in charge. Otherwise the state on the server would drift from the text still on the user's screen. ## 2.4 The turn lock @@ -674,15 +886,16 @@ entity select in a subquery, so the emitted SQL names every column β including ones. No bytes come back either way, but the database still has to read them, and a guard that greps SQL cannot tell the two apart. -There's a companion denormalization for the same reason: `variants` is deferred, so -`variant_count` exists as its own column to answer "how many attempts?" without fetching -them. `set_variants()` is the only function allowed to write `variants`, precisely so the -two can't drift and the pager can't lie about how many takes a turn has. +There's a companion denormalization for the same reason: the pager has to know how many +takes a turn has without fetching any of them, so `variant_index` and `variant_count` are +cached on the row and refreshed by one function (`attempts.renumber`), precisely so they +can't drift and the pager can't lie. `variant_count` is 0 rather than 1 for a turn nobody +retried, because the question it answers is "is there anything to page through?" ## 2.6 Migrations, hand-rolled No Alembic. An append-only list of `(version, SQL)` pairs, with the current version stored -in SQLite's `PRAGMA user_version` or a one-row table on Postgres. 37 versions so far. +in SQLite's `PRAGMA user_version` or a one-row table on Postgres. 64 versions so far. - A **fresh** database is created by `Base.metadata.create_all()` (always current) and stamped at the latest version β it never replays history. @@ -877,9 +1090,10 @@ text appears to type itself. | Database egress per adventure load | 38.5 MB β **0.20 MB** (~189x) | | Prompt snapshot size | ~74 KB/turn, 94% of the database | | Turn read cost at turn 200 | 839 KB β **129 KB**, flat after ~turn 50 | +| Cost of a branch | ~**103 B**; 20 forks load at **1.007Γ** the same story flat | | Length-hint phrasing | 174 β 246 words phrased as a budget; **170** phrased as a ceiling (n=5) | -| Backend tests | 151, LLM mocked, real QuickJS engine | -| Schema versions | 37 | +| Backend tests | 440, LLM mocked, real QuickJS engine | +| Schema versions | 64 | | Sandbox limits | 16 MB, 2 s CPU, fresh context per run | | Context defaults | author's note at depth 3, cards capped at 40% of elastic budget | | Memory cadence | memory / 6 turns, summary / 15 turns, top-5 retrieval | @@ -906,6 +1120,13 @@ nobody has to discover them the hard way. restart. At real load it belongs in a queue. - **The demo key depends on a free-tier provider's daily cap**, which the app can only detect after the fact by string-matching the 429 body. +- **The two memory marks are one pair on the adventure, not one per branch.** Switching + lines makes the mark on the line being left unreadable from the new one, so that ground is + summarized again. It fails in the safe direction β redo, never skip β but switching back + and forth costs AI calls. Per-branch cursors are the fix if it matters. +- **Story cards are adventure-wide**, so a card invented on one branch shows on all of them. +- **Editing an already-summarized turn leaves its memory stale.** Replacing a turn withdraws + what was derived from it; editing one in place does not. ## Cleanup backlog @@ -919,9 +1140,10 @@ The largest ones: content. Passing structure through the hook would be better but would break AI Dungeon compatibility, which is the point of the feature. - The import endpoints hand-coerce raw dicts instead of using Pydantic bundle schemas. -- `Action` has no `UniqueConstraint('adventure_id', 'index')`; index allocation is ad-hoc - per writer, and a database constraint would make the turn-lock race impossible rather - than merely fixed. +- The legacy pre-tree columns (`index`, `variants`, `variant_index`, and the two `*_before` + snapshots) are still on `actions`, unread, kept for one release so redeploying the previous + build remains a way out. Dropping them is a migration that rewrites every row, so it owes a + `VACUUM FULL actions;` after it. --- diff --git a/docs/guide.html b/docs/guide.html index 2c1126a..30365e0 100644 --- a/docs/guide.html +++ b/docs/guide.html @@ -4,7 +4,7 @@
Engineering guide
An AI Dungeon-style storytelling engine. The chat loop is the boring part β - the interesting parts are the token-budget allocator, the world-state referee, and the - memory system that decides what the model is allowed to remember.
+ the interesting parts are the token-budget allocator, the world-state referee, the story tree + that lets a turn have more than one answer, and the memory system that decides what the model is + allowed to remember.Three things make it more than a chat wrapper:
+Four things make it more than a chat wrapper:
hp,
trust, day. The model proposes changes each turn; a Python engine
decides what actually sticks.backend/app/routers/adventures.py.
Snapshots happen before the model call, not after. state_before
-and world_state_before are stapled onto the action before the hooks and the
-delta run. Thatβs the entire mechanism behind undo and retry actually rewinding rather than just
-deleting text.
Every node records the state it leaves behind. state_after and
+world_state_after are stapled onto the action once its hooks and its delta have run,
+so a node carries the scoreboard and the RPG stats as they stood when that turn finished. Rewinding
+to before a turn is then a read of the node in front of it β the same move as switching to
+another branch. One mechanism, and it is why undo, retry and a branch switch all put the numbers
+back rather than only rewriting text.
Only settled actions get summarized. The newest action is always held -back one turn. Only the last action can be retried β so if a memory summarized the newest action -and the player then retried it, that memory would describe narration that no longer exists, and -because its cursor has already advanced it would never be regenerated. Holding one action back -costs a turn of latency and makes that state unreachable.
+A memory hangs off the node whose block it ends on. Not off the adventure, and
+not off a position in a list of actions β off a (branch_id, depth) coordinate.
+That makes βwhich memories described this turn?β an indexed lookup rather than a scan for rows whose
+covered range has fallen off the end of the story, and it is what makes memories inherit correctly
+across a fork: the ones above the fork point already sit on ancestors both lines read.
It is also the repair. When a turnβs text is replaced or removed β a retry, an undo, a deleted
+action β forget_node withdraws the memory hanging off that coordinate and
+rewinds both marks to just before the stretch it covered, so that ground is summarized again from
+what the story now says. An earlier version instead held the newest action back a turn so it could
+never be summarized before it stopped being retryable; that is no longer needed, because the repair
+exists whether or not the invalidation happens at the tip.
Cursors only advance on success. Every AI call here is best-effort. If summarization fails, the function returns and the cursor is unchanged, so the same block is @@ -883,10 +895,12 @@ ugly fast.
User
ββ Scenario (the template) ββ stat_schema, prompt, memory, author's note
β ββ StoryCard, Script
- ββ Adventure (the playthrough) ββ world_state, script_state, cursors
- ββ Action (one story entry) ββ text, context_snapshot, variants, state_before
+ ββ Adventure (the playthrough) ββ world_state, script_state, head_branch_id/head_depth
+ ββ Branch (one line of it) ββ parent_branch_id, fork_depth, lineage, name
+ ββ Action (one node) ββ branch_id, depth, parent_id, live,
+ β text, context_snapshot, state_after
ββ StoryCard (its own copy)
- ββ Memory (text, embedding, source_start/end, use_count)
+ ββ Memory (text, embedding, branch_id, depth, use_count)
ββ AdventureScript
The one decision that shapes everything: template vs instance. A scenario @@ -897,75 +911,266 @@ flow for when you do want that, which diffs the two and shows what woul
Same reasoning as instantiating a class: shared definition, independent state.
-The subtlest thing in the codebase.
+The largest structural change the project has had, and the one with the most reasoning behind +it.
-There are two ways to identify an action:
+The story used to be a list, and a mutable one. Retry rewrote the last entry in place; undo and +delete removed entries from the middle. Everything derived from the story β the memories, the +running summary, the two marks saying how far each had got β was indexed by position in that +list, and a position means something different after anything in front of it is deleted.
+ +That single fact produced a family of bugs that all looked different:
Action.index β a stable number stored on the row. Gaps appear
- when actions are deleted.The memory cursors are positions. Memory.source_start and
-source_end are Action.index values.
The two spaces are identical until the first deletion, and diverge forever after. Mixing them - means summarization silently skips or duplicates blocks β no crash, no error, just a memory - describing the wrong turns.
+ The pattern +Each was fixed where it was found. The shape only becomes visible when you line them up: + they are all the same bug, and it is that the story is a list nobody may reorder.
Three things hold it together:
+position_of_index() is the explicit translation between the spaces, and every
- crossing goes through it.note_action_removed() is called before a delete: if the removed action
- sat before a cursor, the cursor decrements, so an unsummarized action canβt slide into the
- βalready coveredβ range and be skipped forever.trim(), because SQLiteβs and Postgresβ single-argument trim()
- only strips spaces while Pythonβs .strip() also drops newlines. An action of nothing
- but a newline would otherwise count as story text in one and not the other, and every cursor
- after it would be off by one.Make the story a tree and none of them are reachable. Every action is a node
+with a branch_id and a depth. A branch is one line
+through the tree: it holds the nodes played on it and borrows everything before its fork
+point from its ancestors. Nothing is ever copied, and β apart from an explicit delete β nothing is
+ever removed.
branches(id, adventure_id, parent_branch_id, fork_depth, lineage, name)
+actions(id, adventure_id, branch_id, depth, parent_id, live, text, β¦, state_after)
+memories(β¦, branch_id, depth)
+adventures(β¦, head_branch_id, head_depth)
+
+depth is a position along a path, not a global turn number:
+A4 and B4 are two alternatives, not two turns. Reading branch C, whose
+tip is at depth 7 and which left B at 5, which left A at 3:
SELECT * FROM actions
+WHERE (branch_id = 'C')
+ OR (branch_id = 'B' AND depth <= 5)
+ OR (branch_id = 'A' AND depth <= 3)
+ORDER BY depth DESC LIMIT 32
+
+β A0 A1 A2 A3 B4 B5 C6 C7.
Why branch_id + depth rather than parent pointers alone.
+Parent pointers are the obvious way to store a tree and the wrong way to read one: reading a story
+would be N round trips up a chain, which throws away the windowed history work (1.2)
+that made a turnβs read cost flat. Depth replaces the old index as the ordering key, so
+the reads keep the shape they already had.
The OR-clause above is not reconstructed per read. It is stored on the branch row as
+lineage β [(C, β), (B, 5), (A, 3)] β computed once when the fork happens,
+from the parentβs lineage plus one entry. One module knows how to turn it into a query, which is
+deliberate: one forgotten clause shows the wrong story and reports nothing.
Two properties of the shape do the real work:
+ +depth DESC reads entry 0βs nodes, then entry 1βs,
+ then entry 2βs β which lets a tail read use the newest few entries and stop.None of the above is what the screen shows. In the playerβs words:
+ +++ +Any turn can gain another take. On an AI turn that means regenerate; on your + own message it means type something else. Stepping between takes with
+βΉ 2/4 βΊis free + β the story below simply empties, because that take has no children yet. A branch is + created when you write below a take that is not the live one, never before.
That rule collapses two operations into one and deletes a distinction from the UI. The first +version of this screen had a chip that switched at the tip and only previewed +above it, with a second button to take that line β one control whose meaning depended on where the +reader was standing. The rule above replaced it with a pager that only ever steps, a fork button on +every turn, and no tip-versus-past distinction at all. The distinction survives in the +implementation, where it decides whether a write needs a branch: at the tip the attempts are still +leaves nobody has built on, so taking one is a switch and no branch is created.
+ +The load-bearing detail, and the one that isnβt obvious. The natural way to find βthe other +takes of this turnβ is by coordinate β same branch, same depth. It is wrong in both directions:
+ +B ββ C C1 C2 <- three takes, one parent (B)
+ β βββ D1' D2' <- two takes, parent C2
+ βββ D1 D2 D3 <- three takes, parent C1
+
+Standing on the C2 path at that depth must read 2/2, not 5. Coordinate
+grouping gets that one right by accident, because writing under a non-live take forks and the two
+sets land on different branches. It gets C wrong: once C has been forked onto a branch
+of its own it is alone at its coordinate and reads 1/1, having lost C1 and C2 from a
+pager that must still say 1/3.
So a node carries parent_id, read for nothing but this. The alternative β making a
+branchβs fork point a node rather than a depth, so a promoted take never moves β was
+rejected: the whole point of lineage is that a read is an OR-clause per branch instead
+of a walk up parent pointers, and re-pointing the fork at a node changes path resolution itself,
+dragging in the cursors, memory depths and both bundle formats. parent_id is one
+indexed lookup, never a walk, and nothing about how a path resolves changes.
The two marks β how far the memory bank has got, how far the summary has got β used to be
+counts. A count is a position in a list, and every rule about sliding them, rewinding them and
+translating between positions and Action.index existed to patch up the fact that the
+list moves.
A cursor is now an anchor: (branch_id, depth), the node up to and
+including which the work is done. Deleting an action doesnβt move it, because a depth is a
+coordinate along a path rather than a slot in a list. βWhat is not covered yetβ becomes a question
+about the story instead of about a list index, and it answers correctly whatever has been deleted
+in front of it. The branch half is what makes it survive forking: a depth alone is ambiguous once
+two branches both have a node 41.
position_of_index, note_action_removed,
+settled_story_actions and the cursor-rewind machinery were deleted,
+not left unused.
Generalise the rule and a lot falls out: anything derived hangs off the node that produced +it. A memory covering depths 37β42 hangs off that branchβs node 42 and is invisible to any path +that doesnβt run through it. Shared ancestors are therefore shared automatically, so a fork +needs nothing recreated β the memories above the fork point are already on the ancestors +both lines read.
+ +The memory sitting at the forked coordinate. The first cut moved it onto the new + branch and re-anchored the marks naming it. Both are wrong for the same reason: that memory + describes whichever attempt was live at that coordinate, which is the one staying on the parent.
+The right answer needs no code. The lineage caps the parent one depth short of the fork, so + the memory is simply out of range from the new branch β invisible to both the retrieval clause and + the anchor read. The new line summarizes that ground again, from the text it actually tells.
+Hand-written memories obey the same rule. One used to carry a NULL depth, described as βbelongs +to the adventure rather than to a pathβ β which sounds harmless and is not: a NULL is a coordinate +no fork can cap, so a note typed on one line followed the reader onto branches whose events it never +described. They are anchored at the head instead: the story you were reading when you wrote +it.
+ +Nothing is ever auto-pruned. That is the guarantee the whole design rests on, and it is also why +branch management couldnβt be a nice-to-have: without a way to delete a line, storage grows without +limit.
+ +The delete rule has two halves and the second is easy to miss. Refusing to delete the line being
+read is obvious. The other half is refusing any line it was forked from β
+parent_branch_id cascades, so deleting an ancestor takes the head with it and leaves
+head_branch_id pointing at a row that is gone. One membership test against the headβs
+own lineage covers both, because a lineage already names itself and every branch it borrows from.
+The server is the authority; the client computes the same set only so a button can say so before it
+is pressed.
There is no feature flag. A linear story is a tree with one branch, so the +intermediate states werenβt half-migrated β they were the same product with a superset schema +underneath, which made βexisting adventures are unaffectedβ a literal, testable pass condition at +every step. A flag would have bought two live code paths through the context builder, the memory +bank, undo and retry at once.
+ +The legacy columns (index, variants, variant_index, the
+two *_before snapshots) were kept unread for a release rather than dropped with the
+migration that stopped using them, so that redeploying the previous build is still a way out.
+Dropping columns is the one step that isnβt.
On Postgres a migration that rewrites every row of actions roughly doubles the
+ table, and only VACUUM FULL gives it back β 79 MB reclaimed in 5.5 s on one occasion.
+ But bloat scales with the heap, and context_snapshot is 94% of this
+ table and lives out of line, so a migration touching only small columns reuses the existing TOAST
+ pointer and costs a tenth of that.
Read the sizes from sum(octet_length(col)) per column, not from
+ n_live_tup β that one is a stale estimate in exactly the direction that makes bloat
+ look smaller.
Most implementations of undo delete the last message. Thatβs wrong here, because a turn mutates three things: the text, the scripting scoreboard, and the RPG stats.
-The mechanism: every action carries state_before and
-world_state_before β deep copies taken before the turnβs hooks ran. Undo restores from
-them. Retry rolls back to them, then regenerates.
The mechanism: every node carries state_after and
+world_state_after β deep copies of what the adventure looked like once that turn had
+played. Rewinding to before a turn is a read of the node in front of it, so undo, retry and a branch
+switch are the same restore. The cooldown clock comes along for free: it lives inside the world
+state, so each line of the story carries its own without anything having to know there is one.
Retry keeps every attempt. Instead of deleting and replacing, the row survives
-and each attempt is appended to Action.variants; variant_index names the
-live one. The UI shows βΉ 2/3 βΊ and you can page back to a discarded take. A variant
-stores only what differs between attempts β the narration, its reasoning trace, and the state it
-produced β never the assembled prompt, which is identical across attempts of the same turn and is
-by far the biggest thing in the snapshot.
Nothing a retry replaces is thrown away. The old attempt stays as another
+take of that turn β a sibling node at the same coordinate, live false β and the
+pager steps between them. Which is to say retry isnβt a special case: it is the tree, with the branch
+not yet created (2.2).
Three details that are easy to get wrong:
+Four details that are easy to get wrong:
delete_turn used to mean βevery take at this coordinateβ. Once a
+ take can be forked onto a branch of its own the group spans branches, and undo reached across and
+ deleted a take belonging to a line nobody asked about. Anything that reads a take group and then
+ writes has to say whether it means the turn or the coordinate.try/finally: if it ends without saving β provider error, empty reply, a script
- stop, or the browser hanging up β the previous variant is put back in charge.
- Otherwise the state on the server drifts from the text still on the userβs screen.stop, or the browser hanging up β the previous take is put back in charge. Otherwise
+ the state on the server drifts from the text still on the userβs screen.
Thereβs a companion denormalization for the same reason: variants is deferred, so
-variant_count exists as its own column to answer βhow many attempts?β without fetching
-them. One function is the only thing allowed to write variants, precisely so the two
-canβt drift and the pager canβt lie.
Thereβs a companion denormalization for the same reason: the pager has to know how many takes a
+turn has without fetching any of them, so variant_index and variant_count
+are cached on the row and refreshed by exactly one function, precisely so they canβt drift and the
+pager canβt lie. variant_count is 0 rather than 1 for a turn nobody retried, because
+the question it answers is βis there anything to page through?β
No Alembic. An append-only list of (version, SQL) pairs, with the current version
-stored in SQLiteβs PRAGMA user_version or a one-row table on Postgres. 37 versions so
+stored in SQLiteβs PRAGMA user_version or a one-row table on Postgres. 64 versions so
far.
Action has no UniqueConstraint('adventure_id', 'index'); index
- allocation is ad-hoc per writer, and a database constraint would make the turn-lock race
- impossible rather than merely fixed.index, variants,
+ variant_index, and the two *_before snapshots) are still on
+ actions, unread, kept for one release so redeploying the previous build remains a way
+ out. Dropping them is a migration that rewrites every row, so it owes a
+ VACUUM FULL actions; after it.
‹ 2/2 ›
+ under a turn steps between the takes it has.
Three things a plain "talk to a model" app doesn't do.
+Four things a plain "talk to a model" app doesn't do.
Any turn can hold more than one take. Stepping between them is free β the story below simply + empties, and the server is told nothing. Writing below a take that isn't the live one is what makes a + branch, and a branch stores no turns of its own: it records where it left its parent and borrows + everything above that. Twenty forks cost 1.007Γ the page load of the same story flat. Switch lines and + the world state, the script scoreboard and the cooldown clocks all come back to what that line left.
+
+ A scenario declares stats, flags, milestones and a named cast. Each turn the model appends the changes @@ -192,7 +203,7 @@ β onInput script modifier β assemble context: [narrator prompt] + [world state + stat guide] + [AI instructions] + [plot essentials] + [story summary] + [retrieved memories] - + [triggered story cards] + [story history, token-budgeted] + + [triggered story cards] + [history along this branch, token-budgeted] + [author's note] + [player action] β onModelContext script modifier β snapshot context (Insights) @@ -219,8 +230,8 @@