32 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
80 changed files with 6719 additions and 4014 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'

6
.gitignore vendored
View File

@@ -46,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.

143
README.md
View File

@@ -1,49 +1,97 @@
![Omnia Logo](web/docs/src/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.
Single-agent, single-context systems (AI Dungeon and its descendants) prompt one model to _be_ the world and everyone in it. That breaks in predictable ways over long sessions:
<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. The assassin's target greets him by name.
- **Secrets Refuse to Stay Secret:** "Don't reveal this" is a suggestion a model can argue past, not a mechanism that says no. One clever player question and the conspiracy folds.
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.
- **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 same hand.
- **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 root cause is the same in every case: the model is being asked to be the database, the physics engine, the referee, and the whole cast simultaneously inside a context window that forgets, blends, and leaks.
The 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 SQLite database, not in a context window. It cannot drift, because nothing regenerates it — it only changes through validated deltas.
- **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.
- **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
## What this buys you
The payoff is scenario complexity that uni-agent systems structurally cannot represent, no matter how good the model gets:
<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 character acts from its own bounded view, characters can lie to each other and be believed with the truth intact in the world state. A con game, a mole in the party, an unreliable ally: these are queries over who-knows-what, not prompt acrobatics.
- **Betrayal that stays betrayed.** Events persist as per-observer memory entries with outcomes. An apology adds a memory; it does not delete one.
- **Divergent accounts of the same event.** Two witnesses to the same scene hold two different buffer entries, filtered through their own aliases and vantage points. Ask them separately what happened and you get testimony, not a transcript.
- **Identity as information.** Characters refer to each other through subjective alias maps ("the hooded figure" vs. "Bob"). Recognizing someone, being recognized, or staying anonymous are all mechanical states — a masked stranger is a masked stranger until the engine says otherwise.
- **A physics referee that can say no.** "I pick the lock with a hairpin" is validated against world state by the Architect before anything changes. Failure is a recorded outcome the character remembers, not a narrative the model politely retconned.
- **Time that behaves.** A world clock advances by validated, per-action deltas, and memory is recalled with psychologically natural phrasing ("earlier today, in the afternoon" — not a timestamp). Long timelines stay coherent because time is data, not vibes.
- **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.** That division of labor is what lets the cast, the secrets, and the timeline scale without the fiction collapsing.
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 character takes turns through an **Actor Agent** that receives a strictly epistemically-bounded prompt: its own attributes (public, plus private ones explicitly granted to itself), its subjective memory buffer, the entities co-present at its location, and the current moment. Nothing else. The actor responds with free narrative prose — what the character does, says, or _thinks_.
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:
@@ -51,29 +99,29 @@ Prose is decoded into typed intents:
- **`action`** — a physical act, subject to validation.
- **`monologue`** — an inner thought. No one else perceives it, it bypasses validation entirely, and it is written straight into the character's private memory.
Not every turn needs an outward act; a character may simply think. This is what makes characters feel inhabited rather than reactive and it produces a durable, queryable record of each character's private reasoning (see [Research Instrument](#a-research-instrument-model-psychology-in-fiction) below).
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 at a CLI prompt, identically bounded by what their character knows.
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 route to the **World Architect**, which validates them against the objective world state (dialogue is exempt; monologue never even arrives) and generates structured deltas — starting with time advancement — that deterministic code applies after strict schema (Zod) validation. The model proposes a change; it never touches the database.
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.
This is the load-bearing wall. Because every mutation flows through one validated chokepoint, the world cannot rot: there is no second copy of reality inside a context window to fall out of sync.
Because every mutation flows through one validated chokepoint, the world cannot rot: there is no second copy of reality inside a context window to fall out of sync.
### Attribute-Level Privacy
### 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).
Every entity, item, and location is an _attribute bag_. Each attribute carries its own visibility (`PUBLIC` or `PRIVATE`) with an explicit access list. "The sword is cursed" is a private attribute checked in code, not a rule the model is politely asked to honor. Privacy lives at the level of the fact, not the entity. A character can be publicly a blacksmith and privately a spy, and even facts about _itself_ are hidden from it unless explicitly granted (amnesia, repression, and unwitting sleeper agents come free with the model).
The dividend: **prompt-injection-proof secrets.** There is no instruction to override because the information was never serialized into the prompt. Epistemic privacy turns "the model shouldn't say this" (hard, unreliable) into "the model doesn't know this" (trivial, absolute).
### Spatial Perception
Space is a graph: `world → region → location → point of interest`, connected by portals with sound and vision propagation values. When something happens, it bubbles outward. There are no coordinates, no pathfinding, no collision geometry — a narrative engine doesn't need a tactical simulation, and a discrete graph is sufficient. Today actors perceive co-located entities and their location's visible attributes; portal-propagated perception is on the roadmap.
Space is a graph: `world → region → location → point of interest`, connected by portals with sound and vision propagation values. When something happens, it bubbles outward. There are no coordinates, no pathfinding, no collision geometry — a narrative engine doesn't need a tactical simulation, and a discrete graph is sufficient. Today actors perceive co-located entities and their location's visible attributes; portal propagated perception is on the roadmap.
### Memory Tiers
- **Verbatim Buffer (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.
- **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.
@@ -83,16 +131,20 @@ Memory is per-character on purpose: recall is testimony from a vantage point, wh
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 — something uni-agent setups cannot do, because they can neither control what the model knows nor observe what it withholds.
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, or the gap between private appraisal and public behavior.
- **Knowledge as an experimental variable.** Attribute ACLs let you administer information with precision: give one agent a fact, withhold it from another, and observe propagation, inference, and leakage through dialogue alone. Secret-keeping stops being anecdotal and becomes testable — _provably_, since the engine logs exactly what each agent was ever shown.
- **Controlled, reproducible conditions.** A scenario is a JSON file; a run is a SQLite database. Identical initial conditions, swappable model providers behind one interface (`ILLMProvider`), and a deterministic mock for baselines. Rerun the white-room experiment a hundred times, vary one attribute, and diff the transcripts.
- **Multi-agent social dynamics with ground truth.** Because objective world state exists independently of any agent's beliefs, you can score agents' beliefs and claims against reality hallucination, confabulation, and social conformity become measurable quantities rather than impressions.
- **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 knowing their own name but not the other's — and observes what they do. It runs today, via the CLI, with a human optionally playing either subject.
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
@@ -112,15 +164,15 @@ The finish line for the first milestone is small on purpose.
- [x] Two hand-authored NPCs live in one location, playable via CLI.
- [ ] Each has buffer and vector-archive memory and recalls something said a few turns earlier. _(buffer: done; vector archive: not started)_
- [ ] One NPC knows a fact the other does not and, provably by testing, will not leak it.
- [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
@@ -136,19 +188,18 @@ omnia/
memory/ verbatim buffer; later the vector archive, dossier, and affect vectors
spatial/ location and POI graph, portal-based perception
llm/ ILLMProvider interface plus Gemini and deterministic mock implementations
scenario/ scenario JSON schema and loader (JSON → SQLite)
apps/
gui/ Next.js Web GUI dashboard and simulation runner
content/
scenario-core/ scenario JSON schema and loader (JSON → SQLite)
scenario-builder/ Next.js web UI for authoring worlds
demo/ bundled scenarios (talking-room)
cli/ the playable loop (human or LLM actors, --scenario / --play flags)
tests/
integration/ cross-package tests against a mocked LLM
evals/ deliberate real-API evaluation runs
docs/ Astro documentation site (→ web/docs/)
web/
docs/ Astro documentation site
```
_The engine core deliberately knows nothing about domain content (stats, traits, genres). Scenarios are plain JSON the loader ingests; what an attribute means is the scenario's business, not the engine's._
## Roadmap (Build Order after `v0`)
1. Vector-archive memory and retrieval (closing out the `v0` memory milestone).

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,21 +0,0 @@
{
"name": "@omnia/cli",
"version": "0.0.0",
"private": true,
"type": "module",
"exports": {
".": "./dist/index.js"
},
"dependencies": {
"@omnia/core": "workspace:*",
"@omnia/spatial": "workspace:*",
"@omnia/memory": "workspace:*",
"@omnia/intent": "workspace:*",
"@omnia/architect": "workspace:*",
"@omnia/actor": "workspace:*",
"@omnia/llm": "workspace:*",
"@omnia/scenario-core": "workspace:*",
"better-sqlite3": "^12.11.1",
"dotenv": "^17.4.2"
}
}

View File

@@ -1,426 +0,0 @@
import dotenv from "dotenv";
import fs from "fs";
import path from "path";
import readline from "readline";
import Database from "better-sqlite3";
import { WorldState, SQLiteRepository } from "@omnia/core";
import { BufferRepository } from "@omnia/memory";
import { Architect, AliasDeltaGenerator } from "@omnia/architect";
import {
ActorAgent,
ActorPromptBuilder,
IActorProseGenerator,
buildBufferEntryForIntent,
} from "@omnia/actor";
import { GeminiProvider } from "@omnia/llm";
import { ScenarioLoader } from "@omnia/scenario-core";
// Load environment variables
dotenv.config();
class CLIProseGenerator implements IActorProseGenerator {
async generate(
entityId: string,
systemPrompt: string,
userContext: string,
): Promise<string> {
console.log(
"\n================================================================================",
);
console.log(`YOUR TURN: Playing as character "${entityId}"`);
console.log(
"================================================================================",
);
console.log(userContext);
console.log(
"================================================================================",
);
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
return new Promise<string>((resolve) => {
rl.question(
"\nDescribe what your character does, says, or thinks (or type 'exit' to quit):\n> ",
(answer) => {
rl.close();
const trimmed = answer.trim();
if (trimmed.toLowerCase() === "exit") {
console.log("\nExiting simulation. Goodbye!");
process.exit(0);
}
resolve(trimmed);
},
);
});
}
}
/**
* Checks for co-located entities who do not have subjective aliases for each other,
* and calls the AliasDeltaGenerator to synthesize names based on visible attributes.
*/
async function runAliasResolution(
worldState: WorldState,
aliasGenerator: AliasDeltaGenerator,
coreRepo: SQLiteRepository,
): Promise<void> {
const entities = Array.from(worldState.entities.values());
for (const viewer of entities) {
if (!viewer.locationId) continue;
for (const target of entities) {
if (viewer.id === target.id) continue;
if (target.locationId === viewer.locationId) {
if (!viewer.aliases.has(target.id)) {
const alias = await aliasGenerator.generate(viewer, target);
viewer.aliases.set(target.id, alias);
console.log(
`\n[Alias Resolved] "${viewer.id}" sees "${target.id}" -> alias: "${alias}"`,
);
// Save the viewer state with the new alias
coreRepo.saveEntity(viewer, worldState.id);
}
}
}
}
}
async function main() {
const args = process.argv.slice(2);
const logFileIndex = args.indexOf("--log-file");
let logStream: fs.WriteStream | undefined;
if (logFileIndex !== -1 && args[logFileIndex + 1]) {
const logFilePath = path.resolve(args[logFileIndex + 1]);
logStream = fs.createWriteStream(logFilePath, {
flags: "w",
encoding: "utf-8",
});
// Monkeypatch console.log
const originalLog = console.log;
console.log = (...messageArgs: unknown[]) => {
originalLog(...messageArgs);
const text =
messageArgs
.map((arg) => {
if (typeof arg === "object" && arg !== null) {
try {
return JSON.stringify(arg, null, 2);
} catch {
return String(arg);
}
}
return String(arg);
})
.join(" ") + "\n";
logStream?.write(text);
};
// Monkeypatch console.error
const originalError = console.error;
console.error = (...messageArgs: unknown[]) => {
originalError(...messageArgs);
const text =
messageArgs
.map((arg) => {
if (typeof arg === "object" && arg !== null) {
try {
return JSON.stringify(arg, null, 2);
} catch {
return String(arg);
}
}
return String(arg);
})
.join(" ") + "\n";
logStream?.write("[ERROR] " + text);
};
process.on("exit", () => {
logStream?.end();
});
}
const scenarioArgIndex = args.indexOf("--scenario");
const scenarioPath =
scenarioArgIndex !== -1
? args[scenarioArgIndex + 1]
: "content/demo/scenarios/talking-room.json";
const playArgIndex = args.indexOf("--play");
const playEntityId = playArgIndex !== -1 ? args[playArgIndex + 1] : undefined;
const dbPath = path.resolve("./omnia.db");
console.log(`Initializing SQLite database at: ${dbPath}`);
const db = new Database(dbPath);
const coreRepo = new SQLiteRepository(db);
const bufferRepo = new BufferRepository(db);
const loader = new ScenarioLoader(coreRepo, bufferRepo);
// 1. Read Scenario JSON file
if (!fs.existsSync(scenarioPath)) {
console.error(
`Error: Scenario template file not found at: ${scenarioPath}`,
);
process.exit(1);
}
console.log(`Loading scenario template from: ${scenarioPath}`);
const scenarioJson = JSON.parse(fs.readFileSync(scenarioPath, "utf-8"));
// 2. Initialize World Instance
const worldInstanceId = `run-${Date.now()}`;
console.log(`Initializing live world instance: ${worldInstanceId}`);
await loader.initializeWorld(scenarioJson, worldInstanceId);
// Load the running world state
const worldState = coreRepo.loadWorldState(worldInstanceId);
if (!worldState) {
console.error(
`Error: Failed to load initialized world state: ${worldInstanceId}`,
);
process.exit(1);
}
// 3. Ensure API Key exists if we are running LLMs
const apiKey = process.env.GOOGLE_API_KEY;
if (!apiKey) {
console.error("Error: GOOGLE_API_KEY environment variable is missing.");
console.error(
"Please provide it in your .env file to enable LLM generators and decoders.",
);
process.exit(1);
}
const llmProvider = new GeminiProvider(apiKey);
const architect = new Architect(llmProvider, coreRepo);
const aliasGenerator = new AliasDeltaGenerator(llmProvider);
console.log(
"\n================================================================================",
);
console.log(`SIMULATION STARTED: "${scenarioJson.name}"`);
console.log(`Description: ${scenarioJson.description}`);
if (playEntityId) {
console.log(`Player Role: Controlling entity "${playEntityId}"`);
} else {
console.log("Player Role: Observing fully autonomous NPC run");
}
console.log(
"================================================================================",
);
const isVerbose = args.includes("--verbose");
let turnCount = 1;
const maxTurns = 20; // safe loop breaker
while (turnCount <= maxTurns) {
console.log(
`\n\n--- TURN ${turnCount} (World Time: ${worldState.clock.get().toISOString()}) ---`,
);
// Reload world state from database to ensure fresh DB sync
const currentWorldState = coreRepo.loadWorldState(worldInstanceId);
if (!currentWorldState) {
console.error("Error: Synced world state lost.");
process.exit(1);
}
// Auto-resolve aliases for co-located entities who don't know each other yet
await runAliasResolution(currentWorldState, aliasGenerator, coreRepo);
const entities = Array.from(currentWorldState.entities.values());
for (const entity of entities) {
// 1. Determine the ActorAgent generator: CLI input for player, LLM for NPCs
const isPlayer = playEntityId && entity.id === playEntityId;
const generator = isPlayer ? new CLIProseGenerator() : undefined;
const agent = new ActorAgent(llmProvider, bufferRepo, 20, generator);
// Verbose mode: Output the generated prompt builder context before generation
if (isVerbose) {
const promptBuilder = new ActorPromptBuilder(bufferRepo, 20);
const { systemPrompt, userContext } = promptBuilder.build(
currentWorldState,
entity,
);
console.log(`\n[VERBOSE] Assembled Prompts for "${entity.id}":`);
console.log("\n--- SYSTEM PROMPT ---");
console.log(systemPrompt);
console.log("\n--- USER CONTEXT ---");
console.log(userContext);
console.log("\n--- CONTEXT BREAKDOWN ---");
const userSections = userContext.split("\n\n");
const momentSection =
userSections.find((s) => s.startsWith("=== CURRENT MOMENT ===")) ||
"";
const worldSection =
userSections.find((s) =>
s.startsWith("=== THE WORLD AS YOU PERCEIVE IT ==="),
) || "";
const memorySection =
userSections.find((s) =>
s.startsWith("=== YOUR RECENT MEMORY ==="),
) || "";
const systemChars = systemPrompt.length;
const momentChars = momentSection.length;
const worldChars = worldSection.length;
const memoryChars = memorySection.length;
const totalChars = systemChars + userContext.length;
const estTokens = (chars: number) => Math.ceil(chars / 4);
console.log(
` ├─ System Instructions: ${systemChars.toLocaleString()} chars (~${estTokens(systemChars)} tokens)`,
);
console.log(
` ├─ Current Moment Context: ${momentChars.toLocaleString()} chars (~${estTokens(momentChars)} tokens)`,
);
console.log(
` ├─ World Perception: ${worldChars.toLocaleString()} chars (~${estTokens(worldChars)} tokens)`,
);
console.log(
` ├─ Recent Memory Buffer: ${memoryChars.toLocaleString()} chars (~${estTokens(memoryChars)} tokens)`,
);
console.log(
` └─ TOTAL ESTIMATED INPUT: ${totalChars.toLocaleString()} chars (~${estTokens(totalChars)} tokens)`,
);
console.log(
"--------------------------------------------------------------------------------",
);
}
if (!isPlayer) {
console.log(`\n[${entity.id}] is thinking...`);
}
// 2. Execute character turn
const turnResult = await agent.act(currentWorldState, entity);
if (!isPlayer) {
console.log(`\n[${entity.id}]: ${turnResult.narrativeProse}`);
} else {
console.log(`\n[You]: ${turnResult.narrativeProse}`);
}
// Verbose mode: Output decoded intent structures
if (isVerbose) {
console.log(`\n[VERBOSE] Decoded Intents from Prose:`);
console.log(JSON.stringify(turnResult.intents.intents, null, 2));
console.log(
"--------------------------------------------------------------------------------",
);
}
// 3. Process each generated intent sequence through physics and memory
for (const intent of turnResult.intents.intents) {
const outcome = await architect.processIntent(
currentWorldState,
intent,
);
const timestamp = currentWorldState.clock.get().toISOString();
// Verbose mode: Output architect evaluation
if (isVerbose) {
console.log(`\n[VERBOSE] Architect Intent Processing:`);
console.log(` Type: ${intent.type}`);
console.log(` Description: "${intent.description}"`);
if (intent.type === "monologue") {
console.log(" Validation: Bypassed (monologue)");
} else {
console.log(
` Validation Result: isValid = ${outcome.isValid}, reason = "${outcome.reason}"`,
);
if (outcome.timeDelta) {
console.log(
` Clock Delta: +${outcome.timeDelta.minutesToAdvance} min (${outcome.timeDelta.explanation})`,
);
}
}
console.log(
"--------------------------------------------------------------------------------",
);
}
// Save actor's subjective memory
const actorEntry = buildBufferEntryForIntent(
intent,
timestamp,
entity.locationId,
);
if (intent.type === "action") {
actorEntry.outcome = {
isValid: outcome.isValid,
reason: outcome.reason,
};
}
bufferRepo.save(actorEntry);
// Propagate public memories (dialogue/actions) to co-located observers
if (
entity.locationId &&
(intent.type === "dialogue" || intent.type === "action")
) {
for (const other of currentWorldState.entities.values()) {
if (
other.id !== entity.id &&
other.locationId === entity.locationId
) {
const observerEntry = buildBufferEntryForIntent(
intent,
timestamp,
entity.locationId,
);
if (intent.type === "action") {
observerEntry.outcome = {
isValid: outcome.isValid,
reason: outcome.reason,
};
}
bufferRepo.save({
...observerEntry,
ownerId: other.id,
});
}
}
}
// Print formatted logs
if (intent.type === "monologue") {
if (isPlayer) {
console.log(` (Thought processed: "${intent.description}")`);
}
} else if (intent.type === "dialogue") {
console.log(
` (Dialogue spoken: spoken to ${intent.targetIds.join(", ") || "someone"})`,
);
} else {
console.log(
` (Action result: ${outcome.isValid ? "Success" : `Failed - ${outcome.reason}`})`,
);
}
}
// 4. Save synced world state to repository
coreRepo.saveWorldState(currentWorldState);
}
turnCount++;
}
console.log("\nSimulation execution limit reached. Goodbye!");
db.close();
}
main().catch((err) => {
console.error("Simulation run aborted due to error:", err);
process.exit(1);
});

View File

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

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*",
};

View File

@@ -5,7 +5,7 @@ import tseslint from "typescript-eslint";
export default [
{
ignores: ["**/dist/**", "**/node_modules/**", "content/scenario-builder/**", "**/.astro/**"],
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

@@ -7,9 +7,10 @@
"build": "tsc -b",
"build:web": "pnpm --filter landing build",
"build:docs": "pnpm --filter docs build",
"build:all": "pnpm build && pnpm build:web && pnpm build:docs",
"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",
@@ -18,12 +19,11 @@
"watch": "tsc -b --watch",
"test": "vitest run --project unit",
"test:watch": "vitest --project unit",
"test:evals": "vitest run --project evals",
"play": "node cli/dist/index.js"
"test:evals": "vitest run --project evals"
},
"keywords": [],
"author": "",
"license": "ISC",
"author": "sortedcord",
"license": "MIT",
"devEngines": {
"packageManager": {
"name": "pnpm",

View File

@@ -9,6 +9,8 @@ import {
BufferEntry,
BufferRepository,
serializeSubjectiveBufferEntry,
LedgerEntry,
LedgerRepository,
} from "@omnia/memory";
/**
@@ -37,12 +39,17 @@ 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,
) {}
/**
@@ -74,15 +81,14 @@ Guidelines:
- 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 ---
const now = worldState.clock.get();
sections.push(
`=== CURRENT MOMENT ===\nIt is ${now.toISOString()} right now.`,
);
@@ -92,30 +98,43 @@ Guidelines:
`=== 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,
worldState.clock.get(),
);
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, now: Date): string | null {
private buildMemorySection(
entity: Entity,
entries: BufferEntry[],
now: Date,
): string | null {
if (!this.bufferRepo) return null;
let entries: BufferEntry[];
try {
entries = this.bufferRepo.listForOwner(entity.id);
} catch {
return null;
}
if (entries.length === 0) {
return `=== YOUR RECENT MEMORY ===\n(You have no memories yet.)`;
return `=== RECENT EVENTS ===\n(No recent events recorded.)`;
}
const recent = entries.slice(-this.memoryLimit);
@@ -135,6 +154,110 @@ Guidelines:
groupedLines.push(` - ${serialized}`);
}
return `=== YOUR RECENT MEMORY ===\n${groupedLines.join("\n")}`;
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")}`;
}
}

View File

@@ -3,6 +3,7 @@ import { ILLMProvider } from "@omnia/llm";
import {
BufferEntry,
BufferRepository,
LedgerRepository,
} from "@omnia/memory";
import {
Intent,
@@ -68,15 +69,30 @@ export class ActorAgent {
private decoder: IntentDecoder;
private generator: IActorProseGenerator;
private llmProvider: ILLMProvider;
constructor(
private llmProvider: ILLMProvider,
llmProvider: ILLMProvider | { actor: ILLMProvider; decoder: ILLMProvider },
bufferRepo?: BufferRepository,
ledgerRepo?: LedgerRepository,
memoryLimit?: number,
generator?: IActorProseGenerator,
) {
this.promptBuilder = new ActorPromptBuilder(bufferRepo, memoryLimit);
this.decoder = new IntentDecoder(llmProvider);
this.generator = generator ?? new LLMActorProseGenerator(llmProvider);
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;
}
/**

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

@@ -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);
}
/**

View File

@@ -209,7 +209,7 @@ export function serializeSubjectiveWorldState(
lines.push(" Entities present with you:");
for (const e of coLocated) {
const alias = resolveAliasViewer(viewer, e.id);
lines.push(` - ${alias} (ID: ${e.id}):`);
lines.push(` - ${alias}:`);
const eVisible = e.getVisibleAttributesFor(viewerId);
lines.push(serializeVisibleAttributes(eVisible).split("\n").map((l) => " " + l).join("\n"));
}
@@ -221,7 +221,7 @@ export function serializeSubjectiveWorldState(
lines.push(" Other presences you are aware of (elsewhere):");
for (const e of elsewhere) {
const alias = resolveAliasViewer(viewer, e.id);
lines.push(` - ${alias} (ID: ${e.id}) [elsewhere]`);
lines.push(` - ${alias} [elsewhere]`);
}
}

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

@@ -3,3 +3,4 @@ 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

@@ -1,31 +1,80 @@
import { z } from "zod";
import { ChatOpenRouter } from "@langchain/openrouter";
import { ILLMProvider, LLMRequest, LLMResponse } from "../llm.js";
import { ILLMProvider, LLMRequest, LLMResponse, LLMCallRecord } from "../llm.js";
import { llmConfig } from "../config.js";
import { ProviderManager } from "../provider-manager.js";
export class 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;
}
constructor(apiKey?: string, modelName: string = "google/gemini-2.5-flash") {
const key = apiKey || llmConfig.OPENROUTER_API_KEY;
if (!key) {
throw new Error("OPENROUTER_API_KEY is required to initialize OpenRouterProvider");
}
this.model = new ChatOpenRouter({
apiKey: key,
model: modelName,
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);
const result = await structuredModel.invoke([
const structuredModel = this.model.withStructuredOutput(request.schema, { includeRaw: true });
const result = (await structuredModel.invoke([
{ role: "system", content: request.systemPrompt },
{ role: "user", content: request.userContext },
]);
return { success: true, data: result as z.infer<T> };
])) as unknown as {
parsed?: z.infer<T>;
raw?: {
usage_metadata?: {
input_tokens?: number;
output_tokens?: number;
total_tokens?: number;
};
};
};
const parsed = result?.parsed;
const raw = result?.raw;
const usage = raw?.usage_metadata ? {
inputTokens: raw.usage_metadata.input_tokens || 0,
outputTokens: raw.usage_metadata.output_tokens || 0,
totalTokens: raw.usage_metadata.total_tokens || 0,
} : undefined;
this.lastCalls.push({
systemPrompt: request.systemPrompt,
userContext: request.userContext,
usage,
});
return { success: true, data: parsed, usage };
}
}

View File

@@ -1,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

@@ -14,10 +14,19 @@ vi.mock("@langchain/openrouter", () => {
withStructuredOutput = vi.fn().mockImplementation(() => {
return {
invoke: vi.fn().mockImplementation(async () => {
// Return a mock output that matches a sample schema
// Return a mock output that matches the includeRaw: true structure
return {
name: "mocked response",
success: true,
parsed: {
name: "mocked response",
success: true,
},
raw: {
usage_metadata: {
input_tokens: 10,
output_tokens: 5,
total_tokens: 15,
},
},
};
}),
};
@@ -59,7 +68,7 @@ describe("OpenRouterProvider Unit Tests (Tier 1)", () => {
}
});
test("generateStructuredResponse invokes the model with structured output", async () => {
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(),
@@ -77,5 +86,22 @@ describe("OpenRouterProvider Unit Tests (Tier 1)", () => {
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

@@ -32,10 +32,14 @@ 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}`})`;
}

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

@@ -1,5 +1,5 @@
{
"name": "@omnia/scenario-core",
"name": "@omnia/scenario",
"version": "0.0.0",
"private": true,
"type": "module",

View File

@@ -10,7 +10,7 @@ import { ScenarioLoader, ScenarioSchema } from "../src/index.js";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const SCENARIO_PATH = path.resolve(__dirname, "../../demo/scenarios/talking-room.json");
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 () => {

View File

@@ -6,8 +6,8 @@
},
"include": ["src"],
"references": [
{ "path": "../../packages/core" },
{ "path": "../../packages/spatial" },
{ "path": "../../packages/memory" }
{ "path": "../core" },
{ "path": "../spatial" },
{ "path": "../memory" }
]
}

3951
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,14 +1,14 @@
packages:
- "packages/*"
- "cli"
- "apps/*"
- "web/*"
- "content/scenario-builder"
- "content/scenario-core"
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

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

View File

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

View File

@@ -5,10 +5,12 @@ 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,

View File

@@ -7,11 +7,17 @@
"dev": "astro dev",
"start": "astro dev",
"build": "astro build",
"preview": "astro preview"
"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

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

View File

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

View File

@@ -0,0 +1,115 @@
---
title: Tier 2 Memory (Ledger)
description: Long-term episodic memory storage and retrieval
---
Tier 2 memory lives in between the memory buffer and tier 3 dossiers and arguably takes up the largest share of the context pie.
Tier 2 memory (or long-term memory) stores historical events that happened to the entity in the past. It acts as an episodic ledger.
```ts
interface LedgerEntry {
id: string;
ownerId: string; // whose subjective memory this belongs to
timestamp: string; // ISO, tied to WorldClock when the intent causing the event happened.
locationId: string | null; // where it happened
involvedEntityIds: string[]; // who else this event concerns
content: string; // third-person narrative summary — recallable
quotes: string[]; // verbatim lines, only for high-salience dialogue
importance: number; // 110, salience assigned at handoff
embedding: number[]; // for semantic search (storage representation TBD at build time)
}
```
### Storage Model
Tier 2 memory is stored in relational tables to allow efficient deterministic filtering. Embeddings are stored as raw BLOBs (containing a serialized `Float32Array`).
To avoid the build and installation friction associated with native C-extensions like `sqlite-vec` (e.g. node-gyp issues across platforms), index optimization relies on standard SQLite secondary indices. These indices allow database queries to execute in microseconds, even with hundreds of thousands of memories:
```sql
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);
```
### Handoff (Deferred)
The process of moving memories from the Tier 1 working buffer into Tier 2 is called **Handoff**.
During handoff, an LLM chunk-summarizes raw buffer events, extracts salient quotes, and assigns an `importance` score (1-10). Routine actions score low, while life-altering events score high.
Because this summarization requires an LLM call, it utilizes the standard `LLMProviderInstance` inference provider routing architecture just like all other callers in the system. This allows the simulation to route handoff processing to a specific model.
*Note: The automated handoff pipeline is currently deferred for future implementation.*
### Retrieval Architecture
Retrieval happens in phases to manage context window limits without running expensive vector searches across an entity's entire lifetime of memories.
#### Phase 1: Deterministic Heuristic Filtering
This is the primary database-level retrieval mechanism. We use fast SQL queries to filter down to a relevant candidate pool based on immediate context:
1. **Spatial Cues**: Fetch recent memories where `location_id` equals the entity's current location.
2. **Social Cues**: Fetch recent memories involving the `involvedEntityIds` currently in the entity's perception radius.
3. **High Salience**: Always fetch memories with `importance >= 8` regardless of spatial or social context.
#### Phase 2: Semantic & Episodic Ranking
This phase runs in application memory using the candidates returned from Phase 1:
1. **Semantic Match**: Compute cosine similarity dynamically in JS/TS memory over the candidate pool (limit 100). Since Phase 1 narrows the pool down significantly, vector comparisons are highly performant in JS, eliminating the need for native vector database extensions.
2. **Scoring Combination**: Combine recency, importance, and semantic match:
$$\text{Score} = (\text{recencyWeight} \times \text{recency}) + (\text{importanceWeight} \times \text{importanceNorm}) + (\text{relevanceWeight} \times \text{relevance})$$
Where `recency` uses an exponential decay based on elapsed hours ($\text{decayRate}^{\text{hoursElapsed}}$).
3. **Associative Chain**: When a memory is selected, automatically pull in its immediate chronological neighbors (preceding and succeeding ledger entries) to preserve episodic continuity (mirroring how remembering one event triggers the memory of what happened right after).
### Retrieval Triggers & Active Focus
In crowded locations (e.g. a tavern with 15 other characters), retrieving memories for all co-located entities simultaneously would cause **context explosion**. To prevent this, Omnia utilizes an **Active Focus** trigger strategy:
- **Active Focus Scanning**: The prompt builder scans the last 10 entries of the entity's recent working memory (Tier 1 Buffer). Any character that the actor has recently spoken to, thought about, or was targeted by is placed in the "Active Focus" set.
- **Dynamic Thresholding**:
- If the number of co-located entities is small ($\le 3$), long-term memory is retrieved for all of them.
- If the location is crowded ($> 3$ entities), the system **strictly** limits long-term retrieval to the top 3 characters in "Active Focus".
- This creates a natural attention loop. When a new character interacts with the actor, they immediately enter "Active Focus" in the buffer, triggering the retrieval of their long-term history on the subsequent turn.
### Integration into Prompts
Recalled entries are formatted into the prompt using chronological relative time grouping. System-level metrics like salience/importance scores are omitted to preserve immersion, and system UUIDs are mapped to subjective aliases.
To frame the prompt naturally:
1. Tier 1 working buffer entries are presented under the header `=== RECENT EVENTS ===`, referring strictly to events happening in the present narrative context.
2. Tier 2 recalled entries are presented under the header `=== YOUR MEMORIES ===`, framing them simply as the entity's memories.
```text
=== RECENT EVENTS ===
Moments ago
- you spoke to Strider: "Hello there"
=== YOUR MEMORIES ===
A couple days ago
- You met a hooded figure named Strider at The Prancing Pony.
Quote: "I can avoid being seen, if I wish, but to disappear entirely, that is a rare gift."
```

View File

@@ -15,11 +15,11 @@ omnia/
memory/ verbatim buffer; later the vector archive, dossier, and affect vectors
spatial/ location and POI graph, portal-based perception
llm/ ILLMProvider interface plus Gemini and deterministic mock implementations
scenario/ scenario JSON schema and loader (JSON → SQLite)
apps/
cli/ the playable loop (human or LLM actors, --scenario / --play flags)
content/
scenario-core/ scenario JSON schema and loader (JSON → SQLite)
scenario-builder/ Next.js web UI for authoring worlds
demo/ bundled scenarios (talking-room)
cli/ the playable loop (human or LLM actors, --scenario / --play flags)
tests/
integration/ cross-package tests against a mocked LLM
evals/ deliberate real-API evaluation runs

14
web/docs/wrangler.jsonc Normal file
View File

@@ -0,0 +1,14 @@
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "omnia-docs",
"compatibility_date": "2026-07-09",
"assets": {
"directory": "./dist"
},
"routes": [
{
"pattern": "omnia.adityagupta.dev/docs*",
"zone_name": "adityagupta.dev"
}
]
}