diff --git a/packages/architect/src/architect.ts b/packages/architect/src/architect.ts index dd5f4bc..ed2aae3 100644 --- a/packages/architect/src/architect.ts +++ b/packages/architect/src/architect.ts @@ -1,12 +1,19 @@ -import { WorldState } from "@omnia/core"; +import { WorldState, SQLiteRepository } from "@omnia/core"; import { LLMValidator, ValidationResult } from "./llm-validator.js"; +import { TimeDeltaGenerator, TimeDelta } from "./delta.js"; import { ILLMProvider } from "@omnia/llm"; +export interface ProcessResult extends ValidationResult { + timeDelta?: TimeDelta; +} + export class Architect { private validator: LLMValidator; + private timeDeltaGenerator: TimeDeltaGenerator; - constructor(llmProvider: ILLMProvider) { + constructor(llmProvider: ILLMProvider, private repo?: SQLiteRepository) { this.validator = new LLMValidator(llmProvider); + this.timeDeltaGenerator = new TimeDeltaGenerator(llmProvider); } /** @@ -20,4 +27,41 @@ export class Architect { ): Promise { return this.validator.validate(worldState, actorId, actionIntent); } + + /** + * Processes, validates, generates deltas, applies them to the world state, + * and persists the changes to the database. + */ + async processIntent( + worldState: WorldState, + actorId: string, + actionIntent: string, + ): Promise { + // 1. Validate the intent action + const validation = await this.validateIntent(worldState, actorId, actionIntent); + if (!validation.isValid) { + return validation; + } + + // 2. Generate time delta for the valid action + const timeDelta = await this.timeDeltaGenerator.generate( + worldState, + actorId, + actionIntent, + ); + + // 3. Apply the time delta to the world state clock + worldState.clock.advance(timeDelta.minutesToAdvance); + + // 4. Save and persist the updated world state + if (this.repo) { + this.repo.saveWorldState(worldState); + } + + return { + isValid: true, + reason: validation.reason, + timeDelta, + }; + } } diff --git a/packages/architect/src/delta.ts b/packages/architect/src/delta.ts index e69de29..30a1ac2 100644 --- a/packages/architect/src/delta.ts +++ b/packages/architect/src/delta.ts @@ -0,0 +1,63 @@ +import { z } from "zod"; +import { WorldState } from "@omnia/core"; +import { ILLMProvider } from "@omnia/llm"; + +export const TimeDeltaSchema = z.object({ + minutesToAdvance: z.number().int().nonnegative(), + explanation: z.string(), +}); + +export type TimeDelta = z.infer; + +export interface IDeltaGenerator { + generate( + worldState: WorldState, + actorId: string, + actionIntent: string, + ): Promise; +} + +export class TimeDeltaGenerator implements IDeltaGenerator { + constructor(private llmProvider: ILLMProvider) {} + + async generate( + worldState: WorldState, + actorId: string, + actionIntent: string, + ): Promise { + const systemPrompt = ` +You are the Time Delta Generator for the World Architect. +Your task is to judge how much time (in minutes) a proposed action would logically take to execute in the physical world. +Enforce realistic physical constraints: +- Simple actions (e.g. picking up an unlocked key, looking around) take 1-2 minutes. +- Walking between rooms takes 2-5 minutes. +- Complex tasks (e.g. searching a room thoroughly, reading a book chapter, picking a lock) take 15-60 minutes. +Return a structured JSON object containing: +- "minutesToAdvance": integer (0 or more) representing the time elapsed. +- "explanation": a brief explanation of why this amount of time is appropriate. +`.trim(); + + const userContext = ` +=== CURRENT WORLD STATE === +Current Time: ${worldState.clock.get().toISOString()} +World Details: +${worldState.serialize()} + +=== ACTION === +Actor ID: ${actorId} +Action: "${actionIntent}" +`.trim(); + + const response = await this.llmProvider.generateStructuredResponse({ + systemPrompt, + userContext, + schema: TimeDeltaSchema, + }); + + if (!response.success || !response.data) { + throw new Error(`Failed to generate time delta: ${response.error || "Unknown LLM error"}`); + } + + return response.data; + } +} diff --git a/packages/architect/src/index.ts b/packages/architect/src/index.ts index 638c0e0..19e95a8 100644 --- a/packages/architect/src/index.ts +++ b/packages/architect/src/index.ts @@ -1,2 +1,3 @@ export * from "./llm-validator.js"; export * from "./architect.js"; +export * from "./delta.js"; diff --git a/packages/architect/src/llm-validator.ts b/packages/architect/src/llm-validator.ts index 48a95f6..a6e2670 100644 --- a/packages/architect/src/llm-validator.ts +++ b/packages/architect/src/llm-validator.ts @@ -39,6 +39,7 @@ Exempt dialogue or speech actions from validation (consider them always valid). Enforce logical boundaries such as: - Spatial boundaries (an actor cannot grab an object in another location unless they are there). - Physical boundaries (an actor cannot open a locked drawer without a key or breaking it). +- State Boundaries (an actor cannot perform a task if their state doesn't allow them to do so). - State/Attribute constraints. You must respond with a JSON object containing: diff --git a/packages/architect/tests/architect.test.ts b/packages/architect/tests/architect.test.ts index 98192e0..9cc5708 100644 --- a/packages/architect/tests/architect.test.ts +++ b/packages/architect/tests/architect.test.ts @@ -1,6 +1,6 @@ import { describe, test, expect } from "vitest"; -import { WorldState } from "@omnia/core"; -import { Entity } from "@omnia/core"; +import Database from "better-sqlite3"; +import { WorldState, Entity, SQLiteRepository } from "@omnia/core"; import { MockLLMProvider } from "@omnia/llm"; import { Architect } from "@omnia/architect"; @@ -71,3 +71,83 @@ describe("Architect & LLMValidator Unit Tests (Tier 1)", () => { ); }); }); + +describe("TimeDeltaGenerator & Architect.processIntent Unit Tests (Tier 1)", () => { + test("processIntent advances clock and saves state for a valid intent", async () => { + const db = new Database(":memory:"); + const repo = new SQLiteRepository(db); + + const world = new WorldState("world-xyz", new Date("2026-07-06T12:00:00.000Z")); + const alice = new Entity("alice"); + world.addEntity(alice); + + // Initial save so it exists in db + repo.saveWorldState(world); + + // Setup mock LLM responses: + // First call: validateIntent (ValidationResult) + // Second call: TimeDeltaGenerator (TimeDelta) + const mockValidation = { isValid: true, reason: "Alice has the lockpick kit and skill." }; + const mockTimeDelta = { minutesToAdvance: 20, explanation: "Picking a lock takes time." }; + const llmProvider = new MockLLMProvider([mockValidation, mockTimeDelta]); + + const architect = new Architect(llmProvider, repo); + + const result = await architect.processIntent( + world, + "alice", + "pick the lock of the wooden chest", + ); + + // Assert results + expect(result.isValid).toBe(true); + expect(result.reason).toBe("Alice has the lockpick kit and skill."); + expect(result.timeDelta).toBeDefined(); + expect(result.timeDelta!.minutesToAdvance).toBe(20); + + // Verify clock was advanced locally + const expectedTime = new Date(new Date("2026-07-06T12:00:00.000Z").getTime() + 20 * 60_000); + expect(world.clock.get().toISOString()).toBe(expectedTime.toISOString()); + + // Verify it was persisted to the database + const loaded = repo.loadWorldState("world-xyz")!; + expect(loaded.clock.get().toISOString()).toBe(expectedTime.toISOString()); + + db.close(); + }); + + test("processIntent does not advance clock or save state for an invalid intent", async () => { + const db = new Database(":memory:"); + const repo = new SQLiteRepository(db); + + const startTime = new Date("2026-07-06T12:00:00.000Z"); + const world = new WorldState("world-xyz", startTime); + const bob = new Entity("bob"); + world.addEntity(bob); + repo.saveWorldState(world); + + const mockValidation = { isValid: false, reason: "Bob is bound by chains." }; + const llmProvider = new MockLLMProvider([mockValidation]); // TimeDeltaGenerator shouldn't be called + + const architect = new Architect(llmProvider, repo); + + const result = await architect.processIntent( + world, + "bob", + "run away", + ); + + expect(result.isValid).toBe(false); + expect(result.reason).toBe("Bob is bound by chains."); + expect(result.timeDelta).toBeUndefined(); + + // Verify clock did not advance + expect(world.clock.get().toISOString()).toBe(startTime.toISOString()); + + // Verify database value remained unchanged + const loaded = repo.loadWorldState("world-xyz")!; + expect(loaded.clock.get().toISOString()).toBe(startTime.toISOString()); + + db.close(); + }); +}); diff --git a/packages/core/src/entity.ts b/packages/core/src/entity.ts index 3ea7eee..d593ba6 100644 --- a/packages/core/src/entity.ts +++ b/packages/core/src/entity.ts @@ -1,7 +1,22 @@ import { AttributableObject } from "./attribute.js"; export class Entity extends AttributableObject { - constructor(id?: string) { + locationId: string | null = null; + + constructor(id?: string, locationId?: string | null) { super(id); + this.locationId = locationId ?? null; + } + + override serialize(): string { + const lines: string[] = []; + if (this.locationId) { + lines.push(`* Location ID: ${this.locationId}`); + } + const selfSerialized = super.serialize(); + if (selfSerialized) { + lines.push(selfSerialized); + } + return lines.join("\n"); } } diff --git a/packages/core/src/repository.ts b/packages/core/src/repository.ts index 2b91430..abf54aa 100644 --- a/packages/core/src/repository.ts +++ b/packages/core/src/repository.ts @@ -21,6 +21,7 @@ export class SQLiteRepository { type TEXT NOT NULL, world_id TEXT, clock_iso TEXT, + location_id TEXT, FOREIGN KEY (world_id) REFERENCES objects(id) ON DELETE CASCADE ); @@ -48,6 +49,13 @@ export class SQLiteRepository { } catch { // Column already exists, ignore error } + + // Safely add location_id column if it does not exist in an existing database + try { + this.db.exec("ALTER TABLE objects ADD COLUMN location_id TEXT;"); + } catch { + // Column already exists, ignore error + } } save(obj: AttributableObject, type: string, worldId?: string): void { @@ -57,19 +65,25 @@ export class SQLiteRepository { clockIso = obj.clock.get().toISOString(); } + let locationId: string | null = null; + if (obj instanceof Entity) { + locationId = obj.locationId; + } + // 1. Insert or ignore the object in the objects table this.db .prepare( ` - INSERT INTO objects (id, type, world_id, clock_iso) - VALUES (?, ?, ?, ?) + INSERT INTO objects (id, type, world_id, clock_iso, location_id) + VALUES (?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET type = excluded.type, world_id = excluded.world_id, - clock_iso = excluded.clock_iso + clock_iso = excluded.clock_iso, + location_id = excluded.location_id `, ) - .run(obj.id, type, worldId || null, clockIso); + .run(obj.id, type, worldId || null, clockIso, locationId); // Get current attributes from db to delete the ones that are no longer present const existingAttrs = this.db @@ -155,16 +169,16 @@ export class SQLiteRepository { const objRow = this.db .prepare( ` - SELECT type FROM objects WHERE id = ? + SELECT type, location_id FROM objects WHERE id = ? `, ) - .get(id) as { type: string } | undefined; + .get(id) as { type: string; location_id: string | null } | undefined; if (!objRow || objRow.type !== "entity") { return null; } - const entity = new Entity(id); + const entity = new Entity(id, objRow.location_id); this.reconstituteAttributes(entity); return entity; } @@ -190,13 +204,13 @@ export class SQLiteRepository { const entityRows = this.db .prepare( ` - SELECT id FROM objects WHERE type = 'entity' AND world_id = ? + SELECT id, location_id FROM objects WHERE type = 'entity' AND world_id = ? `, ) - .all(id) as { id: string }[]; + .all(id) as { id: string; location_id: string | null }[]; for (const row of entityRows) { - const entity = new Entity(row.id); + const entity = new Entity(row.id, row.location_id); this.reconstituteAttributes(entity); worldState.addEntity(entity); } diff --git a/packages/core/tests/core.test.ts b/packages/core/tests/core.test.ts index aa738d0..2ad80f2 100644 --- a/packages/core/tests/core.test.ts +++ b/packages/core/tests/core.test.ts @@ -126,7 +126,7 @@ describe("SQLiteRepository Unit Tests (Tier 1)", () => { world.addAttribute("location", "Outer Rim", AttributeVisibility.PUBLIC); // 2. Setup Entities and add attributes with ACL grants - const alice = new Entity("alice"); + const alice = new Entity("alice", "location-a"); alice.addAttribute("name", "Alice Smith", AttributeVisibility.PUBLIC); // Secret attribute visible only to 'bob' alice.addAttribute("diaries", "Private thoughts", AttributeVisibility.PRIVATE, new Set(["bob"])); @@ -156,6 +156,8 @@ describe("SQLiteRepository Unit Tests (Tier 1)", () => { expect(loadedBob).toBeDefined(); // 7. Assert entity attributes and access controls are preserved + expect(loadedAlice!.locationId).toBe("location-a"); + expect(loadedBob!.locationId).toBeNull(); expect(loadedAlice!.attributes.get("name")?.getValue()).toBe("Alice Smith"); expect(loadedAlice!.attributes.get("name")?.visibility).toBe(AttributeVisibility.PUBLIC); @@ -184,7 +186,7 @@ describe("Serializer Unit Tests (Tier 1)", () => { const world = new WorldState("world-1"); world.addAttribute("gravity", "9.8", AttributeVisibility.PUBLIC); - const alice = new Entity("alice"); + const alice = new Entity("alice", "location-a"); alice.addAttribute("role", "warrior", AttributeVisibility.PUBLIC); world.addEntity(alice); @@ -196,6 +198,7 @@ describe("Serializer Unit Tests (Tier 1)", () => { expect(result).toContain(" * gravity: 9.8 (Visibility: PUBLIC)"); expect(result).toContain("Entities:"); expect(result).toContain(" - Entity [ID: alice]:"); + expect(result).toContain(" * Location ID: location-a"); expect(result).toContain(" * role: warrior (Visibility: PUBLIC)"); expect(result).toContain(" - Entity [ID: bob]:"); expect(result).toContain(" * (No attributes)"); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9b63c1a..cd27dec 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -331,7 +331,11 @@ importers: packages/memory: {} - packages/spatial: {} + packages/spatial: + dependencies: + '@omnia/core': + specifier: workspace:* + version: link:../core web/landing: devDependencies: