10 Commits

Author SHA1 Message Date
cc9d0006e5 docs: Added documentation for LLMProviders and named instances 2026-07-09 19:35:18 +05:30
6626adf38d MAJOR: deprecated cli interface 2026-07-09 19:09:52 +05:30
053748564f refactor: Decouple model from provider 2026-07-09 19:02:42 +05:30
acd62bdb65 minor: Switch to master-detail layout for LLMProviderInstance 2026-07-09 18:54:35 +05:30
46b12cd668 feat: LLMProviderInstance over LLMProvider
allows for using multiple keys of the same provider
implemented per llm call providerinstance mapping
2026-07-09 18:40:52 +05:30
4ef52f926e MAJOR: Added a GUI app for simulations 2026-07-09 18:14:26 +05:30
0c59756c08 refactor: Move cli package to apps/cli 2026-07-09 13:17:29 +05:30
6e096415ee Merge pull request #20 from sortedcord/remotes/origin/feat/config
MAJOR: Removed old scenario builder and moved scenario loader into pa…
2026-07-09 13:10:24 +05:30
be076d81e5 MAJOR: Removed old scenario builder and moved scenario loader into packages/scenario 2026-07-09 13:09:05 +05:30
13f6dd424e MAJOR: introduce split providers per LLM call and config system 2026-07-09 12:24:15 +05:30
59 changed files with 3924 additions and 4211 deletions

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

View File

@@ -136,11 +136,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

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

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

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

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@@ -1,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,7 +0,0 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

View File

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

Before

Width:  |  Height:  |  Size: 391 B

View File

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

Before

Width:  |  Height:  |  Size: 1.0 KiB

View File

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

Before

Width:  |  Height:  |  Size: 1.3 KiB

View File

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

Before

Width:  |  Height:  |  Size: 128 B

View File

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

Before

Width:  |  Height:  |  Size: 385 B

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

View File

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

View File

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

View File

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

View File

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

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,11 @@
"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",
"build:all": "pnpm build && pnpm build:web && pnpm build:docs && pnpm build:gui",
"dev:web": "pnpm --filter landing dev",
"dev:docs": "pnpm --filter docs dev",
"dev:gui": "pnpm --filter @omnia/gui dev",
"clean": "git clean -xfd",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
@@ -18,8 +20,7 @@
"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": "",

View File

@@ -68,15 +68,29 @@ export class ActorAgent {
private decoder: IntentDecoder;
private generator: IActorProseGenerator;
private llmProvider: ILLMProvider;
constructor(
private llmProvider: ILLMProvider,
llmProvider: ILLMProvider | { actor: ILLMProvider; decoder: ILLMProvider },
bufferRepo?: BufferRepository,
memoryLimit?: number,
generator?: IActorProseGenerator,
) {
let actorProv: ILLMProvider;
let decoderProv: ILLMProvider;
if ("actor" in llmProvider && "decoder" in llmProvider) {
actorProv = llmProvider.actor;
decoderProv = llmProvider.decoder;
} else {
actorProv = llmProvider;
decoderProv = llmProvider;
}
this.promptBuilder = new ActorPromptBuilder(bufferRepo, memoryLimit);
this.decoder = new IntentDecoder(llmProvider);
this.generator = generator ?? new LLMActorProseGenerator(llmProvider);
this.decoder = new IntentDecoder(decoderProv);
this.generator = generator ?? new LLMActorProseGenerator(actorProv);
this.llmProvider = actorProv;
}
/**

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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,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" }
]
}

2903
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,8 @@
packages:
- "packages/*"
- "cli"
- "apps/*"
- "web/*"
- "content/scenario-builder"
- "content/scenario-core"
allowBuilds:
better-sqlite3: true
esbuild: true

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

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

View File

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