diff --git a/Implementation_plan.md b/Implementation_plan.md index 036bc90..b6b84de 100644 --- a/Implementation_plan.md +++ b/Implementation_plan.md @@ -30,6 +30,15 @@ Status: COMPLETE - Interpreter contract and first interpreter module created - Docker container builds are passing for app and frontend +## Phase 0 Structural Cleanup (April 2026) — COMPLETE + +- Deleted turns/processTurn.ts (was a pointless shim over runTurnManager) +- Deleted truthEngine.ts (was a thin wrapper over rulebookEngine.validateWithRulebook) +- Extracted world/seedWorld.ts — seed world logic out of app.ts; app factory is now lean +- Fixed db.listTurns() to return real actions and validation results (previously always empty arrays) +- Fixed worldState.rulebookId persistence — active rulebook now survives restarts +- Generalized has_ in applyActions — no longer hardcoded to key_1 only + --- # Phase 1 — Intent Interpreter Boundary Hardening diff --git a/charactergarden/app/src/app.ts b/charactergarden/app/src/app.ts index ee10c36..9e1ac13 100644 --- a/charactergarden/app/src/app.ts +++ b/charactergarden/app/src/app.ts @@ -1,12 +1,10 @@ -import { randomUUID } from "node:crypto"; - import { createDatabase, CharacterGardenDatabase } from "./db"; -import type { Entity } from "./contracts/entity"; import type { SceneRulebook } from "./contracts/rulebook"; import type { Turn } from "./contracts/turn"; import type { WorldState } from "./contracts/world"; import { createDefaultRulebook, DEFAULT_RULEBOOK_ID } from "./defaultRulebook"; -import { processTurn, ProcessTurnResponse } from "./turns/processTurn"; +import { runTurnManager, TurnManagerResponse } from "./turns/turnManager"; +import { ensureSeedState } from "./world/seedWorld"; export interface AppSnapshot { worldState: WorldState; @@ -16,170 +14,13 @@ export interface AppSnapshot { export interface CharacterGardenApp { db: CharacterGardenDatabase; getSnapshot(): AppSnapshot; - processTurn(rawText: string): Promise; + processTurn(rawText: string): Promise; getRulebook(): SceneRulebook; upsertRulebook(rulebook: SceneRulebook): SceneRulebook; listRulebooks(): SceneRulebook[]; reset(): AppSnapshot; } -function createSeedWorldState(): WorldState { - const now = Date.now(); - - const entities: Record = { - room_offstage: { - id: "room_offstage", - name: "Offstage", - type: "room", - attributes: { - description: "A holding area for characters not currently in the active scene.", - is_joinable: false, - }, - }, - room_start: { - id: "room_start", - name: "Start Room", - type: "room", - attributes: { - description: "A plain room with a locked door.", - is_joinable: true, - }, - }, - room_exit: { - id: "room_exit", - name: "Exit Room", - type: "room", - attributes: { - description: "A simple room beyond the door.", - is_joinable: true, - }, - }, - player: { - id: "player", - name: "Player", - type: "character", - attributes: { - location: "room_start", - has_key_1: false, - }, - }, - groundskeeper: { - id: "groundskeeper", - name: "Groundskeeper", - type: "character", - attributes: { - location: "room_offstage", - is_social: true, - in_scene: false, - }, - }, - door_1: { - id: "door_1", - name: "Old Door", - type: "door", - attributes: { - location: "room_start", - openable: true, - locked: true, - requiredKey: "key_1", - open: false, - }, - }, - key_1: { - id: "key_1", - name: "Brass Key", - type: "item", - attributes: { - location: "room_start", - takeable: true, - }, - }, - }; - - return { - id: randomUUID(), - entities, - metadata: { - domain: "door_key_mvp", - version: 1, - }, - createdAt: now, - }; -} - -function mergeSeedWorldState(worldState: WorldState): { worldState: WorldState; changed: boolean } { - const seed = createSeedWorldState(); - const mergedEntities: Record = {}; - let changed = false; - - for (const [entityId, seedEntity] of Object.entries(seed.entities)) { - const existingEntity = worldState.entities[entityId]; - if (!existingEntity) { - mergedEntities[entityId] = seedEntity; - changed = true; - continue; - } - - const mergedAttributes = { - ...seedEntity.attributes, - ...existingEntity.attributes, - }; - - if (JSON.stringify(existingEntity.attributes) !== JSON.stringify(mergedAttributes)) { - changed = true; - } - - mergedEntities[entityId] = { - ...existingEntity, - attributes: mergedAttributes, - }; - } - - for (const [entityId, existingEntity] of Object.entries(worldState.entities)) { - if (!mergedEntities[entityId]) { - mergedEntities[entityId] = existingEntity; - } - } - - const mergedWorldState: WorldState = { - ...worldState, - entities: mergedEntities, - metadata: { - ...seed.metadata, - ...worldState.metadata, - }, - }; - - if (changed) { - mergedWorldState.id = randomUUID(); - mergedWorldState.createdAt = Date.now(); - } - - return { - worldState: mergedWorldState, - changed, - }; -} - -function ensureSeedState(db: CharacterGardenDatabase): WorldState { - db.init(); - - const latest = db.getLatestWorldState(); - if (latest) { - const merged = mergeSeedWorldState(latest); - db.upsertEntities(Object.values(merged.worldState.entities)); - if (merged.changed) { - db.insertWorldState(null, merged.worldState); - } - return merged.worldState; - } - - const seed = createSeedWorldState(); - db.upsertEntities(Object.values(seed.entities)); - db.insertWorldState(null, seed); - return seed; -} - function ensureDefaultRulebook( db: CharacterGardenDatabase, worldState: WorldState @@ -221,7 +62,7 @@ export function createCharacterGardenApp(dbPath: string): CharacterGardenApp { async processTurn(rawText: string) { const rulebook = loadActiveRulebook(); - const result = await processTurn(rawText, worldState, db, rulebook); + const result = await runTurnManager(rawText, worldState, db, rulebook); worldState = result.worldState; return result; }, @@ -238,6 +79,9 @@ export function createCharacterGardenApp(dbPath: string): CharacterGardenApp { }; db.upsertRulebook(updated); activeRulebookId = updated.id; + // Persist the active rulebook ID on the world state so it survives restarts. + worldState = { ...worldState, rulebookId: updated.id }; + db.insertWorldState(null, worldState); return updated; }, diff --git a/charactergarden/app/src/contracts/rulebook.ts b/charactergarden/app/src/contracts/rulebook.ts index 0da18fd..42beb3c 100644 --- a/charactergarden/app/src/contracts/rulebook.ts +++ b/charactergarden/app/src/contracts/rulebook.ts @@ -3,7 +3,7 @@ * * Rules are stored per-scene in the database and evaluated by rulebookEngine.ts. * The default set is seeded from defaultRulebook.ts and mirrors the original - * hardcoded logic in truthEngine.ts. + * hardcoded logic previously in the truth engine as editable, data-driven rules. */ /** Which entity in the action context a condition refers to. */ diff --git a/charactergarden/app/src/db.ts b/charactergarden/app/src/db.ts index a7bcfdc..88ec1a8 100644 --- a/charactergarden/app/src/db.ts +++ b/charactergarden/app/src/db.ts @@ -155,6 +155,18 @@ export function createDatabase(config: DatabaseConfig): CharacterGardenDatabase ORDER BY created_at ASC `); + const listAllActionsStatement = sqlite.prepare(` + SELECT turn_id, action_index, actor_id, type, target_id, location_id, metadata_json + FROM actions + ORDER BY turn_id, action_index ASC + `); + + const listAllValidationStatement = sqlite.prepare(` + SELECT turn_id, action_index, success, reason, message + FROM validation_results + ORDER BY turn_id, action_index ASC + `); + const listInterpreterEventsStatement = sqlite.prepare(` SELECT turn_id, interpreter_json FROM interpreter_events @@ -322,11 +334,52 @@ export function createDatabase(config: DatabaseConfig): CharacterGardenDatabase interpreterByTurnId.set(row.turn_id, parseJson(row.interpreter_json)); } + const actionRows = listAllActionsStatement.all() as Array<{ + turn_id: string; + action_index: number; + actor_id: string; + type: string; + target_id: string | null; + location_id: string | null; + metadata_json: string | null; + }>; + const actionsByTurnId = new Map(); + for (const row of actionRows) { + const list = actionsByTurnId.get(row.turn_id) ?? []; + list.push({ + actorId: row.actor_id, + type: row.type, + ...(row.target_id != null ? { targetId: row.target_id } : {}), + ...(row.location_id != null ? { locationId: row.location_id } : {}), + ...(row.metadata_json != null ? { metadata: parseJson>(row.metadata_json) } : {}), + }); + actionsByTurnId.set(row.turn_id, list); + } + + const validationRows = listAllValidationStatement.all() as Array<{ + turn_id: string; + action_index: number; + success: number; + reason: string | null; + message: string | null; + }>; + const validationByTurnId = new Map(); + for (const row of validationRows) { + const list = validationByTurnId.get(row.turn_id) ?? []; + list.push({ + actionIndex: row.action_index, + success: row.success === 1, + ...(row.reason != null ? { reason: row.reason } : {}), + ...(row.message != null ? { message: row.message } : {}), + }); + validationByTurnId.set(row.turn_id, list); + } + return rows.map((row) => ({ id: row.id, rawText: row.raw_text, - actions: [], - validation: [], + actions: actionsByTurnId.get(row.id) ?? [], + validation: validationByTurnId.get(row.id) ?? [], createdAt: row.created_at, interpreter: interpreterByTurnId.get(row.id), })); diff --git a/charactergarden/app/src/defaultRulebook.ts b/charactergarden/app/src/defaultRulebook.ts index 71b924e..54fdff4 100644 --- a/charactergarden/app/src/defaultRulebook.ts +++ b/charactergarden/app/src/defaultRulebook.ts @@ -4,7 +4,7 @@ export const DEFAULT_RULEBOOK_ID = "rulebook_default"; /** * Builds the default SceneRulebook, encoding all validation logic that was - * previously hardcoded in truthEngine.ts as editable, data-driven rules. + * previously hardcoded in the truth engine as editable, data-driven rules. */ export function createDefaultRulebook(worldId: string): SceneRulebook { const now = Date.now(); diff --git a/charactergarden/app/src/interpreter/adapters/llmResolver.ts b/charactergarden/app/src/interpreter/adapters/llmResolver.ts index 2e40349..78392d6 100644 --- a/charactergarden/app/src/interpreter/adapters/llmResolver.ts +++ b/charactergarden/app/src/interpreter/adapters/llmResolver.ts @@ -1,5 +1,6 @@ import type { Action } from "../../contracts/action"; import type { InterpreterOutput } from "../../contracts/intent"; +import type { WorldState } from "../../contracts/world"; import type { ResolveIntentInput } from "../resolveIntent"; export const LLM_INTERPRETER_VERSION = "llm-v1-ollama"; @@ -140,20 +141,98 @@ function toReasonCode(value: string | undefined): } } +/** + * Build a compact world-context block so the model can resolve entity references + * (e.g. "the key" → targetId: "key_1") without inventing IDs. + * + * Includes: + * - The actor's current room + * - Every entity visible in that room (items, doors, characters) + * - Items in the actor's inventory + * - All room entities (needed to populate locationId for move actions) + */ +function buildWorldContext(worldState: WorldState, actorId: string): string { + const actor = worldState.entities[actorId]; + const actorLocation = actor ? String(actor.attributes.location ?? "") : ""; + + const lines: string[] = []; + + if (actorLocation) { + const room = worldState.entities[actorLocation]; + lines.push(`actor_location_id: ${actorLocation}${room ? ` (${room.name})` : ""}`); + } + + for (const entity of Object.values(worldState.entities)) { + if (entity.id === actorId) continue; + + const loc = String(entity.attributes.location ?? ""); + const inRoom = loc === actorLocation; + const inActorInventory = loc === `inventory:${actorId}`; + const isRoom = entity.type === "room"; + + if (inRoom || inActorInventory || isRoom) { + const context = inActorInventory + ? "actor_inventory" + : isRoom + ? "room" + : "in_actor_room"; + // Emit only fields the model needs to build actions. + const extras: string[] = []; + if (entity.attributes.locked === true) extras.push("locked"); + if (entity.attributes.open === true) extras.push("open"); + if (entity.attributes.takeable === true) extras.push("takeable"); + if (entity.attributes.openable === true) extras.push("openable"); + const extrasStr = extras.length ? ` [${extras.join(",")}]` : ""; + lines.push( + `entity id=${entity.id} name=${JSON.stringify(entity.name)} type=${entity.type} context=${context}${extrasStr}` + ); + } + } + + return lines.length ? lines.join("\n") : "(no world context available)"; +} + function buildPrompt(input: ResolveIntentInput): { system: string; user: string } { const system = [ "You are an intent-to-actions resolver for a text adventure engine.", - "Return ONLY JSON with this shape:", - '{"status":"resolved|needs_clarification|rejected","selectedActions":[{"type":"inspect|move|take|open|introduce|describe|transfer","targetId":"optional","locationId":"optional","metadata":{"optional":"object"}}],"selectedConfidence":0.0,"clarification":{"reasonCode":"UNRECOGNIZED_INTENT|AMBIGUOUS_REFERENCE|EMPTY_INPUT|LOW_CONFIDENCE|INTERNAL_INVALID_OUTPUT","question":"string","field":"verb|target|item|recipient|location"},"rationale":"brief"}', - "If unresolved, selectedActions must be an empty array and clarification must be present.", - "Use canonical action types only. Do not invent fields.", - ].join(" "); + "You will receive the current world state and a player command.", + "Return ONLY a JSON object with this exact shape (no markdown, no prose):", + JSON.stringify({ + status: "resolved|needs_clarification|rejected", + selectedActions: [ + { + type: "inspect|move|open|take|introduce|describe|transfer", + targetId: "entity id from world context, or omit", + locationId: "room id for move, or omit", + metadata: { note: "omit if unused" }, + }, + ], + selectedConfidence: 0.0, + clarification: { + reasonCode: + "UNRECOGNIZED_INTENT|AMBIGUOUS_REFERENCE|EMPTY_INPUT|LOW_CONFIDENCE|INTERNAL_INVALID_OUTPUT", + question: "question to ask player", + field: "verb|target|item|recipient|location", + }, + rationale: "one sentence", + }), + "Rules:", + "- Use entity ids from the world context for targetId and locationId. Never invent ids.", + "- If status is resolved, selectedActions must be non-empty and clarification must be omitted.", + "- If status is not resolved, selectedActions must be empty and clarification must be present.", + "- Use only the canonical action types listed above.", + ].join("\n"); + + const worldContext = input.worldState + ? buildWorldContext(input.worldState, input.actorId) + : "(no world context provided)"; const user = [ `actorId: ${input.actorId}`, - `input: ${JSON.stringify(input.rawText)}`, `minimum_confidence: ${input.minConfidence}`, - ].join("\n"); + `world_context:\n${worldContext}`, + `player_input: ${JSON.stringify(input.rawText)}`, + ].join("\n\n"); return { system, user }; } diff --git a/charactergarden/app/src/interpreter/interpretTurn.ts b/charactergarden/app/src/interpreter/interpretTurn.ts index ec23908..553f6fe 100644 --- a/charactergarden/app/src/interpreter/interpretTurn.ts +++ b/charactergarden/app/src/interpreter/interpretTurn.ts @@ -1,4 +1,5 @@ import type { InterpreterOutput } from "../contracts/intent"; +import type { WorldState } from "../contracts/world"; import { resolveDeterministicIntent } from "./adapters/deterministicResolver"; import { resolveLlmIntent } from "./adapters/llmResolver"; import { @@ -11,6 +12,7 @@ const DEFAULT_MIN_CONFIDENCE = 0.65; type InterpretTurnOptions = { mode?: ResolverMode; minConfidence?: number; + worldState?: WorldState; }; function getResolverMode(options?: InterpretTurnOptions): ResolverMode { @@ -25,6 +27,7 @@ function buildInput(rawText: string, actorId: string, options?: InterpretTurnOpt rawText, actorId, minConfidence: options?.minConfidence ?? DEFAULT_MIN_CONFIDENCE, + worldState: options?.worldState, }; } diff --git a/charactergarden/app/src/interpreter/resolveIntent.ts b/charactergarden/app/src/interpreter/resolveIntent.ts index 79bcc83..874d2aa 100644 --- a/charactergarden/app/src/interpreter/resolveIntent.ts +++ b/charactergarden/app/src/interpreter/resolveIntent.ts @@ -1,4 +1,5 @@ import type { InterpreterOutput } from "../contracts/intent"; +import type { WorldState } from "../contracts/world"; export type ResolverMode = "deterministic" | "llm" | "hybrid"; @@ -6,6 +7,8 @@ export type ResolveIntentInput = { rawText: string; actorId: string; minConfidence: number; + /** Optional world state — passed to LLM resolvers to provide entity context. */ + worldState?: WorldState; }; export type IntentResolver = { diff --git a/charactergarden/app/src/truthEngine.ts b/charactergarden/app/src/truthEngine.ts deleted file mode 100644 index c2526a7..0000000 --- a/charactergarden/app/src/truthEngine.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { Action } from "./contracts/action"; -import type { SceneRulebook } from "./contracts/rulebook"; -import type { ValidationResult } from "./contracts/validation"; -import type { WorldState } from "./contracts/world"; -import { createDefaultRulebook } from "./defaultRulebook"; -import { validateWithRulebook } from "./rulebookEngine"; - -/** - * Validate a list of parsed actions against the world state. - * - * Pass a SceneRulebook to use data-driven scene rules. - * Falls back to the built-in default rulebook when none is provided. - */ -export function validateActions( - actions: Action[], - worldState: WorldState, - rulebook?: SceneRulebook -): ValidationResult[] { - const activeRulebook = rulebook ?? createDefaultRulebook(worldState.id); - return validateWithRulebook(actions, worldState, activeRulebook); -} diff --git a/charactergarden/app/src/turns/processTurn.ts b/charactergarden/app/src/turns/processTurn.ts deleted file mode 100644 index 1c7bcc1..0000000 --- a/charactergarden/app/src/turns/processTurn.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { CharacterGardenDatabase } from "../db"; -import type { Action } from "../contracts/action"; -import type { InterpreterOutput } from "../contracts/intent"; -import type { SceneRulebook } from "../contracts/rulebook"; -import type { ValidationResult } from "../contracts/validation"; -import type { WorldState } from "../contracts/world"; -import { runTurnManager } from "./turnManager"; - -export type ProcessTurnResponse = { - rawText: string; - actions: Action[]; - validation: ValidationResult[]; - worldState: WorldState; - interpreter: InterpreterOutput; -}; - -export async function processTurn( - rawText: string, - worldState: WorldState, - db: CharacterGardenDatabase, - rulebook?: SceneRulebook -): Promise { - return runTurnManager(rawText, worldState, db, rulebook); -} diff --git a/charactergarden/app/src/turns/turnManager.ts b/charactergarden/app/src/turns/turnManager.ts index e95d620..e4a0bd2 100644 --- a/charactergarden/app/src/turns/turnManager.ts +++ b/charactergarden/app/src/turns/turnManager.ts @@ -9,7 +9,8 @@ import type { ValidationResult } from "../contracts/validation"; import type { WorldState } from "../contracts/world"; import { interpretTurn } from "../interpreter/interpretTurn"; import { validateInterpreterOutput } from "../interpreter/validateInterpreterOutput"; -import { validateActions } from "../truthEngine"; +import { createDefaultRulebook } from "../defaultRulebook"; +import { validateWithRulebook } from "../rulebookEngine"; import { applyActions } from "../world/applyActions"; export type TurnManagerResponse = { @@ -39,7 +40,7 @@ export async function runTurnManager( db: CharacterGardenDatabase, rulebook?: SceneRulebook ): Promise { - const interpreted = await interpretTurn(rawText, "player"); + const interpreted = await interpretTurn(rawText, "player", { worldState }); const boundaryCheck = validateInterpreterOutput(interpreted); const interpreter: InterpreterOutput = boundaryCheck.isValid ? interpreted @@ -84,7 +85,8 @@ export async function runTurnManager( } const actions = interpreter.selectedActions; - const validation = validateActions(actions, worldState, rulebook); + const activeRulebook = rulebook ?? createDefaultRulebook(worldState.id); + const validation = validateWithRulebook(actions, worldState, activeRulebook); const nextWorldState = applyActions(actions, validation, worldState); const turn: Turn = { diff --git a/charactergarden/app/src/world/applyActions.ts b/charactergarden/app/src/world/applyActions.ts index 112f85c..802ad36 100644 --- a/charactergarden/app/src/world/applyActions.ts +++ b/charactergarden/app/src/world/applyActions.ts @@ -109,9 +109,9 @@ export function applyActions( case "take": if (actor && target) { target.attributes.location = `inventory:${actor.id}`; - if (target.id === "key_1") { - actor.attributes.has_key_1 = true; - } + // Set has_ on the actor so attributeRef rulebook checks work + // generically (e.g. has_key_1 for opening a locked door). + actor.attributes[`has_${target.id}`] = true; } else if (actor && action.targetId && action.metadata?.createIfMissing === true) { nextState.entities[action.targetId] = { id: action.targetId, diff --git a/charactergarden/app/src/world/seedWorld.ts b/charactergarden/app/src/world/seedWorld.ts new file mode 100644 index 0000000..1be2324 --- /dev/null +++ b/charactergarden/app/src/world/seedWorld.ts @@ -0,0 +1,162 @@ +import { randomUUID } from "node:crypto"; + +import type { CharacterGardenDatabase } from "../db"; +import type { Entity } from "../contracts/entity"; +import type { WorldState } from "../contracts/world"; + +export function createSeedWorldState(): WorldState { + const now = Date.now(); + + const entities: Record = { + room_offstage: { + id: "room_offstage", + name: "Offstage", + type: "room", + attributes: { + description: "A holding area for characters not currently in the active scene.", + is_joinable: false, + }, + }, + room_start: { + id: "room_start", + name: "Start Room", + type: "room", + attributes: { + description: "A plain room with a locked door.", + is_joinable: true, + }, + }, + room_exit: { + id: "room_exit", + name: "Exit Room", + type: "room", + attributes: { + description: "A simple room beyond the door.", + is_joinable: true, + }, + }, + player: { + id: "player", + name: "Player", + type: "character", + attributes: { + location: "room_start", + has_key_1: false, + }, + }, + groundskeeper: { + id: "groundskeeper", + name: "Groundskeeper", + type: "character", + attributes: { + location: "room_offstage", + is_social: true, + in_scene: false, + }, + }, + door_1: { + id: "door_1", + name: "Old Door", + type: "door", + attributes: { + location: "room_start", + openable: true, + locked: true, + requiredKey: "key_1", + open: false, + }, + }, + key_1: { + id: "key_1", + name: "Brass Key", + type: "item", + attributes: { + location: "room_start", + takeable: true, + }, + }, + }; + + return { + id: randomUUID(), + entities, + metadata: { + domain: "door_key_mvp", + version: 1, + }, + createdAt: now, + }; +} + +export function mergeSeedWorldState(worldState: WorldState): { worldState: WorldState; changed: boolean } { + const seed = createSeedWorldState(); + const mergedEntities: Record = {}; + let changed = false; + + for (const [entityId, seedEntity] of Object.entries(seed.entities)) { + const existingEntity = worldState.entities[entityId]; + if (!existingEntity) { + mergedEntities[entityId] = seedEntity; + changed = true; + continue; + } + + const mergedAttributes = { + ...seedEntity.attributes, + ...existingEntity.attributes, + }; + + if (JSON.stringify(existingEntity.attributes) !== JSON.stringify(mergedAttributes)) { + changed = true; + } + + mergedEntities[entityId] = { + ...existingEntity, + attributes: mergedAttributes, + }; + } + + for (const [entityId, existingEntity] of Object.entries(worldState.entities)) { + if (!mergedEntities[entityId]) { + mergedEntities[entityId] = existingEntity; + } + } + + const mergedWorldState: WorldState = { + ...worldState, + entities: mergedEntities, + metadata: { + ...seed.metadata, + ...worldState.metadata, + }, + }; + + if (changed) { + mergedWorldState.id = randomUUID(); + mergedWorldState.createdAt = Date.now(); + } + + return { + worldState: mergedWorldState, + changed, + }; +} + +export function ensureSeedState(db: CharacterGardenDatabase): WorldState { + db.init(); + + const latest = db.getLatestWorldState(); + if (latest) { + const merged = mergeSeedWorldState(latest); + db.upsertEntities(Object.values(merged.worldState.entities)); + if (merged.changed) { + db.insertWorldState(null, merged.worldState); + } + return merged.worldState; + } + + const seed = createSeedWorldState(); + db.upsertEntities(Object.values(seed.entities)); + db.insertWorldState(null, seed); + return seed; +} diff --git a/charactergarden/docker-compose.yml b/charactergarden/docker-compose.yml index b56a01b..88d384f 100644 --- a/charactergarden/docker-compose.yml +++ b/charactergarden/docker-compose.yml @@ -9,6 +9,10 @@ services: - NODE_ENV=${NODE_ENV:-development} - DB_PATH=/var/lib/charactergarden/app.db - OLLAMA_URL=${OLLAMA_URL:-http://ollama:11434} + - LLM_RESOLVER_URL=${LLM_RESOLVER_URL:-http://ollama:11434} + - LLM_RESOLVER_MODEL=${LLM_RESOLVER_MODEL:-llama3.2:3b} + - LLM_RESOLVER_TIMEOUT_MS=${LLM_RESOLVER_TIMEOUT_MS:-15000} + - INTENT_RESOLVER_MODE=${INTENT_RESOLVER_MODE:-hybrid} volumes: - sqlite_data:/var/lib/charactergarden - ./app/src:/app/src @@ -35,6 +39,23 @@ services: - ollama_data:/root/.ollama profiles: - llm + healthcheck: + test: ["CMD", "ollama", "list"] + interval: 10s + timeout: 5s + retries: 10 + start_period: 20s + + ollama-init: + image: ollama/ollama:latest + depends_on: + ollama: + condition: service_healthy + environment: + - OLLAMA_HOST=http://ollama:11434 + entrypoint: ["ollama", "pull", "${LLM_RESOLVER_MODEL:-llama3.2:3b}"] + profiles: + - llm volumes: ollama_data: diff --git a/thoughts.md b/thoughts.md index 80560cb..d9b10c1 100644 --- a/thoughts.md +++ b/thoughts.md @@ -18,6 +18,18 @@ - Interpreter envelopes are persisted per turn and surfaced to the UI timeline. - LLM resolver now calls an HTTP model backend (Ollama-compatible) with hybrid deterministic fallback. - Rulebooks now include a version field with backward-compatible DB migration. +- Turn log now returns populated actions and validation results per turn (previously always empty). +- Active rulebook ID is now persisted on worldState and survives restarts. +- `take` sets `has_` generically on the actor (was hardcoded to key_1 only). + +### Structural refactoring completed (April 2026) + +- **Deleted `turns/processTurn.ts`** — was a 3-line shim over runTurnManager. app.ts now calls runTurnManager directly. +- **Deleted `truthEngine.ts`** — was a thin wrapper over rulebookEngine.validateWithRulebook. turnManager.ts now calls validateWithRulebook directly. +- **Extracted `world/seedWorld.ts`** — createSeedWorldState, mergeSeedWorldState, ensureSeedState moved out of app.ts. App factory is now clean. +- **Fixed `db.listTurns()`** — now reads back actions and validation_results from their tables. Frontend turn log now has real data. +- **Fixed `worldState.rulebookId` persistence** — upsertRulebook now updates worldState.rulebookId and persists a world snapshot so the active rulebook survives restarts. +- **Generalized `has_` in applyActions** — `take` now sets `has_` on the actor for all taken items, not just `key_1`. The attributeRef rulebook check continues to work generically. ### Confirmed via containerized validation @@ -58,6 +70,13 @@ - Existing DBs may hold older rulebooks missing new action rule sets. - Need explicit upgrade path/versioning. +4. Parser is world-specific +- parseTextToActions.ts hardcodes entity IDs (room_exit, door_1, key_1, groundskeeper). +- The parser is used only inside the deterministic resolver adapter; keeping it isolated limits blast radius, but a future world-context-aware resolver would eliminate this entirely. + +5. Entity table vs world_state blob redundancy +- `entities` table and `world_states.state_json` both store entity data. The entities table is the live read target; world_states is the history log. No query capability on history. Acceptable for MVP. + ## Path Forward (Next 3 Iterations) ### Iteration 1: LLM adapter hardening