Skip to main content
Glama
README.md
# <img src="frontend/public/favicon.svg" width="28" height="28" alt="" align="center" /> Scheherazade's Hoard
### Who's alive? Who's where? What have you promised them?
**A world-state and continuity keeper for interactive fiction and tabletop play — it remembers everything a language model forgets, and hands the narrator exactly the slice it needs for the next scene.**

[Español](README.es.md) · [Quick start](#quick-start) · [Connect to Faustus](#connect-it-to-faustus) · [MCP reference](docs/MCP.md) · [Portfolio](https://luissalet.github.io/Portfolio/#projects)

![The Play screen, mid-scene, with the dice tray and threads/clocks panel](docs/media/02-play.png)
*The real application with synthetic demo data ("El Archipiélago de Sal", an original setting seeded by `--demo`).*

## Why

Language models are good narrators and terrible continuity editors. After
an hour of interactive fiction or a tabletop session they forget who knows
what, resurrect dead characters, move towns, lose the plot threads and
fudge dice. Scheherazade keeps the **world state outside the model** —
entities, relationships, secrets, places, a chronology, open threads,
clocks, tables and an audited dice log — and builds the narrator a
compact, ranked, budget-bound brief for the next scene, then records what
that scene actually changed. It can narrate on its own with a shared
local model, or hand its tools to another AI (Faustus) so that AI
narrates while this app remembers everything.

## What is implemented

| Area | Available now | Boundary |
| --- | --- | --- |
| World state | Entities (character/location/faction/item/lore/creature) with aliases, stats and GM secrets, relations, facts with provenance and a canon flag, timeline, threads, clocks, random tables; SQLite + FTS5 with accent-insensitive search | One local writer; no multi-user editing |
| Dice | `NdM`, `+/-`, `kh/kl/dh/dl`, exploding `!`, `adv(d20)`/`dis(d20)`, `dF`; seeded reproducibility; every roll in an append-only log; with a world, 2d6 rolls get their PbtA band and d20 rolls their natural value and crit | Bounded on purpose (200 dice per roll, 100 characters per expression) |
| Context builder | `world_context()`: premise, content boundaries, current scene and cast, ranked facts, live threads, clocks at least half full and recent turns, each line with a citable id, within a character budget | Ranking is lexical and rule-based, not embedding search |
| Delta engine | Validation per item, then the delta and its turn in one SQLite transaction; the scene carries over between turns; undo walks back turn by turn within the current session | No redo; turns of an earlier session cannot be undone |
| Narrator (standalone) | Builds the prompt, calls the shared model through Hoard Link, separates narration from the delta (fenced or bare JSON; repairs typographic quotes, comments, trailing commas, a delta nested under `"delta"` and a reply cut at the token limit, keeping the complete items), never leaves JSON in the story, and lets you accept or reject each proposed change | Not streamed: one reply, with a spinner |
| Consistency check | Rules for a dead character acting, a character placed away from where they were last seen and a contradicted relation (whole-word, alias-aware), plus an LLM judge over matching facts that must cite fact ids; the result says whether the judge ran | Heuristics: it misses contradictions that no rule covers and no fact states |
| Export | Session as a manuscript-clean Markdown chapter (no ids, dice or out-of-character lines; actions in italics; Spanish raya dialogue), optionally polished by the shared model (falls back to the plain chapter and says why), world bible in Markdown, full JSON export and import | Polish is instructed not to add facts but is not checked against the source; chapters over 6000 characters are not polished; an imported world keeps its sessions and turns but not their undo history |
| Illustrations | "Illustrate" calls Prospero's Hoard's agent API when it answers on 127.0.0.1:8815; the button is hidden otherwise | Needs that separate app; the image is shown, not stored on the turn |
| Shared model backend | Hoard Link vendored unmodified (`scheherazades_hoard/hoard_link/`): explicit settings, then Faustus's model registry, then resident models on loopback (llama.cpp, Ollama, OpenAI-compatible); Settings shows the reason, can clear overrides and never returns the token | Only the language-model capability is used; the app never loads a model itself |
| UI | Play (Narration/Action/Dialogue/Out-of-character modes and a scene editor for place, cast and mood), Bible (create and edit: status, summary, aliases, tags, secrets), Map of relations (SVG), Timeline, Threads & clocks (kanban and segmented clocks), Tables, Sessions (start, rename, export), Dice log, Backends, Assistant activity, Settings; a continuity check box in Play; accent-insensitive Bible search; Spanish and English, light and dark | The map uses a fixed circular layout, not a physics simulation |

## Use cases

Eight scenarios for a solo player who also writes, each walked in the
browser and over MCP before and after the usability review
([docs/USE_CASES.md](docs/USE_CASES.md); findings and a verdict per use
case in [docs/USABILITY_REPORT.md](docs/USABILITY_REPORT.md)). Among them:

- **First evening without a model:** create a world, its people and
  places, set the scene and play it by hand, dice included.
- **A long night narrated by a local model through Faustus:** 44 beats
  in Spanish where the dead stay dead, people stay where they were last
  seen and a bad turn can be taken back.
- **A clean chapter for the manuscript:** the session exported as prose
  and raya dialogue, without ids, dice or out-of-character lines.
- **The app narrates on its own:** a 27B model's sloppy JSON is repaired
  and every proposed change is reviewed before it touches the world.
- **Continuity questions while writing:** "who has died?", "what does
  Iria know?", "is it consistent that Mateo opens the door?", answered in
  one or two tool calls, asked in Spanish.
- **The next evening, fixing by hand:** correct a summary, mark someone
  missing, tick a clock, undo a turn — no model needed.

## Connect it to Faustus

The app declares itself with `faustus-plugin.json`. Start the app, then in
Faustus: **Connectors → Nearby apps → Add**. Faustus finds it by scanning
loopback ports and reading that manifest, which also tells it how to start
the app (`python -m scheherazades_hoard --no-browser`, ready when
`/api/health` answers) and how to launch the MCP server
(`python scheherazades_hoard/mcp_server.py` over stdio, with
`SCHEHERAZADE_URL` pointing at the running app).

Two ways to play, same world: connected, Faustus is the narrator and uses
the tools below (the `narrator-loop` skill tells it in which order); on
its own, the app narrates with the model Faustus already has loaded,
found through Hoard Link, so nothing is loaded twice.

| Tool | Read-only | What it does |
| --- | --- | --- |
| `story_worlds()` | yes | List every world with counts and current session |
| `story_world_create(...)` | no | Create a new world |
| `world_context(world, ...)` | yes | The narrator's brief for the next scene |
| `world_search(world, query, ...)` | yes | Search entities/facts |
| `entity_get(world, ref, ...)` | yes | One entity with relations and facts |
| `entity_upsert(world, kind, name, ...)` | no | Create or update an entity |
| `story_append(world, text, ...)` | no | Record a turn and apply a delta, atomically |
| `dice_roll(expression, ...)` | no | Roll dice with an audited log |
| `table_roll(world, table)` | no | Roll on a random table |
| `thread_update(world, thread, ...)` | no | Advance/resolve/abandon a thread |
| `clock_tick(world, clock, ticks=1)` | no | Advance a clock |
| `world_check(world, statement)` | yes | Check a statement against established facts |
| `session_start(world, title="")` | no | Start a new, optionally named session |
| `session_rename(world, session, title)` | no | Rename a session |
| `session_export(world, ...)` | yes | Export a chapter / the bible / full JSON, a page at a time |
| `story_undo(world)` | no | Revert the last turn and everything its delta changed |

Full argument lists, return shapes and limits: [`docs/MCP.md`](docs/MCP.md).

It also works with any other MCP client over stdio (on Linux/macOS the
interpreter is `.venv/bin/python`):

```json
{
  "mcpServers": {
    "scheherazade": {
      "command": "/absolute/path/to/scheherazades-hoard/.venv/Scripts/python.exe",
      "args": ["/absolute/path/to/scheherazades-hoard/scheherazades_hoard/mcp_server.py"],
      "env": { "SCHEHERAZADE_URL": "http://127.0.0.1:8816" }
    }
  }
}
```

## Quick start

### Windows

Double-click **`Iniciar Scheherazade's Hoard.cmd`**. It runs
`scripts/start.ps1`, which finds Python 3.11 or newer, creates `.venv`
and installs `requirements-lock.txt` (again whenever the lock changes),
builds the interface if `frontend/dist` is missing (Node 22 is needed
only then), starts the app with the repo root as working directory, waits
for `/api/health` and opens the browser. If the app is already running it
only opens the browser. Stop it with **`Detener Scheherazade's Hoard.cmd`**
(`scripts/stop.ps1`), which also stops an instance Faustus started.

Manual steps, from a fresh clone, in PowerShell:

```powershell
git clone https://github.com/Luissalet/ScheherazadesHoard.git
cd ScheherazadesHoard
python -m venv .venv
.venv\Scripts\python.exe -m pip install -r requirements-lock.txt
cd frontend; npm ci; npm run build; cd ..
.venv\Scripts\python.exe -m scheherazades_hoard --demo
```

### Linux / macOS

The app itself is plain Python + a static frontend build, so it runs the
same way outside Windows (only the `.cmd` launchers and `scripts/*.ps1`
are Windows-only):

```bash
git clone https://github.com/Luissalet/ScheherazadesHoard.git
cd ScheherazadesHoard
python3 -m venv .venv
.venv/bin/python -m pip install -r requirements-lock.txt
cd frontend && npm ci && npm run build && cd ..
.venv/bin/python -m scheherazades_hoard --demo
```

`--demo` uses a synthetic seeded world in `data-demo/` instead of your
real `data/`, so you can try everything without touching (or needing) any
real data. Drop `--demo` for your own worlds; add `--no-browser` to skip
the automatic tab.

## Shared models (Hoard Link)

The app never starts or loads a model of its own. It uses
[Hoard Link](https://github.com/Luissalet/HoardLink), vendored
byte-identical in `scheherazades_hoard/hoard_link/` (version in
`VENDORED.txt`), to find the language model that is already running:
explicit settings from the Settings screen or `HOARD_*` environment
variables first, then the model Faustus already uses, then resident
models on loopback (llama.cpp, Ollama, any OpenAI-compatible server).
When nothing answers, Settings says why and everything except
"Narrate", "Polish" and the continuity judge keeps working.

## Architecture

```mermaid
flowchart LR
  UI["React UI<br/>(frontend/dist)"] -->|REST| API["FastAPI app<br/>127.0.0.1:8816"]
  AI["Faustus or any<br/>MCP client"] -->|stdio| MCP["mcp_server.py"]
  MCP -->|loopback HTTP<br/>/api/agent/*| API
  API --> Core["context · delta · consistency<br/>dice · export"]
  Core --> DB[("SQLite + FTS5<br/>data/")]
  API -->|Hoard Link| LLM["Local model server"]
  API -.->|optional| P["Prospero's Hoard<br/>(illustrations)"]
```

Modules, data model, the atomic delta engine, and the decisions behind
them: [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md).

## Development

```powershell
.venv\Scripts\python.exe -m pip install -r requirements-lock.txt
.venv\Scripts\python.exe -m pytest tests/ -q
cd frontend; npm ci; npm run build
```

```bash
.venv/bin/python -m pip install -r requirements-lock.txt
.venv/bin/python -m pytest tests/ -q
cd frontend && npm ci && npm run build
```

The same commands run in CI (`.github/workflows/ci.yml`: Ubuntu,
Python 3.12, Node 22).

294 tests, offline (no real model: Hoard Link, the narrator and the
Prospero adapter run against `httpx.MockTransport`), in well under a
minute.
They cover the dice grammar and its bounds, ruleset readings, the context
builder's budget, ranking and secret exclusion, delta validation, the
single-transaction turn and undo (including repeated updates in one
delta), scene carry-over, JSON extraction including the sloppy shapes a
small model sends, first-name entity lookup, the consistency rules,
accent-insensitive and status search, the manuscript-clean chapter,
exports and import, the static file server
against path traversal, the Host/Origin guard, the CLI start with its pid
file and log, and an MCP protocol test that spawns the real adapter over
stdio against a live instance (`list_tools`, annotations, Keywords lines,
and a create, roll, append, context and undo round trip), plus a check
that a picker reading only line one of each tool description finds the
right tool for Spanish requests.

`npm run build` (inside `frontend/`) runs `tsc -b && vite build` with
TypeScript strict mode, `noUnusedLocals` and `noUnusedParameters` on.

## Privacy and security

- Binds `127.0.0.1` only, rejects other Host headers and cross-site
  writes, and refuses to be framed by web pages. No telemetry. The only
  network calls are to model servers on your machine (or the Faustus you
  configured) and to Prospero's Hoard, both on loopback.
- Your worlds live in `data/` (gitignored) as one SQLite file; the log is
  `data/logs/app.log` and records tool names and timings, never story
  text, secrets or tokens.
- Agent tools never return GM secrets unless asked with
  `include_secrets=true`; every agent call is written to an audit table
  (`agent_calls`: tool, ok/error, duration) that "Assistant activity"
  shows, and your own clicks in the interface are kept out of it.
- The narrator's structured output is parsed from free text; a reply
  that cannot be parsed is kept as narration marked `unparsed`, never
  guessed at.

## Roadmap / known limits

Open items from the project's own design notes
([docs/USABILITY_REPORT.md](docs/USABILITY_REPORT.md)), roughly in order
of value:

- `world_context` returns about 1.5 times its character budget, because
  the `scene`, `lore`, `threads` and `recent_turns` fields repeat what
  the brief already says.
- An agent cannot create a clock or add a standalone relation/fact
  outside of a turn; both need the HTTP route today.
- "Where was X last seen" has no direct tool answer outside
  `world_check`.
- `story_append` over MCP has no `rolls` argument, so a roll the model
  makes is logged but not linked to its turn.
- `thread_update` needs a thread's id or full title; part of a title
  does not match.
- The proposal card in Play shows facts, updates and clock ticks but not
  a proposed scene move, cast or mood change.
- The relations map uses a fixed circular layout that overlaps labels
  once a world has 30+ entities, and does not visually distinguish dead
  or missing entities.
- Below 760px there is no way to open the navigation sidebar.
- Play always opens at the top of the transcript, so a long session
  needs a scroll to reach the latest turn.
- A JSON world backup over MCP pages a large string through the model's
  context; importing through the UI does not have this limit.
- Some messages are still English-only in an otherwise Spanish session:
  delta rejections ("… is dead and cannot act"), raw roll bands
  (`weak_hit`), status badges and some Backends reasons.
- `world_check`'s dead-acting rule has a false positive when a dead
  character is only mentioned, not acting.
- The polished-chapter route does not check the model's reply for
  length or truncation before it can replace the original chapter, and
  refuses to polish chapters over 6000 characters.

None of these block using the app today — see the verdict per use case
in [docs/USABILITY_REPORT.md](docs/USABILITY_REPORT.md#re-walk-after-the-fixes-second-pass).

## License

MIT — see [`LICENSE`](LICENSE).