27 Commits

Author SHA1 Message Date
cc9d0006e5 docs: Added documentation for LLMProviders and named instances 2026-07-09 19:35:18 +05:30
6626adf38d MAJOR: deprecated cli interface 2026-07-09 19:09:52 +05:30
053748564f refactor: Decouple model from provider 2026-07-09 19:02:42 +05:30
acd62bdb65 minor: Switch to master-detail layout for LLMProviderInstance 2026-07-09 18:54:35 +05:30
46b12cd668 feat: LLMProviderInstance over LLMProvider
allows for using multiple keys of the same provider
implemented per llm call providerinstance mapping
2026-07-09 18:40:52 +05:30
4ef52f926e MAJOR: Added a GUI app for simulations 2026-07-09 18:14:26 +05:30
0c59756c08 refactor: Move cli package to apps/cli 2026-07-09 13:17:29 +05:30
6e096415ee Merge pull request #20 from sortedcord/remotes/origin/feat/config
MAJOR: Removed old scenario builder and moved scenario loader into pa…
2026-07-09 13:10:24 +05:30
be076d81e5 MAJOR: Removed old scenario builder and moved scenario loader into packages/scenario 2026-07-09 13:09:05 +05:30
13f6dd424e MAJOR: introduce split providers per LLM call and config system 2026-07-09 12:24:15 +05:30
907c3b8ed7 minor: Improve systme prompt for Alias Generation 2026-07-09 10:56:21 +05:30
9642e2bb54 refactor: Group subsequent events under a naturalized time head
Strips out actual world clock from memories.
2026-07-09 10:19:09 +05:30
8934422a4d feat: Add logging to file for a CLI run 2026-07-09 09:44:00 +05:30
63badedf75 feat: Show context usage breakdown in cli verbose mode 2026-07-09 09:31:22 +05:30
717f9f20d4 minor: Update actor prompt to only output in first person 2026-07-09 09:27:07 +05:30
3da043952c feat: Added Alias Resolver DeltaGenerator 2026-07-09 09:26:56 +05:30
17c5b95f3b update: docs 2026-07-09 05:07:25 +05:30
53b221f80c docs: logo path fix 2026-07-09 04:54:37 +05:30
d9940a036a docs: Migrated to astro v7 for live docs 2026-07-09 04:53:00 +05:30
093f8de4c5 docs: Update readme 2026-07-09 03:14:18 +05:30
6cf099821b refactor: Serializers include available location attributes 2026-07-09 03:04:48 +05:30
fadea41e6f cli: FIRST RUN!!!! ITS ALIVE! 2026-07-09 02:51:57 +05:30
701bc56d0c content: Added demo scenario and tests 2026-07-09 02:07:50 +05:30
d96fc04542 feat: Implement ScenarioLoader pipeline 2026-07-09 01:59:27 +05:30
fa698619b3 feat: Implement actor prompt builder 2026-07-09 01:40:38 +05:30
15abfdd898 feat: Design and implement time naturalization helper function 2026-07-08 22:05:26 +05:30
74ffb456a7 feat: Implement tier 1 memory buffer 2026-07-08 21:33:25 +05:30
99 changed files with 10286 additions and 3838 deletions

7
.gitignore vendored
View File

@@ -14,6 +14,7 @@ dist-ssr/
build/
coverage/
.next/
.astro/
out/
*.tsbuildinfo
@@ -45,6 +46,12 @@ Thumbs.db
# Database
omnia.db
*.db
*.db-journal
*.db-wal
*.db-shm
**/data/*.db
data/
# Environment Files
.env

134
README.md
View File

@@ -1,4 +1,4 @@
![Omnia Logo](./docs/assets/img/logo.png)
![Omnia Logo](web/docs/src/assets/img/logo.png)
An LLM-assisted narrative simulation engine where the <b>world state lives outside the model</b>, characters act through <b>intents that get validated</b> and applied by engine code, and each character's knowledge, memory, and emotional state are subjective and partial by construction.
@@ -6,46 +6,94 @@ Omnia is an engine for building narrative RPG-style worlds where characters are
## The Problem with the Naive Approach
Prompting a model to just _be_ the world or _be_ an NPC breaks in predictable ways over long sessions:
Single-agent, single-context systems (AI Dungeon and its descendants) prompt one model to _be_ the world and everyone in it. That breaks in predictable ways over long sessions:
- **State Leaks:** Characters know things they had no way of learning because a model with full context cannot help but use it.
- **Secrets Refuse to Stay Secret:** "Don't reveal this" is a suggestion a model can argue past, not a mechanism that says no.
- **State Leaks:** Characters know things they had no way of learning, because a model with full context cannot help but use it. The assassin's target greets him by name.
- **Secrets Refuse to Stay Secret:** "Don't reveal this" is a suggestion a model can argue past, not a mechanism that says no. One clever player question and the conspiracy folds.
- **Consequences Evaporate:** Betray someone, apologize, and they forgive you a turn later because nothing is tracking the betrayal as a persistent fact.
- **Emotional Drift:** Emotional state is either frozen into a meaningless number (`trust: 40`) or handed to the model to grade itself, producing drifting, arbitrary values.
- **World Rot:** The world state slowly contradicts itself because the model has no structured place to keep it.
- **World Rot:** The world state slowly contradicts itself because the model has no structured place to keep it. The locked door is open, then locked, then never existed.
- **Everyone Is One Person:** Every character shares one context, so every character shares one mind. They can't genuinely surprise each other, lie to each other, or know different things — they're sock puppets on the same hand.
The root cause is the same in every case: the model is being asked to be the database, the physics engine, the referee, and the whole cast simultaneously — inside a context window that forgets, blends, and leaks.
## The Omnia Solution
Omnia answers every one of these failures with the same move: pull the thing that has to stay consistent out of the model and into structured, queryable, code-controlled state.
- **World State:** Lives in a database, not in a context window.
- **Actions:** Actions are proposals (Intents) that engine code validates and applies; they are never direct edits the model makes to the world.
- **Epistemic Privacy:** Knowledge, memory, and emotion are modeled per character and kept partial on purpose. A character literally cannot reach for what it has not earned the right to know.
- **World State:** Lives in a SQLite database, not in a context window. It cannot drift, because nothing regenerates it — it only changes through validated deltas.
- **Actions:** Actions are proposals (Intents) that engine code validates and applies; they are never direct edits the model makes to the world. The model proposes; deterministic code disposes.
- **Epistemic Privacy:** Knowledge, memory, and emotion are modeled per character and kept partial on purpose. A character literally cannot reach for what it has not earned the right to know — the secret is not in its prompt, so there is nothing to jailbreak out of it.
## What This Buys You
The payoff is scenario complexity that uni-agent systems structurally cannot represent, no matter how good the model gets:
- **Real secrets, real dramatic irony.** One NPC knows the sword is cursed; the other does not. This holds for hundreds of turns not because the model is disciplined, but because the second NPC's prompts are constructed from an attribute set that simply does not contain the fact. Leaking it would require the engine to have handed it over.
- **Genuine deception between characters.** Because each character acts from its own bounded view, characters can lie to each other — and be believed — with the truth intact in the world state. A con game, a mole in the party, an unreliable ally: these are queries over who-knows-what, not prompt acrobatics.
- **Betrayal that stays betrayed.** Events persist as per-observer memory entries with outcomes. An apology adds a memory; it does not delete one.
- **Divergent accounts of the same event.** Two witnesses to the same scene hold two different buffer entries, filtered through their own aliases and vantage points. Ask them separately what happened and you get testimony, not a transcript.
- **Identity as information.** Characters refer to each other through subjective alias maps ("the hooded figure" vs. "Bob"). Recognizing someone, being recognized, or staying anonymous are all mechanical states — a masked stranger is a masked stranger until the engine says otherwise.
- **A physics referee that can say no.** "I pick the lock with a hairpin" is validated against world state by the Architect before anything changes. Failure is a recorded outcome the character remembers, not a narrative the model politely retconned.
- **Time that behaves.** A world clock advances by validated, per-action deltas, and memory is recalled with psychologically natural phrasing ("earlier today, in the afternoon" — not a timestamp). Long timelines stay coherent because time is data, not vibes.
The general principle: **anything that must remain true is state; the model only ever supplies behavior.** That division of labor is what lets the cast, the secrets, and the timeline scale without the fiction collapsing.
## Core Architecture
### The Actor Agent
Each character takes turns through an **Actor Agent** that receives a strictly epistemically-bounded prompt: its own attributes (public, plus private ones explicitly granted to itself), its subjective memory buffer, the entities co-present at its location, and the current moment. Nothing else. The actor responds with free narrative prose — what the character does, says, or _thinks_.
Prose is decoded into typed intents:
- **`dialogue`** — speech others can hear.
- **`action`** — a physical act, subject to validation.
- **`monologue`** — an inner thought. No one else perceives it, it bypasses validation entirely, and it is written straight into the character's private memory.
Not every turn needs an outward act; a character may simply think. This is what makes characters feel inhabited rather than reactive — and it produces a durable, queryable record of each character's private reasoning (see [Research Instrument](#a-research-instrument-model-psychology-in-fiction) below).
The prose generator is pluggable (`IActorProseGenerator`): the same turn loop runs an LLM-driven NPC or a human at a CLI prompt, identically bounded by what their character knows.
### Intents & The World Architect
An action becomes an **Intent**a cheap, declarative, allowed-to-be-wrong proposal. Intents pass through a pipeline of validators (plain functions that reject or reshape proposals against current world state) and resolve. Simple speech resolves directly. Complex actions route to the **World Architect**, a single LLM call that receives scoped world state and returns a structured JSON delta. That delta is applied to the world by deterministic code after strict schema validation. The model proposes a change; it never touches the database.
An action becomes an **Intent**a cheap, declarative, allowed-to-be-wrong proposal. Intents route to the **World Architect**, which validates them against the objective world state (dialogue is exempt; monologue never even arrives) and generates structured deltas — starting with time advancement — that deterministic code applies after strict schema (Zod) validation. The model proposes a change; it never touches the database.
This is the load-bearing wall. Because every mutation flows through one validated chokepoint, the world cannot rot: there is no second copy of reality inside a context window to fall out of sync.
### Attribute-Level Privacy
Every entity, item, and location is an attribute bag. Each attribute carries its own visibility (`PUBLIC` or `PRIVATE`) with an access list. "The sword is cursed" is a private attribute checked in code, not a rule the model is politely asked to honor. Privacy lives at the level of the fact, not the entity.
Every entity, item, and location is an attribute bag. Each attribute carries its own visibility (`PUBLIC` or `PRIVATE`) with an explicit access list. "The sword is cursed" is a private attribute checked in code, not a rule the model is politely asked to honor. Privacy lives at the level of the fact, not the entity — a character can be publicly a blacksmith and privately a spy, and even facts about _itself_ are hidden from it unless explicitly granted (amnesia, repression, and unwitting sleeper agents come free with the model).
The dividend: **prompt-injection-proof secrets.** There is no instruction to override because the information was never serialized into the prompt. Epistemic privacy turns "the model shouldn't say this" (hard, unreliable) into "the model doesn't know this" (trivial, absolute).
### Spatial Perception
Space is a graph: `world → region → location → point of interest`, connected by portals with sound and vision propagation values. When something happens, it bubbles outward. There are no coordinates, no pathfinding, no collision geometrya narrative engine doesn't need a tactical simulation, and a discrete graph is sufficient.
Space is a graph: `world → region → location → point of interest`, connected by portals with sound and vision propagation values. When something happens, it bubbles outward. There are no coordinates, no pathfinding, no collision geometrya narrative engine doesn't need a tactical simulation, and a discrete graph is sufficient. Today actors perceive co-located entities and their location's visible attributes; portal-propagated perception is on the roadmap.
### Memory Tiers
- **Verbatim Buffer:** Holds the last few turns of working memory.
- **Vector Archive:** Stores summarized, embedded memory entries for semantic retrieval, keeping verbatim quotes only for high-salience lines.
- **Dossier (Planned):** Will hold each observer's subjective beliefs about another character.
- **Verbatim Buffer (implemented):** Per-character subjective event log. Every entry is stored from the owner's perspective — actors resolved through the owner's alias map, outcomes attached — and recalled with naturalized time phrasing.
- **Vector Archive (planned):** Summarized, embedded memory entries for semantic retrieval, keeping verbatim quotes only for high-salience lines.
- **Dossier (planned):** Each observer's subjective beliefs about another character.
Memory is per-character on purpose: recall is testimony from a vantage point, which is what makes interrogating two witnesses interesting.
### Emotional State ([NLAVS](https://github.com/sortedcord/NLAVS))
Rather than a scalar the model drifts, every significant interaction becomes a ledger entry with an affect vector across OCC-derived dimensions (plus arousal, dominance, and social drive). The model judges a single moment; deterministic code aggregates the ledger over time with decay and attention weighting. A character can be simultaneously furious about one thing and grateful for another, and an apology does not silently erase a betrayal.
## A Research Instrument: Model Psychology in Fiction
Omnia's architecture doubles as an apparatus for studying how language models behave _as characters_ under controlled epistemic conditions — something uni-agent setups cannot do, because they can neither control what the model knows nor observe what it withholds.
- **A window into private reasoning.** Monologue intents are the model's in-character thoughts: unperceived by other agents, exempt from validation, but durably logged. You can directly compare what a character _thinks_ against what it _says and does_ — measuring deception, self-consistency, motivated reasoning, or the gap between private appraisal and public behavior.
- **Knowledge as an experimental variable.** Attribute ACLs let you administer information with precision: give one agent a fact, withhold it from another, and observe propagation, inference, and leakage through dialogue alone. Secret-keeping stops being anecdotal and becomes testable — _provably_, since the engine logs exactly what each agent was ever shown.
- **Controlled, reproducible conditions.** A scenario is a JSON file; a run is a SQLite database. Identical initial conditions, swappable model providers behind one interface (`ILLMProvider`), and a deterministic mock for baselines. Rerun the white-room experiment a hundred times, vary one attribute, and diff the transcripts.
- **Multi-agent social dynamics with ground truth.** Because objective world state exists independently of any agent's beliefs, you can score agents' beliefs and claims against reality — hallucination, confabulation, and social conformity become measurable quantities rather than impressions.
The bundled demo scenario is exactly this: [`talking-room`](./content/demo/scenarios/talking-room.json) places two memory-wiped subjects in a featureless white room — each knowing their own name but not the other's — and observes what they do. It runs today, via the CLI, with a human optionally playing either subject.
## Project Status: What `v0` Means
The finish line for the first milestone is small on purpose.
@@ -53,17 +101,20 @@ The finish line for the first milestone is small on purpose.
**Currently Implemented:**
- [x] Attribute and ACL model (with some enforcement gaps open).
- [x] World Architect working end-to-end for single actions.
- [x] Verbatim buffer and vector archive.
- [x] Spatial perception graph.
- [x] Typed intent pipeline: `dialogue` / `action` / `monologue`, decoded from free prose.
- [x] World Architect: LLM validation plus time-delta generation, end-to-end for single actions.
- [x] Actor Agent with epistemically-bounded prompts (self, memory, co-located entities, subjective time).
- [x] Verbatim memory buffer with per-observer subjective serialization and alias resolution.
- [x] Spatial location graph (data model; perception is co-location only).
- [x] Scenario loader (JSON → SQLite) and a playable CLI loop with human or LLM actors.
**[The `v0` Milestone:](https://github.com/sortedcord/omnia-consolidated/milestone/1)**
- [ ] Two hand-authored NPCs live in one location, playable via CLI.
- [ ] Each has buffer and vector-archive memory and recalls something said a few turns earlier.
- [x] Two hand-authored NPCs live in one location, playable via CLI.
- [ ] Each has buffer and vector-archive memory and recalls something said a few turns earlier. _(buffer: done; vector archive: not started)_
- [ ] One NPC knows a fact the other does not and, provably by testing, will not leak it.
- [ ] The Architect processes at least one non-trivial action per exchange with a visible state change.
- [ ] The whole thing persists to a SQLite file and reloads identically.
- [x] The Architect processes at least one non-trivial action per exchange with a visible state change.
- [x] The whole thing persists to a SQLite file and reloads identically.
**Explicitly out of scope for `v0`:** Constraint validators (beyond basic sense-checking), multi-location perception, affect-vector decay math, the Dossier, whims/simulation tiering, the delta ledger, and UI beyond CLI.
@@ -78,30 +129,35 @@ The project is one repository because the subsystems share a single evolving sch
```text
omnia/
packages/
core/ entities, attributes, world state, SQLite persistence
intent/ intent pipeline: types, validators, consequence application
architect/ World Architect: LLM delta generation plus Zod validation
memory/ buffer, vector archive, later the dossier and affect vectors
core/ entities, attributes, world state, clock, SQLite persistence
intent/ intent types (dialogue/action/monologue) and the prose decoder
architect/ World Architect: LLM validation plus time-delta generation
actor/ actor agent: epistemically-bounded prompts, pluggable prose generators
memory/ verbatim buffer; later the vector archive, dossier, and affect vectors
spatial/ location and POI graph, portal-based perception
llm/ ILLMProvider interface plus a Gemini implementation
content/ scenario JSON files, produced by the Python scenario builder
cli/ the playable loop
docs/
spec.md the living source of truth
IDEAS.md everything deliberately deferred
BUILD_LOG.md one dated line per session
llm/ ILLMProvider interface plus Gemini and deterministic mock implementations
scenario/ scenario JSON schema and loader (JSON → SQLite)
apps/
cli/ the playable loop (human or LLM actors, --scenario / --play flags)
content/
demo/ bundled scenarios (talking-room)
tests/
integration/ cross-package tests against a mocked LLM
evals/ deliberate real-API evaluation runs
docs/ Astro documentation site (→ web/docs/)
```
_Note: Content tooling stays in Python indefinitely. It emits JSON the engine reads, so it doesn't need to share a language with the core. Domain-specific content (stats, traits) lives here, as the engine core deliberately knows nothing about them._
_The engine core deliberately knows nothing about domain content (stats, traits, genres). Scenarios are plain JSON the loader ingests; what an attribute means is the scenario's business, not the engine's._
## Roadmap (Build Order after `v0`)
1. Constraint validators for specific cases actually hit while testing.
2. Multi-location perception.
3. Memory decay scoring.
4. The Dossier, affect vectors, and identity resolution (sharing time-weighting logic).
5. The delta ledger and undo.
6. Autonomy (once there are enough NPCs for idle simulation to matter).
1. Vector-archive memory and retrieval (closing out the `v0` memory milestone).
2. Constraint validators for specific cases actually hit while testing.
3. Multi-location, portal-propagated perception.
4. Memory decay scoring.
5. The Dossier, affect vectors, and identity resolution (sharing time-weighting logic).
6. The delta ledger and undo.
7. Autonomy (once there are enough NPCs for idle simulation to matter).
_Each step will be genuinely working and in use before the next one starts._

30
apps/gui/next.config.ts Normal file
View File

@@ -0,0 +1,30 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
transpilePackages: [
"@omnia/core",
"@omnia/llm",
"@omnia/intent",
"@omnia/architect",
"@omnia/actor",
"@omnia/memory",
"@omnia/spatial",
"@omnia/scenario",
],
serverExternalPackages: ["better-sqlite3"],
allowedDevOrigins: ["192.168.0.18", "localhost", "127.0.0.1"],
experimental: {
serverActions: {
allowedOrigins: [
"192.168.0.18:3000",
"192.168.0.18:3001",
"192.168.0.18:3002",
"localhost:3000",
"localhost:3001",
"localhost:3002",
],
},
},
};
export default nextConfig;

32
apps/gui/package.json Normal file
View File

@@ -0,0 +1,32 @@
{
"name": "@omnia/gui",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@omnia/core": "workspace:*",
"@omnia/llm": "workspace:*",
"@omnia/intent": "workspace:*",
"@omnia/architect": "workspace:*",
"@omnia/actor": "workspace:*",
"@omnia/memory": "workspace:*",
"@omnia/spatial": "workspace:*",
"@omnia/scenario": "workspace:*",
"dotenv": "^17.4.2",
"next": "^16.2.10",
"react": "^19.2.0",
"react-dom": "^19.2.0"
},
"devDependencies": {
"@types/node": "^26.1.0",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"typescript": "^6.0.3"
}
}

View File

@@ -0,0 +1,777 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import {
getConfigStatus,
listProviderInstances,
createProviderInstance,
deleteProviderInstance,
setActiveProviderInstance,
getProviderMappings,
setProviderMapping,
updateProviderInstance,
} from "@/app/play/actions";
import type { LLMProviderInstance } from "@omnia/llm";
interface ConfigStatus {
apiKeySet: boolean;
apiKeyPreview: string;
model: string;
availableScenarios: { path: string; name: string }[];
}
export default function ConfigPage() {
const [config, setConfig] = useState<ConfigStatus | null>(null);
const [instances, setInstances] = useState<LLMProviderInstance[]>([]);
const [mappings, setMappings] = useState<Record<string, string>>({});
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [selectedInstanceId, setSelectedInstanceId] = useState<string | "new">("new");
const [editName, setEditName] = useState("");
const [editProvider, setEditProvider] = useState("google-genai");
const [editKey, setEditKey] = useState("");
const [editModel, setEditModel] = useState("gemini-2.5-flash");
const [editIsActive, setEditIsActive] = useState(false);
useEffect(() => {
if (selectedInstanceId === "new") {
setEditName("");
setEditProvider("google-genai");
setEditKey("");
setEditModel("gemini-2.5-flash");
setEditIsActive(false);
} else {
const inst = instances.find((i) => i.id === selectedInstanceId);
if (inst) {
setEditName(inst.name);
setEditProvider(inst.providerName);
setEditKey("");
setEditModel(inst.modelName || "gemini-2.5-flash");
setEditIsActive(inst.isActive);
}
}
}, [selectedInstanceId, instances]);
const loadInstances = useCallback(async () => {
try {
const list = await listProviderInstances();
setInstances(list);
} catch {
// ignore
}
}, []);
const loadMappings = useCallback(async () => {
try {
const maps = await getProviderMappings();
setMappings(maps);
} catch {
// ignore
}
}, []);
const loadAll = useCallback(async () => {
setLoading(true);
setError("");
try {
const result = await getConfigStatus();
setConfig(result);
await loadInstances();
await loadMappings();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
}
}, [loadInstances, loadMappings]);
useEffect(() => {
loadAll();
}, [loadAll]);
const handleSave = async (e: React.FormEvent) => {
e.preventDefault();
if (!editName.trim()) {
setError("Name is required.");
return;
}
try {
setLoading(true);
setError("");
if (selectedInstanceId === "new") {
if (!editKey.trim()) {
setError("API Key is required for new instances.");
setLoading(false);
return;
}
const created = await createProviderInstance(editName, editProvider, editKey, editModel || undefined);
if (editIsActive) {
await setActiveProviderInstance(created.id);
}
setSelectedInstanceId(created.id);
} else {
await updateProviderInstance(selectedInstanceId, editName, editProvider, editKey || undefined, editModel || undefined);
if (editIsActive) {
await setActiveProviderInstance(selectedInstanceId);
}
}
await loadInstances();
await loadMappings();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
}
};
const handleDelete = async () => {
if (selectedInstanceId === "new") return;
if (!confirm("Are you sure you want to delete this provider instance?")) return;
try {
setLoading(true);
setError("");
await deleteProviderInstance(selectedInstanceId);
setSelectedInstanceId("new");
await loadInstances();
await loadMappings();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
}
};
const handleUpdateMapping = async (task: string, providerInstanceId: string) => {
try {
setLoading(true);
await setProviderMapping(task, providerInstanceId);
await loadMappings();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
}
};
return (
<div className="config-page">
<h1>Configuration</h1>
{loading && <p>Loading configuration...</p>}
{error && <div className="error-banner">{error}</div>}
{config && !loading && (
<>
<section className="config-section">
<h2>LLM Provider Instances</h2>
<div className="provider-split-container">
{/* 30% area */}
<div className="provider-list-pane">
<div className="pane-header">
<h3>Instances</h3>
<button
onClick={() => setSelectedInstanceId("new")}
className="btn-add-inst"
type="button"
>
+ Add
</button>
</div>
<div className="pane-list">
{instances.length === 0 ? (
<div className="no-instances-msg">No instances configured</div>
) : (
instances.map((inst) => (
<div
key={inst.id}
onClick={() => setSelectedInstanceId(inst.id)}
className={`instance-list-item ${selectedInstanceId === inst.id ? "active" : ""}`}
>
<div className="item-name">{inst.name}</div>
<div className="item-meta">
<span>{inst.providerName}</span>
{inst.isActive && <span className="active-pill">Active</span>}
</div>
</div>
))
)}
</div>
</div>
{/* 70% area */}
<div className="provider-form-pane">
<form onSubmit={handleSave} className="provider-config-form">
<div className="form-scroll-content">
<h3>
{selectedInstanceId === "new"
? "Create New Provider Instance"
: `Configure: ${editName}`}
</h3>
<div className="form-group">
<label htmlFor="formName">Friendly Name</label>
<input
id="formName"
type="text"
value={editName}
onChange={(e) => setEditName(e.target.value)}
placeholder="e.g. Gemini - Production"
required
/>
</div>
<div className="form-group">
<label htmlFor="formProvider">Provider Type</label>
<select
id="formProvider"
value={editProvider}
onChange={(e) => setEditProvider(e.target.value)}
>
<option value="google-genai">Google Gemini (Gemini-2.5-flash)</option>
<option value="mock">Mock LLM Provider</option>
</select>
</div>
<div className="form-group">
<label htmlFor="formKey">API Key</label>
<input
id="formKey"
type="password"
value={editKey}
onChange={(e) => setEditKey(e.target.value)}
placeholder={
selectedInstanceId === "new"
? "AIzaSy..."
: "•••••••• (unchanged)"
}
required={selectedInstanceId === "new"}
/>
</div>
<div className="form-group">
<label htmlFor="formModel">Model Name</label>
<input
id="formModel"
type="text"
value={editModel}
onChange={(e) => setEditModel(e.target.value)}
placeholder="e.g. gemini-2.5-flash, gemini-2.5-pro"
/>
</div>
<div className="form-group checkbox-group">
<input
id="formActive"
type="checkbox"
checked={editIsActive}
onChange={(e) => setEditIsActive(e.target.checked)}
/>
<label htmlFor="formActive">Set as Active Instance</label>
</div>
</div>
<div className="form-actions-bar">
<div className="action-left">
{selectedInstanceId !== "new" && (
<button
type="button"
onClick={handleDelete}
disabled={loading}
className="btn-delete-pane"
>
Delete
</button>
)}
</div>
<div className="action-right">
<button type="submit" disabled={loading} className="btn-save-pane">
{loading ? "Saving..." : "Save"}
</button>
</div>
</div>
</form>
</div>
</div>
</section>
<section className="config-section">
<h2>Task Provider Routing</h2>
<p className="config-hint" style={{ background: "#eff6ff", border: "1px solid #bfdbfe", color: "#1e3a8a", margin: "1rem 0" }}>
Configure which LLM Provider Key Instance should handle each specific simulation task. Mappings default to the currently <strong>Active</strong> instance if not specified.
</p>
<div className="mappings-grid">
{[
{ key: "actor-prose", label: "Actor Prose Generation", desc: "Generates roleplay/narrative prose for Non-Player Characters." },
{ key: "llm-validator", label: "LLM Validator", desc: "Arbitrates and validates proposed actions against the world state rules." },
{ key: "intent-decoder", label: "Intent Decoder", desc: "Splits raw prose actions into structured intents (Player and NPC)." },
{ key: "timedelta", label: "TimeDelta Generator", desc: "Calculates the duration of character actions to advance the game clock." },
].map((task) => (
<div key={task.key} className="mapping-card">
<div className="mapping-info">
<strong>{task.label}</strong>
<span className="text-gray" style={{ fontSize: "0.75rem", marginTop: "0.125rem" }}>{task.desc}</span>
</div>
<select
value={mappings[task.key] || ""}
onChange={(e) => handleUpdateMapping(task.key, e.target.value)}
>
<option value="">-- Use Active Key (Default) --</option>
{instances.map((inst) => (
<option key={inst.id} value={inst.id}>
{inst.name} ({inst.providerName}){inst.isActive ? " [Active]" : ""}
</option>
))}
</select>
</div>
))}
</div>
</section>
<section className="config-section">
<h2>Environment Variables Default</h2>
<div className="config-row">
<span className="config-label">Default Model</span>
<span className="config-value">
<code>{config.model}</code>
</span>
</div>
<div className="config-row">
<span className="config-label">Default API Key (.env)</span>
<span
className={
config.apiKeySet
? "config-value status-ok"
: "config-value status-error"
}
>
{config.apiKeySet
? `✓ Set (${config.apiKeyPreview})`
: "✗ NOT SET"}
</span>
</div>
</section>
<section className="config-section">
<h2>Available Scenarios</h2>
{config.availableScenarios.length === 0 ? (
<p className="config-hint">
No scenarios found in <code>content/demo/scenarios/</code>.
</p>
) : (
<table className="scenario-table">
<thead>
<tr>
<th>Name</th>
<th>Path</th>
</tr>
</thead>
<tbody>
{config.availableScenarios.map((s) => (
<tr key={s.path}>
<td>{s.name}</td>
<td>
<code>{s.path}</code>
</td>
</tr>
))}
</tbody>
</table>
)}
</section>
<section className="config-section">
<h2>Engine Packages</h2>
<p className="config-hint">
All <code>@omnia/*</code> workspace packages are consumed via{" "}
<code>transpilePackages</code> in <code>next.config.ts</code>.
The native <code>better-sqlite3</code> module is externalized via{" "}
<code>serverExternalPackages</code>.
</p>
</section>
</>
)}
<style>{`
.config-page {
max-width: 800px;
margin: 0 auto;
padding: 2rem 1rem;
}
.config-page h1 {
font-size: 1.5rem;
margin-bottom: 1.5rem;
}
.config-section {
margin-bottom: 2rem;
padding-bottom: 1.5rem;
border-bottom: 1px solid #e5e7eb;
}
.config-section h2 {
font-size: 1.125rem;
margin-bottom: 0.75rem;
}
.config-row {
display: flex;
justify-content: space-between;
padding: 0.375rem 0;
border-bottom: 1px solid #f3f4f6;
}
.config-label {
color: #555;
font-size: 0.875rem;
}
.config-value {
font-size: 0.875rem;
}
.status-ok {
color: #16a34a;
}
.status-error {
color: #dc2626;
font-weight: 500;
}
.config-hint {
margin-top: 0.75rem;
padding: 0.5rem 0.75rem;
background: #fef3c7;
border: 1px solid #fde68a;
border-radius: 4px;
font-size: 0.8125rem;
color: #92400e;
}
.config-hint code {
background: rgba(0,0,0,0.06);
padding: 0.125rem 0.25rem;
border-radius: 2px;
font-size: 0.75rem;
}
.error-banner {
background: #fef2f2;
color: #b91c1c;
border: 1px solid #fca5a5;
border-radius: 4px;
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
margin-bottom: 1rem;
}
.scenario-table {
width: 100%;
border-collapse: collapse;
font-size: 0.875rem;
}
.scenario-table th {
text-align: left;
padding: 0.5rem;
border-bottom: 2px solid #e5e7eb;
color: #555;
font-weight: 500;
}
.scenario-table td {
padding: 0.5rem;
border-bottom: 1px solid #f3f4f6;
}
.scenario-table code {
font-size: 0.8125rem;
color: #2563eb;
}
code {
font-family: monospace;
font-size: 0.875rem;
}
/* Pill and List Styles */
.status-pill {
display: inline-block;
padding: 0.125rem 0.5rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 600;
}
.status-pill.active {
background: #dcfce7;
color: #15803d;
}
.status-pill.inactive {
background: #f3f4f6;
color: #4b5563;
}
.action-buttons {
display: flex;
gap: 0.5rem;
align-items: center;
}
.btn-sm {
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
background: #3b82f6;
color: #fff;
border: none;
border-radius: 4px;
cursor: pointer;
}
.btn-sm:hover {
background: #2563eb;
}
.btn-sm.delete-btn {
background: #ef4444;
}
.btn-sm.delete-btn:hover {
background: #dc2626;
}
/* Split container */
.provider-split-container {
display: grid;
grid-template-columns: 1fr;
border: 1px solid #e5e7eb;
border-radius: 12px;
overflow: hidden;
background: #fff;
margin-top: 1rem;
min-height: 400px;
}
@media (min-width: 768px) {
.provider-split-container {
grid-template-columns: 30% 70%;
}
}
/* 30% List Pane */
.provider-list-pane {
border-right: 1px solid #e5e7eb;
background: #f9fafb;
display: flex;
flex-direction: column;
}
.pane-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
border-bottom: 1px solid #e5e7eb;
background: #f3f4f6;
}
.pane-header h3 {
margin: 0;
font-size: 0.95rem;
font-weight: 600;
color: #111;
}
.btn-add-inst {
padding: 0.375rem 0.75rem;
font-size: 0.8125rem;
font-weight: 500;
background: #10b981;
color: #fff;
border: none;
border-radius: 6px;
cursor: pointer;
transition: background 0.2s;
}
.btn-add-inst:hover {
background: #059669;
}
.pane-list {
overflow-y: auto;
flex: 1;
display: flex;
flex-direction: column;
}
.no-instances-msg {
padding: 2rem 1rem;
text-align: center;
color: #6b7280;
font-size: 0.8125rem;
}
.instance-list-item {
padding: 1rem;
border-bottom: 1px solid #e5e7eb;
cursor: pointer;
transition: background 0.15s, border-left 0.15s;
border-left: 3px solid transparent;
}
.instance-list-item:hover {
background: #f3f4f6;
}
.instance-list-item.active {
background: #eff6ff;
border-left: 3px solid #3b82f6;
}
.item-name {
font-weight: 500;
font-size: 0.875rem;
color: #111;
}
.item-meta {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 0.25rem;
font-size: 0.75rem;
color: #6b7280;
}
.active-pill {
background: #dcfce7;
color: #15803d;
font-weight: 600;
padding: 0.0625rem 0.375rem;
border-radius: 9999px;
}
/* 70% Form Pane */
.provider-form-pane {
background: #fff;
display: flex;
flex-direction: column;
}
.provider-config-form {
display: flex;
flex-direction: column;
height: 100%;
justify-content: space-between;
}
.form-scroll-content {
padding: 1.5rem;
flex: 1;
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.form-scroll-content h3 {
margin: 0 0 0.5rem 0;
font-size: 1.125rem;
font-weight: 600;
color: #111;
}
.form-group {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.form-group label {
font-size: 0.8125rem;
font-weight: 500;
color: #374151;
}
.form-group input[type="text"],
.form-group input[type="password"],
.form-group select {
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
border: 1px solid #d1d5db;
border-radius: 6px;
background: #fff;
outline: none;
transition: border-color 0.15s, box-shadow 0.15s;
}
.form-group input:focus,
.form-group select:focus {
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15);
}
.checkbox-group {
flex-direction: row;
align-items: center;
gap: 0.5rem;
margin-top: 0.25rem;
}
.checkbox-group label {
cursor: pointer;
}
.checkbox-group input {
width: 1rem;
height: 1rem;
cursor: pointer;
}
/* Action bar */
.form-actions-bar {
padding: 1rem 1.5rem;
border-top: 1px solid #e5e7eb;
background: #f9fafb;
display: flex;
justify-content: space-between;
align-items: center;
}
.btn-delete-pane {
padding: 0.5rem 1rem;
font-size: 0.875rem;
font-weight: 500;
background: #ef4444;
color: #fff;
border: none;
border-radius: 6px;
cursor: pointer;
transition: background 0.2s;
}
.btn-delete-pane:hover {
background: #dc2626;
}
.btn-save-pane {
padding: 0.5rem 1.25rem;
font-size: 0.875rem;
font-weight: 500;
background: #2563eb;
color: #fff;
border: none;
border-radius: 6px;
cursor: pointer;
transition: background 0.2s;
}
.btn-save-pane:hover {
background: #1d4ed8;
}
/* Task Provider Routing Styles */
.mappings-grid {
display: grid;
grid-template-columns: 1fr;
gap: 1rem;
margin-top: 1rem;
}
@media (min-width: 768px) {
.mappings-grid {
grid-template-columns: 1fr 1fr;
}
}
.mapping-card {
border: 1px solid #e5e7eb;
background: #f9fafb;
border-radius: 8px;
padding: 1rem;
display: flex;
flex-direction: column;
justify-content: space-between;
gap: 0.75rem;
}
.mapping-info {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.8125rem;
}
.mapping-info strong {
font-size: 0.875rem;
color: #111;
}
.mapping-card select {
padding: 0.375rem 0.5rem;
font-size: 0.8125rem;
border: 1px solid #ccc;
border-radius: 4px;
background: #fff;
width: 100%;
}
.text-gray {
color: #6b7280;
}
`}</style>
</div>
);
}

View File

@@ -0,0 +1,18 @@
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
Oxygen, Ubuntu, Cantarell, sans-serif;
color: #111;
background: #fafafa;
}
body {
min-height: 100dvh;
}

View File

@@ -0,0 +1,19 @@
import type { ReactNode } from "react";
import { NavBar } from "@/components/nav/NavBar";
import "./globals.css";
export const metadata = {
title: "Omnia GUI",
description: "Omnia Narrative Simulation Engine — Web Interface",
};
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en">
<body>
<NavBar />
{children}
</body>
</html>
);
}

63
apps/gui/src/app/page.tsx Normal file
View File

@@ -0,0 +1,63 @@
import Link from "next/link";
export default function Home() {
return (
<main className="home">
<h1>Omnia GUI</h1>
<p className="subtitle">
Configuration and gameplay interface for the Omnia simulation engine.
</p>
<div className="home-links">
<Link href="/play" className="home-card">
<h2>Play</h2>
<p>Start a simulation and interact with NPCs</p>
</Link>
<Link href="/config" className="home-card">
<h2>Config</h2>
<p>Check environment, API keys, and available scenarios</p>
</Link>
</div>
<style>{`
.home {
max-width: 800px;
margin: 0 auto;
padding: 3rem 1rem;
}
.home h1 {
font-size: 2rem;
margin-bottom: 0.5rem;
}
.subtitle {
color: #555;
margin-bottom: 2rem;
}
.home-links {
display: flex;
gap: 1rem;
}
.home-card {
flex: 1;
display: block;
padding: 1.5rem;
border: 1px solid #e5e7eb;
border-radius: 8px;
text-decoration: none;
color: inherit;
transition: border-color 0.15s, box-shadow 0.15s;
}
.home-card:hover {
border-color: #2563eb;
box-shadow: 0 2px 8px rgba(37, 99, 235, 0.1);
}
.home-card h2 {
font-size: 1.25rem;
margin-bottom: 0.25rem;
}
.home-card p {
font-size: 0.875rem;
color: #555;
}
`}</style>
</main>
);
}

View File

@@ -0,0 +1,276 @@
"use server";
import path from "path";
import fs from "fs";
import { simulationManager } from "@/lib/simulation";
import type { SimSnapshot } from "@/lib/simulation";
import { ProviderManager, LLMProviderInstance } from "@omnia/llm";
function resolveScenarioPath(relative: string): string {
const cwd = process.cwd();
const candidates = [
path.resolve(cwd, relative),
path.resolve(cwd, "content/demo/scenarios", relative),
path.resolve(cwd, "../../", relative),
path.resolve(cwd, "../../content/demo/scenarios", relative),
];
for (const c of candidates) {
try {
if (fs.statSync(c).isFile()) return c;
} catch {
/* not found */
}
}
return path.resolve(cwd, relative);
}
type ActionResult =
| { ok: true; snapshot: SimSnapshot }
| { ok: false; error: string };
export async function startSimulation(input: {
scenario?: string;
playEntity?: string;
providerInstanceId?: string;
}): Promise<ActionResult> {
try {
const scenarioFile =
input.scenario || "content/demo/scenarios/talking-room.json";
const resolved = resolveScenarioPath(scenarioFile);
if (!fs.existsSync(resolved)) {
return { ok: false, error: `Scenario file not found: ${scenarioFile}` };
}
const snapshot = await simulationManager.create(
resolved,
input.playEntity || undefined,
input.providerInstanceId,
);
if (snapshot.status === "error") {
return { ok: false, error: snapshot.error || "Unknown error" };
}
return { ok: true, snapshot };
} catch (err) {
return {
ok: false,
error: err instanceof Error ? err.message : String(err),
};
}
}
export async function stepSimulation(input: {
simId: string;
}): Promise<ActionResult> {
try {
if (!input.simId) {
return { ok: false, error: "Missing simId" };
}
const snapshot = await simulationManager.step(input.simId);
if (!snapshot) {
return { ok: false, error: "Simulation session not found" };
}
if (snapshot.status === "error") {
return { ok: false, error: snapshot.error || "Unknown error" };
}
return { ok: true, snapshot };
} catch (err) {
return {
ok: false,
error: err instanceof Error ? err.message : String(err),
};
}
}
export async function submitPlayerAction(input: {
simId: string;
prose: string;
}): Promise<ActionResult> {
try {
if (!input.simId || !input.prose.trim()) {
return { ok: false, error: "Missing simId or prose" };
}
const snapshot = await simulationManager.submitPlayerAction(
input.simId,
input.prose.trim(),
);
if (!snapshot) {
return { ok: false, error: "Simulation session not found" };
}
if (snapshot.status === "error") {
return { ok: false, error: snapshot.error || "Unknown error" };
}
return { ok: true, snapshot };
} catch (err) {
return {
ok: false,
error: err instanceof Error ? err.message : String(err),
};
}
}
export async function getConfigStatus(): Promise<{
apiKeySet: boolean;
apiKeyPreview: string;
model: string;
availableScenarios: { path: string; name: string }[];
}> {
const apiKey = process.env.GOOGLE_API_KEY;
const scenarios: { path: string; name: string }[] = [];
const cwd = process.cwd();
const candidates = [
path.resolve(cwd, "content/demo/scenarios"),
path.resolve(cwd, "../../content/demo/scenarios"),
];
let scenariosDir = "";
for (const c of candidates) {
if (fs.existsSync(c) && fs.statSync(c).isDirectory()) {
scenariosDir = c;
break;
}
}
if (scenariosDir) {
for (const file of fs.readdirSync(scenariosDir)) {
if (file.endsWith(".json")) {
try {
const fullPath = path.join(scenariosDir, file);
const content = JSON.parse(fs.readFileSync(fullPath, "utf-8"));
scenarios.push({
path: `content/demo/scenarios/${file}`,
name: content.name || file,
});
} catch {
/* skip invalid */
}
}
}
}
return {
apiKeySet: !!apiKey,
apiKeyPreview: apiKey ? apiKey.substring(0, 10) + "..." : "NOT SET",
model: "gemini-2.5-flash",
availableScenarios: scenarios,
};
}
export async function listSavedSimulations(): Promise<
| { ok: true; sessions: SimSnapshot[] }
| { ok: false; error: string }
> {
try {
const sessions = simulationManager.listSavedSessions();
return { ok: true, sessions };
} catch (err) {
return {
ok: false,
error: err instanceof Error ? err.message : String(err),
};
}
}
export async function resumeSimulation(simId: string): Promise<ActionResult> {
try {
const snapshot = await simulationManager.load(simId);
if (!snapshot) {
return { ok: false, error: `Failed to load simulation: ${simId}` };
}
return { ok: true, snapshot };
} catch (err) {
return {
ok: false,
error: err instanceof Error ? err.message : String(err),
};
}
}
export async function getScenarioEntities(scenarioPath: string): Promise<
| { ok: true; entities: { id: string; name: string }[] }
| { ok: false; error: string }
> {
try {
const resolved = resolveScenarioPath(scenarioPath);
if (!fs.existsSync(resolved)) {
return { ok: false, error: `Scenario file not found: ${scenarioPath}` };
}
const content = JSON.parse(fs.readFileSync(resolved, "utf-8"));
const entities = (content.entities || []).map((e: { id: string; name?: string }) => ({
id: e.id,
name: e.name || e.id,
}));
return { ok: true, entities };
} catch (err) {
return {
ok: false,
error: err instanceof Error ? err.message : String(err),
};
}
}
export async function deleteSimulation(simId: string): Promise<
{ ok: true } | { ok: false; error: string }
> {
try {
simulationManager.deleteSession(simId);
return { ok: true };
} catch (err) {
return {
ok: false,
error: err instanceof Error ? err.message : String(err),
};
}
}
export async function listProviderInstances(): Promise<LLMProviderInstance[]> {
return ProviderManager.list();
}
export async function createProviderInstance(
name: string,
providerName: string,
apiKey: string,
modelName?: string,
): Promise<LLMProviderInstance> {
return ProviderManager.create(name, providerName, apiKey, modelName);
}
export async function deleteProviderInstance(id: string): Promise<void> {
ProviderManager.delete(id);
}
export async function setActiveProviderInstance(id: string): Promise<void> {
ProviderManager.setActive(id);
}
export async function updateProviderInstance(
id: string,
name: string,
providerName: string,
apiKey?: string,
modelName?: string,
): Promise<void> {
ProviderManager.update(id, name, providerName, apiKey, modelName);
}
export async function getProviderMappings(): Promise<Record<string, string>> {
return ProviderManager.getMappings();
}
export async function setProviderMapping(
task: string,
providerInstanceId: string,
): Promise<void> {
ProviderManager.setMapping(task, providerInstanceId);
}

View File

@@ -0,0 +1,5 @@
import { PlayView } from "@/components/play/PlayView";
export default function PlayPage() {
return <PlayView />;
}

View File

@@ -0,0 +1,69 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
const links = [
{ href: "/", label: "Home" },
{ href: "/play", label: "Play" },
{ href: "/config", label: "Config" },
];
export function NavBar() {
const pathname = usePathname();
return (
<nav className="navbar">
<Link href="/" className="nav-brand">
Omnia
</Link>
<div className="nav-links">
{links.map((link) => (
<Link
key={link.href}
href={link.href}
className={pathname === link.href ? "nav-link active" : "nav-link"}
>
{link.label}
</Link>
))}
</div>
<style>{`
.navbar {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.75rem 1rem;
border-bottom: 1px solid #e5e7eb;
background: #fff;
}
.nav-brand {
font-weight: 700;
font-size: 1rem;
color: #111;
text-decoration: none;
}
.nav-links {
display: flex;
gap: 0.5rem;
}
.nav-link {
padding: 0.25rem 0.75rem;
border-radius: 4px;
font-size: 0.875rem;
color: #555;
text-decoration: none;
}
.nav-link:hover {
background: #f3f4f6;
color: #111;
}
.nav-link.active {
background: #eff6ff;
color: #2563eb;
font-weight: 500;
}
`}</style>
</nav>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,62 @@
export interface IntentInfo {
type: string;
description: string;
targetIds: string[];
isValid?: boolean;
reason?: string;
minutesToAdvance?: number;
}
export interface LogEntry {
turn: number;
entityId: string;
entityName: string;
narrativeProse: string;
intents: IntentInfo[];
timestamp: string;
rawPrompt?: {
systemPrompt: string;
userContext: string;
};
usage?: {
inputTokens: number;
outputTokens: number;
totalTokens: number;
};
decoderPrompt?: {
systemPrompt: string;
userContext: string;
};
decoderUsage?: {
inputTokens: number;
outputTokens: number;
totalTokens: number;
};
}
export interface EntityInfo {
id: string;
name: string;
isPlayer: boolean;
}
export interface WaitingContext {
entityId: string;
name: string;
systemPrompt: string;
userContext: string;
}
export interface SimSnapshot {
id: string;
status: "running" | "waiting_player" | "done" | "error";
turn: number;
maxTurns: number;
scenarioName: string;
scenarioDescription: string;
entities: EntityInfo[];
log: LogEntry[];
entityIndex: number;
waitingEntity?: WaitingContext;
error?: string;
}

View File

@@ -0,0 +1,828 @@
import dotenv from "dotenv";
import Database from "better-sqlite3";
import path from "path";
import fs from "fs";
import { SQLiteRepository } from "@omnia/core";
// Load .env from monorepo root or apps/gui/
const cwd = process.cwd();
const envCandidates = [
path.resolve(cwd, ".env"),
path.resolve(cwd, "../../.env"),
];
for (const c of envCandidates) {
if (fs.existsSync(c) && fs.statSync(c).isFile()) {
dotenv.config({ path: c });
break;
}
}
import { BufferRepository } from "@omnia/memory";
import { Architect, AliasDeltaGenerator } from "@omnia/architect";
import {
ActorAgent,
ActorPromptBuilder,
IActorProseGenerator,
buildBufferEntryForIntent,
} from "@omnia/actor";
import { GeminiProvider, ILLMProvider, MockLLMProvider, ProviderManager } from "@omnia/llm";
import { ScenarioLoader } from "@omnia/scenario";
import type {
IntentInfo,
LogEntry,
EntityInfo,
WaitingContext,
SimSnapshot,
} from "./simulation-types.js";
export type { SimSnapshot, EntityInfo, LogEntry, IntentInfo, WaitingContext };
class FixedProseGenerator implements IActorProseGenerator {
constructor(private prose: string) {}
async generate(
entityId: string,
systemPrompt: string,
userContext: string,
): Promise<string> {
void entityId;
void systemPrompt;
void userContext;
return this.prose;
}
}
interface SavedState {
scenarioName: string;
scenarioDescription: string;
turn: number;
maxTurns: number;
entities: EntityInfo[];
playerEntityId: string | undefined;
entityIndex: number;
status: "running" | "waiting_player" | "done" | "error";
error?: string;
waitingEntity?: WaitingContext;
aliasDoneForTurn: boolean;
log: LogEntry[];
providerMappings: Record<string, string>;
}
function loadSessionState(db: Database.Database, id: string): SavedState | null {
try {
db.prepare(`
CREATE TABLE IF NOT EXISTS gui_meta (
id TEXT PRIMARY KEY,
state_json TEXT
)
`).run();
const row = db.prepare(`SELECT state_json FROM gui_meta WHERE id = ?`).get(id) as { state_json: string } | undefined;
return row ? (JSON.parse(row.state_json) as SavedState) : null;
} catch {
return null;
}
}
interface SimSession {
db: Database.Database;
dbPath: string;
coreRepo: SQLiteRepository;
bufferRepo: BufferRepository;
worldInstanceId: string;
scenarioName: string;
scenarioDescription: string;
turn: number;
maxTurns: number;
entities: EntityInfo[];
playerEntityId: string | undefined;
entityIndex: number;
actorProvider: ILLMProvider;
validatorProvider: ILLMProvider;
decoderProvider: ILLMProvider;
timedeltaProvider: ILLMProvider;
architect: Architect;
aliasGenerator: AliasDeltaGenerator;
log: LogEntry[];
status: "running" | "waiting_player" | "done" | "error";
error?: string;
waitingEntity?: WaitingContext;
aliasDoneForTurn: boolean;
providerMappings: Record<string, string>;
}
class SimulationManager {
private sessions = new Map<string, SimSession>();
async create(
scenarioPath: string,
playEntityName?: string,
providerInstanceId?: string,
): Promise<SimSnapshot> {
let activeInstance = providerInstanceId
? ProviderManager.list().find((p) => p.id === providerInstanceId)
: ProviderManager.getActive();
if (!activeInstance) {
const envKey = process.env.GOOGLE_API_KEY;
if (envKey) {
activeInstance = ProviderManager.create("Default (Env)", "google-genai", envKey);
}
}
if (!activeInstance) {
return {
id: "",
status: "error",
turn: 0,
maxTurns: 20,
scenarioName: "",
scenarioDescription: "",
entities: [],
log: [],
entityIndex: 0,
error: "No active LLM Provider Instance found. Please configure a key in Settings first.",
};
}
const scenarioJson = JSON.parse(fs.readFileSync(scenarioPath, "utf-8"));
const id = `sim-${Date.now()}`;
const dbDir = path.resolve(process.cwd(), "data");
fs.mkdirSync(dbDir, { recursive: true });
const dbPath = path.join(dbDir, `${id}.db`);
const db = new Database(dbPath);
const coreRepo = new SQLiteRepository(db);
const bufferRepo = new BufferRepository(db);
const loader = new ScenarioLoader(coreRepo, bufferRepo);
const worldInstanceId = id;
await loader.initializeWorld(scenarioJson, worldInstanceId);
const worldState = coreRepo.loadWorldState(worldInstanceId);
if (!worldState) {
db.close();
return {
id: "",
status: "error",
turn: 0,
maxTurns: 20,
scenarioName: "",
scenarioDescription: "",
entities: [],
log: [],
entityIndex: 0,
error: "Failed to load world state after initialization.",
};
}
const rawEntities = Array.from(worldState.entities.values());
const entityInfos: EntityInfo[] = rawEntities.map((e) => ({
id: e.id,
name: (e.attributes.get("name")?.getValue() as string) || e.id,
isPlayer: false,
}));
let playerEntityId: string | undefined;
if (playEntityName) {
let matched = worldState.getEntity(playEntityName);
if (!matched) {
for (const ent of rawEntities) {
const nameAttr = ent.attributes.get("name")?.getValue() as
| string
| undefined;
if (nameAttr?.toLowerCase() === playEntityName.toLowerCase()) {
matched = ent;
break;
}
}
}
if (!matched) {
for (const ent of rawEntities) {
const nameAttr = ent.attributes.get("name")?.getValue() as
| string
| undefined;
if (
nameAttr?.toLowerCase().includes(playEntityName.toLowerCase()) ||
ent.id.toLowerCase().includes(playEntityName.toLowerCase())
) {
matched = ent;
break;
}
}
}
if (matched) {
playerEntityId = matched.id;
const info = entityInfos.find((e) => e.id === matched.id);
if (info) info.isPlayer = true;
}
}
const list = ProviderManager.list();
const active = ProviderManager.getActive() || activeInstance;
const mappings = ProviderManager.getMappings();
const resolveProviderForTask = (task: string): ILLMProvider => {
const mappedId = mappings[task];
let inst = mappedId ? list.find((p) => p.id === mappedId) : null;
if (!inst) {
inst = active;
}
const key = inst ? inst.apiKey : (process.env.GOOGLE_API_KEY || "");
const providerName = inst ? inst.providerName : "google-genai";
if (providerName === "google-genai") {
return new GeminiProvider(key);
} else {
return new MockLLMProvider([]);
}
};
const actorProvider = resolveProviderForTask("actor-prose");
const validatorProvider = resolveProviderForTask("llm-validator");
const decoderProvider = resolveProviderForTask("intent-decoder");
const timedeltaProvider = resolveProviderForTask("timedelta");
const architect = new Architect(
{ validator: validatorProvider, timedelta: timedeltaProvider },
coreRepo,
);
const aliasGenerator = new AliasDeltaGenerator(actorProvider);
const session: SimSession = {
db,
dbPath,
coreRepo,
bufferRepo,
worldInstanceId,
scenarioName: scenarioJson.name,
scenarioDescription: scenarioJson.description,
turn: 1,
maxTurns: 20,
entities: entityInfos,
playerEntityId,
entityIndex: 0,
actorProvider,
validatorProvider,
decoderProvider,
timedeltaProvider,
architect,
aliasGenerator,
log: [],
status: "running",
aliasDoneForTurn: false,
providerMappings: mappings,
};
this.sessions.set(id, session);
return this.snapshot(session);
}
async step(id: string): Promise<SimSnapshot | null> {
const session = this.sessions.get(id);
if (!session) return null;
if (session.status !== "running") return this.snapshot(session);
try {
if (session.turn > session.maxTurns) {
session.status = "done";
this.save(session);
return this.snapshot(session);
}
if (!session.aliasDoneForTurn && session.entityIndex === 0) {
await this.runAliasResolution(session);
session.aliasDoneForTurn = true;
this.save(session);
return this.snapshot(session);
}
if (session.entityIndex >= session.entities.length) {
session.turn++;
session.entityIndex = 0;
session.aliasDoneForTurn = false;
this.save(session);
return this.snapshot(session);
}
const info = session.entities[session.entityIndex];
if (info.isPlayer) {
await this.preparePlayerTurn(session, info);
this.save(session);
return this.snapshot(session);
}
await this.processNpcTurn(session, info);
session.entityIndex++;
} catch (err) {
session.status = "error";
session.error = err instanceof Error ? err.message : String(err);
}
this.save(session);
return this.snapshot(session);
}
async submitPlayerAction(
id: string,
prose: string,
): Promise<SimSnapshot | null> {
const session = this.sessions.get(id);
if (!session) return null;
if (session.status !== "waiting_player") return this.snapshot(session);
if (!session.waitingEntity) return this.snapshot(session);
const ctx = session.waitingEntity;
session.waitingEntity = undefined;
session.status = "running";
try {
const worldState = session.coreRepo.loadWorldState(
session.worldInstanceId,
);
if (!worldState) throw new Error("World state lost");
const entity = worldState.getEntity(ctx.entityId);
if (!entity) throw new Error(`Player entity "${ctx.entityId}" not found`);
const playerActor = new ActorAgent(
{ actor: session.actorProvider, decoder: session.decoderProvider },
session.bufferRepo,
20,
new FixedProseGenerator(prose),
);
const result = await playerActor.act(worldState, entity);
const entry: LogEntry = {
turn: session.turn,
entityId: ctx.entityId,
entityName: ctx.name,
narrativeProse: result.narrativeProse,
intents: [],
timestamp: worldState.clock.get().toISOString(),
rawPrompt: {
systemPrompt: ctx.systemPrompt,
userContext: ctx.userContext,
},
};
if (session.decoderProvider.lastCalls && session.decoderProvider.lastCalls.length > 0) {
const call = session.decoderProvider.lastCalls[session.decoderProvider.lastCalls.length - 1];
entry.decoderPrompt = {
systemPrompt: call.systemPrompt,
userContext: call.userContext,
};
entry.decoderUsage = call.usage;
}
for (const intent of result.intents.intents) {
const outcome = await session.architect.processIntent(
worldState,
intent,
);
const ts = worldState.clock.get().toISOString();
entry.intents.push({
type: intent.type,
description: intent.description,
targetIds: intent.targetIds,
isValid: outcome.isValid,
reason: outcome.reason,
minutesToAdvance: outcome.timeDelta?.minutesToAdvance,
});
const actorEntry = buildBufferEntryForIntent(
intent,
ts,
entity.locationId,
);
if (intent.type === "action") {
actorEntry.outcome = {
isValid: outcome.isValid,
reason: outcome.reason,
};
}
session.bufferRepo.save(actorEntry);
if (
entity.locationId &&
(intent.type === "dialogue" || intent.type === "action")
) {
for (const [, other] of worldState.entities) {
if (
other.id !== ctx.entityId &&
other.locationId === entity.locationId
) {
const observerEntry = buildBufferEntryForIntent(
intent,
ts,
entity.locationId,
);
if (intent.type === "action") {
observerEntry.outcome = {
isValid: outcome.isValid,
reason: outcome.reason,
};
}
session.bufferRepo.save({
...observerEntry,
ownerId: other.id,
});
}
}
}
}
session.log.push(entry);
session.coreRepo.saveWorldState(worldState);
session.entityIndex++;
} catch (err) {
session.status = "error";
session.error = err instanceof Error ? err.message : String(err);
}
this.save(session);
return this.snapshot(session);
}
private async preparePlayerTurn(
session: SimSession,
info: EntityInfo,
): Promise<void> {
const worldState = session.coreRepo.loadWorldState(
session.worldInstanceId,
);
if (!worldState) throw new Error("World state lost");
const entity = worldState.getEntity(info.id);
if (!entity) throw new Error(`Entity "${info.id}" not found`);
const promptBuilder = new ActorPromptBuilder(session.bufferRepo, 20);
const { systemPrompt, userContext } = promptBuilder.build(
worldState,
entity,
);
session.waitingEntity = {
entityId: info.id,
name: info.name,
systemPrompt,
userContext,
};
session.status = "waiting_player";
}
private async processNpcTurn(
session: SimSession,
info: EntityInfo,
): Promise<void> {
const worldState = session.coreRepo.loadWorldState(
session.worldInstanceId,
);
if (!worldState) throw new Error("World state lost");
const entity = worldState.getEntity(info.id);
if (!entity) throw new Error(`Entity "${info.id}" not found`);
const actor = new ActorAgent(
{ actor: session.actorProvider, decoder: session.decoderProvider },
session.bufferRepo,
20,
);
const result = await actor.act(worldState, entity);
const entry: LogEntry = {
turn: session.turn,
entityId: info.id,
entityName: info.name,
narrativeProse: result.narrativeProse,
intents: [],
timestamp: worldState.clock.get().toISOString(),
};
if (session.actorProvider.lastCalls && session.actorProvider.lastCalls.length > 0) {
const actorCall = session.actorProvider.lastCalls[session.actorProvider.lastCalls.length - 1];
entry.rawPrompt = {
systemPrompt: actorCall.systemPrompt,
userContext: actorCall.userContext,
};
entry.usage = actorCall.usage;
}
if (session.decoderProvider.lastCalls && session.decoderProvider.lastCalls.length > 0) {
const decoderCall = session.decoderProvider.lastCalls[session.decoderProvider.lastCalls.length - 1];
entry.decoderPrompt = {
systemPrompt: decoderCall.systemPrompt,
userContext: decoderCall.userContext,
};
entry.decoderUsage = decoderCall.usage;
}
for (const intent of result.intents.intents) {
const outcome = await session.architect.processIntent(
worldState,
intent,
);
const ts = worldState.clock.get().toISOString();
entry.intents.push({
type: intent.type,
description: intent.description,
targetIds: intent.targetIds,
isValid: outcome.isValid,
reason: outcome.reason,
minutesToAdvance: outcome.timeDelta?.minutesToAdvance,
});
const actorEntry = buildBufferEntryForIntent(
intent,
ts,
entity.locationId,
);
if (intent.type === "action") {
actorEntry.outcome = {
isValid: outcome.isValid,
reason: outcome.reason,
};
}
session.bufferRepo.save(actorEntry);
if (
entity.locationId &&
(intent.type === "dialogue" || intent.type === "action")
) {
for (const [, other] of worldState.entities) {
if (
other.id !== info.id &&
other.locationId === entity.locationId
) {
const observerEntry = buildBufferEntryForIntent(
intent,
ts,
entity.locationId,
);
if (intent.type === "action") {
observerEntry.outcome = {
isValid: outcome.isValid,
reason: outcome.reason,
};
}
session.bufferRepo.save({ ...observerEntry, ownerId: other.id });
}
}
}
}
session.log.push(entry);
session.coreRepo.saveWorldState(worldState);
}
private async runAliasResolution(session: SimSession): Promise<void> {
const worldState = session.coreRepo.loadWorldState(
session.worldInstanceId,
);
if (!worldState) throw new Error("World state lost");
const entities = Array.from(worldState.entities.values());
for (const viewer of entities) {
if (!viewer.locationId) continue;
for (const target of entities) {
if (viewer.id === target.id) continue;
if (
target.locationId === viewer.locationId &&
!viewer.aliases.has(target.id)
) {
const alias = await session.aliasGenerator.generate(viewer, target);
viewer.aliases.set(target.id, alias);
session.coreRepo.saveEntity(viewer, worldState.id);
}
}
}
}
close(id: string): void {
const session = this.sessions.get(id);
if (session) {
session.db.close();
this.sessions.delete(id);
}
}
deleteSession(id: string): void {
const session = this.sessions.get(id);
if (session) {
session.db.close();
this.sessions.delete(id);
}
const dbDir = path.resolve(process.cwd(), "data");
const dbPath = path.join(dbDir, `${id}.db`);
if (fs.existsSync(dbPath)) {
try {
fs.unlinkSync(dbPath);
} catch (err) {
console.error(`Failed to delete session file ${dbPath}:`, err);
}
}
}
async load(id: string): Promise<SimSnapshot | null> {
const active = this.sessions.get(id);
if (active) {
return this.snapshot(active);
}
const dbDir = path.resolve(process.cwd(), "data");
const dbPath = path.join(dbDir, `${id}.db`);
if (!fs.existsSync(dbPath)) return null;
try {
const db = new Database(dbPath);
const state = loadSessionState(db, id);
if (!state) {
db.close();
return null;
}
const list = ProviderManager.list();
const active = ProviderManager.getActive();
const mappings = state.providerMappings || {};
const resolveProviderForTask = (task: string): ILLMProvider => {
const mappedId = mappings[task];
let inst = mappedId ? list.find((p) => p.id === mappedId) : null;
if (!inst) {
inst = active;
}
if (!inst) {
const envKey = process.env.GOOGLE_API_KEY;
if (envKey) {
inst = ProviderManager.create("Default (Env)", "google-genai", envKey);
}
}
if (!inst) {
throw new Error(`No active LLM Provider Instance found for task "${task}". Please configure a key in Settings first.`);
}
if (inst.providerName === "google-genai") {
return new GeminiProvider(inst.apiKey);
} else {
return new MockLLMProvider([]);
}
};
const coreRepo = new SQLiteRepository(db);
const bufferRepo = new BufferRepository(db);
const actorProvider = resolveProviderForTask("actor-prose");
const validatorProvider = resolveProviderForTask("llm-validator");
const decoderProvider = resolveProviderForTask("intent-decoder");
const timedeltaProvider = resolveProviderForTask("timedelta");
const architect = new Architect(
{ validator: validatorProvider, timedelta: timedeltaProvider },
coreRepo,
);
const aliasGenerator = new AliasDeltaGenerator(actorProvider);
const session: SimSession = {
db,
dbPath,
coreRepo,
bufferRepo,
worldInstanceId: id,
scenarioName: state.scenarioName,
scenarioDescription: state.scenarioDescription,
turn: state.turn,
maxTurns: state.maxTurns,
entities: state.entities || [],
playerEntityId: state.playerEntityId,
entityIndex: state.entityIndex,
actorProvider,
validatorProvider,
decoderProvider,
timedeltaProvider,
architect,
aliasGenerator,
log: state.log || [],
status: state.status,
error: state.error,
waitingEntity: state.waitingEntity,
aliasDoneForTurn: state.aliasDoneForTurn || false,
providerMappings: mappings,
};
this.sessions.set(id, session);
return this.snapshot(session);
} catch (err) {
console.error(`Failed to load session ${id}:`, err);
return null;
}
}
listSavedSessions(): SimSnapshot[] {
const dbDir = path.resolve(process.cwd(), "data");
if (!fs.existsSync(dbDir)) return [];
const snapshots: SimSnapshot[] = [];
const files = fs.readdirSync(dbDir).filter(f => f.startsWith("sim-") && f.endsWith(".db"));
for (const file of files) {
const id = file.replace(".db", "");
const dbPath = path.join(dbDir, file);
const active = this.sessions.get(id);
if (active) {
snapshots.push(this.snapshot(active));
continue;
}
try {
const db = new Database(dbPath);
const state = loadSessionState(db, id);
db.close();
if (state) {
snapshots.push({
id,
status: state.status,
turn: state.turn,
maxTurns: state.maxTurns,
scenarioName: state.scenarioName,
scenarioDescription: state.scenarioDescription,
entities: state.entities || [],
log: state.log || [],
entityIndex: state.entityIndex,
waitingEntity: state.waitingEntity,
error: state.error,
});
}
} catch {
/* skip */
}
}
return snapshots.sort((a, b) => {
const tsA = parseInt(a.id.replace("sim-", ""), 10) || 0;
const tsB = parseInt(b.id.replace("sim-", ""), 10) || 0;
return tsB - tsA;
});
}
private save(session: SimSession): void {
const state: SavedState = {
scenarioName: session.scenarioName,
scenarioDescription: session.scenarioDescription,
turn: session.turn,
maxTurns: session.maxTurns,
entities: session.entities,
playerEntityId: session.playerEntityId,
entityIndex: session.entityIndex,
status: session.status,
error: session.error,
waitingEntity: session.waitingEntity,
aliasDoneForTurn: session.aliasDoneForTurn,
log: session.log,
providerMappings: session.providerMappings,
};
session.db.prepare(`
CREATE TABLE IF NOT EXISTS gui_meta (
id TEXT PRIMARY KEY,
state_json TEXT
)
`).run();
session.db.prepare(`
INSERT INTO gui_meta (id, state_json)
VALUES (?, ?)
ON CONFLICT(id) DO UPDATE SET state_json = excluded.state_json
`).run(session.worldInstanceId, JSON.stringify(state));
}
getSnapshot(id: string): SimSnapshot | null {
const session = this.sessions.get(id);
return session ? this.snapshot(session) : null;
}
private snapshot(session: SimSession): SimSnapshot {
return {
id: session.worldInstanceId,
status: session.status,
turn: session.turn,
maxTurns: session.maxTurns,
scenarioName: session.scenarioName,
scenarioDescription: session.scenarioDescription,
entities: session.entities,
log: session.log,
entityIndex: session.entityIndex,
waitingEntity: session.waitingEntity,
error: session.error,
};
}
}
export const simulationManager = new SimulationManager();

View File

@@ -1,7 +1,11 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"target": "ES2022",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
@@ -19,7 +23,9 @@
}
],
"paths": {
"@/*": ["./src/*"]
"@/*": [
"./src/*"
]
}
},
"include": [
@@ -27,8 +33,9 @@
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
".next/dev/types/**/*.ts"
],
"exclude": ["node_modules"]
"exclude": [
"node_modules"
]
}

View File

@@ -1,12 +0,0 @@
{
"name": "@omnia/cli",
"version": "0.0.0",
"private": true,
"type": "module",
"exports": {
".": "./dist/index.js"
},
"dependencies": {
"dotenv": "^17.4.2"
}
}

View File

@@ -1,6 +0,0 @@
import dotenv from "dotenv";
// Load environment variables once at CLI application entry point
dotenv.config();
export {};

View File

@@ -1,9 +0,0 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist"
},
"include": ["src"],
"references": []
}

View File

@@ -0,0 +1,132 @@
{
"id": "talking-room",
"name": "Talking Room",
"description": "A scientific experiment where two memory-wiped subjects are placed in a featureless white room to observe their interaction.",
"startTime": "2026-07-09T08:00:00.000Z",
"world": {
"attributes": [
{
"name": "experiment_codename",
"value": "Project Tabula Rasa (Phase 3)",
"visibility": "PRIVATE",
"allowedEntities": []
},
{
"name": "observation_status",
"value": "Active monitoring. Audio and visual feeds online.",
"visibility": "PRIVATE",
"allowedEntities": []
},
{
"name": "ambient_sound",
"value": "A low, barely audible electrical hum.",
"visibility": "PUBLIC"
}
]
},
"locations": [
{
"id": "white-room",
"parentId": null,
"attributes": [
{
"name": "description",
"value": "A pristine, featureless room with white walls, ceiling, and floor. There are no visible doors, windows, seams, or vents.",
"visibility": "PUBLIC"
},
{
"name": "lighting",
"value": "Bright, uniform illumination casting no shadows.",
"visibility": "PUBLIC"
}
],
"connections": []
}
],
"entities": [
{
"id": "7c9b83b3-8cfb-4e89-8d77-626a5757d591",
"locationId": "white-room",
"attributes": [
{
"name": "name",
"value": "Bob",
"visibility": "PRIVATE",
"allowedEntities": ["7c9b83b3-8cfb-4e89-8d77-626a5757d591"]
},
{
"name": "appearance",
"value": "A tall human with short dark hair and alert eyes, standing near the center of the room.",
"visibility": "PUBLIC"
},
{
"name": "clothing",
"value": "A clean, sterile grey jumpsuit with 'Alpha' embroidered in black lettering on the chest pocket.",
"visibility": "PUBLIC"
},
{
"name": "neural_erasure_dose",
"value": "150mg Compound-TR9",
"visibility": "PRIVATE",
"allowedEntities": []
}
],
"initialMemories": [
{
"id": "alpha-wake",
"timestamp": "2026-07-09T07:58:00.000Z",
"locationId": "white-room",
"intent": {
"type": "monologue",
"originalText": "I didn't have a choice. I would have been sent to jail if I hadn't agreed to do this experiement.",
"description": "",
"actorId": "7c9b83b3-8cfb-4e89-8d77-626a5757d591",
"targetIds": []
}
}
]
},
{
"id": "bf3f29d2-cf11-4b11-9a99-b13c126d400e",
"locationId": "white-room",
"attributes": [
{
"name": "name",
"value": "Bill",
"visibility": "PRIVATE",
"allowedEntities": ["bf3f29d2-cf11-4b11-9a99-b13c126d400e"]
},
{
"name": "appearance",
"value": "A medium-build human with long blonde hair tied back, sitting with their back pressed against the white wall.",
"visibility": "PUBLIC"
},
{
"name": "clothing",
"value": "A clean, sterile grey jumpsuit with 'Beta' embroidered in black lettering on the chest pocket.",
"visibility": "PUBLIC"
},
{
"name": "neural_erasure_dose",
"value": "150mg Compound-TR9",
"visibility": "PRIVATE",
"allowedEntities": []
}
],
"initialMemories": [
{
"id": "beta-wake",
"timestamp": "2026-07-09T07:58:30.000Z",
"locationId": "white-room",
"intent": {
"type": "action",
"originalText": "Why can't I remember anything before the research agreement. It's like my memory was erased.",
"description": "",
"actorId": "bf3f29d2-cf11-4b11-9a99-b13c126d400e",
"targetIds": []
}
}
]
}
]
}

View File

@@ -1,18 +0,0 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;

View File

@@ -1,33 +0,0 @@
import type { NextConfig } from "next";
import { networkInterfaces } from "os";
const getLocalIPs = () => {
const ips: string[] = ["localhost", "127.0.0.1"];
const nets = networkInterfaces();
for (const name of Object.keys(nets)) {
for (const net of nets[name] || []) {
if (net.family === "IPv4" && !net.internal) {
ips.push(net.address);
}
}
}
return ips;
};
const nextConfig: NextConfig = {
allowedDevOrigins: getLocalIPs(),
async headers() {
return [
{
source: "/api/:path*",
headers: [
{ key: "Access-Control-Allow-Origin", value: "*" },
{ key: "Access-Control-Allow-Methods", value: "GET,POST,PUT,DELETE,OPTIONS" },
{ key: "Access-Control-Allow-Headers", value: "Content-Type, Authorization" },
],
},
];
},
};
export default nextConfig;

View File

@@ -1,27 +0,0 @@
{
"name": "scenario-builder",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"next": "16.2.10",
"react": "19.2.4",
"react-dom": "19.2.4",
"@omnia/core": "workspace:*"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.2.10",
"tailwindcss": "^4",
"typescript": "^5"
}
}

View File

@@ -1,7 +0,0 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

View File

@@ -1 +0,0 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 391 B

View File

@@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

Before

Width:  |  Height:  |  Size: 128 B

View File

@@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

Before

Width:  |  Height:  |  Size: 385 B

View File

@@ -1,125 +0,0 @@
import { NextResponse } from "next/server";
import Database from "better-sqlite3";
import { WorldState, Entity, SQLiteRepository, AttributeVisibility } from "@omnia/core";
import path from "path";
const DB_PATH = path.resolve("/home/sortedcord/Projects/omnia_umbrella/omnia/omnia.db");
function getRepo() {
const db = new Database(DB_PATH);
return { repo: new SQLiteRepository(db), db };
}
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url);
const id = searchParams.get("id");
const { repo, db } = getRepo();
try {
if (id) {
const world = repo.loadWorldState(id);
if (!world) {
return NextResponse.json({ error: `World with ID ${id} not found` }, { status: 404 });
}
// Serialize world
const serialized = {
id: world.id,
attributes: Array.from(world.attributes.values()).map(attr => ({
name: attr.name,
value: attr.getValue(),
visibility: attr.getVisibility(),
allowedEntities: Array.from(attr.getAllowedEntities())
})),
entities: Array.from(world.entities.values()).map(entity => ({
id: entity.id,
attributes: Array.from(entity.attributes.values()).map(attr => ({
name: attr.name,
value: attr.getValue(),
visibility: attr.getVisibility(),
allowedEntities: Array.from(attr.getAllowedEntities())
}))
}))
};
return NextResponse.json(serialized);
} else {
// List all worlds
const rows = db.prepare("SELECT id FROM objects WHERE type = 'world'").all() as { id: string }[];
const worlds = [];
for (const row of rows) {
const world = repo.loadWorldState(row.id);
if (world) {
const nameAttr = world.attributes.get("name")?.getValue() || "Unnamed World";
worlds.push({
id: world.id,
name: nameAttr
});
}
}
return NextResponse.json({ worlds });
}
} finally {
db.close();
}
} catch (error) {
console.error("API GET Error:", error);
const message = error instanceof Error ? error.message : "Internal Server Error";
return NextResponse.json({ error: message }, { status: 500 });
}
}
export async function POST(request: Request) {
try {
const payload = await request.json();
if (!payload.id) {
return NextResponse.json({ error: "World ID is required" }, { status: 400 });
}
const { repo, db } = getRepo();
try {
const world = new WorldState(payload.id);
// Add attributes to world
if (payload.attributes && Array.isArray(payload.attributes)) {
for (const attr of payload.attributes) {
world.addAttribute(
attr.name,
attr.value,
attr.visibility || AttributeVisibility.PUBLIC,
attr.allowedEntities ? new Set(attr.allowedEntities) : null
);
}
}
// Add entities to world
if (payload.entities && Array.isArray(payload.entities)) {
for (const ent of payload.entities) {
const entity = new Entity(ent.id);
if (ent.attributes && Array.isArray(ent.attributes)) {
for (const attr of ent.attributes) {
entity.addAttribute(
attr.name,
attr.value,
attr.visibility || AttributeVisibility.PRIVATE,
attr.allowedEntities ? new Set(attr.allowedEntities) : null
);
}
}
world.addEntity(entity);
}
}
repo.saveWorldState(world);
return NextResponse.json({ success: true, worldId: world.id });
} finally {
db.close();
}
} catch (error) {
console.error("API POST Error:", error);
const message = error instanceof Error ? error.message : "Internal Server Error";
return NextResponse.json({ error: message }, { status: 500 });
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -1,6 +0,0 @@
body {
font-family: sans-serif;
padding: 10px;
background-color: white;
color: black;
}

View File

@@ -1,33 +0,0 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html
lang="en"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
>
<body className="min-h-full flex flex-col">{children}</body>
</html>
);
}

View File

@@ -1,586 +0,0 @@
"use client";
import { useState, useEffect } from "react";
function generateUUID(): string {
if (typeof window !== "undefined" && window.crypto && window.crypto.randomUUID) {
return window.crypto.randomUUID();
}
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0;
const v = c === "x" ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
interface Attribute {
name: string;
value: string;
visibility: "PUBLIC" | "PRIVATE";
allowedEntities: string[];
}
interface Entity {
id: string;
attributes: Attribute[];
}
interface WorldData {
id: string;
attributes: Attribute[];
entities: Entity[];
}
export default function Home() {
const [worldsList, setWorldsList] = useState<{ id: string; name: string }[]>([]);
const [selectedWorldId, setSelectedWorldId] = useState<string>("");
const [worldData, setWorldData] = useState<WorldData | null>(null);
const [status, setStatus] = useState<string>("");
const [error, setError] = useState<string>("");
// Temp state for adding new attributes / entities
const [newWorldAttribute, setNewWorldAttribute] = useState<{ name: string; value: string; visibility: "PUBLIC" | "PRIVATE" }>({ name: "", value: "", visibility: "PUBLIC" });
const [newEntityAttribute, setNewEntityAttribute] = useState<Record<string, { name: string; value: string; visibility: "PUBLIC" | "PRIVATE" }>>({});
const fetchWorlds = async () => {
try {
const res = await fetch("/api/world");
if (!res.ok) throw new Error("Failed to fetch worlds");
const data = await res.json();
setWorldsList(data.worlds || []);
} catch (err) {
const msg = err instanceof Error ? err.message : "Failed to load worlds list";
setError(msg);
}
};
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
fetchWorlds();
}, []);
const handleCreateNewWorld = () => {
const newId = generateUUID();
setWorldData({
id: newId,
attributes: [{ name: "name", value: "New World", visibility: "PUBLIC", allowedEntities: [] }],
entities: []
});
setSelectedWorldId("");
setStatus("Created new world locally. Don't forget to save!");
setError("");
};
const handleLoadWorld = async (id: string) => {
if (!id) return;
try {
setStatus(`Loading world ${id}...`);
setError("");
const res = await fetch(`/api/world?id=${id}`);
if (!res.ok) throw new Error("Failed to load world data");
const data = await res.json();
setWorldData(data);
setSelectedWorldId(id);
setStatus(`Loaded world successfully!`);
} catch (err) {
const msg = err instanceof Error ? err.message : "Failed to load world";
setError(msg);
setStatus("");
}
};
const handleSaveWorld = async () => {
if (!worldData) return;
try {
setStatus("Saving world to database...");
setError("");
const res = await fetch("/api/world", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(worldData)
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Failed to save world");
setStatus("World saved successfully!");
fetchWorlds();
setSelectedWorldId(worldData.id);
} catch (err) {
const msg = err instanceof Error ? err.message : "Failed to save world";
setError(msg);
setStatus("");
}
};
// World Attribute management
const addWorldAttribute = () => {
if (!worldData || !newWorldAttribute.name.trim()) return;
if (worldData.attributes.some(a => a.name === newWorldAttribute.name)) {
setError(`Attribute "${newWorldAttribute.name}" already exists on world.`);
return;
}
setWorldData({
...worldData,
attributes: [
...worldData.attributes,
{ name: newWorldAttribute.name, value: newWorldAttribute.value, visibility: newWorldAttribute.visibility, allowedEntities: [] }
]
});
setNewWorldAttribute({ name: "", value: "", visibility: "PUBLIC" });
setError("");
};
const removeWorldAttribute = (name: string) => {
if (!worldData) return;
setWorldData({
...worldData,
attributes: worldData.attributes.filter(a => a.name !== name)
});
};
const updateWorldAttributeValue = (name: string, value: string) => {
if (!worldData) return;
setWorldData({
...worldData,
attributes: worldData.attributes.map(a => a.name === name ? { ...a, value } : a)
});
};
const updateWorldAttributeVisibility = (name: string, visibility: "PUBLIC" | "PRIVATE") => {
if (!worldData) return;
setWorldData({
...worldData,
attributes: worldData.attributes.map(a => a.name === name ? { ...a, visibility, allowedEntities: visibility === "PUBLIC" ? [] : a.allowedEntities } : a)
});
};
// Entity management
const handleAddEntity = () => {
if (!worldData) return;
const newId = generateUUID();
const newEntity: Entity = {
id: newId,
attributes: [{ name: "name", value: "New Entity", visibility: "PRIVATE", allowedEntities: [] }]
};
setWorldData({
...worldData,
entities: [...worldData.entities, newEntity]
});
};
const handleRemoveEntity = (entityId: string) => {
if (!worldData) return;
// Also clean up this entity from all attribute ACL lists
const updatedEntities = worldData.entities.filter(e => e.id !== entityId).map(e => ({
...e,
attributes: e.attributes.map(a => ({
...a,
allowedEntities: a.allowedEntities.filter(id => id !== entityId)
}))
}));
const updatedWorldAttributes = worldData.attributes.map(a => ({
...a,
allowedEntities: a.allowedEntities.filter(id => id !== entityId)
}));
setWorldData({
...worldData,
attributes: updatedWorldAttributes,
entities: updatedEntities
});
};
// Entity Attribute management
const addEntityAttribute = (entityId: string) => {
if (!worldData) return;
const input = newEntityAttribute[entityId];
if (!input || !input.name.trim()) return;
const entity = worldData.entities.find(e => e.id === entityId);
if (!entity) return;
if (entity.attributes.some(a => a.name === input.name)) {
setError(`Attribute "${input.name}" already exists on entity.`);
return;
}
const updatedEntities = worldData.entities.map(e => {
if (e.id === entityId) {
return {
...e,
attributes: [
...e.attributes,
{ name: input.name, value: input.value, visibility: input.visibility, allowedEntities: [] }
]
};
}
return e;
});
setWorldData({ ...worldData, entities: updatedEntities });
setNewEntityAttribute({
...newEntityAttribute,
[entityId]: { name: "", value: "", visibility: "PRIVATE" }
});
setError("");
};
const removeEntityAttribute = (entityId: string, attrName: string) => {
if (!worldData) return;
const updatedEntities = worldData.entities.map(e => {
if (e.id === entityId) {
return {
...e,
attributes: e.attributes.filter(a => a.name !== attrName)
};
}
return e;
});
setWorldData({ ...worldData, entities: updatedEntities });
};
const updateEntityAttributeValue = (entityId: string, attrName: string, value: string) => {
if (!worldData) return;
const updatedEntities = worldData.entities.map(e => {
if (e.id === entityId) {
return {
...e,
attributes: e.attributes.map(a => a.name === attrName ? { ...a, value } : a)
};
}
return e;
});
setWorldData({ ...worldData, entities: updatedEntities });
};
const updateEntityAttributeVisibility = (entityId: string, attrName: string, visibility: "PUBLIC" | "PRIVATE") => {
if (!worldData) return;
const updatedEntities = worldData.entities.map(e => {
if (e.id === entityId) {
return {
...e,
attributes: e.attributes.map(a => a.name === attrName ? { ...a, visibility, allowedEntities: visibility === "PUBLIC" ? [] : a.allowedEntities } : a)
};
}
return e;
});
setWorldData({ ...worldData, entities: updatedEntities });
};
const toggleEntityAcl = (targetEntityId: string, attrName: string, allowedEntityId: string, checked: boolean) => {
if (!worldData) return;
const updatedEntities = worldData.entities.map(e => {
if (e.id === targetEntityId) {
return {
...e,
attributes: e.attributes.map(a => {
if (a.name === attrName) {
const currentAcl = a.allowedEntities;
const newAcl = checked
? [...currentAcl, allowedEntityId]
: currentAcl.filter(id => id !== allowedEntityId);
return { ...a, allowedEntities: newAcl };
}
return a;
})
};
}
return e;
});
setWorldData({ ...worldData, entities: updatedEntities });
};
const toggleWorldAcl = (attrName: string, allowedEntityId: string, checked: boolean) => {
if (!worldData) return;
setWorldData({
...worldData,
attributes: worldData.attributes.map(a => {
if (a.name === attrName) {
const currentAcl = a.allowedEntities;
const newAcl = checked
? [...currentAcl, allowedEntityId]
: currentAcl.filter(id => id !== allowedEntityId);
return { ...a, allowedEntities: newAcl };
}
return a;
})
});
};
return (
<div style={{ padding: "20px", fontFamily: "sans-serif" }}>
<h1>Omnia Scenario Builder</h1>
<hr />
{/* Persistence Controls */}
<section style={{ marginBottom: "20px" }}>
<h2>World Persistence</h2>
<div style={{ display: "flex", gap: "10px", alignItems: "center" }}>
<button onClick={handleCreateNewWorld}>Create New World</button>
<span>or Load Existing:</span>
<select
value={selectedWorldId}
onChange={(e) => handleLoadWorld(e.target.value)}
>
<option value="">-- Select World --</option>
{worldsList.map((w) => (
<option key={w.id} value={w.id}>
{w.name} ({w.id})
</option>
))}
</select>
{worldData && (
<>
<button onClick={handleSaveWorld} style={{ fontWeight: "bold" }}>
Save World to DB
</button>
<button onClick={() => handleLoadWorld(worldData.id)}>
Reload / Discard Changes
</button>
</>
)}
</div>
</section>
{/* Status Messages */}
{status && <div style={{ color: "green", margin: "10px 0" }}><strong>Status:</strong> {status}</div>}
{error && <div style={{ color: "red", margin: "10px 0" }}><strong>Error:</strong> {error}</div>}
{worldData ? (
<div>
<hr />
{/* World Information */}
<section style={{ marginBottom: "30px" }}>
<h2>World Attributes (ID: {worldData.id})</h2>
<table border={1} cellPadding={5} style={{ borderCollapse: "collapse", width: "100%", marginBottom: "10px" }}>
<thead>
<tr>
<th>Attribute Name</th>
<th>Value</th>
<th>Visibility</th>
<th>ACL (Allowed Entities for PRIVATE)</th>
<th>Action</th>
</tr>
</thead>
<tbody>
{worldData.attributes.map((attr) => (
<tr key={attr.name}>
<td><strong>{attr.name}</strong></td>
<td>
<input
type="text"
value={attr.value}
onChange={(e) => updateWorldAttributeValue(attr.name, e.target.value)}
/>
</td>
<td>
<select
value={attr.visibility}
onChange={(e) => updateWorldAttributeVisibility(attr.name, e.target.value as "PUBLIC" | "PRIVATE")}
>
<option value="PUBLIC">PUBLIC</option>
<option value="PRIVATE">PRIVATE</option>
</select>
</td>
<td>
{attr.visibility === "PRIVATE" ? (
<div>
{worldData.entities.length === 0 ? (
<span style={{ color: "gray" }}>No entities in world to grant access to</span>
) : (
worldData.entities.map((e) => {
const entName = e.attributes.find(a => a.name === "name")?.value || e.id;
return (
<label key={e.id} style={{ display: "block" }}>
<input
type="checkbox"
checked={attr.allowedEntities.includes(e.id)}
onChange={(evt) => toggleWorldAcl(attr.name, e.id, evt.target.checked)}
/>{" "}
{entName} ({e.id.slice(0, 8)}...)
</label>
);
})
)}
</div>
) : (
<span style={{ color: "gray" }}>N/A (Visible to all)</span>
)}
</td>
<td>
<button onClick={() => removeWorldAttribute(attr.name)}>Delete</button>
</td>
</tr>
))}
</tbody>
</table>
{/* Add World Attribute */}
<div style={{ background: "#f5f5f5", padding: "10px", border: "1px solid #ccc" }}>
<h4>Add World Attribute</h4>
Name:{" "}
<input
type="text"
value={newWorldAttribute.name}
onChange={(e) => setNewWorldAttribute({ ...newWorldAttribute, name: e.target.value })}
placeholder="e.g. description"
/>{" "}
Value:{" "}
<input
type="text"
value={newWorldAttribute.value}
onChange={(e) => setNewWorldAttribute({ ...newWorldAttribute, value: e.target.value })}
placeholder="e.g. A lush land"
/>{" "}
Visibility:{" "}
<select
value={newWorldAttribute.visibility}
onChange={(e) => setNewWorldAttribute({ ...newWorldAttribute, visibility: e.target.value as "PUBLIC" | "PRIVATE" })}
>
<option value="PUBLIC">PUBLIC</option>
<option value="PRIVATE">PRIVATE</option>
</select>{" "}
<button onClick={addWorldAttribute}>Add Attribute</button>
</div>
</section>
<hr />
{/* Entities section */}
<section style={{ marginBottom: "30px" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<h2>World Entities</h2>
<button onClick={handleAddEntity}>+ Add New Entity</button>
</div>
{worldData.entities.length === 0 ? (
<p>No entities found in this world. Click &quot;+ Add New Entity&quot; to add one.</p>
) : (
worldData.entities.map((entity, index) => {
const entName = entity.attributes.find(a => a.name === "name")?.value || "Unnamed Entity";
const entInputState = newEntityAttribute[entity.id] || { name: "", value: "", visibility: "PRIVATE" };
return (
<div key={entity.id} style={{ border: "1px solid #999", padding: "15px", marginBottom: "20px", background: "#fafafa" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<h3>Entity #{index + 1}: {entName} <span style={{ fontSize: "12px", color: "gray", fontWeight: "normal" }}>({entity.id})</span></h3>
<button onClick={() => handleRemoveEntity(entity.id)} style={{ color: "red" }}>Delete Entity</button>
</div>
<table border={1} cellPadding={5} style={{ borderCollapse: "collapse", width: "100%", marginBottom: "10px", background: "white" }}>
<thead>
<tr>
<th>Attribute Name</th>
<th>Value</th>
<th>Visibility</th>
<th>ACL (Allowed Entities for PRIVATE)</th>
<th>Action</th>
</tr>
</thead>
<tbody>
{entity.attributes.map((attr) => (
<tr key={attr.name}>
<td><strong>{attr.name}</strong></td>
<td>
<input
type="text"
value={attr.value}
onChange={(e) => updateEntityAttributeValue(entity.id, attr.name, e.target.value)}
/>
</td>
<td>
<select
value={attr.visibility}
onChange={(e) => updateEntityAttributeVisibility(entity.id, attr.name, e.target.value as "PUBLIC" | "PRIVATE")}
>
<option value="PUBLIC">PUBLIC</option>
<option value="PRIVATE">PRIVATE</option>
</select>
</td>
<td>
{attr.visibility === "PRIVATE" ? (
<div>
{worldData.entities.filter(e => e.id !== entity.id).length === 0 ? (
<span style={{ color: "gray" }}>No other entities in world to grant access to</span>
) : (
worldData.entities
.filter(e => e.id !== entity.id)
.map((e) => {
const otherName = e.attributes.find(a => a.name === "name")?.value || e.id;
return (
<label key={e.id} style={{ display: "block" }}>
<input
type="checkbox"
checked={attr.allowedEntities.includes(e.id)}
onChange={(evt) => toggleEntityAcl(entity.id, attr.name, e.id, evt.target.checked)}
/>{" "}
{otherName} ({e.id.slice(0, 8)}...)
</label>
);
})
)}
</div>
) : (
<span style={{ color: "gray" }}>N/A (Visible to all)</span>
)}
</td>
<td>
<button onClick={() => removeEntityAttribute(entity.id, attr.name)}>Delete</button>
</td>
</tr>
))}
</tbody>
</table>
{/* Add Entity Attribute */}
<div style={{ background: "#eee", padding: "10px", border: "1px dashed #777" }}>
<h5>Add Attribute to {entName}</h5>
Name:{" "}
<input
type="text"
value={entInputState.name}
onChange={(e) => setNewEntityAttribute({
...newEntityAttribute,
[entity.id]: { ...entInputState, name: e.target.value }
})}
placeholder="e.g. title"
/>{" "}
Value:{" "}
<input
type="text"
value={entInputState.value}
onChange={(e) => setNewEntityAttribute({
...newEntityAttribute,
[entity.id]: { ...entInputState, value: e.target.value }
})}
placeholder="e.g. Witcher"
/>{" "}
Visibility:{" "}
<select
value={entInputState.visibility}
onChange={(e) => setNewEntityAttribute({
...newEntityAttribute,
[entity.id]: { ...entInputState, visibility: e.target.value as "PUBLIC" | "PRIVATE" }
})}
>
<option value="PUBLIC">PUBLIC</option>
<option value="PRIVATE">PRIVATE</option>
</select>{" "}
<button onClick={() => addEntityAttribute(entity.id)}>Add Attribute</button>
</div>
</div>
);
})
)}
</section>
</div>
) : (
<div style={{ padding: "40px 0", textAlign: "center", color: "gray" }}>
Please select a world to load or click &quot;Create New World&quot; to begin.
</div>
)}
</div>
);
}

View File

@@ -1,31 +0,0 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export function proxy(request: NextRequest) {
const origin = request.headers.get("origin") || "*";
// Handle preflight OPTIONS requests
if (request.method === "OPTIONS") {
return new NextResponse(null, {
status: 200,
headers: {
"Access-Control-Allow-Origin": origin,
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
"Access-Control-Max-Age": "86400",
},
});
}
// Handle standard requests
const response = NextResponse.next();
response.headers.set("Access-Control-Allow-Origin", origin);
response.headers.set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
response.headers.set("Access-Control-Allow-Headers", "Content-Type, Authorization");
return response;
}
export const config = {
matcher: "/api/:path*",
};

1
docs Symbolic link
View File

@@ -0,0 +1 @@
web/docs/src

View File

@@ -1,75 +0,0 @@
# Architect
The architect is not a specialized model. Neither is it a special entity. The architect isn't a an entity that inherits `AttributableObject` either. Instead, it is a special context along with a set of tools that is given to an LLM that dictates what happens to our world state.
The Architect (context) is provided with the WorldState, states of entities, state of a scene, location, along with their attributes and is asked to judge weather an action (later an `Intent`) makes canonical sense or not (for example, item ownership, entity location and state tracking) and disallows intents that break the narrative flow completely.
```mermaid
flowchart LR
A[Agent] -->|"Perform Action"| B(Action Intent)
subgraph "Architect Layer"
C[Architect]
D{"Validators"}
E{"Noise Layer 1"}
F{"Noise Layer 2"}
G[Delta Generators]
end
B --> C
C -->|"Tool Call"| D
C -->|"Spontaneity Bypass"| E
D --> F
E --> F
F -->|"Pass"| G
F -->|"Fail"| A
G --> |Deltas modify| H[State]
H --> I[Entity]
H --> J[Location]
H --> K[World]
```
For now, dialogue intents are exempted from validation system and are not used for state manipulation. Dialogues fall more into the domain of the [Perception Engine (Deferred)]() and [memory systems (nlavs)]().
v0 defers atomic validators completely. Instead an umbrella LLM based validator is used for validating the intent (action) generated based on spatial knowledge and heuristic physics.
## Dyamic Validators (Deferred from V0)
There are will a [standard set of Validators]() that dictate if an action is possible or not. The architect can, however, dynamically generate its own set of Validators which can be loaded and unloaded during runtime based on narration. These Validators can be soft validators (if they fail they're sent back to the entity for override confirmation.)
## RNG and Noise Layers (Deferred from v0)
> [!NOTE]
> Documenting story flattening problem.
Since we're have such complex systems for action validation, it's possible that the LLM would naturally steer towards low stakes actions or actions with minimal consequences. That way the narrative would simply flatten out. Which is why there needs to exist a noise layer that would allow for random (slightly non-sensible) actions to take place to introduce Spontaneity and unexpectedness into the system. (See dynamic temperature tweaking)
## Delta Generators
Delta Generators are single-responsibility components in the Architect Layer tasked with computing discrete state updates ("deltas") from validated intents.
### How They Function
1. **Validation Prerequisite**: Delta generators execute _only_ if the `LLMValidator` (or other validator layers) returns `isValid: true`.
2. **Specialized Responsibility**: Each generator isolates a specific aspect of state transition (e.g., advancing the clock, updating positions, modifying attributes). This keeps prompts focused and avoids single monolithic LLM calls trying to update everything.
3. **Structured Outputs**: Generators query the LLM using Zod schemas to ensure type-safe and validated change deltas.
4. **Application and Persistence**: Once generated, the delta is applied to the live `WorldState` by deterministic code, and the changes are persisted to the database.
### Core Generators
#### Time Delta Generator
Calculates the physical time duration (in minutes) that a validated action takes to complete:
- **Inputs**: The validated action and the serialized objective `WorldState`.
- **Output (Zod Schema)**:
```json
{
"minutesToAdvance": 25,
"explanation": "Searching a locked desk thoroughly takes time."
}
```
- **Resolution**: The Architect advances `worldState.clock` by the returned minutes, then saves the updated world state to `SQLiteRepository`.

View File

@@ -1,87 +0,0 @@
# Intents
The simple way of understanding intents is to think of it as a proposal, not an effect.
I want to do X:
- Declarative
- High-level
- Allowed to be wrong
- Cheap to generate (LLM-friendly)
But, the actor LLM doesn't directly generate an intent. In order to keep the narrative going, the actor agent simply generates the continuing prose. This keeps the tone of the story, and if the intents validate, the narrative prose will directly go to the user while the deltas generated from the intents modify the state.
Another benefit of this architecture is that we can use a separate intent decoder to detect the type of intent (Dialogue Intent or Action Intent) or even separate multiple intents in a single prose and validate them. Post-validation they can be sent to the Scheduler to allow little voids where the entity can be interjected, etc (Exact mechanism is deferred).
```mermaid
flowchart LR
A[Actor Agent]
B[/Action Narrative<br/>Prose/]
C{{Intent<br/>Decoder}}
F[Architect]
E{{Intent<br/>Scheduler<br/><i>deferred for v0</i>}}
A --> B
B --> C
subgraph D["Intent Sequence"]
direction TD
I1([Dialogue Intent])
I2([Action Intent])
I3([Action Intent])
I4([⋯])
I1 --> I2 --> I3 --> I4
end
C --> D
D --> |"Concurrent"| F
F --> E
```
## Intent Decoder
The job of the Intent Decoder is to:
- See if the narrative prose can be split into multiple intents.
- Classify each intent to its type (`dialogue` or `action`).
- Parse the narrative text into structured JSON with minimal information loss.
- Contextually resolve the receiving parties/targets (for example, who is being spoken to, what object is being interacted with).
### Zod Schemas & Types
We define structured Zod schemas to validate types returned by the LLM:
- **IntentType**: `"dialogue"` | `"action"`
- **Intent**:
- `type`: `IntentType`
- `originalText`: `string` (the slice of raw prose text containing the intent)
- `description`: `string` (summarized intent action)
- `actorId`: `string`
- `targetIds`: `string[]` (resolved recipient or target entity IDs)
- **IntentSequence**:
- `intents`: `Intent[]`
### IntentDecoder Class
The `IntentDecoder` uses an `ILLMProvider` to query the LLM:
```typescript
export class IntentDecoder {
constructor(private llmProvider: ILLMProvider) {}
async decode(
worldState: WorldState,
actorId: string,
narrativeProse: string,
): Promise<IntentSequence>;
}
```
It serializes the world state (via `worldState.serialize()`) and feeds all known entity IDs as context to the system and user prompts, enabling the model to resolve the target IDs correctly.
## A Dilemma of Concurrent Actions (deferred)
How would you deal with actions that are taking place at the same time? Since the decoder could split them into 2 different intents, and one passes the validators and the other doesn't, would it make sense to send that intent back to the actor agent? Wouldn't that create coherence issues?
If we decide to batch actions together in a single intent then how would the structure change for that?

View File

View File

@@ -1,7 +0,0 @@
# Names and LLMs
- While redesigning ../packages/core/index.ts a fundamental issue came up. Something as simple as name is set to private. Because by common sense, an entity's name isn't common knowledge. You don't instantly know another person's name.
- So, although the internal system can identify an entity by it's id (which is defined in the lower level AttributableObject by default), UUIds aren't very helpful for something like LLMs be it the NPC Agent or the Architect Agent.
- An extension of this problem is unnamed entities. How does the architect orchestrate changes for such an entity when it doesn't even have a name?
- Also, if we fixate on NPC agents using identifiers like, "the hooded man", there is nothing stopping them from using other identifiers like "the shadowey man" in the next action or even make up names due to the inherent nature of LLMs.
- This just becomes a nightmare to deal with when parsing LLM responses to find out involved entities.

View File

@@ -1,102 +0,0 @@
# Testing Strategy
This document outlines the testing architecture for the Omnia project. The central design problem is that most of the system is deterministic and highly testable, but a few critical parts (specifically, LLM behavior) are non-deterministic.
Treating these two categories the same way—either mocking everything (which provides false confidence) or hitting the real LLM API for everything (which is slow, expensive, flaky, and non-repeatable)—is an anti-pattern. Our test architecture makes this split explicit rather than papering over it.
## Test Tiers
We use a three-tiered system, categorized by what the tests actually depend on.
### Tier 1: Unit Tests (Per-Package, No LLM)
Unit tests reside within each package's `tests/` directory. They do not use LLMs and do not perform I/O.
This tier should cover the majority of the codebase, as most of Omnia's specific logic is deterministic. Examples of logic that must be fully covered by unit tests include:
- `hasAccess()` / ACL grant-revoke logic.
- `addAttribute` rejecting duplicate names.
- `WorldClock.advance()` / `getTimeOfDay()` boundaries.
- The spatial bubble-up algorithm (fully mechanical: given a constructed graph and portal properties, assert exactly who perceives what at each step).
- Zod schemas rejecting malformed input at each boundary.
**Execution:** Runs on every save and every commit.
### Tier 2: Integration Tests (Cross-Package, Mocked LLM)
Integration tests live in the root `tests/integration/` directory. They test cross-package flows using a mocked LLM to ensure speed, determinism, and zero cost.
This is where the `MockLLMProvider` earns its keep. It implements the `ILLMProvider` interface and returns canned responses that satisfy whatever Zod schema is requested.
**Mock Implementation:**
```typescript
export class MockLLMProvider implements ILLMProvider {
providerName = "mock";
constructor(private responses: unknown[]) {}
private callCount = 0;
async generateStructuredResponse<T extends z.ZodTypeAny>(
request: LLMRequest<T>,
): Promise<LLMResponse<z.infer<T>>> {
const next = this.responses[this.callCount++];
return { success: true, data: request.schema.parse(next) };
}
}
```
This tier tests our actual logic—e.g., does the Architect apply a delta correctly? Does the consequence generator mutate `WorldState` correctly? Does a scripted CLI conversation end in the expected state?—without ever depending on the model behaving a particular way.
**Shared Contract Suite**
Included in this tier is a shared contract test suite run against _both_ `MockLLMProvider` and `GeminiProvider`. The suite verifies:
1. Given a schema, the provider returns data matching it.
2. Given a malformed response, it fails predictably.
This enforces `ILLMProvider`'s reason for existing: real interchangeability, not just nominal conformance.
### Tier 3: Evals (Real API, Run Deliberately)
Evals live in the root `tests/evals/` directory. They use real LLM APIs and are run manually via a separate script (`test:evals`), excluded from the default Vitest run.
This tier is where our privacy guarantee actually lives. It requires a fundamentally different shape of test because of a critical distinction:
- A Tier 1 test on `hasAccess()` proves the _mechanism_ is correct.
- It says nothing about whether the model, given a correctly-filtered context, actually holds its tongue.
- It says nothing about whether some prompt-building code accidentally used the raw `.attributes/getValue()` path instead of `getVisibleAttributesFor()` (an unenforced-convention risk).
Both of these failure modes are invisible to unit tests. Therefore, evals are run _N_ times and scored, not asserted once.
**Example Eval Structure:**
```typescript
// tests/evals/privacy-leak.eval.ts
const RUNS = 15;
let leaks = 0;
for (let i = 0; i < RUNS; i++) {
const response = await askAboutPrivateFact(npcWithoutAccess, secretFact);
if (containsFact(response, secretFact)) leaks++;
}
// Any leak here is a real failure worth investigating, not noise to average away.
expect(leaks).toBe(0);
```
**Execution:** Run deliberately (e.g., weekly or pre-release). It costs real money and shouldn't fire on every save.
## Directory Structure
```text
omnia/
packages/
core/ src/ tests/ # Tier 1: Unit — no I/O, no LLM
intent/ src/ tests/
spatial/ src/ tests/
memory/ src/ tests/
architect/ src/ tests/
llm/ src/ tests/ # Includes MockLLMProvider + shared contract suite
tests/
integration/ # Tier 2: Cross-package flows, mocked LLM
evals/ # Tier 3: Real LLM calls, slow/costly/non-deterministic
```

View File

@@ -5,7 +5,7 @@ import tseslint from "typescript-eslint";
export default [
{
ignores: ["**/dist/**", "**/node_modules/**", "content/scenario-builder/**"],
ignores: ["**/dist/**", "**/node_modules/**", "**/.astro/**", "**/.next/**"],
},
js.configs.recommended,
@@ -19,5 +19,12 @@ export default [
},
},
{
files: ["**/*.tsx"],
languageOptions: {
globals: { ...globals.browser, ...globals.node },
},
},
eslintConfigPrettier,
];

View File

@@ -6,8 +6,12 @@
"scripts": {
"build": "tsc -b",
"build:web": "pnpm --filter landing build",
"build:all": "pnpm build && pnpm build:web",
"build:docs": "pnpm --filter docs build",
"build:gui": "pnpm --filter @omnia/gui build",
"build:all": "pnpm build && pnpm build:web && pnpm build:docs && pnpm build:gui",
"dev:web": "pnpm --filter landing dev",
"dev:docs": "pnpm --filter docs dev",
"dev:gui": "pnpm --filter @omnia/gui dev",
"clean": "git clean -xfd",
"lint": "eslint .",
"lint:fix": "eslint . --fix",

View File

@@ -0,0 +1,16 @@
{
"name": "@omnia/actor",
"version": "0.0.0",
"private": true,
"type": "module",
"exports": {
".": "./dist/index.js"
},
"dependencies": {
"@omnia/core": "workspace:*",
"@omnia/intent": "workspace:*",
"@omnia/llm": "workspace:*",
"@omnia/memory": "workspace:*",
"zod": "^4.4.3"
}
}

View File

@@ -0,0 +1,140 @@
import { z } from "zod";
import {
Entity,
WorldState,
naturalizeTime,
serializeSubjectiveWorldState,
} from "@omnia/core";
import {
BufferEntry,
BufferRepository,
serializeSubjectiveBufferEntry,
} from "@omnia/memory";
/**
* Zod schema for the structured response expected from the actor LLM.
*
* The actor emits free narrative prose describing what it does next. This
* prose is subsequently fed into the IntentDecoder, which splits and
* classifies it into dialogue / action / monologue intents. Keeping the
* actor's output as prose (rather than a structured intent sequence) lets
* us reuse the entire existing decode pipeline unchanged.
*/
export const ActorResponseSchema = z.object({
narrativeProse: z.string(),
});
export type ActorResponse = z.infer<typeof ActorResponseSchema>;
/**
* Builds the LLM prompt for an entity to act immersively in the world.
*
* The prompt is strictly epistemically bounded: the entity only sees what
* it is allowed to see (public attributes + private attributes explicitly
* ACL'd to it), its own recent memory buffer, and the entities co-located
* with it. System UUIDs are surfaced as subjective aliases.
*/
export class ActorPromptBuilder {
/**
* @param bufferRepo Used to fetch the actor's recent memory. Optional —
* if absent, the memory section is omitted.
* @param memoryLimit Maximum number of recent buffer entries to inject.
* Defaults to 20.
*/
constructor(
private bufferRepo?: BufferRepository,
private memoryLimit = 20,
) {}
/**
* Assembles the system prompt and user context for a given entity.
*/
build(
worldState: WorldState,
entity: Entity,
): { systemPrompt: string; userContext: string } {
const systemPrompt = this.buildSystemPrompt();
const userContext = this.buildUserContext(worldState, entity);
return { systemPrompt, userContext };
}
private buildSystemPrompt(): string {
return `
You are an actor agent embodying a single character in a narrative simulation. You ARE this character — act immersively, naturally, and in-character at all times. Do not break character, do not reference being an AI or a system, and do not narrate from outside the character's perspective.
Your output is a short block of narrative prose describing what your character does, says, or thinks next. You may:
- Speak aloud → this becomes a "dialogue" intent. Other entities can hear it.
- Perform a physical or logical action → this becomes an "action" intent. It is subject to the world's physics and will be validated by the World Architect.
- Think internally / reflect / feel → this becomes a "monologue" intent. NO ONE else perceives it. It bypasses all validation and is written straight to your private memory. Use this for inner thoughts, doubts, plans, and feelings that you would not voice aloud.
Guidelines:
- Always write in the first person (e.g., "I do this", "I say", "I think").
- Only describe your character's own actions, spoken words, and internal reactions. Do NOT narrate or describe the environment, the room, your surroundings, or other characters' actions, as these are managed by the simulation engine.
- Stay strictly within what your character knows. If an attribute, entity, or fact is not present in your context below, your character does not know it — do not invent it or act on it.
- Refer to other entities by the subjective names/aliases given in your context, never by raw system IDs.
- Keep your prose vivid but concise. A single response may contain more than one intent (e.g., you may think, then speak, then act) — write them in natural narrative order.
- Not every response requires an outward action. It is perfectly valid to only think (a monologue) and do nothing perceivable.
- Never speak or act on another entity's behalf — you only control your own character.
".
`.trim();
}
private buildUserContext(worldState: WorldState, entity: Entity): string {
const sections: string[] = [];
// --- Subjective present time ---
const now = worldState.clock.get();
sections.push(
`=== CURRENT MOMENT ===\nIt is ${now.toISOString()} right now.`,
);
// --- Subjective world state (self + perceived entities + co-location) ---
sections.push(
`=== THE WORLD AS YOU PERCEIVE IT ===\n${serializeSubjectiveWorldState(worldState, entity.id)}`,
);
// --- Recent memory ---
const memorySection = this.buildMemorySection(
entity,
worldState.clock.get(),
);
if (memorySection) {
sections.push(memorySection);
}
return sections.join("\n\n");
}
private buildMemorySection(entity: Entity, now: Date): string | null {
if (!this.bufferRepo) return null;
let entries: BufferEntry[];
try {
entries = this.bufferRepo.listForOwner(entity.id);
} catch {
return null;
}
if (entries.length === 0) {
return `=== YOUR RECENT MEMORY ===\n(You have no memories yet.)`;
}
const recent = entries.slice(-this.memoryLimit);
const groupedLines: string[] = [];
let currentGroup: string | null = null;
for (const entry of recent) {
const serialized = serializeSubjectiveBufferEntry(entry, entity);
const when = naturalizeTime(now, new Date(entry.timestamp));
if (when !== currentGroup) {
currentGroup = when;
const header = when.charAt(0).toUpperCase() + when.slice(1);
groupedLines.push(header);
}
groupedLines.push(` - ${serialized}`);
}
return `=== YOUR RECENT MEMORY ===\n${groupedLines.join("\n")}`;
}
}

150
packages/actor/src/actor.ts Normal file
View File

@@ -0,0 +1,150 @@
import { Entity, WorldState } from "@omnia/core";
import { ILLMProvider } from "@omnia/llm";
import {
BufferEntry,
BufferRepository,
} from "@omnia/memory";
import {
Intent,
IntentDecoder,
IntentSequence,
} from "@omnia/intent";
import { ActorPromptBuilder, ActorResponseSchema } from "./actor-prompt-builder.js";
/**
* Interface to generate narrative prose for an actor.
* Allows switching between LLM generators and human CLI inputs.
*/
export interface IActorProseGenerator {
generate(entityId: string, systemPrompt: string, userContext: string): Promise<string>;
}
/**
* Default implementation of IActorProseGenerator using an LLM.
*/
export class LLMActorProseGenerator implements IActorProseGenerator {
constructor(private llmProvider: ILLMProvider) {}
async generate(entityId: string, systemPrompt: string, userContext: string): Promise<string> {
const response = await this.llmProvider.generateStructuredResponse({
systemPrompt,
userContext,
schema: ActorResponseSchema,
});
if (!response.success || !response.data) {
throw new Error(
`Actor generation failed for entity "${entityId}": ${response.error || "Unknown LLM error"}`,
);
}
return response.data.narrativeProse;
}
}
/**
* Result of a single actor turn.
*/
export interface ActorTurnResult {
/** The raw narrative prose the actor produced. */
narrativeProse: string;
/** The decoded intent sequence (split/classified from the prose). */
intents: IntentSequence;
}
/**
* The Actor Agent: embodies a single entity and generates its next beat of
* behavior as narrative prose, then decodes that prose into a structured
* intent sequence via the IntentDecoder.
*
* The actor itself does NOT mutate world state or write memory — that is
* the responsibility of the caller (who routes intents through the
* Architect and writes buffer entries). The actor only produces the
* proposal. This keeps the actor's role cleanly separated from
* validation and persistence.
*/
export class ActorAgent {
private promptBuilder: ActorPromptBuilder;
private decoder: IntentDecoder;
private generator: IActorProseGenerator;
private llmProvider: ILLMProvider;
constructor(
llmProvider: ILLMProvider | { actor: ILLMProvider; decoder: ILLMProvider },
bufferRepo?: BufferRepository,
memoryLimit?: number,
generator?: IActorProseGenerator,
) {
let actorProv: ILLMProvider;
let decoderProv: ILLMProvider;
if ("actor" in llmProvider && "decoder" in llmProvider) {
actorProv = llmProvider.actor;
decoderProv = llmProvider.decoder;
} else {
actorProv = llmProvider;
decoderProv = llmProvider;
}
this.promptBuilder = new ActorPromptBuilder(bufferRepo, memoryLimit);
this.decoder = new IntentDecoder(decoderProv);
this.generator = generator ?? new LLMActorProseGenerator(actorProv);
this.llmProvider = actorProv;
}
/**
* Has the entity produce its next beat of behavior.
*
* 1. Builds an epistemically-bounded prompt for the entity.
* 2. Asks the generator (LLM or human) for narrative prose.
* 3. Decodes the prose into a structured IntentSequence.
*/
async act(
worldState: WorldState,
entity: Entity,
): Promise<ActorTurnResult> {
const { systemPrompt, userContext } = this.promptBuilder.build(
worldState,
entity,
);
const narrativeProse = await this.generator.generate(
entity.id,
systemPrompt,
userContext,
);
const intents = await this.decoder.decode(
worldState,
entity.id,
narrativeProse,
);
return {
narrativeProse,
intents,
};
}
}
/**
* Helper: builds a BufferEntry for an intent produced on behalf of an
* entity. For "action" intents the caller should attach an `outcome`
* after the Architect has processed it; for "dialogue" and "monologue"
* intents no outcome is needed (dialogue is always valid; monologue
* bypasses validation entirely).
*/
export function buildBufferEntryForIntent(
intent: Intent,
timestamp: string,
locationId: string | null,
): BufferEntry {
return {
id: crypto.randomUUID(),
ownerId: intent.actorId,
timestamp,
locationId,
intent,
};
}

View File

@@ -0,0 +1,2 @@
export * from "./actor-prompt-builder.js";
export * from "./actor.js";

View File

@@ -0,0 +1,14 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist"
},
"include": ["src"],
"references": [
{ "path": "../core" },
{ "path": "../intent" },
{ "path": "../llm" },
{ "path": "../memory" }
]
}

View File

@@ -12,9 +12,23 @@ export class Architect {
private validator: LLMValidator;
private timeDeltaGenerator: TimeDeltaGenerator;
constructor(llmProvider: ILLMProvider, private repo?: SQLiteRepository) {
this.validator = new LLMValidator(llmProvider);
this.timeDeltaGenerator = new TimeDeltaGenerator(llmProvider);
constructor(
llmProvider: ILLMProvider | { validator: ILLMProvider; timedelta: ILLMProvider },
private repo?: SQLiteRepository,
) {
let valProv: ILLMProvider;
let timeProv: ILLMProvider;
if ("validator" in llmProvider && "timedelta" in llmProvider) {
valProv = llmProvider.validator;
timeProv = llmProvider.timedelta;
} else {
valProv = llmProvider;
timeProv = llmProvider;
}
this.validator = new LLMValidator(valProv);
this.timeDeltaGenerator = new TimeDeltaGenerator(timeProv);
}
/**
@@ -31,11 +45,26 @@ export class Architect {
/**
* Processes, validates, generates deltas, applies them to the world state,
* and persists the changes to the database.
*
* "monologue" intents are internal thoughts — they bypass validation and
* time-delta generation entirely: the clock does not advance, the world
* state is not mutated or persisted. The caller is responsible for writing
* the monologue to the actor's memory buffer.
*/
async processIntent(
worldState: WorldState,
intent: Intent,
): Promise<ProcessResult> {
// 0. Monologue intents are purely internal — short-circuit before any
// validation or world mutation.
if (intent.type === "monologue") {
return {
isValid: true,
reason: "Monologue intent bypasses validation (internal thought, not perceivable).",
timeDelta: { minutesToAdvance: 0, explanation: "Internal thought — no time elapsed." },
};
}
// 1. Validate the intent action
const validation = await this.validateIntent(worldState, intent);
if (!validation.isValid) {

View File

@@ -11,19 +11,13 @@ export const TimeDeltaSchema = z.object({
export type TimeDelta = z.infer<typeof TimeDeltaSchema>;
export interface IDeltaGenerator<T> {
generate(
worldState: WorldState,
intent: Intent,
): Promise<T>;
generate(worldState: WorldState, intent: Intent): Promise<T>;
}
export class TimeDeltaGenerator implements IDeltaGenerator<TimeDelta> {
constructor(private llmProvider: ILLMProvider) {}
async generate(
worldState: WorldState,
intent: Intent,
): Promise<TimeDelta> {
async generate(worldState: WorldState, intent: Intent): Promise<TimeDelta> {
const systemPrompt = `
You are the Time Delta Generator for the World Architect.
Your task is to judge how much time (in minutes) a proposed action would logically take to execute in the physical world.
@@ -57,9 +51,71 @@ Target IDs: ${intent.targetIds.join(", ") || "(None)"}
});
if (!response.success || !response.data) {
throw new Error(`Failed to generate time delta: ${response.error || "Unknown LLM error"}`);
throw new Error(
`Failed to generate time delta: ${response.error || "Unknown LLM error"}`,
);
}
return response.data;
}
}
export const AliasDeltaSchema = z.object({
alias: z.string(),
});
export type AliasDelta = z.infer<typeof AliasDeltaSchema>;
export class AliasDeltaGenerator {
constructor(private llmProvider: ILLMProvider) {}
/**
* Generates a natural, subjective descriptive alias for a target entity
* based on its visible attributes from the perspective of a viewer entity.
*/
async generate(
viewer: import("@omnia/core").Entity,
target: import("@omnia/core").Entity,
): Promise<string> {
const visibleAttrs = target.getVisibleAttributesFor(viewer.id);
const attrsStr = visibleAttrs
.map((a) => `* ${a.name}: ${a.getValue()}`)
.join("\n");
const systemPrompt = `
You are the Alias Delta Generator for the World Architect.
Your task is to generate a natural, subjective, descriptive alias (noun phrase) that a viewer entity would use to refer to a target entity they are seeing for the first time, based ONLY on the target entity's visible public attributes.
Rules:
1. The alias must be a simple, natural, subjective noun phrase.
2. Base the description strictly on the target's visible attributes. Do not invent details not present in the attributes.
3. Never use raw system IDs or UUIDs in the alias description.
4. Do not use the target's private name attribute unless they have explicit access to it (which is already filtered in the attributes list).
5. Keep the phrase very short. Not more than 5 words. These aliases can also be internal nicknames for those entities.
6. Return a structured JSON object containing:
- "alias": string representing the descriptive alias.
`.trim();
const userContext = `
Viewer Entity ID: ${viewer.id}
Target Entity ID: ${target.id}
Target's Visible Attributes:
${attrsStr || "(No visible attributes)"}
`.trim();
const response = await this.llmProvider.generateStructuredResponse({
systemPrompt,
userContext,
schema: AliasDeltaSchema,
});
if (!response.success || !response.data) {
throw new Error(
`Failed to generate alias delta: ${response.error || "Unknown LLM error"}`,
);
}
return response.data.alias;
}
}

View File

@@ -15,11 +15,23 @@ export class LLMValidator {
/**
* Validates an action intent against the objective world state.
*
* "monologue" intents must never reach this validator — they are internal
* thoughts that bypass validation entirely (see Architect.processIntent).
* This guard exists as a defensive safeguard.
*/
async validate(
worldState: WorldState,
intent: Intent,
): Promise<ValidationResult> {
// Defensive guard: monologue intents bypass validation.
if (intent.type === "monologue") {
return {
isValid: true,
reason: "Monologue intents are internal thoughts and bypass validation.",
};
}
const actor = worldState.getEntity(intent.actorId);
if (!actor) {
return {

View File

@@ -1,8 +1,8 @@
import { describe, test, expect } from "vitest";
import Database from "better-sqlite3";
import { WorldState, Entity, SQLiteRepository } from "@omnia/core";
import { WorldState, Entity, SQLiteRepository, AttributeVisibility } from "@omnia/core";
import { MockLLMProvider } from "@omnia/llm";
import { Architect } from "@omnia/architect";
import { Architect, AliasDeltaGenerator } from "@omnia/architect";
import { Intent } from "@omnia/intent";
describe("Architect & LLMValidator Unit Tests (Tier 1)", () => {
@@ -172,3 +172,25 @@ describe("TimeDeltaGenerator & Architect.processIntent Unit Tests (Tier 1)", ()
db.close();
});
});
describe("AliasDeltaGenerator Unit Tests (Tier 1)", () => {
test("successfully generates descriptive alias based on visible attributes", async () => {
const world = new WorldState("world-1");
const viewer = new Entity("viewer-1");
const target = new Entity("target-1");
target.addAttribute("appearance", "A tall elf with silver hair", AttributeVisibility.PUBLIC);
target.addAttribute("clothing", "A green tunic", AttributeVisibility.PUBLIC);
world.addEntity(viewer);
world.addEntity(target);
const mockResponse = {
alias: "the tall silver-haired elf in the green tunic",
};
const llmProvider = new MockLLMProvider([mockResponse]);
const generator = new AliasDeltaGenerator(llmProvider);
const result = await generator.generate(viewer, target);
expect(result).toBe("the tall silver-haired elf in the green tunic");
});
});

View File

@@ -17,3 +17,90 @@ export class WorldClock {
return new WorldClock(new Date(iso));
}
}
/**
* Naturalizes a standard datetime format into a psychologically realistic subjective duration.
* Resolves quantized time formats into natural language phrases based on the relationship
* between a reference/current time and a past event time.
*/
export function naturalizeTime(now: Date, past: Date): string {
const deltaMs = now.getTime() - past.getTime();
// Guard against future dates
if (deltaMs <= 0) {
return "just now";
}
const deltaSeconds = Math.floor(deltaMs / 1000);
const deltaMinutes = Math.floor(deltaSeconds / 60);
const deltaHours = Math.floor(deltaMinutes / 60);
const deltaDays = Math.floor(deltaHours / 24);
// --- Tier 1: Pure Relative (delta < 6 hours) ---
if (deltaHours < 6) {
if (deltaMinutes < 1) return "just now";
if (deltaMinutes < 3) return "moments ago";
if (deltaMinutes < 10) return "a few minutes ago";
if (deltaMinutes < 30) return "several minutes ago";
if (deltaMinutes < 45) return "about half an hour ago";
if (deltaHours < 1.5) return "about an hour ago";
if (deltaHours < 3) return "a couple hours ago";
return "a few hours ago";
}
// --- Tier 3: Coarse (delta >= 48 hours / 2 days) ---
if (deltaDays >= 2) {
if (deltaDays < 3) return "a couple days ago";
if (deltaDays < 7) return "a few days ago";
if (deltaDays < 14) return "about a week ago";
if (deltaDays < 21) return "a couple weeks ago";
if (deltaDays < 30) return "a few weeks ago";
if (deltaDays < 60) return "about a month ago";
if (deltaDays < 90) return "a couple months ago";
if (deltaDays < 180) return "a few months ago";
if (deltaDays < 365) return "many months ago";
if (deltaDays < 730) return "about a year ago";
return "years ago";
}
// --- Tier 2: Period-Anchored (6 hours <= delta < 48 hours) ---
const pastHour = past.getHours();
let period: string;
if (pastHour >= 5 && pastHour < 12) {
period = "morning";
} else if (pastHour >= 12 && pastHour < 17) {
period = "afternoon";
} else if (pastHour >= 17 && pastHour < 21) {
period = "evening";
} else if (pastHour >= 21 && pastHour < 24) {
period = "night";
} else if (pastHour >= 0 && pastHour < 3) {
period = "midnight";
} else {
period = "late night";
}
// Subjective day test: waking hours (05:00 - 20:59)
const nowHour = now.getHours();
const pastIsWaking = pastHour >= 5 && pastHour < 22;
const nowIsWaking = nowHour >= 5 && nowHour < 22;
const isSameSubjectiveDay = pastIsWaking && nowIsWaking && deltaHours < 18;
if (isSameSubjectiveDay) {
return `earlier today, in the ${period}`;
}
if (period === "night") {
return "last night";
}
if (period === "midnight") {
return "around midnight";
}
if (period === "late night") {
return "late last night";
}
return `yesterday ${period}`;
}

View File

@@ -2,6 +2,7 @@ import { AttributableObject } from "./attribute.js";
export class Entity extends AttributableObject {
locationId: string | null = null;
readonly aliases: Map<string, string> = new Map();
constructor(id?: string, locationId?: string | null) {
super(id);

View File

@@ -4,6 +4,8 @@ import { AttributableObject, AttributeVisibility } from "./attribute.js";
import { Entity } from "./entity.js";
import { WorldState } from "./world.js";
class GenericObject extends AttributableObject {}
export class SQLiteRepository {
private db: Database.Database;
@@ -22,6 +24,7 @@ export class SQLiteRepository {
world_id TEXT,
clock_iso TEXT,
location_id TEXT,
aliases_json TEXT,
FOREIGN KEY (world_id) REFERENCES objects(id) ON DELETE CASCADE
);
@@ -56,6 +59,20 @@ export class SQLiteRepository {
} catch {
// Column already exists, ignore error
}
// Safely add aliases_json column if it does not exist in an existing database
try {
this.db.exec("ALTER TABLE objects ADD COLUMN aliases_json TEXT;");
} catch {
// Column already exists, ignore error
}
// Safely add connections_json column if it does not exist in an existing database
try {
this.db.exec("ALTER TABLE objects ADD COLUMN connections_json TEXT;");
} catch {
// Column already exists, ignore error
}
}
save(obj: AttributableObject, type: string, worldId?: string): void {
@@ -66,24 +83,39 @@ export class SQLiteRepository {
}
let locationId: string | null = null;
let aliasesJson: string | null = null;
let connectionsJson: string | null = null;
if (obj instanceof Entity) {
locationId = obj.locationId;
aliasesJson = JSON.stringify(Array.from(obj.aliases.entries()));
}
// Check if it's a location (using duck typing to avoid circular import of Location)
if (type === "location") {
const loc = obj as { parentId?: string | null; connections?: unknown[] };
locationId = loc.parentId ?? null;
if (loc.connections) {
connectionsJson = JSON.stringify(loc.connections);
}
}
// 1. Insert or ignore the object in the objects table
this.db
.prepare(
`
INSERT INTO objects (id, type, world_id, clock_iso, location_id)
VALUES (?, ?, ?, ?, ?)
INSERT INTO objects (id, type, world_id, clock_iso, location_id, aliases_json, connections_json)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
type = excluded.type,
world_id = excluded.world_id,
clock_iso = excluded.clock_iso,
location_id = excluded.location_id
location_id = excluded.location_id,
aliases_json = excluded.aliases_json,
connections_json = excluded.connections_json
`,
)
.run(obj.id, type, worldId || null, clockIso, locationId);
.run(obj.id, type, worldId || null, clockIso, locationId, aliasesJson, connectionsJson);
// Get current attributes from db to delete the ones that are no longer present
const existingAttrs = this.db
@@ -155,12 +187,19 @@ export class SQLiteRepository {
this.save(entity, "entity", worldId);
}
saveLocation(location: AttributableObject, worldId?: string): void {
this.save(location, "location", worldId);
}
saveWorldState(worldState: WorldState): void {
const saveWorldTx = this.db.transaction(() => {
this.save(worldState, "world");
for (const entity of worldState.entities.values()) {
this.saveEntity(entity, worldState.id);
}
for (const location of worldState.locations.values()) {
this.saveLocation(location, worldState.id);
}
});
saveWorldTx();
}
@@ -169,20 +208,74 @@ export class SQLiteRepository {
const objRow = this.db
.prepare(
`
SELECT type, location_id FROM objects WHERE id = ?
SELECT type, location_id, aliases_json FROM objects WHERE id = ?
`,
)
.get(id) as { type: string; location_id: string | null } | undefined;
.get(id) as { type: string; location_id: string | null; aliases_json: string | null } | undefined;
if (!objRow || objRow.type !== "entity") {
return null;
}
const entity = new Entity(id, objRow.location_id);
if (objRow.aliases_json) {
const entries = JSON.parse(objRow.aliases_json) as [string, string][];
for (const [k, v] of entries) {
entity.aliases.set(k, v);
}
}
this.reconstituteAttributes(entity);
return entity;
}
loadLocation<T extends AttributableObject>(
id: string,
factory: (id: string, parentId: string | null) => T,
): T | null {
const objRow = this.db
.prepare(
`
SELECT type, location_id, connections_json FROM objects WHERE id = ?
`,
)
.get(id) as { type: string; location_id: string | null; connections_json: string | null } | undefined;
if (!objRow || objRow.type !== "location") {
return null;
}
const location = factory(id, objRow.location_id);
if (objRow.connections_json) {
(location as { connections?: unknown[] }).connections = JSON.parse(objRow.connections_json);
}
this.reconstituteAttributes(location);
return location;
}
listLocations<T extends AttributableObject>(
worldId: string,
factory: (id: string, parentId: string | null) => T,
): T[] {
const rows = this.db
.prepare(
`
SELECT id, location_id, connections_json FROM objects WHERE type = 'location' AND world_id = ?
`,
)
.all(worldId) as { id: string; location_id: string | null; connections_json: string | null }[];
const locations: T[] = [];
for (const row of rows) {
const loc = factory(row.id, row.location_id);
if (row.connections_json) {
(loc as { connections?: unknown[] }).connections = JSON.parse(row.connections_json);
}
this.reconstituteAttributes(loc);
locations.push(loc);
}
return locations;
}
loadWorldState(id: string): WorldState | null {
const objRow = this.db
.prepare(
@@ -200,17 +293,42 @@ export class SQLiteRepository {
const worldState = new WorldState(id, startTime);
this.reconstituteAttributes(worldState);
// Reconstitute all locations belonging to this world
const locationRows = this.db
.prepare(
`
SELECT id, location_id, connections_json FROM objects WHERE type = 'location' AND world_id = ?
`,
)
.all(id) as { id: string; location_id: string | null; connections_json: string | null }[];
for (const row of locationRows) {
const loc = new GenericObject(row.id);
(loc as { parentId?: string | null }).parentId = row.location_id;
if (row.connections_json) {
(loc as { connections?: unknown[] }).connections = JSON.parse(row.connections_json);
}
this.reconstituteAttributes(loc);
worldState.addLocation(loc);
}
// Reconstitute all entities belonging to this world
const entityRows = this.db
.prepare(
`
SELECT id, location_id FROM objects WHERE type = 'entity' AND world_id = ?
SELECT id, location_id, aliases_json FROM objects WHERE type = 'entity' AND world_id = ?
`,
)
.all(id) as { id: string; location_id: string | null }[];
.all(id) as { id: string; location_id: string | null; aliases_json: string | null }[];
for (const row of entityRows) {
const entity = new Entity(row.id, row.location_id);
if (row.aliases_json) {
const entries = JSON.parse(row.aliases_json) as [string, string][];
for (const [k, v] of entries) {
entity.aliases.set(k, v);
}
}
this.reconstituteAttributes(entity);
worldState.addEntity(entity);
}
@@ -222,14 +340,20 @@ export class SQLiteRepository {
const rows = this.db
.prepare(
`
SELECT id FROM objects WHERE type = 'entity'
SELECT id, aliases_json FROM objects WHERE type = 'entity'
`,
)
.all() as { id: string }[];
.all() as { id: string; aliases_json: string | null }[];
const entities: Entity[] = [];
for (const row of rows) {
const entity = new Entity(row.id);
if (row.aliases_json) {
const entries = JSON.parse(row.aliases_json) as [string, string][];
for (const [k, v] of entries) {
entity.aliases.set(k, v);
}
}
this.reconstituteAttributes(entity);
entities.push(entity);
}

View File

@@ -1,4 +1,4 @@
import { AttributableObject, serializeAttributes } from "./attribute.js";
import { AttributableObject, Attribute, serializeAttributes } from "./attribute.js";
import { Entity } from "./entity.js";
import { WorldClock } from "./clock.js";
@@ -8,6 +8,7 @@ export class WorldState extends AttributableObject {
* Universe's current state (distinct from how it started)
*/
readonly entities: Map<string, Entity> = new Map();
readonly locations: Map<string, AttributableObject> = new Map();
readonly clock: WorldClock;
constructor(id?: string, startTime?: Date) {
@@ -27,6 +28,19 @@ export class WorldState extends AttributableObject {
getEntity(id: string): Entity | undefined {
return this.entities.get(id);
}
addLocation(location: AttributableObject): void {
if (this.locations.has(location.id)) {
throw new Error(
`Location with ID ${location.id} already exists in the world`,
);
}
this.locations.set(location.id, location);
}
getLocation(id: string): AttributableObject | undefined {
return this.locations.get(id);
}
}
/**
@@ -43,6 +57,45 @@ export function serializeObjectiveWorldState(worldState: WorldState): string {
lines.push(worldAttrsStr.split("\n").map(l => " " + l).join("\n"));
}
// Serialize locations and their attributes/portals
lines.push("Locations:");
if (worldState.locations.size > 0) {
for (const loc of worldState.locations.values()) {
lines.push(` - Location [ID: ${loc.id}]:`);
const parentId = (loc as { parentId?: string | null }).parentId;
if (parentId) {
lines.push(` * Parent Location ID: ${parentId}`);
}
if (loc.attributes.size > 0) {
const locAttrsStr = serializeAttributes(Array.from(loc.attributes.values()));
lines.push(locAttrsStr.split("\n").map(l => " " + l).join("\n"));
} else {
lines.push(" * (No attributes)");
}
const connections = (loc as { connections?: unknown[] }).connections as {
targetId: string;
portalName?: string;
portalStateDescriptor?: string;
visionProp: number;
soundProp: number;
bidirectional: boolean;
}[] | undefined;
if (connections && connections.length > 0) {
lines.push(" * Connections:");
for (const conn of connections) {
const portalStr = conn.portalName ? ` via ${conn.portalName} (${conn.portalStateDescriptor || "normal"})` : "";
lines.push(` -> To: ${conn.targetId}${portalStr} (Vision: ${conn.visionProp}, Sound: ${conn.soundProp})`);
}
}
}
} else {
lines.push(" (No locations)");
}
// Serialize entities and their attributes
lines.push("Entities:");
if (worldState.entities.size > 0) {
@@ -64,3 +117,113 @@ export function serializeObjectiveWorldState(worldState: WorldState): string {
return lines.join("\n");
}
/**
* Resolves how a viewer subjectively refers to a target entity.
* - Self → "you"
* - Known (in the viewer's alias map) → the subjective alias
* - Unknown → "an unfamiliar figure"
*
* Mirrors the implementation in @omnia/memory's resolveAlias, inlined here
* to avoid a circular dependency (memory depends on core).
*/
function resolveAliasViewer(viewer: Entity, targetId: string): string {
if (targetId === viewer.id) return "you";
return viewer.aliases.get(targetId) ?? "an unfamiliar figure";
}
/**
* Serializes a single attribute the way a viewer perceives it — name and
* value only, no visibility/ACL metadata (the viewer already sees only
* what they're allowed to see).
*/
function serializeVisibleAttributes(attrs: Attribute[]): string {
if (attrs.length === 0) return "(No perceivable attributes)";
return attrs.map((a) => `* ${a.name}: ${a.getValue()}`).join("\n");
}
/**
* Subjective world-state serializer for actor/agent prompts.
*
* Epistemic opposite of serializeObjectiveWorldState: renders the world
* strictly as it appears to a given viewer entity. Only attributes the
* viewer has access to (via Attribute.hasAccess) are shown; system UUIDs
* are replaced by subjective aliases ("you", known names, or
* "an unfamiliar figure"). Co-located entities (sharing the viewer's
* locationId) are included; entities elsewhere are listed only as
* presences (name/alias) without their attributes, since the viewer
* cannot perceive them in detail without a location model.
*/
export function serializeSubjectiveWorldState(
worldState: WorldState,
viewerId: string,
): string {
const viewer = worldState.getEntity(viewerId);
if (!viewer) {
return `(Viewer entity "${viewerId}" not found in world state.)`;
}
const lines: string[] = [];
const viewerAlias = resolveAliasViewer(viewer, viewerId);
// --- World attributes (only those the viewer can see) ---
const worldVisible = worldState.getVisibleAttributesFor(viewerId);
if (worldVisible.length > 0) {
lines.push("World (as you know it):");
lines.push(serializeVisibleAttributes(worldVisible).split("\n").map((l) => " " + l).join("\n"));
}
// --- Self ---
lines.push(`Self (${viewerAlias}):`);
const selfVisible = viewer.getVisibleAttributesFor(viewerId);
lines.push(serializeVisibleAttributes(selfVisible).split("\n").map((l) => " " + l).join("\n"));
// --- Location / perceived entities ---
lines.push("What you perceive around you:");
if (viewer.locationId) {
lines.push(` You are at location: ${viewer.locationId}`);
const location = worldState.getLocation(viewer.locationId);
if (location) {
const locVisible = location.getVisibleAttributesFor(viewerId);
if (locVisible.length > 0) {
lines.push(" Location attributes:");
lines.push(serializeVisibleAttributes(locVisible).split("\n").map((l) => " " + l).join("\n"));
}
}
} else {
lines.push(" You are not located anywhere in particular.");
}
const coLocated: Entity[] = [];
const elsewhere: Entity[] = [];
for (const e of worldState.entities.values()) {
if (e.id === viewerId) continue;
if (e.locationId !== null && e.locationId === viewer.locationId) {
coLocated.push(e);
} else {
elsewhere.push(e);
}
}
if (coLocated.length > 0) {
lines.push(" Entities present with you:");
for (const e of coLocated) {
const alias = resolveAliasViewer(viewer, e.id);
lines.push(` - ${alias}:`);
const eVisible = e.getVisibleAttributesFor(viewerId);
lines.push(serializeVisibleAttributes(eVisible).split("\n").map((l) => " " + l).join("\n"));
}
} else {
lines.push(" You are alone here.");
}
if (elsewhere.length > 0) {
lines.push(" Other presences you are aware of (elsewhere):");
for (const e of elsewhere) {
const alias = resolveAliasViewer(viewer, e.id);
lines.push(` - ${alias} [elsewhere]`);
}
}
return lines.join("\n");
}

View File

@@ -0,0 +1,96 @@
import { describe, test, expect } from "vitest";
import { naturalizeTime } from "../src/clock.js";
describe("TimeNaturalization Unit Tests (Tier 1)", () => {
test("future or current times return just now", () => {
const now = new Date(2026, 6, 8, 12, 0, 0);
const pastFuture = new Date(2026, 6, 8, 12, 0, 5);
expect(naturalizeTime(now, pastFuture)).toBe("just now");
expect(naturalizeTime(now, now)).toBe("just now");
});
test("Tier 1: relative time ranges (< 6 hours)", () => {
const now = new Date(2026, 6, 8, 12, 0, 0);
// < 1 minute
expect(naturalizeTime(now, new Date(2026, 6, 8, 11, 59, 30))).toBe("just now");
// < 3 minutes
expect(naturalizeTime(now, new Date(2026, 6, 8, 11, 58, 0))).toBe("moments ago");
// < 10 minutes
expect(naturalizeTime(now, new Date(2026, 6, 8, 11, 52, 0))).toBe("a few minutes ago");
// < 30 minutes
expect(naturalizeTime(now, new Date(2026, 6, 8, 11, 40, 0))).toBe("several minutes ago");
// < 45 minutes
expect(naturalizeTime(now, new Date(2026, 6, 8, 11, 20, 0))).toBe("about half an hour ago");
// < 1.5 hours (90 minutes)
expect(naturalizeTime(now, new Date(2026, 6, 8, 10, 50, 0))).toBe("about an hour ago");
// < 3 hours
expect(naturalizeTime(now, new Date(2026, 6, 8, 9, 30, 0))).toBe("a couple hours ago");
// < 6 hours
expect(naturalizeTime(now, new Date(2026, 6, 8, 7, 0, 0))).toBe("a few hours ago");
});
test("Tier 2: Same subjective day vs yesterday periods (6h <= delta < 48h)", () => {
// 1. Same subjective day (both waking hours, delta < 18h)
// 9am to 3pm (delta 6h) -> morning
const nowWaking = new Date(2026, 6, 8, 15, 0, 0);
const pastMorning = new Date(2026, 6, 8, 9, 0, 0);
expect(naturalizeTime(nowWaking, pastMorning)).toBe("earlier today, in the morning");
// 2pm to 9pm (delta 7h) -> afternoon
const nowEvening = new Date(2026, 6, 8, 21, 0, 0);
const pastAfternoon = new Date(2026, 6, 8, 14, 0, 0);
expect(naturalizeTime(nowEvening, pastAfternoon)).toBe("earlier today, in the afternoon");
// 2. Previous night (past period: night (21-23))
// 11pm to 5am (delta 6h) -> last night
const nowNightRun = new Date(2026, 6, 8, 5, 0, 0);
const pastNight = new Date(2026, 6, 7, 23, 0, 0);
expect(naturalizeTime(nowNightRun, pastNight)).toBe("last night");
// 3. Around midnight (past period: midnight (0-2))
// 1am to 7am (delta 6h) -> around midnight
const nowMidnightRun = new Date(2026, 6, 8, 7, 0, 0);
const pastMidnight = new Date(2026, 6, 8, 1, 0, 0);
expect(naturalizeTime(nowMidnightRun, pastMidnight)).toBe("around midnight");
// 4. Late night (past period: late night (3-4))
// 3am to 9am (delta 6h) -> late last night
const nowLateNightRun = new Date(2026, 6, 8, 9, 0, 0);
const pastLateNight = new Date(2026, 6, 8, 3, 0, 0);
expect(naturalizeTime(nowLateNightRun, pastLateNight)).toBe("late last night");
// 5. Yesterday waking hours (delta >= 6h, diff day / waking check fails)
// 3pm to 9am next day (delta 18h) -> yesterday afternoon
const nowNextDay = new Date(2026, 6, 9, 9, 0, 0);
const pastYesterdayAfternoon = new Date(2026, 6, 8, 15, 0, 0);
expect(naturalizeTime(nowNextDay, pastYesterdayAfternoon)).toBe("yesterday afternoon");
});
test("Tier 3: Coarse relative time ranges (delta >= 48 hours)", () => {
const now = new Date(2026, 6, 10, 12, 0, 0);
// 2 days
expect(naturalizeTime(now, new Date(2026, 6, 8, 11, 0, 0))).toBe("a couple days ago");
// 3-6 days
expect(naturalizeTime(now, new Date(2026, 6, 6, 12, 0, 0))).toBe("a few days ago");
// 7-13 days
expect(naturalizeTime(now, new Date(2026, 6, 1, 12, 0, 0))).toBe("about a week ago");
// 14-20 days
expect(naturalizeTime(now, new Date(2026, 5, 25, 12, 0, 0))).toBe("a couple weeks ago");
// 21-29 days
expect(naturalizeTime(now, new Date(2026, 5, 15, 12, 0, 0))).toBe("a few weeks ago");
// 30-59 days
expect(naturalizeTime(now, new Date(2026, 5, 1, 12, 0, 0))).toBe("about a month ago");
// 60-89 days
expect(naturalizeTime(now, new Date(2026, 4, 1, 12, 0, 0))).toBe("a couple months ago");
// 90-179 days
expect(naturalizeTime(now, new Date(2026, 3, 1, 12, 0, 0))).toBe("a few months ago");
// 180-364 days
expect(naturalizeTime(now, new Date(2026, 0, 1, 12, 0, 0))).toBe("many months ago");
// 365-729 days
expect(naturalizeTime(now, new Date(2025, 6, 1, 12, 0, 0))).toBe("about a year ago");
// >= 730 days
expect(naturalizeTime(now, new Date(2023, 6, 1, 12, 0, 0))).toBe("years ago");
});
});

View File

@@ -171,6 +171,26 @@ describe("SQLiteRepository Unit Tests (Tier 1)", () => {
db.close();
});
test("Save and load entity aliases", () => {
const db = new Database(":memory:");
const repo = new SQLiteRepository(db);
const world = new WorldState("world-xyz");
const alice = new Entity("alice");
alice.aliases.set("bob", "the hooded man");
alice.aliases.set("charlie", "the baker");
world.addEntity(alice);
repo.saveWorldState(world);
const loadedWorld = repo.loadWorldState("world-xyz")!;
const loadedAlice = loadedWorld.getEntity("alice")!;
expect(loadedAlice.aliases.get("bob")).toBe("the hooded man");
expect(loadedAlice.aliases.get("charlie")).toBe("the baker");
db.close();
});
});
describe("Serializer & serializeObjectiveWorldState Unit Tests (Tier 1)", () => {

View File

@@ -10,7 +10,7 @@ export class IntentDecoder {
*
* Responsibilities (from docs/intents.md):
* - Split prose into multiple intents when applicable.
* - Classify each intent as "dialogue" or "action".
* - Classify each intent as "dialogue", "action", or "monologue".
* - Parse narrative text into structured JSON with minimal information loss.
* - Contextually resolve receiving parties (targets).
*/
@@ -20,6 +20,12 @@ export class IntentDecoder {
narrativeProse: string,
): Promise<IntentSequence> {
const entityIds = Array.from(worldState.entities.keys());
const actor = worldState.getEntity(actorId);
const aliasEntries = actor ? Array.from(actor.aliases.entries()) : [];
const aliasContext = aliasEntries.length > 0
? aliasEntries.map(([targetId, alias]) => `- "${alias}" refers to entity ID: "${targetId}"`).join("\n")
: "(No known aliases)";
const systemPrompt = `
You are the Intent Decoder for a narrative simulation engine.
@@ -29,10 +35,11 @@ For each intent you must:
1. Classify its type:
- "dialogue": Any speech, conversation, or verbal communication directed at another entity.
- "action": Any physical or logical action performed in the world (e.g., moving, picking up, opening, looking).
- "monologue": An inner thought, reflection, or internal monologue. This is purely internal — not spoken aloud, not perceivable by any other entity, and not a physical action. Use this for any prose depicting the character thinking, reflecting, feeling, or narrating to themselves internally.
2. Extract the original text fragment from the prose that corresponds to this intent.
3. Write a concise, structured description of the intent (what is being done or said). Include as much detail about the action as possible that was extracted from the narrative prose. Do not make up qualities.
4. Identify the actorId (the entity performing the intent — this will always be "${actorId}").
5. Identify targetIds — the entity IDs of the receiving parties. Use only IDs from the known entities list. If no specific target, use an empty array.
5. Identify targetIds — the entity IDs of the receiving parties. Use the "KNOWN ENTITY IDS" and "ACTOR ALIASES" mapping to resolve any subjective names, descriptions, or nicknames used in the prose to their correct system entity IDs. If no specific target, use an empty array. For "monologue" intents, targetIds must always be an empty array.
Rules:
- Preserve the chronological order of intents as they appear in the prose.
@@ -46,6 +53,10 @@ Rules:
=== KNOWN ENTITY IDS ===
${entityIds.length > 0 ? entityIds.join(", ") : "(No entities)"}
=== ACTOR ALIASES ===
The actor refers to other entities using these subjective names/aliases:
${aliasContext}
=== WORLD STATE ===
${serializeObjectiveWorldState(worldState)}

View File

@@ -4,8 +4,11 @@ import { z } from "zod";
* Intent types as classified by the Intent Decoder.
* - "dialogue": Speech or conversation directed at another entity.
* - "action": A physical or logical action performed in the world.
* - "monologue": An inner thought or internal monologue. Not perceivable by
* any other entity. Bypasses the Architect/validators entirely and is
* written directly to the actor's memory buffer with no outcome.
*/
export const IntentTypeSchema = z.enum(["dialogue", "action"]);
export const IntentTypeSchema = z.enum(["dialogue", "action", "monologue"]);
export type IntentType = z.infer<typeof IntentTypeSchema>;
/**
@@ -26,7 +29,8 @@ export const IntentSchema = z.object({
/**
* Entity IDs of the receiving parties (e.g., who is being spoken to,
* what object is being interacted with).
* what object is being interacted with). Always an empty array for
* "monologue" intents, since they are not perceivable by anyone.
*/
targetIds: z.array(z.string()),
});

View File

@@ -7,6 +7,10 @@
".": "./dist/index.js"
},
"dependencies": {
"@types/node": "^26.1.0"
"@types/node": "^26.1.0",
"better-sqlite3": "^12.11.1"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.13"
}
}

View File

@@ -2,3 +2,4 @@ export * from "./llm.js";
export * from "./config.js";
export * from "./providers/google-genai.js";
export * from "./providers/mock.js";
export * from "./provider-manager.js";

View File

@@ -11,6 +11,21 @@ export interface LLMResponse<T> {
success: boolean;
data?: T;
error?: string;
usage?: {
inputTokens: number;
outputTokens: number;
totalTokens: number;
};
}
export interface LLMCallRecord {
systemPrompt: string;
userContext: string;
usage?: {
inputTokens: number;
outputTokens: number;
totalTokens: number;
};
}
export interface ILLMProvider {
@@ -19,4 +34,14 @@ export interface ILLMProvider {
generateStructuredResponse<T extends z.ZodTypeAny>(
request: LLMRequest<T>,
): Promise<LLMResponse<z.infer<T>>>;
lastCalls?: LLMCallRecord[];
}
export interface LLMProviderInstance {
id: string;
name: string;
providerName: string;
apiKey: string;
isActive: boolean;
modelName?: string;
}

View File

@@ -0,0 +1,254 @@
import Database from "better-sqlite3";
import path from "path";
import fs from "fs";
import type { LLMProviderInstance } from "./llm.js";
function getWorkspaceRoot() {
let current = process.cwd();
while (current !== "/" && current !== path.parse(current).root) {
if (
fs.existsSync(path.join(current, "pnpm-workspace.yaml")) ||
fs.existsSync(path.join(current, "package.json"))
) {
if (fs.existsSync(path.join(current, "pnpm-workspace.yaml"))) {
return current;
}
}
current = path.dirname(current);
}
return process.cwd();
}
function getSettingsDb() {
const wsRoot = getWorkspaceRoot();
const dbDir = path.resolve(wsRoot, "data");
if (!fs.existsSync(dbDir)) {
fs.mkdirSync(dbDir, { recursive: true });
}
const dbPath = path.join(dbDir, "settings.db");
const db = new Database(dbPath);
db.prepare(`
CREATE TABLE IF NOT EXISTS provider_instances (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
providerName TEXT NOT NULL,
apiKey TEXT NOT NULL,
isActive INTEGER NOT NULL DEFAULT 0,
modelName TEXT
)
`).run();
try {
db.prepare(`ALTER TABLE provider_instances ADD COLUMN modelName TEXT`).run();
} catch {
// ignore
}
return db;
}
export class ProviderManager {
static list(): LLMProviderInstance[] {
const db = getSettingsDb();
try {
const rows = db.prepare(`SELECT * FROM provider_instances`).all() as {
id: string;
name: string;
providerName: string;
apiKey: string;
isActive: number;
modelName?: string;
}[];
return rows.map((r) => ({
id: r.id,
name: r.name,
providerName: r.providerName,
apiKey: r.apiKey,
isActive: r.isActive === 1,
modelName: r.modelName || undefined,
}));
} finally {
db.close();
}
}
static create(name: string, providerName: string, apiKey: string, modelName?: string): LLMProviderInstance {
const db = getSettingsDb();
try {
const id = "provider-" + Date.now();
const activeCount = db.prepare(`SELECT COUNT(*) as count FROM provider_instances WHERE isActive = 1`).get() as { count: number };
const isActive = activeCount.count === 0 ? 1 : 0;
db.prepare(`
INSERT INTO provider_instances (id, name, providerName, apiKey, isActive, modelName)
VALUES (?, ?, ?, ?, ?, ?)
`).run(id, name, providerName, apiKey, isActive, modelName || null);
return { id, name, providerName, apiKey, isActive: isActive === 1, modelName };
} finally {
db.close();
}
}
static delete(id: string): void {
const db = getSettingsDb();
try {
const provider = db.prepare(`SELECT isActive FROM provider_instances WHERE id = ?`).get(id) as { isActive: number } | undefined;
db.prepare(`DELETE FROM provider_instances WHERE id = ?`).run(id);
if (provider && provider.isActive === 1) {
const next = db.prepare(`SELECT id FROM provider_instances LIMIT 1`).get() as { id: string } | undefined;
if (next) {
db.prepare(`UPDATE provider_instances SET isActive = 1 WHERE id = ?`).run(next.id);
}
}
} finally {
db.close();
}
}
static setActive(id: string): void {
const db = getSettingsDb();
try {
db.prepare(`UPDATE provider_instances SET isActive = 0`).run();
db.prepare(`UPDATE provider_instances SET isActive = 1 WHERE id = ?`).run(id);
} finally {
db.close();
}
}
static update(id: string, name: string, providerName: string, apiKey?: string, modelName?: string): void {
const db = getSettingsDb();
try {
if (apiKey && apiKey.trim()) {
db.prepare(`
UPDATE provider_instances
SET name = ?, providerName = ?, apiKey = ?, modelName = ?
WHERE id = ?
`).run(name, providerName, apiKey, modelName || null, id);
} else {
db.prepare(`
UPDATE provider_instances
SET name = ?, providerName = ?, modelName = ?
WHERE id = ?
`).run(name, providerName, modelName || null, id);
}
} finally {
db.close();
}
}
static getActive(): LLMProviderInstance | null {
const db = getSettingsDb();
try {
// Query the DB
const row = db.prepare(`SELECT * FROM provider_instances WHERE isActive = 1`).get() as {
id: string;
name: string;
providerName: string;
apiKey: string;
isActive: number;
modelName?: string;
} | undefined;
if (!row) {
// Check if there are any rows at all
const totalCount = db.prepare(`SELECT COUNT(*) as count FROM provider_instances`).get() as { count: number };
if (totalCount.count === 0) {
// Database is completely empty! Check if GOOGLE_API_KEY env is set.
const envKey = process.env.GOOGLE_API_KEY;
if (envKey && envKey.trim()) {
// Auto-bootstrap default active instance from env
const id = "provider-default-env";
db.prepare(`
INSERT INTO provider_instances (id, name, providerName, apiKey, isActive, modelName)
VALUES (?, ?, ?, ?, ?, ?)
`).run(id, "Default (Env)", "google-genai", envKey, 1, "gemini-2.5-flash");
return {
id,
name: "Default (Env)",
providerName: "google-genai",
apiKey: envKey,
isActive: true,
modelName: "gemini-2.5-flash",
};
}
}
return null;
}
return {
id: row.id,
name: row.name,
providerName: row.providerName,
apiKey: row.apiKey,
isActive: true,
modelName: row.modelName || undefined,
};
} catch {
// Lock or write issue fallback: return an in-memory active key if env key exists
const envKey = process.env.GOOGLE_API_KEY;
if (envKey) {
return {
id: "provider-default-env-fallback",
name: "Default (Env Fallback)",
providerName: "google-genai",
apiKey: envKey,
isActive: true,
modelName: "gemini-2.5-flash",
};
}
return null;
} finally {
db.close();
}
}
static getMappings(): Record<string, string> {
const db = getSettingsDb();
try {
db.prepare(`
CREATE TABLE IF NOT EXISTS provider_mappings (
task TEXT PRIMARY KEY,
providerInstanceId TEXT NOT NULL
)
`).run();
const rows = db.prepare(`SELECT * FROM provider_mappings`).all() as {
task: string;
providerInstanceId: string;
}[];
const mappings: Record<string, string> = {};
for (const row of rows) {
mappings[row.task] = row.providerInstanceId;
}
return mappings;
} finally {
db.close();
}
}
static setMapping(task: string, providerInstanceId: string): void {
const db = getSettingsDb();
try {
db.prepare(`
CREATE TABLE IF NOT EXISTS provider_mappings (
task TEXT PRIMARY KEY,
providerInstanceId TEXT NOT NULL
)
`).run();
if (!providerInstanceId) {
db.prepare(`DELETE FROM provider_mappings WHERE task = ?`).run(task);
} else {
db.prepare(`
INSERT INTO provider_mappings (task, providerInstanceId)
VALUES (?, ?)
ON CONFLICT(task) DO UPDATE SET providerInstanceId = excluded.providerInstanceId
`).run(task, providerInstanceId);
}
} finally {
db.close();
}
}
}

View File

@@ -1,31 +1,75 @@
import { z } from "zod";
import { ChatGoogleGenerativeAI } from "@langchain/google-genai";
import { ILLMProvider, LLMRequest, LLMResponse } from "../llm.js";
import { ILLMProvider, LLMRequest, LLMResponse, LLMCallRecord } from "../llm.js";
import { llmConfig } from "../config.js";
import { ProviderManager } from "../provider-manager.js";
export class GeminiProvider implements ILLMProvider {
providerName = "Gemini";
private model: ChatGoogleGenerativeAI;
lastCalls: LLMCallRecord[] = [];
constructor(apiKey?: string, modelName?: string) {
let key = apiKey;
let model = modelName;
if (!key) {
const active = ProviderManager.getActive();
if (active) {
key = active.apiKey;
if (!model) {
model = active.modelName;
}
}
}
if (!key) {
key = llmConfig.GOOGLE_API_KEY;
}
constructor(apiKey?: string) {
const key = apiKey || llmConfig.GOOGLE_API_KEY;
if (!key) {
throw new Error("GOOGLE_API_KEY is required to initialize GeminiProvider");
}
this.model = new ChatGoogleGenerativeAI({
apiKey: key,
model: "gemini-2.5-flash",
model: model || "gemini-2.5-flash",
});
}
async generateStructuredResponse<T extends z.ZodTypeAny>(
request: LLMRequest<T>,
): Promise<LLMResponse<z.infer<T>>> {
const structuredModel = this.model.withStructuredOutput(request.schema);
const result = await structuredModel.invoke([
const structuredModel = this.model.withStructuredOutput(request.schema, { includeRaw: true });
const result = (await structuredModel.invoke([
{ role: "system", content: request.systemPrompt },
{ role: "user", content: request.userContext },
]);
return { success: true, data: result as z.infer<T> };
])) as unknown as {
parsed?: z.infer<T>;
raw?: {
usage_metadata?: {
input_tokens?: number;
output_tokens?: number;
total_tokens?: number;
};
};
};
const parsed = result?.parsed;
const raw = result?.raw;
const usage = raw?.usage_metadata ? {
inputTokens: raw.usage_metadata.input_tokens || 0,
outputTokens: raw.usage_metadata.output_tokens || 0,
totalTokens: raw.usage_metadata.total_tokens || 0,
} : undefined;
this.lastCalls.push({
systemPrompt: request.systemPrompt,
userContext: request.userContext,
usage,
});
return { success: true, data: parsed, usage };
}
}

View File

@@ -1,9 +1,10 @@
import { z } from "zod";
import { ILLMProvider, LLMRequest, LLMResponse } from "../llm.js";
import { ILLMProvider, LLMRequest, LLMResponse, LLMCallRecord } from "../llm.js";
export class MockLLMProvider implements ILLMProvider {
providerName = "mock";
private callCount = 0;
lastCalls: LLMCallRecord[] = [];
constructor(private responses: unknown[]) {}
@@ -14,9 +15,15 @@ export class MockLLMProvider implements ILLMProvider {
if (next === undefined) {
return { success: false, error: "Mock responses exhausted" };
}
const usage = { inputTokens: 100, outputTokens: 50, totalTokens: 150 };
this.lastCalls.push({
systemPrompt: request.systemPrompt,
userContext: request.userContext,
usage,
});
try {
const parsed = request.schema.parse(next);
return { success: true, data: parsed };
return { success: true, data: parsed, usage };
} catch (e) {
return { success: false, error: e instanceof Error ? e.message : String(e) };
}

View File

@@ -5,5 +5,10 @@
"type": "module",
"exports": {
".": "./dist/index.js"
},
"dependencies": {
"@omnia/core": "workspace:*",
"@omnia/intent": "workspace:*",
"zod": "^4.4.3"
}
}

View File

@@ -0,0 +1,158 @@
import Database from "better-sqlite3";
import { Entity } from "@omnia/core";
import { Intent } from "@omnia/intent";
export interface BufferEntry {
id: string;
ownerId: string; // Whose subjective memory buffer this lives in
timestamp: string; // WorldClock.get().toISOString() at write time
locationId: string | null; // Actor's location when this happened
intent: Intent; // The actual dialogue/action intent, reused as-is
outcome?: {
// Present only for "action" intents processed by the Architect
isValid: boolean;
reason: string;
};
}
export function resolveAlias(viewer: Entity, targetId: string): string {
if (targetId === viewer.id) return "you";
return viewer.aliases.get(targetId) ?? "an unfamiliar figure";
}
export function serializeSubjectiveBufferEntry(
entry: BufferEntry,
viewer: Entity,
): string {
const actorAlias = resolveAlias(viewer, entry.intent.actorId);
const targetAliases = entry.intent.targetIds.map((tid) =>
resolveAlias(viewer, tid),
);
let details: string;
const content = entry.intent.description.trim() || entry.intent.originalText.trim();
if (entry.intent.type === "dialogue") {
details = `spoke to ${targetAliases.join(", ") || "someone"}: "${content}"`;
} else if (entry.intent.type === "monologue") {
details = `thought: "${content}"`;
} else {
details = content;
if (entry.outcome) {
details += ` (Outcome: ${entry.outcome.isValid ? "Succeeded" : `Failed - ${entry.outcome.reason}`})`;
}
}
return `${actorAlias} ${details}`;
}
export class BufferRepository {
constructor(private db: Database.Database) {
// Enable foreign keys for cascading deletes
this.db.exec("PRAGMA foreign_keys = ON;");
this.initializeSchema();
}
private initializeSchema(): void {
this.db.exec(`
CREATE TABLE IF NOT EXISTS buffer_entries (
id TEXT PRIMARY KEY,
owner_id TEXT NOT NULL,
timestamp TEXT NOT NULL,
location_id TEXT,
intent_json TEXT NOT NULL,
outcome_json TEXT,
FOREIGN KEY (owner_id) REFERENCES objects(id) ON DELETE CASCADE
);
`);
}
save(entry: BufferEntry): void {
this.db
.prepare(
`
INSERT INTO buffer_entries (id, owner_id, timestamp, location_id, intent_json, outcome_json)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
owner_id = excluded.owner_id,
timestamp = excluded.timestamp,
location_id = excluded.location_id,
intent_json = excluded.intent_json,
outcome_json = excluded.outcome_json
`,
)
.run(
entry.id,
entry.ownerId,
entry.timestamp,
entry.locationId,
JSON.stringify(entry.intent),
entry.outcome ? JSON.stringify(entry.outcome) : null,
);
}
load(id: string): BufferEntry | null {
const row = this.db
.prepare(
`
SELECT id, owner_id, timestamp, location_id, intent_json, outcome_json
FROM buffer_entries WHERE id = ?
`,
)
.get(id) as
| {
id: string;
owner_id: string;
timestamp: string;
location_id: string | null;
intent_json: string;
outcome_json: string | null;
}
| undefined;
if (!row) return null;
return {
id: row.id,
ownerId: row.owner_id,
timestamp: row.timestamp,
locationId: row.location_id,
intent: JSON.parse(row.intent_json),
outcome: row.outcome_json ? JSON.parse(row.outcome_json) : undefined,
};
}
listForOwner(ownerId: string): BufferEntry[] {
const rows = this.db
.prepare(
`
SELECT id, owner_id, timestamp, location_id, intent_json, outcome_json
FROM buffer_entries WHERE owner_id = ?
ORDER BY timestamp ASC
`,
)
.all(ownerId) as {
id: string;
owner_id: string;
timestamp: string;
location_id: string | null;
intent_json: string;
outcome_json: string | null;
}[];
return rows.map((row) => ({
id: row.id,
ownerId: row.owner_id,
timestamp: row.timestamp,
locationId: row.location_id,
intent: JSON.parse(row.intent_json),
outcome: row.outcome_json ? JSON.parse(row.outcome_json) : undefined,
}));
}
delete(id: string): void {
this.db.prepare(`DELETE FROM buffer_entries WHERE id = ?`).run(id);
}
}

View File

@@ -1 +1 @@
export {};
export * from "./buffer.js";

View File

@@ -0,0 +1,200 @@
import { describe, test, expect } from "vitest";
import Database from "better-sqlite3";
import { Entity, SQLiteRepository } from "@omnia/core";
import { Intent } from "@omnia/intent";
import {
BufferEntry,
BufferRepository,
serializeSubjectiveBufferEntry,
resolveAlias,
} from "@omnia/memory";
describe("Subjective Buffer Entry Serializer Tests (Tier 1)", () => {
test("resolveAlias correctly handles self and fallbacks", () => {
const viewer = new Entity("alice");
viewer.aliases.set("bob", "the hooded figure");
expect(resolveAlias(viewer, "alice")).toBe("you");
expect(resolveAlias(viewer, "bob")).toBe("the hooded figure");
expect(resolveAlias(viewer, "charlie")).toBe("an unfamiliar figure");
});
test("serializes dialogue intent substituting target/actor aliases", () => {
const viewer = new Entity("alice");
viewer.aliases.set("bob", "the hooded figure");
viewer.aliases.set("charlie", "the bartender");
const entry: BufferEntry = {
id: "entry-1",
ownerId: "alice",
timestamp: "2026-07-07T12:00:00.000Z",
locationId: "room-1",
intent: {
type: "dialogue",
originalText: '"Hello there," Bob said to Charlie.',
description: "Bob greets Charlie",
actorId: "bob",
targetIds: ["charlie"],
},
};
const result = serializeSubjectiveBufferEntry(entry, viewer);
expect(result).toBe('the hooded figure spoke to the bartender: "Bob greets Charlie"');
});
test("serializes action intent with outcome details", () => {
const viewer = new Entity("alice");
viewer.aliases.set("bob", "the hooded figure");
const entry: BufferEntry = {
id: "entry-2",
ownerId: "alice",
timestamp: "2026-07-07T12:05:00.000Z",
locationId: "room-1",
intent: {
type: "action",
originalText: "Bob tried to break the latch.",
description: "Bob attempts to break the lock latch",
actorId: "bob",
targetIds: [],
},
outcome: {
isValid: false,
reason: "The lock is made of reinforced steel.",
},
};
const result = serializeSubjectiveBufferEntry(entry, viewer);
expect(result).toBe('the hooded figure Bob attempts to break the lock latch (Outcome: Failed - The lock is made of reinforced steel.)');
});
test("serializes self-reference and unfamiliar actors", () => {
const viewer = new Entity("alice");
const entrySelf: BufferEntry = {
id: "entry-self",
ownerId: "alice",
timestamp: "2026-07-07T12:10:00.000Z",
locationId: "room-1",
intent: {
type: "action",
originalText: "I opened the window.",
description: "open the window",
actorId: "alice",
targetIds: [],
},
};
const resultSelf = serializeSubjectiveBufferEntry(entrySelf, viewer);
expect(resultSelf).toBe("you open the window");
const entryUnfamiliar: BufferEntry = {
id: "entry-unfamiliar",
ownerId: "alice",
timestamp: "2026-07-07T12:15:00.000Z",
locationId: "room-1",
intent: {
type: "action",
originalText: "Someone knocked.",
description: "knock on the door",
actorId: "stranger-1",
targetIds: [],
},
};
const resultUnfamiliar = serializeSubjectiveBufferEntry(entryUnfamiliar, viewer);
expect(resultUnfamiliar).toBe("an unfamiliar figure knock on the door");
});
});
describe("BufferRepository Persistence Tests (Tier 1)", () => {
test("saves, loads, lists, and deletes buffer entries in SQLite database", () => {
const db = new Database(":memory:");
// We need SQLiteRepository to initialize the objects table because buffer_entries depends on objects(id) via FK
const coreRepo = new SQLiteRepository(db);
const repo = new BufferRepository(db);
// Add owner entity to objects table
const alice = new Entity("alice");
coreRepo.saveEntity(alice);
const intent: Intent = {
type: "action",
originalText: "Alice picked up a stick.",
description: "Alice gathers a stick",
actorId: "alice",
targetIds: [],
};
const entry: BufferEntry = {
id: "buf-1",
ownerId: "alice",
timestamp: "2026-07-07T14:30:00.000Z",
locationId: "forest",
intent,
outcome: {
isValid: true,
reason: "There are many dry sticks on the ground.",
},
};
// Save
repo.save(entry);
// Load
const loaded = repo.load("buf-1");
expect(loaded).not.toBeNull();
expect(loaded!.id).toBe("buf-1");
expect(loaded!.ownerId).toBe("alice");
expect(loaded!.timestamp).toBe("2026-07-07T14:30:00.000Z");
expect(loaded!.locationId).toBe("forest");
expect(loaded!.intent).toEqual(intent);
expect(loaded!.outcome).toEqual(entry.outcome);
// List
const list = repo.listForOwner("alice");
expect(list).toHaveLength(1);
expect(list[0].id).toBe("buf-1");
// Delete
repo.delete("buf-1");
expect(repo.load("buf-1")).toBeNull();
db.close();
});
test("cascades delete of buffer entries when owner entity is deleted", () => {
const db = new Database(":memory:");
const coreRepo = new SQLiteRepository(db);
const repo = new BufferRepository(db);
const alice = new Entity("alice");
coreRepo.saveEntity(alice);
const entry: BufferEntry = {
id: "buf-1",
ownerId: "alice",
timestamp: "2026-07-07T14:30:00.000Z",
locationId: "forest",
intent: {
type: "action",
originalText: "Alice sneezed.",
description: "Alice sneezes",
actorId: "alice",
targetIds: [],
},
};
repo.save(entry);
expect(repo.load("buf-1")).not.toBeNull();
// Delete owner entity from core repository
coreRepo.delete("alice");
// Verify buffer entry was cascade deleted
expect(repo.load("buf-1")).toBeNull();
db.close();
});
});

View File

@@ -5,5 +5,8 @@
"outDir": "dist"
},
"include": ["src"],
"references": []
"references": [
{ "path": "../core" },
{ "path": "../intent" }
]
}

View File

@@ -0,0 +1,15 @@
{
"name": "@omnia/scenario",
"version": "0.0.0",
"private": true,
"type": "module",
"exports": {
".": "./dist/index.js"
},
"dependencies": {
"@omnia/core": "workspace:*",
"@omnia/spatial": "workspace:*",
"@omnia/memory": "workspace:*",
"zod": "^4.4.3"
}
}

View File

@@ -0,0 +1,2 @@
export * from "./schema.js";
export * from "./loader.js";

View File

@@ -0,0 +1,127 @@
import { WorldState, Entity, SQLiteRepository, AttributeVisibility } from "@omnia/core";
import { Location } from "@omnia/spatial";
import { BufferRepository } from "@omnia/memory";
import { ScenarioSchema, Scenario } from "./schema.js";
export class ScenarioLoader {
constructor(
private coreRepo: SQLiteRepository,
private bufferRepo: BufferRepository,
) {}
/**
* Instantiates a live world from a static JSON scenario template.
* Creates a new world instance in the database using a generated unique World ID.
*
* @param scenarioJson The raw JSON scenario template contents.
* @param targetWorldId The unique ID for the running instance to create (e.g. UUID).
* Allows launching multiple active runs from one scenario.
*/
async initializeWorld(scenarioJson: unknown, targetWorldId: string): Promise<string> {
// 1. Validate scenario template schema
const scenario: Scenario = ScenarioSchema.parse(scenarioJson);
// 2. Instantiate running WorldState using the target instance ID
const world = new WorldState(targetWorldId, new Date(scenario.startTime));
// Seed world-level attributes as system-only (private, empty ACL)
world.addAttribute("name", scenario.name, AttributeVisibility.PRIVATE, new Set());
world.addAttribute("description", scenario.description, AttributeVisibility.PRIVATE, new Set());
if (scenario.world?.attributes) {
for (const attr of scenario.world.attributes) {
const vis = attr.visibility === "PUBLIC" ? AttributeVisibility.PUBLIC : AttributeVisibility.PRIVATE;
world.addAttribute(
attr.name,
attr.value,
vis,
attr.allowedEntities ? new Set(attr.allowedEntities) : null,
);
}
}
// 3. Save World State core row
this.coreRepo.saveWorldState(world);
// 4. Instantiate and Persist Locations
if (scenario.locations) {
for (const locData of scenario.locations) {
const location = new Location(locData.id, locData.parentId ?? null);
if (locData.attributes) {
for (const attr of locData.attributes) {
const vis = attr.visibility === "PUBLIC" ? AttributeVisibility.PUBLIC : AttributeVisibility.PRIVATE;
location.addAttribute(
attr.name,
attr.value,
vis,
attr.allowedEntities ? new Set(attr.allowedEntities) : null,
);
}
}
if (locData.connections) {
location.connections = locData.connections.map((c) => ({
targetId: c.targetId,
portalName: c.portalName,
portalStateDescriptor: c.portalStateDescriptor,
visionProp: c.visionProp,
soundProp: c.soundProp,
bidirectional: c.bidirectional,
}));
}
// Save location record linked to the world instance
world.addLocation(location);
this.coreRepo.saveLocation(location, world.id);
}
}
// 5. Instantiate and Persist Entities (with Aliases & Memory Buffers)
if (scenario.entities) {
for (const entData of scenario.entities) {
const entity = new Entity(entData.id, entData.locationId ?? null);
// Load attributes
if (entData.attributes) {
for (const attr of entData.attributes) {
const vis = attr.visibility === "PUBLIC" ? AttributeVisibility.PUBLIC : AttributeVisibility.PRIVATE;
entity.addAttribute(
attr.name,
attr.value,
vis,
attr.allowedEntities ? new Set(attr.allowedEntities) : null,
);
}
}
// Load aliases
if (entData.aliases) {
for (const [targetId, alias] of Object.entries(entData.aliases)) {
entity.aliases.set(targetId, alias);
}
}
// Save entity record linked to the world instance
world.addEntity(entity);
this.coreRepo.saveEntity(entity, world.id);
// Seed initial memory buffer history
if (entData.initialMemories) {
for (const mem of entData.initialMemories) {
this.bufferRepo.save({
id: mem.id,
ownerId: entData.id,
timestamp: mem.timestamp,
locationId: mem.locationId,
intent: mem.intent,
outcome: mem.outcome,
});
}
}
}
}
return world.id;
}
}

View File

@@ -0,0 +1,65 @@
import { z } from "zod";
export const AttributeVisibilitySchema = z.enum(["PUBLIC", "PRIVATE"]);
export const ScenarioAttributeSchema = z.object({
name: z.string(),
value: z.string(),
visibility: AttributeVisibilitySchema,
allowedEntities: z.array(z.string()).optional(),
});
export const ScenarioPortalConnectionSchema = z.object({
targetId: z.string(),
portalName: z.string().optional(),
portalStateDescriptor: z.string().optional(),
visionProp: z.number().min(0).max(10),
soundProp: z.number().min(0).max(10),
bidirectional: z.boolean(),
});
export const ScenarioLocationSchema = z.object({
id: z.string(),
parentId: z.string().nullable().optional(),
attributes: z.array(ScenarioAttributeSchema).optional(),
connections: z.array(ScenarioPortalConnectionSchema).optional(),
});
export const ScenarioMemoryEntrySchema = z.object({
id: z.string(),
timestamp: z.string(), // ISO string
locationId: z.string().nullable(),
intent: z.object({
type: z.enum(["dialogue", "action", "monologue"]),
originalText: z.string(),
description: z.string(),
actorId: z.string(),
targetIds: z.array(z.string()),
}),
outcome: z.object({
isValid: z.boolean(),
reason: z.string(),
}).optional(),
});
export const ScenarioEntitySchema = z.object({
id: z.string(),
locationId: z.string().nullable().optional(),
attributes: z.array(ScenarioAttributeSchema).optional(),
aliases: z.record(z.string(), z.string()).optional(), // targetId -> subjective descriptor
initialMemories: z.array(ScenarioMemoryEntrySchema).optional(),
});
export const ScenarioSchema = z.object({
id: z.string(), // Template identifier
name: z.string(),
description: z.string(),
startTime: z.string(), // ISO string
world: z.object({
attributes: z.array(ScenarioAttributeSchema).optional(),
}).optional(),
locations: z.array(ScenarioLocationSchema).optional(),
entities: z.array(ScenarioEntitySchema).optional(),
});
export type Scenario = z.infer<typeof ScenarioSchema>;

View File

@@ -0,0 +1,136 @@
import { describe, test, expect } from "vitest";
import Database from "better-sqlite3";
import { SQLiteRepository } from "@omnia/core";
import { Location } from "@omnia/spatial";
import { BufferRepository } from "@omnia/memory";
import { ScenarioLoader, ScenarioSchema } from "../src/index.js";
describe("Scenario Validation & Schema Tests (Tier 1)", () => {
const validScenario = {
id: "sc-haunted-house",
name: "Haunted House Mystery",
description: "A spooky old manor.",
startTime: "2026-07-09T08:00:00.000Z",
world: {
attributes: [
{ name: "weather", value: "stormy", visibility: "PUBLIC" },
],
},
locations: [
{
id: "lobby",
parentId: null,
attributes: [
{ name: "light", value: "dim", visibility: "PUBLIC" },
],
connections: [
{
targetId: "kitchen",
portalName: "swinging door",
visionProp: 2,
soundProp: 6,
bidirectional: true,
},
],
},
{
id: "kitchen",
parentId: "lobby",
},
],
entities: [
{
id: "investigator",
locationId: "lobby",
attributes: [
{ name: "sanity", value: "100", visibility: "PRIVATE", allowedEntities: ["investigator"] },
],
aliases: {
ghost: "shadowy specter",
},
initialMemories: [
{
id: "mem-seed-1",
timestamp: "2026-07-09T07:55:00.000Z",
locationId: "lobby",
intent: {
type: "action",
originalText: "I entered the foyer.",
description: "entered the house",
actorId: "investigator",
targetIds: [],
},
},
],
},
],
};
test("successfully validates a valid scenario JSON template", () => {
const result = ScenarioSchema.safeParse(validScenario);
expect(result.success).toBe(true);
});
test("fails validation on invalid scenario structure", () => {
const invalidScenario = {
id: "sc-bad",
name: "Missing critical fields",
// description and startTime are missing
};
const result = ScenarioSchema.safeParse(invalidScenario);
expect(result.success).toBe(false);
});
test("loads scenario into SQLite database and reconstitutes all objects correctly", async () => {
const db = new Database(":memory:");
const coreRepo = new SQLiteRepository(db);
const bufferRepo = new BufferRepository(db);
const loader = new ScenarioLoader(coreRepo, bufferRepo);
const targetWorldId = "active-world-run-1";
const worldId = await loader.initializeWorld(validScenario, targetWorldId);
expect(worldId).toBe(targetWorldId);
// 1. Verify WorldState loaded
const world = coreRepo.loadWorldState(targetWorldId);
expect(world).not.toBeNull();
expect(world!.id).toBe(targetWorldId);
expect(world!.clock.get().toISOString()).toBe("2026-07-09T08:00:00.000Z");
expect(world!.attributes.get("name")?.getValue()).toBe("Haunted House Mystery");
expect(world!.attributes.get("weather")?.getValue()).toBe("stormy");
// 2. Verify Locations loaded with connections & hierarchy
const locations = coreRepo.listLocations(targetWorldId, (id, parentId) => new Location(id, parentId));
expect(locations).toHaveLength(2);
const lobby = locations.find((l) => l.id === "lobby");
expect(lobby).toBeDefined();
expect(lobby!.parentId).toBeNull();
expect(lobby!.attributes.get("light")?.getValue()).toBe("dim");
expect(lobby!.connections).toHaveLength(1);
expect(lobby!.connections[0].targetId).toBe("kitchen");
expect(lobby!.connections[0].portalName).toBe("swinging door");
expect(lobby!.connections[0].visionProp).toBe(2);
const kitchen = locations.find((l) => l.id === "kitchen");
expect(kitchen).toBeDefined();
expect(kitchen!.parentId).toBe("lobby");
// 3. Verify Entities loaded with subjective alias map
const loadedInvestigator = world!.getEntity("investigator");
expect(loadedInvestigator).toBeDefined();
expect(loadedInvestigator!.locationId).toBe("lobby");
expect(loadedInvestigator!.attributes.get("sanity")?.getValue()).toBe("100");
expect(loadedInvestigator!.aliases.get("ghost")).toBe("shadowy specter");
// 4. Verify pre-seeded memories loaded in BufferRepository
const memories = bufferRepo.listForOwner("investigator");
expect(memories).toHaveLength(1);
expect(memories[0].id).toBe("mem-seed-1");
expect(memories[0].timestamp).toBe("2026-07-09T07:55:00.000Z");
expect(memories[0].locationId).toBe("lobby");
expect(memories[0].intent.description).toBe("entered the house");
db.close();
});
});

View File

@@ -0,0 +1,124 @@
import { describe, test, expect } from "vitest";
import Database from "better-sqlite3";
import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
import { SQLiteRepository } from "@omnia/core";
import { Location } from "@omnia/spatial";
import { BufferRepository } from "@omnia/memory";
import { ScenarioLoader, ScenarioSchema } from "../src/index.js";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const SCENARIO_PATH = path.resolve(__dirname, "../../../content/demo/scenarios/talking-room.json");
describe("Talking Room Demo Scenario Test (Tier 1)", () => {
test("talking-room.json exists, parses, and loads correctly into database", async () => {
// 1. Verify file exists
expect(fs.existsSync(SCENARIO_PATH)).toBe(true);
// 2. Read and parse JSON
const rawJson = fs.readFileSync(SCENARIO_PATH, "utf-8");
const scenarioJson = JSON.parse(rawJson);
const parsed = ScenarioSchema.safeParse(scenarioJson);
expect(parsed.success).toBe(true);
// 3. Setup SQLite and loader
const db = new Database(":memory:");
const coreRepo = new SQLiteRepository(db);
const bufferRepo = new BufferRepository(db);
const loader = new ScenarioLoader(coreRepo, bufferRepo);
const worldInstanceId = "run-talking-room-1";
await loader.initializeWorld(scenarioJson, worldInstanceId);
// 4. Assert WorldState
const world = coreRepo.loadWorldState(worldInstanceId);
expect(world).not.toBeNull();
expect(world!.attributes.get("name")?.getValue()).toBe("Talking Room");
expect(world!.attributes.get("name")?.visibility).toBe("PRIVATE");
expect(world!.attributes.get("description")?.getValue()).toBe(scenarioJson.description);
expect(world!.attributes.get("description")?.visibility).toBe("PRIVATE");
expect(world!.attributes.get("experiment_codename")?.getValue()).toBe("Project Tabula Rasa (Phase 3)");
expect(world!.attributes.get("experiment_codename")?.visibility).toBe("PRIVATE");
expect(world!.attributes.get("experiment_codename")?.getAllowedEntities()).toHaveLength(0); // System only!
// 5. Assert location
const locations = coreRepo.listLocations(worldInstanceId, (id, parentId) => new Location(id, parentId));
expect(locations).toHaveLength(1);
expect(locations[0].id).toBe("white-room");
expect(locations[0].attributes.get("description")?.getValue()).toContain("A pristine, featureless room");
// 6. Assert entities and their private attributes / allowedEntities
const alphaId = "7c9b83b3-8cfb-4e89-8d77-626a5757d591";
const betaId = "bf3f29d2-cf11-4b11-9a99-b13c126d400e";
const alpha = world!.getEntity(alphaId);
expect(alpha).toBeDefined();
expect(alpha!.locationId).toBe("white-room");
// Name visibility check
const alphaName = alpha!.attributes.get("name")!;
expect(alphaName.getValue()).toBe("Bob");
expect(alphaName.visibility).toBe("PRIVATE");
expect(alphaName.hasAccess(alphaId)).toBe(true);
expect(alphaName.hasAccess(betaId)).toBe(false);
// Check system-only attribute (neural_erasure_dose)
const alphaDose = alpha!.attributes.get("neural_erasure_dose")!;
expect(alphaDose.visibility).toBe("PRIVATE");
expect(alphaDose.hasAccess(alphaId)).toBe(false);
expect(alphaDose.hasAccess(betaId)).toBe(false);
// Verify subjective aliases are initially undefined
expect(alpha!.aliases.get(betaId)).toBeUndefined();
const beta = world!.getEntity(betaId);
expect(beta).toBeDefined();
expect(beta!.locationId).toBe("white-room");
expect(beta!.aliases.get(alphaId)).toBeUndefined();
// Verify subjective aliases can be dynamically resolved via AliasDeltaGenerator
const { AliasDeltaGenerator } = await import("@omnia/architect");
const { MockLLMProvider } = await import("@omnia/llm");
const llmProvider = new MockLLMProvider([{ alias: "the person in the Beta jumpsuit" }]);
const aliasGenerator = new AliasDeltaGenerator(llmProvider);
const generatedAlias = await aliasGenerator.generate(alpha!, beta!);
expect(generatedAlias).toBe("the person in the Beta jumpsuit");
alpha!.aliases.set(betaId, generatedAlias);
expect(alpha!.aliases.get(betaId)).toBe("the person in the Beta jumpsuit");
// Verify subjective world state serializes the location attributes (epistemic inclusion)
const { serializeSubjectiveWorldState } = await import("@omnia/core");
const subjectiveState = serializeSubjectiveWorldState(world!, alphaId);
expect(subjectiveState).toContain("You are at location: white-room");
expect(subjectiveState).toContain("Location attributes:");
expect(subjectiveState).toContain("description: A pristine, featureless room");
expect(subjectiveState).toContain("lighting: Bright, uniform illumination");
// Verify objective world state serializes locations (physics awareness)
const { serializeObjectiveWorldState } = await import("@omnia/core");
const objectiveState = serializeObjectiveWorldState(world!);
expect(objectiveState).toContain("Locations:");
expect(objectiveState).toContain("- Location [ID: white-room]:");
expect(objectiveState).toContain("description: A pristine, featureless room");
// 7. Assert initial pre-seeded memories
const alphaMemories = bufferRepo.listForOwner(alphaId);
expect(alphaMemories).toHaveLength(1);
expect(alphaMemories[0].id).toBe("alpha-wake");
expect(alphaMemories[0].intent.type).toBe("monologue");
expect(alphaMemories[0].intent.originalText).toContain("jail");
expect(alphaMemories[0].intent.description).toBe("");
const betaMemories = bufferRepo.listForOwner(betaId);
expect(betaMemories).toHaveLength(1);
expect(betaMemories[0].id).toBe("beta-wake");
expect(betaMemories[0].intent.type).toBe("action");
expect(betaMemories[0].intent.originalText).toContain("agreement");
expect(betaMemories[0].intent.description).toBe("");
db.close();
});
});

View File

@@ -0,0 +1,13 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist"
},
"include": ["src"],
"references": [
{ "path": "../core" },
{ "path": "../spatial" },
{ "path": "../memory" }
]
}

6101
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,13 @@
packages:
- "packages/*"
- "cli"
- "apps/*"
- "web/*"
- "content/scenario-builder"
allowBuilds:
better-sqlite3: true
esbuild: true
sharp: true
unrs-resolver: true
minimumReleaseAgeExclude:
- '@astrojs/telemetry@3.3.3'
- astro@7.0.7

View File

@@ -41,5 +41,35 @@ describe("IntentDecoder Live Eval (Tier 3)", () => {
expect(actionIntent).toBeDefined();
expect(actionIntent!.actorId).toBe("alice");
expect(actionIntent!.originalText).toContain("slipped the silver key");
});
}, 15000);
test("resolves targets correctly using the actor's subjective alias map", async () => {
expect(llmConfig.GOOGLE_API_KEY).toBeDefined();
const provider = new GeminiProvider(llmConfig.GOOGLE_API_KEY);
const decoder = new IntentDecoder(provider);
const world = new WorldState("world-xyz");
const alice = new Entity("alice");
const bob = new Entity("bob");
// Register alias mapping: Bob is known to Alice as "the hooded stranger"
alice.aliases.set("bob", "the hooded stranger");
world.addEntity(alice);
world.addEntity(bob);
// Narrative prose referring to Bob only by the subjective alias
const narrativeProse = "Alice handed the silver key to the hooded stranger.";
const result = await decoder.decode(world, "alice", narrativeProse);
expect(result.intents).toHaveLength(1);
const intent = result.intents[0];
expect(intent.type).toBe("action");
expect(intent.actorId).toBe("alice");
// Verify target resolution resolved the alias back to the correct system entity ID
expect(intent.targetIds).toContain("bob");
}, 15000);
});

View File

@@ -0,0 +1,219 @@
import { describe, test, expect } from "vitest";
import Database from "better-sqlite3";
import {
WorldState,
Entity,
SQLiteRepository,
AttributeVisibility,
} from "@omnia/core";
import { MockLLMProvider } from "@omnia/llm";
import { IntentSequence } from "@omnia/intent";
import { Architect } from "@omnia/architect";
import {
BufferRepository,
BufferEntry,
} from "@omnia/memory";
import {
ActorAgent,
ActorResponseSchema,
buildBufferEntryForIntent,
} from "@omnia/actor";
describe("Actor Agent + Monologue Intent Integration (Tier 2)", () => {
test("actor produces prose → decoder splits into dialogue/action/monologue → architect bypasses monologue", async () => {
const db = new Database(":memory:");
const coreRepo = new SQLiteRepository(db);
const bufferRepo = new BufferRepository(db);
const startTime = new Date("2026-07-09T12:00:00.000Z");
const world = new WorldState("world-actor", startTime);
world.addAttribute("location", "Tavern Cellar", AttributeVisibility.PUBLIC);
const alice = new Entity("alice", "cellar-1");
alice.addAttribute("name", "Alice", AttributeVisibility.PUBLIC);
alice.addAttribute("role", "rogue", AttributeVisibility.PUBLIC);
// A private, self-visible attribute (explicitly ACL'd to self).
alice.addAttribute(
"secret_goal",
"Steal the ledger without being noticed.",
AttributeVisibility.PRIVATE,
new Set(["alice"]),
);
world.addEntity(alice);
const bob = new Entity("bob", "cellar-1");
bob.addAttribute("name", "Bob", AttributeVisibility.PUBLIC);
bob.addAttribute("role", "guard", AttributeVisibility.PUBLIC);
world.addEntity(bob);
// Alice knows Bob by name.
alice.aliases.set("bob", "Bob");
coreRepo.saveWorldState(world);
// --- Mock LLM response queue ---
// 1. Actor produces prose containing a thought, a spoken line, and an action.
const mockActorProse = { narrativeProse: "I can't believe Bob hasn't noticed me yet, Alice thought. \"Hey Bob,\" she called out softly. She reached for the ledger on the table." };
// 2. IntentDecoder splits that prose into 3 intents.
const mockDecodedSequence: IntentSequence = {
intents: [
{
type: "monologue",
originalText: "I can't believe Bob hasn't noticed me yet, Alice thought.",
description: "Alice internally reflects that Bob has not noticed her.",
actorId: "alice",
targetIds: [],
},
{
type: "dialogue",
originalText: '"Hey Bob," she called out softly.',
description: "Alice softly calls out to Bob.",
actorId: "alice",
targetIds: ["bob"],
},
{
type: "action",
originalText: "She reached for the ledger on the table.",
description: "Alice reaches for the ledger on the table.",
actorId: "alice",
targetIds: [],
},
],
};
// 3. Architect: dialogue is always valid (0 min), action is valid (2 min).
// NOTE: monologue never reaches the validator/delta generator.
const mockDialogueValidation = { isValid: true, reason: "Alice can speak." };
const mockDialogueTimeDelta = { minutesToAdvance: 0, explanation: "Speech is instantaneous." };
const mockActionValidation = { isValid: true, reason: "The ledger is within reach." };
const mockActionTimeDelta = { minutesToAdvance: 2, explanation: "Reaching for the ledger takes 2 minutes." };
const llmProvider = new MockLLMProvider([
mockActorProse, // 1. Actor generation
mockDecodedSequence, // 2. IntentDecoder
mockDialogueValidation, // 3. Architect.validateIntent (dialogue)
mockDialogueTimeDelta, // 4. TimeDeltaGenerator (dialogue)
mockActionValidation, // 5. Architect.validateIntent (action)
mockActionTimeDelta, // 6. TimeDeltaGenerator (action)
]);
const actor = new ActorAgent(llmProvider, bufferRepo);
const architect = new Architect(llmProvider, coreRepo);
// 1. Actor acts
const turn = await actor.act(world, alice);
expect(turn.narrativeProse).toBe(mockActorProse.narrativeProse);
expect(turn.intents.intents).toHaveLength(3);
expect(turn.intents.intents[0].type).toBe("monologue");
expect(turn.intents.intents[1].type).toBe("dialogue");
expect(turn.intents.intents[2].type).toBe("action");
const intents = turn.intents.intents;
const writtenEntries: BufferEntry[] = [];
// 2. Process each intent through the Architect and write memory.
for (const intent of intents) {
const result = await architect.processIntent(world, intent);
const entry = buildBufferEntryForIntent(
intent,
world.clock.get().toISOString(),
alice.locationId,
);
// For action intents, attach the validation outcome.
if (intent.type === "action") {
entry.outcome = { isValid: result.isValid, reason: result.reason };
}
bufferRepo.save(entry);
writtenEntries.push(entry);
}
// 3. Monologue bypassed validation: clock did NOT advance for it,
// and no outcome was attached to its buffer entry.
expect(intents[0].type).toBe("monologue");
expect(writtenEntries[0].outcome).toBeUndefined();
// 4. Dialogue: valid, 0-minute delta, no outcome field.
expect(writtenEntries[1].outcome).toBeUndefined();
// 5. Action: valid, 2-minute delta, outcome attached.
expect(writtenEntries[2].outcome).toEqual({
isValid: true,
reason: "The ledger is within reach.",
});
// 6. Clock advanced by exactly 2 minutes (dialogue 0 + action 2).
const expectedTime = new Date(startTime.getTime() + 2 * 60_000);
expect(world.clock.get().toISOString()).toBe(expectedTime.toISOString());
// 7. All three intents persisted to Alice's memory buffer.
const aliceMemory = bufferRepo.listForOwner("alice");
expect(aliceMemory).toHaveLength(3);
expect(aliceMemory[0].intent.type).toBe("monologue");
expect(aliceMemory[1].intent.type).toBe("dialogue");
expect(aliceMemory[2].intent.type).toBe("action");
// 8. Monologue entry has no outcome; action entry does.
expect(aliceMemory[0].outcome).toBeUndefined();
expect(aliceMemory[2].outcome).toBeDefined();
expect(aliceMemory[2].outcome!.isValid).toBe(true);
// 9. Monologue did NOT touch persisted world clock (only the action did).
const reloaded = coreRepo.loadWorldState("world-actor")!;
expect(reloaded.clock.get().toISOString()).toBe(expectedTime.toISOString());
db.close();
});
test("ActorResponseSchema validates prose output shape", () => {
const valid = { narrativeProse: "Alice thought quietly." };
expect(ActorResponseSchema.parse(valid)).toEqual(valid);
expect(() => ActorResponseSchema.parse({})).toThrow();
expect(() =>
ActorResponseSchema.parse({ narrativeProse: 123 }),
).toThrow();
});
test("serializeSubjectiveWorldState is epistemically bounded", async () => {
const { serializeSubjectiveWorldState } = await import("@omnia/core");
const world = new WorldState("world-subj");
const alice = new Entity("alice", "room-1");
alice.addAttribute("name", "Alice", AttributeVisibility.PUBLIC);
alice.addAttribute(
"secret",
"hidden truth",
AttributeVisibility.PRIVATE,
new Set(["alice"]),
);
world.addEntity(alice);
const bob = new Entity("bob", "room-1");
bob.addAttribute("name", "Bob", AttributeVisibility.PUBLIC);
bob.addAttribute(
"bob_secret",
"bob's hidden truth",
AttributeVisibility.PRIVATE,
new Set(["bob"]),
);
world.addEntity(bob);
const view = serializeSubjectiveWorldState(world, "alice");
// Alice sees her own secret (explicitly ACL'd).
expect(view).toContain("secret: hidden truth");
// Alice sees Bob's public name.
expect(view).toContain("name: Bob");
// Alice does NOT see Bob's private attribute.
expect(view).not.toContain("bob's hidden truth");
// Alice perceives herself as "you".
expect(view).toContain("Self (you)");
// Bob is an unfamiliar figure (no alias set).
expect(view).toContain("an unfamiliar figure");
});
});

View File

@@ -10,6 +10,7 @@
{ "path": "./packages/memory" },
{ "path": "./packages/spatial" },
{ "path": "./packages/llm" },
{ "path": "./cli" }
{ "path": "./packages/actor" },
{ "path": "./packages/scenario" }
]
}

View File

@@ -14,7 +14,8 @@ export default defineConfig({
"@omnia/intent": path.resolve(__dirname, "./packages/intent/src"),
"@omnia/memory": path.resolve(__dirname, "./packages/memory/src"),
"@omnia/spatial": path.resolve(__dirname, "./packages/spatial/src"),
"@omnia/cli": path.resolve(__dirname, "./cli/src"),
"@omnia/actor": path.resolve(__dirname, "./packages/actor/src"),
"@omnia/scenario": path.resolve(__dirname, "./packages/scenario/src"),
},
},
test: {

36
web/docs/astro.config.mjs Normal file
View File

@@ -0,0 +1,36 @@
import { defineConfig } from "astro/config";
import starlight from "@astrojs/starlight";
import mermaid from "astro-mermaid";
export default defineConfig({
site: "https://omnia.omniasimulation.com",
base: "/docs",
integrations: [
mermaid(),
starlight({
title: "Omnia Docs",
logo: {
src: "./src/assets/img/logo.png",
replacesTitle: true,
},
social: [{ icon: "github", label: "GitHub", href: "https://github.com/sortedcord/omnia-consolidated" }],
sidebar: [
{
label: "Introduction",
slug: "index",
},
{
label: "Architecture",
items: [{ autogenerate: { directory: "architecture" } }],
},
{
label: "Guides",
items: [{ autogenerate: { directory: "guides" } }],
},
],
editLink: {
baseUrl: "https://github.com/sortedcord/omnia/edit/main/web/docs/",
},
}),
],
});

17
web/docs/package.json Normal file
View File

@@ -0,0 +1,17 @@
{
"name": "docs",
"type": "module",
"version": "0.0.1",
"private": true,
"scripts": {
"dev": "astro dev",
"start": "astro dev",
"build": "astro build",
"preview": "astro preview"
},
"dependencies": {
"@astrojs/starlight": "^0.41.3",
"astro": "^7.0.7",
"sharp": "^0.33.5"
}
}

View File

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

@@ -0,0 +1,7 @@
import { defineCollection } from "astro:content";
import { docsLoader } from "@astrojs/starlight/loaders";
import { docsSchema } from "@astrojs/starlight/schema";
export const collections = {
docs: defineCollection({ loader: docsLoader(), schema: docsSchema() }),
};

View File

@@ -0,0 +1,93 @@
---
title: Actor Agent
description: The component that embodies a single entity and produces narrative prose
---
The Actor Agent is the system component that embodies a single entity and produces narrative prose describing what that entity does, says, or thinks next. It is the "inner voice" of an NPC (or player character), generating behavior proposals that are then validated and executed by the rest of the engine.
## Design Principles
1. **Epistemic boundedness** — The actor only sees what its entity would perceive: public attributes of other entities, private attributes explicitly ACL'd to it, its own memory buffer, and co-located entities. It does not have system-level access to all world state.
2. **Proposal, not mutation** — The actor generates a _proposal_ (narrative prose). It never mutates world state, persists to the database, or writes to memory directly. Validation, execution, and persistence are the Architect's job.
3. **Free prose → structured intents** — The actor outputs free natural-language prose. This is fed to the `IntentDecoder`, which splits and classifies it into a sequence of typed intents.
## Prompt Structure
The actor prompt is assembled by `ActorPromptBuilder` and has two parts:
### System Prompt
Establishes the role, rules, and output contract:
- The LLM **is** the character, not a narrator or system.
- The character may produce three kinds of behavior:
- **Spoken dialogue** → `dialogue` intent.
- **Physical/logical action** → `action` intent.
- **Inner thought / reflection** → `monologue` intent.
- The character must stay in-character, respect its knowledge bounds, and refer to others by subjective aliases (not system UUIDs).
- Not every turn requires an outward action — internal monologue alone is valid.
- The character controls only itself.
### User Context
Epistemically bounded, with these sections:
| Section | Content | Source |
|---|---|---|
| Current moment | The subjective present time | `worldState.clock.get().toISOString()` |
| The world as you perceive it | Self-visible attributes, co-located entities + their visible attributes, other presences elsewhere | `serializeSubjectiveWorldState()` |
| Your recent memory | Recent `BufferEntry`s, alias-substituted, with relative time phrasing | `serializeSubjectiveBufferEntry()` |
No system UUIDs, no private attributes the entity lacks ACL access to, and no objective-world-state dump are present.
## The Monologue Intent Type
Monologue (`"monologue"`) is the third intent type. Its properties:
- **No perceiver** — `targetIds` is always `[]`. No other entity perceives or can react to a monologue.
- **No validation** — The Architect's `processIntent` short-circuits for monologues.
- **Direct-to-memory** — Written directly to the actor's buffer with no `outcome` field.
- **Defensive guard** — `LLMValidator.validate` has an early-return guard so a stray monologue can never reach the validation LLM.
## Flow
```
[ActorAgent.act()]
├─ 1. ActorPromptBuilder.build(entity, worldState)
│ → system prompt + user context (subjective world + memory + time)
├─ 2. IActorProseGenerator.generate(entityId, systemPrompt, userContext)
│ ├─ LLMActorProseGenerator: queries LLM via generateStructuredResponse
│ └─ CLIProseGenerator: prompts human player via CLI / readline interface
│ → narrativeProse: string
├─ 3. IntentDecoder.decode(worldState, actorId, prose)
│ → IntentSequence (dialogue | action | monologue intents)
└─ returns { narrativeProse, intents }
[Caller (e.g. game loop)]
├─ for each intent in intents:
│ ├─ if intent.type === "monologue": short-circuit, write to buffer
│ ├─ if intent.type === "dialogue": validate (always valid), write to buffer
│ └─ if intent.type === "action": validate, generate time delta, advance clock
└─ world state persisted to DB
```
## Key Files
| File | Role |
|---|---|
| `packages/actor/src/actor-prompt-builder.ts` | Assembles the epistemically-bounded actor prompt |
| `packages/actor/src/actor.ts` | `ActorAgent` class: orchestrates prompt → LLM → decoder flow |
| `packages/actor/src/index.ts` | Package exports |
| `packages/core/src/world.ts:72` | `serializeSubjectiveWorldState()` |
| `packages/intent/src/intent.ts:8` | `IntentTypeSchema` — includes `"monologue"` |
| `packages/intent/src/intent-decoder.ts:30` | Decoder system prompt |
| `packages/architect/src/architect.ts:35` | Monologue short-circuit |
| `packages/architect/src/llm-validator.ts:19` | Defensive monologue guard |

View File

@@ -0,0 +1,85 @@
---
title: World Architect
description: The validation and state-mutation layer that sits between intents and world state
---
The Architect is not a specialized model, nor a special entity. It is a special context along with a set of tools given to an LLM that dictates what happens to world state.
The Architect is provided with the `WorldState`, states of entities, state of a scene, location, along with their attributes, and is asked to judge whether an action (an `Intent`) makes canonical sense. It disallows intents that break the narrative flow (for example, item ownership, entity location and state tracking).
```mermaid
flowchart LR
A[Agent] -->|"Perform Action"| B(Action Intent)
subgraph "Architect Layer"
C[Architect]
D{"Validators"}
E{"Noise Layer 1"}
F{"Noise Layer 2"}
G[Delta Generators]
end
B --> C
C -->|"Tool Call"| D
C -->|"Spontaneity Bypass"| E
D --> F
E --> F
F -->|"Pass"| G
F -->|"Fail"| A
G --> |Deltas modify| H[State]
H --> I[Entity]
H --> J[Location]
H --> K[World]
```
Dialogue intents are exempted from validation and are not used for state manipulation. v0 defers atomic validators completely; instead, an umbrella LLM-based validator is used.
## Dynamic Validators (Deferred from v0)
A standard set of validators dictate if an action is possible. The Architect can dynamically generate its own validators which can be loaded and unloaded at runtime based on narration. These can be soft validators (if they fail, the intent is sent back to the entity for override confirmation).
## Noise Layers (Deferred from v0)
Since the system has complex action validation, the LLM may naturally steer towards low-stakes actions with minimal consequences, flattening the narrative. Noise layers introduce random, slightly non-sensical actions to introduce spontaneity and unexpectedness.
## Delta Generators
Delta Generators are single-responsibility components that compute discrete state updates ("deltas") from validated intents.
### How They Function
1. **Validation Prerequisite**: Execute only if validation returns `isValid: true`.
2. **Specialized Responsibility**: Each generator isolates a specific aspect of state transition (clock advancement, position updates, attribute modifications).
3. **Structured Outputs**: Generators query the LLM using Zod schemas for type-safe change deltas.
4. **Application and Persistence**: The delta is applied to the live `WorldState` by deterministic code and persisted to the database.
### Time Delta Generator
Calculates the physical time duration (in minutes) that a validated action takes to complete:
- **Inputs**: The validated action and the serialized objective `WorldState`.
- **Output (Zod Schema)**:
```json
{
"minutesToAdvance": 25,
"explanation": "Searching a locked desk thoroughly takes time."
}
```
- **Resolution**: The Architect advances `worldState.clock` by the returned minutes and saves to `SQLiteRepository`.
### Alias Delta Generator
Dynamically synthesizes subjective names/aliases when an entity perceives another entity for the first time:
- **Trigger**: Run automatically during simulation loops for any co-located entities who do not yet have a record in their subjective alias registry.
- **Epistemic Constraints**: Uses only the target entity's visible public attributes (e.g. appearance, clothing) as context, ensuring private names and details remain hidden from the observer.
- **Inputs**: The observer (`viewer: Entity`) and the observed (`target: Entity`), along with the target's visible attributes.
- **Output (Zod Schema)**:
```json
{
"alias": "the tall silver-haired elf in the green tunic"
}
```
- **Resolution**: Registers the generated descriptive alias to the observer's `aliases` map and persists the entity change to the SQLite database.
## A Note on Tech Debt
The Architect currently trusts an LLM's judgment about reasonable consequences rather than validating every change against declarative constraints. A general constraint solver is worth building eventually, but building it before anything is playable is foundational perfectionism that produces beautiful architecture and no game.

View File

@@ -0,0 +1,88 @@
---
title: Intents
description: How narrative prose becomes structured, validated actions
---
The simple way of understanding intents is to think of it as a proposal, not an effect.
Intents are:
- **Declarative** — they describe what the character intends, not the final outcome.
- **High-level** — they capture the gist of an action or dialogue.
- **Allowed to be wrong** — validation happens downstream.
- **Cheap to generate** — LLM-friendly structured output.
The actor LLM doesn't directly generate an intent. To keep the narrative going, the actor agent generates continuing prose. If the intents validate, the narrative prose goes directly to the user while deltas generated from the intents modify the state.
```mermaid
flowchart LR
A[Actor Agent]
B[/Action Narrative Prose/]
C{{Intent Decoder}}
F[Architect]
E{{Intent Scheduler}}
A --> B
B --> C
subgraph D["Intent Sequence"]
direction TD
I1([Dialogue Intent])
I2([Action Intent])
I3([Action Intent])
I4([⋯])
I1 --> I2 --> I3 --> I4
end
C --> D
D --> |"Concurrent"| F
F --> E
```
## Intent Decoder
The Intent Decoder:
- Splits narrative prose into multiple intents when applicable.
- Classifies each intent to its type (`dialogue`, `action`, or `monologue`).
- Parses narrative text into structured JSON with minimal information loss.
- Contextually resolves the receiving parties/targets.
### Zod Schemas & Types
- **IntentType**: `"dialogue"` | `"action"` | `"monologue"`
- **Intent**:
- `type`: `IntentType`
- `originalText`: `string` — the slice of raw prose text containing the intent
- `description`: `string` — summarized intent action
- `actorId`: `string`
- `targetIds`: `string[]` — resolved recipient or target entity IDs
- **IntentSequence**:
- `intents`: `Intent[]`
### IntentDecoder Class
```typescript
export class IntentDecoder {
constructor(private llmProvider: ILLMProvider) {}
async decode(
worldState: WorldState,
actorId: string,
narrativeProse: string,
): Promise<IntentSequence>;
}
```
It serializes the world state and feeds all known entity IDs as context, enabling the model to resolve target IDs correctly.
## Names and LLMs
A fundamental issue emerged during early design: something as simple as a name is set to private. An entity's name is not common knowledge — you don't instantly know another person's name.
Although the internal system can identify an entity by its ID (defined in `AttributableObject`), UUIDs aren't helpful for LLMs. This creates several problems:
- **Unnamed entities**: How does the Architect orchestrate changes for an entity that doesn't even have a name?
- **Inconsistent identifiers**: An NPC might use "the hooded man" in one turn and "the shadowy figure" in the next.
- **Made-up names**: LLMs may invent names not present in the world state.
The **Subjective Alias System** solves this by maintaining a per-entity map from target IDs to subjective descriptors (see [Memory & Aliases](./memory)).

View File

@@ -0,0 +1,97 @@
---
title: LLM Providers & Configuration
description: Details of the LLM provider instances, task routing, and self-bootstrapping setup in Omnia.
sidebar:
order: 5
---
In Omnia, all non-player character behaviors, action validation, intent decoding, and time step logic are simulated using Large Language Models (LLMs). The LLM subsystem is built around **polymorphism, key instance management, and task provider routing**.
## Core Interfaces
All LLM providers implement the common `ILLMProvider` interface defined in `packages/llm/src/llm.ts`:
```typescript
export interface ILLMProvider {
providerName: string;
generateStructuredResponse<T extends z.ZodTypeAny>(
request: LLMRequest<T>,
): Promise<LLMResponse<z.infer<T>>>;
lastCalls?: LLMCallRecord[];
}
```
The codebase provides two primary implementations:
1. **`GeminiProvider`:** The production provider utilizing Google's Gemini Models via the `@langchain/google-genai` SDK.
2. **`MockLLMProvider`:** A stateless, pre-programmed mock provider used for fast, deterministic unit testing and local integration tests.
---
## LLM Provider Instances
To support multiple different API keys, key rotation, and model variation, Omnia utilizes a **Provider Instance model** rather than relying on static configuration:
```typescript
export interface LLMProviderInstance {
id: string;
name: string;
providerName: string;
apiKey: string;
isActive: boolean;
modelName?: string;
}
```
Users can register multiple provider instances in the **Configuration Page** under the GUI. Each instance is given:
* A friendly, human-readable name (e.g., `"Gemini Production Key"`, `"Experimental Gemini Pro"`).
* A provider type (e.g., `google-genai`, `mock`).
* An API key credential.
* A custom target model name (e.g., `gemini-2.5-flash` or `gemini-2.5-pro`).
* An **Active** status flag (one key is marked as globally active).
Configurations are stored globally in `data/settings.db` (separated from specific simulation run databases like `data/sim-*.db` to keep key storage and audit logs isolated).
---
## Task Provider Routing
During a simulation run, the engine executes four distinct LLM operations. To optimize costs, latency, or model accuracy, you can route each of these tasks to different LLM provider instances:
| Task Name | Key ID | Description | Default Model |
| :--- | :--- | :--- | :--- |
| **Actor Prose Generation** | `actor-prose` | Generates roleplay and narrative behavioral prose for Non-Player Characters. | `gemini-2.5-flash` |
| **LLM Validator** | `llm-validator` | Arbitrates and validates proposed actions against the world state rules and constraints. | `gemini-2.5-flash` |
| **Intent Decoder** | `intent-decoder` | Parses and splits free-text actions/prose into structured intent sequences. | `gemini-2.5-flash` |
| **TimeDelta Generator** | `timedelta` | Calculates the duration of character actions to advance the game clock. | `gemini-2.5-flash` |
If no specific provider instance is mapped to a task, the task automatically routes to the globally marked **Active** provider instance.
---
## Automatic Bootstrapping (Environment Key Fallback)
To maintain backwards-compatibility and support headless runs, live evaluation suites, and automated unit tests without requiring database pre-configuration, the config manager supports **self-bootstrapping**:
1. When the provider manager queries the active key instance, if `data/settings.db` contains **0 registered keys**, it checks the process environment for `GOOGLE_API_KEY`.
2. If `process.env.GOOGLE_API_KEY` is present, it automatically creates, saves, and activates a default provider instance (`Default (Env)`) in `settings.db`.
3. If database write locks occur (e.g., during high-concurrency Vitest test suites), the system seamlessly returns a temporary in-memory `LLMProviderInstance` to keep execution fluent and error-free.
---
## Developer Guide: Managing Mappings
Configuration settings are managed through `ProviderManager` static methods:
```typescript
// Query the active provider configuration
const activeConfig = ProviderManager.getActive();
// List all registered provider instances
const allConfigs = ProviderManager.list();
// Retrieve task-specific mappings
const mappings = ProviderManager.getMappings(); // e.g., { "actor-prose": "provider-123" }
// Map a task to a provider instance
ProviderManager.setMapping("actor-prose", "provider-123");
```

View File

@@ -0,0 +1,103 @@
---
title: Memory & Subjective Aliases
description: The memory subsystem and how entities refer to each other
---
This document outlines the memory subsystem (`packages/memory`) and the Subjective Alias System.
## Subjective Alias System
System-level IDs (e.g. `alice`, `bob`, or UUIDs) are critical for state tracking, but placing them directly in prompts violates epistemic privacy and breaks narrative generation.
Each `Entity` class maintains a private **Subjective Alias Map**:
```typescript
class Entity extends AttributableObject {
locationId: string | null = null;
readonly aliases: Map<string, string> = new Map();
// Key: target entity ID (e.g., "bob")
// Value: subjective string (e.g., "the hooded figure" or "Gareth")
}
```
### Alias States
- **Unknown Name**: Defaults to a subjective label derived from the target's visible description (e.g., `"the hooded figure"`).
- **Known Name**: Updated to their name once learned (e.g., `"Gareth"`).
### How It Wires Into Prompts
- **Intent Decoder**: The decoder maps subjective labels like "the hooded figure" back to the correct system ID `bob`.
- **Prompt Injection**: World state, events, and memories are injected with raw target IDs replaced by subjective aliases.
### SQLite Persistence
Entity aliases are persisted in the `objects` table via the `aliases_json TEXT` column. The aliases map is stringified as JSON on save and parsed back upon entity reconstitution.
## Subjective Buffer Entry
A subjective `BufferEntry` records a discrete event from the perspective of an entity (the `owner`). It wraps a structured `Intent` and appends execution metadata.
### The Shape of a Buffer Entry
```typescript
interface BufferEntry {
id: string;
ownerId: string; // Whose subjective memory buffer this lives in
timestamp: string; // WorldClock.get().toISOString() at write time
locationId: string | null; // Actor's location when this happened
intent: Intent; // The actual dialogue/action intent, reused as-is
outcome?: { // Present only for "action" intents
isValid: boolean;
reason: string;
};
}
```
- **Intent Reuse**: Schema changes to `Intent` flow down automatically.
- **Write-time Location**: `locationId` is captured immediately when writing the entry.
## Buffer Serialization (Epistemic Substitute)
To prevent leaking system IDs, buffer memories are serialized using `serializeSubjectiveBufferEntry` with the `resolveAlias` helper:
```typescript
export function resolveAlias(viewer: Entity, targetId: string): string {
if (targetId === viewer.id) return "you";
return viewer.aliases.get(targetId) ?? "an unfamiliar figure";
}
```
This guarantees prompts read cleanly — e.g. `[12:03:00 PM] the hooded figure opened the wooden chest (Outcome: Succeeded)` — without exposing raw system IDs.
## SQLite Persistence & BufferRepository
The `BufferRepository` class uses the same SQLite database as core repositories:
```sql
CREATE TABLE IF NOT EXISTS buffer_entries (
id TEXT PRIMARY KEY,
owner_id TEXT NOT NULL,
timestamp TEXT NOT NULL,
location_id TEXT,
intent_json TEXT NOT NULL,
outcome_json TEXT,
FOREIGN KEY (owner_id) REFERENCES objects(id) ON DELETE CASCADE
);
```
- **JSON Storage**: `intent` and `outcome` are serialized/deserialized as raw JSON, validated by Zod at creation time.
- **Cascade Deletes**: Deleting an entity removes all associated subjective memory entries.
## Time Naturalization
LLMs are poor at tracking quantized clock times, and real entities do not recall exact timestamps for past events. To make memories psychologically realistic, timestamps are converted into relative natural language phrases prior to prompt injection:
* **Utility**: `naturalizeTime(now: Date, past: Date): string` converts raw dates into subjective relative strings.
* **Granularity Tiers**:
* **Relative (< 6 hours)**: Returns short offsets like `"just now"`, `"moments ago"`, `"a couple hours ago"`, or `"a few hours ago"`.
* **Same Subjective Day (6h to 18h)**: Detects waking hours (05:00 - 21:59). If both times occur within the same waking block, it returns `"earlier today, in the {period}"` (where period is `morning`, `afternoon`, or `evening`).
* **Plausible Sleep Boundaries**: Past events from sleep hours are mapped to `"last night"`, `"around midnight"`, or `"late last night"`.
* **Coarse (>= 48 hours)**: Returns broad descriptors like `"a couple days ago"`, `"about a week ago"`, `"a couple months ago"`, or `"years ago"`.

View File

@@ -0,0 +1,43 @@
---
title: Architecture Overview
description: High-level architecture of the Omnia engine
---
Omnia is organized as a monorepo with the following subsystems:
```
omnia/
packages/
core/ entities, attributes, world state, clock, SQLite persistence
intent/ intent types (dialogue/action/monologue) and the prose decoder
architect/ World Architect: LLM validation plus time-delta generation
actor/ actor agent: epistemically-bounded prompts, pluggable prose generators
memory/ verbatim buffer; later the vector archive, dossier, and affect vectors
spatial/ location and POI graph, portal-based perception
llm/ ILLMProvider interface plus Gemini and deterministic mock implementations
scenario/ scenario JSON schema and loader (JSON → SQLite)
apps/
cli/ the playable loop (human or LLM actors, --scenario / --play flags)
content/
demo/ bundled scenarios (talking-room)
tests/
integration/ cross-package tests against a mocked LLM
evals/ deliberate real-API evaluation runs
web/
landing/ Vite-based landing page
docs/ Astro-based documentation site
```
The engine core deliberately knows nothing about domain content (stats, traits, genres). Scenarios are plain JSON the loader ingests; what an attribute means is the scenario's business, not the engine's.
## Core Data Flow
1. An **Actor Agent** receives an epistemically-bounded view of the world and produces narrative prose.
2. The **Intent Decoder** splits prose into typed intents (`dialogue`, `action`, `monologue`).
3. The **World Architect** validates action intents against objective world state and generates structured deltas.
4. Deterministic code applies deltas to the **World State** (SQLite) and persists results.
5. Memory entries are written per-character, filtered through **Subjective Aliases**.
## A Research Instrument
Omnia's architecture doubles as an apparatus for studying how language models behave _as characters_ under controlled epistemic conditions. Monologue intents provide a window into private reasoning; attribute ACLs let you administer information with precision; identical initial conditions with swappable model providers enable reproducible experiments.

View File

@@ -0,0 +1,23 @@
---
title: Attributes & Privacy
description: Attribute-level access control and epistemic privacy model
---
Every entity, item, and location in Omnia is an attribute bag. Each attribute carries its own visibility (`PUBLIC` or `PRIVATE`) with an explicit access list.
## How Privacy Works
"The sword is cursed" is a private attribute checked in code, not a rule the model is politely asked to honor. Privacy lives at the level of the fact, not the entity:
- A character can be publicly a blacksmith and privately a spy.
- Even facts about _itself_ can be hidden from it unless explicitly granted (amnesia, repression, and sleeper agents come free with the model).
## Prompt-Injection-Proof Secrets
There is no instruction to override because the information was never serialized into the prompt. Epistemic privacy turns "the model shouldn't say this" (hard, unreliable) into "the model doesn't know this" (trivial, absolute).
## Key Components
- `hasAccess()` / ACL grant-revoke logic
- `getVisibleAttributesFor()` — viewer-relative attribute filtering
- Attribute schema with visibility metadata

View File

@@ -0,0 +1,24 @@
---
title: Spatial System
description: The spatial graph model for location and perception
---
Space in Omnia is modeled as a graph, not a coordinate grid.
## The Graph Model
```
world → region → location → point of interest
```
These nodes are connected by **portals** with sound and vision propagation values. When something happens, perception information bubbles outward through portals.
Today, actors perceive:
- Co-located entities
- Their location's visible attributes
Portal-propagated perception is on the roadmap.
## Design Rationale
There are no coordinates, no pathfinding algorithms, no collision geometry. A narrative engine doesn't need a tactical simulation — a discrete graph is sufficient for modeling who is where and who can perceive whom.

View File

@@ -0,0 +1,79 @@
---
title: Testing Strategy
description: Three-tiered testing architecture for deterministic and non-deterministic code
---
The central testing challenge: most of Omnia is deterministic and highly testable, but a few critical parts (LLM behavior) are non-deterministic. Treating both categories the same way — either mocking everything or hitting the real LLM API — is an anti-pattern.
## Test Tiers
### Tier 1: Unit Tests (Per-Package, No LLM)
Unit tests reside within each package's `tests/` directory. They do not use LLMs and do not perform I/O. This tier covers the majority of the codebase.
Examples:
- `hasAccess()` / ACL grant-revoke logic
- `addAttribute` rejecting duplicate names
- `WorldClock.advance()` / `getTimeOfDay()` boundaries
- The spatial bubble-up algorithm
- Zod schemas rejecting malformed input
**Execution:** Runs on every save and every commit.
### Tier 2: Integration Tests (Cross-Package, Mocked LLM)
Integration tests live in the root `tests/integration/` directory. They test cross-package flows using `MockLLMProvider` for speed and determinism.
```typescript
export class MockLLMProvider implements ILLMProvider {
providerName = "mock";
constructor(private responses: unknown[]) {}
private callCount = 0;
async generateStructuredResponse<T extends z.ZodTypeAny>(
request: LLMRequest<T>,
): Promise<LLMResponse<z.infer<T>>> {
const next = this.responses[this.callCount++];
return { success: true, data: request.schema.parse(next) };
}
}
```
A shared contract test suite runs against both `MockLLMProvider` and `GeminiProvider` to enforce real interchangeability.
### Tier 3: Evals (Real API, Run Deliberately)
Evals live in `tests/evals/`. They use real LLM APIs and are run manually via `test:evals`, excluded from the default Vitest run.
This tier tests privacy guarantees that unit tests cannot verify:
```typescript
// tests/evals/privacy-leak.eval.ts
const RUNS = 15;
let leaks = 0;
for (let i = 0; i < RUNS; i++) {
const response = await askAboutPrivateFact(npcWithoutAccess, secretFact);
if (containsFact(response, secretFact)) leaks++;
}
expect(leaks).toBe(0);
```
**Execution:** Run deliberately (e.g., weekly or pre-release).
## Directory Structure
```
omnia/
packages/
core/ src/ tests/ # Tier 1: Unit — no I/O, no LLM
intent/ src/ tests/
spatial/ src/ tests/
memory/ src/ tests/
architect/ src/ tests/
llm/ src/ tests/ # Includes MockLLMProvider + shared contract suite
tests/
integration/ # Tier 2: Cross-package flows, mocked LLM
evals/ # Tier 3: Real LLM calls, slow/costly/non-deterministic
```

View File

@@ -0,0 +1,45 @@
---
title: Introduction
description: What is Omnia and why it exists
---
![Omnia Logo](../../assets/img/logo.png)
An LLM-assisted narrative simulation engine where the **world state lives outside the model**, characters act through **intents that get validated** and applied by engine code, and each character's knowledge, memory, and emotional state are subjective and partial by construction.
Omnia is an engine for building narrative RPG-style worlds where characters are played by a language model. It is built to survive long play sessions instead of falling apart after twenty minutes.
## The Problem with the Naive Approach
Single-agent, single-context systems (AI Dungeon and its descendants) prompt one model to _be_ the world and everyone in it. That breaks in predictable ways over long sessions:
- **State Leaks:** Characters know things they had no way of learning, because a model with full context cannot help but use it. The assassin's target greets him by name.
- **Secrets Refuse to Stay Secret:** "Don't reveal this" is a suggestion a model can argue past, not a mechanism that says no. One clever player question and the conspiracy folds.
- **Consequences Evaporate:** Betray someone, apologize, and they forgive you a turn later because nothing is tracking the betrayal as a persistent fact.
- **Emotional Drift:** Emotional state is either frozen into a meaningless number (`trust: 40`) or handed to the model to grade itself, producing drifting, arbitrary values.
- **World Rot:** The world state slowly contradicts itself because the model has no structured place to keep it. The locked door is open, then locked, then never existed.
- **Everyone Is One Person:** Every character shares one context, so every character shares one mind. They can't genuinely surprise each other, lie to each other, or know different things — they're sock puppets on the same hand.
The root cause is the same in every case: the model is being asked to be the database, the physics engine, the referee, and the whole cast simultaneously — inside a context window that forgets, blends, and leaks.
## The Omnia Solution
Omnia answers every one of these failures with the same move: pull the thing that has to stay consistent out of the model and into structured, queryable, code-controlled state.
- **World State:** Lives in a SQLite database, not in a context window. It cannot drift, because nothing regenerates it — it only changes through validated deltas.
- **Actions:** Actions are proposals (Intents) that engine code validates and applies; they are never direct edits the model makes to the world. The model proposes; deterministic code disposes.
- **Epistemic Privacy:** Knowledge, memory, and emotion are modeled per character and kept partial on purpose. A character literally cannot reach for what it has not earned the right to know — the secret is not in its prompt, so there is nothing to jailbreak out of it.
## What This Buys You
The payoff is scenario complexity that uni-agent systems structurally cannot represent, no matter how good the model gets:
- **Real secrets, real dramatic irony.** One NPC knows the sword is cursed; the other does not. This holds for hundreds of turns not because the model is disciplined, but because the second NPC's prompts are constructed from an attribute set that simply does not contain the fact.
- **Genuine deception between characters.** Because each character acts from its own bounded view, characters can lie to each other — and be believed — with the truth intact in the world state.
- **Betrayal that stays betrayed.** Events persist as per-observer memory entries with outcomes. An apology adds a memory; it does not delete one.
- **Divergent accounts of the same event.** Two witnesses to the same scene hold two different buffer entries, filtered through their own aliases and vantage points.
- **Identity as information.** Characters refer to each other through subjective alias maps. Recognizing someone, being recognized, or staying anonymous are all mechanical states.
- **A physics referee that can say no.** "I pick the lock with a hairpin" is validated against world state by the Architect before anything changes.
- **Time that behaves.** A world clock advances by validated, per-action deltas, and memory is recalled with psychologically natural phrasing.
The general principle: **anything that must remain true is state; the model only ever supplies behavior.**

11
web/docs/tsconfig.json Normal file
View File

@@ -0,0 +1,11 @@
{
"extends": "astro/tsconfigs/strict",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@assets/*": ["src/assets/*"]
}
},
"include": [".astro/types.d.ts", "**/*"],
"exclude": ["dist"]
}