94 files, +1,395 -6,578. Three files are new; twenty-four are gone. The milestone is subtraction, and what is left is the single-user local storyteller the specification describes. Removed in full: campaign scripting and its QuickJS sandbox; multi-user accounts, guest sessions, login, registration and the shared demo key; the visitor-analytics tables, dashboard and page beacon; the access log of sign-ins, addresses and devices; per-IP and per-user rate limiting and quotas; Render deployment config; Postgres and psycopg; cloud inference providers, the API-key field and the key encryption that existed to store it; session-cookie signing. None of it was hidden behind a flag — the routes are gone and answer 404. Two things were kept that the brief allowed keeping. The `users` table and its foreign keys stay as an internal ownership detail, because rewriting them out means a migration across most of the schema to delete a column that costs nothing; nothing creates a second user and no request carries an identity. Five inert tables and four inert columns stay for the same reason, so an M1 campaign database opens unchanged. The one addition is app/endpoints.py, which decides where a story may be sent. Loopback, RFC1918, link-local, unique-local and CGNAT — an explicit allowlist of networks, not a guess at what `ipaddress` means by "private", which calls the documentation ranges private and IPv6 loopback reserved. Every address a hostname resolves to must be in it, so a split answer does not squeak through, and the rule runs both when the endpoint is saved and before every outbound request, because a name that resolved to the LAN this morning can resolve elsewhere this afternoon. Known cloud hosts are named in the refusal so the error says why rather than looking like broken DNS. TLS is never traded against it: M1's shared trust context is intact on all four clients and there is no way to skip verification. The hardcoded 120-second model timeout is now a setting. That was not theoretical — on this GPU-less four-core host a cold load of qwen2.5:3b-instruct took 648.9 seconds to produce the first turn, while turns 2 to 5 of the same campaign took 3.6 to 13.1. Connect stays short at 10s so a wrong address still fails fast; the read timeout defaults to 300s and is bounded at 3600, because "wait longer" must stay a number. Two defects found while testing and fixed here. An unknown /api path fell through the SPA catch-all and came back as HTML with status 200, so a client asking for JSON parsed a web page instead of learning the route was gone. And AIDND_CORS_ORIGINS accepted "*", which on an unauthenticated loopback API would hand every page on the Internet a write handle on the campaign database; it now refuses to start. Verified rather than assumed. Offline, on a network with no route out and no DNS: five turns, retry with both takes retained, restart with an identical transcript digest, a failed model call leaving the accepted AI-turn count untouched, and a capture with zero non-loopback unicast packets. Against a real second machine on the LAN over HTTPS with a private CA: four turns, restart, and a capture showing 289 packets to the approved host, 344 loopback, zero anywhere else, zero DNS queries. Cloud and public endpoints refused with their reasons; no API key settable; every removed route 404. 604 backend tests pass, down from 648 by the fifteen retired with the subsystems they tested and up by the twenty-nine added for the endpoint policy and the removed surface. The scripting tests were not deleted: eight files used a JavaScript counter as instrumentation for the state snapshot and rollback machinery, which M2 does not touch, so the counter moved to the world-state engine and those tests still assert what they always did. Frontend lint and build are clean; the image builds, and its wheel-building stage is gone with quickjs. No M3 work. Undo is still destructive and there is still no Redo.
302 lines
14 KiB
Python
302 lines
14 KiB
Python
"""Rewrite memories already in the bank with the current summarizer prompt.
|
|
|
|
Phase 18b changed what the summarizer is told: a cast brief naming the
|
|
protagonist and the other characters, a rule to write in the third person, and
|
|
`MEMORY_MAX_WORDS`. Only memories written after that change get any of it. An
|
|
adventure played before it keeps a bank of "You entered the crypt" — unnamed,
|
|
in the second person, and occasionally a hundred-word paragraph — and those are
|
|
the memories that get injected into every turn from now on.
|
|
|
|
This rewrites them in place, from the same actions they were written from. It is
|
|
a one-off backfill, not part of the app.
|
|
|
|
**In place, rather than deleting and letting the app re-summarize.** A memory
|
|
row carries more than its text: whether it is pinned, how often it has been
|
|
retrieved, and the node it hangs off, which is what makes a fork inherit the
|
|
right memories. Rewriting `text` keeps all of that. Deleting the bank and
|
|
rewinding the cursor would lose it, and would then trickle memories back at
|
|
`MAX_MEMORIES_PER_RUN` per turn.
|
|
|
|
**The prompt is not assembled here.** `memorybank.summarize_block` is what the
|
|
app itself calls, so a rewritten memory is written by the prompt that is
|
|
actually shipping rather than by a copy of it that can drift.
|
|
|
|
Reading the block back is the one thing the app has never had to do. A memory
|
|
records `source_start`, `source_end` and `branch_id`; `memorybank.source_block`
|
|
turns those back into actions, on the branch the memory was written on rather
|
|
than whichever branch the adventure is on now.
|
|
|
|
What it will not touch:
|
|
|
|
- A memory with no source range: hand-written, or migrated from before memories
|
|
had coordinates. There is no block to rewrite it from, and the player may have
|
|
typed it.
|
|
- A memory whose actions have since been deleted.
|
|
- An adventure whose owner has no API key in Settings. Summarization spends the
|
|
user's own key by construction and never the shared demo key (see the comment
|
|
above `memorybank.summary_provider`), and this holds to that. Pass
|
|
`--endpoint`/`--model`/`--api-key` to summarize with something else.
|
|
|
|
The vector is cleared for every memory it rewrites, because the stored one
|
|
describes the old wording. `--embed` re-embeds them here; without it the app's
|
|
own post-turn pass does it, `MAX_EMBED_BATCH` per turn, and until then those
|
|
memories are out of the ranked bank. **Stop the app before running with
|
|
`--embed`.** A running process caches vectors by memory id and expects to be the
|
|
one writing them (see `memorybank._vector_cache`), so a vector written from
|
|
outside it can sit behind a stale cached copy until it restarts.
|
|
|
|
`--embed` always uses the embedding model and endpoint in the owner's Settings,
|
|
never `--endpoint`. A vector is only meaningful against the vectors it is ranked
|
|
beside, so re-embedding two memories with a different model would quietly put
|
|
two coordinate systems in one bank. `--endpoint` moves the summarizer alone.
|
|
|
|
Usage, from `backend/`:
|
|
|
|
python -m tools.rewrite_memories # what would change
|
|
python -m tools.rewrite_memories --write --limit 3 # try three of them
|
|
python -m tools.rewrite_memories --write --embed # the whole backfill
|
|
|
|
Without `--write` it makes no model calls and only reports the scope. Every run
|
|
reads the database the app reads, at `AIDND_DB_PATH`. Take a copy of it first —
|
|
the old text is overwritten and is not kept anywhere.
|
|
|
|
`--adventure` restricts the run to single adventures, and a dry run lists what
|
|
would change. The deployed image does not carry this directory — the Dockerfile
|
|
copies `backend/app` alone — so run it from a checkout.
|
|
"""
|
|
import argparse
|
|
import asyncio
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
from sqlalchemy import func, inspect as sa_inspect, select
|
|
|
|
|
|
def words(text: str) -> int:
|
|
return len(text.split())
|
|
|
|
|
|
def one_line(text: str, width: int = 96) -> str:
|
|
flat = " ".join(text.split())
|
|
return flat if len(flat) <= width else flat[: width - 1] + "…"
|
|
|
|
|
|
async def main(args) -> int:
|
|
from app import memorybank, models
|
|
from app.database import DB_PATH, SessionLocal
|
|
from app.providers import OpenAICompatibleProvider, ProviderError
|
|
|
|
db = SessionLocal()
|
|
print(f"database: {DB_PATH}")
|
|
if not sa_inspect(db.get_bind()).has_table(models.Adventure.__tablename__):
|
|
# A mistyped path creates an empty SQLite file rather than failing, so
|
|
# say what is wrong instead of raising "no such table: adventures".
|
|
print("There are no tables here. Point AIDND_DB_PATH at the database "
|
|
"the app uses.")
|
|
return 2
|
|
|
|
adventures = db.query(models.Adventure).order_by(models.Adventure.id)
|
|
if args.adventure:
|
|
adventures = adventures.filter(models.Adventure.id.in_(args.adventure))
|
|
if args.email:
|
|
# Compared case-insensitively: an address is typed on the command line
|
|
# here and was typed into a registration form there.
|
|
wanted = [e.strip().lower() for e in args.email]
|
|
owners = db.execute(
|
|
select(models.User.id, func.lower(models.User.email))
|
|
.where(func.lower(models.User.email).in_(wanted))
|
|
).all()
|
|
unknown = sorted(set(wanted) - {email for _, email in owners})
|
|
if unknown:
|
|
print(f"no account with that email: {', '.join(unknown)}")
|
|
return 2
|
|
adventures = adventures.filter(
|
|
models.Adventure.user_id.in_([user_id for user_id, _ in owners]))
|
|
adventures = adventures.all()
|
|
if args.adventure and len(adventures) != len(set(args.adventure)):
|
|
found = {a.id for a in adventures}
|
|
missing = sorted(set(args.adventure) - found)
|
|
print(f"no such adventure: {', '.join(str(i) for i in missing)}")
|
|
return 2
|
|
|
|
# Settings are per user, and several adventures usually share one owner.
|
|
settings_by_user: dict[int, models.Settings | None] = {}
|
|
|
|
def settings_for(user_id: int) -> models.Settings | None:
|
|
if user_id not in settings_by_user:
|
|
settings_by_user[user_id] = (
|
|
db.query(models.Settings)
|
|
.filter(models.Settings.user_id == user_id)
|
|
.first()
|
|
)
|
|
return settings_by_user[user_id]
|
|
|
|
def provider_for(settings: models.Settings) -> OpenAICompatibleProvider:
|
|
"""The adventure owner's own summarizer, unless the run overrides it."""
|
|
if not (args.endpoint or args.model):
|
|
return memorybank.summary_provider(settings)
|
|
return OpenAICompatibleProvider(
|
|
args.endpoint or settings.endpoint_url,
|
|
args.model or settings.summary_model or settings.model,
|
|
settings.api_mode,
|
|
settings.model_timeout_seconds,
|
|
)
|
|
|
|
totals = {"rewritten": 0, "would rewrite": 0, "no source": 0,
|
|
"no story left": 0, "no key": 0, "failed": 0, "empty reply": 0}
|
|
rewritten: list[models.Memory] = []
|
|
budget = args.limit
|
|
|
|
for adventure in adventures:
|
|
memories = (
|
|
db.query(models.Memory)
|
|
.filter(models.Memory.adventure_id == adventure.id)
|
|
.order_by(models.Memory.id)
|
|
)
|
|
if not args.include_forgotten:
|
|
memories = memories.filter(models.Memory.forgotten.is_(False))
|
|
memories = memories.all()
|
|
if not memories:
|
|
continue
|
|
|
|
settings = settings_for(adventure.user_id)
|
|
# An adventure with no model configured is reported rather than
|
|
# skipped silently: it is the one reason a memory this tool can rewrite
|
|
# is left alone, and the operator can fix it with --model.
|
|
usable = settings is not None and bool(
|
|
args.model or settings.summary_model or settings.model
|
|
)
|
|
owner = db.get(models.User, adventure.user_id)
|
|
who = (owner.email if owner and owner.email
|
|
else f"guest #{adventure.user_id}")
|
|
print(f"\nadventure {adventure.id}: {adventure.title!r} ({who}) "
|
|
f"— {len(memories)} memories"
|
|
+ ("" if usable else " [owner has no API key in Settings]"))
|
|
|
|
for memory in memories:
|
|
if memory.source_start is None or memory.source_end is None:
|
|
totals["no source"] += 1
|
|
print(f" #{memory.id} skipped: hand-written, no source range")
|
|
continue
|
|
block = memorybank.source_block(db, memory)
|
|
if not block:
|
|
totals["no story left"] += 1
|
|
print(f" #{memory.id} skipped: the actions it summarized are gone")
|
|
continue
|
|
if not usable:
|
|
totals["no key"] += 1
|
|
continue
|
|
if not args.write:
|
|
totals["would rewrite"] += 1
|
|
print(f" #{memory.id} would rewrite from {len(block)} actions "
|
|
f"({words(memory.text)}w): {one_line(memory.text, 70)}")
|
|
continue
|
|
if budget is not None and budget <= 0:
|
|
break
|
|
try:
|
|
text = (await memorybank.summarize_block(
|
|
adventure, provider_for(settings), block)).strip()
|
|
except ProviderError as exc:
|
|
totals["failed"] += 1
|
|
print(f" #{memory.id} FAILED: {exc}")
|
|
continue
|
|
if not text:
|
|
totals["empty reply"] += 1
|
|
print(f" #{memory.id} FAILED: the model returned nothing")
|
|
continue
|
|
print(f" #{memory.id} {words(memory.text)}w → {words(text)}w")
|
|
print(f" was: {one_line(memory.text)}")
|
|
print(f" now: {one_line(text)}")
|
|
memory.text = text
|
|
# The stored vector describes the wording that has just been
|
|
# replaced. Clearing it takes the memory out of the ranked bank
|
|
# (`retrieve_memories` selects on `embedded`) until something
|
|
# embeds the new text.
|
|
memorybank.set_vector(memory, None)
|
|
# Commit each one. A run that dies halfway keeps what it has done,
|
|
# and re-running it costs only the memories still to do.
|
|
db.commit()
|
|
rewritten.append(memory)
|
|
totals["rewritten"] += 1
|
|
if budget is not None:
|
|
budget -= 1
|
|
if budget is not None and budget <= 0 and args.write:
|
|
print("\n--limit reached.")
|
|
break
|
|
|
|
embedded = 0
|
|
if args.write and args.embed and rewritten:
|
|
by_adventure: dict[int, list[models.Memory]] = {}
|
|
for memory in rewritten:
|
|
by_adventure.setdefault(memory.adventure_id, []).append(memory)
|
|
print()
|
|
for adventure_id, group in by_adventure.items():
|
|
adventure = db.get(models.Adventure, adventure_id)
|
|
settings = settings_for(adventure.user_id)
|
|
if settings is None or not settings.embedding_model.strip():
|
|
print(f"adventure {adventure_id}: no embedding model in Settings; "
|
|
f"{len(group)} memories left for the app to embed")
|
|
continue
|
|
done = 0
|
|
for start in range(0, len(group), memorybank.MAX_EMBED_BATCH):
|
|
batch = group[start:start + memorybank.MAX_EMBED_BATCH]
|
|
try:
|
|
new = await memorybank.embedding_provider(settings).embed(
|
|
[m.text for m in batch])
|
|
except ProviderError as exc:
|
|
print(f"adventure {adventure_id}: embedding failed ({exc}); "
|
|
f"{len(group) - done} left for the app to embed")
|
|
break
|
|
for memory, vector in zip(batch, new):
|
|
memorybank.set_vector(memory, vector)
|
|
db.commit()
|
|
done += len(batch)
|
|
embedded += done
|
|
if done:
|
|
print(f"adventure {adventure_id}: embedded {done}")
|
|
|
|
print("\n" + "-" * 60)
|
|
for label, count in totals.items():
|
|
if count:
|
|
print(f"{label:>14}: {count}")
|
|
if not args.write:
|
|
print("\nNothing was written. Re-run with --write to rewrite these.")
|
|
elif totals["rewritten"] - embedded > 0:
|
|
left = totals["rewritten"] - embedded
|
|
print(f"\n{left} rewritten {'memory is' if left == 1 else 'memories are'}"
|
|
" unembedded, so they are out of the ranked bank until the app's "
|
|
f"post-turn pass embeds them ({memorybank.MAX_EMBED_BATCH} per "
|
|
"turn).")
|
|
db.close()
|
|
return 1 if totals["failed"] or totals["empty reply"] else 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
parser = argparse.ArgumentParser(description=__doc__.split("\n")[0])
|
|
parser.add_argument("--write", action="store_true",
|
|
help="rewrite. Without it, only report what would change.")
|
|
parser.add_argument("--adventure", type=int, action="append",
|
|
help="restrict to this adventure id; repeatable.")
|
|
parser.add_argument("--email", action="append",
|
|
help="restrict to this account's adventures; repeatable. "
|
|
"Use it on a shared database.")
|
|
parser.add_argument("--limit", type=int,
|
|
help="stop after rewriting this many memories.")
|
|
parser.add_argument("--include-forgotten", action="store_true",
|
|
help="also rewrite evicted memories, which are out of play.")
|
|
parser.add_argument("--embed", action="store_true",
|
|
help="re-embed here. Stop the app first; see the docstring.")
|
|
parser.add_argument("--endpoint", help="override the owner's endpoint URL.")
|
|
parser.add_argument("--model", help="override the owner's summary model.")
|
|
args = parser.parse_args()
|
|
|
|
# A Windows console defaults to cp1252, which cannot encode the arrow this
|
|
# prints, let alone whatever is in a story's prose. Ask for UTF-8 and
|
|
# replace what the terminal still cannot render, so a run never dies
|
|
# halfway through on an encoding error.
|
|
if hasattr(sys.stdout, "reconfigure"):
|
|
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
raise SystemExit(asyncio.run(main(args)))
|