diff --git a/apps/gui/next-env.d.ts b/apps/gui/next-env.d.ts
index c4b7818..9edff1c 100644
--- a/apps/gui/next-env.d.ts
+++ b/apps/gui/next-env.d.ts
@@ -1,6 +1,6 @@
///
///
-import "./.next/dev/types/routes.d.ts";
+import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
diff --git a/apps/gui/src/lib/simulation.ts b/apps/gui/src/lib/simulation.ts
deleted file mode 100644
index 6980e50..0000000
--- a/apps/gui/src/lib/simulation.ts
+++ /dev/null
@@ -1,1071 +0,0 @@
-import dotenv from "dotenv";
-import Database from "better-sqlite3";
-import path from "path";
-import fs from "fs";
-import { SQLiteRepository } from "@omnia/core";
-
-// Load .env from monorepo root or apps/gui/
-const cwd = process.cwd();
-const envCandidates = [
- path.resolve(cwd, ".env"),
- path.resolve(cwd, "../../.env"),
-];
-for (const c of envCandidates) {
- if (fs.existsSync(c) && fs.statSync(c).isFile()) {
- dotenv.config({ path: c });
- break;
- }
-}
-
-import {
- BufferRepository,
- LedgerRepository,
- HandoffEngine,
- checkHandoffTrigger,
-} from "@omnia/memory";
-import { Architect, AliasDeltaGenerator } from "@omnia/architect";
-import {
- ActorAgent,
- ActorPromptBuilder,
- IActorProseGenerator,
- buildBufferEntryForIntent,
-} from "@omnia/actor";
-import {
- GeminiProvider,
- ILLMProvider,
- MockLLMProvider,
- ProviderManager,
- OpenRouterProvider,
- IEmbeddingProvider,
- GeminiEmbeddingProvider,
- MockEmbeddingProvider,
- ModelProviderInstance,
-} from "@omnia/llm";
-import { ScenarioLoader } from "@omnia/scenario";
-
-import type {
- IntentInfo,
- LogEntry,
- EntityInfo,
- WaitingContext,
- SimSnapshot,
-} from "./simulation-types.js";
-
-export type { SimSnapshot, EntityInfo, LogEntry, IntentInfo, WaitingContext };
-
-class FixedProseGenerator implements IActorProseGenerator {
- constructor(private prose: string) {}
-
- async generate(
- entityId: string,
- systemPrompt: string,
- userContext: string,
- ): Promise {
- 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;
-}
-
-function loadSessionState(
- db: Database.Database,
- id: string,
-): SavedState | null {
- try {
- db.prepare(
- `
- CREATE TABLE IF NOT EXISTS gui_meta (
- id TEXT PRIMARY KEY,
- state_json TEXT
- )
- `,
- ).run();
- const row = db
- .prepare(`SELECT state_json FROM gui_meta WHERE id = ?`)
- .get(id) as { state_json: string } | undefined;
- return row ? (JSON.parse(row.state_json) as SavedState) : null;
- } catch {
- return null;
- }
-}
-
-interface SimSession {
- db: Database.Database;
- dbPath: string;
- coreRepo: SQLiteRepository;
- bufferRepo: BufferRepository;
- ledgerRepo: LedgerRepository;
- worldInstanceId: string;
- scenarioName: string;
- scenarioDescription: string;
- turn: number;
- maxTurns: number;
- entities: EntityInfo[];
- playerEntityId: string | undefined;
- entityIndex: number;
- actorProvider: ILLMProvider;
- validatorProvider: ILLMProvider;
- decoderProvider: ILLMProvider;
- timedeltaProvider: ILLMProvider;
- handoffProvider: ILLMProvider;
- embeddingProvider: IEmbeddingProvider;
- architect: Architect;
- aliasGenerator: AliasDeltaGenerator;
- log: LogEntry[];
- status: "running" | "waiting_player" | "done" | "error";
- error?: string;
- waitingEntity?: WaitingContext;
- aliasDoneForTurn: boolean;
- providerMappings: Record;
-}
-
-class SimulationManager {
- private sessions = new Map();
-
- async create(
- scenarioPath: string,
- playEntityName?: string,
- providerInstanceId?: string,
- ): Promise {
- let activeInstance: ModelProviderInstance | null = providerInstanceId
- ? ProviderManager.list().find((p) => p.id === providerInstanceId) || null
- : ProviderManager.getActive("generative");
-
- if (!activeInstance) {
- const envKey = process.env.GOOGLE_API_KEY;
- if (envKey) {
- activeInstance = ProviderManager.create(
- "Default (Env)",
- "google-genai",
- envKey,
- undefined,
- "generative",
- );
- }
- }
- if (!activeInstance) {
- return {
- id: "",
- status: "error",
- turn: 0,
- maxTurns: 20,
- scenarioName: "",
- scenarioDescription: "",
- entities: [],
- log: [],
- entityIndex: 0,
- error:
- "No active LLM Provider Instance found. Please configure a key in Settings first.",
- };
- }
-
- const scenarioJson = JSON.parse(fs.readFileSync(scenarioPath, "utf-8"));
- const id = `sim-${Date.now()}`;
-
- const dbDir = path.resolve(process.cwd(), "data");
- fs.mkdirSync(dbDir, { recursive: true });
- const dbPath = path.join(dbDir, `${id}.db`);
- const db = new Database(dbPath);
- const coreRepo = new SQLiteRepository(db);
- const bufferRepo = new BufferRepository(db);
- const ledgerRepo = new LedgerRepository(db);
- const loader = new ScenarioLoader(coreRepo, bufferRepo);
-
- const worldInstanceId = id;
- await loader.initializeWorld(scenarioJson, worldInstanceId);
-
- const worldState = coreRepo.loadWorldState(worldInstanceId);
- if (!worldState) {
- db.close();
- return {
- id: "",
- status: "error",
- turn: 0,
- maxTurns: 20,
- scenarioName: "",
- scenarioDescription: "",
- entities: [],
- log: [],
- entityIndex: 0,
- error: "Failed to load world state after initialization.",
- };
- }
-
- const rawEntities = Array.from(worldState.entities.values());
- const entityInfos: EntityInfo[] = rawEntities.map((e) => ({
- id: e.id,
- name: (e.attributes.get("name")?.getValue() as string) || e.id,
- isPlayer: false,
- isAgent: e.isAgent,
- }));
-
- let playerEntityId: string | undefined;
- if (playEntityName) {
- let matched = worldState.getEntity(playEntityName);
- if (!matched) {
- for (const ent of rawEntities) {
- const nameAttr = ent.attributes.get("name")?.getValue() as
- string | undefined;
- if (nameAttr?.toLowerCase() === playEntityName.toLowerCase()) {
- matched = ent;
- break;
- }
- }
- }
- if (!matched) {
- for (const ent of rawEntities) {
- const nameAttr = ent.attributes.get("name")?.getValue() as
- string | undefined;
- if (
- nameAttr?.toLowerCase().includes(playEntityName.toLowerCase()) ||
- ent.id.toLowerCase().includes(playEntityName.toLowerCase())
- ) {
- matched = ent;
- break;
- }
- }
- }
- if (matched) {
- playerEntityId = matched.id;
- const info = entityInfos.find((e) => e.id === matched.id);
- if (info) info.isPlayer = true;
- }
- }
-
- const list = ProviderManager.list();
- const active = ProviderManager.getActive("generative") || activeInstance;
- const mappings = ProviderManager.getMappings();
-
- const resolveProviderForTask = (task: string): ILLMProvider => {
- const mappedId = mappings[task];
- let inst = mappedId ? list.find((p) => p.id === mappedId) : null;
- if (!inst || inst.type !== "generative") {
- inst = active;
- }
-
- const key = inst ? inst.apiKey : process.env.GOOGLE_API_KEY || "";
- const providerName = inst ? inst.providerName : "google-genai";
- const modelName = inst ? inst.modelName : undefined;
- const instanceName = inst ? inst.name : undefined;
- const maxContext = inst ? inst.maxContext : undefined;
-
- if (providerName === "google-genai") {
- return new GeminiProvider(key, modelName, instanceName, maxContext);
- } else if (providerName === "openrouter") {
- return new OpenRouterProvider(key, modelName, instanceName, maxContext);
- } else {
- return new MockLLMProvider([]);
- }
- };
-
- const resolveEmbeddingProvider = (): IEmbeddingProvider => {
- const mappedId = mappings["embeddings"];
- let inst = mappedId ? list.find((p) => p.id === mappedId) : null;
- if (!inst || inst.type !== "embedding") {
- inst = ProviderManager.getActive("embedding");
- }
-
- const key = inst ? inst.apiKey : process.env.GOOGLE_API_KEY || "";
- const providerName = inst ? inst.providerName : "google-genai";
- const modelName = inst ? inst.modelName : undefined;
-
- if (providerName === "google-genai") {
- return new GeminiEmbeddingProvider(key, modelName);
- } else {
- return new MockEmbeddingProvider(modelName);
- }
- };
-
- const actorProvider = resolveProviderForTask("actor-prose");
- const validatorProvider = resolveProviderForTask("llm-validator");
- const decoderProvider = resolveProviderForTask("intent-decoder");
- const timedeltaProvider = resolveProviderForTask("timedelta");
- const handoffProvider = resolveProviderForTask("handoff");
- const embeddingProvider = resolveEmbeddingProvider();
-
- const architect = new Architect(
- { validator: validatorProvider, timedelta: timedeltaProvider },
- coreRepo,
- );
- const aliasGenerator = new AliasDeltaGenerator(actorProvider);
-
- const session: SimSession = {
- db,
- dbPath,
- coreRepo,
- bufferRepo,
- ledgerRepo,
- worldInstanceId: worldInstanceId,
- scenarioName: scenarioJson.name,
- scenarioDescription: scenarioJson.description || "",
- turn: 1,
- maxTurns: 20,
- entities: entityInfos,
- playerEntityId,
- entityIndex: 0,
- actorProvider,
- validatorProvider,
- decoderProvider,
- timedeltaProvider,
- handoffProvider,
- embeddingProvider,
- architect,
- aliasGenerator,
- log: [],
- status: "running",
- aliasDoneForTurn: false,
- providerMappings: mappings,
- };
-
- this.sessions.set(id, session);
- return this.snapshot(session);
- }
-
- async step(id: string): Promise {
- 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);
- await this.runHandoffResolution(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.isAgent) {
- session.entityIndex++;
- this.save(session);
- return this.snapshot(session);
- }
-
- 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 {
- const session = this.sessions.get(id);
- if (!session) return null;
- if (session.status !== "waiting_player") return this.snapshot(session);
- if (!session.waitingEntity) return this.snapshot(session);
-
- const ctx = session.waitingEntity;
- session.waitingEntity = undefined;
- session.status = "running";
-
- try {
- const worldState = session.coreRepo.loadWorldState(
- session.worldInstanceId,
- );
- if (!worldState) throw new Error("World state lost");
-
- const entity = worldState.getEntity(ctx.entityId);
- if (!entity) throw new Error(`Player entity "${ctx.entityId}" not found`);
-
- const playerActor = new ActorAgent(
- { actor: session.actorProvider, decoder: session.decoderProvider },
- session.bufferRepo,
- session.ledgerRepo,
- 20,
- new FixedProseGenerator(prose),
- );
-
- const result = await playerActor.act(worldState, entity);
-
- const entry: LogEntry = {
- turn: session.turn,
- entityId: ctx.entityId,
- entityName: ctx.name,
- narrativeProse: result.narrativeProse,
- intents: [],
- timestamp: worldState.clock.get().toISOString(),
- rawPrompt: {
- systemPrompt: ctx.systemPrompt,
- userContext: ctx.userContext,
- },
- };
-
- if (
- session.decoderProvider.lastCalls &&
- session.decoderProvider.lastCalls.length > 0
- ) {
- const call =
- session.decoderProvider.lastCalls[
- session.decoderProvider.lastCalls.length - 1
- ];
- entry.decoderPrompt = {
- systemPrompt: call.systemPrompt,
- userContext: call.userContext,
- };
- entry.decoderUsage = call.usage;
- }
-
- for (const intent of result.intents.intents) {
- const outcome = await session.architect.processIntent(
- worldState,
- intent,
- );
- const ts = worldState.clock.get().toISOString();
-
- entry.intents.push({
- type: intent.type,
- description: intent.description,
- selfDescription: intent.selfDescription,
- modifiers: intent.modifiers || [],
- 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 {
- const worldState = session.coreRepo.loadWorldState(session.worldInstanceId);
- if (!worldState) throw new Error("World state lost");
-
- const entity = worldState.getEntity(info.id);
- if (!entity) throw new Error(`Entity "${info.id}" not found`);
-
- const promptBuilder = new ActorPromptBuilder(
- session.bufferRepo,
- session.ledgerRepo,
- 20,
- );
- const { systemPrompt, userContext } = promptBuilder.build(
- worldState,
- entity,
- );
-
- session.waitingEntity = {
- entityId: info.id,
- name: info.name,
- systemPrompt,
- userContext,
- };
- session.status = "waiting_player";
- }
-
- private async processNpcTurn(
- session: SimSession,
- info: EntityInfo,
- ): Promise {
- const worldState = session.coreRepo.loadWorldState(session.worldInstanceId);
- if (!worldState) throw new Error("World state lost");
-
- const entity = worldState.getEntity(info.id);
- if (!entity) throw new Error(`Entity "${info.id}" not found`);
-
- const actor = new ActorAgent(
- { actor: session.actorProvider, decoder: session.decoderProvider },
- session.bufferRepo,
- session.ledgerRepo,
- 20,
- );
- const result = await actor.act(worldState, entity);
-
- const entry: LogEntry = {
- turn: session.turn,
- entityId: info.id,
- entityName: info.name,
- narrativeProse: result.narrativeProse,
- intents: [],
- timestamp: worldState.clock.get().toISOString(),
- };
-
- if (
- session.actorProvider.lastCalls &&
- session.actorProvider.lastCalls.length > 0
- ) {
- const actorCall =
- session.actorProvider.lastCalls[
- session.actorProvider.lastCalls.length - 1
- ];
- entry.rawPrompt = {
- systemPrompt: actorCall.systemPrompt,
- userContext: actorCall.userContext,
- };
- entry.usage = actorCall.usage;
- }
-
- if (
- session.decoderProvider.lastCalls &&
- session.decoderProvider.lastCalls.length > 0
- ) {
- const decoderCall =
- session.decoderProvider.lastCalls[
- session.decoderProvider.lastCalls.length - 1
- ];
- entry.decoderPrompt = {
- systemPrompt: decoderCall.systemPrompt,
- userContext: decoderCall.userContext,
- };
- entry.decoderUsage = decoderCall.usage;
- }
-
- for (const intent of result.intents.intents) {
- const outcome = await session.architect.processIntent(worldState, intent);
- const ts = worldState.clock.get().toISOString();
-
- entry.intents.push({
- type: intent.type,
- description: intent.description,
- selfDescription: intent.selfDescription,
- modifiers: intent.modifiers || [],
- 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 runHandoffResolution(session: SimSession): Promise {
- const worldState = session.coreRepo.loadWorldState(session.worldInstanceId);
- if (!worldState) throw new Error("World state lost");
-
- const handoffEngine = new HandoffEngine(
- session.handoffProvider,
- session.embeddingProvider,
- session.bufferRepo,
- session.ledgerRepo,
- );
-
- const entities = Array.from(worldState.entities.values());
- for (const entity of entities) {
- if (!entity.isAgent) continue;
- const bufferEntries = session.bufferRepo.listForOwner(entity.id);
- const maxContext =
- session.handoffProvider.maxContext !== undefined
- ? session.handoffProvider.maxContext
- : 32768;
-
- const trigger = checkHandoffTrigger(
- entity,
- bufferEntries,
- worldState.clock.get(),
- maxContext,
- );
- if (trigger !== "none") {
- await handoffEngine.runHandoff(
- entity,
- bufferEntries,
- worldState.clock.get(),
- );
- }
- }
- }
-
- private async runAliasResolution(session: SimSession): Promise {
- const worldState = session.coreRepo.loadWorldState(session.worldInstanceId);
- if (!worldState) throw new Error("World state lost");
-
- const entities = Array.from(worldState.entities.values());
- for (const viewer of entities) {
- if (!viewer.isAgent) continue;
- if (!viewer.locationId) continue;
- for (const target of entities) {
- if (viewer.id === target.id) continue;
- if (
- target.locationId === viewer.locationId &&
- !viewer.aliases.has(target.id)
- ) {
- const alias = await session.aliasGenerator.generate(viewer, target);
- viewer.aliases.set(target.id, alias);
- session.coreRepo.saveEntity(viewer, worldState.id);
- }
- }
- }
- }
-
- 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 {
- const active = this.sessions.get(id);
- if (active) {
- return this.snapshot(active);
- }
-
- const dbDir = path.resolve(process.cwd(), "data");
- const dbPath = path.join(dbDir, `${id}.db`);
- if (!fs.existsSync(dbPath)) return null;
-
- try {
- const db = new Database(dbPath);
- const state = loadSessionState(db, id);
- if (!state) {
- db.close();
- return null;
- }
-
- const list = ProviderManager.list();
- const active = ProviderManager.getActive("generative");
- const mappings = state.providerMappings || {};
-
- const resolveProviderForTask = (task: string): ILLMProvider => {
- const mappedId = mappings[task];
- let inst = mappedId ? list.find((p) => p.id === mappedId) : null;
- if (!inst || inst.type !== "generative") {
- inst = active;
- }
-
- if (!inst) {
- const envKey = process.env.GOOGLE_API_KEY;
- if (envKey) {
- inst = ProviderManager.create(
- "Default (Env)",
- "google-genai",
- envKey,
- undefined,
- "generative",
- );
- }
- }
-
- if (!inst) {
- throw new Error(
- `No active LLM Provider Instance found for task "${task}". Please configure a key in Settings first.`,
- );
- }
-
- if (inst.providerName === "google-genai") {
- return new GeminiProvider(
- inst.apiKey,
- inst.modelName,
- inst.name,
- inst.maxContext,
- );
- } else if (inst.providerName === "openrouter") {
- return new OpenRouterProvider(
- inst.apiKey,
- inst.modelName,
- inst.name,
- inst.maxContext,
- );
- } else {
- return new MockLLMProvider([]);
- }
- };
-
- const resolveEmbeddingProvider = (): IEmbeddingProvider => {
- const mappedId = mappings["embeddings"];
- let inst = mappedId ? list.find((p) => p.id === mappedId) : null;
- if (!inst || inst.type !== "embedding") {
- inst = ProviderManager.getActive("embedding");
- }
- if (!inst) {
- const envKey = process.env.GOOGLE_API_KEY;
- if (envKey) {
- inst = ProviderManager.create(
- "Default Embed (Env)",
- "google-genai",
- envKey,
- "gemini-embedding-001",
- "embedding",
- );
- }
- }
-
- if (!inst) {
- throw new Error(
- `No active Embedding Provider Instance found for task "embeddings". Please configure an embedding key in Settings first.`,
- );
- }
-
- if (inst.providerName === "google-genai") {
- return new GeminiEmbeddingProvider(inst.apiKey, inst.modelName);
- } else {
- return new MockEmbeddingProvider(inst.modelName);
- }
- };
-
- const coreRepo = new SQLiteRepository(db);
- const bufferRepo = new BufferRepository(db);
- const ledgerRepo = new LedgerRepository(db);
-
- const actorProvider = resolveProviderForTask("actor-prose");
- const validatorProvider = resolveProviderForTask("llm-validator");
- const decoderProvider = resolveProviderForTask("intent-decoder");
- const timedeltaProvider = resolveProviderForTask("timedelta");
- const handoffProvider = resolveProviderForTask("handoff");
- const embeddingProvider = resolveEmbeddingProvider();
-
- const architect = new Architect(
- { validator: validatorProvider, timedelta: timedeltaProvider },
- coreRepo,
- );
- const aliasGenerator = new AliasDeltaGenerator(actorProvider);
-
- const session: SimSession = {
- db,
- dbPath,
- coreRepo,
- bufferRepo,
- ledgerRepo,
- worldInstanceId: id,
- scenarioName: state.scenarioName,
- scenarioDescription: state.scenarioDescription,
- turn: state.turn,
- maxTurns: state.maxTurns,
- entities: state.entities || [],
- playerEntityId: state.playerEntityId,
- entityIndex: state.entityIndex,
- actorProvider,
- validatorProvider,
- decoderProvider,
- timedeltaProvider,
- handoffProvider,
- embeddingProvider,
- architect,
- aliasGenerator,
- log: state.log || [],
- status: state.status,
- error: state.error,
- waitingEntity: state.waitingEntity,
- aliasDoneForTurn: state.aliasDoneForTurn || false,
- providerMappings: mappings,
- };
-
- this.sessions.set(id, session);
- return this.snapshot(session);
- } catch (err) {
- console.error(`Failed to load session ${id}:`, err);
- return null;
- }
- }
-
- listSavedSessions(): SimSnapshot[] {
- const dbDir = path.resolve(process.cwd(), "data");
- if (!fs.existsSync(dbDir)) return [];
-
- const snapshots: SimSnapshot[] = [];
- const files = fs
- .readdirSync(dbDir)
- .filter((f) => f.startsWith("sim-") && f.endsWith(".db"));
-
- for (const file of files) {
- const id = file.replace(".db", "");
- const dbPath = path.join(dbDir, file);
-
- const active = this.sessions.get(id);
- if (active) {
- snapshots.push(this.snapshot(active));
- continue;
- }
-
- try {
- const db = new Database(dbPath);
- const state = loadSessionState(db, id);
- db.close();
-
- if (state) {
- snapshots.push({
- id,
- status: state.status,
- turn: state.turn,
- maxTurns: state.maxTurns,
- scenarioName: state.scenarioName,
- scenarioDescription: state.scenarioDescription,
- entities: state.entities || [],
- log: state.log || [],
- entityIndex: state.entityIndex,
- waitingEntity: state.waitingEntity,
- error: state.error,
- });
- }
- } catch {
- /* skip */
- }
- }
-
- return snapshots.sort((a, b) => {
- const tsA = parseInt(a.id.replace("sim-", ""), 10) || 0;
- const tsB = parseInt(b.id.replace("sim-", ""), 10) || 0;
- return tsB - tsA;
- });
- }
-
- async regenerateAllEmbeddings(newProviderInstanceId?: string): Promise {
- const dbDir = path.resolve(process.cwd(), "data");
- if (!fs.existsSync(dbDir)) return;
-
- const files = fs
- .readdirSync(dbDir)
- .filter((f) => f.startsWith("sim-") && f.endsWith(".db"));
-
- const list = ProviderManager.list();
- let inst = newProviderInstanceId
- ? list.find((p) => p.id === newProviderInstanceId)
- : null;
- if (!inst || inst.type !== "embedding") {
- inst = ProviderManager.getActive("embedding");
- }
-
- const key = inst ? inst.apiKey : process.env.GOOGLE_API_KEY || "";
- const providerName = inst ? inst.providerName : "google-genai";
- const modelName = inst ? inst.modelName : undefined;
-
- let embeddingProvider: IEmbeddingProvider;
- if (providerName === "google-genai") {
- embeddingProvider = new GeminiEmbeddingProvider(key, modelName);
- } else {
- embeddingProvider = new MockEmbeddingProvider(modelName);
- }
-
- for (const file of files) {
- const dbPath = path.join(dbDir, file);
- const id = file.replace(".db", "");
- const activeSession = this.sessions.get(id);
- const db = activeSession ? activeSession.db : new Database(dbPath);
-
- try {
- const rows = db
- .prepare(`SELECT id, content FROM ledger_entries`)
- .all() as { id: string; content: string }[];
-
- for (const row of rows) {
- const vector = await embeddingProvider.embed(row.content);
- const buffer = Buffer.from(new Float32Array(vector).buffer);
- db.prepare(
- `UPDATE ledger_entries SET embedding = ? WHERE id = ?`,
- ).run(buffer, row.id);
- }
- } catch (err) {
- console.error(`Failed to regenerate embeddings for ${file}:`, err);
- } finally {
- if (!activeSession) {
- db.close();
- }
- }
- }
- }
-
- private save(session: SimSession): void {
- const state: SavedState = {
- scenarioName: session.scenarioName,
- scenarioDescription: session.scenarioDescription,
- turn: session.turn,
- maxTurns: session.maxTurns,
- entities: session.entities,
- playerEntityId: session.playerEntityId,
- entityIndex: session.entityIndex,
- status: session.status,
- error: session.error,
- waitingEntity: session.waitingEntity,
- aliasDoneForTurn: session.aliasDoneForTurn,
- log: session.log,
- providerMappings: session.providerMappings,
- };
-
- session.db
- .prepare(
- `
- CREATE TABLE IF NOT EXISTS gui_meta (
- id TEXT PRIMARY KEY,
- state_json TEXT
- )
- `,
- )
- .run();
-
- session.db
- .prepare(
- `
- INSERT INTO gui_meta (id, state_json)
- VALUES (?, ?)
- ON CONFLICT(id) DO UPDATE SET state_json = excluded.state_json
- `,
- )
- .run(session.worldInstanceId, JSON.stringify(state));
- }
-
- getSnapshot(id: string): SimSnapshot | null {
- const session = this.sessions.get(id);
- return session ? this.snapshot(session) : null;
- }
-
- private snapshot(session: SimSession): SimSnapshot {
- return {
- id: session.worldInstanceId,
- status: session.status,
- turn: session.turn,
- maxTurns: session.maxTurns,
- scenarioName: session.scenarioName,
- scenarioDescription: session.scenarioDescription,
- entities: session.entities,
- log: session.log,
- entityIndex: session.entityIndex,
- waitingEntity: session.waitingEntity,
- error: session.error,
- };
- }
-}
-
-export const simulationManager = new SimulationManager();
diff --git a/apps/gui/src/lib/simulation/alias-handoff.ts b/apps/gui/src/lib/simulation/alias-handoff.ts
new file mode 100644
index 0000000..dc17697
--- /dev/null
+++ b/apps/gui/src/lib/simulation/alias-handoff.ts
@@ -0,0 +1,71 @@
+import { HandoffEngine, checkHandoffTrigger } from "@omnia/memory";
+import type { SimSession } from "./types";
+
+/**
+ * Runs the HandoffEngine for every agent entity that has accumulated enough
+ * buffer entries to warrant a handoff (compression to long-term memory).
+ */
+export async function runHandoffResolution(session: SimSession): Promise {
+ const worldState = session.coreRepo.loadWorldState(session.worldInstanceId);
+ if (!worldState) throw new Error("World state lost");
+
+ const handoffEngine = new HandoffEngine(
+ session.handoffProvider,
+ session.embeddingProvider,
+ session.bufferRepo,
+ session.ledgerRepo,
+ );
+
+ const entities = Array.from(worldState.entities.values());
+ for (const entity of entities) {
+ if (!entity.isAgent) continue;
+
+ const bufferEntries = session.bufferRepo.listForOwner(entity.id);
+ const maxContext =
+ session.handoffProvider.maxContext !== undefined
+ ? session.handoffProvider.maxContext
+ : 32768;
+
+ const trigger = checkHandoffTrigger(
+ entity,
+ bufferEntries,
+ worldState.clock.get(),
+ maxContext,
+ );
+ if (trigger !== "none") {
+ await handoffEngine.runHandoff(
+ entity,
+ bufferEntries,
+ worldState.clock.get(),
+ );
+ }
+ }
+}
+
+/**
+ * For every agent that shares a location with another entity they haven't
+ * previously encountered, generates a first-person alias description and
+ * persists it on the viewing entity.
+ */
+export async function runAliasResolution(session: SimSession): Promise {
+ const worldState = session.coreRepo.loadWorldState(session.worldInstanceId);
+ if (!worldState) throw new Error("World state lost");
+
+ const entities = Array.from(worldState.entities.values());
+ for (const viewer of entities) {
+ if (!viewer.isAgent) continue;
+ if (!viewer.locationId) continue;
+
+ for (const target of entities) {
+ if (viewer.id === target.id) continue;
+ if (
+ target.locationId === viewer.locationId &&
+ !viewer.aliases.has(target.id)
+ ) {
+ const alias = await session.aliasGenerator.generate(viewer, target);
+ viewer.aliases.set(target.id, alias);
+ session.coreRepo.saveEntity(viewer, worldState.id);
+ }
+ }
+ }
+}
diff --git a/apps/gui/src/lib/simulation/env.ts b/apps/gui/src/lib/simulation/env.ts
new file mode 100644
index 0000000..67ce80e
--- /dev/null
+++ b/apps/gui/src/lib/simulation/env.ts
@@ -0,0 +1,16 @@
+import dotenv from "dotenv";
+import path from "path";
+import fs from "fs";
+
+// Load .env from monorepo root or apps/gui/
+const cwd = process.cwd();
+const envCandidates = [
+ path.resolve(cwd, ".env"),
+ path.resolve(cwd, "../../.env"),
+];
+for (const c of envCandidates) {
+ if (fs.existsSync(c) && fs.statSync(c).isFile()) {
+ dotenv.config({ path: c });
+ break;
+ }
+}
diff --git a/apps/gui/src/lib/simulation/index.ts b/apps/gui/src/lib/simulation/index.ts
new file mode 100644
index 0000000..c42f199
--- /dev/null
+++ b/apps/gui/src/lib/simulation/index.ts
@@ -0,0 +1,17 @@
+/**
+ * Barrel entry point for the simulation module.
+ *
+ * Consumers import from "@/lib/simulation" exactly as before — no import
+ * paths need to change anywhere in the codebase.
+ */
+import { SimulationManager } from "./simulation-manager";
+
+export const simulationManager = new SimulationManager();
+
+export type {
+ SimSnapshot,
+ EntityInfo,
+ LogEntry,
+ IntentInfo,
+ WaitingContext,
+} from "../simulation-types";
diff --git a/apps/gui/src/lib/simulation/provider-resolver.ts b/apps/gui/src/lib/simulation/provider-resolver.ts
new file mode 100644
index 0000000..325d33a
--- /dev/null
+++ b/apps/gui/src/lib/simulation/provider-resolver.ts
@@ -0,0 +1,175 @@
+import {
+ GeminiProvider,
+ MockLLMProvider,
+ ProviderManager,
+ OpenRouterProvider,
+ GeminiEmbeddingProvider,
+ MockEmbeddingProvider,
+} from "@omnia/llm";
+import type {
+ ILLMProvider,
+ IEmbeddingProvider,
+ ModelProviderInstance,
+} from "@omnia/llm";
+
+// ---------------------------------------------------------------------------
+// Public types
+// ---------------------------------------------------------------------------
+
+export interface ResolvedProviders {
+ actorProvider: ILLMProvider;
+ validatorProvider: ILLMProvider;
+ decoderProvider: ILLMProvider;
+ timedeltaProvider: ILLMProvider;
+ handoffProvider: ILLMProvider;
+ embeddingProvider: IEmbeddingProvider;
+}
+
+export interface ProviderResolverOptions {
+ /**
+ * Pre-resolved generative instance to fall back to when ProviderManager has
+ * no active generative provider (e.g. when the caller already validated a
+ * specific provider during session creation).
+ */
+ fallbackInstance?: ModelProviderInstance | null;
+ /**
+ * When true, throws an Error if no provider can be resolved for a task.
+ * When false (default), falls back silently to MockLLMProvider / MockEmbeddingProvider.
+ */
+ required?: boolean;
+}
+
+// ---------------------------------------------------------------------------
+// Private builders
+// ---------------------------------------------------------------------------
+
+function buildLLMProvider(inst: ModelProviderInstance): ILLMProvider {
+ if (inst.providerName === "google-genai") {
+ return new GeminiProvider(
+ inst.apiKey,
+ inst.modelName,
+ inst.name,
+ inst.maxContext,
+ );
+ } else if (inst.providerName === "openrouter") {
+ return new OpenRouterProvider(
+ inst.apiKey,
+ inst.modelName,
+ inst.name,
+ inst.maxContext,
+ );
+ }
+ return new MockLLMProvider([]);
+}
+
+function buildEmbeddingProvider(
+ inst: ModelProviderInstance,
+): IEmbeddingProvider {
+ if (inst.providerName === "google-genai") {
+ return new GeminiEmbeddingProvider(inst.apiKey, inst.modelName);
+ }
+ return new MockEmbeddingProvider(inst.modelName);
+}
+
+// ---------------------------------------------------------------------------
+// Public API
+// ---------------------------------------------------------------------------
+
+/**
+ * Resolves all six LLM + embedding providers needed for a simulation session.
+ *
+ * Resolution order for each generative task:
+ * 1. Task-specific mapping from ProviderManager (via `mappings[task]`)
+ * 2. ProviderManager active generative instance
+ * 3. `fallbackInstance` (if supplied)
+ * 4. GOOGLE_API_KEY env var → auto-creates a temporary GeminiProvider
+ * 5. Throws (if `required`) or returns MockLLMProvider
+ */
+export function resolveProviders(
+ mappings: Record,
+ options: ProviderResolverOptions = {},
+): ResolvedProviders {
+ const { fallbackInstance = null, required = false } = options;
+ const list = ProviderManager.list();
+ const activeGenerative =
+ ProviderManager.getActive("generative") ?? fallbackInstance ?? null;
+
+ const resolveGenerative = (task: string): ILLMProvider => {
+ const mappedId = mappings[task];
+ let inst: ModelProviderInstance | null = mappedId
+ ? (list.find((p) => p.id === mappedId) ?? null)
+ : null;
+
+ if (!inst || inst.type !== "generative") {
+ inst = activeGenerative;
+ }
+
+ if (!inst) {
+ const envKey = process.env.GOOGLE_API_KEY;
+ if (envKey) {
+ inst = ProviderManager.create(
+ "Default (Env)",
+ "google-genai",
+ envKey,
+ undefined,
+ "generative",
+ );
+ }
+ }
+
+ if (!inst) {
+ if (required) {
+ throw new Error(
+ `No active LLM Provider Instance found for task "${task}". Please configure a key in Settings first.`,
+ );
+ }
+ return new MockLLMProvider([]);
+ }
+
+ return buildLLMProvider(inst);
+ };
+
+ const resolveEmbedding = (): IEmbeddingProvider => {
+ const mappedId = mappings["embeddings"];
+ let inst: ModelProviderInstance | null = mappedId
+ ? (list.find((p) => p.id === mappedId) ?? null)
+ : null;
+
+ if (!inst || inst.type !== "embedding") {
+ inst = ProviderManager.getActive("embedding");
+ }
+
+ if (!inst) {
+ const envKey = process.env.GOOGLE_API_KEY;
+ if (envKey) {
+ inst = ProviderManager.create(
+ "Default Embed (Env)",
+ "google-genai",
+ envKey,
+ "gemini-embedding-001",
+ "embedding",
+ );
+ }
+ }
+
+ if (!inst) {
+ if (required) {
+ throw new Error(
+ `No active Embedding Provider Instance found. Please configure an embedding key in Settings first.`,
+ );
+ }
+ return new MockEmbeddingProvider(undefined);
+ }
+
+ return buildEmbeddingProvider(inst);
+ };
+
+ return {
+ actorProvider: resolveGenerative("actor-prose"),
+ validatorProvider: resolveGenerative("llm-validator"),
+ decoderProvider: resolveGenerative("intent-decoder"),
+ timedeltaProvider: resolveGenerative("timedelta"),
+ handoffProvider: resolveGenerative("handoff"),
+ embeddingProvider: resolveEmbedding(),
+ };
+}
diff --git a/apps/gui/src/lib/simulation/session-store.ts b/apps/gui/src/lib/simulation/session-store.ts
new file mode 100644
index 0000000..adee610
--- /dev/null
+++ b/apps/gui/src/lib/simulation/session-store.ts
@@ -0,0 +1,139 @@
+import Database from "better-sqlite3";
+import path from "path";
+import fs from "fs";
+import type { SimSession, SavedState } from "./types";
+import type { SimSnapshot } from "../simulation-types";
+
+export const DATA_DIR = path.resolve(process.cwd(), "data");
+
+// ---------------------------------------------------------------------------
+// Low-level read/write helpers
+// ---------------------------------------------------------------------------
+
+export function loadSessionState(
+ db: Database.Database,
+ id: string,
+): SavedState | null {
+ try {
+ db.prepare(
+ `CREATE TABLE IF NOT EXISTS gui_meta (
+ id TEXT PRIMARY KEY,
+ state_json TEXT
+ )`,
+ ).run();
+ const row = db
+ .prepare(`SELECT state_json FROM gui_meta WHERE id = ?`)
+ .get(id) as { state_json: string } | undefined;
+ return row ? (JSON.parse(row.state_json) as SavedState) : null;
+ } catch {
+ return null;
+ }
+}
+
+export function saveSession(session: SimSession): void {
+ const state: SavedState = {
+ scenarioName: session.scenarioName,
+ scenarioDescription: session.scenarioDescription,
+ turn: session.turn,
+ maxTurns: session.maxTurns,
+ entities: session.entities,
+ playerEntityId: session.playerEntityId,
+ entityIndex: session.entityIndex,
+ status: session.status,
+ error: session.error,
+ waitingEntity: session.waitingEntity,
+ aliasDoneForTurn: session.aliasDoneForTurn,
+ log: session.log,
+ providerMappings: session.providerMappings,
+ };
+
+ session.db
+ .prepare(
+ `CREATE TABLE IF NOT EXISTS gui_meta (
+ id TEXT PRIMARY KEY,
+ state_json TEXT
+ )`,
+ )
+ .run();
+
+ session.db
+ .prepare(
+ `INSERT INTO gui_meta (id, state_json)
+ VALUES (?, ?)
+ ON CONFLICT(id) DO UPDATE SET state_json = excluded.state_json`,
+ )
+ .run(session.worldInstanceId, JSON.stringify(state));
+}
+
+// ---------------------------------------------------------------------------
+// Session file management
+// ---------------------------------------------------------------------------
+
+export function deleteSessionFile(id: string): void {
+ const dbPath = path.join(DATA_DIR, `${id}.db`);
+ if (fs.existsSync(dbPath)) {
+ try {
+ fs.unlinkSync(dbPath);
+ } catch (err) {
+ console.error(`Failed to delete session file ${dbPath}:`, err);
+ }
+ }
+}
+
+/**
+ * Lists all saved simulation snapshots by scanning the data directory.
+ * Active in-memory sessions are snapshotted via the provided callback;
+ * inactive ones are read directly from their `.db` files.
+ */
+export function listSavedSessions(
+ activeSessions: Map,
+ snapshotFn: (session: SimSession) => SimSnapshot,
+): SimSnapshot[] {
+ if (!fs.existsSync(DATA_DIR)) return [];
+
+ const snapshots: SimSnapshot[] = [];
+ const files = fs
+ .readdirSync(DATA_DIR)
+ .filter((f) => f.startsWith("sim-") && f.endsWith(".db"));
+
+ for (const file of files) {
+ const id = file.replace(".db", "");
+ const dbPath = path.join(DATA_DIR, file);
+
+ const active = activeSessions.get(id);
+ if (active) {
+ snapshots.push(snapshotFn(active));
+ continue;
+ }
+
+ try {
+ const db = new Database(dbPath);
+ const state = loadSessionState(db, id);
+ db.close();
+
+ if (state) {
+ snapshots.push({
+ id,
+ status: state.status,
+ turn: state.turn,
+ maxTurns: state.maxTurns,
+ scenarioName: state.scenarioName,
+ scenarioDescription: state.scenarioDescription,
+ entities: state.entities || [],
+ log: state.log || [],
+ entityIndex: state.entityIndex,
+ waitingEntity: state.waitingEntity,
+ error: state.error,
+ });
+ }
+ } catch {
+ /* skip corrupt / in-use db files */
+ }
+ }
+
+ return snapshots.sort((a, b) => {
+ const tsA = parseInt(a.id.replace("sim-", ""), 10) || 0;
+ const tsB = parseInt(b.id.replace("sim-", ""), 10) || 0;
+ return tsB - tsA;
+ });
+}
diff --git a/apps/gui/src/lib/simulation/simulation-manager.ts b/apps/gui/src/lib/simulation/simulation-manager.ts
new file mode 100644
index 0000000..dc0f159
--- /dev/null
+++ b/apps/gui/src/lib/simulation/simulation-manager.ts
@@ -0,0 +1,455 @@
+import "./env"; // Must be first — loads .env before any code reads process.env
+import Database from "better-sqlite3";
+import path from "path";
+import fs from "fs";
+import { SQLiteRepository } from "@omnia/core";
+import { BufferRepository, LedgerRepository } from "@omnia/memory";
+import { Architect, AliasDeltaGenerator } from "@omnia/architect";
+import {
+ ProviderManager,
+ GeminiEmbeddingProvider,
+ MockEmbeddingProvider,
+} from "@omnia/llm";
+import type { ModelProviderInstance, IEmbeddingProvider } from "@omnia/llm";
+import { ScenarioLoader } from "@omnia/scenario";
+import type { SimSnapshot } from "../simulation-types";
+import type { SimSession, EntityInfo } from "./types";
+import { resolveProviders } from "./provider-resolver";
+import {
+ DATA_DIR,
+ loadSessionState,
+ saveSession,
+ listSavedSessions,
+ deleteSessionFile,
+} from "./session-store";
+import {
+ preparePlayerTurn,
+ processNpcTurn,
+ executePlayerAction,
+} from "./turn-executor";
+import { runAliasResolution, runHandoffResolution } from "./alias-handoff";
+
+export class SimulationManager {
+ private sessions = new Map();
+
+ // ---------------------------------------------------------------------------
+ // Session lifecycle
+ // ---------------------------------------------------------------------------
+
+ async create(
+ scenarioPath: string,
+ playEntityName?: string,
+ providerInstanceId?: string,
+ ): Promise {
+ // Resolve or validate the active generative provider upfront so we can
+ // return a clean error snapshot before touching the filesystem.
+ let activeInstance: ModelProviderInstance | null = providerInstanceId
+ ? ProviderManager.list().find((p) => p.id === providerInstanceId) || null
+ : ProviderManager.getActive("generative");
+
+ if (!activeInstance) {
+ const envKey = process.env.GOOGLE_API_KEY;
+ if (envKey) {
+ activeInstance = ProviderManager.create(
+ "Default (Env)",
+ "google-genai",
+ envKey,
+ undefined,
+ "generative",
+ );
+ }
+ }
+ if (!activeInstance) {
+ return {
+ id: "",
+ status: "error",
+ turn: 0,
+ maxTurns: 20,
+ scenarioName: "",
+ scenarioDescription: "",
+ entities: [],
+ log: [],
+ entityIndex: 0,
+ error:
+ "No active LLM Provider Instance found. Please configure a key in Settings first.",
+ };
+ }
+
+ const scenarioJson = JSON.parse(fs.readFileSync(scenarioPath, "utf-8"));
+ const id = `sim-${Date.now()}`;
+
+ fs.mkdirSync(DATA_DIR, { recursive: true });
+ const dbPath = path.join(DATA_DIR, `${id}.db`);
+ const db = new Database(dbPath);
+ const coreRepo = new SQLiteRepository(db);
+ const bufferRepo = new BufferRepository(db);
+ const ledgerRepo = new LedgerRepository(db);
+ const loader = new ScenarioLoader(coreRepo, bufferRepo);
+
+ const worldInstanceId = id;
+ await loader.initializeWorld(scenarioJson, worldInstanceId);
+
+ const worldState = coreRepo.loadWorldState(worldInstanceId);
+ if (!worldState) {
+ db.close();
+ return {
+ id: "",
+ status: "error",
+ turn: 0,
+ maxTurns: 20,
+ scenarioName: "",
+ scenarioDescription: "",
+ entities: [],
+ log: [],
+ entityIndex: 0,
+ error: "Failed to load world state after initialization.",
+ };
+ }
+
+ // Build entity list
+ const rawEntities = Array.from(worldState.entities.values());
+ const entityInfos: EntityInfo[] = rawEntities.map((e) => ({
+ id: e.id,
+ name: (e.attributes.get("name")?.getValue() as string) || e.id,
+ isPlayer: false,
+ isAgent: e.isAgent,
+ }));
+
+ // Resolve player entity (exact match → name match → fuzzy)
+ let playerEntityId: string | undefined;
+ if (playEntityName) {
+ let matched = worldState.getEntity(playEntityName);
+ if (!matched) {
+ for (const ent of rawEntities) {
+ const nameAttr = ent.attributes.get("name")?.getValue() as
+ string | undefined;
+ if (nameAttr?.toLowerCase() === playEntityName.toLowerCase()) {
+ matched = ent;
+ break;
+ }
+ }
+ }
+ if (!matched) {
+ for (const ent of rawEntities) {
+ const nameAttr = ent.attributes.get("name")?.getValue() as
+ string | undefined;
+ if (
+ nameAttr?.toLowerCase().includes(playEntityName.toLowerCase()) ||
+ ent.id.toLowerCase().includes(playEntityName.toLowerCase())
+ ) {
+ matched = ent;
+ break;
+ }
+ }
+ }
+ if (matched) {
+ playerEntityId = matched.id;
+ const info = entityInfos.find((e) => e.id === matched!.id);
+ if (info) info.isPlayer = true;
+ }
+ }
+
+ const mappings = ProviderManager.getMappings();
+ const {
+ actorProvider,
+ validatorProvider,
+ decoderProvider,
+ timedeltaProvider,
+ handoffProvider,
+ embeddingProvider,
+ } = resolveProviders(mappings, { fallbackInstance: activeInstance });
+
+ const architect = new Architect(
+ { validator: validatorProvider, timedelta: timedeltaProvider },
+ coreRepo,
+ );
+ const aliasGenerator = new AliasDeltaGenerator(actorProvider);
+
+ const session: SimSession = {
+ db,
+ dbPath,
+ coreRepo,
+ bufferRepo,
+ ledgerRepo,
+ worldInstanceId,
+ scenarioName: scenarioJson.name,
+ scenarioDescription: scenarioJson.description || "",
+ turn: 1,
+ maxTurns: 20,
+ entities: entityInfos,
+ playerEntityId,
+ entityIndex: 0,
+ actorProvider,
+ validatorProvider,
+ decoderProvider,
+ timedeltaProvider,
+ handoffProvider,
+ embeddingProvider,
+ architect,
+ aliasGenerator,
+ log: [],
+ status: "running",
+ aliasDoneForTurn: false,
+ providerMappings: mappings,
+ };
+
+ this.sessions.set(id, session);
+ return this.snapshot(session);
+ }
+
+ async load(id: string): Promise {
+ const active = this.sessions.get(id);
+ if (active) return this.snapshot(active);
+
+ const dbPath = path.join(DATA_DIR, `${id}.db`);
+ if (!fs.existsSync(dbPath)) return null;
+
+ try {
+ const db = new Database(dbPath);
+ const state = loadSessionState(db, id);
+ if (!state) {
+ db.close();
+ return null;
+ }
+
+ const mappings = state.providerMappings || {};
+ const {
+ actorProvider,
+ validatorProvider,
+ decoderProvider,
+ timedeltaProvider,
+ handoffProvider,
+ embeddingProvider,
+ } = resolveProviders(mappings, { required: true });
+
+ const coreRepo = new SQLiteRepository(db);
+ const bufferRepo = new BufferRepository(db);
+ const ledgerRepo = new LedgerRepository(db);
+ const architect = new Architect(
+ { validator: validatorProvider, timedelta: timedeltaProvider },
+ coreRepo,
+ );
+ const aliasGenerator = new AliasDeltaGenerator(actorProvider);
+
+ const session: SimSession = {
+ db,
+ dbPath,
+ coreRepo,
+ bufferRepo,
+ ledgerRepo,
+ worldInstanceId: id,
+ scenarioName: state.scenarioName,
+ scenarioDescription: state.scenarioDescription,
+ turn: state.turn,
+ maxTurns: state.maxTurns,
+ entities: state.entities || [],
+ playerEntityId: state.playerEntityId,
+ entityIndex: state.entityIndex,
+ actorProvider,
+ validatorProvider,
+ decoderProvider,
+ timedeltaProvider,
+ handoffProvider,
+ embeddingProvider,
+ architect,
+ aliasGenerator,
+ log: state.log || [],
+ status: state.status,
+ error: state.error,
+ waitingEntity: state.waitingEntity,
+ aliasDoneForTurn: state.aliasDoneForTurn || false,
+ providerMappings: mappings,
+ };
+
+ this.sessions.set(id, session);
+ return this.snapshot(session);
+ } catch (err) {
+ console.error(`Failed to load session ${id}:`, err);
+ return null;
+ }
+ }
+
+ close(id: string): void {
+ const session = this.sessions.get(id);
+ if (session) {
+ session.db.close();
+ this.sessions.delete(id);
+ }
+ }
+
+ deleteSession(id: string): void {
+ const session = this.sessions.get(id);
+ if (session) {
+ session.db.close();
+ this.sessions.delete(id);
+ }
+ deleteSessionFile(id);
+ }
+
+ listSavedSessions(): SimSnapshot[] {
+ return listSavedSessions(this.sessions, (s) => this.snapshot(s));
+ }
+
+ getSnapshot(id: string): SimSnapshot | null {
+ const session = this.sessions.get(id);
+ return session ? this.snapshot(session) : null;
+ }
+
+ // ---------------------------------------------------------------------------
+ // Simulation stepping
+ // ---------------------------------------------------------------------------
+
+ async step(id: string): Promise {
+ const session = this.sessions.get(id);
+ if (!session) return null;
+ if (session.status !== "running") return this.snapshot(session);
+
+ try {
+ if (session.turn > session.maxTurns) {
+ session.status = "done";
+ saveSession(session);
+ return this.snapshot(session);
+ }
+
+ // Start of turn: alias + handoff resolution before any entity acts
+ if (!session.aliasDoneForTurn && session.entityIndex === 0) {
+ await runAliasResolution(session);
+ await runHandoffResolution(session);
+ session.aliasDoneForTurn = true;
+ saveSession(session);
+ return this.snapshot(session);
+ }
+
+ // End of turn: advance to next turn
+ if (session.entityIndex >= session.entities.length) {
+ session.turn++;
+ session.entityIndex = 0;
+ session.aliasDoneForTurn = false;
+ saveSession(session);
+ return this.snapshot(session);
+ }
+
+ const info = session.entities[session.entityIndex];
+
+ if (!info.isAgent) {
+ session.entityIndex++;
+ saveSession(session);
+ return this.snapshot(session);
+ }
+
+ if (info.isPlayer) {
+ await preparePlayerTurn(session, info);
+ saveSession(session);
+ return this.snapshot(session);
+ }
+
+ await processNpcTurn(session, info);
+ session.entityIndex++;
+ } catch (err) {
+ session.status = "error";
+ session.error = err instanceof Error ? err.message : String(err);
+ }
+
+ saveSession(session);
+ return this.snapshot(session);
+ }
+
+ async submitPlayerAction(
+ id: string,
+ prose: string,
+ ): Promise {
+ const session = this.sessions.get(id);
+ if (!session) return null;
+ if (session.status !== "waiting_player") return this.snapshot(session);
+ if (!session.waitingEntity) return this.snapshot(session);
+
+ const ctx = session.waitingEntity;
+ session.waitingEntity = undefined;
+ session.status = "running";
+
+ try {
+ await executePlayerAction(session, ctx, prose);
+ session.entityIndex++;
+ } catch (err) {
+ session.status = "error";
+ session.error = err instanceof Error ? err.message : String(err);
+ }
+
+ saveSession(session);
+ return this.snapshot(session);
+ }
+
+ // ---------------------------------------------------------------------------
+ // Utility
+ // ---------------------------------------------------------------------------
+
+ async regenerateAllEmbeddings(newProviderInstanceId?: string): Promise {
+ if (!fs.existsSync(DATA_DIR)) return;
+
+ const files = fs
+ .readdirSync(DATA_DIR)
+ .filter((f) => f.startsWith("sim-") && f.endsWith(".db"));
+
+ const list = ProviderManager.list();
+ let inst = newProviderInstanceId
+ ? (list.find((p) => p.id === newProviderInstanceId) ?? null)
+ : null;
+ if (!inst || inst.type !== "embedding") {
+ inst = ProviderManager.getActive("embedding");
+ }
+
+ const key = inst ? inst.apiKey : process.env.GOOGLE_API_KEY || "";
+ const providerName = inst ? inst.providerName : "google-genai";
+ const modelName = inst ? inst.modelName : undefined;
+
+ const embeddingProvider: IEmbeddingProvider =
+ providerName === "google-genai"
+ ? new GeminiEmbeddingProvider(key, modelName)
+ : new MockEmbeddingProvider(modelName);
+
+ for (const file of files) {
+ const dbPath = path.join(DATA_DIR, file);
+ const fileId = file.replace(".db", "");
+ const activeSession = this.sessions.get(fileId);
+ const db = activeSession ? activeSession.db : new Database(dbPath);
+
+ try {
+ const rows = db
+ .prepare(`SELECT id, content FROM ledger_entries`)
+ .all() as { id: string; content: string }[];
+
+ for (const row of rows) {
+ const vector = await embeddingProvider.embed(row.content);
+ const buffer = Buffer.from(new Float32Array(vector).buffer);
+ db.prepare(
+ `UPDATE ledger_entries SET embedding = ? WHERE id = ?`,
+ ).run(buffer, row.id);
+ }
+ } catch (err) {
+ console.error(`Failed to regenerate embeddings for ${file}:`, err);
+ } finally {
+ if (!activeSession) db.close();
+ }
+ }
+ }
+
+ // ---------------------------------------------------------------------------
+ // Private
+ // ---------------------------------------------------------------------------
+
+ private snapshot(session: SimSession): SimSnapshot {
+ 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,
+ };
+ }
+}
diff --git a/apps/gui/src/lib/simulation/turn-executor.ts b/apps/gui/src/lib/simulation/turn-executor.ts
new file mode 100644
index 0000000..825ffc0
--- /dev/null
+++ b/apps/gui/src/lib/simulation/turn-executor.ts
@@ -0,0 +1,277 @@
+import {
+ ActorAgent,
+ ActorPromptBuilder,
+ buildBufferEntryForIntent,
+} from "@omnia/actor";
+import type { IActorProseGenerator } from "@omnia/actor";
+import type { SimSession } from "./types";
+import type {
+ EntityInfo,
+ IntentInfo,
+ LogEntry,
+ WaitingContext,
+} from "../simulation-types";
+
+// ---------------------------------------------------------------------------
+// Internal helpers
+// ---------------------------------------------------------------------------
+
+/** Prose generator that returns a fixed player-supplied string verbatim. */
+class FixedProseGenerator implements IActorProseGenerator {
+ constructor(private prose: string) {}
+
+ async generate(
+ entityId: string,
+ systemPrompt: string,
+ userContext: string,
+ ): Promise {
+ void entityId;
+ void systemPrompt;
+ void userContext;
+ return this.prose;
+ }
+}
+
+/**
+ * Processes every intent produced by an actor turn:
+ * - Validates via Architect
+ * - Appends to actor's own buffer
+ * - Fan-outs to co-located observers for dialogue/action intents
+ *
+ * Extracted to eliminate verbatim duplication between NPC and player paths.
+ */
+async function processIntents(
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ intents: any[],
+ actorEntityId: string,
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ entity: any,
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ worldState: any,
+ session: SimSession,
+): Promise {
+ const intentInfos: IntentInfo[] = [];
+
+ for (const intent of intents) {
+ const outcome = await session.architect.processIntent(worldState, intent);
+ const ts = worldState.clock.get().toISOString();
+
+ intentInfos.push({
+ type: intent.type,
+ description: intent.description,
+ selfDescription: intent.selfDescription,
+ modifiers: intent.modifiers || [],
+ 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);
+
+ // Fan-out observable events to co-located entities
+ if (
+ entity.locationId &&
+ (intent.type === "dialogue" || intent.type === "action")
+ ) {
+ for (const [, other] of worldState.entities) {
+ if (
+ other.id !== actorEntityId &&
+ other.locationId === entity.locationId
+ ) {
+ const observerEntry = buildBufferEntryForIntent(
+ intent,
+ ts,
+ entity.locationId,
+ );
+ if (intent.type === "action") {
+ observerEntry.outcome = {
+ isValid: outcome.isValid,
+ reason: outcome.reason,
+ };
+ }
+ session.bufferRepo.save({ ...observerEntry, ownerId: other.id });
+ }
+ }
+ }
+ }
+
+ return intentInfos;
+}
+
+// ---------------------------------------------------------------------------
+// Exported turn functions
+// ---------------------------------------------------------------------------
+
+/**
+ * Builds the prompt for the player entity and sets the session to
+ * `waiting_player` so the next client call can supply the prose.
+ */
+export async function preparePlayerTurn(
+ session: SimSession,
+ info: EntityInfo,
+): Promise {
+ const worldState = session.coreRepo.loadWorldState(session.worldInstanceId);
+ if (!worldState) throw new Error("World state lost");
+
+ const entity = worldState.getEntity(info.id);
+ if (!entity) throw new Error(`Entity "${info.id}" not found`);
+
+ const promptBuilder = new ActorPromptBuilder(
+ session.bufferRepo,
+ session.ledgerRepo,
+ 20,
+ );
+ const { systemPrompt, userContext } = promptBuilder.build(worldState, entity);
+
+ session.waitingEntity = {
+ entityId: info.id,
+ name: info.name,
+ systemPrompt,
+ userContext,
+ };
+ session.status = "waiting_player";
+}
+
+/**
+ * Runs an autonomous NPC turn: generates prose via ActorAgent, validates
+ * and persists all intents, and appends a LogEntry to the session.
+ */
+export async function processNpcTurn(
+ session: SimSession,
+ info: EntityInfo,
+): Promise {
+ const worldState = session.coreRepo.loadWorldState(session.worldInstanceId);
+ if (!worldState) throw new Error("World state lost");
+
+ const entity = worldState.getEntity(info.id);
+ if (!entity) throw new Error(`Entity "${info.id}" not found`);
+
+ const actor = new ActorAgent(
+ { actor: session.actorProvider, decoder: session.decoderProvider },
+ session.bufferRepo,
+ session.ledgerRepo,
+ 20,
+ );
+ const result = await actor.act(worldState, entity);
+
+ const entry: LogEntry = {
+ turn: session.turn,
+ entityId: info.id,
+ entityName: info.name,
+ narrativeProse: result.narrativeProse,
+ intents: [],
+ timestamp: worldState.clock.get().toISOString(),
+ };
+
+ if (
+ session.actorProvider.lastCalls &&
+ session.actorProvider.lastCalls.length > 0
+ ) {
+ const actorCall =
+ session.actorProvider.lastCalls[
+ session.actorProvider.lastCalls.length - 1
+ ];
+ entry.rawPrompt = {
+ systemPrompt: actorCall.systemPrompt,
+ userContext: actorCall.userContext,
+ };
+ entry.usage = actorCall.usage;
+ }
+
+ if (
+ session.decoderProvider.lastCalls &&
+ session.decoderProvider.lastCalls.length > 0
+ ) {
+ const decoderCall =
+ session.decoderProvider.lastCalls[
+ session.decoderProvider.lastCalls.length - 1
+ ];
+ entry.decoderPrompt = {
+ systemPrompt: decoderCall.systemPrompt,
+ userContext: decoderCall.userContext,
+ };
+ entry.decoderUsage = decoderCall.usage;
+ }
+
+ entry.intents = await processIntents(
+ result.intents.intents,
+ info.id,
+ entity,
+ worldState,
+ session,
+ );
+
+ session.log.push(entry);
+ session.coreRepo.saveWorldState(worldState);
+}
+
+/**
+ * Executes the player's turn using the prose they supplied.
+ * Uses a `FixedProseGenerator` so the ActorAgent bypasses its LLM call and
+ * returns the player's text directly.
+ */
+export async function executePlayerAction(
+ session: SimSession,
+ ctx: WaitingContext,
+ prose: string,
+): Promise {
+ const worldState = session.coreRepo.loadWorldState(session.worldInstanceId);
+ if (!worldState) throw new Error("World state lost");
+
+ const entity = worldState.getEntity(ctx.entityId);
+ if (!entity) throw new Error(`Player entity "${ctx.entityId}" not found`);
+
+ const playerActor = new ActorAgent(
+ { actor: session.actorProvider, decoder: session.decoderProvider },
+ session.bufferRepo,
+ session.ledgerRepo,
+ 20,
+ new FixedProseGenerator(prose),
+ );
+
+ const result = await playerActor.act(worldState, entity);
+
+ const entry: LogEntry = {
+ turn: session.turn,
+ entityId: ctx.entityId,
+ entityName: ctx.name,
+ narrativeProse: result.narrativeProse,
+ intents: [],
+ timestamp: worldState.clock.get().toISOString(),
+ rawPrompt: {
+ systemPrompt: ctx.systemPrompt,
+ userContext: ctx.userContext,
+ },
+ };
+
+ if (
+ session.decoderProvider.lastCalls &&
+ session.decoderProvider.lastCalls.length > 0
+ ) {
+ const call =
+ session.decoderProvider.lastCalls[
+ session.decoderProvider.lastCalls.length - 1
+ ];
+ entry.decoderPrompt = {
+ systemPrompt: call.systemPrompt,
+ userContext: call.userContext,
+ };
+ entry.decoderUsage = call.usage;
+ }
+
+ entry.intents = await processIntents(
+ result.intents.intents,
+ ctx.entityId,
+ entity,
+ worldState,
+ session,
+ );
+
+ session.log.push(entry);
+ session.coreRepo.saveWorldState(worldState);
+}
diff --git a/apps/gui/src/lib/simulation/types.ts b/apps/gui/src/lib/simulation/types.ts
new file mode 100644
index 0000000..d99b115
--- /dev/null
+++ b/apps/gui/src/lib/simulation/types.ts
@@ -0,0 +1,68 @@
+import type Database from "better-sqlite3";
+import type { SQLiteRepository } from "@omnia/core";
+import type { BufferRepository, LedgerRepository } from "@omnia/memory";
+import type { Architect, AliasDeltaGenerator } from "@omnia/architect";
+import type { ILLMProvider, IEmbeddingProvider } from "@omnia/llm";
+import type { EntityInfo, LogEntry, WaitingContext } from "../simulation-types";
+
+export type {
+ EntityInfo,
+ IntentInfo,
+ LogEntry,
+ SimSnapshot,
+ WaitingContext,
+} from "../simulation-types";
+
+// ---------------------------------------------------------------------------
+// Persisted state (written to sqlite gui_meta table as JSON)
+// ---------------------------------------------------------------------------
+
+export interface SavedState {
+ scenarioName: string;
+ scenarioDescription: string;
+ turn: number;
+ maxTurns: number;
+ entities: EntityInfo[];
+ playerEntityId: string | undefined;
+ entityIndex: number;
+ status: "running" | "waiting_player" | "done" | "error";
+ error?: string;
+ waitingEntity?: WaitingContext;
+ aliasDoneForTurn: boolean;
+ log: LogEntry[];
+ providerMappings: Record;
+}
+
+// ---------------------------------------------------------------------------
+// In-memory session (held in SimulationManager.sessions Map)
+// ---------------------------------------------------------------------------
+
+export interface SimSession {
+ db: Database.Database;
+ dbPath: string;
+ coreRepo: SQLiteRepository;
+ bufferRepo: BufferRepository;
+ ledgerRepo: LedgerRepository;
+ worldInstanceId: string;
+ scenarioName: string;
+ scenarioDescription: string;
+ turn: number;
+ maxTurns: number;
+ entities: EntityInfo[];
+ playerEntityId: string | undefined;
+ entityIndex: number;
+ actorProvider: ILLMProvider;
+ validatorProvider: ILLMProvider;
+ decoderProvider: ILLMProvider;
+ timedeltaProvider: ILLMProvider;
+ handoffProvider: ILLMProvider;
+ embeddingProvider: IEmbeddingProvider;
+ architect: Architect;
+ aliasGenerator: AliasDeltaGenerator;
+ log: LogEntry[];
+ status: "running" | "waiting_player" | "done" | "error";
+ error?: string;
+ waitingEntity?: WaitingContext;
+ aliasDoneForTurn: boolean;
+ providerMappings: Record;
+}