3 Commits

49 changed files with 6835 additions and 9497 deletions

View File

@@ -1,5 +0,0 @@
# CodeGraph data files — local to each machine, not for committing.
# Ignore everything in .codegraph/ except this file itself, so transient
# files (the database, daemon.pid, sockets, logs) never show up in git.
*
!.gitignore

1
.gitignore vendored
View File

@@ -62,3 +62,4 @@ data/
.vercel
__local_notes/
omnia.code-workspace

View File

@@ -1,65 +0,0 @@
# Git metadata
.git/
.gitignore
.gitattributes
# Dependencies and package managers
node_modules/
.pnpm-store/
.pnp
.pnp.*
.yarn/
.yarn-cache/
# Build and generated output
dist/
dist-ssr/
build/
coverage/
.next/
.astro/
out/
*.tsbuildinfo
.turbo/
.cache/
# Logs and temporary files
*.log
logs/
npm-debug.log*
yarn-debug.log*
pnpm-debug.log*
.pnpm-debug.log*
lerna-debug.log*
*.swp
*.swo
*.swn
*.tmp
*.temp
# Environment and local config
.env
.env*
*.local
# OS/editor files
.DS_Store
Thumbs.db
.idea/
.vscode/
*.suo
*.sln
*.ntvs*
*.njsproj
# Databases and local data
*.db
*.sqlite
*.sqlite3
*.db-journal
*.db-wal
*.db-shm
omnia.db
# Local notes and generated artifacts
__local_notes/

View File

@@ -26,28 +26,35 @@ Omnia is organized as a monorepo managed with **pnpm** workspaces.
### Prerequisites
- **Node.js** (v22.13 or newer recommended)
- **pnpm** (v11 or newer recommended)
- **pnpm** (v11.15.1)
### Local Setup
1. Fork the repository and clone your fork:
```bash
git clone https://github.com/YOUR_USERNAME/omnia-consolidated.git
cd omnia-consolidated
git clone https://github.com/YOUR_USERNAME/omnia.git
cd omnia
```
2. Install dependencies:
2. Install dependencies and compile the workspace packages:
```bash
pnpm install
pnpm install --frozen-lockfile
pnpm build
```
3. Run the Web GUI interface locally:
```bash
pnpm dev:gui
```
The Next.js server hosts `@omnia/runtime`; no separate backend process is
required.
4. Run the Starlight documentation site locally:
```bash
pnpm dev:docs
```
When editing packages, run `pnpm watch` and `pnpm dev:gui` in separate
terminals. The initial `pnpm build` is still required because workspace package
exports resolve to compiled files under `dist/`.
## Development Workflow
### Branching
@@ -72,6 +79,13 @@ pnpm test
pnpm test:watch
```
Before submitting a change, also verify the package and production GUI builds:
```bash
pnpm build
pnpm build:gui
```
### Linting and Formatting
We enforce consistent code quality and formatting rules across the repository.
@@ -94,6 +108,6 @@ pnpm format
## Pull Request Guidelines
1. **Keep PRs Focused**: Keep your changes as small and focused as possible.
2. **Include Tests**: If you are introducing a new feature or fixing a bug, write corresponding tests in `tests/`.
2. **Include Tests**: Add package unit tests under `packages/<package>/tests`, or cross-package tests under `tests/integration`, as appropriate.
3. **Update Documentation**: If your changes alter public behavior or introduce new APIs, update the docs under `web/docs/src/content/docs/`.
4. **Follow Commit Conventions**: Write clear, descriptive commit messages.

View File

@@ -61,25 +61,26 @@ The general principle: **anything that must remain true is state; the model only
### Prerequisites
- [Node.js](https://nodejs.org/) (v20+ recommended)
- [pnpm](https://pnpm.io/) (v9+ recommended)
- [Node.js](https://nodejs.org/) (v22.13 or newer recommended)
- [pnpm](https://pnpm.io/) (v11.15.1)
- An API key for Google Gemini (`GOOGLE_API_KEY` environment variable), or configured settings via the GUI.
### Installation
1. Clone the repository:
```bash
git clone https://github.com/sortedcord/omnia-consolidated.git
cd omnia-consolidated
git clone https://github.com/sortedcord/omnia.git
cd omnia
```
2. Install dependencies:
2. Install dependencies and compile the workspace packages:
```bash
pnpm install
pnpm install --frozen-lockfile
pnpm build
```
### Running the Web GUI
To launch the Next.js development server for the GUI dashboard:
After compiling the workspace packages, launch the Next.js application:
```bash
pnpm dev:gui
@@ -87,6 +88,47 @@ pnpm dev:gui
Access the application locally at `http://localhost:3000`.
The Next.js server hosts `@omnia/runtime`, which owns simulation sessions, turn
execution, provider routing, and simulation persistence. There is no separate
backend process to start.
When changing code under `packages/`, keep the package compiler running in a
second terminal while the GUI development server runs:
```bash
pnpm watch
```
```bash
pnpm dev:gui
```
For a production build, compile the packages before building and starting the
GUI:
```bash
pnpm build
pnpm build:gui
pnpm --filter @omnia/gui start
```
### Running with Docker
Build and run the production application with Docker Compose:
```bash
GOOGLE_API_KEY=your-api-key docker compose up --build
```
For the bind-mounted development environment, use:
```bash
GOOGLE_API_KEY=your-api-key docker compose -f docker-compose.dev.yml up --build
```
In either case, open `http://localhost:3000`. The API key can be omitted when
providers will be configured through the GUI.
## Core Architecture
### The Actor Agent
@@ -158,11 +200,11 @@ The finish line for the first milestone is small on purpose. `v0` is almost on t
- [x] Actor Agent with epistemically-bounded prompts (self, memory, co-located entities, subjective time).
- [x] Verbatim Cognitive 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.
- [x] Scenario loader (JSON → SQLite) and a runtime-driven simulation loop exposed through the GUI.
**[The `v0` Milestone:](https://github.com/sortedcord/omnia-consolidated/milestone/1)**
- [x] Two hand-authored NPCs live in one location, playable via CLI.
- [x] Two hand-authored NPCs live in one location and are playable through the GUI.
- [x] Each has Cognitive Buffer and Memory Ledger memory and recalls something said a few turns earlier.
- [x] One NPC knows a fact the other does not and, provably by testing, will not leak it.
- [x] The Architect processes at least one non-trivial action per exchange with a visible state change.
@@ -187,8 +229,9 @@ omnia/
spatial/ location and POI graph, portal-based perception
llm/ ILLMProvider interface plus Gemini and deterministic mock implementations
scenario/ scenario JSON schema and loader (JSON → SQLite)
runtime/ session lifecycle, turn execution, provider routing, and simulation persistence
apps/
gui/ Next.js Web GUI dashboard and simulation runner
gui/ Next.js UI and server-action adapter; hosts RuntimeService
content/
demo/ bundled scenarios (talking-room)
tests/

View File

@@ -23,8 +23,10 @@ COPY packages/core/package.json packages/core/package.json
COPY packages/intent/package.json packages/intent/package.json
COPY packages/llm/package.json packages/llm/package.json
COPY packages/memory/package.json packages/memory/package.json
COPY packages/runtime/package.json packages/runtime/package.json
COPY packages/scenario/package.json packages/scenario/package.json
COPY packages/spatial/package.json packages/spatial/package.json
COPY packages/voice/package.json packages/voice/package.json
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
pnpm install --frozen-lockfile

View File

@@ -10,6 +10,7 @@ const nextConfig: NextConfig = {
"@omnia/memory",
"@omnia/spatial",
"@omnia/scenario",
"@omnia/runtime",
],
serverExternalPackages: ["better-sqlite3"],
allowedDevOrigins: ["192.168.0.18", "localhost", "127.0.0.1"],

View File

@@ -11,14 +11,8 @@
},
"dependencies": {
"@base-ui/react": "^1.6.0",
"@omnia/actor": "workspace:*",
"@omnia/architect": "workspace:*",
"@omnia/core": "workspace:*",
"@omnia/intent": "workspace:*",
"@omnia/llm": "workspace:*",
"@omnia/memory": "workspace:*",
"@omnia/scenario": "workspace:*",
"@omnia/spatial": "workspace:*",
"@omnia/runtime": "workspace:*",
"@omnia/voice": "workspace:*",
"@radix-ui/react-dialog": "^1.1.19",
"@radix-ui/react-separator": "^1.1.11",

View File

@@ -108,7 +108,7 @@ export function ConfigView() {
Configuration
</h1>
<h2 className="mb-3 text-headline-md text-foreground">
Manage Model Instances
Manage Model Providers
</h2>
{config === null && loading && (
<p className="text-body-md text-muted-foreground">

View File

@@ -1,111 +1,11 @@
export interface IntentInfo {
type: string;
content: string;
modifiers: string[];
targetIds: string[];
isValid?: boolean;
reason?: string;
minutesToAdvance?: number;
}
export interface PromptComponent {
label: string;
type: "system" | "world" | "events" | "memories" | "input" | "other";
content: string;
}
export interface PromptBreakdown {
systemPrompt: string;
userContext: string;
components?: PromptComponent[];
}
export interface ValidatorCall {
intentIndex: number;
intentContent: string;
prompt?: PromptBreakdown;
response: {
isValid: boolean;
reason: string;
};
usage?: {
inputTokens: number;
outputTokens: number;
totalTokens: number;
modelName?: string;
providerInstanceName?: string;
maxContext?: number;
};
}
export interface HandoffResult {
chunks: {
content: string;
importance: number;
quotes?: string[];
retainInBuffer?: boolean;
involvedEntityIds?: string[];
}[];
}
export interface LogEntry {
turn: number;
entityId: string;
entityName: string;
narrativeProse: string;
intents: IntentInfo[];
timestamp: string;
isHandoff?: boolean;
handoffResult?: HandoffResult;
decodedIntents?: IntentInfo[];
validatorCalls?: ValidatorCall[];
rawPrompt?: PromptBreakdown;
usage?: {
inputTokens: number;
outputTokens: number;
totalTokens: number;
modelName?: string;
providerInstanceName?: string;
maxContext?: number;
};
decoderPrompt?: PromptBreakdown;
decoderUsage?: {
inputTokens: number;
outputTokens: number;
totalTokens: number;
modelName?: string;
providerInstanceName?: string;
maxContext?: number;
};
}
export interface EntityInfo {
id: string;
name: string;
isPlayer: boolean;
isAgent: boolean;
aliases?: Record<string, string>;
}
export interface WaitingContext {
entityId: string;
name: string;
systemPrompt: string;
userContext: string;
}
export interface SimSnapshot {
id: string;
status: "running" | "waiting_player" | "done" | "error";
turn: number;
maxTurns: number;
scenarioName: string;
scenarioDescription: string;
entities: EntityInfo[];
log: LogEntry[];
entityIndex: number;
waitingEntity?: WaitingContext;
error?: string;
worldTime?: string;
currentLocation?: string;
}
export type {
EntityInfo,
HandoffResult,
IntentInfo,
LogEntry,
PromptBreakdown,
PromptComponent,
SimSnapshot,
ValidatorCall,
WaitingContext,
} from "@omnia/runtime";

View File

@@ -1,99 +0,0 @@
import { HandoffEngine, checkHandoffTrigger } from "@omnia/memory";
import type { SimSession } from "./types";
/**
* Runs the HandoffEngine for every agent entity that has accumulated enough
* buffer entries to warrant a handoff (compression to the Memory Ledger).
*/
export async function runHandoffResolution(session: SimSession): Promise<void> {
const worldState = session.coreRepo.loadWorldState(session.worldInstanceId);
if (!worldState) throw new Error("World state lost");
const handoffEngine = new HandoffEngine(
session.handoffProvider,
session.embeddingProvider,
session.bufferRepo,
session.ledgerRepo,
);
const entities = Array.from(worldState.entities.values());
for (const entity of entities) {
if (!entity.isAgent) continue;
const bufferEntries = session.bufferRepo.listForOwner(entity.id);
const maxContext =
session.handoffProvider.maxContext !== undefined
? session.handoffProvider.maxContext
: 32768;
const trigger = checkHandoffTrigger(
entity,
bufferEntries,
worldState.clock.get(),
maxContext,
);
if (trigger !== "none") {
const ran = await handoffEngine.runHandoff(
entity,
bufferEntries,
worldState.clock.get(),
);
if (ran) {
const lastResult = handoffEngine.lastResult;
const lastCall =
session.handoffProvider.lastCalls?.[
(session.handoffProvider.lastCalls?.length || 0) - 1
];
const info = session.entities.find((e) => e.id === entity.id);
const entityName = info?.name || entity.id;
session.log.push({
turn: session.turn,
entityId: entity.id,
entityName,
narrativeProse: `Handoff triggered for ${entityName}: memories were transferred from Cognitive Buffer to Memory Ledger`,
intents: [],
timestamp: worldState.clock.get().toISOString(),
isHandoff: true,
rawPrompt: lastResult
? {
systemPrompt: lastResult.systemPrompt || "",
userContext: lastResult.userContext || "",
components: lastResult.promptComponents,
}
: undefined,
usage: lastCall?.usage,
handoffResult: (lastResult?.response || lastCall?.response) as any,
});
}
}
}
}
/**
* For every agent that shares a location with another entity they haven't
* previously encountered, generates a first-person alias description and
* persists it on the viewing entity.
*/
export async function runAliasResolution(session: SimSession): Promise<void> {
const worldState = session.coreRepo.loadWorldState(session.worldInstanceId);
if (!worldState) throw new Error("World state lost");
const entities = Array.from(worldState.entities.values());
for (const viewer of entities) {
if (!viewer.isAgent) continue;
if (!viewer.locationId) continue;
for (const target of entities) {
if (viewer.id === target.id) continue;
if (
target.locationId === viewer.locationId &&
!viewer.aliases.has(target.id)
) {
const alias = await session.aliasGenerator.generate(viewer, target);
viewer.aliases.set(target.id, alias);
session.coreRepo.saveEntity(viewer, worldState.id);
}
}
}
}

View File

@@ -1,16 +0,0 @@
import dotenv from "dotenv";
import path from "path";
import fs from "fs";
// Load .env from monorepo root or apps/gui/
const cwd = process.cwd();
const envCandidates = [
path.resolve(cwd, ".env"),
path.resolve(cwd, "../../.env"),
];
for (const c of envCandidates) {
if (fs.existsSync(c) && fs.statSync(c).isFile()) {
dotenv.config({ path: c });
break;
}
}

View File

@@ -1,12 +1,6 @@
/**
* Barrel entry point for the simulation module.
*
* Consumers import from "@/lib/simulation" exactly as before — no import
* paths need to change anywhere in the codebase.
*/
import { SimulationManager } from "./simulation-manager";
import { RuntimeService } from "@omnia/runtime";
export const simulationManager = new SimulationManager();
export const simulationManager = new RuntimeService();
export type {
SimSnapshot,
@@ -14,4 +8,4 @@ export type {
LogEntry,
IntentInfo,
WaitingContext,
} from "../simulation-types";
} from "@omnia/runtime";

View File

@@ -1,146 +0,0 @@
import {
MockLLMProvider,
MockEmbeddingProvider,
ProviderManager,
buildLLMProvider,
buildEmbeddingProvider,
} from "@omnia/llm";
import type {
ILLMProvider,
IEmbeddingProvider,
ModelProviderInstance,
} from "@omnia/llm";
// ---------------------------------------------------------------------------
// Public types
// ---------------------------------------------------------------------------
export interface ResolvedProviders {
actorProvider: ILLMProvider;
validatorProvider: ILLMProvider;
decoderProvider: ILLMProvider;
timedeltaProvider: ILLMProvider;
handoffProvider: ILLMProvider;
embeddingProvider: IEmbeddingProvider;
}
export interface ProviderResolverOptions {
/**
* Pre-resolved generative instance to fall back to when ProviderManager has
* no active generative provider (e.g. when the caller already validated a
* specific provider during session creation).
*/
fallbackInstance?: ModelProviderInstance | null;
/**
* When true, throws an Error if no provider can be resolved for a task.
* When false (default), falls back silently to MockLLMProvider / MockEmbeddingProvider.
*/
required?: boolean;
}
// ---------------------------------------------------------------------------
// Resolution logic
// ---------------------------------------------------------------------------
/**
// Public API
// ---------------------------------------------------------------------------
/**
* Resolves all six LLM + embedding providers needed for a simulation session.
*
* Resolution order for each generative task:
* 1. Task-specific mapping from ProviderManager (via `mappings[task]`)
* 2. ProviderManager active generative instance
* 3. `fallbackInstance` (if supplied)
* 4. GOOGLE_API_KEY env var → auto-creates a temporary GeminiProvider
* 5. Throws (if `required`) or returns MockLLMProvider
*/
export function resolveProviders(
mappings: Record<string, string>,
options: ProviderResolverOptions = {},
): ResolvedProviders {
const { fallbackInstance = null, required = false } = options;
const list = ProviderManager.list();
const activeGenerative =
ProviderManager.getActive("generative") ?? fallbackInstance ?? null;
const resolveGenerative = (task: string): ILLMProvider => {
const mappedId = mappings[task];
let inst: ModelProviderInstance | null = mappedId
? (list.find((p) => p.id === mappedId) ?? null)
: null;
if (!inst || inst.type !== "generative") {
inst = activeGenerative;
}
if (!inst) {
const envKey = process.env.GOOGLE_API_KEY;
if (envKey) {
inst = ProviderManager.create(
"Default (Env)",
"google-genai",
envKey,
undefined,
"generative",
);
}
}
if (!inst) {
if (required) {
throw new Error(
`No active LLM Provider Instance found for task "${task}". Please configure a key in Settings first.`,
);
}
return new MockLLMProvider([]);
}
return buildLLMProvider(inst);
};
const resolveEmbedding = (): IEmbeddingProvider => {
const mappedId = mappings["embeddings"];
let inst: ModelProviderInstance | null = mappedId
? (list.find((p) => p.id === mappedId) ?? null)
: null;
if (!inst || inst.type !== "embedding") {
inst = ProviderManager.getActive("embedding");
}
if (!inst) {
const envKey = process.env.GOOGLE_API_KEY;
if (envKey) {
inst = ProviderManager.create(
"Default Embed (Env)",
"google-genai",
envKey,
"gemini-embedding-001",
"embedding",
);
}
}
if (!inst) {
if (required) {
throw new Error(
`No active Embedding Provider Instance found. Please configure an embedding key in Settings first.`,
);
}
return new MockEmbeddingProvider(undefined);
}
return buildEmbeddingProvider(inst);
};
return {
actorProvider: resolveGenerative("actor-prose"),
validatorProvider: resolveGenerative("llm-validator"),
decoderProvider: resolveGenerative("intent-decoder"),
timedeltaProvider: resolveGenerative("timedelta"),
handoffProvider: resolveGenerative("handoff"),
embeddingProvider: resolveEmbedding(),
};
}

View File

@@ -1,139 +0,0 @@
import Database from "better-sqlite3";
import path from "path";
import fs from "fs";
import type { SimSession, SavedState } from "./types";
import type { SimSnapshot } from "../simulation-types";
export const DATA_DIR = path.resolve(process.cwd(), "data");
// ---------------------------------------------------------------------------
// Low-level read/write helpers
// ---------------------------------------------------------------------------
export function loadSessionState(
db: Database.Database,
id: string,
): SavedState | null {
try {
db.prepare(
`CREATE TABLE IF NOT EXISTS gui_meta (
id TEXT PRIMARY KEY,
state_json TEXT
)`,
).run();
const row = db
.prepare(`SELECT state_json FROM gui_meta WHERE id = ?`)
.get(id) as { state_json: string } | undefined;
return row ? (JSON.parse(row.state_json) as SavedState) : null;
} catch {
return null;
}
}
export function saveSession(session: SimSession): void {
const state: SavedState = {
scenarioName: session.scenarioName,
scenarioDescription: session.scenarioDescription,
turn: session.turn,
maxTurns: session.maxTurns,
entities: session.entities,
playerEntityId: session.playerEntityId,
entityIndex: session.entityIndex,
status: session.status,
error: session.error,
waitingEntity: session.waitingEntity,
aliasDoneForTurn: session.aliasDoneForTurn,
log: session.log,
providerMappings: session.providerMappings,
};
session.db
.prepare(
`CREATE TABLE IF NOT EXISTS gui_meta (
id TEXT PRIMARY KEY,
state_json TEXT
)`,
)
.run();
session.db
.prepare(
`INSERT INTO gui_meta (id, state_json)
VALUES (?, ?)
ON CONFLICT(id) DO UPDATE SET state_json = excluded.state_json`,
)
.run(session.worldInstanceId, JSON.stringify(state));
}
// ---------------------------------------------------------------------------
// Session file management
// ---------------------------------------------------------------------------
export function deleteSessionFile(id: string): void {
const dbPath = path.join(DATA_DIR, `${id}.db`);
if (fs.existsSync(dbPath)) {
try {
fs.unlinkSync(dbPath);
} catch (err) {
console.error(`Failed to delete session file ${dbPath}:`, err);
}
}
}
/**
* Lists all saved simulation snapshots by scanning the data directory.
* Active in-memory sessions are snapshotted via the provided callback;
* inactive ones are read directly from their `.db` files.
*/
export function listSavedSessions(
activeSessions: Map<string, SimSession>,
snapshotFn: (session: SimSession) => SimSnapshot,
): SimSnapshot[] {
if (!fs.existsSync(DATA_DIR)) return [];
const snapshots: SimSnapshot[] = [];
const files = fs
.readdirSync(DATA_DIR)
.filter((f) => f.startsWith("sim-") && f.endsWith(".db"));
for (const file of files) {
const id = file.replace(".db", "");
const dbPath = path.join(DATA_DIR, file);
const active = activeSessions.get(id);
if (active) {
snapshots.push(snapshotFn(active));
continue;
}
try {
const db = new Database(dbPath);
const state = loadSessionState(db, id);
db.close();
if (state) {
snapshots.push({
id,
status: state.status,
turn: state.turn,
maxTurns: state.maxTurns,
scenarioName: state.scenarioName,
scenarioDescription: state.scenarioDescription,
entities: state.entities || [],
log: state.log || [],
entityIndex: state.entityIndex,
waitingEntity: state.waitingEntity,
error: state.error,
});
}
} catch {
/* skip corrupt / in-use db files */
}
}
return snapshots.sort((a, b) => {
const tsA = parseInt(a.id.replace("sim-", ""), 10) || 0;
const tsB = parseInt(b.id.replace("sim-", ""), 10) || 0;
return tsB - tsA;
});
}

View File

@@ -1,519 +0,0 @@
import "./env"; // Must be first — loads .env before any code reads process.env
import Database from "better-sqlite3";
import path from "path";
import fs from "fs";
import { SQLiteRepository } from "@omnia/core";
import { BufferRepository, LedgerRepository } from "@omnia/memory";
import { Architect, AliasDeltaGenerator } from "@omnia/architect";
import { ProviderManager, buildEmbeddingProvider } from "@omnia/llm";
import type { ModelProviderInstance, IEmbeddingProvider } from "@omnia/llm";
import { ScenarioLoader } from "@omnia/scenario";
import type { SimSnapshot } from "../simulation-types";
import type { SimSession, EntityInfo } from "./types";
import { resolveProviders } from "./provider-resolver";
import {
DATA_DIR,
loadSessionState,
saveSession,
listSavedSessions,
deleteSessionFile,
} from "./session-store";
import {
preparePlayerTurn,
processNpcTurn,
executePlayerAction,
} from "./turn-executor";
import { runAliasResolution, runHandoffResolution } from "./alias-handoff";
export class SimulationManager {
private sessions = new Map<string, SimSession>();
// ---------------------------------------------------------------------------
// Session lifecycle
// ---------------------------------------------------------------------------
async create(
scenarioPath: string,
playEntityName?: string,
providerInstanceId?: string,
customName?: string,
): Promise<SimSnapshot> {
// Resolve or validate the active generative provider upfront so we can
// return a clean error snapshot before touching the filesystem.
let activeInstance: ModelProviderInstance | null = providerInstanceId
? ProviderManager.list().find((p) => p.id === providerInstanceId) || null
: ProviderManager.getActive("generative");
if (!activeInstance) {
const envKey = process.env.GOOGLE_API_KEY;
if (envKey) {
activeInstance = ProviderManager.create(
"Default (Env)",
"google-genai",
envKey,
undefined,
"generative",
);
}
}
if (!activeInstance) {
return {
id: "",
status: "error",
turn: 0,
maxTurns: 20,
scenarioName: "",
scenarioDescription: "",
entities: [],
log: [],
entityIndex: 0,
error:
"No active LLM Provider Instance found. Please configure a key in Settings first.",
};
}
const scenarioJson = JSON.parse(fs.readFileSync(scenarioPath, "utf-8"));
const id = `sim-${Date.now()}`;
fs.mkdirSync(DATA_DIR, { recursive: true });
const dbPath = path.join(DATA_DIR, `${id}.db`);
const db = new Database(dbPath);
const coreRepo = new SQLiteRepository(db);
const bufferRepo = new BufferRepository(db);
const ledgerRepo = new LedgerRepository(db);
const loader = new ScenarioLoader(coreRepo, bufferRepo);
const worldInstanceId = id;
await loader.initializeWorld(scenarioJson, worldInstanceId);
const worldState = coreRepo.loadWorldState(worldInstanceId);
if (!worldState) {
db.close();
return {
id: "",
status: "error",
turn: 0,
maxTurns: 20,
scenarioName: "",
scenarioDescription: "",
entities: [],
log: [],
entityIndex: 0,
error: "Failed to load world state after initialization.",
};
}
// Build entity list
const rawEntities = Array.from(worldState.entities.values());
const entityInfos: EntityInfo[] = rawEntities.map((e) => ({
id: e.id,
name: (e.attributes.get("name")?.getValue() as string) || e.id,
isPlayer: false,
isAgent: e.isAgent,
}));
// Resolve player entity (exact match → name match → fuzzy)
let playerEntityId: string | undefined;
if (playEntityName) {
let matched = worldState.getEntity(playEntityName);
if (!matched) {
for (const ent of rawEntities) {
const nameAttr = ent.attributes.get("name")?.getValue() as
string | undefined;
if (nameAttr?.toLowerCase() === playEntityName.toLowerCase()) {
matched = ent;
break;
}
}
}
if (!matched) {
for (const ent of rawEntities) {
const nameAttr = ent.attributes.get("name")?.getValue() as
string | undefined;
if (
nameAttr?.toLowerCase().includes(playEntityName.toLowerCase()) ||
ent.id.toLowerCase().includes(playEntityName.toLowerCase())
) {
matched = ent;
break;
}
}
}
if (matched) {
playerEntityId = matched.id;
const info = entityInfos.find((e) => e.id === matched!.id);
if (info) info.isPlayer = true;
}
}
const mappings = ProviderManager.getMappings();
const {
actorProvider,
validatorProvider,
decoderProvider,
timedeltaProvider,
handoffProvider,
embeddingProvider,
} = resolveProviders(mappings, { fallbackInstance: activeInstance });
const architect = new Architect(
{ validator: validatorProvider, timedelta: timedeltaProvider },
coreRepo,
);
const aliasGenerator = new AliasDeltaGenerator(actorProvider);
const session: SimSession = {
db,
dbPath,
coreRepo,
bufferRepo,
ledgerRepo,
worldInstanceId,
scenarioName: customName || scenarioJson.name,
scenarioDescription: scenarioJson.description || "",
turn: 1,
maxTurns: 20,
entities: entityInfos,
playerEntityId,
entityIndex: 0,
actorProvider,
validatorProvider,
decoderProvider,
timedeltaProvider,
handoffProvider,
embeddingProvider,
architect,
aliasGenerator,
log: [],
status: "running",
aliasDoneForTurn: false,
providerMappings: mappings,
};
this.sessions.set(id, session);
return this.snapshot(session);
}
async load(id: string): Promise<SimSnapshot | null> {
const active = this.sessions.get(id);
if (active) return this.snapshot(active);
const dbPath = path.join(DATA_DIR, `${id}.db`);
if (!fs.existsSync(dbPath)) return null;
try {
const db = new Database(dbPath);
const state = loadSessionState(db, id);
if (!state) {
db.close();
return null;
}
const mappings = state.providerMappings || {};
const {
actorProvider,
validatorProvider,
decoderProvider,
timedeltaProvider,
handoffProvider,
embeddingProvider,
} = resolveProviders(mappings, { required: true });
const coreRepo = new SQLiteRepository(db);
const bufferRepo = new BufferRepository(db);
const ledgerRepo = new LedgerRepository(db);
const architect = new Architect(
{ validator: validatorProvider, timedelta: timedeltaProvider },
coreRepo,
);
const aliasGenerator = new AliasDeltaGenerator(actorProvider);
const session: SimSession = {
db,
dbPath,
coreRepo,
bufferRepo,
ledgerRepo,
worldInstanceId: id,
scenarioName: state.scenarioName,
scenarioDescription: state.scenarioDescription,
turn: state.turn,
maxTurns: state.maxTurns,
entities: state.entities || [],
playerEntityId: state.playerEntityId,
entityIndex: state.entityIndex,
actorProvider,
validatorProvider,
decoderProvider,
timedeltaProvider,
handoffProvider,
embeddingProvider,
architect,
aliasGenerator,
log: state.log || [],
status: state.status,
error: state.error,
waitingEntity: state.waitingEntity,
aliasDoneForTurn: state.aliasDoneForTurn || false,
providerMappings: mappings,
};
this.sessions.set(id, session);
return this.snapshot(session);
} catch (err) {
console.error(`Failed to load session ${id}:`, err);
return null;
}
}
close(id: string): void {
const session = this.sessions.get(id);
if (session) {
session.db.close();
this.sessions.delete(id);
}
}
deleteSession(id: string): void {
const session = this.sessions.get(id);
if (session) {
session.db.close();
this.sessions.delete(id);
}
deleteSessionFile(id);
}
listSavedSessions(): SimSnapshot[] {
return listSavedSessions(this.sessions, (s) => this.snapshot(s));
}
getSnapshot(id: string): SimSnapshot | null {
const session = this.sessions.get(id);
return session ? this.snapshot(session) : null;
}
async rename(id: string, newName: string): Promise<SimSnapshot | null> {
let session = this.sessions.get(id);
if (!session) {
await this.load(id);
session = this.sessions.get(id);
}
if (!session) return null;
session.scenarioName = newName;
saveSession(session);
return this.snapshot(session);
}
// ---------------------------------------------------------------------------
// Simulation stepping
// ---------------------------------------------------------------------------
async step(id: string): Promise<SimSnapshot | null> {
const session = this.sessions.get(id);
if (!session) return null;
if (session.status !== "running") return this.snapshot(session);
try {
if (session.turn > session.maxTurns) {
session.status = "done";
saveSession(session);
return this.snapshot(session);
}
// Start of turn: alias + handoff resolution before any entity acts
if (!session.aliasDoneForTurn && session.entityIndex === 0) {
await runAliasResolution(session);
await runHandoffResolution(session);
session.aliasDoneForTurn = true;
saveSession(session);
return this.snapshot(session);
}
// End of turn: advance to next turn
if (session.entityIndex >= session.entities.length) {
session.turn++;
session.entityIndex = 0;
session.aliasDoneForTurn = false;
saveSession(session);
return this.snapshot(session);
}
const info = session.entities[session.entityIndex];
if (!info.isAgent) {
session.entityIndex++;
saveSession(session);
return this.snapshot(session);
}
if (info.isPlayer) {
await preparePlayerTurn(session, info);
saveSession(session);
return this.snapshot(session);
}
await processNpcTurn(session, info);
session.entityIndex++;
} catch (err) {
session.status = "error";
session.error = err instanceof Error ? err.message : String(err);
}
saveSession(session);
return this.snapshot(session);
}
async submitPlayerAction(
id: string,
prose: string,
): Promise<SimSnapshot | null> {
const session = this.sessions.get(id);
if (!session) return null;
if (session.status !== "waiting_player") return this.snapshot(session);
if (!session.waitingEntity) return this.snapshot(session);
const ctx = session.waitingEntity;
session.waitingEntity = undefined;
session.status = "running";
try {
await executePlayerAction(session, ctx, prose);
session.entityIndex++;
} catch (err) {
session.status = "error";
session.error = err instanceof Error ? err.message : String(err);
}
saveSession(session);
return this.snapshot(session);
}
// ---------------------------------------------------------------------------
// Utility
// ---------------------------------------------------------------------------
async regenerateAllEmbeddings(newProviderInstanceId?: string): Promise<void> {
if (!fs.existsSync(DATA_DIR)) return;
const files = fs
.readdirSync(DATA_DIR)
.filter((f) => f.startsWith("sim-") && f.endsWith(".db"));
const list = ProviderManager.list();
let inst = newProviderInstanceId
? (list.find((p) => p.id === newProviderInstanceId) ?? null)
: null;
if (!inst || inst.type !== "embedding") {
inst = ProviderManager.getActive("embedding");
}
if (!inst) {
const envKey = process.env.GOOGLE_API_KEY || "";
if (envKey) {
inst = {
id: "regen-env-fallback",
name: "Gemini Embed (Env)",
providerName: "google-genai",
apiKey: envKey,
isActive: true,
modelName: "gemini-embedding-001",
type: "embedding",
maxContext: 0,
};
} else {
inst = {
id: "regen-mock-fallback",
name: "Mock Embed (Fallback)",
providerName: "mock",
apiKey: "",
isActive: true,
modelName: undefined,
type: "embedding",
maxContext: 0,
};
}
}
const embeddingProvider: IEmbeddingProvider = buildEmbeddingProvider(inst);
for (const file of files) {
const dbPath = path.join(DATA_DIR, file);
const fileId = file.replace(".db", "");
const activeSession = this.sessions.get(fileId);
const db = activeSession ? activeSession.db : new Database(dbPath);
try {
const rows = db
.prepare(`SELECT id, content FROM ledger_entries`)
.all() as { id: string; content: string }[];
for (const row of rows) {
const vector = await embeddingProvider.embed(row.content);
const buffer = Buffer.from(new Float32Array(vector).buffer);
db.prepare(
`UPDATE ledger_entries SET embedding = ? WHERE id = ?`,
).run(buffer, row.id);
}
} catch (err) {
console.error(`Failed to regenerate embeddings for ${file}:`, err);
} finally {
if (!activeSession) db.close();
}
}
}
// ---------------------------------------------------------------------------
// Private
// ---------------------------------------------------------------------------
private snapshot(session: SimSession): SimSnapshot {
const worldState = session.coreRepo.loadWorldState(session.worldInstanceId);
const hydratedEntities = session.entities.map((e) => {
const actualEntity = worldState?.getEntity(e.id);
const aliases: Record<string, string> = {};
if (actualEntity) {
for (const [targetId, alias] of actualEntity.aliases.entries()) {
aliases[targetId] = alias;
}
}
return {
...e,
aliases,
};
});
// Get current location from the waiting entity
let currentLocation: string | undefined;
if (
worldState &&
session.entityIndex >= 0 &&
session.entityIndex < session.entities.length
) {
const currentEntityInfo = session.entities[session.entityIndex];
const actualEntity = worldState.getEntity(currentEntityInfo.id);
if (actualEntity?.locationId) {
const location = worldState.getLocation(actualEntity.locationId);
if (location) {
currentLocation = location.id;
}
}
}
return {
id: session.worldInstanceId,
status: session.status,
turn: session.turn,
maxTurns: session.maxTurns,
scenarioName: session.scenarioName,
scenarioDescription: session.scenarioDescription,
entities: hydratedEntities,
log: session.log,
entityIndex: session.entityIndex,
waitingEntity: session.waitingEntity,
error: session.error,
worldTime: worldState?.clock.get().toISOString(),
currentLocation,
};
}
}

View File

@@ -1,379 +0,0 @@
import {
ActorAgent,
ActorPromptBuilder,
buildBufferEntryForIntent,
} from "@omnia/actor";
import type { IActorProseGenerator } from "@omnia/actor";
import type { SimSession } from "./types";
import type {
EntityInfo,
IntentInfo,
LogEntry,
WaitingContext,
ValidatorCall,
} from "../simulation-types";
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
/** Prose generator that returns a fixed player-supplied string verbatim. */
class FixedProseGenerator implements IActorProseGenerator {
constructor(private prose: string) {}
async generate(
entityId: string,
systemPrompt: string,
userContext: string,
): Promise<string> {
void entityId;
void systemPrompt;
void userContext;
return this.prose;
}
}
/**
* Processes every intent produced by an actor turn:
* - Validates via Architect
* - Appends to actor's own buffer
* - Fan-outs to co-located observers for dialogue/action intents
*
* Extracted to eliminate verbatim duplication between NPC and player paths.
*/
async function processIntents(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
intents: any[],
actorEntityId: string,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
entity: any,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
worldState: any,
session: SimSession,
): Promise<{ intentInfos: IntentInfo[]; validatorCalls: ValidatorCall[] }> {
const intentInfos: IntentInfo[] = [];
const validatorCalls: ValidatorCall[] = [];
for (let i = 0; i < intents.length; i++) {
const intent = intents[i];
const outcome = await session.architect.processIntent(worldState, intent);
const ts = worldState.clock.get().toISOString();
intentInfos.push({
type: intent.type,
content: intent.content,
modifiers: intent.modifiers || [],
targetIds: intent.targetIds,
isValid: outcome.isValid,
reason: outcome.reason,
minutesToAdvance: outcome.timeDelta?.minutesToAdvance,
});
if (intent.type === "action" && session.architect.validator.lastResult) {
const lastResult = session.architect.validator.lastResult;
let usage = undefined;
if (
session.validatorProvider.lastCalls &&
session.validatorProvider.lastCalls.length > 0
) {
const valCall =
session.validatorProvider.lastCalls[
session.validatorProvider.lastCalls.length - 1
];
usage = valCall.usage;
}
validatorCalls.push({
intentIndex: i,
intentContent: intent.content,
prompt: {
systemPrompt: lastResult.systemPrompt || "",
userContext: lastResult.userContext || "",
components: lastResult.components,
},
response: {
isValid: outcome.isValid,
reason: outcome.reason,
},
usage,
});
} else {
const reason =
intent.type === "dialogue"
? "Dialogue intents represent verbal/communication actions and are automatically valid."
: "Monologue/thought intents represent internal reflections and bypass validation.";
validatorCalls.push({
intentIndex: i,
intentContent: intent.content,
response: {
isValid: true,
reason: outcome.reason || reason,
},
});
}
const actorEntry = buildBufferEntryForIntent(intent, ts, entity.locationId);
if (intent.type === "action") {
actorEntry.outcome = { isValid: outcome.isValid, reason: outcome.reason };
}
session.bufferRepo.save(actorEntry);
// Fan-out observable events to co-located entities
if (
entity.locationId &&
(intent.type === "dialogue" || intent.type === "action")
) {
for (const [, other] of worldState.entities) {
if (
other.id !== actorEntityId &&
other.locationId === entity.locationId
) {
const observerEntry = buildBufferEntryForIntent(
intent,
ts,
entity.locationId,
);
if (intent.type === "action") {
observerEntry.outcome = {
isValid: outcome.isValid,
reason: outcome.reason,
};
}
session.bufferRepo.save({ ...observerEntry, ownerId: other.id });
}
}
}
}
return { intentInfos, validatorCalls };
}
// ---------------------------------------------------------------------------
// Exported turn functions
// ---------------------------------------------------------------------------
/**
* Builds the prompt for the player entity and sets the session to
* `waiting_player` so the next client call can supply the prose.
*/
export async function preparePlayerTurn(
session: SimSession,
info: EntityInfo,
): Promise<void> {
const worldState = session.coreRepo.loadWorldState(session.worldInstanceId);
if (!worldState) throw new Error("World state lost");
const entity = worldState.getEntity(info.id);
if (!entity) throw new Error(`Entity "${info.id}" not found`);
const promptBuilder = new ActorPromptBuilder(
session.bufferRepo,
session.ledgerRepo,
20,
);
const { systemPrompt, userContext } = promptBuilder.build(worldState, entity);
session.waitingEntity = {
entityId: info.id,
name: info.name,
systemPrompt,
userContext,
};
session.status = "waiting_player";
}
/**
* Runs an autonomous NPC turn: generates prose via ActorAgent, validates
* and persists all intents, and appends a LogEntry to the session.
*/
export async function processNpcTurn(
session: SimSession,
info: EntityInfo,
): Promise<void> {
const worldState = session.coreRepo.loadWorldState(session.worldInstanceId);
if (!worldState) throw new Error("World state lost");
const entity = worldState.getEntity(info.id);
if (!entity) throw new Error(`Entity "${info.id}" not found`);
const actor = new ActorAgent(
{ actor: session.actorProvider, decoder: session.decoderProvider },
session.bufferRepo,
session.ledgerRepo,
20,
);
const result = await actor.act(worldState, entity);
const entry: LogEntry = {
turn: session.turn,
entityId: info.id,
entityName: info.name,
narrativeProse: result.narrativeProse,
intents: [],
timestamp: worldState.clock.get().toISOString(),
rawPrompt: {
systemPrompt: result.systemPrompt || "",
userContext: result.userContext || "",
components: result.promptComponents,
},
};
if (
session.actorProvider.lastCalls &&
session.actorProvider.lastCalls.length > 0
) {
const actorCall =
session.actorProvider.lastCalls[
session.actorProvider.lastCalls.length - 1
];
entry.usage = actorCall.usage;
}
if (
session.decoderProvider.lastCalls &&
session.decoderProvider.lastCalls.length > 0
) {
const decoderCall =
session.decoderProvider.lastCalls[
session.decoderProvider.lastCalls.length - 1
];
const proseHeader = "=== NARRATIVE PROSE ===";
const userContext = decoderCall.userContext;
const idx = userContext.indexOf(proseHeader);
let contextStr = userContext;
let proseStr = "";
if (idx !== -1) {
contextStr = userContext.substring(0, idx).trim();
proseStr = userContext.substring(idx).trim();
}
entry.decoderPrompt = {
systemPrompt: decoderCall.systemPrompt,
userContext: decoderCall.userContext,
components: [
{
label: "System Prompt",
type: "system",
content: decoderCall.systemPrompt,
},
{ label: "Decoder Context", type: "world", content: contextStr },
{ label: "Narrative Prose", type: "input", content: proseStr },
],
};
entry.decoderUsage = decoderCall.usage;
}
const { intentInfos, validatorCalls } = await processIntents(
result.intents.intents,
info.id,
entity,
worldState,
session,
);
entry.intents = intentInfos;
entry.validatorCalls = validatorCalls;
entry.decodedIntents = result.intents.intents.map((intent) => ({
type: intent.type,
content: intent.content,
modifiers: intent.modifiers || [],
targetIds: intent.targetIds,
}));
session.log.push(entry);
session.coreRepo.saveWorldState(worldState);
}
/**
* Executes the player's turn using the prose they supplied.
* Uses a `FixedProseGenerator` so the ActorAgent bypasses its LLM call and
* returns the player's text directly.
*/
export async function executePlayerAction(
session: SimSession,
ctx: WaitingContext,
prose: string,
): Promise<void> {
const worldState = session.coreRepo.loadWorldState(session.worldInstanceId);
if (!worldState) throw new Error("World state lost");
const entity = worldState.getEntity(ctx.entityId);
if (!entity) throw new Error(`Player entity "${ctx.entityId}" not found`);
const playerActor = new ActorAgent(
{ actor: session.actorProvider, decoder: session.decoderProvider },
session.bufferRepo,
session.ledgerRepo,
20,
new FixedProseGenerator(prose),
);
const result = await playerActor.act(worldState, entity);
const entry: LogEntry = {
turn: session.turn,
entityId: ctx.entityId,
entityName: ctx.name,
narrativeProse: result.narrativeProse,
intents: [],
timestamp: worldState.clock.get().toISOString(),
rawPrompt: {
systemPrompt: result.systemPrompt || ctx.systemPrompt,
userContext: result.userContext || ctx.userContext,
components: result.promptComponents,
},
};
if (
session.decoderProvider.lastCalls &&
session.decoderProvider.lastCalls.length > 0
) {
const call =
session.decoderProvider.lastCalls[
session.decoderProvider.lastCalls.length - 1
];
const proseHeader = "=== NARRATIVE PROSE ===";
const userContext = call.userContext;
const idx = userContext.indexOf(proseHeader);
let contextStr = userContext;
let proseStr = "";
if (idx !== -1) {
contextStr = userContext.substring(0, idx).trim();
proseStr = userContext.substring(idx).trim();
}
entry.decoderPrompt = {
systemPrompt: call.systemPrompt,
userContext: call.userContext,
components: [
{ label: "System Prompt", type: "system", content: call.systemPrompt },
{ label: "Decoder Context", type: "world", content: contextStr },
{ label: "Narrative Prose", type: "input", content: proseStr },
],
};
entry.decoderUsage = call.usage;
}
const { intentInfos, validatorCalls: playerValCalls } = await processIntents(
result.intents.intents,
ctx.entityId,
entity,
worldState,
session,
);
entry.intents = intentInfos;
entry.validatorCalls = playerValCalls;
entry.decodedIntents = result.intents.intents.map((intent) => ({
type: intent.type,
content: intent.content,
modifiers: intent.modifiers || [],
targetIds: intent.targetIds,
}));
session.log.push(entry);
session.coreRepo.saveWorldState(worldState);
}

View File

@@ -1,68 +0,0 @@
import type Database from "better-sqlite3";
import type { SQLiteRepository } from "@omnia/core";
import type { BufferRepository, LedgerRepository } from "@omnia/memory";
import type { Architect, AliasDeltaGenerator } from "@omnia/architect";
import type { ILLMProvider, IEmbeddingProvider } from "@omnia/llm";
import type { EntityInfo, LogEntry, WaitingContext } from "../simulation-types";
export type {
EntityInfo,
IntentInfo,
LogEntry,
SimSnapshot,
WaitingContext,
} from "../simulation-types";
// ---------------------------------------------------------------------------
// Persisted state (written to sqlite gui_meta table as JSON)
// ---------------------------------------------------------------------------
export interface SavedState {
scenarioName: string;
scenarioDescription: string;
turn: number;
maxTurns: number;
entities: EntityInfo[];
playerEntityId: string | undefined;
entityIndex: number;
status: "running" | "waiting_player" | "done" | "error";
error?: string;
waitingEntity?: WaitingContext;
aliasDoneForTurn: boolean;
log: LogEntry[];
providerMappings: Record<string, string>;
}
// ---------------------------------------------------------------------------
// In-memory session (held in SimulationManager.sessions Map)
// ---------------------------------------------------------------------------
export interface SimSession {
db: Database.Database;
dbPath: string;
coreRepo: SQLiteRepository;
bufferRepo: BufferRepository;
ledgerRepo: LedgerRepository;
worldInstanceId: string;
scenarioName: string;
scenarioDescription: string;
turn: number;
maxTurns: number;
entities: EntityInfo[];
playerEntityId: string | undefined;
entityIndex: number;
actorProvider: ILLMProvider;
validatorProvider: ILLMProvider;
decoderProvider: ILLMProvider;
timedeltaProvider: ILLMProvider;
handoffProvider: ILLMProvider;
embeddingProvider: IEmbeddingProvider;
architect: Architect;
aliasGenerator: AliasDeltaGenerator;
log: LogEntry[];
status: "running" | "waiting_player" | "done" | "error";
error?: string;
waitingEntity?: WaitingContext;
aliasDoneForTurn: boolean;
providerMappings: Record<string, string>;
}

View File

@@ -0,0 +1,13 @@
{
"name": "@omnia/api-client",
"version": "0.0.0",
"private": true,
"type": "module",
"exports": {
".": "./dist/index.js"
},
"dependencies": {
"@omnia/api-contracts": "workspace:*",
"zod": "^4.4.3"
}
}

View File

@@ -0,0 +1,362 @@
import {
createSimulationRequestV1,
eventEnvelopeV1,
logPageV1,
modelListV1,
operationV1,
playerActionRequestV1,
problemDetailsV1,
providerCatalogEntryV1,
providerCreateRequestV1,
providerMappingV1,
providerSummaryV1,
providerUpdateRequestV1,
renameSimulationRequestV1,
scenarioV1,
simulationSnapshotV1,
simulationSummaryV1,
stepSimulationRequestV1,
} from "@omnia/api-contracts";
import type {
CreateSimulationRequestV1,
EventEnvelopeV1,
LogPageV1,
ModelListV1,
OperationV1,
PlayerActionRequestV1,
ProblemDetailsV1,
ProviderCatalogEntryV1,
ProviderCreateRequestV1,
ProviderMappingV1,
ProviderSummaryV1,
ProviderUpdateRequestV1,
RenameSimulationRequestV1,
ScenarioV1,
SimulationSnapshotV1,
SimulationSummaryV1,
StepSimulationRequestV1,
} from "@omnia/api-contracts";
export interface ApiClientOptions {
baseUrl: string;
token?: string;
fetch?: typeof globalThis.fetch;
requestIdFactory?: () => string;
}
export interface ListResponse<T> {
items: T[];
page: {
nextCursor: string | null;
hasMore: boolean;
};
}
export class ApiClientError extends Error {
readonly status: number;
readonly problem: ProblemDetailsV1 | null;
constructor(status: number, message: string, problem: ProblemDetailsV1 | null) {
super(message);
this.name = "ApiClientError";
this.status = status;
this.problem = problem;
}
}
export class OmniaApiClient {
private readonly baseUrl: string;
private readonly token?: string;
private readonly fetchImpl: typeof globalThis.fetch;
private readonly requestIdFactory: () => string;
constructor(options: ApiClientOptions) {
this.baseUrl = options.baseUrl.replace(/\/$/, "");
this.token = options.token;
this.fetchImpl = options.fetch ?? globalThis.fetch;
this.requestIdFactory =
options.requestIdFactory ?? (() => crypto.randomUUID());
}
async listSimulations(query: { cursor?: string; limit?: number } = {}): Promise<ListResponse<SimulationSummaryV1>> {
const params = new URLSearchParams();
if (query.cursor) params.set("cursor", query.cursor);
if (query.limit !== undefined) params.set("limit", String(query.limit));
const result = await this.request<unknown>(
`/api/v1/simulations${params.size ? `?${params}` : ""}`,
);
return parseOrThrow(
simulationListResponseV1,
result,
"Invalid simulation list response",
);
}
async createSimulation(
input: CreateSimulationRequestV1,
options: { idempotencyKey?: string } = {},
): Promise<SimulationSnapshotV1 | OperationV1> {
const body = createSimulationRequestV1.parse(input);
const result = await this.request<unknown>("/api/v1/simulations", {
method: "POST",
body,
idempotencyKey: options.idempotencyKey,
});
return parseOrThrow(
simulationSnapshotV1.or(operationV1),
result,
"Invalid create simulation response",
);
}
async getSimulation(id: string): Promise<SimulationSnapshotV1> {
const result = await this.request<unknown>(`/api/v1/simulations/${encodeURIComponent(id)}`);
return parseOrThrow(simulationSnapshotV1, result, "Invalid simulation response");
}
async renameSimulation(
id: string,
input: RenameSimulationRequestV1,
options: { etag: string; idempotencyKey?: string },
): Promise<SimulationSnapshotV1> {
const body = renameSimulationRequestV1.parse(input);
const result = await this.request<unknown>(
`/api/v1/simulations/${encodeURIComponent(id)}`,
{ method: "PATCH", body, etag: options.etag, idempotencyKey: options.idempotencyKey },
);
return parseOrThrow(simulationSnapshotV1, result, "Invalid rename simulation response");
}
async deleteSimulation(
id: string,
options: { etag: string; idempotencyKey?: string },
): Promise<void> {
await this.request<unknown>(`/api/v1/simulations/${encodeURIComponent(id)}`, {
method: "DELETE",
etag: options.etag,
idempotencyKey: options.idempotencyKey,
});
}
async stepSimulation(
id: string,
input: StepSimulationRequestV1 = {},
options: { etag: string; idempotencyKey?: string },
): Promise<OperationV1> {
const body = stepSimulationRequestV1.parse(input);
const result = await this.request<unknown>(
`/api/v1/simulations/${encodeURIComponent(id)}/steps`,
{ method: "POST", body, etag: options.etag, idempotencyKey: options.idempotencyKey },
);
return parseOrThrow(operationV1, result, "Invalid step operation response");
}
async submitPlayerAction(
id: string,
input: PlayerActionRequestV1,
options: { etag: string; idempotencyKey?: string },
): Promise<OperationV1> {
const body = playerActionRequestV1.parse(input);
const result = await this.request<unknown>(
`/api/v1/simulations/${encodeURIComponent(id)}/player-actions`,
{ method: "POST", body, etag: options.etag, idempotencyKey: options.idempotencyKey },
);
return parseOrThrow(operationV1, result, "Invalid player action operation response");
}
async listSimulationLogs(
id: string,
query: { cursor?: string; limit?: number } = {},
): Promise<LogPageV1> {
const params = new URLSearchParams();
if (query.cursor) params.set("cursor", query.cursor);
if (query.limit !== undefined) params.set("limit", String(query.limit));
const result = await this.request<unknown>(
`/api/v1/simulations/${encodeURIComponent(id)}/logs${params.size ? `?${params}` : ""}`,
);
return parseOrThrow(logPageV1, result, "Invalid log page response");
}
async getOperation(id: string): Promise<OperationV1> {
const result = await this.request<unknown>(`/api/v1/operations/${encodeURIComponent(id)}`);
return parseOrThrow(operationV1, result, "Invalid operation response");
}
async cancelOperation(id: string): Promise<OperationV1> {
const result = await this.request<unknown>(`/api/v1/operations/${encodeURIComponent(id)}`, {
method: "DELETE",
});
return parseOrThrow(operationV1, result, "Invalid cancellation response");
}
async listScenarios(): Promise<ScenarioV1[]> {
const result = await this.request<unknown>("/api/v1/scenarios");
return parseOrThrow(zArray(scenarioV1), result, "Invalid scenarios response");
}
async listProviders(): Promise<ProviderSummaryV1[]> {
const result = await this.request<unknown>("/api/v1/admin/providers");
return parseOrThrow(zArray(providerSummaryV1), result, "Invalid providers response");
}
async createProvider(input: ProviderCreateRequestV1): Promise<ProviderSummaryV1> {
const result = await this.request<unknown>("/api/v1/admin/providers", {
method: "POST",
body: providerCreateRequestV1.parse(input),
});
return parseOrThrow(providerSummaryV1, result, "Invalid provider response");
}
async updateProvider(id: string, input: ProviderUpdateRequestV1): Promise<ProviderSummaryV1> {
const result = await this.request<unknown>(`/api/v1/admin/providers/${encodeURIComponent(id)}`, {
method: "PATCH",
body: providerUpdateRequestV1.parse(input),
});
return parseOrThrow(providerSummaryV1, result, "Invalid provider response");
}
async listProviderMappings(): Promise<ProviderMappingV1> {
const result = await this.request<unknown>("/api/v1/admin/provider-mappings");
return parseOrThrow(providerMappingV1, result, "Invalid provider mappings response");
}
async listProviderCatalog(): Promise<ProviderCatalogEntryV1[]> {
const result = await this.request<unknown>("/api/v1/admin/provider-catalog");
return parseOrThrow(zArray(providerCatalogEntryV1), result, "Invalid provider catalog response");
}
async discoverModelsForProvider(id: string): Promise<ModelListV1> {
const result = await this.request<unknown>(`/api/v1/admin/providers/${encodeURIComponent(id)}/models`);
return parseOrThrow(modelListV1, result, "Invalid model discovery response");
}
async *events(signal?: AbortSignal): AsyncGenerator<EventEnvelopeV1> {
const response = await this.fetchRequest("/api/v1/events", { signal });
if (!response.body) throw new Error("API event stream has no body");
yield* parseEventStream(response.body, eventEnvelopeV1, signal);
}
async *simulationEvents(id: string, signal?: AbortSignal): AsyncGenerator<EventEnvelopeV1> {
const response = await this.fetchRequest(
`/api/v1/simulations/${encodeURIComponent(id)}/events`,
{ signal },
);
if (!response.body) throw new Error("Simulation event stream has no body");
yield* parseEventStream(response.body, eventEnvelopeV1, signal);
}
private async request<T>(path: string, options: RequestOptions = {}): Promise<T> {
const response = await this.fetchRequest(path, options);
if (response.status === 204) return undefined as T;
return (await response.json()) as T;
}
private async fetchRequest(path: string, options: RequestOptions = {}): Promise<Response> {
const headers = new Headers(options.headers);
headers.set("Accept", "application/json");
headers.set("X-Request-Id", this.requestIdFactory());
if (options.body !== undefined) {
headers.set("Content-Type", "application/json");
}
if (this.token) headers.set("Authorization", `Bearer ${this.token}`);
if (options.etag) headers.set("If-Match", options.etag);
if (options.idempotencyKey) headers.set("Idempotency-Key", options.idempotencyKey);
const response = await this.fetchImpl(`${this.baseUrl}${path}`, {
method: options.method ?? "GET",
headers,
body: options.body === undefined ? undefined : JSON.stringify(options.body),
signal: options.signal,
});
if (!response.ok) {
const contentType = response.headers.get("content-type") ?? "";
const problem = contentType.includes("application/problem+json")
? parseOrNull(problemDetailsV1, await response.json())
: null;
throw new ApiClientError(
response.status,
problem?.detail ?? `Omnia API request failed with status ${response.status}`,
problem,
);
}
return response;
}
}
interface RequestOptions {
method?: string;
body?: unknown;
headers?: HeadersInit;
etag?: string;
idempotencyKey?: string;
signal?: AbortSignal;
}
const zArray = <T extends z.ZodType>(item: T) => z.array(item);
import { z } from "zod";
const simulationListResponseV1 = z.object({
items: z.array(simulationSummaryV1),
page: z.object({ nextCursor: z.string().nullable(), hasMore: z.boolean() }),
});
function parseOrThrow<T>(schema: z.ZodType<T>, input: unknown, message: string): T {
const parsed = schema.safeParse(input);
if (!parsed.success) throw new Error(`${message}: ${parsed.error.message}`);
return parsed.data;
}
function parseOrNull<T>(schema: z.ZodType<T>, input: unknown): T | null {
const parsed = schema.safeParse(input);
return parsed.success ? parsed.data : null;
}
async function* parseEventStream<T>(
body: ReadableStream<Uint8Array>,
schema: z.ZodType<T>,
signal?: AbortSignal,
): AsyncGenerator<T> {
const reader = body.pipeThrough(new TextDecoderStream()).getReader();
let buffer = "";
try {
while (!signal?.aborted) {
const result = await reader.read();
if (result.done) break;
buffer += result.value;
const frames = buffer.split("\n\n");
buffer = frames.pop() ?? "";
for (const frame of frames) {
const data = frame
.split("\n")
.filter((line) => line.startsWith("data:"))
.map((line) => line.slice(5).trimStart())
.join("\n");
if (!data) continue;
yield parseOrThrow(schema, JSON.parse(data), "Invalid API event");
}
}
} finally {
await reader.cancel();
}
}
export type {
CreateSimulationRequestV1,
EventEnvelopeV1,
LogPageV1,
OperationV1,
PlayerActionRequestV1,
ProblemDetailsV1,
ProviderCatalogEntryV1,
ModelListV1,
ProviderCreateRequestV1,
ProviderMappingV1,
ProviderSummaryV1,
ProviderUpdateRequestV1,
RenameSimulationRequestV1,
ScenarioV1,
SimulationSnapshotV1,
SimulationSummaryV1,
StepSimulationRequestV1,
};

View File

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

View File

@@ -0,0 +1,14 @@
{
"name": "@omnia/api-contracts",
"version": "0.0.0",
"private": true,
"type": "module",
"exports": {
".": "./dist/index.js",
"./v1": "./dist/v1/index.js",
"./openapi": "./dist/v1/openapi.js"
},
"dependencies": {
"zod": "^4.4.3"
}
}

View File

@@ -0,0 +1 @@
export * from "./v1/index.js";

View File

@@ -0,0 +1,6 @@
export * from "./schemas.js";
export {
getOpenApiDocumentV1,
openApiDocumentV1,
} from "./openapi.js";
export type { OpenApiDocumentV1 } from "./openapi.js";

View File

@@ -0,0 +1,341 @@
import { toJSONSchema, z } from "zod";
import {
createSimulationRequestV1,
eventEnvelopeV1,
logPageV1,
modelListV1,
operationV1,
playerActionRequestV1,
problemDetailsV1,
providerCatalogEntryV1,
providerCreateRequestV1,
providerMappingV1,
providerSummaryV1,
providerUpdateRequestV1,
renameSimulationRequestV1,
scenarioV1,
simulationSnapshotV1,
simulationSummaryV1,
stepSimulationRequestV1,
} from "./schemas.js";
export interface OpenApiDocumentV1 {
openapi: "3.1.0";
info: {
title: string;
version: string;
description: string;
};
jsonSchemaDialect: string;
paths: Record<string, unknown>;
components: {
schemas: Record<string, unknown>;
responses: Record<string, unknown>;
parameters: Record<string, unknown>;
securitySchemes: Record<string, unknown>;
};
}
function schema(input: unknown): unknown {
return toJSONSchema(input as Parameters<typeof toJSONSchema>[0], {
target: "draft-2020-12",
});
}
const jsonContent = (schemaName: string, status = "200") => ({
[status]: {
description: "JSON response",
content: {
"application/json": {
schema: { $ref: `#/components/schemas/${schemaName}` },
},
},
},
});
export const openApiDocumentV1: OpenApiDocumentV1 = {
openapi: "3.1.0",
info: {
title: "Omnia API",
version: "1.0.0",
description: "Versioned REST contract for Omnia runtime and administration.",
},
jsonSchemaDialect: "https://json-schema.org/draft/2020-12/schema",
paths: {
"/api/v1/simulations": {
get: {
operationId: "listSimulations",
security: [{ bearerAuth: [] }],
responses: jsonContent("SimulationSummaryPageV1"),
},
post: {
operationId: "createSimulation",
security: [{ bearerAuth: [] }],
requestBody: {
required: true,
content: {
"application/json": {
schema: { $ref: "#/components/schemas/CreateSimulationRequestV1" },
},
},
},
responses: {
...jsonContent("SimulationSnapshotV1", "201"),
"400": { $ref: "#/components/responses/ProblemDetails" },
},
},
},
"/api/v1/simulations/{simulationId}": {
get: {
operationId: "getSimulation",
security: [{ bearerAuth: [] }],
parameters: [{ $ref: "#/components/parameters/SimulationId" }],
responses: {
...jsonContent("SimulationSnapshotV1"),
"404": { $ref: "#/components/responses/ProblemDetails" },
},
},
patch: {
operationId: "renameSimulation",
security: [{ bearerAuth: [] }],
parameters: [
{ $ref: "#/components/parameters/SimulationId" },
{ $ref: "#/components/parameters/IfMatch" },
],
requestBody: {
required: true,
content: {
"application/json": {
schema: { $ref: "#/components/schemas/RenameSimulationRequestV1" },
},
},
},
responses: jsonContent("SimulationSnapshotV1"),
},
delete: {
operationId: "deleteSimulation",
security: [{ bearerAuth: [] }],
parameters: [
{ $ref: "#/components/parameters/SimulationId" },
{ $ref: "#/components/parameters/IfMatch" },
],
responses: { "204": { description: "Simulation deleted" } },
},
},
"/api/v1/simulations/{simulationId}/steps": {
post: {
operationId: "stepSimulation",
security: [{ bearerAuth: [] }],
parameters: [
{ $ref: "#/components/parameters/SimulationId" },
{ $ref: "#/components/parameters/IfMatch" },
{ $ref: "#/components/parameters/IdempotencyKey" },
],
requestBody: {
required: false,
content: {
"application/json": {
schema: { $ref: "#/components/schemas/StepSimulationRequestV1" },
},
},
},
responses: jsonContent("OperationV1", "202"),
},
},
"/api/v1/simulations/{simulationId}/player-actions": {
post: {
operationId: "submitPlayerAction",
security: [{ bearerAuth: [] }],
parameters: [
{ $ref: "#/components/parameters/SimulationId" },
{ $ref: "#/components/parameters/IfMatch" },
{ $ref: "#/components/parameters/IdempotencyKey" },
],
requestBody: {
required: true,
content: {
"application/json": {
schema: { $ref: "#/components/schemas/PlayerActionRequestV1" },
},
},
},
responses: jsonContent("OperationV1", "202"),
},
},
"/api/v1/simulations/{simulationId}/logs": {
get: {
operationId: "listSimulationLogs",
security: [{ bearerAuth: [] }],
parameters: [
{ $ref: "#/components/parameters/SimulationId" },
{ $ref: "#/components/parameters/Cursor" },
{ $ref: "#/components/parameters/Limit" },
],
responses: jsonContent("LogPageV1"),
},
},
"/api/v1/simulations/{simulationId}/events": {
get: {
operationId: "subscribeSimulationEvents",
security: [{ bearerAuth: [] }],
parameters: [{ $ref: "#/components/parameters/SimulationId" }],
responses: {
"200": {
description: "Server-sent event stream",
content: { "text/event-stream": { schema: { type: "string" } } },
},
},
},
},
"/api/v1/operations/{operationId}": {
get: {
operationId: "getOperation",
security: [{ bearerAuth: [] }],
parameters: [{ $ref: "#/components/parameters/OperationId" }],
responses: jsonContent("OperationV1"),
},
delete: {
operationId: "cancelOperation",
security: [{ bearerAuth: [] }],
parameters: [{ $ref: "#/components/parameters/OperationId" }],
responses: jsonContent("OperationV1"),
},
},
"/api/v1/scenarios": {
get: {
operationId: "listScenarios",
security: [{ bearerAuth: [] }],
responses: {
"200": {
description: "Available scenarios",
content: {
"application/json": {
schema: { type: "array", items: { $ref: "#/components/schemas/ScenarioV1" } },
},
},
},
},
},
},
"/api/v1/admin/providers": {
get: {
operationId: "listProviders",
security: [{ bearerAuth: [] }],
responses: {
"200": {
description: "Redacted provider configurations",
content: {
"application/json": {
schema: { type: "array", items: { $ref: "#/components/schemas/ProviderSummaryV1" } },
},
},
},
},
},
post: {
operationId: "createProvider",
security: [{ bearerAuth: [] }],
requestBody: {
required: true,
content: {
"application/json": {
schema: { $ref: "#/components/schemas/ProviderCreateRequestV1" },
},
},
},
responses: jsonContent("ProviderSummaryV1", "201"),
},
},
"/api/v1/events": {
get: {
operationId: "subscribeEvents",
security: [{ bearerAuth: [] }],
responses: {
"200": {
description: "Server-sent event stream",
content: { "text/event-stream": { schema: { type: "string" } } },
},
},
},
},
},
components: {
schemas: {
CreateSimulationRequestV1: schema(createSimulationRequestV1),
RenameSimulationRequestV1: schema(renameSimulationRequestV1),
PlayerActionRequestV1: schema(playerActionRequestV1),
StepSimulationRequestV1: schema(stepSimulationRequestV1),
SimulationSummaryV1: schema(simulationSummaryV1),
SimulationSnapshotV1: schema(simulationSnapshotV1),
SimulationSummaryPageV1: schema(
z.object({ items: z.array(simulationSummaryV1), page: z.object({ nextCursor: z.string().nullable(), hasMore: z.boolean() }) }),
),
LogPageV1: schema(logPageV1),
OperationV1: schema(operationV1),
ProviderSummaryV1: schema(providerSummaryV1),
ProviderCreateRequestV1: schema(providerCreateRequestV1),
ProviderUpdateRequestV1: schema(providerUpdateRequestV1),
ProviderMappingV1: schema(providerMappingV1),
ProviderCatalogEntryV1: schema(providerCatalogEntryV1),
ScenarioV1: schema(scenarioV1),
EventEnvelopeV1: schema(eventEnvelopeV1),
ModelListV1: schema(modelListV1),
ProblemDetailsV1: schema(problemDetailsV1),
},
responses: {
ProblemDetails: {
description: "RFC 9457 problem details",
content: {
"application/problem+json": {
schema: { $ref: "#/components/schemas/ProblemDetailsV1" },
},
},
},
},
parameters: {
SimulationId: {
name: "simulationId",
in: "path",
required: true,
schema: { type: "string" },
},
OperationId: {
name: "operationId",
in: "path",
required: true,
schema: { type: "string", format: "uuid" },
},
Cursor: {
name: "cursor",
in: "query",
required: false,
schema: { type: "string" },
},
Limit: {
name: "limit",
in: "query",
required: false,
schema: { type: "integer", minimum: 1, maximum: 100, default: 25 },
},
IfMatch: {
name: "If-Match",
in: "header",
required: true,
schema: { type: "string" },
},
IdempotencyKey: {
name: "Idempotency-Key",
in: "header",
required: false,
schema: { type: "string", minLength: 1, maxLength: 256 },
},
},
securitySchemes: {
bearerAuth: { type: "http", scheme: "bearer" },
},
},
};
export function getOpenApiDocumentV1(): OpenApiDocumentV1 {
return openApiDocumentV1;
}

View File

@@ -0,0 +1,282 @@
import { z } from "zod";
export const apiVersionV1 = z.literal("1");
export const runtimeStatusV1 = z.enum([
"running",
"waiting_player",
"done",
"error",
]);
export const simulationIdV1 = z.string().regex(
/^(?:sim-[0-9]+|[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})$/i,
"Invalid simulation identifier",
);
export const operationIdV1 = z.string().regex(
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
"Invalid operation identifier",
);
export const isoTimestampV1 = z.string().datetime({ offset: true });
export const entityV1 = z.object({
id: z.string().min(1),
name: z.string().min(1),
isPlayer: z.boolean(),
isAgent: z.boolean(),
aliases: z.record(z.string(), z.string()).nullable(),
});
export const intentV1 = z.object({
type: z.string().min(1),
content: z.string(),
modifiers: z.array(z.string()),
targetIds: z.array(z.string()),
isValid: z.boolean().nullable(),
reason: z.string().nullable(),
minutesToAdvance: z.number().nullable(),
});
export const tokenUsageV1 = z.object({
inputTokens: z.number().int().nonnegative(),
outputTokens: z.number().int().nonnegative(),
totalTokens: z.number().int().nonnegative(),
modelName: z.string().nullable(),
providerInstanceName: z.string().nullable(),
maxContext: z.number().int().nonnegative().nullable(),
});
export const handoffChunkV1 = z.object({
content: z.string(),
importance: z.number(),
quotes: z.array(z.string()).nullable(),
retainInBuffer: z.boolean().nullable(),
involvedEntityIds: z.array(z.string()).nullable(),
});
/** Public log data intentionally excludes prompts and model diagnostic payloads. */
export const logEntryV1 = z.object({
turn: z.number().int().nonnegative(),
entityId: z.string().min(1),
entityName: z.string().min(1),
narrativeProse: z.string(),
intents: z.array(intentV1),
timestamp: isoTimestampV1,
isHandoff: z.boolean(),
handoffResult: z.object({ chunks: z.array(handoffChunkV1) }).nullable(),
decodedIntents: z.array(intentV1).nullable(),
usage: tokenUsageV1.nullable(),
});
/** Public player context deliberately contains no prompt material. */
export const waitingPlayerV1 = z.object({
entityId: z.string().min(1),
name: z.string().min(1),
});
export const simulationSummaryV1 = z.object({
apiVersion: apiVersionV1,
id: simulationIdV1,
status: runtimeStatusV1,
turn: z.number().int().nonnegative(),
maxTurns: z.number().int().positive(),
scenarioName: z.string(),
scenarioDescription: z.string(),
entityCount: z.number().int().nonnegative(),
updatedAt: isoTimestampV1.nullable(),
});
export const simulationSnapshotV1 = z.object({
apiVersion: apiVersionV1,
id: simulationIdV1,
status: runtimeStatusV1,
turn: z.number().int().nonnegative(),
maxTurns: z.number().int().positive(),
scenarioName: z.string(),
scenarioDescription: z.string(),
entities: z.array(entityV1),
entityIndex: z.number().int().nonnegative(),
waitingPlayer: waitingPlayerV1.nullable(),
error: z.string().nullable(),
worldTime: isoTimestampV1.nullable(),
currentLocation: z.string().nullable(),
revision: z.number().int().nonnegative().nullable(),
updatedAt: isoTimestampV1.nullable(),
});
export const pageInfoV1 = z.object({
nextCursor: z.string().nullable(),
hasMore: z.boolean(),
});
export const logPageV1 = z.object({
items: z.array(logEntryV1),
page: pageInfoV1,
});
export const createSimulationRequestV1 = z.object({
scenarioId: z.string().min(1).max(128),
playEntity: z.string().min(1).max(256).nullable().optional(),
providerInstanceId: z.string().min(1).max(256).nullable().optional(),
customName: z.string().trim().min(1).max(256).nullable().optional(),
});
export const renameSimulationRequestV1 = z.object({
name: z.string().trim().min(1).max(256),
});
export const playerActionRequestV1 = z.object({
prose: z.string().trim().min(1).max(32_000),
});
export const stepSimulationRequestV1 = z.object({
waitForCompletion: z.boolean().default(false),
});
export const providerTypeV1 = z.enum(["generative", "embedding"]);
export const providerSummaryV1 = z.object({
id: z.string().min(1),
name: z.string().min(1),
providerName: z.string().min(1),
hasCredential: z.boolean(),
isActive: z.boolean(),
modelName: z.string().nullable(),
type: providerTypeV1,
maxContext: z.number().int().nonnegative().nullable(),
endpointUrl: z.string().url().nullable(),
});
/** Credentials are accepted on writes only and are never part of provider reads. */
export const providerCredentialInputV1 = z.object({
apiKey: z.string().max(16_384).nullable().optional(),
});
export const providerCreateRequestV1 = providerCredentialInputV1.extend({
name: z.string().trim().min(1).max(256),
providerName: z.string().min(1).max(128),
modelName: z.string().trim().max(256).nullable().optional(),
type: providerTypeV1.default("generative"),
maxContext: z.number().int().positive().nullable().optional(),
endpointUrl: z.string().url().nullable().optional(),
});
export const providerUpdateRequestV1 = providerCreateRequestV1.partial().extend({
apiKey: z.string().max(16_384).nullable().optional(),
});
export const providerMappingV1 = z.record(z.string().min(1), z.string().min(1));
export const modelV1 = z.object({
id: z.string().min(1),
name: z.string().min(1),
ownedBy: z.string().nullable(),
});
export const modelListV1 = z.array(modelV1);
export const providerCatalogEntryV1 = z.object({
id: z.string().min(1),
displayName: z.string().min(1),
description: z.string(),
defaultModel: z.string(),
defaultEmbeddingModel: z.string(),
});
export const scenarioV1 = z.object({
id: z.string().min(1),
name: z.string().min(1),
description: z.string(),
entities: z.array(z.object({ id: z.string().min(1), name: z.string().min(1) })),
});
export const operationStatusV1 = z.enum([
"queued",
"running",
"completed",
"failed",
"cancelled",
]);
export const operationV1 = z.object({
apiVersion: apiVersionV1,
id: operationIdV1,
kind: z.enum(["step", "run", "player_action", "embedding_regeneration"]),
status: operationStatusV1,
progress: z.number().min(0).max(1).nullable(),
resultSimulationId: simulationIdV1.nullable(),
errorCode: z.string().nullable(),
createdAt: isoTimestampV1,
updatedAt: isoTimestampV1,
});
export const problemDetailsV1 = z.object({
type: z.string().url(),
title: z.string().min(1),
status: z.number().int().min(400).max(599),
detail: z.string(),
instance: z.string().nullable(),
code: z.string().min(1),
requestId: z.string().min(1),
});
export const eventTypeV1 = z.enum([
"simulation.created",
"simulation.status.changed",
"simulation.turn.started",
"simulation.turn.completed",
"simulation.player_input.requested",
"simulation.player_action.accepted",
"simulation.log_entry.appended",
"simulation.completed",
"simulation.failed",
"operation.progress",
"operation.completed",
"operation.failed",
"operation.cancelled",
]);
export const eventEnvelopeV1 = z.object({
apiVersion: apiVersionV1,
eventId: z.string().min(1),
sequence: z.number().int().nonnegative(),
revision: z.number().int().nonnegative().nullable(),
occurredAt: isoTimestampV1,
type: eventTypeV1,
simulationId: simulationIdV1.nullable(),
operationId: operationIdV1.nullable(),
data: z.unknown(),
});
export const listQueryV1 = z.object({
cursor: z.string().max(512).nullable().optional(),
limit: z.coerce.number().int().min(1).max(100).default(25),
});
export type ApiVersionV1 = z.infer<typeof apiVersionV1>;
export type RuntimeStatusV1 = z.infer<typeof runtimeStatusV1>;
export type SimulationIdV1 = z.infer<typeof simulationIdV1>;
export type OperationIdV1 = z.infer<typeof operationIdV1>;
export type EntityV1 = z.infer<typeof entityV1>;
export type IntentV1 = z.infer<typeof intentV1>;
export type LogEntryV1 = z.infer<typeof logEntryV1>;
export type SimulationSummaryV1 = z.infer<typeof simulationSummaryV1>;
export type SimulationSnapshotV1 = z.infer<typeof simulationSnapshotV1>;
export type CreateSimulationRequestV1 = z.infer<typeof createSimulationRequestV1>;
export type RenameSimulationRequestV1 = z.infer<typeof renameSimulationRequestV1>;
export type PlayerActionRequestV1 = z.infer<typeof playerActionRequestV1>;
export type StepSimulationRequestV1 = z.infer<typeof stepSimulationRequestV1>;
export type ProviderSummaryV1 = z.infer<typeof providerSummaryV1>;
export type ProviderCreateRequestV1 = z.infer<typeof providerCreateRequestV1>;
export type ProviderUpdateRequestV1 = z.infer<typeof providerUpdateRequestV1>;
export type ProviderMappingV1 = z.infer<typeof providerMappingV1>;
export type ModelV1 = z.infer<typeof modelV1>;
export type ModelListV1 = z.infer<typeof modelListV1>;
export type ProviderCatalogEntryV1 = z.infer<typeof providerCatalogEntryV1>;
export type ScenarioV1 = z.infer<typeof scenarioV1>;
export type OperationV1 = z.infer<typeof operationV1>;
export type ProblemDetailsV1 = z.infer<typeof problemDetailsV1>;
export type EventEnvelopeV1 = z.infer<typeof eventEnvelopeV1>;
export type ListQueryV1 = z.infer<typeof listQueryV1>;

View File

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

View File

@@ -0,0 +1,20 @@
{
"name": "@omnia/runtime",
"private": true,
"type": "module",
"exports": {
".": "./dist/index.js",
"./testing": "./dist/testing/index.js",
"./internal": "./dist/internal.js"
},
"dependencies": {
"@omnia/actor": "workspace:*",
"@omnia/architect": "workspace:*",
"@omnia/core": "workspace:*",
"@omnia/llm": "workspace:*",
"@omnia/memory": "workspace:*",
"@omnia/scenario": "workspace:*",
"@omnia/voice": "workspace:*",
"better-sqlite3": "^12.11.1"
}
}

View File

@@ -0,0 +1,93 @@
import { HandoffEngine, checkHandoffTrigger } from "@omnia/memory";
import type { HandoffResult } from "./snapshot.js";
import type { RuntimeSession } from "./session.js";
function isHandoffResult(value: unknown): value is HandoffResult {
if (!value || typeof value !== "object") return false;
return Array.isArray((value as { chunks?: unknown }).chunks);
}
export async function runHandoffResolution(
session: RuntimeSession,
): Promise<void> {
const worldState = session.coreRepo.loadWorldState(session.worldInstanceId);
if (!worldState) throw new Error("World state lost");
const handoffEngine = new HandoffEngine(
session.handoffProvider,
session.embeddingProvider,
session.bufferRepo,
session.ledgerRepo,
);
for (const entity of worldState.entities.values()) {
if (!entity.isAgent) continue;
const bufferEntries = session.bufferRepo.listForOwner(entity.id);
const trigger = checkHandoffTrigger(
entity,
bufferEntries,
worldState.clock.get(),
session.handoffProvider.maxContext ?? 32768,
);
if (trigger === "none") continue;
const ran = await handoffEngine.runHandoff(
entity,
bufferEntries,
worldState.clock.get(),
);
if (!ran) continue;
const lastResult = handoffEngine.lastResult;
const lastCall = session.handoffProvider.lastCalls?.at(-1);
const entityName =
session.entities.find((item) => item.id === entity.id)?.name ?? entity.id;
session.log.push({
turn: session.turn,
entityId: entity.id,
entityName,
narrativeProse: `Handoff triggered for ${entityName}: memories were transferred from Cognitive Buffer to Memory Ledger`,
intents: [],
timestamp: worldState.clock.get().toISOString(),
isHandoff: true,
rawPrompt: lastResult
? {
systemPrompt: lastResult.systemPrompt || "",
userContext: lastResult.userContext || "",
components: lastResult.promptComponents,
}
: undefined,
usage: lastCall?.usage,
handoffResult: isHandoffResult(lastResult?.response)
? lastResult.response
: isHandoffResult(lastCall?.response)
? lastCall.response
: undefined,
});
}
}
export async function runAliasResolution(
session: RuntimeSession,
): Promise<void> {
const worldState = session.coreRepo.loadWorldState(session.worldInstanceId);
if (!worldState) throw new Error("World state lost");
const entities = Array.from(worldState.entities.values());
for (const viewer of entities) {
if (!viewer.isAgent || !viewer.locationId) continue;
for (const target of entities) {
if (
viewer.id !== target.id &&
target.locationId === viewer.locationId &&
!viewer.aliases.has(target.id)
) {
viewer.aliases.set(
target.id,
await session.aliasGenerator.generate(viewer, target),
);
session.coreRepo.saveEntity(viewer, worldState.id);
}
}
}
}

View File

@@ -0,0 +1,16 @@
export interface CreateRuntimeCommand {
scenarioPath: string;
playEntityName?: string;
providerInstanceId?: string;
customName?: string;
}
export interface SubmitPlayerActionCommand {
sessionId: string;
prose: string;
}
export interface RenameRuntimeCommand {
sessionId: string;
name: string;
}

View File

@@ -0,0 +1,24 @@
export class RuntimeError extends Error {
constructor(
message: string,
readonly code: string,
options?: ErrorOptions,
) {
super(message, options);
this.name = "RuntimeError";
}
}
export class SessionNotFoundError extends RuntimeError {
constructor(sessionId: string) {
super(`Runtime session not found: ${sessionId}`, "SESSION_NOT_FOUND");
this.name = "SessionNotFoundError";
}
}
export class ProviderUnavailableError extends RuntimeError {
constructor(message: string) {
super(message, "PROVIDER_UNAVAILABLE");
this.name = "ProviderUnavailableError";
}
}

View File

@@ -0,0 +1,4 @@
export * from "./commands.js";
export * from "./errors.js";
export * from "./runtime-service.js";
export * from "./snapshot.js";

View File

@@ -0,0 +1,10 @@
export * from "./providers.js";
export * from "./session.js";
export * from "./persistence/types.js";
export * from "./persistence/sqlite-session-store.js";
export {
executePlayerAction,
preparePlayerTurn,
processNpcTurn,
} from "./turn-executor.js";
export { runAliasResolution, runHandoffResolution } from "./alias-handoff.js";

View File

@@ -0,0 +1,163 @@
import Database from "better-sqlite3";
import path from "node:path";
import fs from "node:fs";
import type { RuntimeSession, SavedSessionState } from "../session.js";
import type { RuntimeSnapshot } from "../snapshot.js";
import type { SessionStore } from "./types.js";
const RUNTIME_META = "runtime_meta";
const LEGACY_META = "gui_meta";
export class SQLiteSessionStore implements SessionStore {
constructor(
readonly dataDir: string = path.resolve(process.cwd(), "data"),
) { }
loadState(db: Database.Database, id: string): SavedSessionState | null {
try {
this.ensureRuntimeTable(db);
let row = this.readRow(db, RUNTIME_META, id);
if (!row && this.tableExists(db, LEGACY_META)) {
row = this.readRow(db, LEGACY_META, id);
if (row) this.writeStateJson(db, id, row.state_json);
}
return row ? (JSON.parse(row.state_json) as SavedSessionState) : null;
} catch {
return null;
}
}
save(session: RuntimeSession): void {
const state: SavedSessionState = {
scenarioName: session.scenarioName,
scenarioDescription: session.scenarioDescription,
turn: session.turn,
maxTurns: session.maxTurns,
entities: session.entities,
playerEntityId: session.playerEntityId,
entityIndex: session.entityIndex,
status: session.status,
error: session.error,
waitingEntity: session.waitingEntity,
aliasDoneForTurn: session.aliasDoneForTurn,
log: session.log,
providerMappings: session.providerMappings,
};
this.ensureRuntimeTable(session.db);
this.writeStateJson(
session.db,
session.worldInstanceId,
JSON.stringify(state),
);
}
delete(id: string): void {
const dbPath = this.pathFor(id);
if (!fs.existsSync(dbPath)) return;
try {
fs.unlinkSync(dbPath);
} catch (error) {
console.error(`Failed to delete session file ${dbPath}:`, error);
}
}
list(
activeSessions: ReadonlyMap<string, RuntimeSession>,
snapshot: (session: RuntimeSession) => RuntimeSnapshot,
): RuntimeSnapshot[] {
if (!fs.existsSync(this.dataDir)) return [];
const snapshots: RuntimeSnapshot[] = [];
const files = fs
.readdirSync(this.dataDir)
.filter((file) => file.startsWith("sim-") && file.endsWith(".db"));
for (const file of files) {
const id = file.slice(0, -3);
const active = activeSessions.get(id);
if (active) {
snapshots.push(snapshot(active));
continue;
}
try {
const db = new Database(this.pathFor(id));
const state = this.loadState(db, id);
db.close();
if (state) {
snapshots.push({
id,
status: state.status,
turn: state.turn,
maxTurns: state.maxTurns,
scenarioName: state.scenarioName,
scenarioDescription: state.scenarioDescription,
entities: state.entities || [],
log: state.log || [],
entityIndex: state.entityIndex,
waitingEntity: state.waitingEntity,
error: state.error,
});
}
} catch {
// Skip corrupt or locked session files.
}
}
return snapshots.sort(
(a, b) =>
(Number.parseInt(b.id.replace("sim-", ""), 10) || 0) -
(Number.parseInt(a.id.replace("sim-", ""), 10) || 0),
);
}
pathFor(id: string): string {
return path.join(this.dataDir, `${id}.db`);
}
private ensureRuntimeTable(db: Database.Database): void {
db.prepare(
`CREATE TABLE IF NOT EXISTS runtime_meta (
id TEXT PRIMARY KEY,
state_json TEXT
)`,
).run();
}
private tableExists(db: Database.Database, table: string): boolean {
return Boolean(
db
.prepare(
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?",
)
.get(table),
);
}
private readRow(
db: Database.Database,
table: typeof RUNTIME_META | typeof LEGACY_META,
id: string,
): { state_json: string } | undefined {
return db
.prepare(`SELECT state_json FROM ${table} WHERE id = ?`)
.get(id) as { state_json: string } | undefined;
}
private writeStateJson(
db: Database.Database,
id: string,
stateJson: string,
): void {
db.prepare(
`INSERT INTO runtime_meta (id, state_json)
VALUES (?, ?)
ON CONFLICT(id) DO UPDATE SET state_json = excluded.state_json`,
).run(id, stateJson);
}
}
export const DATA_DIR = path.resolve(process.cwd(), "data");
const defaultStore = new SQLiteSessionStore(DATA_DIR);
export const loadSessionState = defaultStore.loadState.bind(defaultStore);
export const saveSession = defaultStore.save.bind(defaultStore);
export const deleteSessionFile = defaultStore.delete.bind(defaultStore);
export const listSavedSessions = defaultStore.list.bind(defaultStore);

View File

@@ -0,0 +1,14 @@
import type Database from "better-sqlite3";
import type { RuntimeSession, SavedSessionState } from "../session.js";
import type { RuntimeSnapshot } from "../snapshot.js";
export interface SessionStore {
readonly dataDir: string;
loadState(db: Database.Database, id: string): SavedSessionState | null;
save(session: RuntimeSession): void;
delete(id: string): void;
list(
activeSessions: ReadonlyMap<string, RuntimeSession>,
snapshot: (session: RuntimeSession) => RuntimeSnapshot,
): RuntimeSnapshot[];
}

View File

@@ -0,0 +1,101 @@
import {
MockLLMProvider,
MockEmbeddingProvider,
ProviderManager,
buildLLMProvider,
buildEmbeddingProvider,
} from "@omnia/llm";
import type {
ILLMProvider,
IEmbeddingProvider,
ModelProviderInstance,
} from "@omnia/llm";
export interface ResolvedProviders {
actorProvider: ILLMProvider;
validatorProvider: ILLMProvider;
decoderProvider: ILLMProvider;
timedeltaProvider: ILLMProvider;
handoffProvider: ILLMProvider;
embeddingProvider: IEmbeddingProvider;
}
export interface ProviderResolverOptions {
fallbackInstance?: ModelProviderInstance | null;
required?: boolean;
}
export function resolveProviders(
mappings: Record<string, string>,
options: ProviderResolverOptions = {},
): ResolvedProviders {
const { fallbackInstance = null, required = false } = options;
const list = ProviderManager.list();
const activeGenerative =
ProviderManager.getActive("generative") ?? fallbackInstance ?? null;
const resolveGenerative = (task: string): ILLMProvider => {
const mappedId = mappings[task];
let inst: ModelProviderInstance | null = mappedId
? (list.find((provider) => provider.id === mappedId) ?? null)
: null;
if (!inst || inst.type !== "generative") inst = activeGenerative;
if (!inst && process.env.GOOGLE_API_KEY) {
inst = ProviderManager.create(
"Default (Env)",
"google-genai",
process.env.GOOGLE_API_KEY,
undefined,
"generative",
);
}
if (!inst) {
if (required) {
throw new Error(
`No active LLM Provider Instance found for task "${task}". Please configure a key in Settings first.`,
);
}
return new MockLLMProvider([]);
}
return buildLLMProvider(inst);
};
const resolveEmbedding = (): IEmbeddingProvider => {
const mappedId = mappings.embeddings;
let inst: ModelProviderInstance | null = mappedId
? (list.find((provider) => provider.id === mappedId) ?? null)
: null;
if (!inst || inst.type !== "embedding") {
inst = ProviderManager.getActive("embedding");
}
if (!inst && process.env.GOOGLE_API_KEY) {
inst = ProviderManager.create(
"Default Embed (Env)",
"google-genai",
process.env.GOOGLE_API_KEY,
"gemini-embedding-001",
"embedding",
);
}
if (!inst) {
if (required) {
throw new Error(
"No active Embedding Provider Instance found. Please configure an embedding key in Settings first.",
);
}
return new MockEmbeddingProvider(undefined);
}
return buildEmbeddingProvider(inst);
};
return {
actorProvider: resolveGenerative("actor-prose"),
validatorProvider: resolveGenerative("llm-validator"),
decoderProvider: resolveGenerative("intent-decoder"),
timedeltaProvider: resolveGenerative("timedelta"),
handoffProvider: resolveGenerative("handoff"),
embeddingProvider: resolveEmbedding(),
};
}

View File

@@ -0,0 +1,446 @@
import Database from "better-sqlite3";
import path from "node:path";
import fs from "node:fs";
import { SQLiteRepository } from "@omnia/core";
import { BufferRepository, LedgerRepository } from "@omnia/memory";
import { Architect, AliasDeltaGenerator } from "@omnia/architect";
import { ProviderManager, buildEmbeddingProvider } from "@omnia/llm";
import type { ModelProviderInstance, IEmbeddingProvider } from "@omnia/llm";
import { ScenarioLoader } from "@omnia/scenario";
import type { RuntimeSession } from "./session.js";
import type { EntityInfo, RuntimeSnapshot } from "./snapshot.js";
import { resolveProviders } from "./providers.js";
import { SQLiteSessionStore } from "./persistence/sqlite-session-store.js";
import type { SessionStore } from "./persistence/types.js";
import {
preparePlayerTurn,
processNpcTurn,
executePlayerAction,
} from "./turn-executor.js";
import { runAliasResolution, runHandoffResolution } from "./alias-handoff.js";
export interface RuntimeServiceOptions {
dataDir?: string;
store?: SessionStore;
idFactory?: () => string;
}
export class RuntimeService {
private readonly sessions = new Map<string, RuntimeSession>();
private readonly pending = new Map<string, Promise<unknown>>();
private readonly store: SessionStore;
private readonly idFactory: () => string;
private lastTimestamp = 0;
constructor(options: RuntimeServiceOptions = {}) {
this.store =
options.store ??
new SQLiteSessionStore(
options.dataDir ?? path.resolve(process.cwd(), "data"),
);
this.idFactory =
options.idFactory ??
(() => {
this.lastTimestamp = Math.max(Date.now(), this.lastTimestamp + 1);
return `sim-${this.lastTimestamp}`;
});
}
async create(
scenarioPath: string,
playEntityName?: string,
providerInstanceId?: string,
customName?: string,
): Promise<RuntimeSnapshot> {
let activeInstance: ModelProviderInstance | null = providerInstanceId
? (ProviderManager.list().find((item) => item.id === providerInstanceId) ??
null)
: ProviderManager.getActive("generative");
if (!activeInstance && process.env.GOOGLE_API_KEY) {
activeInstance = ProviderManager.create(
"Default (Env)",
"google-genai",
process.env.GOOGLE_API_KEY,
undefined,
"generative",
);
}
if (!activeInstance) return this.providerErrorSnapshot();
const scenarioJson = JSON.parse(fs.readFileSync(scenarioPath, "utf-8"));
const id = this.idFactory();
fs.mkdirSync(this.store.dataDir, { recursive: true });
const dbPath = path.join(this.store.dataDir, `${id}.db`);
const db = new Database(dbPath);
const coreRepo = new SQLiteRepository(db);
const bufferRepo = new BufferRepository(db);
const ledgerRepo = new LedgerRepository(db);
await new ScenarioLoader(coreRepo, bufferRepo).initializeWorld(
scenarioJson,
id,
);
const worldState = coreRepo.loadWorldState(id);
if (!worldState) {
db.close();
return this.errorSnapshot("Failed to load world state after initialization.");
}
const rawEntities = Array.from(worldState.entities.values());
const entities: EntityInfo[] = rawEntities.map((entity) => ({
id: entity.id,
name:
(entity.attributes.get("name")?.getValue() as string | undefined) ??
entity.id,
isPlayer: false,
isAgent: entity.isAgent,
}));
const playerEntityId = this.resolvePlayerEntity(
rawEntities,
entities,
playEntityName,
);
const mappings = ProviderManager.getMappings();
const providers = resolveProviders(mappings, {
fallbackInstance: activeInstance,
});
const session: RuntimeSession = {
db,
dbPath,
coreRepo,
bufferRepo,
ledgerRepo,
worldInstanceId: id,
scenarioName: customName || scenarioJson.name,
scenarioDescription: scenarioJson.description || "",
turn: 1,
maxTurns: 20,
entities,
playerEntityId,
entityIndex: 0,
...providers,
architect: new Architect(
{
validator: providers.validatorProvider,
timedelta: providers.timedeltaProvider,
},
coreRepo,
),
aliasGenerator: new AliasDeltaGenerator(providers.actorProvider),
log: [],
status: "running",
aliasDoneForTurn: false,
providerMappings: mappings,
};
this.sessions.set(id, session);
this.store.save(session);
return this.snapshot(session);
}
async load(id: string): Promise<RuntimeSnapshot | null> {
return this.exclusive(id, async () => {
const active = this.sessions.get(id);
if (active) return this.snapshot(active);
const dbPath = path.join(this.store.dataDir, `${id}.db`);
if (!fs.existsSync(dbPath)) return null;
let db: Database.Database | undefined;
try {
db = new Database(dbPath);
const state = this.store.loadState(db, id);
if (!state) {
db.close();
return null;
}
const providers = resolveProviders(state.providerMappings || {}, {
required: true,
});
const coreRepo = new SQLiteRepository(db);
const bufferRepo = new BufferRepository(db);
const ledgerRepo = new LedgerRepository(db);
const session: RuntimeSession = {
...state,
db,
dbPath,
coreRepo,
bufferRepo,
ledgerRepo,
worldInstanceId: id,
...providers,
architect: new Architect(
{
validator: providers.validatorProvider,
timedelta: providers.timedeltaProvider,
},
coreRepo,
),
aliasGenerator: new AliasDeltaGenerator(providers.actorProvider),
entities: state.entities || [],
log: state.log || [],
aliasDoneForTurn: state.aliasDoneForTurn || false,
providerMappings: state.providerMappings || {},
};
this.sessions.set(id, session);
return this.snapshot(session);
} catch (error) {
if (db?.open) db.close();
console.error(`Failed to load session ${id}:`, error);
return null;
}
});
}
close(id: string): void {
const session = this.sessions.get(id);
if (session) session.db.close();
this.sessions.delete(id);
}
deleteSession(id: string): void {
this.close(id);
this.store.delete(id);
}
listSavedSessions(): RuntimeSnapshot[] {
return this.store.list(this.sessions, (session) => this.snapshot(session));
}
getSnapshot(id: string): RuntimeSnapshot | null {
const session = this.sessions.get(id);
return session ? this.snapshot(session) : null;
}
async rename(id: string, newName: string): Promise<RuntimeSnapshot | null> {
if (!this.sessions.has(id)) await this.load(id);
return this.exclusive(id, async () => {
const session = this.sessions.get(id);
if (!session) return null;
session.scenarioName = newName;
this.store.save(session);
return this.snapshot(session);
});
}
async step(id: string): Promise<RuntimeSnapshot | null> {
return this.exclusive(id, async () => {
const session = this.sessions.get(id);
if (!session) return null;
if (session.status !== "running") return this.snapshot(session);
try {
if (session.turn > session.maxTurns) {
session.status = "done";
} else if (!session.aliasDoneForTurn && session.entityIndex === 0) {
await runAliasResolution(session);
await runHandoffResolution(session);
session.aliasDoneForTurn = true;
} else if (session.entityIndex >= session.entities.length) {
session.turn++;
session.entityIndex = 0;
session.aliasDoneForTurn = false;
} else {
const info = session.entities[session.entityIndex];
if (!info.isAgent) session.entityIndex++;
else if (info.isPlayer) await preparePlayerTurn(session, info);
else {
await processNpcTurn(session, info);
session.entityIndex++;
}
}
} catch (error) {
session.status = "error";
session.error = error instanceof Error ? error.message : String(error);
}
this.store.save(session);
return this.snapshot(session);
});
}
async submitPlayerAction(
id: string,
prose: string,
): Promise<RuntimeSnapshot | null> {
return this.exclusive(id, async () => {
const session = this.sessions.get(id);
if (!session) return null;
if (session.status !== "waiting_player" || !session.waitingEntity) {
return this.snapshot(session);
}
const context = session.waitingEntity;
session.waitingEntity = undefined;
session.status = "running";
try {
await executePlayerAction(session, context, prose);
session.entityIndex++;
} catch (error) {
session.status = "error";
session.error = error instanceof Error ? error.message : String(error);
}
this.store.save(session);
return this.snapshot(session);
});
}
async regenerateAllEmbeddings(
newProviderInstanceId?: string,
): Promise<void> {
if (!fs.existsSync(this.store.dataDir)) return;
let instance = newProviderInstanceId
? (ProviderManager.list().find((item) => item.id === newProviderInstanceId) ??
null)
: null;
if (!instance || instance.type !== "embedding") {
instance = ProviderManager.getActive("embedding");
}
if (!instance) {
instance = process.env.GOOGLE_API_KEY
? {
id: "regen-env-fallback",
name: "Gemini Embed (Env)",
providerName: "google-genai",
apiKey: process.env.GOOGLE_API_KEY,
isActive: true,
modelName: "gemini-embedding-001",
type: "embedding",
maxContext: 0,
}
: {
id: "regen-mock-fallback",
name: "Mock Embed (Fallback)",
providerName: "mock",
apiKey: "",
isActive: true,
modelName: undefined,
type: "embedding",
maxContext: 0,
};
}
const embeddingProvider: IEmbeddingProvider = buildEmbeddingProvider(instance);
const files = fs
.readdirSync(this.store.dataDir)
.filter((file) => file.startsWith("sim-") && file.endsWith(".db"));
for (const file of files) {
const id = file.slice(0, -3);
const active = this.sessions.get(id);
const db = active?.db ?? new Database(path.join(this.store.dataDir, file));
try {
const rows = db
.prepare("SELECT id, content FROM ledger_entries")
.all() as { id: string; content: string }[];
for (const row of rows) {
const vector = await embeddingProvider.embed(row.content);
db.prepare("UPDATE ledger_entries SET embedding = ? WHERE id = ?").run(
Buffer.from(new Float32Array(vector).buffer),
row.id,
);
}
} catch (error) {
console.error(`Failed to regenerate embeddings for ${file}:`, error);
} finally {
if (!active) db.close();
}
}
}
private snapshot(session: RuntimeSession): RuntimeSnapshot {
const worldState = session.coreRepo.loadWorldState(session.worldInstanceId);
const entities = session.entities.map((entity) => {
const actual = worldState?.getEntity(entity.id);
return {
...entity,
aliases: actual ? Object.fromEntries(actual.aliases) : {},
};
});
let currentLocation: string | undefined;
if (
worldState &&
session.entityIndex >= 0 &&
session.entityIndex < session.entities.length
) {
const actual = worldState.getEntity(
session.entities[session.entityIndex].id,
);
currentLocation = actual?.locationId
? worldState.getLocation(actual.locationId)?.id
: undefined;
}
return {
id: session.worldInstanceId,
status: session.status,
turn: session.turn,
maxTurns: session.maxTurns,
scenarioName: session.scenarioName,
scenarioDescription: session.scenarioDescription,
entities,
log: session.log,
entityIndex: session.entityIndex,
waitingEntity: session.waitingEntity,
error: session.error,
worldTime: worldState?.clock.get().toISOString(),
currentLocation,
};
}
private resolvePlayerEntity(
rawEntities: Array<{
id: string;
attributes: Map<string, { getValue(): unknown }>;
}>,
entities: EntityInfo[],
name?: string,
): string | undefined {
if (!name) return undefined;
const query = name.toLowerCase();
const matched =
rawEntities.find((entity) => entity.id === name) ??
rawEntities.find(
(entity) =>
String(entity.attributes.get("name")?.getValue()).toLowerCase() ===
query,
) ??
rawEntities.find((entity) => {
const entityName = String(
entity.attributes.get("name")?.getValue() ?? "",
).toLowerCase();
return entityName.includes(query) || entity.id.toLowerCase().includes(query);
});
if (!matched) return undefined;
const info = entities.find((entity) => entity.id === matched.id);
if (info) info.isPlayer = true;
return matched.id;
}
private exclusive<T>(id: string, operation: () => Promise<T>): Promise<T> {
const previous = this.pending.get(id) ?? Promise.resolve();
const current = previous.catch(() => undefined).then(operation);
this.pending.set(id, current);
void current.then(() => {
if (this.pending.get(id) === current) this.pending.delete(id);
}, () => {
if (this.pending.get(id) === current) this.pending.delete(id);
});
return current;
}
private providerErrorSnapshot(): RuntimeSnapshot {
return this.errorSnapshot(
"No active LLM Provider Instance found. Please configure a key in Settings first.",
);
}
private errorSnapshot(error: string): RuntimeSnapshot {
return {
id: "",
status: "error",
turn: 0,
maxTurns: 20,
scenarioName: "",
scenarioDescription: "",
entities: [],
log: [],
entityIndex: 0,
error,
};
}
}
/** @deprecated Use RuntimeService. */
export class SimulationManager extends RuntimeService { }

View File

@@ -0,0 +1,49 @@
import type Database from "better-sqlite3";
import type { SQLiteRepository } from "@omnia/core";
import type { BufferRepository, LedgerRepository } from "@omnia/memory";
import type { Architect, AliasDeltaGenerator } from "@omnia/architect";
import type { ILLMProvider, IEmbeddingProvider } from "@omnia/llm";
import type {
EntityInfo,
LogEntry,
RuntimeStatus,
WaitingContext,
} from "./snapshot.js";
export interface SavedSessionState {
scenarioName: string;
scenarioDescription: string;
turn: number;
maxTurns: number;
entities: EntityInfo[];
playerEntityId: string | undefined;
entityIndex: number;
status: RuntimeStatus;
error?: string;
waitingEntity?: WaitingContext;
aliasDoneForTurn: boolean;
log: LogEntry[];
providerMappings: Record<string, string>;
}
export interface RuntimeSession extends SavedSessionState {
db: Database.Database;
dbPath: string;
coreRepo: SQLiteRepository;
bufferRepo: BufferRepository;
ledgerRepo: LedgerRepository;
worldInstanceId: string;
actorProvider: ILLMProvider;
validatorProvider: ILLMProvider;
decoderProvider: ILLMProvider;
timedeltaProvider: ILLMProvider;
handoffProvider: ILLMProvider;
embeddingProvider: IEmbeddingProvider;
architect: Architect;
aliasGenerator: AliasDeltaGenerator;
}
/** @deprecated Use RuntimeSession. */
export type SimSession = RuntimeSession;
/** @deprecated Use SavedSessionState. */
export type SavedState = SavedSessionState;

View File

@@ -0,0 +1,101 @@
export interface IntentInfo {
type: string;
content: string;
modifiers: string[];
targetIds: string[];
isValid?: boolean;
reason?: string;
minutesToAdvance?: number;
}
export interface PromptComponent {
label: string;
type: "system" | "world" | "events" | "memories" | "input" | "other";
content: string;
}
export interface PromptBreakdown {
systemPrompt: string;
userContext: string;
components?: PromptComponent[];
}
export interface TokenUsage {
inputTokens: number;
outputTokens: number;
totalTokens: number;
modelName?: string;
providerInstanceName?: string;
maxContext?: number;
}
export interface ValidatorCall {
intentIndex: number;
intentContent: string;
prompt?: PromptBreakdown;
response: { isValid: boolean; reason: string };
usage?: TokenUsage;
}
export interface HandoffResult {
chunks: {
content: string;
importance: number;
quotes?: string[];
retainInBuffer?: boolean;
involvedEntityIds?: string[];
}[];
}
export interface LogEntry {
turn: number;
entityId: string;
entityName: string;
narrativeProse: string;
intents: IntentInfo[];
timestamp: string;
isHandoff?: boolean;
handoffResult?: HandoffResult;
decodedIntents?: IntentInfo[];
validatorCalls?: ValidatorCall[];
rawPrompt?: PromptBreakdown;
usage?: TokenUsage;
decoderPrompt?: PromptBreakdown;
decoderUsage?: TokenUsage;
}
export interface EntityInfo {
id: string;
name: string;
isPlayer: boolean;
isAgent: boolean;
aliases?: Record<string, string>;
}
export interface WaitingContext {
entityId: string;
name: string;
systemPrompt: string;
userContext: string;
}
export type RuntimeStatus = "running" | "waiting_player" | "done" | "error";
export interface RuntimeSnapshot {
id: string;
status: RuntimeStatus;
turn: number;
maxTurns: number;
scenarioName: string;
scenarioDescription: string;
entities: EntityInfo[];
log: LogEntry[];
entityIndex: number;
waitingEntity?: WaitingContext;
error?: string;
worldTime?: string;
currentLocation?: string;
}
/** @deprecated Use RuntimeSnapshot. */
export type SimSnapshot = RuntimeSnapshot;

View File

@@ -0,0 +1 @@
export { createRuntimeSnapshot } from "./runtime-fixtures.js";

View File

@@ -0,0 +1,18 @@
import type { RuntimeSnapshot } from "../snapshot.js";
export function createRuntimeSnapshot(
overrides: Partial<RuntimeSnapshot> = {},
): RuntimeSnapshot {
return {
id: "sim-test",
status: "running",
turn: 1,
maxTurns: 20,
scenarioName: "Test scenario",
scenarioDescription: "",
entities: [],
log: [],
entityIndex: 0,
...overrides,
};
}

View File

@@ -0,0 +1,258 @@
import {
ActorAgent,
ActorPromptBuilder,
buildBufferEntryForIntent,
} from "@omnia/actor";
import type { IActorProseGenerator } from "@omnia/actor";
import type { RuntimeSession } from "./session.js";
import type {
EntityInfo,
IntentInfo,
LogEntry,
WaitingContext,
ValidatorCall,
} from "./snapshot.js";
class FixedProseGenerator implements IActorProseGenerator {
constructor(private readonly prose: string) { }
async generate(): Promise<string> {
return this.prose;
}
}
type RuntimeIntent = Parameters<typeof buildBufferEntryForIntent>[0];
async function processIntents(
intents: RuntimeIntent[],
actorEntityId: string,
entity: { locationId: string | null },
worldState: NonNullable<ReturnType<RuntimeSession["coreRepo"]["loadWorldState"]>>,
session: RuntimeSession,
): Promise<{ intentInfos: IntentInfo[]; validatorCalls: ValidatorCall[] }> {
const intentInfos: IntentInfo[] = [];
const validatorCalls: ValidatorCall[] = [];
for (const [intentIndex, intent] of intents.entries()) {
const outcome = await session.architect.processIntent(worldState, intent);
const timestamp = worldState.clock.get().toISOString();
intentInfos.push({
type: intent.type,
content: intent.content,
modifiers: intent.modifiers || [],
targetIds: intent.targetIds,
isValid: outcome.isValid,
reason: outcome.reason,
minutesToAdvance: outcome.timeDelta?.minutesToAdvance,
});
if (intent.type === "action" && session.architect.validator.lastResult) {
const result = session.architect.validator.lastResult;
validatorCalls.push({
intentIndex,
intentContent: intent.content,
prompt: {
systemPrompt: result.systemPrompt || "",
userContext: result.userContext || "",
components: result.components,
},
response: { isValid: outcome.isValid, reason: outcome.reason },
usage: session.validatorProvider.lastCalls?.at(-1)?.usage,
});
} else {
const reason =
intent.type === "dialogue"
? "Dialogue intents represent verbal/communication actions and are automatically valid."
: "Monologue/thought intents represent internal reflections and bypass validation.";
validatorCalls.push({
intentIndex,
intentContent: intent.content,
response: {
isValid: true,
reason: outcome.reason || reason,
},
});
}
const actorEntry = buildBufferEntryForIntent(
intent,
timestamp,
entity.locationId,
);
if (intent.type === "action") {
actorEntry.outcome = { isValid: outcome.isValid, reason: outcome.reason };
}
session.bufferRepo.save(actorEntry);
if (
entity.locationId &&
(intent.type === "dialogue" || intent.type === "action")
) {
for (const other of worldState.entities.values()) {
if (
other.id === actorEntityId ||
other.locationId !== entity.locationId
) {
continue;
}
const observerEntry = buildBufferEntryForIntent(
intent,
timestamp,
entity.locationId,
);
if (intent.type === "action") {
observerEntry.outcome = {
isValid: outcome.isValid,
reason: outcome.reason,
};
}
session.bufferRepo.save({ ...observerEntry, ownerId: other.id });
}
}
}
return { intentInfos, validatorCalls };
}
function attachDecoderDetails(
session: RuntimeSession,
entry: LogEntry,
): void {
const call = session.decoderProvider.lastCalls?.at(-1);
if (!call) return;
const proseHeader = "=== NARRATIVE PROSE ===";
const index = call.userContext.indexOf(proseHeader);
const context =
index === -1 ? call.userContext : call.userContext.substring(0, index).trim();
const prose = index === -1 ? "" : call.userContext.substring(index).trim();
entry.decoderPrompt = {
systemPrompt: call.systemPrompt,
userContext: call.userContext,
components: [
{ label: "System Prompt", type: "system", content: call.systemPrompt },
{ label: "Decoder Context", type: "world", content: context },
{ label: "Narrative Prose", type: "input", content: prose },
],
};
entry.decoderUsage = call.usage;
}
export async function preparePlayerTurn(
session: RuntimeSession,
info: EntityInfo,
): Promise<void> {
const worldState = session.coreRepo.loadWorldState(session.worldInstanceId);
if (!worldState) throw new Error("World state lost");
const entity = worldState.getEntity(info.id);
if (!entity) throw new Error(`Entity "${info.id}" not found`);
const prompt = new ActorPromptBuilder(
session.bufferRepo,
session.ledgerRepo,
20,
).build(worldState, entity);
session.waitingEntity = {
entityId: info.id,
name: info.name,
systemPrompt: prompt.systemPrompt,
userContext: prompt.userContext,
};
session.status = "waiting_player";
}
export async function processNpcTurn(
session: RuntimeSession,
info: EntityInfo,
): Promise<void> {
const worldState = session.coreRepo.loadWorldState(session.worldInstanceId);
if (!worldState) throw new Error("World state lost");
const entity = worldState.getEntity(info.id);
if (!entity) throw new Error(`Entity "${info.id}" not found`);
const result = await new ActorAgent(
{ actor: session.actorProvider, decoder: session.decoderProvider },
session.bufferRepo,
session.ledgerRepo,
20,
).act(worldState, entity);
const entry: LogEntry = {
turn: session.turn,
entityId: info.id,
entityName: info.name,
narrativeProse: result.narrativeProse,
intents: [],
timestamp: worldState.clock.get().toISOString(),
rawPrompt: {
systemPrompt: result.systemPrompt || "",
userContext: result.userContext || "",
components: result.promptComponents,
},
usage: session.actorProvider.lastCalls?.at(-1)?.usage,
};
attachDecoderDetails(session, entry);
const processed = await processIntents(
result.intents.intents,
info.id,
entity,
worldState,
session,
);
entry.intents = processed.intentInfos;
entry.validatorCalls = processed.validatorCalls;
entry.decodedIntents = result.intents.intents.map((intent) => ({
type: intent.type,
content: intent.content,
modifiers: intent.modifiers || [],
targetIds: intent.targetIds,
}));
session.log.push(entry);
session.coreRepo.saveWorldState(worldState);
}
export async function executePlayerAction(
session: RuntimeSession,
context: WaitingContext,
prose: string,
): Promise<void> {
const worldState = session.coreRepo.loadWorldState(session.worldInstanceId);
if (!worldState) throw new Error("World state lost");
const entity = worldState.getEntity(context.entityId);
if (!entity) throw new Error(`Player entity "${context.entityId}" not found`);
const result = await new ActorAgent(
{ actor: session.actorProvider, decoder: session.decoderProvider },
session.bufferRepo,
session.ledgerRepo,
20,
new FixedProseGenerator(prose),
).act(worldState, entity);
const entry: LogEntry = {
turn: session.turn,
entityId: context.entityId,
entityName: context.name,
narrativeProse: result.narrativeProse,
intents: [],
timestamp: worldState.clock.get().toISOString(),
rawPrompt: {
systemPrompt: result.systemPrompt || context.systemPrompt,
userContext: result.userContext || context.userContext,
components: result.promptComponents,
},
};
attachDecoderDetails(session, entry);
const processed = await processIntents(
result.intents.intents,
context.entityId,
entity,
worldState,
session,
);
entry.intents = processed.intentInfos;
entry.validatorCalls = processed.validatorCalls;
entry.decodedIntents = result.intents.intents.map((intent) => ({
type: intent.type,
content: intent.content,
modifiers: intent.modifiers || [],
targetIds: intent.targetIds,
}));
session.log.push(entry);
session.coreRepo.saveWorldState(worldState);
}

View File

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

12228
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -11,6 +11,9 @@
{ "path": "./packages/spatial" },
{ "path": "./packages/llm" },
{ "path": "./packages/actor" },
{ "path": "./packages/scenario" }
{ "path": "./packages/scenario" },
{ "path": "./packages/runtime" },
{ "path": "./packages/api-contracts" },
{ "path": "./packages/api-client" }
]
}

View File

@@ -16,6 +16,9 @@ export default defineConfig({
"@omnia/spatial": path.resolve(__dirname, "./packages/spatial/src"),
"@omnia/actor": path.resolve(__dirname, "./packages/actor/src"),
"@omnia/scenario": path.resolve(__dirname, "./packages/scenario/src"),
"@omnia/runtime": path.resolve(__dirname, "./packages/runtime/src"),
"@omnia/api-contracts": path.resolve(__dirname, "./packages/api-contracts/src"),
"@omnia/api-client": path.resolve(__dirname, "./packages/api-client/src"),
},
},
test: {

View File

@@ -60,8 +60,7 @@ Monologue (`"monologue"`) is the third intent type. Its properties:
│ → 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
─ LLMActorProseGenerator: queries LLM via generateStructuredResponse
│ → narrativeProse: string
├─ 3. IntentDecoder.decode(worldState, actorId, prose)
@@ -69,7 +68,7 @@ Monologue (`"monologue"`) is the third intent type. Its properties:
└─ returns { narrativeProse, intents }
[Caller (e.g. game loop)]
[Runtime turn executor]
├─ for each intent in intents:
│ ├─ if intent.type === "monologue": short-circuit, write to buffer
@@ -79,6 +78,11 @@ Monologue (`"monologue"`) is the third intent type. Its properties:
└─ world state persisted to DB
```
Human-controlled turns bypass NPC prose generation. `@omnia/runtime` prepares
a waiting-player snapshot, the GUI collects prose, and
`RuntimeService.submitPlayerAction()` sends it through intent decoding and turn
execution.
## Key Files
| File | Role |

View File

@@ -58,7 +58,7 @@ Configurations are stored globally in `data/settings.db` (separated from specifi
## Task Provider Routing
During a simulation run, the engine executes four distinct LLM operations. To optimize costs, latency, or model accuracy, you can route each of these tasks to different LLM provider instances:
During a simulation run, the runtime executes five generative operations and one embedding operation. To optimize costs, latency, or model accuracy, you can route each task to a different provider instance:
| Task Name | Key ID | Description | Default Model |
| :------------------------- | :--------------- | :--------------------------------------------------------------------------------------- | :--------------------------------------------- |
@@ -66,6 +66,8 @@ During a simulation run, the engine executes four distinct LLM operations. To op
| **LLM Validator** | `llm-validator` | Arbitrates and validates proposed actions against the world state rules and constraints. | `gemini-2.5-flash` / `google/gemini-2.5-flash` |
| **Intent Decoder** | `intent-decoder` | Parses and splits free-text actions/prose into structured intent sequences. | `gemini-2.5-flash` / `google/gemini-2.5-flash` |
| **TimeDelta Generator** | `timedelta` | Calculates the duration of character actions to advance the game clock. | `gemini-2.5-flash` / `google/gemini-2.5-flash` |
| **Memory Handoff Engine** | `handoff` | Summarizes Cognitive Buffer entries into the Memory Ledger. | Active generative provider |
| **Text Embeddings** | `embeddings` | Generates vectors for Memory Ledger retrieval. | Active embedding provider |
If no specific provider instance is mapped to a task, the task automatically routes to the globally marked **Active** provider instance.
@@ -73,11 +75,12 @@ If no specific provider instance is mapped to a task, the task automatically rou
## CLI Setup & Seeding
Rather than automatically bootstrapping from environment variables at runtime, which adds runtime complexity, you can quickly seed the database using the CLI setup tool:
Provider instances can be configured in the GUI or seeded from environment variables with the CLI setup tool. The CLI requires compiled workspace output, so run `pnpm build` first.
### Seeding All Environment-Variable Providers
```bash
pnpm build
pnpm setup-provider --all
```
@@ -91,7 +94,7 @@ pnpm setup-provider --provider google-genai --key YOUR_API_KEY [--name "My Gemin
### Environment Variable Fallback
If the database contains no active provider instances, the LLM providers (e.g. `GeminiProvider`, `OpenAIProvider`, etc.) will fall back directly to reading their keys from environment variables (e.g. `GOOGLE_API_KEY`, `OPENAI_API_KEY`) via `resolveCredentials`.
When `GOOGLE_API_KEY` is present and no suitable active instance exists, `@omnia/runtime` creates Google generative and embedding fallback instances in `data/settings.db`. Other provider environment variables can be seeded with `pnpm setup-provider --all`.
---

View File

@@ -16,8 +16,9 @@ omnia/
spatial/ location and POI graph, portal-based perception
llm/ ILLMProvider interface plus Gemini and deterministic mock implementations
scenario/ scenario JSON schema and loader (JSON → SQLite)
runtime/ session lifecycle, turn execution, provider routing, and simulation persistence
apps/
cli/ the playable loop (human or LLM actors, --scenario / --play flags)
gui/ Next.js UI and server actions; instantiates RuntimeService
content/
demo/ bundled scenarios (talking-room)
tests/
@@ -30,13 +31,17 @@ omnia/
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.
`@omnia/runtime` is an application library hosted by the Next.js server, not a
separately deployed backend service. Browser requests reach GUI server actions,
which delegate simulation lifecycle and turn execution to `RuntimeService`.
## 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**.
1. A browser action reaches a Next.js server action in `apps/gui`.
2. `RuntimeService` loads the session and asks an **Actor Agent** for narrative prose when the active entity is an NPC.
3. The **Intent Decoder** splits prose into typed intents (`dialogue`, `action`, `monologue`).
4. The **World Architect** validates action intents against objective world state and generates structured deltas.
5. Deterministic code applies deltas to the **World State** (SQLite), writes per-character memory through **Subjective Aliases**, and persists the runtime session.
## A Research Instrument