sneq-narrative-system
A narrative-state engine for AI-narrated games — TTRPGs with an AI Game Master, AI-driven RPGs, agent-played campaigns on Discord, anything where the AI invents the world and you don't want it forgetting what it invented.
Status: V2 — published on npm as
sneq-engine. Bindings to specific consumers (TTRPG app, Hermes-Agent on Discord) are separate follow-ups.
What problem this solves
When an LLM plays GM, two things break over a real campaign:
- It forgets. Three sessions in, the blacksmith's name has drifted, the village your character liberated has a different geography, the secret it hinted at last week has been silently rewritten.
- It forks canon. Even within a single session, the model will happily invent a "captain of the guard" who is structurally the same person as the captain you met in chapter one — under a different name, with a different personality, in a different city.
sneq-engine is a bookkeeping library that sits next to your GM agent. You drive the narration; the engine tracks canonical entities, facts, scenes, and turns, resolves new mentions against the existing world, and refuses to let the model fork reality.
It implements the SNEQ model — Système Narratif à État Quantique — a constraint-driven narrative engine where attributes stay undefined until the player observes them, then collapse into permanent facts that propagate constraints to the rest of the world. The original v1 design lives in SNEQ/. This repo implements V2, recalibrated for turn-based dialog rather than the real-time RPG framing of v1.
What V2 ships
- Bookkeeping library in TypeScript (Node 20+, ESM). No GM logic; you stay in control of prose.
- Multi-campaign — one Engine instance, many campaigns, scoped by
campaignId. - Layered entity resolution —
alias → vector → LLM judge → user-promptcascade for "is this the same NPC as 3 sessions ago?". Runs keyless in a degraded alias-only mode (omit the embeddings tier,embeddingDim: 0) — useful for demos, prototypes, and providers without an embeddings endpoint (DeepSeek). - Anti-fork guard —
mention_entityrefuses to silently create a near-duplicate when resolution is ambiguous: it returnsneedsAdjudication: true+ candidates so the caller decides (re-use an id, or re-call withforce: true). - Provider router with three task tiers (
heavy/light/ optionalembeddings), each with primary + fallback chain and real retry/backoff. Built-in adapters for DeepSeek, Mistral, Together, OpenRouter (fetch-based, zero deps), plus Anthropic and Google GenAI (lazy-loaded — their SDKs are genuinely optional peers), and acustomescape hatch. - Three repository adapters behind one contract: SQLite + sqlite-vec (file-based, zero ops), in-memory (
sneq-engine/memory, zero deps, brute-force cosine), and JSON-file (sneq-engine/json, atomic write-through, human-readable saves). The shared contract test suite is the seam's specification. - Tool-call protocol — Zod-validated tool schemas + ready-to-drop-in adapter shapes for Anthropic, OpenAI-compatible, and Gemini SDKs (10 advertised tools).
- Agent-discoverable skill — drop
skills/sneq-narrative-engine.mdinto a Claude Code / Hermes-Agent skills dir and the agent learns when to call which engine tool.
Stack policy
The default router excludes OpenAI and xAI/Grok. The custom provider escape hatch lets the host wire whatever they want — but the shipped defaults reflect a deliberate stack choice. See docs/superpowers/specs/2026-05-19-sneq-v2-engine-design.md §6 for the full rationale.
Install
pnpm add sneq-engine # only hard dependency: zod
# or, for the CLI alone:
npm i -g sneq-engine # puts the `sneq-engine` binary on your PATH
To hack on the engine itself, clone the repo and see Development.
Optional peers, only for what you actually use (the core import never touches them):
| You use | Install |
|---|---|
sneq-engine/memory or sneq-engine/json |
nothing |
sneq-engine/sqlite without vectors (embeddingDim: 0) |
better-sqlite3 |
sneq-engine/sqlite with vector resolution |
better-sqlite3 sqlite-vec |
| DeepSeek / Mistral / Together / OpenRouter / any OpenAI-compatible | nothing (fetch-based) |
| the Anthropic provider | @anthropic-ai/sdk |
| the Google GenAI provider | @google/generative-ai |
Quick start — zero config, zero keys
No API keys, no native modules: the in-memory adapter plus alias-only resolution. This is the smallest thing that works — perfect for a demo mode or a prototype.
import { Engine, asCampaignId } from "sneq-engine";
import { memoryRepository } from "sneq-engine/memory";
const engine = new Engine({
repository: memoryRepository(), // or jsonFileRepository({ path: "./save.json" })
router: { tiers: {
heavy: { primary: { provider: "openai-compatible", baseUrl: "https://api.deepseek.com/v1", apiKeyEnv: "DEEPSEEK_API_KEY", model: "deepseek-chat" }, fallbacks: [] },
light: { primary: { provider: "openai-compatible", baseUrl: "https://api.deepseek.com/v1", apiKeyEnv: "DEEPSEEK_API_KEY", model: "deepseek-chat" }, fallbacks: [] }
// no embeddings tier → alias-only resolution, no embeddings key needed
} }
});
const campaign = await engine.createCampaign({
id: asCampaignId("demo"), name: "Demo", embeddingDim: 0 // 0 = no vectors
});
With a chat key present the LLM judge still disambiguates multi-alias hits; with no keys at all the engine stays fully functional on exact-alias resolution.
Quick start — full cascade (SQLite + vectors)
import { Engine, defaultRouterConfig, asCampaignId } from "sneq-engine";
import { sqliteRepository } from "sneq-engine/sqlite";
const engine = new Engine({
repository: sqliteRepository({ path: "./my-campaign.db", embeddingDim: 768 }),
router: defaultRouterConfig()
});
const campaign = await engine.createCampaign({
id: asCampaignId("campaign-1"),
name: "The Forgeron of Valmure",
embeddingDim: 768
});
// Player says "I look for the blacksmith"
const r = await campaign.resolveEntity({ mention: "the blacksmith" });
if (r.match) {
console.log("Known:", r.match.name);
} else {
await campaign.mentionEntity({
canonicalName: "Aldric Fervent",
type: "PERSONNAGE",
aliases: ["the blacksmith"],
description: "A grizzled smith with haunted eyes."
});
}
Distributed stores — opt-in atomic writes
Local repositories keep the historical API unchanged:
new Engine({
repository: sqliteRepository({ path: "./canon.db" }),
router: routerConfig,
});
A distributed store cannot transport Repository.transaction(fn) atomically across several HTTP
calls. Supply a RepositoryAccess plus an explicit AtomicWriteStrategy instead:
import { Engine, Router } from "sneq-engine";
import type { AtomicWriteStrategy, RepositoryAccess } from "sneq-engine";
const sharedRouter = new Router(routerConfig, routerDeps);
const repository: RepositoryAccess = distributedRepository;
const writeStrategy: AtomicWriteStrategy = distributedAtomicWrites;
const engine = new Engine({
repository,
writeStrategy,
router: routerConfig,
routerInstance: sharedRouter,
});
engine.routerClient() === sharedRouter; // true — the host and canon share one Router
The strategy owns the atomic execution of registerFact, setScene, advanceTurn, entity
confirmation, constraint append (addConstraint), and canonical entity creation (createEntity).
Pure command decisions are available from sneq-engine/atomic so an adapter can run
SNEQ's rules inside its store transaction without importing a framework into the engine.
Every command carries an operationId generated once per logical engine call. A distributed strategy
must atomically deduplicate retries of that ID and return the original result; this covers a committed
store mutation whose transport response was lost. The local repository-backed strategy remains an
in-process Repository.transaction(fn) implementation.
Canonical creation is optimistic: mentionEntity() reads a per-campaign entityRevision, resolves and
embeds outside any transaction, then asks the strategy to createEntity only if the revision is
unchanged. If canon moved under it the create returns stale and the engine re-resolves against the
newer world before retrying (bounded, then SneqConcurrentEntityCreationError). A distributed strategy
must not record a non-terminal stale result in its idempotency store — only terminal
create/existing/conflict results are deduplicated.
For asynchronous web adjudication, mentionEntity() still returns needsAdjudication. A later
request can confirm the selected existing entity and persist the mention as a player-observed alias:
await campaign.confirmEntityMatch({
mention: "the captain",
entityId: selectedEntityId,
type: "PERSONNAGE",
});
This complements the synchronous UserPromptRegistry; it does not replace it. File-backed agents
such as Hermes can continue using their current prompt handler and repository configuration.
CLI usage (out-of-process consumers)
For agents that can't (or don't want to) embed the TypeScript library — Hermes-Agent
on Discord, scripts in other languages, smoke-test sessions — install the package and
use the sneq-engine binary. Every call reads/writes a single line of JSON on stdout.
# Create a campaign
sneq-engine init-campaign --db ./campaign.db --campaign forge-de-valmure \
--args '{"name":"La Forge de Valmure","embeddingDim":768}'
# Resolve a mention
sneq-engine lookup-entity --db ./campaign.db --campaign forge-de-valmure \
--args '{"mention":"the blacksmith","type":"PERSONNAGE"}'
# Register a fact (observation provenance via --source preset)
sneq-engine register-fact --db ./campaign.db --campaign forge-de-valmure \
--source gm-narration \
--args '{"entityId":"ent_abc","attributeKey":"metier","category":"HISTORIQUE","value":{"type":"STRING","value":"capitaine"}}'
# Args via stdin work too
echo '{"entityId":"ent_abc"}' | sneq-engine get-entity --db ./campaign.db --campaign forge-de-valmure
# Probe whether a campaign is initialized (no throw on missing)
sneq-engine campaign-exists --db ./campaign.db --campaign forge-de-valmure
# Atomic wake-up bundle: scene + present entities + their facts in one call
sneq-engine prepare-turn --db ./campaign.db --campaign forge-de-valmure
# Validate a candidate narration before flushing to the player
sneq-engine validate-narration --db ./campaign.db --campaign forge-de-valmure \
--args '{"narration":"Mira rejoint Aldric à Valmure.","strict":true}'
- 14 commands: the 9 tool dispatcher entries (
lookup-entity,get-entity,get-relevant-facts,suggest-existing,mention-entity,register-fact,add-constraint,set-scene,advance-turn) plus three conveniences (init-campaign,get-scene,campaign-exists), one defensive validation command (validate-narration), and one orchestration command (prepare-turn). - Exit codes:
0on success,1on user/validation errors,2on internal errors. - Errors emit
{"error":"…","code":"…","details":…}on stdout — never on stderr. - Provider keys (
ANTHROPIC_API_KEY,MISTRAL_API_KEY, etc.) are read from env. Use--config <path>to override the router config. --embedding-dimis only needed atinit-campaign(existing DBs remember their dim). The default derives from the config's embeddings primary (768 with the default config); pass0for alias-only campaigns with no embeddings provider at all.- Run
sneq-engine --helporsneq-engine <command> --helpfor usage details. - Full spec:
docs/superpowers/specs/2026-05-20-sneq-cli-design.md(initial CLI) +docs/superpowers/specs/2026-05-21-sneq-defensive-features-design.md(defensive features).
Wiring as agent tools
import { Engine } from "sneq-engine";
// Get the tool schemas in the shape your model wants (10 advertised tools):
const anthropicTools = Engine.tools.anthropic;
const openaiTools = Engine.tools.openai;
const geminiTools = Engine.tools.gemini;
// Pass into your model call. When the model emits a tool call, dispatch it:
const result = await campaign.handleToolCall(name, args);
The full tool reference (when to call what, in narrative terms) lives in skills/sneq-narrative-engine.md. The authoritative signatures live in docs/api.md.
Architecture
┌─────────────────────────────────┐
consumer│ Engine (facade) │
(your │ engine.campaign(id).… │
GM) └─────┬───────────────────────────┘
│
┌───────┬──┴────┬─────────┬────────┬────────┐
▼ ▼ ▼ ▼ ▼ ▼
Domain GCN Resolver Router Tools Hooks
RC + CP cascade tiers schemas (askUser,
pre-gen)
│
┌─────────┴────────┐
▼ ▼
Provider Repository
interface interface
│ │
OpenAI-compat / SQLite + sqlite-vec
Anthropic / (reference adapter)
Google GenAI /
custom
Documentation
| File | Audience |
|---|---|
UPGRADING.md |
Existing consumers (CLI agents like Hermes, in-process apps) — version migration guide, agent-executable |
docs/api.md |
TypeScript developers — full API reference (TypeDoc-generated) |
skills/sneq-narrative-engine.md |
Claude Code / Hermes / agent runtimes — when to invoke which tool |
docs/superpowers/specs/ |
V2 design spec (markdown + HTML brief) |
docs/superpowers/plans/ |
Implementation plan with per-task TDD steps |
SNEQ/ |
Original v1 design docs (in French) — the conceptual foundation |
Known deferred scope
V2 is intentionally minimal. The following are out-of-scope for this version and tracked for follow-ups:
- Attribute collapse (generate-then-commit) — there is no
collapsetool or method. Consumers compose it from exported primitives:Router.chat(heavy tier) +validateValue+registerFact. - Pre-generation cache — the v1 spec's elaborate predictor/cache for real-time RPGs.
PreGenerationHookinterface exists with a no-op default; the full implementation is a future version. - Convex / Postgres repository adapters — SQLite, in-memory, and JSON-file ship; the
Repositorycontract test suite (test/repository/contract.ts) is the specification for new adapters. - One DB per campaign is the blessed layout — the sqlite-vec prefilter degrades on shared multi-campaign databases with many entities; the CLI examples already follow this.
- Multi-PC / party support — V2 assumes single-PC sessions.
- HTTP / MCP gateway — engine is in-process. Wrap trivially later if needed.
- Consumer bindings — TTRPG single-player app and Hermes-Agent MCP / skill integrations get their own follow-up specs.
Project structure
SNEQ/ v1 design docs (French)
src/ engine source
domain/ branded IDs, Entity, AttributValue, GCN, etc.
core/ state machine, propagation, validation (pure)
repository/{interface,sqlite/,memory/,json/} Repository contract + 3 adapters
router/{interface,router,providers/,defaults} Router + 4 providers (SDK ones lazy-loaded)
resolver/{resolver,judge,thresholds,normalize} Layered cascade (degrades keyless)
tools/{schemas,json-schema,adapters,dispatcher} Tool-call protocol
hooks/{user-prompt,pre-generation} Extension points
engine.ts, campaign.ts Facade + CampaignContext
config.ts, logger.ts, errors.ts, index.ts
test/ 235 unit tests (incl. the repository contract suite) + 1 env-gated integration smoke
docs/ generated API + design specs + plans
skills/ agent-discoverable skill
Development
pnpm test # unit tests (excludes integration smoke)
pnpm typecheck # full project tsc --noEmit
pnpm build # emit dist/
pnpm docs # regenerate docs/api.md from TypeDoc
SNEQ_INTEGRATION_SMOKE=1 pnpm test # include integration smoke (needs API keys)
License
MIT — see LICENSE.
Acknowledgments
Built with Claude Code (Opus 4.7, 1M context) over a long brainstorm → spec → plan → subagent-driven-execution session. The v1 SNEQ design docs were Jean's starting input; the V2 design, plan, and implementation were produced collaboratively with the AI. The Anthropic superpowers plugin provided the brainstorming / planning / execution skills.