Rewrite docs and READMEs in Google developer documentation style
Trim em dashes, convert first-person plural to second person, and tighten sentences in README.md, docs/GUIDE.md, docs/self-review.md, and frontend/README.md, matching the style already applied to code comments. No technical content, numbers, or code blocks changed. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N7vUFpYkLrJnuSgwcRjqSx
This commit is contained in:
committed by
Parth
co-authored by
Claude Sonnet 5
parent
e7d75c3b05
commit
446ceb6cab
@@ -3,76 +3,77 @@
|
|||||||
[](https://github.com/parththakkar106/AI-DnD/actions/workflows/ci.yml)
|
[](https://github.com/parththakkar106/AI-DnD/actions/workflows/ci.yml)
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
|
|
||||||
An AI Dungeon-style interactive storytelling app you can run entirely on your own machine —
|
An AI Dungeon-style interactive storytelling app that runs entirely on your own machine, with
|
||||||
with your own AI model. Create scenarios, play open-ended adventures where an LLM narrates the
|
your own AI model. Create scenarios, play open-ended adventures where an LLM narrates the
|
||||||
world, and extend the engine with **JavaScript scripts compatible with real AI Dungeon
|
world, and extend the engine with **JavaScript scripts compatible with real AI Dungeon
|
||||||
scripting**.
|
scripting**.
|
||||||
|
|
||||||
> ### ▶️ Try it live: **[parththakkar106.github.io/AI-DnD](https://parththakkar106.github.io/AI-DnD/)**
|
> ### ▶️ Try it live: **[parththakkar106.github.io/AI-DnD](https://parththakkar106.github.io/AI-DnD/)**
|
||||||
> The project page loads instantly and launches the hosted demo in one tap — play a scenario as
|
> The project page loads instantly and launches the hosted demo in one tap. Play a scenario as
|
||||||
> a guest, no sign-up and no API key needed. (The demo runs on a free tier that sleeps, so the
|
> a guest: no sign-up and no API key needed. The demo runs on a free tier that sleeps, so the
|
||||||
> first load after it's been idle takes ~30–60s to wake up.)
|
> first load after it's been idle takes about 30 to 60 seconds to wake up.
|
||||||
>
|
>
|
||||||
> Want the internals? The **[design notes](https://parththakkar106.github.io/AI-DnD/guide.html)**
|
> For the internals, read the **[design notes](https://parththakkar106.github.io/AI-DnD/guide.html)**.
|
||||||
> walk through the context budgeting, the world-state referee and the memory bank, and state the
|
> They walk through the context budgeting, the world-state referee, and the memory bank, and
|
||||||
> reasoning behind each one ([Markdown version](docs/GUIDE.md)).
|
> state the reasoning behind each one ([Markdown version](docs/GUIDE.md)).
|
||||||
|
|
||||||
Built with FastAPI + SQLAlchemy on the backend and React (Vite) on the frontend, running on
|
Built with FastAPI and SQLAlchemy on the backend and React (Vite) on the frontend, running on
|
||||||
SQLite locally and Postgres in the cloud. Works with **any OpenAI-compatible endpoint**: Ollama
|
SQLite locally and Postgres in the cloud. It works with **any OpenAI-compatible endpoint**:
|
||||||
and LM Studio locally, or OpenRouter / OpenAI / Groq / vLLM in the cloud — endpoint, key, and
|
Ollama and LM Studio locally, or OpenRouter, OpenAI, Groq, or vLLM in the cloud. Endpoint, key,
|
||||||
model are all runtime settings, and OpenRouter's free-tier models make the whole experience $0.
|
and model are all runtime settings, and OpenRouter's free-tier models make the whole experience
|
||||||
|
cost nothing.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
*The play screen. The left rail is live world state — the AI proposes changes each turn and a
|
*The play screen. The left rail shows live world state. The AI proposes changes each turn, and
|
||||||
Python engine decides what actually sticks. The chip under the narration reports what changed.
|
a Python engine decides what actually sticks. The chip under the narration reports what
|
||||||
The `‹ 2/2 ›` under a turn steps between the takes it has; writing below one that isn't the
|
changed. The `‹ 2/2 ›` under a turn steps between the takes it has. Writing below a take that
|
||||||
live one is what starts a new branch.*
|
isn't the live one starts a new branch.*
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **The full play loop** — Do / Say / Story / Continue actions, streamed AI responses (SSE),
|
- **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 💭
|
retry, undo, and edit. Reasoning models are supported: "thinking" streams into a collapsible
|
||||||
panel with its own token budget.
|
💭 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
|
- **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,
|
**take**, and `‹ 2/4 ›` steps between them. 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
|
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
|
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
|
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
|
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
|
deletes; **⌗ See the tree** draws every line against the story's own clock
|
||||||
(`backend/app/tree.py`, `backend/app/context/lineage.py`).
|
(`backend/app/tree.py`, `backend/app/context/lineage.py`).
|
||||||
- **An RPG world-state engine** — a scenario can declare stats, flags, milestones and a named
|
- **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
|
cast; the adventure carries their live values. The AI proposes deltas, and a Python engine
|
||||||
Python engine referees them**: it clamps to range, enforces per-turn caps and cooldowns, keeps
|
referees them: it clamps values to range, enforces per-turn caps and cooldowns, keeps counters
|
||||||
counters monotonic and milestones sticky, then strips the machine-readable block out of the
|
monotonic and milestones sticky, then strips the machine-readable block out of the prose
|
||||||
prose (`backend/app/worldstate/engine.py`). Word-labelled bands (`40–60: minor damage`) are
|
(`backend/app/worldstate/engine.py`). Word-labeled bands (`40–60: minor damage`) make the
|
||||||
what make the model reliable at it. No dice, no scripting required.
|
model reliable at it. No dice and no scripting are required.
|
||||||
- **AI Dungeon-compatible context engine** — memory, author's note, and story cards (world
|
- **AI Dungeon-compatible context engine.** Memory, author's note, and story cards (world
|
||||||
info) triggered by keywords in recent story text, assembled under a token budget
|
info) are triggered by keywords in recent story text, then assembled under a token budget
|
||||||
(`backend/app/context/builder.py`).
|
(`backend/app/context/builder.py`).
|
||||||
- **Insights: total prompt transparency** — every turn stores the exact prompt sent to the
|
- **Insights: total prompt transparency.** Every turn stores the exact prompt sent to the
|
||||||
model; open 🔍 on any AI action to see each context component, its token cost, and why it was
|
model. Open 🔍 on any AI action to see each context component, its token cost, and why it was
|
||||||
included.
|
included.
|
||||||
- **JavaScript scripting, AI Dungeon-compatible** — `onInput` / `onModelContext` / `onOutput`
|
- **JavaScript scripting, AI Dungeon-compatible.** `onInput` / `onModelContext` / `onOutput`
|
||||||
modifiers with shared `state` and a `worldEntries` API, executed in an embedded quickjs
|
modifiers share `state` and a `worldEntries` API, and run in an embedded quickjs sandbox
|
||||||
sandbox (`backend/app/scripting/`). Real AI Dungeon scripts import and run. In-app
|
(`backend/app/scripting/`). Real AI Dungeon scripts import and run as is. An in-app CodeMirror
|
||||||
CodeMirror editor included.
|
editor is included.
|
||||||
- **Auto-summarization + Memory Bank** — the modern AI Dungeon memory system: AI-generated
|
- **Auto-summarization and Memory Bank.** The modern AI Dungeon memory system: AI-generated
|
||||||
memories every few actions, a running story summary, and embedding-based retrieval that
|
memories every few actions, a running story summary, and embedding-based retrieval that
|
||||||
pulls old-but-relevant facts back into context, with similarity scores visible in Insights
|
pulls old-but-relevant facts back into context, with similarity scores visible in Insights
|
||||||
(`backend/app/memorybank.py`).
|
(`backend/app/memorybank.py`).
|
||||||
- **Undo and retry that actually rewind** — undo and retry roll back the world state and script
|
- **Undo and retry that actually roll back state.** Undo and retry roll back the world state
|
||||||
state to a per-node snapshot, not just the text, and prune the memories that covered the
|
and script state to a per-node snapshot, not just the text, and prune the memories that
|
||||||
removed turns. Nothing a retry replaces is thrown away: the old attempt stays as another take
|
covered the removed turns. Nothing a retry replaces is discarded: the old attempt stays as
|
||||||
of that turn, and is one keystroke and one click from being a branch of its own.
|
another take of that turn, one keystroke and one click from becoming a branch of its own.
|
||||||
- **Import/export** — AI Dungeon-compatible formats for scripts and scenarios; JSON for
|
- **Import and export.** AI Dungeon-compatible formats for scripts and scenarios; JSON for
|
||||||
everything. An adventure exports as `ai-dnd-adventure-v2`, which carries the whole tree —
|
everything else. 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.
|
every branch, every take, and the fork points, since those were chosen rather than computed.
|
||||||
Files saved in the old single-line format still import.
|
Files saved in the old single-line format still import.
|
||||||
- **Optional accounts for hosted deployments** — by default the app is single-user with zero
|
- **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
|
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
|
session cookie), can register (email and password) at any point to keep their data, and each
|
||||||
user gets isolated data plus their own encrypted-at-rest API key. A server-funded **shared
|
user gets isolated data plus their own encrypted-at-rest API key. A server-funded **shared
|
||||||
demo key** with a daily turn cap lets people try it without bringing a key
|
demo key** with a daily turn cap lets people try it without bringing a key
|
||||||
(`backend/app/auth.py`).
|
(`backend/app/auth.py`).
|
||||||
@@ -82,11 +83,11 @@ live one is what starts a new branch.*
|
|||||||
| | |
|
| | |
|
||||||
|---|---|
|
|---|---|
|
||||||
|  |  |
|
|  |  |
|
||||||
| **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. |
|
| **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-labeled 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. |
|
| **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. |
|
| **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
|
## Quick start
|
||||||
|
|
||||||
@@ -127,8 +128,8 @@ Open **Settings** in the app and point it at any OpenAI-compatible endpoint:
|
|||||||
| OpenRouter | `https://openrouter.ai/api/v1` | `:free` models cost nothing (no embeddings on the free tier) |
|
| OpenRouter | `https://openrouter.ai/api/v1` | `:free` models cost nothing (no embeddings on the free tier) |
|
||||||
| OpenAI / Groq / vLLM / … | provider's `/v1` URL | anything speaking `/v1/chat/completions` |
|
| OpenAI / Groq / vLLM / … | provider's `/v1` URL | anything speaking `/v1/chat/completions` |
|
||||||
|
|
||||||
Model name, API key, generation parameters, and (optionally) summary/embedding models for the
|
Model name, API key, generation parameters, and (optionally) summary and embedding models for
|
||||||
Memory Bank are all configured there too — no config files, no rebuild.
|
the Memory Bank are all configured there too. No config files and no rebuild are needed.
|
||||||
|
|
||||||
## How a turn works
|
## How a turn works
|
||||||
|
|
||||||
@@ -168,14 +169,14 @@ frontend/ React + Vite SPA ──HTTP/SSE──► backend/ FastAPI
|
|||||||
└─ data.db SQLite (path overridable via AIDND_DB_PATH)
|
└─ data.db SQLite (path overridable via AIDND_DB_PATH)
|
||||||
```
|
```
|
||||||
|
|
||||||
In production the backend serves the built SPA from one port (see `Dockerfile`); in
|
In production the backend serves the built SPA from one port (see `Dockerfile`). In
|
||||||
development Vite proxies `/api` to FastAPI.
|
development, Vite proxies `/api` to FastAPI.
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
497 backend tests — unit plus full HTTP integration through the real quickjs scripting engine,
|
497 backend tests: unit tests plus full HTTP integration through the real quickjs scripting
|
||||||
with the LLM provider mocked. CI runs them on every push, alongside the frontend lint/build and
|
engine, with the LLM provider mocked. CI runs them on every push, alongside the frontend
|
||||||
a Docker image build.
|
lint/build and a Docker image build.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cd backend && pip install -r requirements.txt -r requirements-dev.txt
|
cd backend && pip install -r requirements.txt -r requirements-dev.txt
|
||||||
@@ -184,71 +185,73 @@ python -m pytest tests/
|
|||||||
|
|
||||||
## Notes on performance
|
## Notes on performance
|
||||||
|
|
||||||
Two of the tests exist because of bugs that were measured rather than guessed at, and they're
|
Two of the tests exist because of bugs that were measured rather than guessed at. They are the
|
||||||
the most interesting engineering in the repo:
|
most interesting engineering in the repo.
|
||||||
|
|
||||||
- **Database egress, cut ~189x.** Every adventure load was pulling `Action.context_snapshot` —
|
- **Database egress, cut about 189x.** Every adventure load pulled `Action.context_snapshot`,
|
||||||
the entire assembled prompt, ~74 KB per turn — to read two small fields off it. Moving those
|
the entire assembled prompt at about 74 KB per turn, to read two small fields off it. Moving
|
||||||
fields into their own columns and marking the heavy ones `deferred` took one adventure load
|
those fields into their own columns and marking the heavy ones `deferred` took one adventure
|
||||||
from 38.5 MB to 0.20 MB. The backfill runs server-side with dialect-specific SQL so the old
|
load from 38.5 MB to 0.20 MB. The backfill runs server-side with dialect-specific SQL, so the
|
||||||
data never crosses the wire. `tests/test_egress.py` hooks into SQLAlchemy's cursor events and
|
old data never crosses the wire. `tests/test_egress.py` hooks into SQLAlchemy's cursor events
|
||||||
fails if a bulk load ever names those columns again.
|
and fails if a bulk load ever names those columns again.
|
||||||
- **Turn cost, made flat.** Assembling a turn walked the whole story, so it was O(story length)
|
- **Turn cost, made flat.** Assembling a turn walked the whole story, so cost scaled with story
|
||||||
— 839 KB of reads at turn 200. `backend/app/context/history.py` now serves tails and slices
|
length: 839 KB of reads at turn 200. `backend/app/context/history.py` now serves tails and
|
||||||
from SQL and measures what it fetched; the same turn costs 129 KB and stops growing at around
|
slices from SQL and measures what it fetched. The same turn now costs 129 KB and stops
|
||||||
turn 50.
|
growing at around turn 50.
|
||||||
- **Branching that doesn't cost anything to read.** A branch stores no turns — it stores where it
|
- **Branching that costs nothing to read.** A branch stores no turns. It stores where it left
|
||||||
left its parent, and borrows everything above that. A 40-turn story forked twenty times loads in
|
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
|
31,652 bytes against 31,433 bytes for the same story flat: a 1.007x ratio, or about 103 bytes
|
||||||
stay cheap because the lineage is windowed like the history is, so the number of SQL clauses is
|
per branch. Reads stay cheap because the lineage is windowed the same way the history is, so
|
||||||
bounded by the context window rather than by the number of forks.
|
the number of SQL clauses is bounded by the context window rather than by the number of
|
||||||
|
forks.
|
||||||
|
|
||||||
## Visit analytics
|
## Visit analytics
|
||||||
|
|
||||||
The hosted demo keeps its own analytics: an owner-only dashboard at `/analytics` showing
|
The hosted demo keeps its own analytics: an owner-only dashboard at `/analytics` shows
|
||||||
traffic, which shared scenarios get played, turns and demo-key spend, errors, and a funnel
|
traffic, which shared scenarios get played, turns and demo-key spend, errors, and a funnel
|
||||||
from *visited* to *played a turn* to *signed up*. It is visible only to the emails listed in
|
from *visited* to *played a turn* to *signed up*. It is visible only to the emails listed in
|
||||||
`AIDND_ANALYTICS_EMAILS`, and the route 404s for everyone else.
|
`AIDND_ANALYTICS_EMAILS`, and the route returns 404 for everyone else.
|
||||||
|
|
||||||
Built into the app rather than bolted on with a third-party script, for reasons specific to
|
This is built into the app rather than added with a third-party script, for reasons specific
|
||||||
this one: the CSP allows `script-src 'self'`, adblockers eat the popular trackers, and none
|
to this project: the CSP allows only `script-src 'self'`, ad blockers block the popular
|
||||||
of them can see the measurement that actually matters here — a turn. Counts are aggregated
|
trackers, and none of those trackers can see the measurement that matters here, a turn. Counts
|
||||||
in memory and flushed as UPSERTs, so a visit is a write and never a read, and every dashboard
|
are aggregated in memory and flushed as UPSERTs, so a visit is a write and never a read, and
|
||||||
query is a `GROUP BY` returning tens of rows however much traffic sits behind it. That last
|
every dashboard query is a `GROUP BY` that returns tens of rows regardless of traffic volume.
|
||||||
part is not incidental; see the egress note above for what reading rows per request costs on
|
That matters: see the egress note above for what reading rows per request costs on this stack.
|
||||||
this stack.
|
|
||||||
|
|
||||||
## Deploy (Render)
|
## Deploy (Render)
|
||||||
|
|
||||||
The repo ships a [`render.yaml`](render.yaml) blueprint: one Docker web service that
|
The repo ships a [`render.yaml`](render.yaml) blueprint: one Docker web service that serves
|
||||||
serves the SPA and API same-origin, backed by external [Neon](https://neon.tech) Postgres
|
the SPA and API same-origin, backed by external [Neon](https://neon.tech) Postgres. The free
|
||||||
(the free tier has no persistent disk, so the database lives off-box).
|
Render tier has no persistent disk, so the database lives off-box.
|
||||||
|
|
||||||
1. Create a **Neon** project and copy its pooled connection string.
|
1. Create a **Neon** project and copy its pooled connection string.
|
||||||
2. In Render: **New → Blueprint**, point it at this repo. Render reads `render.yaml`.
|
2. In Render, choose **New → Blueprint** and point it at this repo. Render reads
|
||||||
3. Fill the secrets it prompts for (`sync: false` vars): `AIDND_DATABASE_URL` (the Neon
|
`render.yaml`.
|
||||||
string); to offer a no-signup demo, `AIDND_DEMO_API_KEY` / `AIDND_DEMO_MODELS`; and to
|
3. Fill in the secrets it prompts for (`sync: false` vars): `AIDND_DATABASE_URL` (the Neon
|
||||||
see the Visitors dashboard, `AIDND_ANALYTICS_EMAILS` (your own account's email).
|
string); `AIDND_DEMO_API_KEY` and `AIDND_DEMO_MODELS` to offer a no-signup demo; and
|
||||||
`AIDND_SECRET_KEY` is generated automatically and kept stable across deploys.
|
`AIDND_ANALYTICS_EMAILS` (your own account's email) to see the Visitors dashboard.
|
||||||
4. Deploy. Pushes to `main` auto-deploy thereafter. Health check: `/api/health`.
|
`AIDND_SECRET_KEY` is generated automatically and stays stable across deploys.
|
||||||
|
4. Deploy. Pushes to `main` auto-deploy after this. The health check is `/api/health`.
|
||||||
|
|
||||||
On the free tier the service sleeps after ~15 min idle; the first request then takes
|
On the free tier the service sleeps after about 15 minutes idle, and the first request after
|
||||||
~30–60s to wake. Point any keep-warm pinger at `/api/health`, which deliberately doesn't
|
that takes about 30 to 60 seconds to wake it. Point any keep-warm pinger at `/api/health`,
|
||||||
touch the database — waking the database around the clock costs far more than the cold start
|
which deliberately doesn't touch the database: waking the database around the clock costs far
|
||||||
is worth.
|
more than the cold start saves.
|
||||||
|
|
||||||
## Repo notes
|
## Repo notes
|
||||||
|
|
||||||
- `plan/` — the phased implementation plan this was built from, kept as a build log. All fourteen
|
- `plan/` holds the phased implementation plan this project was built from, kept as a build
|
||||||
phases are complete; the later files (11, 12, 14) double as design notes for the state-revert,
|
log. All fourteen phases are complete. The later files (11, 12, 14) also serve as design
|
||||||
world-state and story-tree work. [`plan/STATUS.md`](plan/STATUS.md) is the running thread —
|
notes for the state-revert, world-state, and story-tree work.
|
||||||
what shipped, what was measured, and what is owed next.
|
[`plan/STATUS.md`](plan/STATUS.md) is the running thread: what shipped, what was measured,
|
||||||
- [`docs/GUIDE.md`](docs/GUIDE.md) — design notes: how each subsystem works and why it was built
|
and what is owed next.
|
||||||
that way, with the measurements behind the decisions. Also rendered as a
|
- [`docs/GUIDE.md`](docs/GUIDE.md) holds design notes: how each subsystem works and why it was
|
||||||
|
built that way, with the measurements behind the decisions. It is also rendered as a
|
||||||
[reading page](https://parththakkar106.github.io/AI-DnD/guide.html).
|
[reading page](https://parththakkar106.github.io/AI-DnD/guide.html).
|
||||||
- `backend/.env.example` — the few environment variables the backend reads.
|
- `backend/.env.example` lists the few environment variables the backend reads.
|
||||||
- [`docs/self-review.md`](docs/self-review.md) — a full-codebase self-review pass and what came
|
- [`docs/self-review.md`](docs/self-review.md) records a full-codebase self-review pass and
|
||||||
out of it. All correctness findings are resolved.
|
what came out of it. All correctness findings are resolved.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
+164
-163
@@ -108,19 +108,19 @@ player input
|
|||||||
Two design choices are visible in that list before any of the details.
|
Two design choices are visible in that list before any of the details.
|
||||||
|
|
||||||
**The prompt is snapshotted, not reconstructed.** Every AI action stores the exact text
|
**The prompt is snapshotted, not reconstructed.** Every AI action stores the exact text
|
||||||
that was sent to the model. That's what powers the Insights panel — open any turn and see
|
that was sent to the model. That's what powers the Insights panel: open any turn and see
|
||||||
each context component, its token cost, and why it was included. It's also what makes
|
each context component, its token cost, and why it was included. It's also what makes
|
||||||
prompt bugs findable. The cost is storage (~74 KB per turn), which turns into a real
|
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).
|
performance problem later. See [2.5](#25-the-189x-egress-fix).
|
||||||
|
|
||||||
**Every node records the state it leaves behind.** `state_after` and `world_state_after`
|
**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
|
are attached to 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*
|
stats and the RPG values 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
|
a turn is then a read of the node in front of it, which is the same operation as switching
|
||||||
another branch — one mechanism, and it is the whole reason undo, retry and branch
|
to another branch: one mechanism, and the reason undo, retry, and branch switching all put
|
||||||
switching all put the numbers back rather than only rewriting text. (These were `*_before`
|
the numbers back instead of only rewriting text. (These were `*_before` fields originally; a
|
||||||
pictures originally; a tree wants the *after*, because a branch's tip is what a reader
|
tree needs the *after* value, because a branch's tip is what a reader standing on it should
|
||||||
standing on it should see.)
|
see.)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -138,7 +138,7 @@ whether the story stays coherent.
|
|||||||
|
|
||||||
Send the last N turns. That breaks in two directions: N turns of short exchanges wastes the
|
Send the last N turns. That breaks in two directions: N turns of short exchanges wastes the
|
||||||
window, and N turns of long ones overflows it. It also throws away the things that matter
|
window, and N turns of long ones overflows it. It also throws away the things that matter
|
||||||
most — the premise, the character sheet, the fact that you promised the innkeeper you'd
|
most: the premise, the character sheet, the fact that you promised the innkeeper you'd
|
||||||
return.
|
return.
|
||||||
|
|
||||||
### What this app does
|
### What this app does
|
||||||
@@ -149,12 +149,12 @@ Fixed (always included, whatever they cost):
|
|||||||
|
|
||||||
| Section | What it is |
|
| Section | What it is |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `narrator` | The system prompt — how to write. |
|
| `narrator` | The system prompt: how to write. |
|
||||||
| `world_state_guide` | The stat legend: what each stat means, its range, its bands. |
|
| `world_state_guide` | The stat legend: what each stat means, its range, its bands. |
|
||||||
| `world_state` | Current values of every stat, plus NPCs in scene. |
|
| `world_state` | Current values of every stat, plus NPCs in scene. |
|
||||||
| `world_state_rule` | How to report changes. |
|
| `world_state_rule` | How to report changes. |
|
||||||
| `ai_instructions` | Per-adventure steering. |
|
| `ai_instructions` | Per-adventure steering. |
|
||||||
| `plot_essentials` | AI Dungeon's "Memory" — the premise. |
|
| `plot_essentials` | AI Dungeon's "Memory": the premise. |
|
||||||
| `story_summary` | The auto-maintained running summary. |
|
| `story_summary` | The auto-maintained running summary. |
|
||||||
| `used_memories` | Top-K retrievals from the memory bank. |
|
| `used_memories` | Top-K retrievals from the memory bank. |
|
||||||
|
|
||||||
@@ -184,7 +184,7 @@ of "the model has no idea what just happened". Cards that don't fit are still *r
|
|||||||
to Insights with `included: false`, so the UI can show the lore that got squeezed out.
|
to Insights with `included: false`, so the UI can show the lore that got squeezed out.
|
||||||
|
|
||||||
**History fills newest-first and stops.** Oldest turns fall out. This is the right
|
**History fills newest-first and stops.** Oldest turns fall out. This is the right
|
||||||
direction because the old material is not actually lost — it has been summarized into
|
direction because the old material is not actually lost: it has been summarized into
|
||||||
memories and the running summary, which are in the fixed section.
|
memories and the running summary, which are in the fixed section.
|
||||||
|
|
||||||
**If even the single newest turn is over budget, it gets hard-truncated** rather than
|
**If even the single newest turn is over budget, it gets hard-truncated** rather than
|
||||||
@@ -193,8 +193,8 @@ of the last turn produces something.
|
|||||||
|
|
||||||
**The author's note is injected 3 actions from the end**, not at the top.
|
**The author's note is injected 3 actions from the end**, not at the top.
|
||||||
`AUTHORS_NOTE_DEPTH = 3`. Instructions placed near the end of a prompt have more influence
|
`AUTHORS_NOTE_DEPTH = 3`. Instructions placed near the end of a prompt have more influence
|
||||||
on what comes next than instructions at the top — recency. The author's note is a steering
|
on what comes next than instructions at the top, because of recency. The author's note is a
|
||||||
control ("keep it tense"), so it goes where steering works.
|
steering control ("keep it tense"), so it goes where steering works.
|
||||||
|
|
||||||
**The world-state reminder goes dead last.** The full emit rule lives up in the system
|
**The world-state reminder goes dead last.** The full emit rule lives up in the system
|
||||||
block, hundreds of tokens away from where the model starts writing. A one-line reminder
|
block, hundreds of tokens away from where the model starts writing. A one-line reminder
|
||||||
@@ -203,15 +203,15 @@ forgotten.
|
|||||||
|
|
||||||
**Past AI turns get their state block re-attached.** The state block is stripped from the
|
**Past AI turns get their state block re-attached.** The state block is stripped from the
|
||||||
text before it's stored, so a replayed history would show the model twenty of its own past
|
text before it's stored, so a replayed history would show the model twenty of its own past
|
||||||
turns that *contain no state block* — which teaches it, by imitation, to stop emitting one.
|
turns that *contain no state block*. That teaches it, by imitation, to stop emitting one.
|
||||||
So `_history_text()` reconstructs the block from the stored delta and re-appends it when
|
So `_history_text()` reconstructs the block from the stored delta and re-appends it when
|
||||||
building history. The model sees its own pattern and keeps following it.
|
building history. The model sees its own pattern and keeps following it.
|
||||||
|
|
||||||
### The performance trap hiding in this
|
### The performance trap hiding in this
|
||||||
|
|
||||||
Building the context needs the newest ~6,000 tokens of story. The obvious implementation
|
Building the context needs the newest ~6,000 tokens of story. The obvious implementation
|
||||||
reads `adventure.actions` — which loads every row of the adventure — and then throws 90%
|
reads `adventure.actions`, which loads every row of the adventure, and then throws 90% of
|
||||||
of it away. At turn 200 that was 839 KB of database reads to use maybe 70 KB, and it grew
|
it away. At turn 200 that was 839 KB of database reads to use maybe 70 KB, and it grew
|
||||||
every single turn.
|
every single turn.
|
||||||
|
|
||||||
`backend/app/context/history.py` fixes it by serving three shapes directly from SQL: a
|
`backend/app/context/history.py` fixes it by serving three shapes directly from SQL: a
|
||||||
@@ -225,13 +225,13 @@ projected = int(budget / average * 1.15) + 8
|
|||||||
```
|
```
|
||||||
|
|
||||||
Each round fetches only what it doesn't already hold, so no row is read twice. Result: the
|
Each round fetches only what it doesn't already hold, so no row is read twice. Result: the
|
||||||
same turn costs 129 KB instead of 839 KB, and stops growing at around turn 50 — the cost
|
same turn costs 129 KB instead of 839 KB, and stops growing at around turn 50. The cost is
|
||||||
is bounded by the context budget instead of by the length of the story.
|
bounded by the context budget instead of by the length of the story.
|
||||||
|
|
||||||
There's a second rule in that module worth naming: **if the actions are already loaded in
|
There's a second rule in that module worth naming: **if the actions are already loaded in
|
||||||
memory, slice them instead of querying.** The scripting pipeline hands the whole history to
|
memory, slice them instead of querying.** The scripting pipeline hands the whole history to
|
||||||
user scripts (AI Dungeon's API requires it), so on a scripted adventure the rows are
|
user scripts (AI Dungeon's API requires it), so on a scripted adventure the rows are already
|
||||||
already there — issuing a query beside them would mean paying twice.
|
there. Issuing a query beside them would mean paying twice.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -241,19 +241,19 @@ Source: `backend/app/worldstate/engine.py`, `plan/12-phase-rpg-world-state.md`.
|
|||||||
|
|
||||||
### The problem
|
### The problem
|
||||||
|
|
||||||
You want an RPG layer — hit points, trust, quest progress. Who owns the numbers?
|
You want an RPG layer: hit points, trust, quest progress. Who owns the numbers?
|
||||||
|
|
||||||
### Three options, and why two lose
|
### Three options, and why two lose
|
||||||
|
|
||||||
**Option A: a deterministic dice engine.** The player types "attack the goblin", the
|
**Option A: a deterministic dice engine.** The player types "attack the goblin", the
|
||||||
engine rolls, applies damage, and the model narrates the result. This is what a real RPG
|
engine rolls, applies damage, and the model narrates the result. This is what a real RPG
|
||||||
does. It loses here because the action space is unbounded — the player can type anything,
|
does. It loses here because the action space is unbounded: the player can type anything, and
|
||||||
and mapping arbitrary natural language onto a fixed rules system is a harder problem than
|
mapping arbitrary natural language onto a fixed rules system is a harder problem than the
|
||||||
the one being solved.
|
one being solved.
|
||||||
|
|
||||||
**Option B: the model owns the numbers.** Let it track hp in the prose and trust it. This
|
**Option B: the model owns the numbers.** Let it track hp in the prose and trust it. This
|
||||||
fails immediately. Models are bad at arithmetic, worse at remembering a number across
|
fails immediately. Models are bad at arithmetic, worse at remembering a number across
|
||||||
twenty turns, and completely unable to obey their own frequency rules — tell one "only
|
twenty turns, and completely unable to obey their own frequency rules. Tell one "only
|
||||||
change this every 5 turns" and it will change it every turn.
|
change this every 5 turns" and it will change it every turn.
|
||||||
|
|
||||||
**Option C, chosen: the model proposes, the engine disposes.** The model narrates and
|
**Option C, chosen: the model proposes, the engine disposes.** The model narrates and
|
||||||
@@ -279,10 +279,10 @@ The engine then applies, in order:
|
|||||||
| `max_delta_per_turn` | Losing 90 hp to a stubbed toe |
|
| `max_delta_per_turn` | Losing 90 hp to a stubbed toe |
|
||||||
| Clamp to `min`/`max` | Negative hp, trust above 100 |
|
| Clamp to `min`/`max` | Negative hp, trust above 100 |
|
||||||
| Milestones are sticky, `true` only | Un-completing a quest |
|
| Milestones are sticky, `true` only | Un-completing a quest |
|
||||||
| Flags are two-way booleans | (Deliberately unrestricted — that's what flags are for) |
|
| Flags are two-way booleans | (Deliberately unrestricted: that's what flags are for) |
|
||||||
|
|
||||||
Everything it rejects is *reported*, not silently swallowed — the Insights panel shows
|
Everything it rejects is *reported*, not silently swallowed. The Insights panel shows
|
||||||
applied, clamped and rejected paths per turn, and the chip under each narration shows what
|
applied, clamped, and rejected paths per turn, and the chip under each narration shows what
|
||||||
actually changed.
|
actually changed.
|
||||||
|
|
||||||
### The reliability mechanism: word bands
|
### The reliability mechanism: word bands
|
||||||
@@ -295,33 +295,33 @@ A stat can carry **bands**:
|
|||||||
[60,90,"healthy"],[90,100,"full health"]] }
|
[60,90,"healthy"],[90,100,"full health"]] }
|
||||||
```
|
```
|
||||||
|
|
||||||
Two things use them. The live state block shows the current band label — `hp 55/100 (minor
|
Two things use them. The live state block shows the current band label, `hp 55/100 (minor
|
||||||
damage)` — so the model reads a *word*, not just a number. And the stat guide shows the
|
damage)`, so the model reads a *word*, not just a number. And the stat guide shows the
|
||||||
whole ladder once per turn, so the model can see the full scale it's reasoning across.
|
whole ladder once per turn, so the model can see the full scale it's reasoning across.
|
||||||
|
|
||||||
The point: models reason well over semantics and badly over arithmetic. "He's badly hurt,
|
The point: models reason well over semantics and badly over arithmetic. "He's badly hurt,
|
||||||
so a solid hit should take him to very weak" is a judgement a model can make. "55 minus 22
|
so a solid hit should take him to very weak" is a judgment a model can make. "55 minus 22 is
|
||||||
is 33" is one it will get wrong often enough to matter.
|
33" is one it will get wrong often enough to matter.
|
||||||
|
|
||||||
### The failure philosophy
|
### The failure philosophy
|
||||||
|
|
||||||
Nothing in the world-state engine raises. A malformed delta returns `{}` and the turn
|
Nothing in the world-state engine raises. A malformed delta returns `{}` and the turn
|
||||||
continues. The parser is deliberately tolerant — it strips trailing commas and leading `+`
|
continues. The parser is deliberately tolerant: it strips trailing commas and leading `+`
|
||||||
signs on numbers, both of which weaker free models emit and strict JSON rejects. It accepts
|
signs on numbers, both of which weaker free models emit and strict JSON rejects. It accepts
|
||||||
a fence labelled `state`, one labelled `json`, or an unlabelled one, and falls back to a
|
a fence labeled `state`, one labeled `json`, or an unlabeled one, and falls back to a bare
|
||||||
bare JSON object hugging the end of the text — but only if it parses into something that
|
JSON object at the end of the text, but only if it parses into something that looks like a
|
||||||
looks like a delta, so prose ending in `}` is never eaten.
|
delta, so prose ending in `}` is never eaten.
|
||||||
|
|
||||||
This matters because the hosted demo runs on free-tier models. A stricter parser would mean
|
This matters because the hosted demo runs on free-tier models. A stricter parser would mean
|
||||||
a good model works and a free one doesn't.
|
a good model works and a free one doesn't.
|
||||||
|
|
||||||
### One call, not two
|
### One call, not two
|
||||||
|
|
||||||
The model narrates *and* emits the delta in a single request. The alternative — narrate,
|
The model narrates *and* emits the delta in a single request. The alternative, narrating and
|
||||||
then a second call to extract structured state — is more reliable per call and costs twice
|
then making a second call to extract structured state, is more reliable per call and costs
|
||||||
the latency and twice the rate-limit budget. On the free tier (20 requests/minute) that
|
twice the latency and twice the rate-limit budget. On the free tier (20 requests/minute)
|
||||||
would halve the playable turn rate. The tolerant parser plus the terminal reminder was the
|
that would halve the playable turn rate. The tolerant parser plus the terminal reminder was
|
||||||
cheaper way to buy the same reliability.
|
the cheaper way to buy the same reliability.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -339,9 +339,9 @@ Tell the model its budget: *"keep this turn under about N words"*.
|
|||||||
|
|
||||||
### What the measurement showed
|
### What the measurement showed
|
||||||
|
|
||||||
Average turn length went from **174 words to 246** — every run longer than every unhinted
|
Average turn length went from **174 words to 246**, and every run was longer than every
|
||||||
run (n=5). Phrased as a budget, the number reads as a *target to fill*. The hint pushed
|
unhinted run (n=5). Phrased as a budget, the number reads as a *target to fill*. The hint
|
||||||
turns toward the very wall it existed to protect.
|
pushed turns toward the very wall it existed to protect.
|
||||||
|
|
||||||
### The fix
|
### The fix
|
||||||
|
|
||||||
@@ -361,12 +361,12 @@ Average came back to 170 words, and the state block survived at tight caps.
|
|||||||
words = int((max_output_tokens - 50) * 0.75 * 0.90)
|
words = int((max_output_tokens - 50) * 0.75 * 0.90)
|
||||||
```
|
```
|
||||||
|
|
||||||
- `- 50` (`LENGTH_HEADROOM`) — tokens held back for the state block itself.
|
- `- 50` (`LENGTH_HEADROOM`): tokens held back for the state block itself.
|
||||||
- `* 0.75` (`WORDS_PER_TOKEN`) — models can't count their own tokens, but they do follow a
|
- `* 0.75` (`WORDS_PER_TOKEN`): models can't count their own tokens, but they do follow a
|
||||||
word budget. English prose is roughly 0.75 words per token.
|
word budget. English prose is roughly 0.75 words per token.
|
||||||
- `* 0.90` (`LENGTH_BUFFER`) — a word budget is a suggestion the model overshoots; the cap
|
- `* 0.90` (`LENGTH_BUFFER`): a word budget is a suggestion the model overshoots, and the cap
|
||||||
it protects is a hard wall. Aim 10% short so the overshoot lands in slack.
|
it protects is a hard wall. Aim 10% short so the overshoot lands in slack.
|
||||||
- Below 40 words the hint is dropped entirely — it stops earning its tokens.
|
- Below 40 words the hint is dropped entirely: it stops earning its tokens.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -377,7 +377,7 @@ Source: `backend/app/memorybank.py`.
|
|||||||
### The problem
|
### The problem
|
||||||
|
|
||||||
Story history falls out of the context window as the adventure grows. Turn 4 said you
|
Story history falls out of the context window as the adventure grows. Turn 4 said you
|
||||||
promised the innkeeper you'd return. At turn 90 that's long gone from the prompt — but if
|
promised the innkeeper you'd return. At turn 90 that's long gone from the prompt, but if
|
||||||
you walk back into the inn, it should come back.
|
you walk back into the inn, it should come back.
|
||||||
|
|
||||||
### The three layers
|
### The three layers
|
||||||
@@ -401,23 +401,23 @@ back into the prompt.
|
|||||||
|
|
||||||
### The decisions inside it
|
### The decisions inside it
|
||||||
|
|
||||||
**A memory hangs off the node whose block it ends on.** Not off the adventure, and not off
|
**A memory attaches to the node whose block it ends on.** Not to the adventure, and not to
|
||||||
a *position* in a list of actions — off a `(branch_id, depth)` coordinate. That is what
|
a *position* in a list of actions, but to a `(branch_id, depth)` coordinate. That is what
|
||||||
makes "which memories described this turn?" an indexed lookup rather than a scan for rows
|
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
|
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
|
inherit correctly across a fork: the ones above the fork point already sit on ancestors
|
||||||
both lines read.
|
both lines read.
|
||||||
|
|
||||||
It is also the repair. When a turn's text is replaced or removed — a retry, an undo, a
|
This is also how repair works. When a turn's text is replaced or removed (a retry, an undo,
|
||||||
deleted action — `forget_node` withdraws the memory hanging off that coordinate *and*
|
a deleted action), `forget_node` withdraws the memory attached to that coordinate and
|
||||||
rewinds both marks to just before the stretch it covered, so the ground is summarized again
|
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
|
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,
|
so it could never be summarized before it stopped being retryable. That is no longer
|
||||||
because the repair exists whether or not the invalidation happens at the tip.
|
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
|
**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
|
summarization fails, the function returns and the cursor is unchanged, so the same block is
|
||||||
retried on a later turn. There is no retry loop, no dead-letter queue, no backoff — the
|
retried on a later turn. There is no retry loop, no dead-letter queue, and no backoff: the
|
||||||
cadence *is* the retry mechanism. Failures are logged to the debug page.
|
cadence *is* the retry mechanism. Failures are logged to the debug page.
|
||||||
|
|
||||||
**Summarization is fire-and-forget, in a background task with its own DB session.** The
|
**Summarization is fire-and-forget, in a background task with its own DB session.** The
|
||||||
@@ -437,7 +437,7 @@ truncate and score garbage silently. An explicit length check returns 0.0 instea
|
|||||||
|
|
||||||
**Eviction is LRU-ish, and evicted memories are kept.** Over capacity (default 200), the
|
**Eviction is LRU-ish, and evicted memories are kept.** Over capacity (default 200), the
|
||||||
least-used, least-recently-used unpinned memories are marked `forgotten` rather than
|
least-used, least-recently-used unpinned memories are marked `forgotten` rather than
|
||||||
deleted — so the UI can still show them and you can un-forget one.
|
deleted, so the UI can still show them and you can un-forget one.
|
||||||
|
|
||||||
**Background calls never spend the shared demo key.** The summarization and embedding
|
**Background calls never spend the shared demo key.** The summarization and embedding
|
||||||
providers are built directly from the user's own settings and never from the demo config,
|
providers are built directly from the user's own settings and never from the demo config,
|
||||||
@@ -454,7 +454,7 @@ anything makes a 20-second generation feel broken.
|
|||||||
|
|
||||||
**Server-Sent Events (SSE)** is the mechanism: an HTTP response that stays open and pushes
|
**Server-Sent Events (SSE)** is the mechanism: an HTTP response that stays open and pushes
|
||||||
`data: {...}` lines as they become available. It's one-directional (server → browser),
|
`data: {...}` lines as they become available. It's one-directional (server → browser),
|
||||||
which is exactly what's needed here — WebSockets would be a bidirectional connection for a
|
which is exactly what's needed here. WebSockets would be a bidirectional connection for a
|
||||||
unidirectional problem.
|
unidirectional problem.
|
||||||
|
|
||||||
The chain:
|
The chain:
|
||||||
@@ -473,7 +473,7 @@ text), `stopped` (a script blocked the turn), `error`, `done`.
|
|||||||
|
|
||||||
Two production details that only show up when hosted:
|
Two production details that only show up when hosted:
|
||||||
|
|
||||||
- `X-Accel-Buffering: no` — nginx-style reverse proxies buffer responses by default, which
|
- `X-Accel-Buffering: no`: nginx-style reverse proxies buffer responses by default, which
|
||||||
turns a stream into one big delivery at the end. This header tells them to flush each
|
turns a stream into one big delivery at the end. This header tells them to flush each
|
||||||
event.
|
event.
|
||||||
- The security-headers and body-size middlewares are written as **pure ASGI** rather than
|
- The security-headers and body-size middlewares are written as **pure ASGI** rather than
|
||||||
@@ -481,7 +481,7 @@ Two production details that only show up when hosted:
|
|||||||
break streaming.
|
break streaming.
|
||||||
|
|
||||||
**The empty-reply case is diagnosed, not reported as "empty".** If a reasoning model
|
**The empty-reply case is diagnosed, not reported as "empty".** If a reasoning model
|
||||||
streams thinking but no story text, it spent its whole budget thinking — the error says so
|
streams thinking but no story text, it spent its whole budget thinking. The error says so
|
||||||
and tells you which three settings to change.
|
and tells you which three settings to change.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -498,18 +498,18 @@ The safety properties are mostly structural:
|
|||||||
|
|
||||||
| Property | How |
|
| Property | How |
|
||||||
|---|---|
|
|---|---|
|
||||||
| No filesystem, network, or process access | QuickJS has none by default — nothing was removed, nothing was added |
|
| No filesystem, network, or process access | QuickJS has none by default: nothing was removed, nothing was added |
|
||||||
| Memory cap | 16 MB per run |
|
| Memory cap | 16 MB per run |
|
||||||
| CPU cap | 2 seconds per run |
|
| CPU cap | 2 seconds per run |
|
||||||
| No shared state between runs | A fresh `Context` per hook execution |
|
| No shared state between runs | A fresh `Context` per hook execution |
|
||||||
| A broken script can't break a turn | Every failure comes back as `.error` with text/state/cards unchanged; the pipeline logs it and continues |
|
| A broken script can't break a turn | Every failure comes back as `.error` with text/state/cards unchanged; the pipeline logs it and continues |
|
||||||
|
|
||||||
Data crosses the boundary as JSON — Python serializes `{state, text, history, storyCards,
|
Data crosses the boundary as JSON: Python serializes `{state, text, history, storyCards,
|
||||||
info}` in, and the script's results out. There is no object bridge to exploit.
|
info}` in, and the script's results out. There is no object bridge to exploit.
|
||||||
|
|
||||||
One deliberate bug-compatibility: `addStoryCard` returns the new card's *index*, so the
|
One deliberate bug-compatibility: `addStoryCard` returns the new card's *index*, so the
|
||||||
first card returns `0`, which is falsy, so `if (!addStoryCard(...))` misfires. That's
|
first card returns `0`, which is falsy, so `if (!addStoryCard(...))` misfires. That's
|
||||||
upstream AI Dungeon's behaviour. It's documented in the code and left alone, because
|
upstream AI Dungeon's behavior. It's documented in the code and left alone, because
|
||||||
matching real scripts is the whole point of the feature.
|
matching real scripts is the whole point of the feature.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -517,7 +517,7 @@ matching real scripts is the whole point of the feature.
|
|||||||
## 1.8 Why there is no agent framework
|
## 1.8 Why there is no agent framework
|
||||||
|
|
||||||
Graph-based agent frameworks (LangGraph and similar) earn their complexity with
|
Graph-based agent frameworks (LangGraph and similar) earn their complexity with
|
||||||
**branching, cyclic, multi-step control flow** — a graph of nodes where the path depends on
|
**branching, cyclic, multi-step control flow**: a graph of nodes where the path depends on
|
||||||
what the model decides, with loops, retries, tool calls, and persisted state between steps.
|
what the model decides, with loops, retries, tool calls, and persisted state between steps.
|
||||||
|
|
||||||
This turn pipeline is a **fixed linear sequence with exactly one model call**. There is no
|
This turn pipeline is a **fixed linear sequence with exactly one model call**. There is no
|
||||||
@@ -533,13 +533,13 @@ abstraction, its serialization model, and its debugging surface to express a str
|
|||||||
There is also a specific reason a framework's context handling wouldn't fit: **the
|
There is also a specific reason a framework's context handling wouldn't fit: **the
|
||||||
budgeting logic is the product.** Buffer-window and summary-memory abstractions are
|
budgeting logic is the product.** Buffer-window and summary-memory abstractions are
|
||||||
opinionated about how to fit history into a window. Here the Insights panel exposes each
|
opinionated about how to fit history into a window. Here the Insights panel exposes each
|
||||||
context component, its token cost, and the trigger word that pulled it in — which means the
|
context component, its token cost, and the trigger word that pulled it in, so the assembly
|
||||||
assembly has to be explicit and inspectable.
|
has to be explicit and inspectable.
|
||||||
|
|
||||||
**When it would be the right call:** if the design went toward the two-call version —
|
**When it would be the right call:** if the design went toward the two-call version, where
|
||||||
narrate, then a separate structured-extraction step, with a retry branch when extraction
|
narration is followed by a separate structured-extraction step, with a retry branch when
|
||||||
fails and a tool-calling path for dice — that is a graph, and hand-rolling it would get
|
extraction fails and a tool-calling path for dice, that is a graph, and hand-rolling it
|
||||||
ugly fast.
|
would get ugly fast.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -576,8 +576,8 @@ behind it.
|
|||||||
### The problem
|
### The problem
|
||||||
|
|
||||||
The story used to be a list, and a mutable one. Retry rewrote the last entry in place;
|
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 —
|
undo and delete removed entries from the middle. Everything derived from the story, such as
|
||||||
the memories, the running summary, the two marks saying how far each had got — was
|
the memories, the running summary, and the two marks saying how far each had got, was
|
||||||
indexed by *position in that list*, and a position means something different after
|
indexed by *position in that list*, and a position means something different after
|
||||||
anything in front of it is deleted.
|
anything in front of it is deleted.
|
||||||
|
|
||||||
@@ -603,8 +603,8 @@ 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
|
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
|
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
|
fork point from its ancestors. Nothing is ever copied, and apart from an explicit delete,
|
||||||
delete — nothing is ever removed.
|
nothing is ever removed.
|
||||||
|
|
||||||
```
|
```
|
||||||
branches(id, adventure_id, parent_branch_id, fork_depth, lineage, name)
|
branches(id, adventure_id, parent_branch_id, fork_depth, lineage, name)
|
||||||
@@ -636,38 +636,38 @@ keep the shape they already had.
|
|||||||
### The lineage, and why fork count doesn't cost anything
|
### 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
|
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
|
`lineage`, for example `[(C, ∞), (B, 5), (A, 3)]`, computed once when the fork happens,
|
||||||
parent's lineage plus one entry. `context/lineage.py` is the only module that knows how
|
from the parent's lineage plus one entry. `context/lineage.py` is the only module that
|
||||||
to turn it into a query, which is deliberate: one forgotten clause shows the wrong story
|
knows how to turn it into a query, which is deliberate: one forgotten clause shows the
|
||||||
and reports nothing.
|
wrong story and reports nothing.
|
||||||
|
|
||||||
Two properties of the shape do the real work:
|
Two properties of the shape do the real work:
|
||||||
|
|
||||||
- **The ranges are disjoint and descending.** A branch's own nodes always sit deeper than
|
- **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.
|
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,
|
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.
|
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
|
- **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
|
whose newest branch is 40 turns long reads with *one* clause, because the window is
|
||||||
covered before the second entry is reached.
|
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
|
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
|
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
|
flat: a page load of **31,652 B against 31,433 B, a 1.007× ratio**, or about **103 bytes
|
||||||
branch**. No migration, no vacuum, no copy.
|
per branch**. No migration, no vacuum, no copy.
|
||||||
|
|
||||||
### What a player actually does
|
### What a player actually does
|
||||||
|
|
||||||
None of the above is what the screen shows. In the player's words:
|
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
|
> 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 —
|
> 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
|
> 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.
|
> 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
|
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*
|
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
|
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
|
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
|
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
|
distinction survives in the implementation, where it decides whether a write needs a
|
||||||
@@ -678,7 +678,7 @@ switch and no branch is created.
|
|||||||
|
|
||||||
The load-bearing detail, and the one that is not obvious.
|
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,
|
The natural way to find "the other takes of this turn" is by coordinate: same branch,
|
||||||
same depth. It is wrong in both directions:
|
same depth. It is wrong in both directions:
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -693,8 +693,8 @@ land on different branches. It gets `C` wrong: once C has been forked onto a bra
|
|||||||
its own it is alone at its coordinate and reads `1/1`, having lost C1 and C2 from a pager
|
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`.
|
that must still say `1/3`.
|
||||||
|
|
||||||
So a node carries `parent_id`, read for nothing but this. The alternative — making a
|
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
|
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
|
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
|
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
|
itself, dragging in the cursors, memory depths and both bundle formats. `parent_id` is
|
||||||
@@ -702,7 +702,7 @@ one indexed lookup, never a walk, and nothing about how a path resolves changes.
|
|||||||
|
|
||||||
### Cursors become anchors
|
### Cursors become anchors
|
||||||
|
|
||||||
The two marks — how far the memory bank has got, how far the summary has got — used to
|
The two marks, how far the memory bank has got and 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
|
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
|
them and translating between positions and `Action.index` existed to patch up the fact
|
||||||
that the list moves.
|
that the list moves.
|
||||||
@@ -720,23 +720,23 @@ existed because a retry could rewrite an action the mark had already passed.
|
|||||||
|
|
||||||
### Derived work attaches to the node that produced it
|
### Derived work attaches to the node that produced it
|
||||||
|
|
||||||
Generalise the rule and a lot falls out: *anything derived hangs off the node that
|
Generalize the rule and a lot falls out: *anything derived attaches to the node that
|
||||||
produced it*. A memory covering depths 37–42 hangs off that branch's node 42 and is
|
produced it*. A memory covering depths 37–42 attaches to that branch's node 42 and is
|
||||||
invisible to any path that does not run through it. Shared ancestors are therefore shared
|
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
|
automatically, so **a fork needs nothing recreated**: the memories above the fork point
|
||||||
are already on the ancestors both lines read.
|
are already on the ancestors both lines read.
|
||||||
|
|
||||||
The subtle case is the memory sitting *at* the forked coordinate. The first cut moved it
|
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
|
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
|
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 staying on the parent. The right answer needs no code: the lineage caps the parent one
|
||||||
one depth short of the fork, so the memory is simply out of range from the new branch,
|
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
|
invisible to both the retrieval clause and the anchor read. The new line summarizes that
|
||||||
ground again, from the text it actually tells.
|
ground again, from the text it actually tells.
|
||||||
|
|
||||||
Hand-written memories obey the same rule. One used to carry a NULL depth, described as
|
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
|
"belongs to the adventure rather than to a path". That sounds harmless and is not: a NULL
|
||||||
NULL is a coordinate no fork can cap, so a note typed on one line followed the reader onto
|
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
|
branches whose events it never described. They are anchored at the head instead: *the
|
||||||
story you were reading when you wrote it*.
|
story you were reading when you wrote it*.
|
||||||
|
|
||||||
@@ -747,7 +747,7 @@ also why branch management could not be a nice-to-have: without a way to delete
|
|||||||
storage grows without limit.
|
storage grows without limit.
|
||||||
|
|
||||||
The delete rule has two halves and the second is easy to miss. Refusing to delete the
|
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** —
|
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
|
`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
|
`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
|
own lineage covers both, because a lineage already names itself and every branch it
|
||||||
@@ -757,7 +757,7 @@ button can say so before it is pressed.
|
|||||||
### The migration, and what it deliberately did not do
|
### 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
|
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
|
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
|
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
|
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.
|
context builder, the memory bank, undo and retry at once.
|
||||||
@@ -767,8 +767,8 @@ were kept unread for a release rather than dropped with the migration that stopp
|
|||||||
them, so that a redeploy of the previous build is still a way out. Dropping columns is the
|
them, so that a redeploy of the previous build is still a way out. Dropping columns is the
|
||||||
one step that isn't.
|
one step that isn't.
|
||||||
|
|
||||||
One operational note that generalises: on Postgres, a migration that rewrites every row of
|
One operational note that generalizes: on Postgres, a migration that rewrites every row of
|
||||||
`actions` roughly doubles the table and only `VACUUM FULL` gives it back — 79 MB reclaimed
|
`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%
|
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
|
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())`
|
existing TOAST pointer and costs a tenth of that. Read the sizes from `sum(octet_length())`
|
||||||
@@ -779,14 +779,14 @@ makes bloat look smaller.
|
|||||||
|
|
||||||
- **The two marks are one pair on the adventure**, not one per branch. Switching branches
|
- **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
|
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
|
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
|
work, never skip it. But switching back and forth costs AI calls. Per-branch cursors
|
||||||
are the fix if it ever matters.
|
are the fix if it ever matters.
|
||||||
- **Story cards stay adventure-wide.** A card invented on branch B shows on branch A.
|
- **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.
|
Event-sourcing card changes onto nodes was considered and rejected.
|
||||||
- **Editing an already-summarized action still leaves its memory stale.** The machinery to
|
- **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
|
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.
|
the player typed. It does not do that yet.
|
||||||
|
|
||||||
## 2.3 Undo and retry that actually rewind
|
## 2.3 Undo and retry that actually rewind
|
||||||
|
|
||||||
@@ -794,17 +794,17 @@ 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
|
mutates three things: the text, the scripting scoreboard (`script_state`), and the RPG
|
||||||
stats (`world_state`).
|
stats (`world_state`).
|
||||||
|
|
||||||
**The mechanism:** every node carries `state_after` and `world_state_after` — deep copies
|
**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
|
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
|
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
|
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
|
`_meta.last_changed`, so each line of the story carries its own without anything having to
|
||||||
know there is one.
|
know there is one.
|
||||||
|
|
||||||
**Nothing a retry replaces is thrown away.** The old attempt stays as another **take** of
|
**Nothing a retry replaces is discarded.** The old attempt stays as another **take** of
|
||||||
that turn — a sibling node at the same coordinate, `live` false — and the pager steps
|
that turn, a sibling node at the same coordinate with `live` set to false, and the pager
|
||||||
between them. Which is to say retry is not a special case: it is the tree, with the branch
|
steps between them. Retry is not a special case: it is the tree, with the branch not yet
|
||||||
not yet created. See [2.2](#22-the-story-is-a-tree).
|
created. See [2.2](#22-the-story-is-a-tree).
|
||||||
|
|
||||||
Three details that are easy to get wrong:
|
Three details that are easy to get wrong:
|
||||||
|
|
||||||
@@ -825,9 +825,10 @@ take belonging to a line nobody asked about. Anything that reads a take group an
|
|||||||
*writes* has to say whether it means the turn or the coordinate.
|
*writes* has to say whether it means the turn or the coordinate.
|
||||||
|
|
||||||
**If the regeneration fails, the rollback is reversed.** `generate_turn` wraps the
|
**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,
|
generator in a `try/finally`: if it ends without saving, whether from a provider error, an
|
||||||
a script `stop`, or the browser hanging up — the previous take is put back in charge.
|
empty reply, a script `stop`, or the browser hanging up, the previous take is put back in
|
||||||
Otherwise the state on the server would drift from the text still on the user's screen.
|
charge. Otherwise the state on the server would drift from the text still on the user's
|
||||||
|
screen.
|
||||||
|
|
||||||
## 2.4 The turn lock
|
## 2.4 The turn lock
|
||||||
|
|
||||||
@@ -854,11 +855,11 @@ async def with_turn_lock(adventure_id, gen): # wraps the SSE generator
|
|||||||
```
|
```
|
||||||
|
|
||||||
In-memory, so it's a single-process guarantee. That's honest for the deployment this
|
In-memory, so it's a single-process guarantee. That's honest for the deployment this
|
||||||
targets — one Render web service. Two processes would need the lock in the database.
|
targets: one Render web service. Two processes would need the lock in the database.
|
||||||
|
|
||||||
## 2.5 The 189x egress fix
|
## 2.5 The 189x egress fix
|
||||||
|
|
||||||
**The setup:** `Action.context_snapshot` holds the entire assembled prompt for a turn —
|
**The setup:** `Action.context_snapshot` holds the entire assembled prompt for a turn,
|
||||||
about 74 KB per row, 94% of the database.
|
about 74 KB per row, 94% of the database.
|
||||||
|
|
||||||
**The bug:** every adventure load pulled that column for every action, to read two small
|
**The bug:** every adventure load pulled that column for every action, to read two small
|
||||||
@@ -869,7 +870,7 @@ report). SQLAlchemy loads all columns by default.
|
|||||||
|
|
||||||
1. Move the two small things that *are* needed for every action into their own column
|
1. Move the two small things that *are* needed for every action into their own column
|
||||||
(`Action.world_delta`).
|
(`Action.world_delta`).
|
||||||
2. Mark the heavy columns `deferred` — `context_snapshot`, `variants`, `reasoning` — so
|
2. Mark the heavy columns `deferred` (`context_snapshot`, `variants`, `reasoning`), so
|
||||||
they're only fetched when explicitly asked for.
|
they're only fetched when explicitly asked for.
|
||||||
3. Backfill the new column with dialect-specific server-side SQL, so the old data is
|
3. Backfill the new column with dialect-specific server-side SQL, so the old data is
|
||||||
extracted inside the database and never crosses the wire.
|
extracted inside the database and never crosses the wire.
|
||||||
@@ -883,7 +884,7 @@ not on a timing.
|
|||||||
|
|
||||||
One more detail from that test's design: the count query is written as a real
|
One more detail from that test's design: the count query is written as a real
|
||||||
`SELECT count(...)` rather than `query.count()`, because SQLAlchemy's `.count()` wraps the
|
`SELECT count(...)` rather than `query.count()`, because SQLAlchemy's `.count()` wraps the
|
||||||
entity select in a subquery, so the emitted SQL names every column — including the deferred
|
entity select in a subquery, so the emitted SQL names every column, including the deferred
|
||||||
ones. No bytes come back either way, but the database still has to read them, and a guard
|
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.
|
that greps SQL cannot tell the two apart.
|
||||||
|
|
||||||
@@ -899,7 +900,7 @@ No Alembic. An append-only list of `(version, SQL)` pairs, with the current vers
|
|||||||
in SQLite's `PRAGMA user_version` or a one-row table on Postgres. 64 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
|
- A **fresh** database is created by `Base.metadata.create_all()` (always current) and
|
||||||
stamped at the latest version — it never replays history.
|
stamped at the latest version; it never replays history.
|
||||||
- An **existing** database runs every migration above its stored version, in order.
|
- An **existing** database runs every migration above its stored version, in order.
|
||||||
|
|
||||||
Why this and not Alembic: for a single-file SQLite app that a user might have been running
|
Why this and not Alembic: for a single-file SQLite app that a user might have been running
|
||||||
@@ -909,9 +910,9 @@ environment. This is 250 lines and you can read all of it.
|
|||||||
|
|
||||||
The constraint it creates is written at the top of the file: change `models.py` (so fresh
|
The constraint it creates is written at the top of the file: change `models.py` (so fresh
|
||||||
databases are current) *and* append a pair here (so existing ones upgrade). Migrations 2–23
|
databases are current) *and* append a pair here (so existing ones upgrade). Migrations 2–23
|
||||||
predate Postgres support and use SQLite-only syntax — harmless, because every Postgres
|
predate Postgres support and use SQLite-only syntax. This is harmless, because every
|
||||||
database starts fresh and never replays them, but anything added since must run on both
|
Postgres database starts fresh and never replays them, but anything added since must run
|
||||||
dialects.
|
on both dialects.
|
||||||
|
|
||||||
One migration worth reading (#10, repairing duplicate action indexes) uses `UPDATE … FROM`
|
One migration worth reading (#10, repairing duplicate action indexes) uses `UPDATE … FROM`
|
||||||
with a window function rather than a correlated subquery, because SQLite may evaluate a
|
with a window function rather than a correlated subquery, because SQLite may evaluate a
|
||||||
@@ -929,7 +930,7 @@ correlated subquery against partially-updated rows and produce duplicates again
|
|||||||
| | Local (default) | Hosted |
|
| | Local (default) | Hosted |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Users | One auto-created "local user" | Guest on first visit, optional account |
|
| Users | One auto-created "local user" | Guest on first visit, optional account |
|
||||||
| Auth | None — no cookies, no login UI | Signed session cookie |
|
| Auth | None: no cookies, no login UI | Signed session cookie |
|
||||||
| Rate limits | Off | On |
|
| Rate limits | Off | On |
|
||||||
| Row caps | Off | On |
|
| Row caps | Off | On |
|
||||||
| API docs (`/docs`) | On | Off |
|
| API docs (`/docs`) | On | Off |
|
||||||
@@ -941,13 +942,13 @@ deployment needs all four of those to be the opposite. Rather than two builds, t
|
|||||||
differences are gated at each site.
|
differences are gated at each site.
|
||||||
|
|
||||||
**Guests upgrade in place.** A visitor gets a guest `User` row on first load. Registering
|
**Guests upgrade in place.** A visitor gets a guest `User` row on first load. Registering
|
||||||
sets `email` and `password_hash` on that *same row* — so every adventure they played as a
|
sets `email` and `password_hash` on that *same row*, so every adventure they played as a
|
||||||
guest survives with no re-parenting and no migration step. Three kinds of row share the
|
guest survives with no re-parenting and no migration step. Three kinds of row share the
|
||||||
users table: local (email NULL, not guest), guest (email NULL, guest), registered (email
|
users table: local (email NULL, not guest), guest (email NULL, guest), registered (email
|
||||||
set).
|
set).
|
||||||
|
|
||||||
**Guests expire; accounts don't.** One row per curious visitor adds up, so `cleanup.py`
|
**Guests expire; accounts don't.** One row per curious visitor adds up, so `cleanup.py`
|
||||||
deletes guests idle for `AIDND_GUEST_RETENTION_DAYS` (default 5) — measured as
|
deletes guests idle for `AIDND_GUEST_RETENTION_DAYS` (default 5), measured as
|
||||||
`COALESCE(last_seen_at, created_at)`, because `_touch` only writes `last_seen_at` hourly
|
`COALESCE(last_seen_at, created_at)`, because `_touch` only writes `last_seen_at` hourly
|
||||||
and a guest minted by `/auth/me` has NULL until its second request. The filter requires
|
and a guest minted by `/auth/me` has NULL until its second request. The filter requires
|
||||||
both `is_guest` *and* `email IS NULL`, so upgrading in place is also how you opt out of
|
both `is_guest` *and* `email IS NULL`, so upgrading in place is also how you opt out of
|
||||||
@@ -957,7 +958,7 @@ every few hours.
|
|||||||
It's a single Core `DELETE`, not `db.delete(user)`: the ORM path would SELECT every
|
It's a single Core `DELETE`, not `db.delete(user)`: the ORM path would SELECT every
|
||||||
adventure, action and memory into Python purely to delete them, and the FK graph is
|
adventure, action and memory into Python purely to delete them, and the FK graph is
|
||||||
`ON DELETE CASCADE` from `users` all the way down, so the database can do the whole graph
|
`ON DELETE CASCADE` from `users` all the way down, so the database can do the whole graph
|
||||||
in one statement. Nothing a guest owns is visible to anyone else either — `is_public` is
|
in one statement. Nothing a guest owns is visible to anyone else either: `is_public` is
|
||||||
output-only, so shared content is exactly the seeded scenarios, which have `user_id NULL`
|
output-only, so shared content is exactly the seeded scenarios, which have `user_id NULL`
|
||||||
and never match the filter.
|
and never match the filter.
|
||||||
|
|
||||||
@@ -969,11 +970,11 @@ is a spending surface, so it's the most defended code in the project.
|
|||||||
`resolve_provider_config()` is the single place the BYOK-vs-demo decision is made, and on
|
`resolve_provider_config()` is the single place the BYOK-vs-demo decision is made, and on
|
||||||
the demo branch it pins **two** things:
|
the demo branch it pins **two** things:
|
||||||
|
|
||||||
- **The model** — to a whitelist. A caller-supplied override or a hand-edited settings row
|
- **The model**, pinned to a whitelist. A caller-supplied override or a hand-edited
|
||||||
can't aim a server-funded key at an expensive model. Anything unrecognised falls back to
|
settings row can't aim a server-funded key at an expensive model. Anything unrecognized
|
||||||
the first whitelisted model.
|
falls back to the first whitelisted model.
|
||||||
- **The endpoint** — to the configured demo URL. Otherwise the key could be redirected to a
|
- **The endpoint**, pinned to the configured demo URL. Otherwise the key could be
|
||||||
URL the user controls and harvested.
|
redirected to a URL the user controls and harvested.
|
||||||
|
|
||||||
Plus a daily per-user turn cap (default 20), checked *before* the player's input is stored
|
Plus a daily per-user turn cap (default 20), checked *before* the player's input is stored
|
||||||
so a capped player doesn't get their message saved with no reply, and counted only after a
|
so a capped player doesn't get their message saved with no reply, and counted only after a
|
||||||
@@ -982,12 +983,12 @@ successful turn.
|
|||||||
There's a defensive `__post_init__` on the config object that raises if a demo config
|
There's a defensive `__post_init__` on the config object that raises if a demo config
|
||||||
somehow carries a non-whitelisted model. The comment on it records a real bug: the check
|
somehow carries a non-whitelisted model. The comment on it records a real bug: the check
|
||||||
tests `using_demo`, **not** `api_key == DEMO_API_KEY`. Keying on the key value looks
|
tests `using_demo`, **not** `api_key == DEMO_API_KEY`. Keying on the key value looks
|
||||||
stricter but is wrong — the demo key is an ordinary OpenRouter key, so a user can
|
stricter but is wrong: the demo key is an ordinary OpenRouter key, so a user can
|
||||||
legitimately paste that same key into their own settings as BYOK, and then every resolution
|
legitimately paste that same key into their own settings as BYOK, and then every resolution
|
||||||
raised, 500ing even `GET /auth/me` and taking the whole SPA down. `using_demo` is what
|
raised, 500ing even `GET /auth/me` and taking the whole SPA down. `using_demo` is what
|
||||||
actually means "the server is paying".
|
actually means "the server is paying".
|
||||||
|
|
||||||
Background work (summarization, embeddings) is excluded from the demo key entirely — those
|
Background work (summarization, embeddings) is excluded from the demo key entirely: those
|
||||||
are unmetered calls, and unmetered calls on a server-funded key is a bill.
|
are unmetered calls, and unmetered calls on a server-funded key is a bill.
|
||||||
|
|
||||||
## 3.3 Secrets
|
## 3.3 Secrets
|
||||||
@@ -997,11 +998,11 @@ Everything derives from one server-side secret (`AIDND_SECRET_KEY`).
|
|||||||
| Thing | Mechanism |
|
| Thing | Mechanism |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Passwords | `hashlib.scrypt`, N=2^14, r=8, p=1, per-password salt, constant-time compare. Stdlib, so no extra dependency. |
|
| Passwords | `hashlib.scrypt`, N=2^14, r=8, p=1, per-password salt, constant-time compare. Stdlib, so no extra dependency. |
|
||||||
| Sessions | `v1.<user_id>.<HMAC-SHA256>`, no expiry — long-lived guest sessions are the point. A cookie can outlive a swept guest row; that resolves to a 401, which the frontend already turns into a fresh session. |
|
| Sessions | `v1.<user_id>.<HMAC-SHA256>`, no expiry: long-lived guest sessions are the point. A cookie can outlive a swept guest row; that resolves to a 401, which the frontend already turns into a fresh session. |
|
||||||
| Stored LLM API keys | Fernet (AES) encryption at rest, key derived from the secret, `enc:` prefix so legacy plaintext rows are recognisable and migratable. |
|
| Stored LLM API keys | Fernet (AES) encryption at rest, key derived from the secret, `enc:` prefix so legacy plaintext rows are recognizable and migratable. |
|
||||||
|
|
||||||
The secret auto-generates into a file next to the database for local installs (zero config),
|
The secret auto-generates into a file next to the database for local installs (zero config),
|
||||||
but **multi-user mode refuses to start without the env var** — with an error message that
|
but **multi-user mode refuses to start without the env var**, with an error message that
|
||||||
explains why and gives you the command to generate one. Hosted filesystems are ephemeral; a
|
explains why and gives you the command to generate one. Hosted filesystems are ephemeral; a
|
||||||
regenerated secret on every deploy would silently log out every user and orphan their stored
|
regenerated secret on every deploy would silently log out every user and orphan their stored
|
||||||
API keys.
|
API keys.
|
||||||
@@ -1025,11 +1026,11 @@ rather than raising, so the user just re-enters their key instead of hitting a 5
|
|||||||
Rate limits are keyed per user when one is known (accounts survive IP changes) and per IP
|
Rate limits are keyed per user when one is known (accounts survive IP changes) and per IP
|
||||||
otherwise, in fixed windows held in memory, with a pruning pass so the per-IP dict can't
|
otherwise, in fixed windows held in memory, with a pruning pass so the per-IP dict can't
|
||||||
grow without bound. Import endpoints check bundle list lengths against the same caps live
|
grow without bound. Import endpoints check bundle list lengths against the same caps live
|
||||||
creation enforces — otherwise the cap is trivially bypassed by uploading a file.
|
creation enforces, otherwise the cap is trivially bypassed by uploading a file.
|
||||||
|
|
||||||
Security headers on every response: `nosniff`, `X-Frame-Options: DENY`,
|
Security headers on every response: `nosniff`, `X-Frame-Options: DENY`,
|
||||||
`Referrer-Policy: same-origin`, and a CSP allowing exactly what the SPA uses — same-origin
|
`Referrer-Policy: same-origin`, and a CSP allowing exactly what the SPA uses: same-origin
|
||||||
everything, inline styles (React needs them), Google Fonts.
|
everything, inline styles (React needs them), and Google Fonts.
|
||||||
|
|
||||||
## 3.5 Deployment
|
## 3.5 Deployment
|
||||||
|
|
||||||
@@ -1053,13 +1054,13 @@ push.
|
|||||||
|
|
||||||
A hosted demo raises a question a local app never does: is anyone using it, and do they get
|
A hosted demo raises a question a local app never does: is anyone using it, and do they get
|
||||||
anywhere? The answer is an owner-only dashboard at `/analytics`, gated on
|
anywhere? The answer is an owner-only dashboard at `/analytics`, gated on
|
||||||
`AIDND_ANALYTICS_EMAILS` — a list kept separate from `AIDND_POWER_USERS`, since an unmetered
|
`AIDND_ANALYTICS_EMAILS`, a list kept separate from `AIDND_POWER_USERS`, since an unmetered
|
||||||
tester is not automatically someone who should see the traffic.
|
tester is not automatically someone who should see the traffic.
|
||||||
|
|
||||||
**Why it isn't a third-party script.** The CSP allows `script-src 'self'`, so a tracker would
|
**Why it isn't a third-party script.** The CSP allows `script-src 'self'`, so a tracker would
|
||||||
mean loosening it; adblockers eat the popular ones, which silently biases exactly the
|
mean loosening it. Ad blockers block the popular ones, which silently biases exactly the
|
||||||
technical audience this project is shown to; and none of them can see the measurement that
|
technical audience this project is shown to. And none of them can see the measurement that
|
||||||
matters here — a *turn*. The interesting funnel step is not a pageview.
|
matters here: a *turn*. The interesting funnel step is not a pageview.
|
||||||
|
|
||||||
**Egress is the budget.** After the 189x fix (§2.5) it would be perverse to add a feature
|
**Egress is the budget.** After the 189x fix (§2.5) it would be perverse to add a feature
|
||||||
that reads rows per request. So counts accumulate in a process-local dict and flush every 60
|
that reads rows per request. So counts accumulate in a process-local dict and flush every 60
|
||||||
@@ -1068,23 +1069,23 @@ seconds as UPSERTs: **a visit is a write and never a read**. Storage is a generi
|
|||||||
Every dashboard query is a `GROUP BY` that returns tens of rows regardless of the traffic
|
Every dashboard query is a `GROUP BY` that returns tens of rows regardless of the traffic
|
||||||
behind it, so a month costs a few kilobytes to read back. The cost of the buffer is that a
|
behind it, so a month costs a few kilobytes to read back. The cost of the buffer is that a
|
||||||
hard restart can lose up to a minute; the flusher also runs on shutdown, and on a tier that
|
hard restart can lose up to a minute; the flusher also runs on shutdown, and on a tier that
|
||||||
sleeps when idle the buffer it sleeps on is empty anyway.
|
sleeps when idle, the buffer it sleeps on is empty anyway.
|
||||||
|
|
||||||
**The numbers are the server's, not the browser's.** The client reports one fact — which page
|
**The numbers are the server's, not the browser's.** The client reports one fact, which page
|
||||||
was viewed — and even that is normalized to a route (`/play/12` → `/play/:id`) against a
|
was viewed, and even that is normalized to a route (`/play/12` → `/play/:id`) against a
|
||||||
whitelist, so the page list cannot be polluted by anything a stranger posts. Everything that
|
whitelist, so the page list cannot be polluted by anything a stranger posts. Everything that
|
||||||
means something — a turn, an adventure, a sign-up — is recorded by the code that performs it.
|
means something, such as a turn, an adventure, or a sign-up, is recorded by the code that
|
||||||
That also fixes a blind spot: a failed turn is an HTTP 200 with a bad ending, so a
|
performs it. That also fixes a blind spot: a failed turn is an HTTP 200 with a bad ending, so
|
||||||
status-code tally cannot see it, and a demo whose model has started refusing looks perfectly
|
a status-code tally cannot see it, and a demo whose model has started refusing looks
|
||||||
healthy from outside. `turn_error` is counted where the SSE error is written.
|
perfectly healthy from outside. `turn_error` is counted where the SSE error is written.
|
||||||
|
|
||||||
The funnel counts **people, not clicks** — a player who starts six adventures is one person
|
The funnel counts **people, not clicks**: a player who starts six adventures is one person
|
||||||
who started an adventure — which is the entire reason the per-visitor-day table exists.
|
who started an adventure, which is the entire reason the per-visitor-day table exists.
|
||||||
|
|
||||||
One smaller decision worth naming: error buckets are labelled by the matched *route template*,
|
One smaller decision worth naming: error buckets are labeled by the matched *route
|
||||||
never the requested path. That gives one bucket per endpoint instead of one per adventure id,
|
template*, never the requested path. That gives one bucket per endpoint instead of one per
|
||||||
and — the reason it isn't merely tidier — an unmatched path is entirely attacker-chosen, so
|
adventure id. The reason it isn't merely tidier is that an unmatched path is entirely
|
||||||
labelling by it would let anyone mint rows.
|
attacker-chosen, so labeling by it would let anyone mint rows.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -1092,9 +1093,9 @@ labelling by it would let anyone mint rows.
|
|||||||
|
|
||||||
For the parts that are just how the web works, not decisions.
|
For the parts that are just how the web works, not decisions.
|
||||||
|
|
||||||
**Frontend and backend are two programs.** In development they're two servers — Vite on
|
**Frontend and backend are two programs.** In development they're two servers: Vite on 5173
|
||||||
5173 serving React, FastAPI on 8000 serving the API — and Vite proxies `/api` to FastAPI so
|
serving React, and FastAPI on 8000 serving the API. Vite proxies `/api` to FastAPI so
|
||||||
the browser thinks it's all one origin (which avoids CORS entirely). In production there's
|
the browser thinks it's all one origin, which avoids CORS entirely. In production there's
|
||||||
one server: FastAPI serves the built React files as static assets from the same port.
|
one server: FastAPI serves the built React files as static assets from the same port.
|
||||||
|
|
||||||
**SPA routing.** React Router handles URLs like `/play/3` in the browser without a round
|
**SPA routing.** React Router handles URLs like `/play/3` in the browser without a round
|
||||||
@@ -1105,7 +1106,7 @@ unaffected.
|
|||||||
|
|
||||||
**Sessions.** A cookie is a small value the browser stores and automatically attaches to
|
**Sessions.** A cookie is a small value the browser stores and automatically attaches to
|
||||||
every request to that site. Here it holds `v1.<user_id>.<signature>`. The server doesn't
|
every request to that site. Here it holds `v1.<user_id>.<signature>`. The server doesn't
|
||||||
store sessions anywhere — it re-verifies the signature on each request, which is why there's
|
store sessions anywhere; it re-verifies the signature on each request, which is why there's
|
||||||
no session table.
|
no session table.
|
||||||
|
|
||||||
**The 401 retry.** If the cookie is missing or stale, any API call returns 401. The frontend
|
**The 401 retry.** If the cookie is missing or stale, any API call returns 401. The frontend
|
||||||
@@ -1136,7 +1137,7 @@ text appears to type itself.
|
|||||||
| Context defaults | author's note at depth 3, cards capped at 40% of elastic budget |
|
| 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 |
|
| Memory cadence | memory / 6 turns, summary / 15 turns, top-5 retrieval |
|
||||||
|
|
||||||
Two of the tests encode a performance property rather than a behaviour:
|
Two of the tests encode a performance property rather than a behavior:
|
||||||
`test_egress.py` asserts on the SQL the ORM emits, and `test_history_window.py` asserts
|
`test_egress.py` asserts on the SQL the ORM emits, and `test_history_window.py` asserts
|
||||||
that the read cost stops growing with story length.
|
that the read cost stops growing with story length.
|
||||||
|
|
||||||
@@ -1150,7 +1151,7 @@ nobody has to discover them the hard way.
|
|||||||
lock) and the rate limiter in Redis.
|
lock) and the rate limiter in Redis.
|
||||||
- **No vector index.** Retrieval does cosine similarity in Python over the whole bank. Fine
|
- **No vector index.** Retrieval does cosine similarity in Python over the whole bank. Fine
|
||||||
at the 200-memory cap; at 10,000 it would want pgvector.
|
at the 200-memory cap; at 10,000 it would want pgvector.
|
||||||
- **Prompt snapshots are heavy** even after the egress fix — they're deferred, not smaller.
|
- **Prompt snapshots are heavy** even after the egress fix; they're deferred, not smaller.
|
||||||
Compressing them or expiring old ones is the real fix.
|
Compressing them or expiring old ones is the real fix.
|
||||||
- **In-memory rate-limit windows reset on restart**, so a restart grants a brief extra
|
- **In-memory rate-limit windows reset on restart**, so a restart grants a brief extra
|
||||||
allowance.
|
allowance.
|
||||||
@@ -1160,7 +1161,7 @@ nobody has to discover them the hard way.
|
|||||||
detect after the fact by string-matching the 429 body.
|
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
|
- **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
|
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
|
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.
|
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.
|
- **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
|
- **Editing an already-summarized turn leaves its memory stale.** Replacing a turn withdraws
|
||||||
@@ -1168,8 +1169,8 @@ nobody has to discover them the hard way.
|
|||||||
|
|
||||||
## Cleanup backlog
|
## Cleanup backlog
|
||||||
|
|
||||||
`docs/self-review.md` carries an open list of non-bugs — reuse, simplification and
|
`docs/self-review.md` carries an open list of non-bugs (reuse, simplification, and
|
||||||
efficiency items — kept deliberately separate from the correctness list, which is empty.
|
efficiency items) kept deliberately separate from the correctness list, which is empty.
|
||||||
The largest ones:
|
The largest ones:
|
||||||
|
|
||||||
- `Section.tokens` is uncached, so the context gets tokenized two or three times a turn.
|
- `Section.tokens` is uncached, so the context gets tokenized two or three times a turn.
|
||||||
|
|||||||
+55
-53
@@ -1,72 +1,73 @@
|
|||||||
# Self-review log
|
# Self-review log
|
||||||
|
|
||||||
**Every correctness bug on this page is resolved** — 20 fixed, 1 intentionally skipped with the
|
**Every correctness bug on this page is resolved.** 20 are fixed, and 1 is intentionally
|
||||||
reasoning recorded below. The record is kept because the reasoning outlives the verdicts;
|
skipped, with the reasoning recorded below. The record is kept because the reasoning outlives
|
||||||
several of these are traps worth remembering. The *cleanup backlog* at the bottom is a
|
the verdicts, and several of these are traps worth remembering. The *cleanup backlog* at the
|
||||||
deliberately open list of non-bugs (reuse, simplification, efficiency), not outstanding defects.
|
bottom is a deliberately open list of non-bugs (reuse, simplification, efficiency), not
|
||||||
|
outstanding defects.
|
||||||
|
|
||||||
Original review: 2026-07-05, whole project in scope (no git history at the time).
|
Original review: 2026-07-05, whole project in scope (no git history at the time).
|
||||||
Status key: `fixed` = applied, `skipped` = intentionally not fixed, `pending` = outstanding
|
Status key: `fixed` means applied, `skipped` means intentionally not fixed, and `pending` means
|
||||||
(none remain).
|
outstanding (none remain).
|
||||||
|
|
||||||
**2026-07-06 update (branch `bugfix-code-review`):** every finding re-verified against
|
**2026-07-06 update (branch `bugfix-code-review`):** every finding was re-verified against
|
||||||
current code. #1/#2/#3/#4/#6/#12 had already been fixed in earlier sessions (statuses were
|
current code. #1, #2, #3, #4, #6, and #12 had already been fixed in earlier sessions, so their
|
||||||
stale); #5/#7/#8/#9/#10/#13/#14 (backend) and #15–#21 (frontend) fixed in this pass.
|
statuses were stale. #5, #7, #8, #9, #10, #13, #14 (backend) and #15 through #21 (frontend)
|
||||||
#11 is skipped: real AI Dungeon's `addStoryCard` also returns the new card's index
|
were fixed in this pass. #11 is skipped: real AI Dungeon's `addStoryCard` also returns the new
|
||||||
(0-falsy included) per the official scripting guidebook, so changing it would break
|
card's index (0-falsy included) per the official scripting guidebook, so changing it would
|
||||||
compatibility — documented in `engine.py`'s prelude instead. The cleanup backlog
|
break compatibility. This is documented in `engine.py`'s prelude instead. The cleanup backlog
|
||||||
(R/S/E/A items) below remains open by design.
|
(R/S/E/A items) below remains open by design.
|
||||||
|
|
||||||
## Correctness bugs
|
## Correctness bugs
|
||||||
|
|
||||||
### 1. [fixed] seed_demo.py doesn't stamp schema version → server crashes on next start
|
### 1. [fixed] seed_demo.py doesn't stamp schema version → server crashes on next start
|
||||||
- `backend/seed_demo.py:10`
|
- `backend/seed_demo.py:10`
|
||||||
- Fresh DB created via `Base.metadata.create_all` leaves `PRAGMA user_version` at 0. Next server start sees tables exist, replays every ALTER TABLE migration → `duplicate column name` crash.
|
- A fresh DB created via `Base.metadata.create_all` leaves `PRAGMA user_version` at 0. The next server start sees the tables exist and replays every ALTER TABLE migration, causing a `duplicate column name` crash.
|
||||||
- Fix: stamp user_version to latest after create_all (reuse migrations.bootstrap logic).
|
- Fix: stamp user_version to latest after create_all (reuse migrations.bootstrap logic).
|
||||||
|
|
||||||
### 2. [fixed] Turn-lock race: two simultaneous turns can run on the same adventure
|
### 2. [fixed] Turn-lock race: two simultaneous turns can run on the same adventure
|
||||||
- `backend/app/routers/adventures.py:302`
|
- `backend/app/routers/adventures.py:302`
|
||||||
- `ensure_not_generating()` runs in the route handler but `_active_turns.add()` only happens when the StreamingResponse generator is first iterated. Double-click Continue → both requests pass the 409 check → duplicate Action.index rows, interleaved generations.
|
- `ensure_not_generating()` runs in the route handler, but `_active_turns.add()` only happens when the StreamingResponse generator is first iterated. Double-clicking Continue sends both requests past the 409 check, producing duplicate Action.index rows and interleaved generations.
|
||||||
- Fix: atomically test-and-set the lock in the request phase, release in the stream's `finally`.
|
- Fix: atomically test-and-set the lock in the request phase, release in the stream's `finally`.
|
||||||
|
|
||||||
### 3. [fixed] Migration 10 renumbers indexes with a correlated subquery on the table being updated
|
### 3. [fixed] Migration 10 renumbers indexes with a correlated subquery on the table being updated
|
||||||
- `backend/app/migrations.py:34`
|
- `backend/app/migrations.py:34`
|
||||||
- SQLite may evaluate the subquery against partially-updated rows → duplicate indexes survive the "repair".
|
- SQLite may evaluate the subquery against partially updated rows, so duplicate indexes can survive the "repair".
|
||||||
- Fix: compute new indexes in Python (SELECT ordered, then UPDATE per row).
|
- Fix: compute new indexes in Python (SELECT ordered, then UPDATE per row).
|
||||||
|
|
||||||
### 4. [fixed] SQLite foreign keys never enabled → CASCADE/SET NULL clauses are dead
|
### 4. [fixed] SQLite foreign keys never enabled → CASCADE/SET NULL clauses are dead
|
||||||
- `backend/app/database.py:8`
|
- `backend/app/database.py:8`
|
||||||
- Deleting a Script leaves orphaned `scenario_scripts` rows; SQLite rowid reuse can attach a future script to an old scenario.
|
- Deleting a Script leaves orphaned `scenario_scripts` rows. SQLite rowid reuse can then attach a future script to an old scenario.
|
||||||
- Fix: `PRAGMA foreign_keys=ON` via engine connect event.
|
- Fix: `PRAGMA foreign_keys=ON` via engine connect event.
|
||||||
|
|
||||||
### 5. [fixed] Provider generate() silently yields nothing for non-SSE 200 responses
|
### 5. [fixed] Provider generate() silently yields nothing for non-SSE 200 responses
|
||||||
- `backend/app/providers/openai_compatible.py:84`
|
- `backend/app/providers/openai_compatible.py:84`
|
||||||
- Server that ignores `stream=true` and returns plain JSON → no `data:` lines → empty AI action, no error.
|
- A server that ignores `stream=true` and returns plain JSON produces no `data:` lines, so the result is an empty AI action with no error.
|
||||||
- Fix: buffer non-SSE body and fall back to parsing it as a single JSON completion.
|
- Fix: buffer the non-SSE body and fall back to parsing it as a single JSON completion.
|
||||||
|
|
||||||
### 6. [fixed] Fire-and-forget asyncio task can be GC'd mid-run and wedge the memory bank
|
### 6. [fixed] Fire-and-forget asyncio task can be GC'd mid-run and wedge the memory bank
|
||||||
- `backend/app/memorybank.py:146`
|
- `backend/app/memorybank.py:146`
|
||||||
- `asyncio.create_task` result not referenced; task can vanish silently; adventure ID can stay stuck in `_running`.
|
- The result of `asyncio.create_task` is not referenced, so the task can vanish silently and leave an adventure ID stuck in `_running`.
|
||||||
- Fix: keep strong refs in a set, discard in done-callback.
|
- Fix: keep strong refs in a set, discard in done-callback.
|
||||||
|
|
||||||
### 7. [fixed] Memory cursors are list positions but Memory.source_start/end are Action.index values
|
### 7. [fixed] Memory cursors are list positions but Memory.source_start/end are Action.index values
|
||||||
- `backend/app/memorybank.py:213`
|
- `backend/app/memorybank.py:213`
|
||||||
- After any action deletion (indexes keep gaps, positions shift), summarization skips/duplicates blocks and `_update_story_summary` folds the wrong memories.
|
- After any action deletion, indexes keep gaps and positions shift, so summarization skips or duplicates blocks and `_update_story_summary` folds the wrong memories.
|
||||||
- Fix: use one space consistently — track cursors by Action.index (position-independent), or renumber on delete.
|
- Fix: use one space consistently. Track cursors by Action.index (position-independent), or renumber on delete.
|
||||||
|
|
||||||
### 8. [fixed] Pinned memories don't count toward memory_top_k cap
|
### 8. [fixed] Pinned memories don't count toward memory_top_k cap
|
||||||
- `backend/app/memorybank.py:120`
|
- `backend/app/memorybank.py:120`
|
||||||
- 6 pinned + top_k=5 → 11 memories injected, blowing token budget.
|
- 6 pinned plus top_k=5 injects 11 memories, blowing the token budget.
|
||||||
- Fix: fill with unpinned only up to `top_k - len(pinned)` (min 0).
|
- Fix: fill with unpinned only up to `top_k - len(pinned)` (min 0).
|
||||||
|
|
||||||
### 9. [fixed] Embedding-model change → cosine() zips different-dimension vectors silently
|
### 9. [fixed] Embedding-model change → cosine() zips different-dimension vectors silently
|
||||||
- `backend/app/memorybank.py:69`
|
- `backend/app/memorybank.py:69`
|
||||||
- Old 768-dim embeddings scored against new 1536-dim query → garbage similarity, no error, never re-embedded.
|
- Old 768-dimension embeddings scored against a new 1536-dimension query produce garbage similarity with no error, and are never re-embedded.
|
||||||
- Fix: return 0.0 on length mismatch (and ideally clear stale embeddings so _embed_pending redoes them).
|
- Fix: return 0.0 on length mismatch (and ideally clear stale embeddings so _embed_pending redoes them).
|
||||||
|
|
||||||
### 10. [fixed] MAX_STORY_CARDS cap is a no-op for cards created in one hook
|
### 10. [fixed] MAX_STORY_CARDS cap is a no-op for cards created in one hook
|
||||||
- `backend/app/scripting/pipeline.py:59`
|
- `backend/app/scripting/pipeline.py:59`
|
||||||
- `len(existing) + len(seen_ids) < MAX...` never counts newly added cards (seen_ids ⊂ existing). A script can insert unbounded cards in one turn.
|
- `len(existing) + len(seen_ids) < MAX...` never counts newly added cards, since seen_ids is a subset of existing. A script can insert an unbounded number of cards in one turn.
|
||||||
- Fix: count inserts made during the loop.
|
- Fix: count inserts made during the loop.
|
||||||
|
|
||||||
### 11. [skipped] addStoryCard returns 0 (falsy) for the first card, indistinguishable from `false` rejection
|
### 11. [skipped] addStoryCard returns 0 (falsy) for the first card, indistinguishable from `false` rejection
|
||||||
@@ -80,17 +81,17 @@ compatibility — documented in `engine.py`'s prelude instead. The cleanup backl
|
|||||||
|
|
||||||
### 13. [fixed] test_connection 500s on non-dict JSON from /models
|
### 13. [fixed] test_connection 500s on non-dict JSON from /models
|
||||||
- `backend/app/routers/settings.py:54`
|
- `backend/app/routers/settings.py:54`
|
||||||
- Only ValueError caught; `data.get`/`m.get` on non-dict raises AttributeError → 500 instead of `{ok:false}`.
|
- Only ValueError is caught. `data.get`/`m.get` on non-dict input raises AttributeError, producing a 500 instead of `{ok:false}`.
|
||||||
- Fix: catch (ValueError, AttributeError, TypeError) or validate shapes.
|
- Fix: catch (ValueError, AttributeError, TypeError) or validate shapes.
|
||||||
|
|
||||||
### 14. [fixed] AI Dungeon exports with `worldInformation` key lose all story cards silently
|
### 14. [fixed] AI Dungeon exports with `worldInformation` key lose all story cards silently
|
||||||
- `backend/app/routers/scenarios.py:133`
|
- `backend/app/routers/scenarios.py:133`
|
||||||
- Import reads only `storyCards`/`worldInfo`; `worldInformation` is in _IGNORED_KEYS so it's dropped and not reported.
|
- Import reads only `storyCards`/`worldInfo`. `worldInformation` is in _IGNORED_KEYS, so it is dropped without being reported.
|
||||||
- Fix: accept `worldInformation` as a card source too.
|
- Fix: accept `worldInformation` as a card source too.
|
||||||
|
|
||||||
### 15. [fixed] Shared debounce timer loses edits (Play PlotPanel)
|
### 15. [fixed] Shared debounce timer loses edits (Play PlotPanel)
|
||||||
- `frontend/src/pages/Play.jsx:28`
|
- `frontend/src/pages/Play.jsx:28`
|
||||||
- One `saveTimer` shared by all plot fields AND story-card saves; editing a second thing within 600ms cancels the first pending PATCH → silent data loss.
|
- One `saveTimer` is shared by all plot fields and story-card saves. Editing a second field within 600ms cancels the first pending PATCH, causing silent data loss.
|
||||||
- Fix: per-key timers (e.g. a Map keyed by field/card id).
|
- Fix: per-key timers (e.g. a Map keyed by field/card id).
|
||||||
|
|
||||||
### 16. [fixed] Same shared-debounce data loss in ScenarioEditor
|
### 16. [fixed] Same shared-debounce data loss in ScenarioEditor
|
||||||
@@ -99,49 +100,50 @@ compatibility — documented in `engine.py`'s prelude instead. The cleanup backl
|
|||||||
|
|
||||||
### 17. [fixed] Continue button silently discards typed input text
|
### 17. [fixed] Continue button silently discards typed input text
|
||||||
- `frontend/src/pages/Play.jsx:469`
|
- `frontend/src/pages/Play.jsx:469`
|
||||||
- Clicking Continue with text in the box sends type 'continue' (backend ignores text) and clears the input.
|
- Clicking Continue with text in the box sends type 'continue' (the backend ignores the text) and clears the input.
|
||||||
- Fix: don't clear input on continue (or treat non-empty input as a normal send).
|
- Fix: don't clear input on continue (or treat non-empty input as a normal send).
|
||||||
|
|
||||||
### 18. [fixed] retry() optimistically deletes last AI action with no rollback on failure
|
### 18. [fixed] retry() optimistically deletes last AI action with no rollback on failure
|
||||||
- `frontend/src/pages/Play.jsx:475`
|
- `frontend/src/pages/Play.jsx:475`
|
||||||
- Failed retry (409/network) leaves UI missing an action that still exists server-side.
|
- A failed retry (409 or network error) leaves the UI missing an action that still exists server-side.
|
||||||
- Fix: restore the removed action in the catch path (or only remove on first stream event).
|
- Fix: restore the removed action in the catch path (or only remove on first stream event).
|
||||||
|
|
||||||
### 19. [fixed] Settings test()/save() have no error handling → stuck on "Testing…"
|
### 19. [fixed] Settings test()/save() have no error handling → stuck on "Testing…"
|
||||||
- `frontend/src/pages/Settings.jsx:66`
|
- `frontend/src/pages/Settings.jsx:66`
|
||||||
- Rejection leaves `{pending:true}` forever + unhandled rejection.
|
- A rejection leaves `{pending:true}` forever and produces an unhandled rejection.
|
||||||
- Fix: try/catch → setTestResult({ok:false, error:msg}).
|
- Fix: try/catch → setTestResult({ok:false, error:msg}).
|
||||||
|
|
||||||
### 20. [fixed] InsightsPanel race: slow earlier request overwrites newer report
|
### 20. [fixed] InsightsPanel race: slow earlier request overwrites newer report
|
||||||
- `frontend/src/pages/Play.jsx:302`
|
- `frontend/src/pages/Play.jsx:302`
|
||||||
- No staleness guard; slow getAdventureContext can clobber a newer action snapshot.
|
- There is no staleness guard, so a slow getAdventureContext call can overwrite a newer action snapshot.
|
||||||
- Fix: track a request id / cancelled flag in the effect.
|
- Fix: track a request id or cancelled flag in the effect.
|
||||||
|
|
||||||
### 21. [fixed] extractPlaceholders ignores ${...} in story-card trigger keys
|
### 21. [fixed] extractPlaceholders ignores ${...} in story-card trigger keys
|
||||||
- `frontend/src/pages/Scenarios.jsx:50`
|
- `frontend/src/pages/Scenarios.jsx:50`
|
||||||
- Backend fills placeholders in card.keys but the modal never prompts for those names → literal `${hero}` keys never match.
|
- The backend fills placeholders in card.keys, but the modal never prompts for those names, so a literal `${hero}` key never matches.
|
||||||
- Fix: also scan card.keys when collecting placeholder names.
|
- Fix: also scan card.keys when collecting placeholder names.
|
||||||
|
|
||||||
## Cleanup backlog (reuse / simplification / efficiency / altitude — not bugs, apply later)
|
## Cleanup backlog (reuse / simplification / efficiency / altitude — not bugs, apply later)
|
||||||
|
|
||||||
- **R1** `frontend/src/pages/Play.jsx:26` + `ScenarioEditor.jsx:19` + `ScriptEditor.jsx:34` — three copies of the debounced-autosave + story-card handlers; extract a `useDebouncedSave` hook / shared StoryCardList component. (Fixing bugs #15/#16 properly may accomplish this.)
|
- **R1** `frontend/src/pages/Play.jsx:26` + `ScenarioEditor.jsx:19` + `ScriptEditor.jsx:34`: three copies of the debounced-autosave and story-card handlers. Extract a `useDebouncedSave` hook and a shared StoryCardList component. Fixing bugs #15/#16 properly may accomplish this.
|
||||||
- **R2** `backend/seed_demo.py:228` — re-implements create_adventure; call the router logic instead.
|
- **R2** `backend/seed_demo.py:228`: re-implements create_adventure. Call the router logic instead.
|
||||||
- **R3** `backend/app/providers/openai_compatible.py:122` — complete() duplicates _request() body building; add a `stream` param to _request().
|
- **R3** `backend/app/providers/openai_compatible.py:122`: complete() duplicates _request()'s body building. Add a `stream` param to _request().
|
||||||
- **R4** `backend/app/routers/adventures.py:476` — six copies of child-resource get+owner-check+404; extract `get_owned_or_404`.
|
- **R4** `backend/app/routers/adventures.py:476`: six copies of child-resource get+owner-check+404. Extract `get_owned_or_404`.
|
||||||
- **R5** `frontend/src/api.js:26` — streamSSE duplicates request()'s error extraction; extract `throwIfNotOk(resp)`.
|
- **R5** `frontend/src/api.js:26`: streamSSE duplicates request()'s error extraction. Extract `throwIfNotOk(resp)`.
|
||||||
- **S1** `backend/app/models.py:210` — `Settings.stream` is dead state (never read); delete column + schema fields.
|
- **S1** `backend/app/models.py:210`: `Settings.stream` is dead state (never read). Delete the column and its schema fields.
|
||||||
- **S2** `frontend/src/pages/Play.jsx:6` — MODES and PLAYER_TYPES are identical constants; lastIsAi/canUndo computed twice.
|
- **S2** `frontend/src/pages/Play.jsx:6`: MODES and PLAYER_TYPES are identical constants; lastIsAi/canUndo are computed twice.
|
||||||
- **E1** `backend/app/context/builder.py:119` — joins+tokenizes the ENTIRE adventure history every turn for the trigger window; walk reversed(actions) until budget instead.
|
- **E1** `backend/app/context/builder.py:119`: joins and tokenizes the entire adventure history every turn for the trigger window. Walk reversed(actions) until budget instead.
|
||||||
- **E2** `backend/app/scripting/pipeline.py:77` — rebuilds full history dicts + JSON + a blocking commit per script per hook; build once per hook, slice to HISTORY_WINDOW first, commit once.
|
- **E2** `backend/app/scripting/pipeline.py:77`: rebuilds full history dicts, JSON, and a blocking commit per script per hook. Build once per hook, slice to HISTORY_WINDOW first, and commit once.
|
||||||
- **E3** `frontend/src/pages/Play.jsx:561` — every SSE chunk re-renders all action rows; isolate streaming text in a child component / React.memo rows.
|
- **E3** `frontend/src/pages/Play.jsx:561`: every SSE chunk re-renders all action rows. Isolate streaming text in a child component or React.memo rows.
|
||||||
- **E4** `backend/app/context/builder.py:48` — Section.tokens uncached, whole context tokenized 2-3×/turn; cache counts, sum sections.
|
- **E4** `backend/app/context/builder.py:48`: Section.tokens is uncached, so the whole context gets tokenized 2 to 3 times per turn. Cache counts and sum sections.
|
||||||
- **E5** `frontend/src/pages/Play.jsx:490` — keydown effect has no dep array → listener re-registered every render.
|
- **E5** `frontend/src/pages/Play.jsx:490`: the keydown effect has no dependency array, so the listener is re-registered every render.
|
||||||
- **E6** `backend/app/memorybank.py:182` — catch-up summarization awaits blocks sequentially; gather independent blocks.
|
- **E6** `backend/app/memorybank.py:182`: catch-up summarization awaits blocks sequentially. Gather independent blocks instead.
|
||||||
- **A1** ~~no UniqueConstraint('adventure_id','index'); index allocation is ad-hoc per writer.~~
|
- **A1** ~~no UniqueConstraint('adventure_id','index'); index allocation is ad-hoc per writer.~~
|
||||||
**Overtaken by phase 14 (2026-08).** `index` is a legacy column that nothing reads: ordering is
|
**Overtaken by phase 14 (2026-08).** `index` is a legacy column that nothing reads: ordering
|
||||||
`(branch_id, depth)` now, allocated in one place (`tree.place_action`). The column is kept unread
|
is `(branch_id, depth)` now, allocated in one place (`tree.place_action`). The column is kept
|
||||||
for one release and then dropped, so a constraint on it would be a constraint on a corpse.
|
unread for one release and then dropped, so a constraint on it would apply to a column that
|
||||||
- **A2** `backend/app/providers/openai_compatible.py:45` — CHAT_CONTINUE_HINT appended below the budgeting layer; assemble prompts in context builder.
|
no longer does anything.
|
||||||
- **A3** `backend/app/routers/adventures.py:390` — import endpoints hand-coerce raw dicts; use a Pydantic bundle schema.
|
- **A2** `backend/app/providers/openai_compatible.py:45`: CHAT_CONTINUE_HINT is appended below the budgeting layer. Assemble prompts in the context builder instead.
|
||||||
- **A4** `backend/app/routers/adventures.py:207` — onModelContext flattens (system, story) and ships everything as user content if modified; pass structure through the hook.
|
- **A3** `backend/app/routers/adventures.py:390`: import endpoints hand-coerce raw dicts. Use a Pydantic bundle schema.
|
||||||
- **A5** `frontend/src/pages/Home.jsx:87` — client appends 'Z' to naive datetimes; emit ISO-8601 with offset from the API instead.
|
- **A4** `backend/app/routers/adventures.py:207`: onModelContext flattens (system, story) and ships everything as user content if modified. Pass structure through the hook instead.
|
||||||
|
- **A5** `frontend/src/pages/Home.jsx:87`: the client appends 'Z' to naive datetimes. Emit ISO-8601 with an offset from the API instead.
|
||||||
|
|||||||
+1
-1
@@ -13,4 +13,4 @@ The React Compiler is not enabled on this template because of its impact on dev
|
|||||||
|
|
||||||
## Expanding the Oxlint configuration
|
## Expanding the Oxlint configuration
|
||||||
|
|
||||||
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and Oxlint's TypeScript related rules in your project.
|
If you are developing a production application, use TypeScript with type-aware lint rules enabled. See the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for how to integrate TypeScript and Oxlint's TypeScript-related rules in your project.
|
||||||
|
|||||||
Reference in New Issue
Block a user