mirror of
https://github.com/sortedcord/omnia.git
synced 2026-07-22 20:12:48 +05:30
Compare commits
16 Commits
15abfdd898
...
feat/openr
| Author | SHA1 | Date | |
|---|---|---|---|
| 4339a8b4b5 | |||
| 907c3b8ed7 | |||
| 9642e2bb54 | |||
| 8934422a4d | |||
| 63badedf75 | |||
| 717f9f20d4 | |||
| 3da043952c | |||
| 17c5b95f3b | |||
| 53b221f80c | |||
| d9940a036a | |||
| 093f8de4c5 | |||
| 6cf099821b | |||
| fadea41e6f | |||
| 701bc56d0c | |||
| d96fc04542 | |||
| fa698619b3 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -14,6 +14,7 @@ dist-ssr/
|
||||
build/
|
||||
coverage/
|
||||
.next/
|
||||
.astro/
|
||||
out/
|
||||
*.tsbuildinfo
|
||||
|
||||
|
||||
134
README.md
134
README.md
@@ -1,4 +1,4 @@
|
||||

|
||||

|
||||
|
||||
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 geometry—a 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 geometry — a 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
|
||||
content/
|
||||
scenario-core/ scenario JSON schema and loader (JSON → SQLite)
|
||||
scenario-builder/ Next.js web UI for authoring worlds
|
||||
demo/ bundled scenarios (talking-room)
|
||||
cli/ the playable loop (human or LLM actors, --scenario / --play flags)
|
||||
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._
|
||||
|
||||
|
||||
@@ -7,6 +7,15 @@
|
||||
".": "./dist/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@omnia/core": "workspace:*",
|
||||
"@omnia/spatial": "workspace:*",
|
||||
"@omnia/memory": "workspace:*",
|
||||
"@omnia/intent": "workspace:*",
|
||||
"@omnia/architect": "workspace:*",
|
||||
"@omnia/actor": "workspace:*",
|
||||
"@omnia/llm": "workspace:*",
|
||||
"@omnia/scenario-core": "workspace:*",
|
||||
"better-sqlite3": "^12.11.1",
|
||||
"dotenv": "^17.4.2"
|
||||
}
|
||||
}
|
||||
|
||||
424
cli/src/index.ts
424
cli/src/index.ts
@@ -1,6 +1,426 @@
|
||||
import dotenv from "dotenv";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import readline from "readline";
|
||||
import Database from "better-sqlite3";
|
||||
import { WorldState, SQLiteRepository } from "@omnia/core";
|
||||
import { BufferRepository } from "@omnia/memory";
|
||||
import { Architect, AliasDeltaGenerator } from "@omnia/architect";
|
||||
import {
|
||||
ActorAgent,
|
||||
ActorPromptBuilder,
|
||||
IActorProseGenerator,
|
||||
buildBufferEntryForIntent,
|
||||
} from "@omnia/actor";
|
||||
import { GeminiProvider } from "@omnia/llm";
|
||||
import { ScenarioLoader } from "@omnia/scenario-core";
|
||||
|
||||
// Load environment variables once at CLI application entry point
|
||||
// Load environment variables
|
||||
dotenv.config();
|
||||
|
||||
export {};
|
||||
class CLIProseGenerator implements IActorProseGenerator {
|
||||
async generate(
|
||||
entityId: string,
|
||||
systemPrompt: string,
|
||||
userContext: string,
|
||||
): Promise<string> {
|
||||
console.log(
|
||||
"\n================================================================================",
|
||||
);
|
||||
console.log(`YOUR TURN: Playing as character "${entityId}"`);
|
||||
console.log(
|
||||
"================================================================================",
|
||||
);
|
||||
console.log(userContext);
|
||||
console.log(
|
||||
"================================================================================",
|
||||
);
|
||||
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
});
|
||||
|
||||
return new Promise<string>((resolve) => {
|
||||
rl.question(
|
||||
"\nDescribe what your character does, says, or thinks (or type 'exit' to quit):\n> ",
|
||||
(answer) => {
|
||||
rl.close();
|
||||
const trimmed = answer.trim();
|
||||
if (trimmed.toLowerCase() === "exit") {
|
||||
console.log("\nExiting simulation. Goodbye!");
|
||||
process.exit(0);
|
||||
}
|
||||
resolve(trimmed);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks for co-located entities who do not have subjective aliases for each other,
|
||||
* and calls the AliasDeltaGenerator to synthesize names based on visible attributes.
|
||||
*/
|
||||
async function runAliasResolution(
|
||||
worldState: WorldState,
|
||||
aliasGenerator: AliasDeltaGenerator,
|
||||
coreRepo: SQLiteRepository,
|
||||
): Promise<void> {
|
||||
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) {
|
||||
if (!viewer.aliases.has(target.id)) {
|
||||
const alias = await aliasGenerator.generate(viewer, target);
|
||||
viewer.aliases.set(target.id, alias);
|
||||
console.log(
|
||||
`\n[Alias Resolved] "${viewer.id}" sees "${target.id}" -> alias: "${alias}"`,
|
||||
);
|
||||
// Save the viewer state with the new alias
|
||||
coreRepo.saveEntity(viewer, worldState.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
const logFileIndex = args.indexOf("--log-file");
|
||||
let logStream: fs.WriteStream | undefined;
|
||||
if (logFileIndex !== -1 && args[logFileIndex + 1]) {
|
||||
const logFilePath = path.resolve(args[logFileIndex + 1]);
|
||||
logStream = fs.createWriteStream(logFilePath, {
|
||||
flags: "w",
|
||||
encoding: "utf-8",
|
||||
});
|
||||
|
||||
// Monkeypatch console.log
|
||||
const originalLog = console.log;
|
||||
console.log = (...messageArgs: unknown[]) => {
|
||||
originalLog(...messageArgs);
|
||||
const text =
|
||||
messageArgs
|
||||
.map((arg) => {
|
||||
if (typeof arg === "object" && arg !== null) {
|
||||
try {
|
||||
return JSON.stringify(arg, null, 2);
|
||||
} catch {
|
||||
return String(arg);
|
||||
}
|
||||
}
|
||||
return String(arg);
|
||||
})
|
||||
.join(" ") + "\n";
|
||||
logStream?.write(text);
|
||||
};
|
||||
|
||||
// Monkeypatch console.error
|
||||
const originalError = console.error;
|
||||
console.error = (...messageArgs: unknown[]) => {
|
||||
originalError(...messageArgs);
|
||||
const text =
|
||||
messageArgs
|
||||
.map((arg) => {
|
||||
if (typeof arg === "object" && arg !== null) {
|
||||
try {
|
||||
return JSON.stringify(arg, null, 2);
|
||||
} catch {
|
||||
return String(arg);
|
||||
}
|
||||
}
|
||||
return String(arg);
|
||||
})
|
||||
.join(" ") + "\n";
|
||||
logStream?.write("[ERROR] " + text);
|
||||
};
|
||||
|
||||
process.on("exit", () => {
|
||||
logStream?.end();
|
||||
});
|
||||
}
|
||||
const scenarioArgIndex = args.indexOf("--scenario");
|
||||
const scenarioPath =
|
||||
scenarioArgIndex !== -1
|
||||
? args[scenarioArgIndex + 1]
|
||||
: "content/demo/scenarios/talking-room.json";
|
||||
|
||||
const playArgIndex = args.indexOf("--play");
|
||||
const playEntityId = playArgIndex !== -1 ? args[playArgIndex + 1] : undefined;
|
||||
|
||||
const dbPath = path.resolve("./omnia.db");
|
||||
console.log(`Initializing SQLite database at: ${dbPath}`);
|
||||
|
||||
const db = new Database(dbPath);
|
||||
const coreRepo = new SQLiteRepository(db);
|
||||
const bufferRepo = new BufferRepository(db);
|
||||
const loader = new ScenarioLoader(coreRepo, bufferRepo);
|
||||
|
||||
// 1. Read Scenario JSON file
|
||||
if (!fs.existsSync(scenarioPath)) {
|
||||
console.error(
|
||||
`Error: Scenario template file not found at: ${scenarioPath}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`Loading scenario template from: ${scenarioPath}`);
|
||||
const scenarioJson = JSON.parse(fs.readFileSync(scenarioPath, "utf-8"));
|
||||
|
||||
// 2. Initialize World Instance
|
||||
const worldInstanceId = `run-${Date.now()}`;
|
||||
console.log(`Initializing live world instance: ${worldInstanceId}`);
|
||||
await loader.initializeWorld(scenarioJson, worldInstanceId);
|
||||
|
||||
// Load the running world state
|
||||
const worldState = coreRepo.loadWorldState(worldInstanceId);
|
||||
if (!worldState) {
|
||||
console.error(
|
||||
`Error: Failed to load initialized world state: ${worldInstanceId}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 3. Ensure API Key exists if we are running LLMs
|
||||
const apiKey = process.env.GOOGLE_API_KEY;
|
||||
if (!apiKey) {
|
||||
console.error("Error: GOOGLE_API_KEY environment variable is missing.");
|
||||
console.error(
|
||||
"Please provide it in your .env file to enable LLM generators and decoders.",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const llmProvider = new GeminiProvider(apiKey);
|
||||
const architect = new Architect(llmProvider, coreRepo);
|
||||
const aliasGenerator = new AliasDeltaGenerator(llmProvider);
|
||||
|
||||
console.log(
|
||||
"\n================================================================================",
|
||||
);
|
||||
console.log(`SIMULATION STARTED: "${scenarioJson.name}"`);
|
||||
console.log(`Description: ${scenarioJson.description}`);
|
||||
if (playEntityId) {
|
||||
console.log(`Player Role: Controlling entity "${playEntityId}"`);
|
||||
} else {
|
||||
console.log("Player Role: Observing fully autonomous NPC run");
|
||||
}
|
||||
console.log(
|
||||
"================================================================================",
|
||||
);
|
||||
|
||||
const isVerbose = args.includes("--verbose");
|
||||
|
||||
let turnCount = 1;
|
||||
const maxTurns = 20; // safe loop breaker
|
||||
|
||||
while (turnCount <= maxTurns) {
|
||||
console.log(
|
||||
`\n\n--- TURN ${turnCount} (World Time: ${worldState.clock.get().toISOString()}) ---`,
|
||||
);
|
||||
|
||||
// Reload world state from database to ensure fresh DB sync
|
||||
const currentWorldState = coreRepo.loadWorldState(worldInstanceId);
|
||||
if (!currentWorldState) {
|
||||
console.error("Error: Synced world state lost.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Auto-resolve aliases for co-located entities who don't know each other yet
|
||||
await runAliasResolution(currentWorldState, aliasGenerator, coreRepo);
|
||||
|
||||
const entities = Array.from(currentWorldState.entities.values());
|
||||
|
||||
for (const entity of entities) {
|
||||
// 1. Determine the ActorAgent generator: CLI input for player, LLM for NPCs
|
||||
const isPlayer = playEntityId && entity.id === playEntityId;
|
||||
const generator = isPlayer ? new CLIProseGenerator() : undefined;
|
||||
|
||||
const agent = new ActorAgent(llmProvider, bufferRepo, 20, generator);
|
||||
|
||||
// Verbose mode: Output the generated prompt builder context before generation
|
||||
if (isVerbose) {
|
||||
const promptBuilder = new ActorPromptBuilder(bufferRepo, 20);
|
||||
const { systemPrompt, userContext } = promptBuilder.build(
|
||||
currentWorldState,
|
||||
entity,
|
||||
);
|
||||
console.log(`\n[VERBOSE] Assembled Prompts for "${entity.id}":`);
|
||||
console.log("\n--- SYSTEM PROMPT ---");
|
||||
console.log(systemPrompt);
|
||||
console.log("\n--- USER CONTEXT ---");
|
||||
console.log(userContext);
|
||||
console.log("\n--- CONTEXT BREAKDOWN ---");
|
||||
|
||||
const userSections = userContext.split("\n\n");
|
||||
const momentSection =
|
||||
userSections.find((s) => s.startsWith("=== CURRENT MOMENT ===")) ||
|
||||
"";
|
||||
const worldSection =
|
||||
userSections.find((s) =>
|
||||
s.startsWith("=== THE WORLD AS YOU PERCEIVE IT ==="),
|
||||
) || "";
|
||||
const memorySection =
|
||||
userSections.find((s) =>
|
||||
s.startsWith("=== YOUR RECENT MEMORY ==="),
|
||||
) || "";
|
||||
|
||||
const systemChars = systemPrompt.length;
|
||||
const momentChars = momentSection.length;
|
||||
const worldChars = worldSection.length;
|
||||
const memoryChars = memorySection.length;
|
||||
const totalChars = systemChars + userContext.length;
|
||||
|
||||
const estTokens = (chars: number) => Math.ceil(chars / 4);
|
||||
|
||||
console.log(
|
||||
` ├─ System Instructions: ${systemChars.toLocaleString()} chars (~${estTokens(systemChars)} tokens)`,
|
||||
);
|
||||
console.log(
|
||||
` ├─ Current Moment Context: ${momentChars.toLocaleString()} chars (~${estTokens(momentChars)} tokens)`,
|
||||
);
|
||||
console.log(
|
||||
` ├─ World Perception: ${worldChars.toLocaleString()} chars (~${estTokens(worldChars)} tokens)`,
|
||||
);
|
||||
console.log(
|
||||
` ├─ Recent Memory Buffer: ${memoryChars.toLocaleString()} chars (~${estTokens(memoryChars)} tokens)`,
|
||||
);
|
||||
console.log(
|
||||
` └─ TOTAL ESTIMATED INPUT: ${totalChars.toLocaleString()} chars (~${estTokens(totalChars)} tokens)`,
|
||||
);
|
||||
console.log(
|
||||
"--------------------------------------------------------------------------------",
|
||||
);
|
||||
}
|
||||
|
||||
if (!isPlayer) {
|
||||
console.log(`\n[${entity.id}] is thinking...`);
|
||||
}
|
||||
|
||||
// 2. Execute character turn
|
||||
const turnResult = await agent.act(currentWorldState, entity);
|
||||
|
||||
if (!isPlayer) {
|
||||
console.log(`\n[${entity.id}]: ${turnResult.narrativeProse}`);
|
||||
} else {
|
||||
console.log(`\n[You]: ${turnResult.narrativeProse}`);
|
||||
}
|
||||
|
||||
// Verbose mode: Output decoded intent structures
|
||||
if (isVerbose) {
|
||||
console.log(`\n[VERBOSE] Decoded Intents from Prose:`);
|
||||
console.log(JSON.stringify(turnResult.intents.intents, null, 2));
|
||||
console.log(
|
||||
"--------------------------------------------------------------------------------",
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Process each generated intent sequence through physics and memory
|
||||
for (const intent of turnResult.intents.intents) {
|
||||
const outcome = await architect.processIntent(
|
||||
currentWorldState,
|
||||
intent,
|
||||
);
|
||||
const timestamp = currentWorldState.clock.get().toISOString();
|
||||
|
||||
// Verbose mode: Output architect evaluation
|
||||
if (isVerbose) {
|
||||
console.log(`\n[VERBOSE] Architect Intent Processing:`);
|
||||
console.log(` Type: ${intent.type}`);
|
||||
console.log(` Description: "${intent.description}"`);
|
||||
if (intent.type === "monologue") {
|
||||
console.log(" Validation: Bypassed (monologue)");
|
||||
} else {
|
||||
console.log(
|
||||
` Validation Result: isValid = ${outcome.isValid}, reason = "${outcome.reason}"`,
|
||||
);
|
||||
if (outcome.timeDelta) {
|
||||
console.log(
|
||||
` Clock Delta: +${outcome.timeDelta.minutesToAdvance} min (${outcome.timeDelta.explanation})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
console.log(
|
||||
"--------------------------------------------------------------------------------",
|
||||
);
|
||||
}
|
||||
|
||||
// Save actor's subjective memory
|
||||
const actorEntry = buildBufferEntryForIntent(
|
||||
intent,
|
||||
timestamp,
|
||||
entity.locationId,
|
||||
);
|
||||
if (intent.type === "action") {
|
||||
actorEntry.outcome = {
|
||||
isValid: outcome.isValid,
|
||||
reason: outcome.reason,
|
||||
};
|
||||
}
|
||||
bufferRepo.save(actorEntry);
|
||||
|
||||
// Propagate public memories (dialogue/actions) to co-located observers
|
||||
if (
|
||||
entity.locationId &&
|
||||
(intent.type === "dialogue" || intent.type === "action")
|
||||
) {
|
||||
for (const other of currentWorldState.entities.values()) {
|
||||
if (
|
||||
other.id !== entity.id &&
|
||||
other.locationId === entity.locationId
|
||||
) {
|
||||
const observerEntry = buildBufferEntryForIntent(
|
||||
intent,
|
||||
timestamp,
|
||||
entity.locationId,
|
||||
);
|
||||
if (intent.type === "action") {
|
||||
observerEntry.outcome = {
|
||||
isValid: outcome.isValid,
|
||||
reason: outcome.reason,
|
||||
};
|
||||
}
|
||||
bufferRepo.save({
|
||||
...observerEntry,
|
||||
ownerId: other.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Print formatted logs
|
||||
if (intent.type === "monologue") {
|
||||
if (isPlayer) {
|
||||
console.log(` (Thought processed: "${intent.description}")`);
|
||||
}
|
||||
} else if (intent.type === "dialogue") {
|
||||
console.log(
|
||||
` (Dialogue spoken: spoken to ${intent.targetIds.join(", ") || "someone"})`,
|
||||
);
|
||||
} else {
|
||||
console.log(
|
||||
` (Action result: ${outcome.isValid ? "Success" : `Failed - ${outcome.reason}`})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Save synced world state to repository
|
||||
coreRepo.saveWorldState(currentWorldState);
|
||||
}
|
||||
|
||||
turnCount++;
|
||||
}
|
||||
|
||||
console.log("\nSimulation execution limit reached. Goodbye!");
|
||||
db.close();
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("Simulation run aborted due to error:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
@@ -5,5 +5,14 @@
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": []
|
||||
"references": [
|
||||
{ "path": "../packages/core" },
|
||||
{ "path": "../packages/spatial" },
|
||||
{ "path": "../packages/memory" },
|
||||
{ "path": "../packages/intent" },
|
||||
{ "path": "../packages/architect" },
|
||||
{ "path": "../packages/actor" },
|
||||
{ "path": "../packages/llm" },
|
||||
{ "path": "../content/scenario-core" }
|
||||
]
|
||||
}
|
||||
|
||||
132
content/demo/scenarios/talking-room.json
Normal file
132
content/demo/scenarios/talking-room.json
Normal 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": []
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
15
content/scenario-core/package.json
Normal file
15
content/scenario-core/package.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "@omnia/scenario-core",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
2
content/scenario-core/src/index.ts
Normal file
2
content/scenario-core/src/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./schema.js";
|
||||
export * from "./loader.js";
|
||||
127
content/scenario-core/src/loader.ts
Normal file
127
content/scenario-core/src/loader.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
65
content/scenario-core/src/schema.ts
Normal file
65
content/scenario-core/src/schema.ts
Normal 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>;
|
||||
136
content/scenario-core/tests/scenario.test.ts
Normal file
136
content/scenario-core/tests/scenario.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
124
content/scenario-core/tests/talking-room.test.ts
Normal file
124
content/scenario-core/tests/talking-room.test.ts
Normal 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, "../../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();
|
||||
});
|
||||
});
|
||||
13
content/scenario-core/tsconfig.json
Normal file
13
content/scenario-core/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [
|
||||
{ "path": "../../packages/core" },
|
||||
{ "path": "../../packages/spatial" },
|
||||
{ "path": "../../packages/memory" }
|
||||
]
|
||||
}
|
||||
@@ -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`.
|
||||
@@ -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?
|
||||
125
docs/memory.md
125
docs/memory.md
@@ -1,125 +0,0 @@
|
||||
# Memory & Subjective Aliases
|
||||
|
||||
This document outlines the memory subsystem (`packages/memory`) and the v0 Subjective Alias System. The goal is to enforce epistemic privacy while allowing the LLM-driven components (NPC agents and decoders) to translate naturally between system-level IDs and human-readable narrative context.
|
||||
|
||||
---
|
||||
|
||||
## 1. Subjective Alias System (v0)
|
||||
|
||||
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 (as models make up inconsistent names or fail to match references).
|
||||
|
||||
To solve this, each `Entity` class (in [entity.ts](file:///home/sortedcord/Projects/omnia_umbrella/omnia/packages/core/src/entity.ts)) 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**: The entity does not know the target's real name. The alias defaults to a subjective label derived from the target's visible description/attributes (e.g., `"the hooded figure"`). This label is used in both internal thoughts and external dialogues.
|
||||
* **Known Name**: The entity has learned the target's name. The alias is updated to their name (e.g., `"Gareth"`).
|
||||
|
||||
### How It Wires Into Prompts
|
||||
* **Intent Decoder**: When decoding narrative prose written by actor `X`, we pass `X`'s alias map to the LLM. This allows the decoder to map subjective labels like *"the hooded figure"* back to the correct system ID `bob`.
|
||||
* **Prompt Injection**: When injecting world state, events, or memories into an NPC's prompt context, the system replaces raw target IDs with the subjective aliases defined in that NPC's alias map.
|
||||
|
||||
### SQLite Persistence
|
||||
Entity aliases are persisted in the `objects` table via the `aliases_json TEXT` column in [repository.ts](file:///home/sortedcord/Projects/omnia_umbrella/omnia/packages/core/src/repository.ts). The aliases map is stringified as JSON entries on save and parsed back upon entity reconstitution in `SQLiteRepository.loadEntity()`, `loadWorldState()`, and `listEntities()`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Subjective Buffer Entry
|
||||
|
||||
A subjective `BufferEntry` records a discrete event from the perspective of an entity (the `owner`). It wraps a structured `Intent` (reused as-is to prevent schema drift) and appends execution metadata. The interface is defined and exported from [buffer.ts](file:///home/sortedcord/Projects/omnia_umbrella/omnia/packages/memory/src/buffer.ts).
|
||||
|
||||
### 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 processed by the Architect
|
||||
isValid: boolean;
|
||||
reason: string;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
* **Intent Reuse**: By wrapping the `Intent` directly, any future schema changes to `Intent` flow down automatically without duplicating code.
|
||||
* **Write-time Location**: `locationId` is captured immediately when writing the entry (rather than computed dynamically later), matching the schema of long-term `LedgerEntry` consolidation.
|
||||
|
||||
---
|
||||
|
||||
## 3. Buffer Serialization (Epistemic Substitute)
|
||||
|
||||
To prevent leaking system IDs or universal state to NPCs, we serialize buffer memories using a decoupled, viewer-relative function `serializeSubjectiveBufferEntry` in [buffer.ts](file:///home/sortedcord/Projects/omnia_umbrella/omnia/packages/memory/src/buffer.ts).
|
||||
|
||||
To safely resolve actor and target entities without leaking internal UUIDs, we use 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 helper maps:
|
||||
- Self-references (when an entity evaluates their own memory) to `"you"`.
|
||||
- Known targets to their subjective name/descriptor from the alias map.
|
||||
- Unknown targets to a generic `"an unfamiliar figure"`.
|
||||
|
||||
The primary serialization function consumes this helper:
|
||||
```typescript
|
||||
export function serializeSubjectiveBufferEntry(
|
||||
entry: BufferEntry,
|
||||
viewer: Entity
|
||||
): string {
|
||||
const dateObj = new Date(entry.timestamp);
|
||||
const timeStr = dateObj.toLocaleTimeString("en-US", { hour12: true, timeZone: "UTC" });
|
||||
const actorAlias = resolveAlias(viewer, entry.intent.actorId);
|
||||
|
||||
const targetAliases = entry.intent.targetIds.map(
|
||||
(tid) => resolveAlias(viewer, tid)
|
||||
);
|
||||
|
||||
let details: string;
|
||||
if (entry.intent.type === "dialogue") {
|
||||
details = `spoke to ${targetAliases.join(", ") || "someone"}: "${entry.intent.description}"`;
|
||||
} else {
|
||||
details = `${entry.intent.description}`;
|
||||
if (entry.outcome) {
|
||||
details += ` (Outcome: ${entry.outcome.isValid ? "Succeeded" : `Failed - ${entry.outcome.reason}`})`;
|
||||
}
|
||||
}
|
||||
|
||||
return `[${timeStr}] ${actorAlias} ${details}`;
|
||||
}
|
||||
```
|
||||
|
||||
This guarantees that the prompt reads cleanly (e.g. `[12:03:00 PM] the hooded figure opened the wooden chest (Outcome: Succeeded)` or `[12:03:00 PM] you spoke to an unfamiliar figure...`) without exposing raw system IDs to the NPC.
|
||||
|
||||
---
|
||||
|
||||
## 4. SQLite Persistence & BufferRepository
|
||||
|
||||
The `BufferRepository` class in [buffer.ts](file:///home/sortedcord/Projects/omnia_umbrella/omnia/packages/memory/src/buffer.ts) utilizes 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. Because they are validated by Zod at creation time, they bypass redundant validation checks during database roundtrips.
|
||||
* **Cascade Deletes**: The table is configured with a foreign key referencing the `objects` table (`ON DELETE CASCADE`), ensuring that deleting an entity cleanses all their associated subjective memory entries automatically.
|
||||
@@ -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.
|
||||
102
docs/testing.md
102
docs/testing.md
@@ -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
|
||||
```
|
||||
@@ -5,7 +5,7 @@ import tseslint from "typescript-eslint";
|
||||
|
||||
export default [
|
||||
{
|
||||
ignores: ["**/dist/**", "**/node_modules/**", "content/scenario-builder/**"],
|
||||
ignores: ["**/dist/**", "**/node_modules/**", "content/scenario-builder/**", "**/.astro/**"],
|
||||
},
|
||||
|
||||
js.configs.recommended,
|
||||
|
||||
@@ -6,8 +6,10 @@
|
||||
"scripts": {
|
||||
"build": "tsc -b",
|
||||
"build:web": "pnpm --filter landing build",
|
||||
"build:all": "pnpm build && pnpm build:web",
|
||||
"build:docs": "pnpm --filter docs build",
|
||||
"build:all": "pnpm build && pnpm build:web && pnpm build:docs",
|
||||
"dev:web": "pnpm --filter landing dev",
|
||||
"dev:docs": "pnpm --filter docs dev",
|
||||
"clean": "git clean -xfd",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
@@ -16,7 +18,8 @@
|
||||
"watch": "tsc -b --watch",
|
||||
"test": "vitest run --project unit",
|
||||
"test:watch": "vitest --project unit",
|
||||
"test:evals": "vitest run --project evals"
|
||||
"test:evals": "vitest run --project evals",
|
||||
"play": "node cli/dist/index.js"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
@@ -44,6 +47,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@langchain/google-genai": "^2.2.0",
|
||||
"@langchain/openrouter": "^0.4.3",
|
||||
"@types/node": "^20.19.43",
|
||||
"dotenv": "^17.4.2"
|
||||
}
|
||||
|
||||
16
packages/actor/package.json
Normal file
16
packages/actor/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
140
packages/actor/src/actor-prompt-builder.ts
Normal file
140
packages/actor/src/actor-prompt-builder.ts
Normal 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")}`;
|
||||
}
|
||||
}
|
||||
136
packages/actor/src/actor.ts
Normal file
136
packages/actor/src/actor.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
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;
|
||||
|
||||
constructor(
|
||||
private llmProvider: ILLMProvider,
|
||||
bufferRepo?: BufferRepository,
|
||||
memoryLimit?: number,
|
||||
generator?: IActorProseGenerator,
|
||||
) {
|
||||
this.promptBuilder = new ActorPromptBuilder(bufferRepo, memoryLimit);
|
||||
this.decoder = new IntentDecoder(llmProvider);
|
||||
this.generator = generator ?? new LLMActorProseGenerator(llmProvider);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
}
|
||||
2
packages/actor/src/index.ts
Normal file
2
packages/actor/src/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./actor-prompt-builder.js";
|
||||
export * from "./actor.js";
|
||||
14
packages/actor/tsconfig.json
Normal file
14
packages/actor/tsconfig.json
Normal 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" }
|
||||
]
|
||||
}
|
||||
@@ -31,11 +31,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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -64,6 +66,13 @@ export class SQLiteRepository {
|
||||
} 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 {
|
||||
@@ -75,26 +84,38 @@ 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, aliases_json)
|
||||
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,
|
||||
aliases_json = excluded.aliases_json
|
||||
aliases_json = excluded.aliases_json,
|
||||
connections_json = excluded.connections_json
|
||||
`,
|
||||
)
|
||||
.run(obj.id, type, worldId || null, clockIso, locationId, aliasesJson);
|
||||
.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
|
||||
@@ -166,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();
|
||||
}
|
||||
@@ -200,6 +228,54 @@ export class SQLiteRepository {
|
||||
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(
|
||||
@@ -217,6 +293,25 @@ 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(
|
||||
|
||||
@@ -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} (ID: ${e.id}):`);
|
||||
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} (ID: ${e.id}) [elsewhere]`);
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
*/
|
||||
@@ -35,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 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.
|
||||
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.
|
||||
|
||||
@@ -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()),
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import { z } from "zod";
|
||||
|
||||
const LLMConfigSchema = z.object({
|
||||
GOOGLE_API_KEY: z.string().optional(),
|
||||
OPENROUTER_API_KEY: z.string().optional(),
|
||||
});
|
||||
|
||||
export const llmConfig = LLMConfigSchema.parse(process.env);
|
||||
|
||||
@@ -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 "./providers/openrouter.js";
|
||||
|
||||
31
packages/llm/src/providers/openrouter.ts
Normal file
31
packages/llm/src/providers/openrouter.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { z } from "zod";
|
||||
import { ChatOpenRouter } from "@langchain/openrouter";
|
||||
import { ILLMProvider, LLMRequest, LLMResponse } from "../llm.js";
|
||||
import { llmConfig } from "../config.js";
|
||||
|
||||
export class OpenRouterProvider implements ILLMProvider {
|
||||
providerName = "OpenRouter";
|
||||
private model: ChatOpenRouter;
|
||||
|
||||
constructor(apiKey?: string, modelName: string = "google/gemini-2.5-flash") {
|
||||
const key = apiKey || llmConfig.OPENROUTER_API_KEY;
|
||||
if (!key) {
|
||||
throw new Error("OPENROUTER_API_KEY is required to initialize OpenRouterProvider");
|
||||
}
|
||||
this.model = new ChatOpenRouter({
|
||||
apiKey: key,
|
||||
model: modelName,
|
||||
});
|
||||
}
|
||||
|
||||
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([
|
||||
{ role: "system", content: request.systemPrompt },
|
||||
{ role: "user", content: request.userContext },
|
||||
]);
|
||||
return { success: true, data: result as z.infer<T> };
|
||||
}
|
||||
}
|
||||
81
packages/llm/tests/openrouter.test.ts
Normal file
81
packages/llm/tests/openrouter.test.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { describe, test, expect, vi } from "vitest";
|
||||
import { z } from "zod";
|
||||
import { OpenRouterProvider } from "../src/providers/openrouter.js";
|
||||
import { llmConfig } from "../src/config.js";
|
||||
|
||||
// Mock the ChatOpenRouter class
|
||||
vi.mock("@langchain/openrouter", () => {
|
||||
return {
|
||||
ChatOpenRouter: class {
|
||||
config: unknown;
|
||||
constructor(config: unknown) {
|
||||
this.config = config;
|
||||
}
|
||||
withStructuredOutput = vi.fn().mockImplementation(() => {
|
||||
return {
|
||||
invoke: vi.fn().mockImplementation(async () => {
|
||||
// Return a mock output that matches a sample schema
|
||||
return {
|
||||
name: "mocked response",
|
||||
success: true,
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe("OpenRouterProvider Unit Tests (Tier 1)", () => {
|
||||
test("initializes successfully with a provided apiKey", () => {
|
||||
const provider = new OpenRouterProvider("dummy-key");
|
||||
expect(provider.providerName).toBe("OpenRouter");
|
||||
});
|
||||
|
||||
test("initializes successfully with apiKey from config", () => {
|
||||
// Save current config
|
||||
const originalKey = llmConfig.OPENROUTER_API_KEY;
|
||||
llmConfig.OPENROUTER_API_KEY = "env-dummy-key";
|
||||
|
||||
try {
|
||||
const provider = new OpenRouterProvider();
|
||||
expect(provider.providerName).toBe("OpenRouter");
|
||||
} finally {
|
||||
llmConfig.OPENROUTER_API_KEY = originalKey;
|
||||
}
|
||||
});
|
||||
|
||||
test("throws error if no API key is provided or in config", () => {
|
||||
// Save current config
|
||||
const originalKey = llmConfig.OPENROUTER_API_KEY;
|
||||
llmConfig.OPENROUTER_API_KEY = undefined;
|
||||
|
||||
try {
|
||||
expect(() => new OpenRouterProvider()).toThrow(
|
||||
"OPENROUTER_API_KEY is required to initialize OpenRouterProvider"
|
||||
);
|
||||
} finally {
|
||||
llmConfig.OPENROUTER_API_KEY = originalKey;
|
||||
}
|
||||
});
|
||||
|
||||
test("generateStructuredResponse invokes the model with structured output", async () => {
|
||||
const provider = new OpenRouterProvider("dummy-key");
|
||||
const TestSchema = z.object({
|
||||
name: z.string(),
|
||||
success: z.boolean(),
|
||||
});
|
||||
|
||||
const response = await provider.generateStructuredResponse({
|
||||
systemPrompt: "system prompt",
|
||||
userContext: "user context",
|
||||
schema: TestSchema,
|
||||
});
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
expect(response.data).toEqual({
|
||||
name: "mocked response",
|
||||
success: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -25,13 +25,6 @@ export function serializeSubjectiveBufferEntry(
|
||||
entry: BufferEntry,
|
||||
viewer: Entity,
|
||||
): string {
|
||||
const dateObj = new Date(entry.timestamp);
|
||||
// Ensure a deterministic timezone/format for testing and model inputs:
|
||||
const timeStr = dateObj.toLocaleTimeString("en-US", {
|
||||
hour12: true,
|
||||
timeZone: "UTC",
|
||||
});
|
||||
|
||||
const actorAlias = resolveAlias(viewer, entry.intent.actorId);
|
||||
|
||||
const targetAliases = entry.intent.targetIds.map((tid) =>
|
||||
@@ -48,7 +41,7 @@ export function serializeSubjectiveBufferEntry(
|
||||
}
|
||||
}
|
||||
|
||||
return `[${timeStr}] ${actorAlias} ${details}`;
|
||||
return `${actorAlias} ${details}`;
|
||||
}
|
||||
|
||||
export class BufferRepository {
|
||||
|
||||
@@ -39,7 +39,7 @@ describe("Subjective Buffer Entry Serializer Tests (Tier 1)", () => {
|
||||
};
|
||||
|
||||
const result = serializeSubjectiveBufferEntry(entry, viewer);
|
||||
expect(result).toBe('[12:00:00 PM] the hooded figure spoke to the bartender: "Bob greets Charlie"');
|
||||
expect(result).toBe('the hooded figure spoke to the bartender: "Bob greets Charlie"');
|
||||
});
|
||||
|
||||
test("serializes action intent with outcome details", () => {
|
||||
@@ -65,7 +65,7 @@ describe("Subjective Buffer Entry Serializer Tests (Tier 1)", () => {
|
||||
};
|
||||
|
||||
const result = serializeSubjectiveBufferEntry(entry, viewer);
|
||||
expect(result).toBe('[12:05:00 PM] the hooded figure Bob attempts to break the lock latch (Outcome: Failed - The lock is made of reinforced steel.)');
|
||||
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", () => {
|
||||
@@ -86,7 +86,7 @@ describe("Subjective Buffer Entry Serializer Tests (Tier 1)", () => {
|
||||
};
|
||||
|
||||
const resultSelf = serializeSubjectiveBufferEntry(entrySelf, viewer);
|
||||
expect(resultSelf).toBe("[12:10:00 PM] you open the window");
|
||||
expect(resultSelf).toBe("you open the window");
|
||||
|
||||
const entryUnfamiliar: BufferEntry = {
|
||||
id: "entry-unfamiliar",
|
||||
@@ -103,7 +103,7 @@ describe("Subjective Buffer Entry Serializer Tests (Tier 1)", () => {
|
||||
};
|
||||
|
||||
const resultUnfamiliar = serializeSubjectiveBufferEntry(entryUnfamiliar, viewer);
|
||||
expect(resultUnfamiliar).toBe("[12:15:00 PM] an unfamiliar figure knock on the door");
|
||||
expect(resultUnfamiliar).toBe("an unfamiliar figure knock on the door");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
3794
pnpm-lock.yaml
generated
3794
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,12 @@ packages:
|
||||
- "cli"
|
||||
- "web/*"
|
||||
- "content/scenario-builder"
|
||||
- "content/scenario-core"
|
||||
allowBuilds:
|
||||
better-sqlite3: true
|
||||
esbuild: true
|
||||
sharp: true
|
||||
unrs-resolver: true
|
||||
minimumReleaseAgeExclude:
|
||||
- '@astrojs/telemetry@3.3.3'
|
||||
- astro@7.0.7
|
||||
|
||||
219
tests/integration/actor-monologue.test.ts
Normal file
219
tests/integration/actor-monologue.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,8 @@
|
||||
{ "path": "./packages/memory" },
|
||||
{ "path": "./packages/spatial" },
|
||||
{ "path": "./packages/llm" },
|
||||
{ "path": "./packages/actor" },
|
||||
{ "path": "./content/scenario-core" },
|
||||
{ "path": "./cli" }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -14,6 +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/actor": path.resolve(__dirname, "./packages/actor/src"),
|
||||
"@omnia/scenario-core": path.resolve(__dirname, "./content/scenario-core/src"),
|
||||
"@omnia/cli": path.resolve(__dirname, "./cli/src"),
|
||||
},
|
||||
},
|
||||
|
||||
36
web/docs/astro.config.mjs
Normal file
36
web/docs/astro.config.mjs
Normal 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
17
web/docs/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 2.1 KiB |
7
web/docs/src/content.config.ts
Normal file
7
web/docs/src/content.config.ts
Normal 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() }),
|
||||
};
|
||||
93
web/docs/src/content/docs/architecture/actor.md
Normal file
93
web/docs/src/content/docs/architecture/actor.md
Normal 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 |
|
||||
85
web/docs/src/content/docs/architecture/architect.md
Normal file
85
web/docs/src/content/docs/architecture/architect.md
Normal 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.
|
||||
88
web/docs/src/content/docs/architecture/intents.md
Normal file
88
web/docs/src/content/docs/architecture/intents.md
Normal 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)).
|
||||
103
web/docs/src/content/docs/architecture/memory.md
Normal file
103
web/docs/src/content/docs/architecture/memory.md
Normal 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"`.
|
||||
|
||||
43
web/docs/src/content/docs/architecture/overview.md
Normal file
43
web/docs/src/content/docs/architecture/overview.md
Normal 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
|
||||
content/
|
||||
scenario-core/ scenario JSON schema and loader (JSON → SQLite)
|
||||
scenario-builder/ Next.js web UI for authoring worlds
|
||||
demo/ bundled scenarios (talking-room)
|
||||
cli/ the playable loop (human or LLM actors, --scenario / --play flags)
|
||||
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.
|
||||
23
web/docs/src/content/docs/architecture/privacy.md
Normal file
23
web/docs/src/content/docs/architecture/privacy.md
Normal 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
|
||||
24
web/docs/src/content/docs/architecture/spatial.md
Normal file
24
web/docs/src/content/docs/architecture/spatial.md
Normal 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.
|
||||
79
web/docs/src/content/docs/guides/testing.md
Normal file
79
web/docs/src/content/docs/guides/testing.md
Normal 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
|
||||
```
|
||||
45
web/docs/src/content/docs/index.md
Normal file
45
web/docs/src/content/docs/index.md
Normal file
@@ -0,0 +1,45 @@
|
||||
---
|
||||
title: Introduction
|
||||
description: What is Omnia and why it exists
|
||||
---
|
||||
|
||||

|
||||
|
||||
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
11
web/docs/tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@assets/*": ["src/assets/*"]
|
||||
}
|
||||
},
|
||||
"include": [".astro/types.d.ts", "**/*"],
|
||||
"exclude": ["dist"]
|
||||
}
|
||||
Reference in New Issue
Block a user