48 Commits

Author SHA1 Message Date
597d4f1711 major(llm): Support Embedding Providers 2026-07-11 14:31:18 +05:30
a5fa43e2e6 feat(memory): Implemented tier two memory retrieval using cognition model 2026-07-11 13:57:27 +05:30
1509b69ca7 feat(memory): Finalize Ledger Storage Model 2026-07-10 22:02:01 +05:30
c230934625 feat: Added base ledger 2026-07-10 21:27:05 +05:30
b5fb48ed99 docs: refined readme 2026-07-10 17:08:55 +05:30
ae06982620 feat(gui): Switch to tailwind 2026-07-10 15:53:51 +05:30
d8d9015015 refactor: dynamically fetch available InferenceProviders for gui 2026-07-10 15:38:08 +05:30
Aditya Gupta
f88cc4efb4 docs: Improve Illustrations 2026-07-10 13:42:04 +05:30
185e68b541 feat: Added OpenRouter LLMProvider and setup bootstrapping 2026-07-10 07:59:31 +05:30
ebd0b76c23 minor: Updated favicon for web interfaces 2026-07-10 07:51:59 +05:30
2c842b1520 docs: Added CoC and Contributing guidelines 2026-07-10 07:51:48 +05:30
Aditya Gupta
b36517e5f3 Add files via upload 2026-07-10 07:44:14 +05:30
3c9c55157c merge: badge style change 2026-07-09 22:33:32 +05:30
cf6c015726 docs: Refined readme 2026-07-09 22:32:39 +05:30
8c0f1b45fd Update README.md 2026-07-09 22:16:20 +05:30
6d9155ef28 docs: Added docs link 2026-07-09 22:06:08 +05:30
b854dfe45c ci: fix node version 2026-07-09 21:57:45 +05:30
61c6fe8513 ci: Workflow for docs deployment to cf 2026-07-09 21:56:22 +05:30
30e26f78f9 minor: remove redundant pnpm scripts 2026-07-09 21:12:59 +05:30
1ebd5f77dc docs: Refine readme 2026-07-09 21:12:40 +05:30
Aditya Gupta
fd377e8794 Add files via upload 2026-07-09 20:53:30 +05:30
817bbde265 Merge pull request #21 from sortedcord/feat/config
Refactor LLM provider system and introduce GUI for simulations
2026-07-09 19:38:20 +05:30
cc9d0006e5 docs: Added documentation for LLMProviders and named instances 2026-07-09 19:35:18 +05:30
6626adf38d MAJOR: deprecated cli interface 2026-07-09 19:09:52 +05:30
053748564f refactor: Decouple model from provider 2026-07-09 19:02:42 +05:30
acd62bdb65 minor: Switch to master-detail layout for LLMProviderInstance 2026-07-09 18:54:35 +05:30
46b12cd668 feat: LLMProviderInstance over LLMProvider
allows for using multiple keys of the same provider
implemented per llm call providerinstance mapping
2026-07-09 18:40:52 +05:30
4ef52f926e MAJOR: Added a GUI app for simulations 2026-07-09 18:14:26 +05:30
0c59756c08 refactor: Move cli package to apps/cli 2026-07-09 13:17:29 +05:30
6e096415ee Merge pull request #20 from sortedcord/remotes/origin/feat/config
MAJOR: Removed old scenario builder and moved scenario loader into pa…
2026-07-09 13:10:24 +05:30
be076d81e5 MAJOR: Removed old scenario builder and moved scenario loader into packages/scenario 2026-07-09 13:09:05 +05:30
13f6dd424e MAJOR: introduce split providers per LLM call and config system 2026-07-09 12:24:15 +05:30
4339a8b4b5 feat: Added openrouter provider 2026-07-09 11:13:02 +05:30
907c3b8ed7 minor: Improve systme prompt for Alias Generation 2026-07-09 10:56:21 +05:30
9642e2bb54 refactor: Group subsequent events under a naturalized time head
Strips out actual world clock from memories.
2026-07-09 10:19:09 +05:30
8934422a4d feat: Add logging to file for a CLI run 2026-07-09 09:44:00 +05:30
63badedf75 feat: Show context usage breakdown in cli verbose mode 2026-07-09 09:31:22 +05:30
717f9f20d4 minor: Update actor prompt to only output in first person 2026-07-09 09:27:07 +05:30
3da043952c feat: Added Alias Resolver DeltaGenerator 2026-07-09 09:26:56 +05:30
17c5b95f3b update: docs 2026-07-09 05:07:25 +05:30
53b221f80c docs: logo path fix 2026-07-09 04:54:37 +05:30
d9940a036a docs: Migrated to astro v7 for live docs 2026-07-09 04:53:00 +05:30
093f8de4c5 docs: Update readme 2026-07-09 03:14:18 +05:30
6cf099821b refactor: Serializers include available location attributes 2026-07-09 03:04:48 +05:30
fadea41e6f cli: FIRST RUN!!!! ITS ALIVE! 2026-07-09 02:51:57 +05:30
701bc56d0c content: Added demo scenario and tests 2026-07-09 02:07:50 +05:30
d96fc04542 feat: Implement ScenarioLoader pipeline 2026-07-09 01:59:27 +05:30
fa698619b3 feat: Implement actor prompt builder 2026-07-09 01:40:38 +05:30
110 changed files with 12609 additions and 3759 deletions

44
.github/workflows/deploy-docs.yml vendored Normal file
View File

@@ -0,0 +1,44 @@
name: Deploy Docs
on:
push:
branches:
- master
paths:
- 'web/docs/**'
- 'pnpm-lock.yaml'
- '.github/workflows/deploy-docs.yml'
workflow_dispatch:
jobs:
deploy:
runs-on: ubuntu-latest
name: Deploy Docs to Cloudflare Workers
steps:
- name: Checkout Repository
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 11
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: 'pnpm'
- name: Install Dependencies
run: pnpm install --frozen-lockfile
- name: Build Docs
run: pnpm --filter docs build
- name: Deploy to Cloudflare Workers
uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
workingDirectory: 'web/docs'

7
.gitignore vendored
View File

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

128
CODE_OF_CONDUCT.md Normal file
View File

@@ -0,0 +1,128 @@
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, religion, or sexual identity
and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our
community include:
- Demonstrating empathy and kindness toward other people
- Being respectful of differing opinions, viewpoints, and experiences
- Giving and gracefully accepting constructive feedback
- Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
- Focusing on what is best not just for us as individuals, but for the
overall community
Examples of unacceptable behavior include:
- The use of sexualized language or imagery, and sexual attention or
advances of any kind
- Trolling, insulting or derogatory comments, and personal or political attacks
- Public or private harassment
- Publishing others' private information, such as a physical or email
address, without their explicit permission
- Other conduct which could reasonably be considered inappropriate in a
professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of
acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.
Community leaders have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are
not aligned to this Code of Conduct, and will communicate reasons for moderation
decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces.
Examples of representing our community include using an official e-mail address,
posting via an official social media account, or acting as an appointed
representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at
mail@adityagupta.dev.
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining
the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed
unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders, providing
clarity around the nature of the violation and an explanation of why the
behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series
of actions.
**Consequence**: A warning with consequences for continued behavior. No
interaction with the people involved, including unsolicited interaction with
those enforcing the Code of Conduct, for a specified period of time. This
includes avoiding interactions in community spaces as well as external channels
like social media. Violating these terms may lead to a temporary or
permanent ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including
sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public
communication with the community for a specified period of time. No public or
private interaction with the people involved, including unsolicited interaction
with those enforcing the Code of Conduct, is allowed during this period.
Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within
the community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.0, available at
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
Community Impact Guidelines were inspired by [Mozilla's code of conduct
enforcement ladder](https://github.com/mozilla/diversity).
[homepage]: https://www.contributor-covenant.org
For answers to common questions about this code of conduct, see the FAQ at
https://www.contributor-covenant.org/faq. Translations are available at
https://www.contributor-covenant.org/translations.

99
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,99 @@
# Contributing to Omnia
Thank you for your interest in contributing to Omnia! We welcome contributions from developers, technical writers, and anyone interested in agentic narrative simulation.
Please take a moment to review this document before submitting contributions.
## Table of Contents
1. [Documentation](#documentation)
2. [Getting Started](#getting-started)
3. [Development Workflow](#development-workflow)
4. [Coding Standards](#coding-standards)
5. [Pull Request Guidelines](#pull-request-guidelines)
## Documentation
The primary source of truth for the Omnia project is the official documentation:
👉 **[Omnia Documentation](https://omnia.adityagupta.dev/docs)**
Please refer to the documentation to understand the project architecture, memory model, spatial systems, intents framework, and custom LLM configurations.
## Getting Started
Omnia is organized as a monorepo managed with **pnpm** workspaces.
### Prerequisites
- **Node.js** (v22.13 or newer recommended)
- **pnpm** (v11 or newer recommended)
### Local Setup
1. Fork the repository and clone your fork:
```bash
git clone https://github.com/YOUR_USERNAME/omnia-consolidated.git
cd omnia-consolidated
```
2. Install dependencies:
```bash
pnpm install
```
3. Run the Web GUI interface locally:
```bash
pnpm dev:gui
```
4. Run the Starlight documentation site locally:
```bash
pnpm dev:docs
```
## Development Workflow
### Branching
Create a descriptive branch for your changes:
```bash
git checkout -b feature/your-feature-name
# or
git checkout -b fix/issue-description
```
### Running Tests
Make sure all unit tests pass before submitting changes:
```bash
# Run tests once
pnpm test
# Run tests in watch mode
pnpm test:watch
```
### Linting and Formatting
We enforce consistent code quality and formatting rules across the repository.
```bash
# Check code style and formatting
pnpm lint
pnpm format:check
# Auto-fix code style issues
pnpm lint:fix
pnpm format
```
## Coding Standards
- **TypeScript**: Omnia is written entirely in TypeScript. Ensure all new code is strongly typed.
- **Docstrings**: Document public-facing APIs, methods, and configurations.
## 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/`.
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.

205
README.md
View File

@@ -1,51 +1,151 @@
![Omnia Logo](./docs/assets/img/logo.png)
<p align="center">
<img src="web/docs/src/assets/img/logo.png" alt="Omnia Logo" />
</p>
An LLM-assisted narrative simulation engine where the <b>world state lives outside the model</b>, characters act through <b>intents that get validated</b> and applied by engine code, and each character's knowledge, memory, and emotional state are subjective and partial by construction.
<h1 align="center">Omnia</h1>
Omnia is an engine for building narrative RPG-style worlds where characters are played by a language model. It is built to survive long play sessions instead of falling apart after twenty minutes.
<p align="center">
<b>An architectural framework for multi agent-narrative simulations and fictional worlds!</b>
</p>
<p align="center">
<a href="https://omnia.adityagupta.dev/docs"><img src="https://img.shields.io/badge/Omnia_Docs-Read_The_Docs-red?style=for-the-badge" alt="Docs" /></a>
<img src="https://img.shields.io/github/license/sortedcord/omnia-consolidated?style=for-the-badge" alt="License" />
<img src="https://img.shields.io/github/repo-size/sortedcord/omnia-consolidated?style=for-the-badge" alt="Repo Size" />
<img src="https://img.shields.io/github/languages/top/sortedcord/omnia-consolidated?style=for-the-badge" alt="Top Language" />
</p>
## The Problem with the Naive Approach
The <b>world state lives outside the model</b>, characters act through <b>intents that get validated</b> and applied by engine code. Each character's knowledge, memory, and emotional state are subjective and partial by construction.
Prompting a model to just _be_ the world or _be_ an NPC breaks in predictable ways over long sessions:
<p align="center">
<img src="./web/docs/src/assets/img/puppet.webp" />
</p>
- **State Leaks:** Characters know things they had no way of learning because a model with full context cannot help but use it.
- **Secrets Refuse to Stay Secret:** "Don't reveal this" is a suggestion a model can argue past, not a mechanism that says no.
Single-agent or single-context systems (AI Dungeon and its descendants) prompt one model to _be_ the world and everyone in it. That breaks in predictable ways over long sessions:
- **State Leaks:** Characters know things they had no way of learning, because a model with full context cannot help but use it.
- **Consequences Evaporate:** Betray someone, apologize, and they forgive you a turn later because nothing is tracking the betrayal as a persistent fact.
- **Emotional Drift:** Emotional state is either frozen into a meaningless number (`trust: 40`) or handed to the model to grade itself, producing drifting, arbitrary values.
- **World Rot:** The world state slowly contradicts itself because the model has no structured place to keep it.
- **Stat Drift:** Statistical attributes are either frozen into a meaningless number (`trust: 40`) or handed to the model to grade itself, producing drifting, arbitrary values.
- **World Rot:** The world state slowly contradicts itself because the model has no structured place to keep it. The locked door is open, then locked, then never existed.
- **Everyone Is One Person:** Every character shares one context, so every character shares one mind. They can't genuinely surprise each other, lie to each other, or know different things. They're sock puppets on the hands of one puppetmaster.
The model should not be the database, the physics engine and the whole cast simultaneously inside a sliding context window.
## The Omnia Solution
Omnia answers every one of these failures with the same move: pull the thing that has to stay consistent out of the model and into structured, queryable, code-controlled state.
Omnia answers every one of these failures with the same move: **pull the thing that has to stay consistent out of the model** and into structured, queryable, code-controlled state.
- **World State:** Lives in a database, not in a context window.
- **Actions:** Actions are proposals (Intents) that engine code validates and applies; they are never direct edits the model makes to the world.
- **Epistemic Privacy:** Knowledge, memory, and emotion are modeled per character and kept partial on purpose. A character literally cannot reach for what it has not earned the right to know.
- **World State:** Lives in a DB, not a context window. It cannot drift, because nothing regenerates it. The world state only changes through validated deltas.
- **Actions:** Actions are proposals (Intents) that engine code validates and applies; they are never direct edits the model makes to the world. The model proposes; deterministic code disposes.
- **Epistemic Privacy:** Knowledge, memory, and emotion are modeled per character and kept partial on purpose. A character literally cannot reach for what it has not earned the right to know. The secret is not in its prompt, so there is **nothing to jailbreak out of it**.
## What this buys you
<p align="center">
<img src="./web/docs/src/assets/img/features.webp" />
</p>
The payoff is scenario complexity that **uni-agent systems structurally cannot represent, no matter how good the model gets**.
- **Real secrets, real dramatic irony.** One NPC knows the sword is cursed; the other does not. This holds for hundreds of turns not because the model is disciplined, but because the second NPC's prompts are constructed from an attribute set that simply does not contain the fact. Leaking it would require the engine to have handed it over.
- **Genuine deception between characters.** Because each entity acts from its own bounded view, they can lie to each other and be believed; with the truth intact in the world state. A con game, a mole in the party, an unreliable ally: these are queries over "who knows what" and not prompt engineering.
- Events persist as per observer memory entries with outcomes. An apology adds a memory; it does not delete one.
- **Divergent accounts of the same event.** Two witnesses to the same scene hold two different buffer entries, filtered through their own aliases and vantage points. Ask them separately what happened and you get varied testimony.
- **A physics referee that can say no.** `I pick the lock with a hairpin` is validated against world state by the Architect before anything changes. Failure is a recorded outcome the entity remembers.
- **Time that behaves.** A world clock advances by validated, per-action deltas, and memory is recalled with psychologically natural phrasing ("earlier today, in the afternoon" — not a timestamp). `TimeOfDay` is deterministic and not based on vibes.
- **No main character syndrome.** The simulation runs fully autonomously or you act on behalf of any entity. You, the player, are just an entity in the data model, not structurally elevated above the rest of the world. The world can exist without the you.
- **Granular model control.** Omnia is not locked to a single LLM. You can pick a different model for **every individual step that calls the LLM**. Narration prose that demands richer reasoning gets a frontier model; quick intent decoding or other generators get smaller ones or a model running entirely on your local machine.
The general principle: **anything that must remain true is state; the model only ever supplies behavior.**
## Installation
### Prerequisites
- [Node.js](https://nodejs.org/) (v20+ recommended)
- [pnpm](https://pnpm.io/) (v9+ recommended)
- 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
```
2. Install dependencies:
```bash
pnpm install
```
### Running the Web GUI
To launch the Next.js development server for the GUI dashboard:
```bash
pnpm dev:gui
```
Access the application locally at `http://localhost:3000`.
## Core Architecture
### The Actor Agent
Each entity takes turns through an **Actor Agent** that receives a strictly epistemically bounded prompt: its own attributes (public, plus private ones explicitly granted to itself), its subjective memory buffer, the entities co-present at its location, and the current moment. Nothing else. The actor responds with free narrative prose.
Prose is decoded into typed intents:
- **`dialogue`** — speech others can hear.
- **`action`** — a physical act, subject to validation.
- **`monologue`** — an inner thought. No one else perceives it, it bypasses validation entirely, and it is written straight into the character's private memory.
Not every turn needs an outward act; a character may simply think. This is what makes characters feel inhabited rather than reactive and it produces a queryable record of each character's private reasoning (see [Research Instrument](#a-research-instrument-model-psychology-in-fiction) below).
The prose generator is pluggable (`IActorProseGenerator`): the same turn loop runs an LLM driven NPC or a human, identically bounded by what their character knows. (This is what eliminates Main Character Syndrome)
### Intents & The World Architect
An action becomes an **Intent**—a cheap, declarative, allowed-to-be-wrong proposal. Intents pass through a pipeline of validators (plain functions that reject or reshape proposals against current world state) and resolve. Simple speech resolves directly. Complex actions route to the **World Architect**, a single LLM call that receives scoped world state and returns a structured JSON delta. That delta is applied to the world by deterministic code after strict schema validation. The model proposes a change; it never touches the database.
An action becomes an **Intent** which is a simple proposal that is _allowed to be wrong_. Intents route to the **World Architect**, which validates them against the objective world state and generates structured deltas like time advancement, attribute change, etc. This deterministic code applies after strict schema (Zod) validation. The model never touches the DB.
### Attribute-Level Privacy
Because every mutation flows through one validated chokepoint, the world cannot rot: there is no second copy of reality inside a context window to fall out of sync.
Every entity, item, and location is an attribute bag. Each attribute carries its own visibility (`PUBLIC` or `PRIVATE`) with an access list. "The sword is cursed" is a private attribute checked in code, not a rule the model is politely asked to honor. Privacy lives at the level of the fact, not the entity.
### Attribute Level Privacy
Every entity, item, and location is an _attribute bag_. Each attribute carries its own visibility (`PUBLIC` or `PRIVATE`) with an explicit access list. "The sword is cursed" is a private attribute checked in code, not a rule the model is politely asked to honor. Privacy lives at the level of the fact, not the entity. A character can be publicly a blacksmith and privately a spy, and even facts about _itself_ are hidden from it unless explicitly granted (amnesia, repression, and unwitting sleeper agents come free with the model).
The dividend: **prompt-injection-proof secrets.** There is no instruction to override because the information was never serialized into the prompt. Epistemic privacy turns "the model shouldn't say this" (hard, unreliable) into "the model doesn't know this" (trivial, absolute).
### Spatial Perception
Space is a graph: `world → region → location → point of interest`, connected by portals with sound and vision propagation values. When something happens, it bubbles outward. There are no coordinates, no pathfinding, no collision geometrya narrative engine doesn't need a tactical simulation, and a discrete graph is sufficient.
Space is a graph: `world → region → location → point of interest`, connected by portals with sound and vision propagation values. When something happens, it bubbles outward. There are no coordinates, no pathfinding, no collision geometrya narrative engine doesn't need a tactical simulation, and a discrete graph is sufficient. Today actors perceive co-located entities and their location's visible attributes; portal propagated perception is on the roadmap.
### Memory Tiers
- **Verbatim Buffer:** Holds the last few turns of working memory.
- **Vector Archive:** Stores summarized, embedded memory entries for semantic retrieval, keeping verbatim quotes only for high-salience lines.
- **Dossier (Planned):** Will hold each observer's subjective beliefs about another character.
- **Verbatim Buffer (implemented):** Per-character subjective event log. Every entry is stored from the owner's perspective actors resolved through the owner's alias map, outcomes attached — and recalled with naturalized time phrasing.
- **Vector Archive (planned):** Summarized, embedded memory entries for semantic retrieval, keeping verbatim quotes only for high-salience lines.
- **Dossier (planned):** Each observer's subjective beliefs about another character.
Memory is per-character on purpose: recall is testimony from a vantage point, which is what makes interrogating two witnesses interesting.
### Emotional State ([NLAVS](https://github.com/sortedcord/NLAVS))
Rather than a scalar the model drifts, every significant interaction becomes a ledger entry with an affect vector across OCC-derived dimensions (plus arousal, dominance, and social drive). The model judges a single moment; deterministic code aggregates the ledger over time with decay and attention weighting. A character can be simultaneously furious about one thing and grateful for another, and an apology does not silently erase a betrayal.
This however is something that I haven't implementing or plan to implement anytime soon. The mathematical models described in NLAVS is still very abstract and subject to a lot of changes. CAA and RepE is still cutting edge research that I'm still reading papers about.
Omnia might get an affect vector system however, it's going to be more simplistic than what the NLAVS proposal scribbles down.
## A Research Instrument: Model Psychology in Fiction
Omnia's architecture doubles as an apparatus for studying how language models behave _as characters_ under controlled epistemic conditions.
- **A window into private reasoning.** Monologue intents are the model's in character thoughts: unperceived by other agents, exempt from validation, but durably logged. You can directly compare what a character _thinks_ against what it _says and does_: measuring deception, self-consistency, motivated reasoning, etc.
- **Knowledge as an experimental variable.** Attribute ACLs let you administer information with precision: give one agent a fact, withhold it from another, and observe propagation, inference, and leakage through dialogue alone.
- **Controlled, reproducible conditions.** A scenario is a JSON file (like a template); a run is a SQLite database. Identical initial conditions, swappable model providers behind one interface (`ILLMProvider`), and a deterministic mock for baselines. Rerun the scenario a hundred times, vary one attribute, and diff the transcripts.
- **Multi agent social dynamics with ground truth.** Because objective world state exists independently of any agent's beliefs, you can score agents' beliefs and claims against reality like hallucination. Even social conformity become measurable quantities rather than impressions or _✨ vibes_.
The bundled demo scenario is exactly this: [`talking-room`](./content/demo/scenarios/talking-room.json) places two memory wiped subjects in a featureless white room. Each know their own name but not the other's. Observe what they do. It runs today, ~~via the CLI, with a human optionally playing either subject~~ via a GUI which is in rapid development. You can let it run forever autonomously or roleplay as either character.
## Project Status: What `v0` Means
The finish line for the first milestone is small on purpose.
@@ -53,23 +153,26 @@ The finish line for the first milestone is small on purpose.
**Currently Implemented:**
- [x] Attribute and ACL model (with some enforcement gaps open).
- [x] World Architect working end-to-end for single actions.
- [x] Verbatim buffer and vector archive.
- [x] Spatial perception graph.
- [x] Typed intent pipeline: `dialogue` / `action` / `monologue`, decoded from free prose.
- [x] World Architect: LLM validation plus time-delta generation, end-to-end for single actions.
- [x] Actor Agent with epistemically-bounded prompts (self, memory, co-located entities, subjective time).
- [x] Verbatim memory buffer with per-observer subjective serialization and alias resolution.
- [x] Spatial location graph (data model; perception is co-location only).
- [x] Scenario loader (JSON → SQLite) and a playable CLI loop with human or LLM actors.
**[The `v0` Milestone:](https://github.com/sortedcord/omnia-consolidated/milestone/1)**
- [ ] Two hand-authored NPCs live in one location, playable via CLI.
- [ ] Each has buffer and vector-archive memory and recalls something said a few turns earlier.
- [ ] One NPC knows a fact the other does not and, provably by testing, will not leak it.
- [ ] The Architect processes at least one non-trivial action per exchange with a visible state change.
- [ ] The whole thing persists to a SQLite file and reloads identically.
- [x] Two hand-authored NPCs live in one location, playable via CLI.
- [ ] Each has buffer and vector-archive memory and recalls something said a few turns earlier. _(buffer: done; vector archive: not started)_
- [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.
- [x] The whole thing persists to a SQLite file and reloads identically.
**Explicitly out of scope for `v0`:** Constraint validators (beyond basic sense-checking), multi-location perception, affect-vector decay math, the Dossier, whims/simulation tiering, the delta ledger, and UI beyond CLI.
**Explicitly out of scope for `v0`:** Constraint validators (beyond basic sense-checking), multi-location perception, affect-vector decay math, the Dossier, whims/simulation tiering, the delta ledger.
### A Note on Tech Debt
The Architect currently trusts an LLM's judgement about reasonable consequences rather than validating every change against declarative constraints. A general constraint solver is worth building eventually, but building it before anything is playable is foundational perfectionism that produces beautiful architecture and no game. `v0` keeps the single-call Architect on purpose.
The Architect currently trusts an LLM's judgement about reasonable consequences rather than validating every change against declarative constraints. A general constraint solver is worth building eventually, but building it before anything is playable is foundational perfectionism that produces beautiful architecture and no framework. `v0` keeps the single-call Architect on purpose.
## Repository Layout
@@ -78,30 +181,34 @@ The project is one repository because the subsystems share a single evolving sch
```text
omnia/
packages/
core/ entities, attributes, world state, SQLite persistence
intent/ intent pipeline: types, validators, consequence application
architect/ World Architect: LLM delta generation plus Zod validation
memory/ buffer, vector archive, later the dossier and affect vectors
core/ entities, attributes, world state, clock, SQLite persistence
intent/ intent types (dialogue/action/monologue) and the prose decoder
architect/ World Architect: LLM validation plus time-delta generation
actor/ actor agent: epistemically-bounded prompts, pluggable prose generators
memory/ verbatim buffer; later the vector archive, dossier, and affect vectors
spatial/ location and POI graph, portal-based perception
llm/ ILLMProvider interface plus a Gemini implementation
content/ scenario JSON files, produced by the Python scenario builder
cli/ the playable loop
docs/
spec.md the living source of truth
IDEAS.md everything deliberately deferred
BUILD_LOG.md one dated line per session
llm/ ILLMProvider interface plus Gemini and deterministic mock implementations
scenario/ scenario JSON schema and loader (JSON → SQLite)
apps/
gui/ Next.js Web GUI dashboard and simulation runner
content/
demo/ bundled scenarios (talking-room)
tests/
integration/ cross-package tests against a mocked LLM
evals/ deliberate real-API evaluation runs
web/
docs/ Astro documentation site
```
_Note: Content tooling stays in Python indefinitely. It emits JSON the engine reads, so it doesn't need to share a language with the core. Domain-specific content (stats, traits) lives here, as the engine core deliberately knows nothing about them._
## Roadmap (Build Order after `v0`)
1. Constraint validators for specific cases actually hit while testing.
2. Multi-location perception.
3. Memory decay scoring.
4. The Dossier, affect vectors, and identity resolution (sharing time-weighting logic).
5. The delta ledger and undo.
6. Autonomy (once there are enough NPCs for idle simulation to matter).
1. Vector-archive memory and retrieval (closing out the `v0` memory milestone).
2. Constraint validators for specific cases actually hit while testing.
3. Multi-location, portal-propagated perception.
4. Memory decay scoring.
5. The Dossier, affect vectors, and identity resolution (sharing time-weighting logic).
6. The delta ledger and undo.
7. Autonomy (once there are enough NPCs for idle simulation to matter).
_Each step will be genuinely working and in use before the next one starts._

View File

@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

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

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

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

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

View File

@@ -1,6 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
tailwindcss: {},
autoprefixer: {},
},
};

View File

@@ -0,0 +1,534 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import {
getConfigStatus,
listProviderInstances,
createProviderInstance,
deleteProviderInstance,
setActiveProviderInstance,
getProviderMappings,
setProviderMapping,
updateProviderInstance,
getAvailableProviders,
regenerateEmbeddings,
} from "@/app/play/actions";
import type { ModelProviderInstance, ModelProviderMeta } from "@omnia/llm";
interface ConfigStatus {
apiKeySet: boolean;
apiKeyPreview: string;
model: string;
availableScenarios: { path: string; name: string }[];
}
export default function ConfigPage() {
const [config, setConfig] = useState<ConfigStatus | null>(null);
const [instances, setInstances] = useState<ModelProviderInstance[]>([]);
const [mappings, setMappings] = useState<Record<string, string>>({});
const [availableProviders, setAvailableProviders] = useState<ModelProviderMeta[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [selectedInstanceId, setSelectedInstanceId] = useState<string | "new">("new");
const [editName, setEditName] = useState("");
const [editProvider, setEditProvider] = useState("google-genai");
const [editKey, setEditKey] = useState("");
const [editModel, setEditModel] = useState("gemini-2.5-flash");
const [editIsActive, setEditIsActive] = useState(false);
const [editType, setEditType] = useState<"generative" | "embedding">("generative");
useEffect(() => {
if (selectedInstanceId === "new") {
setEditName("");
const defaultProvider = "google-genai";
setEditProvider(defaultProvider);
setEditKey("");
setEditType("generative");
const pMeta = availableProviders.find((p) => p.id === defaultProvider);
setEditModel(pMeta?.defaultModel || "gemini-2.5-flash");
setEditIsActive(false);
} else {
const inst = instances.find((i) => i.id === selectedInstanceId);
if (inst) {
setEditName(inst.name);
setEditProvider(inst.providerName);
setEditKey("");
setEditType(inst.type || "generative");
const pMeta = availableProviders.find((p) => p.id === inst.providerName);
setEditModel(inst.modelName || (inst.type === "embedding" ? pMeta?.defaultEmbeddingModel : pMeta?.defaultModel) || "gemini-2.5-flash");
setEditIsActive(inst.isActive);
}
}
}, [selectedInstanceId, instances, availableProviders]);
const handleProviderChange = (providerId: string) => {
setEditProvider(providerId);
const pMeta = availableProviders.find((p) => p.id === providerId);
if (pMeta) {
setEditModel(editType === "embedding" ? pMeta.defaultEmbeddingModel : pMeta.defaultModel);
}
};
const handleTypeChange = (type: "generative" | "embedding") => {
setEditType(type);
const pMeta = availableProviders.find((p) => p.id === editProvider);
if (pMeta) {
setEditModel(type === "embedding" ? pMeta.defaultEmbeddingModel : pMeta.defaultModel);
}
};
const loadInstances = useCallback(async () => {
try {
const list = await listProviderInstances();
setInstances(list);
} catch {
// ignore
}
}, []);
const loadMappings = useCallback(async () => {
try {
const maps = await getProviderMappings();
setMappings(maps);
} catch {
// ignore
}
}, []);
const loadAll = useCallback(async () => {
setLoading(true);
setError("");
try {
const result = await getConfigStatus();
setConfig(result);
await loadInstances();
await loadMappings();
const provs = await getAvailableProviders();
setAvailableProviders(provs);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
}
}, [loadInstances, loadMappings]);
useEffect(() => {
loadAll();
}, [loadAll]);
const handleSave = async (e: React.FormEvent) => {
e.preventDefault();
if (!editName.trim()) {
setError("Name is required.");
return;
}
try {
setLoading(true);
setError("");
let shouldRegenerate = false;
let targetInstanceId = selectedInstanceId;
if (selectedInstanceId === "new") {
if (!editKey.trim()) {
setError("API Key is required for new instances.");
setLoading(false);
return;
}
const created = await createProviderInstance(editName, editProvider, editKey, editModel || undefined, editType);
if (editIsActive) {
await setActiveProviderInstance(created.id);
}
targetInstanceId = created.id;
setSelectedInstanceId(created.id);
} else {
const inst = instances.find((i) => i.id === selectedInstanceId);
if (inst && inst.type === "embedding") {
const isMapped = mappings["embeddings"] === selectedInstanceId;
const isActive = inst.isActive && !mappings["embeddings"];
if (isMapped || isActive) {
const hasChanged = inst.providerName !== editProvider || inst.modelName !== editModel;
if (hasChanged) {
const confirmChange = window.confirm(
"You have changed the configuration of the active embedding provider. This will delete all existing embeddings and regenerate them from scratch. Are you sure you want to do this?"
);
if (!confirmChange) {
setLoading(false);
return;
}
shouldRegenerate = true;
}
}
}
await updateProviderInstance(selectedInstanceId, editName, editProvider, editKey || undefined, editModel || undefined, editType);
if (editIsActive) {
await setActiveProviderInstance(selectedInstanceId);
}
}
await loadInstances();
await loadMappings();
if (shouldRegenerate && targetInstanceId !== "new") {
await regenerateEmbeddings(targetInstanceId);
}
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
}
};
const handleDelete = async () => {
if (selectedInstanceId === "new") return;
if (!confirm("Are you sure you want to delete this provider instance?")) return;
try {
setLoading(true);
setError("");
await deleteProviderInstance(selectedInstanceId);
setSelectedInstanceId("new");
await loadInstances();
await loadMappings();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
}
};
const handleUpdateMapping = async (task: string, providerInstanceId: string) => {
if (task === "embeddings" && mappings[task] !== providerInstanceId) {
const confirmChange = window.confirm(
"Changing the embeddings provider will delete all existing embeddings and regenerate them from scratch. Are you sure you want to do this?"
);
if (!confirmChange) return;
}
try {
setLoading(true);
await setProviderMapping(task, providerInstanceId);
if (task === "embeddings") {
await regenerateEmbeddings(providerInstanceId);
}
await loadMappings();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
}
};
return (
<div className="mx-auto max-w-[800px] px-4 py-8">
<h1 className="mb-6 text-2xl">Configuration</h1>
{loading && <p>Loading configuration...</p>}
{error && (
<div className="mb-4 rounded border border-red-300 bg-red-50 px-3 py-2 text-sm text-red-700">
{error}
</div>
)}
{config && !loading && (
<>
<section className="mb-8 border-b border-gray-200 pb-6">
<h2 className="mb-3 text-lg">LLM Provider Instances</h2>
<div className="mt-4 grid min-h-[400px] grid-cols-1 overflow-hidden rounded-xl border border-gray-200 bg-white md:grid-cols-[30%_70%]">
{/* 30% area */}
<div className="flex flex-col border-r border-gray-200 bg-gray-50">
<div className="flex items-center justify-between border-b border-gray-200 bg-gray-100 px-4 py-4">
<h3 className="m-0 text-[0.95rem] font-semibold text-[#111]">Instances</h3>
<button
onClick={() => setSelectedInstanceId("new")}
className="cursor-pointer rounded-md bg-emerald-500 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-emerald-600"
type="button"
>
+ Add
</button>
</div>
<div className="flex flex-1 flex-col overflow-y-auto">
{instances.length === 0 ? (
<div className="px-4 py-8 text-center text-xs text-gray-400">
No instances configured
</div>
) : (
instances.map((inst) => (
<div
key={inst.id}
onClick={() => setSelectedInstanceId(inst.id)}
className={`cursor-pointer border-b border-gray-200 border-l-[3px] px-4 py-4 transition-all hover:bg-gray-100 ${
selectedInstanceId === inst.id
? "border-l-blue-500 bg-blue-50"
: "border-l-transparent"
}`}
>
<div className="text-sm font-medium text-[#111]">{inst.name}</div>
<div className="mt-1 flex items-center justify-between text-xs text-gray-500">
<span>{inst.providerName} ({inst.type || "generative"})</span>
{inst.isActive && (
<span className="rounded-full bg-green-100 px-1.5 py-[1px] text-[0.65rem] font-semibold text-green-700">
Active
</span>
)}
</div>
</div>
))
)}
</div>
</div>
{/* 70% area */}
<div className="flex flex-col bg-white">
<form onSubmit={handleSave} className="flex h-full flex-col justify-between">
<div className="flex flex-1 flex-col gap-5 p-6">
<h3 className="m-0 mb-2 text-lg font-semibold text-[#111]">
{selectedInstanceId === "new"
? "Create New Provider Instance"
: `Configure: ${editName}`}
</h3>
<div className="flex flex-col gap-1.5">
<label htmlFor="formName" className="text-xs font-medium text-gray-700">
Friendly Name
</label>
<input
id="formName"
type="text"
value={editName}
onChange={(e) => setEditName(e.target.value)}
placeholder="e.g. Gemini - Production"
required
className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm outline-none transition-[border-color,box-shadow] focus:border-blue-500 focus:ring-3 focus:ring-blue-500/15"
/>
</div>
<div className="flex flex-col gap-1.5">
<label htmlFor="formType" className="text-xs font-medium text-gray-700">
Instance Type
</label>
<select
id="formType"
value={editType}
onChange={(e) => handleTypeChange(e.target.value as "generative" | "embedding")}
className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm outline-none transition-[border-color,box-shadow] focus:border-blue-500 focus:ring-3 focus:ring-blue-500/15"
>
<option value="generative">Generative (Chat / Text Completion)</option>
<option value="embedding">Embedding (Vector generation)</option>
</select>
</div>
<div className="flex flex-col gap-1.5">
<label htmlFor="formProvider" className="text-xs font-medium text-gray-700">
Provider Type
</label>
<select
id="formProvider"
value={editProvider}
onChange={(e) => handleProviderChange(e.target.value)}
className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm outline-none transition-[border-color,box-shadow] focus:border-blue-500 focus:ring-3 focus:ring-blue-500/15"
>
{availableProviders.map((p) => (
<option key={p.id} value={p.id}>
{p.displayName}
</option>
))}
</select>
{editProvider && availableProviders.length > 0 && (
<span className="mt-1 block rounded border border-gray-200 bg-gray-100 px-3 py-2 text-xs text-gray-600">
{availableProviders.find((p) => p.id === editProvider)?.description}
</span>
)}
</div>
<div className="flex flex-col gap-1.5">
<label htmlFor="formKey" className="text-xs font-medium text-gray-700">
API Key
</label>
<input
id="formKey"
type="password"
value={editKey}
onChange={(e) => setEditKey(e.target.value)}
placeholder={
selectedInstanceId === "new"
? "AIzaSy..."
: "•••••••• (unchanged)"
}
required={selectedInstanceId === "new"}
className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm outline-none transition-[border-color,box-shadow] focus:border-blue-500 focus:ring-3 focus:ring-blue-500/15"
/>
</div>
<div className="flex flex-col gap-1.5">
<label htmlFor="formModel" className="text-xs font-medium text-gray-700">
Model Name
</label>
<input
id="formModel"
type="text"
value={editModel}
onChange={(e) => setEditModel(e.target.value)}
placeholder="e.g. gemini-2.5-flash, gemini-2.5-pro"
className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm outline-none transition-[border-color,box-shadow] focus:border-blue-500 focus:ring-3 focus:ring-blue-500/15"
/>
</div>
<div className="mt-1 flex flex-row items-center gap-2">
<input
id="formActive"
type="checkbox"
checked={editIsActive}
onChange={(e) => setEditIsActive(e.target.checked)}
className="h-4 w-4 cursor-pointer"
/>
<label htmlFor="formActive" className="cursor-pointer text-xs font-medium text-gray-700">
Set as Active Instance
</label>
</div>
</div>
<div className="flex items-center justify-between border-t border-gray-200 bg-gray-50 px-6 py-4">
<div>
{selectedInstanceId !== "new" && (
<button
type="button"
onClick={handleDelete}
disabled={loading}
className="cursor-pointer rounded-md bg-red-500 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-red-600 disabled:opacity-50"
>
Delete
</button>
)}
</div>
<div>
<button
type="submit"
disabled={loading}
className="cursor-pointer rounded-md bg-blue-600 px-5 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:opacity-50"
>
{loading ? "Saving..." : "Save"}
</button>
</div>
</div>
</form>
</div>
</div>
</section>
<section className="mb-8 border-b border-gray-200 pb-6">
<h2 className="mb-3 text-lg">Task Provider Routing</h2>
<p className="my-4 rounded border border-blue-200 bg-blue-50 px-3 py-2 text-xs text-blue-800">
Configure which LLM Provider Key Instance should handle each specific simulation
task. Mappings default to the currently <strong>Active</strong> instance if not
specified.
</p>
<div className="mt-4 grid grid-cols-1 gap-4 md:grid-cols-2">
{[
{ key: "actor-prose", label: "Actor Prose Generation", desc: "Generates roleplay/narrative prose for Non-Player Characters.", type: "generative" },
{ key: "llm-validator", label: "LLM Validator", desc: "Arbitrates and validates proposed actions against the world state rules.", type: "generative" },
{ key: "intent-decoder", label: "Intent Decoder", desc: "Splits raw prose actions into structured intents (Player and NPC).", type: "generative" },
{ key: "timedelta", label: "TimeDelta Generator", desc: "Calculates the duration of character actions to advance the game clock.", type: "generative" },
{ key: "embeddings", label: "Text Embeddings Generator", desc: "Generates vector embeddings for long-term memory retrieval.", type: "embedding" },
].map((task) => (
<div
key={task.key}
className="flex flex-col justify-between gap-3 rounded-lg border border-gray-200 bg-gray-50 p-4"
>
<div className="flex flex-col gap-1 text-xs">
<strong className="text-sm text-[#111]">{task.label}</strong>
<span className="mt-0.5 text-gray-500">{task.desc}</span>
</div>
<select
value={mappings[task.key] || ""}
onChange={(e) => handleUpdateMapping(task.key, e.target.value)}
className="w-full rounded border border-gray-300 bg-white px-2 py-1.5 text-xs"
>
<option value="">-- Use Active Key (Default) --</option>
{instances
.filter((inst) => (inst.type || "generative") === task.type)
.map((inst) => (
<option key={inst.id} value={inst.id}>
{inst.name} ({inst.providerName}){inst.isActive ? " [Active]" : ""}
</option>
))}
</select>
</div>
))}
</div>
</section>
<section className="mb-8 border-b border-gray-200 pb-6">
<h2 className="mb-3 text-lg">Environment Variables Default</h2>
<div className="flex justify-between border-b border-gray-100 py-1.5">
<span className="text-sm text-gray-500">Default Model</span>
<span className="text-sm">
<code className="font-mono text-sm">{config.model}</code>
</span>
</div>
<div className="flex justify-between border-b border-gray-100 py-1.5">
<span className="text-sm text-gray-500">Default API Key (.env)</span>
<span
className={
config.apiKeySet
? "text-sm text-green-600"
: "text-sm font-medium text-red-600"
}
>
{config.apiKeySet
? `✓ Set (${config.apiKeyPreview})`
: "✗ NOT SET"}
</span>
</div>
</section>
<section className="mb-8 border-b border-gray-200 pb-6">
<h2 className="mb-3 text-lg">Available Scenarios</h2>
{config.availableScenarios.length === 0 ? (
<p className="mt-3 rounded border border-amber-200 bg-amber-100 px-3 py-2 text-xs text-amber-800">
No scenarios found in <code className="font-mono text-xs">content/demo/scenarios/</code>.
</p>
) : (
<table className="w-full border-collapse text-sm">
<thead>
<tr>
<th className="border-b-2 border-gray-200 p-2 text-left font-medium text-gray-500">
Name
</th>
<th className="border-b-2 border-gray-200 p-2 text-left font-medium text-gray-500">
Path
</th>
</tr>
</thead>
<tbody>
{config.availableScenarios.map((s) => (
<tr key={s.path}>
<td className="border-b border-gray-100 p-2">{s.name}</td>
<td className="border-b border-gray-100 p-2">
<code className="font-mono text-xs text-blue-600">{s.path}</code>
</td>
</tr>
))}
</tbody>
</table>
)}
</section>
<section className="mb-8 border-b border-gray-200 pb-6">
<h2 className="mb-3 text-lg">Engine Packages</h2>
<p className="mt-3 rounded border border-amber-200 bg-amber-100 px-3 py-2 text-xs text-amber-800">
All <code className="font-mono text-xs">@omnia/*</code> workspace packages are
consumed via <code className="font-mono text-xs">transpilePackages</code> in{" "}
<code className="font-mono text-xs">next.config.ts</code>. The native{" "}
<code className="font-mono text-xs">better-sqlite3</code> module is externalized
via <code className="font-mono text-xs">serverExternalPackages</code>.
</p>
</section>
</>
)}
</div>
);
}

View File

@@ -0,0 +1,10 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
html {
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
}
}

BIN
apps/gui/src/app/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 919 B

View File

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

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

@@ -0,0 +1,32 @@
import Link from "next/link";
export default function Home() {
return (
<main className="mx-auto max-w-[800px] px-4 py-12">
<h1 className="mb-2 text-3xl">Omnia GUI</h1>
<p className="mb-8 text-gray-500">
Configuration and gameplay interface for the Omnia simulation engine.
</p>
<div className="flex gap-4">
<Link
href="/play"
className="block flex-1 rounded-lg border border-gray-200 p-6 text-inherit no-underline transition-[border-color,box-shadow] duration-150 hover:border-blue-600 hover:shadow-[0_2px_8px_rgba(37,99,235,0.1)]"
>
<h2 className="mb-1 text-xl">Play</h2>
<p className="text-sm text-gray-500">
Start a simulation and interact with NPCs
</p>
</Link>
<Link
href="/config"
className="block flex-1 rounded-lg border border-gray-200 p-6 text-inherit no-underline transition-[border-color,box-shadow] duration-150 hover:border-blue-600 hover:shadow-[0_2px_8px_rgba(37,99,235,0.1)]"
>
<h2 className="mb-1 text-xl">Config</h2>
<p className="text-sm text-gray-500">
Check environment, API keys, and available scenarios
</p>
</Link>
</div>
</main>
);
}

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -0,0 +1,11 @@
import type { Config } from "tailwindcss";
const config: Config = {
content: ["./src/**/*.{js,ts,jsx,tsx,mdx}"],
theme: {
extend: {},
},
plugins: [],
};
export default config;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Before

Width:  |  Height:  |  Size: 391 B

View File

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

Before

Width:  |  Height:  |  Size: 1.0 KiB

View File

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

Before

Width:  |  Height:  |  Size: 1.3 KiB

View File

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

Before

Width:  |  Height:  |  Size: 128 B

View File

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

Before

Width:  |  Height:  |  Size: 385 B

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

View File

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

View File

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

View File

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

View File

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

1
docs Symbolic link
View File

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

View File

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

View File

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

View File

@@ -1,125 +0,0 @@
# Memory & Subjective Aliases
This document outlines the memory subsystem (`packages/memory`) and the v0 Subjective Alias System. The goal is to enforce epistemic privacy while allowing the LLM-driven components (NPC agents and decoders) to translate naturally between system-level IDs and human-readable narrative context.
---
## 1. Subjective Alias System (v0)
System-level IDs (e.g. `alice`, `bob`, or UUIDs) are critical for state tracking, but placing them directly in prompts violates epistemic privacy and breaks narrative generation (as models make up inconsistent names or fail to match references).
To solve this, each `Entity` class (in [entity.ts](file:///home/sortedcord/Projects/omnia_umbrella/omnia/packages/core/src/entity.ts)) maintains a private **Subjective Alias Map**:
```typescript
class Entity extends AttributableObject {
locationId: string | null = null;
readonly aliases: Map<string, string> = new Map();
// Key: target entity ID (e.g., "bob")
// Value: subjective string (e.g., "the hooded figure" or "Gareth")
}
```
### Alias States
* **Unknown Name**: The entity does not know the target's real name. The alias defaults to a subjective label derived from the target's visible description/attributes (e.g., `"the hooded figure"`). This label is used in both internal thoughts and external dialogues.
* **Known Name**: The entity has learned the target's name. The alias is updated to their name (e.g., `"Gareth"`).
### How It Wires Into Prompts
* **Intent Decoder**: When decoding narrative prose written by actor `X`, we pass `X`'s alias map to the LLM. This allows the decoder to map subjective labels like *"the hooded figure"* back to the correct system ID `bob`.
* **Prompt Injection**: When injecting world state, events, or memories into an NPC's prompt context, the system replaces raw target IDs with the subjective aliases defined in that NPC's alias map.
### SQLite Persistence
Entity aliases are persisted in the `objects` table via the `aliases_json TEXT` column in [repository.ts](file:///home/sortedcord/Projects/omnia_umbrella/omnia/packages/core/src/repository.ts). The aliases map is stringified as JSON entries on save and parsed back upon entity reconstitution in `SQLiteRepository.loadEntity()`, `loadWorldState()`, and `listEntities()`.
---
## 2. Subjective Buffer Entry
A subjective `BufferEntry` records a discrete event from the perspective of an entity (the `owner`). It wraps a structured `Intent` (reused as-is to prevent schema drift) and appends execution metadata. The interface is defined and exported from [buffer.ts](file:///home/sortedcord/Projects/omnia_umbrella/omnia/packages/memory/src/buffer.ts).
### The Shape of a Buffer Entry
```typescript
interface BufferEntry {
id: string;
ownerId: string; // Whose subjective memory buffer this lives in
timestamp: string; // WorldClock.get().toISOString() at write time
locationId: string | null; // Actor's location when this happened
intent: Intent; // The actual dialogue/action intent, reused as-is
outcome?: { // Present only for "action" intents processed by the Architect
isValid: boolean;
reason: string;
};
}
```
* **Intent Reuse**: By wrapping the `Intent` directly, any future schema changes to `Intent` flow down automatically without duplicating code.
* **Write-time Location**: `locationId` is captured immediately when writing the entry (rather than computed dynamically later), matching the schema of long-term `LedgerEntry` consolidation.
---
## 3. Buffer Serialization (Epistemic Substitute)
To prevent leaking system IDs or universal state to NPCs, we serialize buffer memories using a decoupled, viewer-relative function `serializeSubjectiveBufferEntry` in [buffer.ts](file:///home/sortedcord/Projects/omnia_umbrella/omnia/packages/memory/src/buffer.ts).
To safely resolve actor and target entities without leaking internal UUIDs, we use the `resolveAlias` helper:
```typescript
export function resolveAlias(viewer: Entity, targetId: string): string {
if (targetId === viewer.id) return "you";
return viewer.aliases.get(targetId) ?? "an unfamiliar figure";
}
```
This helper maps:
- Self-references (when an entity evaluates their own memory) to `"you"`.
- Known targets to their subjective name/descriptor from the alias map.
- Unknown targets to a generic `"an unfamiliar figure"`.
The primary serialization function consumes this helper:
```typescript
export function serializeSubjectiveBufferEntry(
entry: BufferEntry,
viewer: Entity
): string {
const dateObj = new Date(entry.timestamp);
const timeStr = dateObj.toLocaleTimeString("en-US", { hour12: true, timeZone: "UTC" });
const actorAlias = resolveAlias(viewer, entry.intent.actorId);
const targetAliases = entry.intent.targetIds.map(
(tid) => resolveAlias(viewer, tid)
);
let details: string;
if (entry.intent.type === "dialogue") {
details = `spoke to ${targetAliases.join(", ") || "someone"}: "${entry.intent.description}"`;
} else {
details = `${entry.intent.description}`;
if (entry.outcome) {
details += ` (Outcome: ${entry.outcome.isValid ? "Succeeded" : `Failed - ${entry.outcome.reason}`})`;
}
}
return `[${timeStr}] ${actorAlias} ${details}`;
}
```
This guarantees that the prompt reads cleanly (e.g. `[12:03:00 PM] the hooded figure opened the wooden chest (Outcome: Succeeded)` or `[12:03:00 PM] you spoke to an unfamiliar figure...`) without exposing raw system IDs to the NPC.
---
## 4. SQLite Persistence & BufferRepository
The `BufferRepository` class in [buffer.ts](file:///home/sortedcord/Projects/omnia_umbrella/omnia/packages/memory/src/buffer.ts) utilizes the same SQLite database as core repositories:
```sql
CREATE TABLE IF NOT EXISTS buffer_entries (
id TEXT PRIMARY KEY,
owner_id TEXT NOT NULL,
timestamp TEXT NOT NULL,
location_id TEXT,
intent_json TEXT NOT NULL,
outcome_json TEXT,
FOREIGN KEY (owner_id) REFERENCES objects(id) ON DELETE CASCADE
);
```
* **JSON Storage**: `intent` and `outcome` are serialized/deserialized as raw JSON. Because they are validated by Zod at creation time, they bypass redundant validation checks during database roundtrips.
* **Cascade Deletes**: The table is configured with a foreign key referencing the `objects` table (`ON DELETE CASCADE`), ensuring that deleting an entity cleanses all their associated subjective memory entries automatically.

View File

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

View File

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

View File

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

View File

@@ -6,8 +6,11 @@
"scripts": {
"build": "tsc -b",
"build:web": "pnpm --filter landing build",
"build:all": "pnpm build && pnpm build:web",
"build:docs": "pnpm --filter docs build",
"build:gui": "pnpm --filter @omnia/gui build",
"dev:web": "pnpm --filter landing dev",
"dev:docs": "pnpm --filter docs dev",
"dev:gui": "pnpm --filter @omnia/gui dev",
"clean": "git clean -xfd",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
@@ -19,8 +22,8 @@
"test:evals": "vitest run --project evals"
},
"keywords": [],
"author": "",
"license": "ISC",
"author": "sortedcord",
"license": "MIT",
"devEngines": {
"packageManager": {
"name": "pnpm",
@@ -44,6 +47,7 @@
},
"dependencies": {
"@langchain/google-genai": "^2.2.0",
"@langchain/openrouter": "^0.4.3",
"@types/node": "^20.19.43",
"dotenv": "^17.4.2"
}

View File

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

View File

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

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

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

View File

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

View File

@@ -0,0 +1,100 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import Database from "better-sqlite3";
import { WorldState, Entity, AttributeVisibility } from "@omnia/core";
import { BufferRepository, LedgerRepository } from "@omnia/memory";
import { ActorPromptBuilder } from "../src/actor-prompt-builder";
describe("ActorPromptBuilder with Long-Term Memory Integration", () => {
let db: Database.Database;
let bufferRepo: BufferRepository;
let ledgerRepo: LedgerRepository;
beforeEach(() => {
db = new Database(":memory:");
// Core database schemas for testing
db.exec(`
CREATE TABLE objects (
id TEXT PRIMARY KEY
);
`);
db.exec(`
INSERT INTO objects (id) VALUES ('alice'), ('bob'), ('charlie');
`);
bufferRepo = new BufferRepository(db);
ledgerRepo = new LedgerRepository(db);
});
afterEach(() => {
db.close();
});
it("should inject both recent memory and recalled long-term memory with subjective aliases resolved", () => {
const world = new WorldState("world-123", new Date("2024-01-10T12:00:00.000Z"));
const alice = new Entity("alice", "tavern");
// Add subjective alias for bob
alice.aliases.set("bob", "Strider");
world.addEntity(alice);
const bob = new Entity("bob", "tavern");
world.addEntity(bob);
// 1. Populate recent buffer memory
bufferRepo.save({
id: "buf1",
ownerId: "alice",
timestamp: "2024-01-10T11:58:00.000Z", // 2 mins ago
locationId: "tavern",
intent: {
type: "dialogue",
actorId: "alice",
targetIds: ["bob"],
originalText: "Hello there",
description: "Alice greets Bob",
},
});
// 2. Populate ledger repository (long-term memory)
ledgerRepo.save({
id: "ledger1",
ownerId: "alice",
timestamp: "2024-01-08T12:00:00.000Z", // 2 days ago
locationId: "tavern",
involvedEntityIds: ["bob"],
content: "alice met bob at the tavern.",
quotes: ["I am a ranger."],
importance: 9,
embedding: [],
});
const builder = new ActorPromptBuilder(bufferRepo, ledgerRepo, 20, 5);
const { userContext } = builder.build(world, alice);
// Check recent memory exists
expect(userContext).toContain("=== RECENT EVENTS ===");
expect(userContext).toContain("Alice greets Bob");
// Bob should be resolved to Strider
expect(userContext).toContain("spoke to Strider");
// Check long-term memory exists
expect(userContext).toContain("=== YOUR MEMORIES ===");
// Bob should be resolved to Strider in the ledger content
expect(userContext).toContain("alice met Strider at the tavern.");
expect(userContext).toContain('Quote: "I am a ranger."');
});
it("should not explode if ledger contains no memories or is empty", () => {
const world = new WorldState("world-123", new Date("2024-01-10T12:00:00.000Z"));
const alice = new Entity("alice", "tavern");
world.addEntity(alice);
const builder = new ActorPromptBuilder(bufferRepo, ledgerRepo, 20, 5);
const { userContext } = builder.build(world, alice);
expect(userContext).toContain("=== RECENT EVENTS ===");
expect(userContext).not.toContain("=== YOUR MEMORIES ===");
});
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -10,7 +10,7 @@ export class IntentDecoder {
*
* Responsibilities (from docs/intents.md):
* - Split prose into multiple intents when applicable.
* - Classify each intent as "dialogue" or "action".
* - Classify each intent as "dialogue", "action", or "monologue".
* - Parse narrative text into structured JSON with minimal information loss.
* - Contextually resolve receiving parties (targets).
*/
@@ -35,10 +35,11 @@ For each intent you must:
1. Classify its type:
- "dialogue": Any speech, conversation, or verbal communication directed at another entity.
- "action": Any physical or logical action performed in the world (e.g., moving, picking up, opening, looking).
- "monologue": An inner thought, reflection, or internal monologue. This is purely internal — not spoken aloud, not perceivable by any other entity, and not a physical action. Use this for any prose depicting the character thinking, reflecting, feeling, or narrating to themselves internally.
2. Extract the original text fragment from the prose that corresponds to this intent.
3. Write a concise, structured description of the intent (what is being done or said). Include as much detail about the action as possible that was extracted from the narrative prose. Do not make up qualities.
4. Identify the actorId (the entity performing the intent — this will always be "${actorId}").
5. Identify targetIds — the entity IDs of the receiving parties. Use the "KNOWN ENTITY IDS" and "ACTOR ALIASES" mapping to resolve any subjective names, descriptions, or nicknames used in the prose to their correct system entity IDs. If no specific target, use an empty array.
5. Identify targetIds — the entity IDs of the receiving parties. Use the "KNOWN ENTITY IDS" and "ACTOR ALIASES" mapping to resolve any subjective names, descriptions, or nicknames used in the prose to their correct system entity IDs. If no specific target, use an empty array. For "monologue" intents, targetIds must always be an empty array.
Rules:
- Preserve the chronological order of intents as they appear in the prose.

View File

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

View File

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

View File

@@ -2,6 +2,7 @@ import { z } from "zod";
const LLMConfigSchema = z.object({
GOOGLE_API_KEY: z.string().optional(),
OPENROUTER_API_KEY: z.string().optional(),
});
export const llmConfig = LLMConfigSchema.parse(process.env);

View File

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

View File

@@ -11,12 +11,74 @@ export interface LLMResponse<T> {
success: boolean;
data?: T;
error?: string;
usage?: {
inputTokens: number;
outputTokens: number;
totalTokens: number;
};
}
export interface LLMCallRecord {
systemPrompt: string;
userContext: string;
usage?: {
inputTokens: number;
outputTokens: number;
totalTokens: number;
};
}
export interface ILLMProvider {
providerName: string;
// We use Zod to ensure the generic T matches the schema
generateStructuredResponse<T extends z.ZodTypeAny>(
request: LLMRequest<T>,
): Promise<LLMResponse<z.infer<T>>>;
lastCalls?: LLMCallRecord[];
}
export interface IEmbeddingProvider {
providerName: string;
embed(text: string): Promise<number[]>;
}
export interface ModelProviderInstance {
id: string;
name: string;
providerName: string;
apiKey: string;
isActive: boolean;
modelName?: string;
type: "generative" | "embedding";
}
export interface ModelProviderMeta {
id: string;
displayName: string;
description: string;
defaultModel: string;
defaultEmbeddingModel: string;
}
export const AVAILABLE_PROVIDERS: ModelProviderMeta[] = [
{
id: "google-genai",
displayName: "Google Gemini",
description: "Official Gemini integration using Google Gen AI SDK",
defaultModel: "gemini-2.5-flash",
defaultEmbeddingModel: "gemini-embedding-001",
},
{
id: "openrouter",
displayName: "OpenRouter",
description: "Multi-model router supporting Anthropic, OpenAI, DeepSeek, and local models",
defaultModel: "google/gemini-2.5-flash",
defaultEmbeddingModel: "openai/text-embedding-3-small",
},
{
id: "mock",
displayName: "Mock LLM Provider",
description: "Stateless mock provider for testing and offline development",
defaultModel: "mock",
defaultEmbeddingModel: "mock-embeddings",
},
];

View File

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

View File

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

View File

@@ -1,9 +1,15 @@
import { z } from "zod";
import { ILLMProvider, LLMRequest, LLMResponse } from "../llm.js";
import { ILLMProvider, LLMRequest, LLMResponse, LLMCallRecord, IEmbeddingProvider } from "../llm.js";
export class MockLLMProvider implements ILLMProvider {
static readonly providerId = "mock";
static readonly displayName = "Mock LLM Provider";
static readonly description = "Stateless mock provider for testing and offline development";
static readonly defaultModel = "mock";
providerName = "mock";
private callCount = 0;
lastCalls: LLMCallRecord[] = [];
constructor(private responses: unknown[]) {}
@@ -14,11 +20,35 @@ export class MockLLMProvider implements ILLMProvider {
if (next === undefined) {
return { success: false, error: "Mock responses exhausted" };
}
const usage = { inputTokens: 100, outputTokens: 50, totalTokens: 150 };
this.lastCalls.push({
systemPrompt: request.systemPrompt,
userContext: request.userContext,
usage,
});
try {
const parsed = request.schema.parse(next);
return { success: true, data: parsed };
return { success: true, data: parsed, usage };
} catch (e) {
return { success: false, error: e instanceof Error ? e.message : String(e) };
}
}
}
export class MockEmbeddingProvider implements IEmbeddingProvider {
static readonly providerId = "mock";
providerName = "mock";
constructor(private modelName?: string) {}
async embed(text: string): Promise<number[]> {
// Return a deterministic mock 768-dimensional vector based on the text
const vec = new Array(768).fill(0).map((_, i) => {
// Return a predictable float between -1.0 and 1.0
const charCode = text.charCodeAt(i % text.length) || 0;
return Math.sin(charCode + i);
});
return vec;
}
}

View File

@@ -0,0 +1,80 @@
import { z } from "zod";
import { ChatOpenRouter } from "@langchain/openrouter";
import { ILLMProvider, LLMRequest, LLMResponse, LLMCallRecord } from "../llm.js";
import { llmConfig } from "../config.js";
import { ProviderManager } from "../provider-manager.js";
export class OpenRouterProvider implements ILLMProvider {
static readonly providerId = "openrouter";
static readonly displayName = "OpenRouter";
static readonly description = "Multi-model router supporting Anthropic, OpenAI, DeepSeek, and local models";
static readonly defaultModel = "google/gemini-2.5-flash";
providerName = "OpenRouter";
private model: ChatOpenRouter;
lastCalls: LLMCallRecord[] = [];
constructor(apiKey?: string, modelName?: string) {
let key = apiKey;
let model = modelName;
if (!key) {
const active = ProviderManager.getActive("generative");
if (active) {
key = active.apiKey;
if (!model) {
model = active.modelName;
}
}
}
if (!key) {
key = llmConfig.OPENROUTER_API_KEY;
}
if (!key) {
throw new Error("OPENROUTER_API_KEY is required to initialize OpenRouterProvider");
}
this.model = new ChatOpenRouter({
apiKey: key,
model: model || "google/gemini-2.5-flash",
});
}
async generateStructuredResponse<T extends z.ZodTypeAny>(
request: LLMRequest<T>,
): Promise<LLMResponse<z.infer<T>>> {
const structuredModel = this.model.withStructuredOutput(request.schema, { includeRaw: true });
const result = (await structuredModel.invoke([
{ role: "system", content: request.systemPrompt },
{ role: "user", content: request.userContext },
])) as unknown as {
parsed?: z.infer<T>;
raw?: {
usage_metadata?: {
input_tokens?: number;
output_tokens?: number;
total_tokens?: number;
};
};
};
const parsed = result?.parsed;
const raw = result?.raw;
const usage = raw?.usage_metadata ? {
inputTokens: raw.usage_metadata.input_tokens || 0,
outputTokens: raw.usage_metadata.output_tokens || 0,
totalTokens: raw.usage_metadata.total_tokens || 0,
} : undefined;
this.lastCalls.push({
systemPrompt: request.systemPrompt,
userContext: request.userContext,
usage,
});
return { success: true, data: parsed, usage };
}
}

View File

@@ -1,6 +1,6 @@
import { describe, test, expect } from "vitest";
import { z } from "zod";
import { MockLLMProvider } from "@omnia/llm";
import { MockLLMProvider, MockEmbeddingProvider } from "@omnia/llm";
describe("MockLLMProvider Unit Tests (Tier 1)", () => {
test("returns parsed matching data for valid mock response", async () => {
@@ -61,3 +61,21 @@ describe("MockLLMProvider Unit Tests (Tier 1)", () => {
expect(response.data).toBeUndefined();
});
});
describe("MockEmbeddingProvider Unit Tests (Tier 1)", () => {
test("generates deterministic 768-dimensional vectors", async () => {
const provider = new MockEmbeddingProvider("mock-embeddings");
const text = "Hello world";
const vec1 = await provider.embed(text);
const vec2 = await provider.embed(text);
expect(vec1.length).toBe(768);
expect(vec2.length).toBe(768);
expect(vec1).toEqual(vec2); // Deterministic
// Ensure values are numbers between -1.0 and 1.0 (since they are generated with Math.sin)
expect(typeof vec1[0]).toBe("number");
expect(vec1[0]).toBeGreaterThanOrEqual(-1.0);
expect(vec1[0]).toBeLessThanOrEqual(1.0);
});
});

View File

@@ -0,0 +1,107 @@
import { describe, test, expect, vi } from "vitest";
import { z } from "zod";
import { OpenRouterProvider } from "../src/providers/openrouter.js";
import { llmConfig } from "../src/config.js";
// Mock the ChatOpenRouter class
vi.mock("@langchain/openrouter", () => {
return {
ChatOpenRouter: class {
config: unknown;
constructor(config: unknown) {
this.config = config;
}
withStructuredOutput = vi.fn().mockImplementation(() => {
return {
invoke: vi.fn().mockImplementation(async () => {
// Return a mock output that matches the includeRaw: true structure
return {
parsed: {
name: "mocked response",
success: true,
},
raw: {
usage_metadata: {
input_tokens: 10,
output_tokens: 5,
total_tokens: 15,
},
},
};
}),
};
});
},
};
});
describe("OpenRouterProvider Unit Tests (Tier 1)", () => {
test("initializes successfully with a provided apiKey", () => {
const provider = new OpenRouterProvider("dummy-key");
expect(provider.providerName).toBe("OpenRouter");
});
test("initializes successfully with apiKey from config", () => {
// Save current config
const originalKey = llmConfig.OPENROUTER_API_KEY;
llmConfig.OPENROUTER_API_KEY = "env-dummy-key";
try {
const provider = new OpenRouterProvider();
expect(provider.providerName).toBe("OpenRouter");
} finally {
llmConfig.OPENROUTER_API_KEY = originalKey;
}
});
test("throws error if no API key is provided or in config", () => {
// Save current config
const originalKey = llmConfig.OPENROUTER_API_KEY;
llmConfig.OPENROUTER_API_KEY = undefined;
try {
expect(() => new OpenRouterProvider()).toThrow(
"OPENROUTER_API_KEY is required to initialize OpenRouterProvider"
);
} finally {
llmConfig.OPENROUTER_API_KEY = originalKey;
}
});
test("generateStructuredResponse invokes the model with structured output, records usage and updates lastCalls", async () => {
const provider = new OpenRouterProvider("dummy-key");
const TestSchema = z.object({
name: z.string(),
success: z.boolean(),
});
const response = await provider.generateStructuredResponse({
systemPrompt: "system prompt",
userContext: "user context",
schema: TestSchema,
});
expect(response.success).toBe(true);
expect(response.data).toEqual({
name: "mocked response",
success: true,
});
expect(response.usage).toEqual({
inputTokens: 10,
outputTokens: 5,
totalTokens: 15,
});
expect(provider.lastCalls.length).toBe(1);
expect(provider.lastCalls[0]).toEqual({
systemPrompt: "system prompt",
userContext: "user context",
usage: {
inputTokens: 10,
outputTokens: 5,
totalTokens: 15,
},
});
});
});

View File

@@ -25,13 +25,6 @@ export function serializeSubjectiveBufferEntry(
entry: BufferEntry,
viewer: Entity,
): string {
const dateObj = new Date(entry.timestamp);
// Ensure a deterministic timezone/format for testing and model inputs:
const timeStr = dateObj.toLocaleTimeString("en-US", {
hour12: true,
timeZone: "UTC",
});
const actorAlias = resolveAlias(viewer, entry.intent.actorId);
const targetAliases = entry.intent.targetIds.map((tid) =>
@@ -39,16 +32,20 @@ export function serializeSubjectiveBufferEntry(
);
let details: string;
const content = entry.intent.description.trim() || entry.intent.originalText.trim();
if (entry.intent.type === "dialogue") {
details = `spoke to ${targetAliases.join(", ") || "someone"}: "${entry.intent.description}"`;
details = `spoke to ${targetAliases.join(", ") || "someone"}: "${content}"`;
} else if (entry.intent.type === "monologue") {
details = `thought: "${content}"`;
} else {
details = `${entry.intent.description}`;
details = content;
if (entry.outcome) {
details += ` (Outcome: ${entry.outcome.isValid ? "Succeeded" : `Failed - ${entry.outcome.reason}`})`;
}
}
return `[${timeStr}] ${actorAlias} ${details}`;
return `${actorAlias} ${details}`;
}
export class BufferRepository {

View File

@@ -1 +1,2 @@
export * from "./buffer.js";
export * from "./ledger.js";

View File

@@ -0,0 +1,379 @@
import Database from "better-sqlite3";
export interface LedgerEntry {
id: string;
ownerId: string;
timestamp: string;
locationId: string | null;
involvedEntityIds: string[];
content: string;
quotes: string[];
importance: number;
embedding: number[];
}
export class LedgerRepository {
constructor(private db: Database.Database) {
// Enable foreign keys for cascading deletes
this.db.exec("PRAGMA foreign_keys = ON;");
this.initializeSchema();
}
private initializeSchema(): void {
this.db.exec(`
CREATE TABLE IF NOT EXISTS ledger_entries (
id TEXT PRIMARY KEY,
owner_id TEXT NOT NULL,
timestamp TEXT NOT NULL,
location_id TEXT,
content TEXT NOT NULL,
quotes_json TEXT,
importance INTEGER NOT NULL,
embedding BLOB,
FOREIGN KEY (owner_id) REFERENCES objects(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS ledger_involved_entities (
entry_id TEXT NOT NULL,
entity_id TEXT NOT NULL,
PRIMARY KEY (entry_id, entity_id),
FOREIGN KEY (entry_id) REFERENCES ledger_entries(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_ledger_owner ON ledger_entries(owner_id);
CREATE INDEX IF NOT EXISTS idx_ledger_location ON ledger_entries(location_id);
CREATE INDEX IF NOT EXISTS idx_ledger_importance ON ledger_entries(importance);
CREATE INDEX IF NOT EXISTS idx_ledger_involved_entity ON ledger_involved_entities(entity_id);
`);
}
save(entry: LedgerEntry): void {
const insertEntry = this.db.prepare(`
INSERT INTO ledger_entries (id, owner_id, timestamp, location_id, content, quotes_json, importance, embedding)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
owner_id = excluded.owner_id,
timestamp = excluded.timestamp,
location_id = excluded.location_id,
content = excluded.content,
quotes_json = excluded.quotes_json,
importance = excluded.importance,
embedding = excluded.embedding
`);
const insertEntity = this.db.prepare(`
INSERT OR IGNORE INTO ledger_involved_entities (entry_id, entity_id)
VALUES (?, ?)
`);
const deleteEntities = this.db.prepare(`
DELETE FROM ledger_involved_entities WHERE entry_id = ?
`);
this.db.transaction(() => {
insertEntry.run(
entry.id,
entry.ownerId,
entry.timestamp,
entry.locationId,
entry.content,
JSON.stringify(entry.quotes),
entry.importance,
entry.embedding.length > 0
? Buffer.from(new Float32Array(entry.embedding).buffer)
: null
);
deleteEntities.run(entry.id);
for (const entityId of entry.involvedEntityIds) {
insertEntity.run(entry.id, entityId);
}
})();
}
private mapRowToEntry(row: any, involvedEntityIds: string[]): LedgerEntry {
let embedding: number[] = [];
if (row.embedding) {
const buffer = row.embedding as Buffer;
const floatArray = new Float32Array(
buffer.buffer,
buffer.byteOffset,
buffer.byteLength / Float32Array.BYTES_PER_ELEMENT
);
embedding = Array.from(floatArray);
}
return {
id: row.id,
ownerId: row.owner_id,
timestamp: row.timestamp,
locationId: row.location_id,
involvedEntityIds,
content: row.content,
quotes: JSON.parse(row.quotes_json || "[]"),
importance: row.importance,
embedding: embedding,
};
}
load(id: string): LedgerEntry | null {
const row = this.db
.prepare(
`
SELECT id, owner_id, timestamp, location_id, content, quotes_json, importance, embedding
FROM ledger_entries
WHERE id = ?
`
)
.get(id) as any;
if (!row) return null;
const entitiesRows = this.db
.prepare(
`
SELECT entity_id FROM ledger_involved_entities WHERE entry_id = ?
`
)
.all(id) as { entity_id: string }[];
return this.mapRowToEntry(row, entitiesRows.map((er) => er.entity_id));
}
/**
* Retrieves relevant ledger entries using Phase 1: Deterministic Heuristic Filtering
* Filters by:
* 1. locationId matches current location
* 2. involvedEntityIds overlaps with current involved entities
* 3. importance >= 8 (high salience)
*/
getRelevant(
ownerId: string,
currentLocationId: string | null,
currentInvolvedEntityIds: string[],
limit: number = 20
): LedgerEntry[] {
let query = `
SELECT DISTINCT le.id, le.owner_id, le.timestamp, le.location_id, le.content, le.quotes_json, le.importance, le.embedding
FROM ledger_entries le
LEFT JOIN ledger_involved_entities lie ON le.id = lie.entry_id
WHERE le.owner_id = ?
AND (
le.importance >= 8
`;
const params: any[] = [ownerId];
if (currentLocationId) {
query += ` OR le.location_id = ?`;
params.push(currentLocationId);
}
if (currentInvolvedEntityIds.length > 0) {
const placeholders = currentInvolvedEntityIds.map(() => "?").join(",");
query += ` OR lie.entity_id IN (${placeholders})`;
params.push(...currentInvolvedEntityIds);
}
query += `
)
ORDER BY le.timestamp DESC
LIMIT ?
`;
params.push(limit);
const rows = this.db.prepare(query).all(...params) as any[];
if (rows.length === 0) return [];
const entryIds = rows.map((r) => r.id);
const placeholders = entryIds.map(() => "?").join(",");
const entitiesRows = this.db
.prepare(
`
SELECT entry_id, entity_id FROM ledger_involved_entities
WHERE entry_id IN (${placeholders})
`
)
.all(...entryIds) as { entry_id: string; entity_id: string }[];
const entitiesMap = new Map<string, string[]>();
for (const er of entitiesRows) {
if (!entitiesMap.has(er.entry_id)) {
entitiesMap.set(er.entry_id, []);
}
entitiesMap.get(er.entry_id)!.push(er.entity_id);
}
return rows.map((row) => this.mapRowToEntry(row, entitiesMap.get(row.id) || []));
}
private fetchRawNeighbors(ownerId: string, timestamp: string): LedgerEntry[] {
const neighbors: LedgerEntry[] = [];
// Preceding entry
const preceding = this.db
.prepare(
`
SELECT id, owner_id, timestamp, location_id, content, quotes_json, importance, embedding
FROM ledger_entries
WHERE owner_id = ? AND timestamp < ?
ORDER BY timestamp DESC
LIMIT 1
`
)
.get(ownerId, timestamp) as any;
if (preceding) {
neighbors.push(this.mapRowToEntry(preceding, []));
}
// Succeeding entry
const succeeding = this.db
.prepare(
`
SELECT id, owner_id, timestamp, location_id, content, quotes_json, importance, embedding
FROM ledger_entries
WHERE owner_id = ? AND timestamp > ?
ORDER BY timestamp ASC
LIMIT 1
`
)
.get(ownerId, timestamp) as any;
if (succeeding) {
neighbors.push(this.mapRowToEntry(succeeding, []));
}
return neighbors;
}
/**
* Phase 1 + Phase 2 Retrieval Pipeline
* 1. Fetches candidates via Phase 1 heuristic filtering.
* 2. Ranks them using: Score = Recency + Importance + Semantic Match.
* 3. Selects the top `limit` memories.
* 4. Optionally pulls in the immediate chronological neighbors (associative chain).
* 5. Returns all gathered entries sorted chronologically (timestamp ASC).
*/
retrieve(
ownerId: string,
currentLocationId: string | null,
currentInvolvedEntityIds: string[],
queryEmbedding?: number[],
now: Date = new Date(),
limit: number = 5,
options?: {
includeAssociativeNeighbors?: boolean;
recencyWeight?: number;
importanceWeight?: number;
relevanceWeight?: number;
decayRate?: number;
}
): LedgerEntry[] {
const includeAssociativeNeighbors = options?.includeAssociativeNeighbors ?? false;
const recencyWeight = options?.recencyWeight ?? 1.0;
const importanceWeight = options?.importanceWeight ?? 1.0;
const relevanceWeight = options?.relevanceWeight ?? 1.0;
const decayRate = options?.decayRate ?? 0.99;
// Fetch candidate pool (limit 100 to provide enough options for Phase 2 ranking)
const candidates = this.getRelevant(ownerId, currentLocationId, currentInvolvedEntityIds, 100);
if (candidates.length === 0) return [];
// Score candidates
const scored = candidates.map((entry) => {
// Recency calculation with exponential decay
const deltaMs = now.getTime() - new Date(entry.timestamp).getTime();
const hoursElapsed = Math.max(0, deltaMs / (3600 * 1000));
const recency = Math.pow(decayRate, hoursElapsed);
// Importance score normalized (0.0 to 1.0)
const importanceNorm = entry.importance / 10.0;
// Semantic relevance
let relevance = 0;
if (queryEmbedding && entry.embedding && entry.embedding.length > 0) {
relevance = cosineSimilarity(queryEmbedding, entry.embedding);
}
const score =
recencyWeight * recency +
importanceWeight * importanceNorm +
relevanceWeight * relevance;
return { entry, score };
});
// Rank and take top memories
scored.sort((a, b) => b.score - a.score);
const selected = scored.slice(0, limit).map((s) => s.entry);
let finalEntries = [...selected];
// Optionally retrieve associative neighbors
if (includeAssociativeNeighbors && selected.length > 0) {
const neighborMap = new Map<string, LedgerEntry>();
for (const entry of selected) {
const rawNeighbors = this.fetchRawNeighbors(ownerId, entry.timestamp);
for (const rn of rawNeighbors) {
if (!finalEntries.some((fe) => fe.id === rn.id) && !neighborMap.has(rn.id)) {
neighborMap.set(rn.id, rn);
}
}
}
const neighborsToPopulate = Array.from(neighborMap.values());
if (neighborsToPopulate.length > 0) {
const neighborIds = neighborsToPopulate.map((n) => n.id);
const placeholders = neighborIds.map(() => "?").join(",");
const entitiesRows = this.db
.prepare(
`
SELECT entry_id, entity_id FROM ledger_involved_entities
WHERE entry_id IN (${placeholders})
`
)
.all(...neighborIds) as { entry_id: string; entity_id: string }[];
const entitiesMap = new Map<string, string[]>();
for (const er of entitiesRows) {
if (!entitiesMap.has(er.entry_id)) {
entitiesMap.set(er.entry_id, []);
}
entitiesMap.get(er.entry_id)!.push(er.entity_id);
}
for (const n of neighborsToPopulate) {
n.involvedEntityIds = entitiesMap.get(n.id) || [];
finalEntries.push(n);
}
}
}
// Sort chronologically ASC for the final prompt output
finalEntries.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
return finalEntries;
}
delete(id: string): void {
this.db.prepare(`DELETE FROM ledger_entries WHERE id = ?`).run(id);
}
}
function cosineSimilarity(a: number[], b: number[]): number {
if (a.length !== b.length || a.length === 0) return 0;
let dot = 0;
let normA = 0;
let normB = 0;
for (let i = 0; i < a.length; i++) {
dot += a[i] * b[i];
normA += a[i] * a[i];
normB += b[i] * b[i];
}
if (normA === 0 || normB === 0) return 0;
return dot / (Math.sqrt(normA) * Math.sqrt(normB));
}

View File

@@ -0,0 +1,228 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import Database from "better-sqlite3";
import { LedgerRepository, LedgerEntry } from "../src/ledger";
describe("LedgerRepository", () => {
let db: Database.Database;
let repo: LedgerRepository;
beforeEach(() => {
db = new Database(":memory:");
// We need to create a dummy objects table to satisfy foreign keys
db.exec(`
CREATE TABLE objects (
id TEXT PRIMARY KEY
);
`);
db.exec(`
INSERT INTO objects (id) VALUES ('alice'), ('bob'), ('charlie');
`);
repo = new LedgerRepository(db);
});
afterEach(() => {
db.close();
});
it("should save and load a ledger entry", () => {
const entry: LedgerEntry = {
id: "mem1",
ownerId: "alice",
timestamp: new Date().toISOString(),
locationId: "loc1",
involvedEntityIds: ["bob", "charlie"],
content: "Alice met Bob and Charlie at the market.",
quotes: ["Hi guys!"],
importance: 5,
embedding: [0.1, 0.2, 0.3],
};
repo.save(entry);
const loaded = repo.load("mem1");
expect(loaded).toBeDefined();
expect(loaded?.id).toBe("mem1");
expect(loaded?.ownerId).toBe("alice");
expect(loaded?.locationId).toBe("loc1");
expect(loaded?.involvedEntityIds.sort()).toEqual(["bob", "charlie"].sort());
expect(loaded?.content).toBe(entry.content);
expect(loaded?.quotes).toEqual(entry.quotes);
expect(loaded?.importance).toBe(5);
// Check float precision
expect(loaded?.embedding[0]).toBeCloseTo(0.1);
expect(loaded?.embedding[1]).toBeCloseTo(0.2);
expect(loaded?.embedding[2]).toBeCloseTo(0.3);
});
it("should return null for non-existent entry", () => {
const loaded = repo.load("missing");
expect(loaded).toBeNull();
});
it("should retrieve relevant memories based on Phase 1 heuristics", () => {
repo.save({
id: "mem_high_salience",
ownerId: "alice",
timestamp: "2024-01-01T10:00:00.000Z",
locationId: "loc2",
involvedEntityIds: [],
content: "Alice found a magical sword.",
quotes: [],
importance: 9, // high salience
embedding: [],
});
repo.save({
id: "mem_location",
ownerId: "alice",
timestamp: "2024-01-02T10:00:00.000Z",
locationId: "loc1", // matches query
involvedEntityIds: [],
content: "Alice sat on a bench.",
quotes: [],
importance: 2,
embedding: [],
});
repo.save({
id: "mem_social",
ownerId: "alice",
timestamp: "2024-01-03T10:00:00.000Z",
locationId: "loc2",
involvedEntityIds: ["bob"], // matches query
content: "Alice waved at Bob.",
quotes: [],
importance: 3,
embedding: [],
});
repo.save({
id: "mem_irrelevant",
ownerId: "alice",
timestamp: "2024-01-04T10:00:00.000Z",
locationId: "loc3",
involvedEntityIds: ["charlie"],
content: "Alice sneezed.",
quotes: [],
importance: 2,
embedding: [],
});
const relevant = repo.getRelevant("alice", "loc1", ["bob"]);
expect(relevant).toHaveLength(3);
const ids = relevant.map((r) => r.id);
expect(ids).toContain("mem_high_salience"); // due to importance >= 8
expect(ids).toContain("mem_location"); // due to locationId
expect(ids).toContain("mem_social"); // due to involvedEntityIds
expect(ids).not.toContain("mem_irrelevant");
});
it("should retrieve ranked memories with recency, importance, and semantic match", () => {
const now = new Date("2024-01-10T12:00:00.000Z");
repo.save({
id: "mem1",
ownerId: "alice",
timestamp: "2024-01-01T12:00:00.000Z",
locationId: "loc1",
involvedEntityIds: [],
content: "Alice fought a dragon.",
quotes: [],
importance: 10,
embedding: [0, 1, 0],
});
repo.save({
id: "mem2",
ownerId: "alice",
timestamp: "2024-01-10T11:00:00.000Z",
locationId: "loc1",
involvedEntityIds: [],
content: "Alice ate a sandwich.",
quotes: [],
importance: 2,
embedding: [1, 0, 0],
});
repo.save({
id: "mem3",
ownerId: "alice",
timestamp: "2024-01-10T11:50:00.000Z",
locationId: "loc1",
involvedEntityIds: [],
content: "Alice read a book.",
quotes: [],
importance: 5,
embedding: [0.707, 0.707, 0],
});
// Query: [1, 0, 0]
// mem3 score: recency (~0.998) + importance (0.5) + relevance (0.707) = ~2.205
// mem2 score: recency (~0.99) + importance (0.2) + relevance (1.0) = ~2.19
// mem1 score: recency (~0.114) + importance (1.0) + relevance (0.0) = ~1.114
// If limit = 2, should return mem2 and mem3, sorted chronologically (mem2 first, then mem3)
const results = repo.retrieve("alice", "loc1", [], [1, 0, 0], now, 2);
expect(results).toHaveLength(2);
expect(results[0].id).toBe("mem2");
expect(results[1].id).toBe("mem3");
});
it("should pull in associative neighbors when specified", () => {
repo.save({
id: "mem_preceding",
ownerId: "alice",
timestamp: "2024-01-10T10:00:00.000Z",
locationId: "loc_other",
involvedEntityIds: [],
content: "Alice woke up.",
quotes: [],
importance: 2,
embedding: [],
});
repo.save({
id: "mem_target",
ownerId: "alice",
timestamp: "2024-01-10T11:00:00.000Z",
locationId: "loc1",
involvedEntityIds: [],
content: "Alice arrived at tavern.",
quotes: [],
importance: 2,
embedding: [],
});
repo.save({
id: "mem_succeeding",
ownerId: "alice",
timestamp: "2024-01-10T12:00:00.000Z",
locationId: "loc_other",
involvedEntityIds: [],
content: "Alice ordered ale.",
quotes: [],
importance: 2,
embedding: [],
});
// Without neighbors: only returns mem_target
const withoutNeighbors = repo.retrieve("alice", "loc1", [], undefined, new Date("2024-01-10T14:00:00.000Z"), 1, {
includeAssociativeNeighbors: false,
});
expect(withoutNeighbors).toHaveLength(1);
expect(withoutNeighbors[0].id).toBe("mem_target");
// With neighbors: returns preceding, target, and succeeding sorted chronologically
const withNeighbors = repo.retrieve("alice", "loc1", [], undefined, new Date("2024-01-10T14:00:00.000Z"), 1, {
includeAssociativeNeighbors: true,
});
expect(withNeighbors).toHaveLength(3);
expect(withNeighbors[0].id).toBe("mem_preceding");
expect(withNeighbors[1].id).toBe("mem_target");
expect(withNeighbors[2].id).toBe("mem_succeeding");
});
});

View File

@@ -39,7 +39,7 @@ describe("Subjective Buffer Entry Serializer Tests (Tier 1)", () => {
};
const result = serializeSubjectiveBufferEntry(entry, viewer);
expect(result).toBe('[12:00:00 PM] the hooded figure spoke to the bartender: "Bob greets Charlie"');
expect(result).toBe('the hooded figure spoke to the bartender: "Bob greets Charlie"');
});
test("serializes action intent with outcome details", () => {
@@ -65,7 +65,7 @@ describe("Subjective Buffer Entry Serializer Tests (Tier 1)", () => {
};
const result = serializeSubjectiveBufferEntry(entry, viewer);
expect(result).toBe('[12:05:00 PM] the hooded figure Bob attempts to break the lock latch (Outcome: Failed - The lock is made of reinforced steel.)');
expect(result).toBe('the hooded figure Bob attempts to break the lock latch (Outcome: Failed - The lock is made of reinforced steel.)');
});
test("serializes self-reference and unfamiliar actors", () => {
@@ -86,7 +86,7 @@ describe("Subjective Buffer Entry Serializer Tests (Tier 1)", () => {
};
const resultSelf = serializeSubjectiveBufferEntry(entrySelf, viewer);
expect(resultSelf).toBe("[12:10:00 PM] you open the window");
expect(resultSelf).toBe("you open the window");
const entryUnfamiliar: BufferEntry = {
id: "entry-unfamiliar",
@@ -103,7 +103,7 @@ describe("Subjective Buffer Entry Serializer Tests (Tier 1)", () => {
};
const resultUnfamiliar = serializeSubjectiveBufferEntry(entryUnfamiliar, viewer);
expect(resultUnfamiliar).toBe("[12:15:00 PM] an unfamiliar figure knock on the door");
expect(resultUnfamiliar).toBe("an unfamiliar figure knock on the door");
});
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

7313
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

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

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

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

@@ -0,0 +1,23 @@
{
"name": "docs",
"type": "module",
"version": "0.0.1",
"private": true,
"scripts": {
"dev": "astro dev",
"start": "astro dev",
"build": "astro build",
"preview": "astro preview",
"preview:wrangler": "astro build && wrangler dev"
},
"dependencies": {
"@astrojs/starlight": "^0.41.3",
"astro": "^7.0.7",
"astro-mermaid": "^2.1.0",
"mermaid": "^11.16.0",
"sharp": "^0.33.5"
},
"devDependencies": {
"wrangler": "^4.107.1"
}
}

BIN
web/docs/public/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 919 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 919 B

View File

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

View File

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

View File

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

View File

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

View File

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

Some files were not shown because too many files have changed in this diff Show More