refactor!(runtime): Moved simulation orchestration and DB management out of GUI

This commit is contained in:
Ayush Dagar
2026-07-26 17:08:21 +05:30
parent e09b603d14
commit 5fb0d93a52
29 changed files with 5678 additions and 9399 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,15 @@
export * from "./commands.js";
export * from "./errors.js";
export * from "./providers.js";
export * from "./runtime-service.js";
export * from "./session.js";
export * from "./snapshot.js";
export * from "./persistence/types.js";
export * from "./persistence/sqlite-session-store.js";
export * from "./testing/runtime-fixtures.js";
export {
executePlayerAction,
preparePlayerTurn,
processNpcTurn,
} from "./turn-executor.js";
export { runAliasResolution, runHandoffResolution } from "./alias-handoff.js";

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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