From 906ef2cdf3f035d00ece6d59d0d3b96451e6feb4 Mon Sep 17 00:00:00 2001 From: Aditya Gupta Date: Thu, 16 Jul 2026 08:43:07 +0530 Subject: [PATCH] refactor!(gui): Refactor simulation.ts --- apps/gui/next-env.d.ts | 2 +- apps/gui/src/lib/simulation.ts | 1071 ----------------- apps/gui/src/lib/simulation/alias-handoff.ts | 71 ++ apps/gui/src/lib/simulation/env.ts | 16 + apps/gui/src/lib/simulation/index.ts | 17 + .../src/lib/simulation/provider-resolver.ts | 175 +++ apps/gui/src/lib/simulation/session-store.ts | 139 +++ .../src/lib/simulation/simulation-manager.ts | 455 +++++++ apps/gui/src/lib/simulation/turn-executor.ts | 277 +++++ apps/gui/src/lib/simulation/types.ts | 68 ++ 10 files changed, 1219 insertions(+), 1072 deletions(-) delete mode 100644 apps/gui/src/lib/simulation.ts create mode 100644 apps/gui/src/lib/simulation/alias-handoff.ts create mode 100644 apps/gui/src/lib/simulation/env.ts create mode 100644 apps/gui/src/lib/simulation/index.ts create mode 100644 apps/gui/src/lib/simulation/provider-resolver.ts create mode 100644 apps/gui/src/lib/simulation/session-store.ts create mode 100644 apps/gui/src/lib/simulation/simulation-manager.ts create mode 100644 apps/gui/src/lib/simulation/turn-executor.ts create mode 100644 apps/gui/src/lib/simulation/types.ts 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; +}