diff --git a/Implementation_plan.md b/Implementation_plan.md index e28b5a6..036bc90 100644 --- a/Implementation_plan.md +++ b/Implementation_plan.md @@ -1,12 +1,12 @@ -# CharacterGarden — Iterative Implementation Plan +# CharacterGarden — Iterative Implementation Plan (Updated) -## Copilot Operating Rules +## Planning Rules Work in small, reviewable steps. -After every completed step: +After each completed step: -1. Update `thoughts.md` +1. Update thoughts.md 2. Record files changed 3. Record assumptions made 4. Record next step @@ -16,388 +16,233 @@ Do not redesign the project without updating this plan. --- -# Phase 1 — Contracts First +# Phase 0 — Completed Baseline -## Step 1.1 — Create contracts folder +Status: COMPLETE -Create: - -```txt -app/src/contracts/ -``` - -Add: - -```txt -app/src/contracts/action.ts -app/src/contracts/turn.ts -app/src/contracts/validation.ts -app/src/contracts/world.ts -app/src/contracts/entity.ts -``` - -Goal: all shared types live here. +- Contracts are established under app/src/contracts/ +- Deterministic truth engine is active +- Rulebook-driven validation is active and editable +- Core actions supported: inspect, move, open, take, introduce, describe, transfer +- take createIfMissing path implemented with rulebook authorization +- transfer action implemented with ownership/location checks +- Turn orchestration moved behind turn manager seam +- Interpreter contract and first interpreter module created +- Docker container builds are passing for app and frontend --- -## Step 1.2 — Define Action contract +# Phase 1 — Intent Interpreter Boundary Hardening -In `action.ts`: +Status: IN PROGRESS -```ts -export type Action = { - actorId: string; - type: string; - targetId?: string; - locationId?: string; - metadata?: Record; -}; -``` +## Step 1.1 — Strict interpreter envelope validation -No other action shape should be used. +Goal: + +- Validate InterpreterOutput shape before it reaches validation/mutation. +- Reject malformed interpreter payloads deterministically. + +Target files: + +- app/src/contracts/intent.ts +- app/src/interpreter/interpretTurn.ts +- app/src/turns/turnManager.ts + +## Step 1.2 — Clarification model expansion + +Goal: + +- Add structured clarification options and optional entity candidates. +- Distinguish unrecognized-intent from reference ambiguity consistently. + +Target files: + +- app/src/contracts/intent.ts +- app/src/interpreter/interpretTurn.ts + +## Step 1.3 — API compatibility for unresolved turns + +Goal: + +- Ensure /api/turn returns interpreter status and clarification payload reliably. +- Keep existing fields backward-compatible for current frontend behavior. + +Target files: + +- app/src/turns/processTurn.ts +- app/src/index.ts --- -## Step 1.3 — Define ValidationResult contract +# Phase 2 — Turn Trace and Persistence Enrichment -In `validation.ts`: +Status: NOT STARTED -```ts -export type ValidationResult = { - actionIndex: number; - success: boolean; - reason?: string; - message?: string; -}; -``` +## Step 2.1 — Persist interpreter envelope per turn + +Goal: + +- Persist interpreter status, diagnostics, and clarification metadata. + +Target files: + +- app/src/db.ts +- app/src/contracts/turn.ts +- app/src/turns/turnManager.ts + +## Step 2.2 — Extend read models and API snapshots + +Goal: + +- Include interpreter trace data in turn history returned by /api/state. + +Target files: + +- app/src/db.ts +- app/src/app.ts +- app/src/index.ts + +## Step 2.3 — Frontend turn inspector updates + +Goal: + +- Display interpreter status and clarification prompts in timeline and latest result. + +Target files: + +- frontend/src/App.tsx --- -## Step 1.4 — Define Turn contract +# Phase 3 — Resolver Plug-in Architecture -In `turn.ts`: +Status: COMPLETE -```ts -import type { Action } from "./action"; -import type { ValidationResult } from "./validation"; +## Step 3.1 — Introduce resolver interface -export type Turn = { - id: string; - rawText: string; - actions: Action[]; - validation: ValidationResult[]; - createdAt: number; -}; -``` +Goal: + +- Define a stable resolver interface that returns InterpreterOutput. + +Target files: + +- app/src/interpreter/resolveIntent.ts (new) +- app/src/interpreter/interpretTurn.ts + +## Step 3.2 — Deterministic adapter extraction + +Goal: + +- Move current parser-backed behavior into a deterministic adapter. + +Target files: + +- app/src/interpreter/adapters/deterministicResolver.ts (new) +- app/src/interpreter/interpretTurn.ts + +## Step 3.3 — LLM resolver adapter + +Goal: + +- Add LLM adapter behind config, without making it authoritative over deterministic validation. + +Target files: + +- app/src/interpreter/adapters/llmResolver.ts (new) +- app/src/app.ts + +## Step 3.4 — Fallback strategy + +Goal: + +- Support deterministic fallback when LLM resolver fails or is low confidence. + +Target files: + +- app/src/interpreter/interpretTurn.ts --- -## Step 1.5 — Define Entity contract +# Phase 4 — Rulebook Governance and Compatibility -In `entity.ts`: +Status: IN PROGRESS -```ts -export type Entity = { - id: string; - name: string; - type: string; - attributes: Record; -}; -``` +## Step 4.1 — Rulebook versioning + +Goal: + +- Add version field and migration path for existing saved rulebooks. + +Status: COMPLETE + +Target files: + +- app/src/contracts/rulebook.ts +- app/src/defaultRulebook.ts +- app/src/db.ts + +## Step 4.2 — Policy pack structure + +Goal: + +- Organize rules into policy packs (creation, transfer, social) while retaining current behavior. + +Target files: + +- app/src/defaultRulebook.ts + +## Step 4.3 — Rulebook editor affordances + +Goal: + +- Surface policy grouping and version in the frontend editor. + +Target files: + +- frontend/src/App.tsx --- -## Step 1.6 — Define WorldState contract +# Phase 5 — Docker-First Test Harness -In `world.ts`: +Status: NOT STARTED -```ts -import type { Entity } from "./entity"; +## Step 5.1 — Backend integration tests -export type WorldState = { - id: string; - entities: Record; - metadata: Record; - createdAt: number; -}; -``` +Goal: ---- +- Cover deterministic scenarios: + - unauthorized createIfMissing denied + - authorized createIfMissing allowed + - transfer success and failure matrix + - unresolved intent clarification behavior -# Phase 2 — Enforce Layer Boundaries +Target files: -## Step 2.1 — Refactor truth engine imports +- app/src/** tests (new) -Update `truthEngine.ts` so it imports: +## Step 5.2 — Containerized test commands -```ts -import type { Action } from "./contracts/action"; -import type { ValidationResult } from "./contracts/validation"; -import type { WorldState } from "./contracts/world"; -``` +Goal: -Truth engine must only receive structured actions. +- Provide canonical Docker commands for app/frontend build and tests. ---- +Target files: -## Step 2.2 — Remove text parsing from truth engine +- charactergarden/docker-compose.yml +- project.md +- thoughts.md -Search `truthEngine.ts` for: +## Step 5.3 — CI readiness checklist -* string parsing -* natural language interpretation -* prompt logic -* LLM calls +Goal: -Move any such logic out. +- Ensure all checks are containerized and reproducible. -Truth engine should expose: +Deliverable: -```ts -export function validateActions( - actions: Action[], - worldState: WorldState -): ValidationResult[] { - // deterministic validation only -} -``` - ---- - -## Step 2.3 — Create parser layer - -Create: - -```txt -app/src/parser/ -app/src/parser/parseTextToActions.ts -``` - -Function: - -```ts -import type { Action } from "../contracts/action"; - -export function parseTextToActions(text: string): Action[] { - // temporary simple parser - return []; -} -``` - -For now, returning `[]` is acceptable. - ---- - -## Step 2.4 — Create world state engine - -Create: - -```txt -app/src/world/ -app/src/world/applyActions.ts -``` - -Function: - -```ts -import type { Action } from "../contracts/action"; -import type { ValidationResult } from "../contracts/validation"; -import type { WorldState } from "../contracts/world"; - -export function applyActions( - actions: Action[], - results: ValidationResult[], - worldState: WorldState -): WorldState { - // apply only successful actions - return worldState; -} -``` - ---- - -# Phase 3 — Build First Deterministic Test Domain - -Use a simple door/key room before anything complex. - -## Step 3.1 — Seed initial world - -Create initial world state: - -* actor: `player` -* room: `room_start` -* door: `door_1` -* key: `key_1` - -Door starts locked. - -Key starts in room. - -Player starts in room. - ---- - -## Step 3.2 — Support action types - -Truth engine should recognize: - -```txt -inspect -take -open -move -``` - -Unknown action types fail with: - -```txt -reason: "unknown_action" -``` - ---- - -## Step 3.3 — Validate take action - -Rules: - -* Actor must exist -* Target must exist -* Target must be in same location -* Target must be takeable - -Failure reasons: - -* `actor_not_found` -* `target_not_found` -* `not_in_same_location` -* `not_takeable` - ---- - -## Step 3.4 — Validate open action - -Rules: - -* Actor must exist -* Target must exist -* Target must be openable -* If locked, actor must have matching key - -Failure reasons: - -* `actor_not_found` -* `target_not_found` -* `not_openable` -* `locked_requires_key` - ---- - -## Step 3.5 — Apply successful take - -If `take` succeeds: - -* move item into actor inventory - ---- - -## Step 3.6 — Apply successful open - -If `open` succeeds: - -* set door attribute `open: true` - ---- - -# Phase 4 — Wire Full Turn Processing - -## Step 4.1 — Create turn processor - -Create: - -```txt -app/src/turns/processTurn.ts -``` - -Function: - -```ts -export async function processTurn(rawText: string): Promise { - // parse - // validate - // apply - // persist - // return turn -} -``` - ---- - -## Step 4.2 — Enforce pipeline order - -The turn processor must call: - -```txt -parseTextToActions -validateActions -applyActions -persistTurn -``` - -In that order. - -No layer may skip ahead. - ---- - -## Step 4.3 — Add debug response - -API should return: - -```ts -{ - rawText, - actions, - validation, - worldState -} -``` - -This is for MVP debugging. - ---- - -# Phase 5 — Persistence - -## Step 5.1 — Add database tables - -Minimum SQLite tables: - -```sql -turns -actions -validation_results -entities -world_states -``` - ---- - -## Step 5.2 — Persist every turn - -Each call to `processTurn` must save: - -* raw text -* parsed actions -* validation results -* resulting world state snapshot - ---- - -## Step 5.3 — Add reset endpoint - -Add an endpoint to reset world state to seed state. - -This is needed for testing. +- One documented command sequence that reproduces local validation. --- diff --git a/charactergarden/app/package.json b/charactergarden/app/package.json index ff0b829..6311391 100644 --- a/charactergarden/app/package.json +++ b/charactergarden/app/package.json @@ -5,7 +5,8 @@ "scripts": { "dev": "ts-node-dev --respawn --transpile-only src/index.ts", "build": "tsc", - "start": "node dist/index.js" + "start": "node dist/index.js", + "test:integration": "npm run build && node dist/tests/integrationRunner.js" }, "dependencies": { "fastify": "^4.28.1", diff --git a/charactergarden/app/src/app.ts b/charactergarden/app/src/app.ts index 236c6cc..ee10c36 100644 --- a/charactergarden/app/src/app.ts +++ b/charactergarden/app/src/app.ts @@ -16,7 +16,7 @@ export interface AppSnapshot { export interface CharacterGardenApp { db: CharacterGardenDatabase; getSnapshot(): AppSnapshot; - processTurn(rawText: string): ProcessTurnResponse; + processTurn(rawText: string): Promise; getRulebook(): SceneRulebook; upsertRulebook(rulebook: SceneRulebook): SceneRulebook; listRulebooks(): SceneRulebook[]; @@ -219,9 +219,9 @@ export function createCharacterGardenApp(dbPath: string): CharacterGardenApp { }; }, - processTurn(rawText: string) { + async processTurn(rawText: string) { const rulebook = loadActiveRulebook(); - const result = processTurn(rawText, worldState, db, rulebook); + const result = await processTurn(rawText, worldState, db, rulebook); worldState = result.worldState; return result; }, @@ -231,7 +231,11 @@ export function createCharacterGardenApp(dbPath: string): CharacterGardenApp { }, upsertRulebook(rulebook: SceneRulebook) { - const updated: SceneRulebook = { ...rulebook, updatedAt: Date.now() }; + const updated: SceneRulebook = { + ...rulebook, + version: Number.isInteger(rulebook.version) ? rulebook.version : 1, + updatedAt: Date.now(), + }; db.upsertRulebook(updated); activeRulebookId = updated.id; return updated; diff --git a/charactergarden/app/src/contracts/intent.ts b/charactergarden/app/src/contracts/intent.ts new file mode 100644 index 0000000..523e470 --- /dev/null +++ b/charactergarden/app/src/contracts/intent.ts @@ -0,0 +1,48 @@ +import type { Action } from "./action"; + +export type InterpreterStatus = + | "resolved" + | "needs_clarification" + | "rejected"; + +export type ClarificationReasonCode = + | "UNRECOGNIZED_INTENT" + | "AMBIGUOUS_REFERENCE" + | "EMPTY_INPUT" + | "LOW_CONFIDENCE" + | "INTERNAL_INVALID_OUTPUT"; + +export type ClarificationOption = { + id: string; + label: string; + value: string; + entityId?: string; + entityType?: "character" | "item" | "room" | "unknown"; +}; + +export type ClarificationRequest = { + reasonCode: ClarificationReasonCode; + question: string; + field?: "verb" | "target" | "item" | "recipient" | "location"; + options?: ClarificationOption[]; +}; + +export type InterpreterCandidate = { + action: Action; + confidence: number; + rationale?: string; +}; + +export type InterpreterOutput = { + interpreterVersion: string; + rawText: string; + actorId: string; + resolutionSource: "deterministic" | "llm" | "hybrid"; + minConfidence: number; + selectedConfidence?: number; + status: InterpreterStatus; + selectedActions: Action[]; + candidates: InterpreterCandidate[]; + diagnostics: string[]; + clarification?: ClarificationRequest; +}; diff --git a/charactergarden/app/src/contracts/rulebook.ts b/charactergarden/app/src/contracts/rulebook.ts index 9b5f499..0da18fd 100644 --- a/charactergarden/app/src/contracts/rulebook.ts +++ b/charactergarden/app/src/contracts/rulebook.ts @@ -22,6 +22,8 @@ export type EntityRole = "actor" | "target" | "actorRoom" | "targetRoom"; * sameLocation — two entities share the same location attribute value * actorIdIn — action.actorId is included in an allowed list * actorNameIn — actor.name matches one of an allowed list (case-insensitive) + * actionMetadataEq — action.metadata[key] === value + * itemInInventory — item entity referenced by metadata key is in inventory of holder role * attributeRef — entities[checkRole].attributes[prefix + entities[refRole].attributes[refAttribute]] === true * metaValueNotInRoom — no entity of entityType in actor's room has name === action.metadata[metaKey] */ @@ -38,6 +40,8 @@ export type ConditionExpr = | { op: "sameLocation"; roleA: EntityRole; roleB: EntityRole } | { op: "actorIdIn"; allowedIds: string[] } | { op: "actorNameIn"; allowedNames: string[] } + | { op: "actionMetadataEq"; key: string; value: unknown } + | { op: "itemInInventory"; itemMetadataKey: string; holderRole: EntityRole } | { op: "attributeRef"; /** Entity whose attribute is being tested */ @@ -86,6 +90,8 @@ export type ActionRuleSet = { export type SceneRulebook = { id: string; worldId: string; + /** Increment when schema/policy format changes in breaking ways. */ + version: number; name: string; description?: string; rules: ActionRuleSet[]; diff --git a/charactergarden/app/src/contracts/turn.ts b/charactergarden/app/src/contracts/turn.ts index 4f6fdc7..19c739e 100644 --- a/charactergarden/app/src/contracts/turn.ts +++ b/charactergarden/app/src/contracts/turn.ts @@ -1,4 +1,5 @@ import type { Action } from "./action"; +import type { InterpreterOutput } from "./intent"; import type { ValidationResult } from "./validation"; export type Turn = { @@ -7,4 +8,5 @@ export type Turn = { actions: Action[]; validation: ValidationResult[]; createdAt: number; + interpreter?: InterpreterOutput; }; diff --git a/charactergarden/app/src/db.ts b/charactergarden/app/src/db.ts index 2ac3dda..24cf1fd 100644 --- a/charactergarden/app/src/db.ts +++ b/charactergarden/app/src/db.ts @@ -4,6 +4,7 @@ import Database from "better-sqlite3"; import type { Action } from "./contracts/action"; import type { Entity } from "./contracts/entity"; +import type { InterpreterOutput } from "./contracts/intent"; import type { SceneRulebook } from "./contracts/rulebook"; import type { Turn } from "./contracts/turn"; import type { ValidationResult } from "./contracts/validation"; @@ -21,6 +22,7 @@ export interface CharacterGardenDatabase { listEntities(): Entity[]; insertTurn(turn: Turn): void; listTurns(): Turn[]; + insertInterpreterOutput(turnId: string, interpreter: InterpreterOutput): void; insertActions(turnId: string, actions: Action[]): void; insertValidationResults(turnId: string, results: ValidationResult[]): void; insertWorldState(turnId: string | null, worldState: WorldState): void; @@ -97,6 +99,7 @@ export function createDatabase(config: DatabaseConfig): CharacterGardenDatabase CREATE TABLE IF NOT EXISTS rulebooks ( id TEXT PRIMARY KEY, world_id TEXT NOT NULL, + version INTEGER NOT NULL DEFAULT 1, name TEXT NOT NULL, description TEXT, rules_json TEXT NOT NULL, @@ -104,12 +107,27 @@ export function createDatabase(config: DatabaseConfig): CharacterGardenDatabase updated_at INTEGER NOT NULL ) `, + ` + CREATE TABLE IF NOT EXISTS interpreter_events ( + turn_id TEXT PRIMARY KEY, + interpreter_json TEXT NOT NULL, + created_at INTEGER NOT NULL, + FOREIGN KEY(turn_id) REFERENCES turns(id) + ) + `, ]; for (const statement of initStatements) { sqlite.exec(statement); } + // Backward-compatible migration for pre-versioned databases. + try { + sqlite.exec("ALTER TABLE rulebooks ADD COLUMN version INTEGER NOT NULL DEFAULT 1"); + } catch { + // Column already exists. + } + const clearEntitiesStatement = sqlite.prepare("DELETE FROM entities"); const upsertEntityStatement = sqlite.prepare(` INSERT INTO entities (id, name, type, attributes_json) @@ -137,6 +155,19 @@ export function createDatabase(config: DatabaseConfig): CharacterGardenDatabase ORDER BY created_at ASC `); + const listInterpreterEventsStatement = sqlite.prepare(` + SELECT turn_id, interpreter_json + FROM interpreter_events + `); + + const insertInterpreterOutputStatement = sqlite.prepare(` + INSERT INTO interpreter_events (turn_id, interpreter_json, created_at) + VALUES (@turn_id, @interpreter_json, @created_at) + ON CONFLICT(turn_id) DO UPDATE SET + interpreter_json = excluded.interpreter_json, + created_at = excluded.created_at + `); + const insertActionStatement = sqlite.prepare(` INSERT INTO actions ( turn_id, @@ -186,9 +217,10 @@ export function createDatabase(config: DatabaseConfig): CharacterGardenDatabase `); const upsertRulebookStatement = sqlite.prepare(` - INSERT INTO rulebooks (id, world_id, name, description, rules_json, created_at, updated_at) - VALUES (@id, @world_id, @name, @description, @rules_json, @created_at, @updated_at) + INSERT INTO rulebooks (id, world_id, version, name, description, rules_json, created_at, updated_at) + VALUES (@id, @world_id, @version, @name, @description, @rules_json, @created_at, @updated_at) ON CONFLICT(id) DO UPDATE SET + version = excluded.version, name = excluded.name, description = excluded.description, rules_json = excluded.rules_json, @@ -196,13 +228,13 @@ export function createDatabase(config: DatabaseConfig): CharacterGardenDatabase `); const getRulebookStatement = sqlite.prepare(` - SELECT id, world_id, name, description, rules_json, created_at, updated_at + SELECT id, world_id, version, name, description, rules_json, created_at, updated_at FROM rulebooks WHERE id = @id `); const listRulebooksStatement = sqlite.prepare(` - SELECT id, world_id, name, description, rules_json, created_at, updated_at + SELECT id, world_id, version, name, description, rules_json, created_at, updated_at FROM rulebooks ORDER BY created_at ASC `); @@ -224,6 +256,7 @@ export function createDatabase(config: DatabaseConfig): CharacterGardenDatabase wipe() { sqlite.exec(` + DELETE FROM interpreter_events; DELETE FROM validation_results; DELETE FROM actions; DELETE FROM world_states; @@ -279,15 +312,33 @@ export function createDatabase(config: DatabaseConfig): CharacterGardenDatabase created_at: number; }>; + const interpreterRows = listInterpreterEventsStatement.all() as Array<{ + turn_id: string; + interpreter_json: string; + }>; + const interpreterByTurnId = new Map(); + for (const row of interpreterRows) { + interpreterByTurnId.set(row.turn_id, parseJson(row.interpreter_json)); + } + return rows.map((row) => ({ id: row.id, rawText: row.raw_text, actions: [], validation: [], createdAt: row.created_at, + interpreter: interpreterByTurnId.get(row.id), })); }, + insertInterpreterOutput(turnId, interpreter) { + insertInterpreterOutputStatement.run({ + turn_id: turnId, + interpreter_json: JSON.stringify(interpreter), + created_at: Date.now(), + }); + }, + insertActions(turnId, actions) { const tx = sqlite.transaction((actionList: Action[]) => { actionList.forEach((action, index) => { @@ -343,6 +394,7 @@ export function createDatabase(config: DatabaseConfig): CharacterGardenDatabase upsertRulebookStatement.run({ id: rulebook.id, world_id: rulebook.worldId, + version: rulebook.version, name: rulebook.name, description: rulebook.description ?? null, rules_json: JSON.stringify(rulebook.rules), @@ -356,6 +408,7 @@ export function createDatabase(config: DatabaseConfig): CharacterGardenDatabase | { id: string; world_id: string; + version: number; name: string; description: string | null; rules_json: string; @@ -367,6 +420,7 @@ export function createDatabase(config: DatabaseConfig): CharacterGardenDatabase return { id: row.id, worldId: row.world_id, + version: row.version ?? 1, name: row.name, description: row.description ?? undefined, rules: parseJson(row.rules_json), @@ -379,6 +433,7 @@ export function createDatabase(config: DatabaseConfig): CharacterGardenDatabase const rows = listRulebooksStatement.all() as Array<{ id: string; world_id: string; + version: number; name: string; description: string | null; rules_json: string; @@ -388,6 +443,7 @@ export function createDatabase(config: DatabaseConfig): CharacterGardenDatabase return rows.map((row) => ({ id: row.id, worldId: row.world_id, + version: row.version ?? 1, name: row.name, description: row.description ?? undefined, rules: parseJson(row.rules_json), diff --git a/charactergarden/app/src/defaultRulebook.ts b/charactergarden/app/src/defaultRulebook.ts index d36ecfa..71b924e 100644 --- a/charactergarden/app/src/defaultRulebook.ts +++ b/charactergarden/app/src/defaultRulebook.ts @@ -11,6 +11,7 @@ export function createDefaultRulebook(worldId: string): SceneRulebook { return { id: DEFAULT_RULEBOOK_ID, worldId, + version: 1, name: "Default Rulebook", description: "Built-in scene rules for the CharacterGarden engine. Edit freely — the engine re-evaluates on every turn.", createdAt: now, @@ -27,23 +28,47 @@ export function createDefaultRulebook(worldId: string): SceneRulebook { enabled: true, checks: [ { - id: "take_target_exists", - description: "Target entity must exist in the world", - condition: { op: "entityExists", role: "target" }, + id: "take_target_exists_or_actor_can_create", + description: "Target must exist, or actor must be authorized to create it when createIfMissing is true", + condition: { + op: "or", + conditions: [ + { op: "entityExists", role: "target" }, + { + op: "and", + conditions: [ + { op: "actionMetadataEq", key: "createIfMissing", value: true }, + { op: "actorIdIn", allowedIds: ["player"] }, + ], + }, + ], + }, failReason: "target_not_found", - failMessage: "Target '{target.id}' does not exist.", + failMessage: "Target '{target.id}' does not exist, and actor '{actor.id}' is not allowed to create missing items.", }, { id: "take_same_location", - description: "Actor and target must be in the same location", - condition: { op: "sameLocation", roleA: "actor", roleB: "target" }, + description: "If target exists, actor and target must be in the same location", + condition: { + op: "or", + conditions: [ + { op: "not", condition: { op: "entityExists", role: "target" } }, + { op: "sameLocation", roleA: "actor", roleB: "target" }, + ], + }, failReason: "not_in_same_location", failMessage: "Target '{target.id}' is not in the same location as '{actor.id}'.", }, { id: "take_takeable", - description: "Target must have takeable attribute set to true", - condition: { op: "eq", role: "target", attribute: "takeable", value: true }, + description: "If target exists, it must have takeable attribute set to true", + condition: { + op: "or", + conditions: [ + { op: "not", condition: { op: "entityExists", role: "target" } }, + { op: "eq", role: "target", attribute: "takeable", value: true }, + ], + }, failReason: "not_takeable", failMessage: "Target '{target.id}' cannot be taken.", }, @@ -242,6 +267,41 @@ export function createDefaultRulebook(worldId: string): SceneRulebook { }, ], }, + + { + actionType: "transfer", + enabled: true, + checks: [ + { + id: "transfer_recipient_exists", + description: "Recipient must exist in the world", + condition: { op: "entityExists", role: "target" }, + failReason: "target_not_found", + failMessage: "Recipient '{target.id}' does not exist.", + }, + { + id: "transfer_recipient_character", + description: "Recipient must be a character", + condition: { op: "entityType", role: "target", requiredType: "character" }, + failReason: "target_not_character", + failMessage: "Recipient '{target.id}' is not a character.", + }, + { + id: "transfer_same_location", + description: "Actor and recipient must be in the same location", + condition: { op: "sameLocation", roleA: "actor", roleB: "target" }, + failReason: "not_in_same_location", + failMessage: "Recipient '{target.id}' is not in the same location as '{actor.id}'.", + }, + { + id: "transfer_actor_holds_item", + description: "Actor must currently hold the specified item in inventory", + condition: { op: "itemInInventory", itemMetadataKey: "itemId", holderRole: "actor" }, + failReason: "item_not_in_inventory", + failMessage: "Actor '{actor.id}' is not holding the requested item.", + }, + ], + }, ], }; } diff --git a/charactergarden/app/src/index.ts b/charactergarden/app/src/index.ts index c53512c..c33293a 100644 --- a/charactergarden/app/src/index.ts +++ b/charactergarden/app/src/index.ts @@ -17,13 +17,13 @@ server.get("/api/state", async () => game.getSnapshot()); server.post("/api/reset", async () => game.reset()); server.post<{ Body: { input?: string } }>("/api/turn", async (request, reply) => { - const input = request.body?.input?.trim(); - if (!input) { + const input = request.body?.input; + if (typeof input !== "string") { reply.code(400); - return { error: "input is required" }; + return { error: "input is required and must be a string" }; } - return game.processTurn(input); + return await game.processTurn(input); }); // --------------------------------------------------------------------------- diff --git a/charactergarden/app/src/interpreter/adapters/deterministicResolver.ts b/charactergarden/app/src/interpreter/adapters/deterministicResolver.ts new file mode 100644 index 0000000..8acc72b --- /dev/null +++ b/charactergarden/app/src/interpreter/adapters/deterministicResolver.ts @@ -0,0 +1,131 @@ +import type { InterpreterOutput } from "../../contracts/intent"; +import type { ResolveIntentInput } from "../resolveIntent"; +import { parseTextToActions } from "../../parser/parseTextToActions"; + +export const DETERMINISTIC_INTERPRETER_VERSION = "deterministic-v1"; + +function hasAmbiguousReference(input: string): boolean { + return /\b(it|them|that|this|him|her|there|here)\b/i.test(input); +} + +export function resolveDeterministicIntent(input: ResolveIntentInput): InterpreterOutput { + const trimmed = input.rawText.trim(); + + if (!trimmed) { + return { + interpreterVersion: DETERMINISTIC_INTERPRETER_VERSION, + rawText: input.rawText, + actorId: input.actorId, + resolutionSource: "deterministic", + minConfidence: input.minConfidence, + status: "rejected", + selectedActions: [], + candidates: [], + diagnostics: ["Input was empty after trimming whitespace."], + clarification: { + reasonCode: "EMPTY_INPUT", + question: "What would you like to do?", + field: "verb", + }, + }; + } + + const actions = parseTextToActions(trimmed, input.actorId); + if (actions.length > 0) { + const candidates = actions.map((action) => ({ + action, + confidence: 0.85, + rationale: "Matched deterministic parser pattern and normalized to canonical action.", + })); + const selectedConfidence = candidates.reduce((min, c) => Math.min(min, c.confidence), 1); + + if (selectedConfidence < input.minConfidence) { + return { + interpreterVersion: DETERMINISTIC_INTERPRETER_VERSION, + rawText: input.rawText, + actorId: input.actorId, + resolutionSource: "deterministic", + minConfidence: input.minConfidence, + selectedConfidence, + status: "needs_clarification", + selectedActions: [], + candidates, + diagnostics: [ + "Parser produced candidates but confidence did not meet threshold.", + ], + clarification: { + reasonCode: "LOW_CONFIDENCE", + question: "I found a possible action but confidence is low. Can you rephrase your intent?", + field: "verb", + options: [ + { id: "inspect", label: "Inspect", value: "inspect" }, + { id: "move", label: "Move", value: "move" }, + { id: "take", label: "Take", value: "take" }, + { id: "open", label: "Open", value: "open" }, + { id: "introduce", label: "Introduce", value: "introduce" }, + { id: "describe", label: "Describe", value: "describe" }, + { id: "transfer", label: "Transfer", value: "transfer" }, + ], + }, + }; + } + + return { + interpreterVersion: DETERMINISTIC_INTERPRETER_VERSION, + rawText: input.rawText, + actorId: input.actorId, + resolutionSource: "deterministic", + minConfidence: input.minConfidence, + selectedConfidence, + status: "resolved", + selectedActions: actions, + candidates, + diagnostics: ["Resolved by deterministic parser rules."], + }; + } + + if (hasAmbiguousReference(trimmed)) { + return { + interpreterVersion: DETERMINISTIC_INTERPRETER_VERSION, + rawText: input.rawText, + actorId: input.actorId, + resolutionSource: "deterministic", + minConfidence: input.minConfidence, + status: "needs_clarification", + selectedActions: [], + candidates: [], + diagnostics: ["Could not resolve pronoun/reference to a concrete entity."], + clarification: { + reasonCode: "AMBIGUOUS_REFERENCE", + question: "I am not sure what that refers to. Which item, character, or location did you mean?", + field: "target", + }, + }; + } + + return { + interpreterVersion: DETERMINISTIC_INTERPRETER_VERSION, + rawText: input.rawText, + actorId: input.actorId, + resolutionSource: "deterministic", + minConfidence: input.minConfidence, + status: "needs_clarification", + selectedActions: [], + candidates: [], + diagnostics: ["No parser pattern matched this input."], + clarification: { + reasonCode: "UNRECOGNIZED_INTENT", + question: "I could not map that to a known action. Try verbs like inspect, move, take, open, introduce, describe, or transfer.", + field: "verb", + options: [ + { id: "inspect", label: "Inspect", value: "inspect" }, + { id: "move", label: "Move", value: "move" }, + { id: "take", label: "Take", value: "take" }, + { id: "open", label: "Open", value: "open" }, + { id: "introduce", label: "Introduce", value: "introduce" }, + { id: "describe", label: "Describe", value: "describe" }, + { id: "transfer", label: "Transfer", value: "transfer" }, + ], + }, + }; +} diff --git a/charactergarden/app/src/interpreter/adapters/llmResolver.ts b/charactergarden/app/src/interpreter/adapters/llmResolver.ts new file mode 100644 index 0000000..2e40349 --- /dev/null +++ b/charactergarden/app/src/interpreter/adapters/llmResolver.ts @@ -0,0 +1,282 @@ +import type { Action } from "../../contracts/action"; +import type { InterpreterOutput } from "../../contracts/intent"; +import type { ResolveIntentInput } from "../resolveIntent"; + +export const LLM_INTERPRETER_VERSION = "llm-v1-ollama"; + +type LlmClarification = { + reasonCode?: string; + question?: string; + field?: "verb" | "target" | "item" | "recipient" | "location"; + options?: Array<{ + id?: string; + label?: string; + value?: string; + entityId?: string; + entityType?: "character" | "item" | "room" | "unknown"; + }>; +}; + +type LlmIntentResponse = { + status?: "resolved" | "needs_clarification" | "rejected"; + selectedActions?: unknown; + selectedConfidence?: unknown; + clarification?: LlmClarification; + rationale?: string; +}; + +function fallbackClarification(input: ResolveIntentInput, diagnostic: string): InterpreterOutput { + return { + interpreterVersion: LLM_INTERPRETER_VERSION, + rawText: input.rawText, + actorId: input.actorId, + resolutionSource: "llm", + minConfidence: input.minConfidence, + status: "needs_clarification", + selectedActions: [], + candidates: [], + diagnostics: [diagnostic], + clarification: { + reasonCode: "UNRECOGNIZED_INTENT", + question: "I could not confidently resolve that intent. Please rephrase with a clear verb.", + field: "verb", + options: [ + { id: "inspect", label: "Inspect", value: "inspect" }, + { id: "move", label: "Move", value: "move" }, + { id: "take", label: "Take", value: "take" }, + { id: "open", label: "Open", value: "open" }, + { id: "introduce", label: "Introduce", value: "introduce" }, + { id: "describe", label: "Describe", value: "describe" }, + { id: "transfer", label: "Transfer", value: "transfer" }, + ], + }, + }; +} + +function extractFirstJsonObject(text: string): string | null { + const trimmed = text.trim(); + if (trimmed.startsWith("{") && trimmed.endsWith("}")) { + return trimmed; + } + + const codeFenceMatch = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i); + if (codeFenceMatch?.[1]) { + const fenced = codeFenceMatch[1].trim(); + if (fenced.startsWith("{") && fenced.endsWith("}")) { + return fenced; + } + } + + const firstBrace = trimmed.indexOf("{"); + const lastBrace = trimmed.lastIndexOf("}"); + if (firstBrace >= 0 && lastBrace > firstBrace) { + return trimmed.slice(firstBrace, lastBrace + 1); + } + + return null; +} + +function toActionArray(value: unknown, actorId: string): Action[] { + if (!Array.isArray(value)) return []; + + const actions: Action[] = []; + for (const item of value) { + if (!item || typeof item !== "object") continue; + const action = item as Record; + const type = typeof action.type === "string" ? action.type.trim() : ""; + if (!type) continue; + + const normalized: Action = { + actorId, + type, + }; + + if (typeof action.actorId === "string" && action.actorId.trim()) { + normalized.actorId = action.actorId; + } + if (typeof action.targetId === "string" && action.targetId.trim()) { + normalized.targetId = action.targetId; + } + if (typeof action.locationId === "string" && action.locationId.trim()) { + normalized.locationId = action.locationId; + } + if (action.metadata && typeof action.metadata === "object" && !Array.isArray(action.metadata)) { + normalized.metadata = action.metadata as Record; + } + + actions.push(normalized); + } + + return actions; +} + +function toConfidence(value: unknown, fallback: number): number { + if (typeof value !== "number" || Number.isNaN(value)) { + return fallback; + } + if (value < 0) return 0; + if (value > 1) return 1; + return value; +} + +function toReasonCode(value: string | undefined): + | "UNRECOGNIZED_INTENT" + | "AMBIGUOUS_REFERENCE" + | "EMPTY_INPUT" + | "LOW_CONFIDENCE" + | "INTERNAL_INVALID_OUTPUT" { + const normalized = (value ?? "").trim().toUpperCase(); + switch (normalized) { + case "AMBIGUOUS_REFERENCE": + return "AMBIGUOUS_REFERENCE"; + case "EMPTY_INPUT": + return "EMPTY_INPUT"; + case "LOW_CONFIDENCE": + return "LOW_CONFIDENCE"; + case "INTERNAL_INVALID_OUTPUT": + return "INTERNAL_INVALID_OUTPUT"; + default: + return "UNRECOGNIZED_INTENT"; + } +} + +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(" "); + + const user = [ + `actorId: ${input.actorId}`, + `input: ${JSON.stringify(input.rawText)}`, + `minimum_confidence: ${input.minConfidence}`, + ].join("\n"); + + return { system, user }; +} + +export async function resolveLlmIntent(input: ResolveIntentInput): Promise { + const baseUrl = (process.env.LLM_RESOLVER_URL ?? process.env.OLLAMA_URL ?? "").trim(); + const model = (process.env.LLM_RESOLVER_MODEL ?? "llama3.2:3b").trim(); + const timeoutMs = Number(process.env.LLM_RESOLVER_TIMEOUT_MS ?? 6000); + + if (!baseUrl) { + return fallbackClarification(input, "LLM resolver disabled: no LLM_RESOLVER_URL/OLLAMA_URL configured."); + } + + const prompt = buildPrompt(input); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), Number.isFinite(timeoutMs) ? timeoutMs : 6000); + + try { + const response = await fetch(`${baseUrl.replace(/\/$/, "")}/api/chat`, { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify({ + model, + stream: false, + format: "json", + options: { + temperature: 0, + }, + messages: [ + { role: "system", content: prompt.system }, + { role: "user", content: prompt.user }, + ], + }), + signal: controller.signal, + }); + + if (!response.ok) { + return fallbackClarification( + input, + `LLM resolver HTTP error: ${response.status} ${response.statusText}` + ); + } + + const payload = (await response.json()) as { + message?: { content?: string }; + }; + const text = payload.message?.content ?? ""; + const jsonText = extractFirstJsonObject(text); + if (!jsonText) { + return fallbackClarification(input, "LLM resolver returned non-JSON content."); + } + + let parsed: LlmIntentResponse; + try { + parsed = JSON.parse(jsonText) as LlmIntentResponse; + } catch { + return fallbackClarification(input, "LLM resolver returned malformed JSON."); + } + + const status = parsed.status ?? "needs_clarification"; + const selectedActions = toActionArray(parsed.selectedActions, input.actorId); + const selectedConfidence = toConfidence(parsed.selectedConfidence, 0.7); + const diagnostics = [ + "Resolved via LLM resolver.", + ...(parsed.rationale ? [parsed.rationale] : []), + ]; + + if (status === "resolved" && selectedActions.length > 0 && selectedConfidence >= input.minConfidence) { + return { + interpreterVersion: LLM_INTERPRETER_VERSION, + rawText: input.rawText, + actorId: input.actorId, + resolutionSource: "llm", + minConfidence: input.minConfidence, + selectedConfidence, + status: "resolved", + selectedActions, + candidates: selectedActions.map((action) => ({ + action, + confidence: selectedConfidence, + rationale: "Selected by configured LLM resolver.", + })), + diagnostics, + }; + } + + return { + interpreterVersion: LLM_INTERPRETER_VERSION, + rawText: input.rawText, + actorId: input.actorId, + resolutionSource: "llm", + minConfidence: input.minConfidence, + selectedConfidence, + status: status === "rejected" ? "rejected" : "needs_clarification", + selectedActions: [], + candidates: [], + diagnostics: [ + "LLM resolver did not produce a high-confidence resolved action set.", + ...diagnostics, + ], + clarification: { + reasonCode: toReasonCode(parsed.clarification?.reasonCode), + question: + parsed.clarification?.question ?? + "I need a clearer command. Please rephrase with a specific verb and target.", + field: parsed.clarification?.field, + options: parsed.clarification?.options + ?.filter((option) => !!option && typeof option.value === "string" && option.value.trim()) + .map((option, index) => ({ + id: option.id ?? `llm-option-${index + 1}`, + label: option.label ?? option.value ?? "Option", + value: option.value ?? "", + entityId: option.entityId, + entityType: option.entityType, + })), + }, + }; + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown LLM resolver error."; + return fallbackClarification(input, `LLM resolver request failed: ${message}`); + } finally { + clearTimeout(timeout); + } +} diff --git a/charactergarden/app/src/interpreter/interpretTurn.ts b/charactergarden/app/src/interpreter/interpretTurn.ts new file mode 100644 index 0000000..ec23908 --- /dev/null +++ b/charactergarden/app/src/interpreter/interpretTurn.ts @@ -0,0 +1,67 @@ +import type { InterpreterOutput } from "../contracts/intent"; +import { resolveDeterministicIntent } from "./adapters/deterministicResolver"; +import { resolveLlmIntent } from "./adapters/llmResolver"; +import { + type ResolverMode, + normalizeResolverMode, +} from "./resolveIntent"; + +const DEFAULT_MIN_CONFIDENCE = 0.65; + +type InterpretTurnOptions = { + mode?: ResolverMode; + minConfidence?: number; +}; + +function getResolverMode(options?: InterpretTurnOptions): ResolverMode { + if (options?.mode) { + return options.mode; + } + return normalizeResolverMode(process.env.INTENT_RESOLVER_MODE); +} + +function buildInput(rawText: string, actorId: string, options?: InterpretTurnOptions) { + return { + rawText, + actorId, + minConfidence: options?.minConfidence ?? DEFAULT_MIN_CONFIDENCE, + }; +} + +export async function interpretTurn( + rawText: string, + actorId = "player", + options?: InterpretTurnOptions +): Promise { + const mode = getResolverMode(options); + const input = buildInput(rawText, actorId, options); + + if (mode === "deterministic") { + return resolveDeterministicIntent(input); + } + + if (mode === "llm") { + return resolveLlmIntent(input); + } + + // hybrid mode: prefer LLM when available, but deterministically fall back. + const llmOutput = await resolveLlmIntent(input); + if (llmOutput.status === "resolved") { + return { + ...llmOutput, + resolutionSource: "hybrid", + diagnostics: ["Hybrid mode: resolved via LLM adapter.", ...llmOutput.diagnostics], + }; + } + + const deterministicOutput = resolveDeterministicIntent(input); + return { + ...deterministicOutput, + resolutionSource: "hybrid", + diagnostics: [ + "Hybrid mode: LLM adapter did not resolve intent; used deterministic fallback.", + ...deterministicOutput.diagnostics, + ...llmOutput.diagnostics, + ], + }; +} diff --git a/charactergarden/app/src/interpreter/resolveIntent.ts b/charactergarden/app/src/interpreter/resolveIntent.ts new file mode 100644 index 0000000..79bcc83 --- /dev/null +++ b/charactergarden/app/src/interpreter/resolveIntent.ts @@ -0,0 +1,22 @@ +import type { InterpreterOutput } from "../contracts/intent"; + +export type ResolverMode = "deterministic" | "llm" | "hybrid"; + +export type ResolveIntentInput = { + rawText: string; + actorId: string; + minConfidence: number; +}; + +export type IntentResolver = { + name: string; + resolve(input: ResolveIntentInput): Promise | InterpreterOutput; +}; + +export function normalizeResolverMode(value: string | undefined): ResolverMode { + const normalized = (value ?? "").trim().toLowerCase(); + if (normalized === "deterministic" || normalized === "llm" || normalized === "hybrid") { + return normalized; + } + return "hybrid"; +} diff --git a/charactergarden/app/src/interpreter/validateInterpreterOutput.ts b/charactergarden/app/src/interpreter/validateInterpreterOutput.ts new file mode 100644 index 0000000..760b475 --- /dev/null +++ b/charactergarden/app/src/interpreter/validateInterpreterOutput.ts @@ -0,0 +1,94 @@ +import type { InterpreterOutput } from "../contracts/intent"; + +const VALID_STATUSES = new Set(["resolved", "needs_clarification", "rejected"]); + +export type InterpreterValidation = { + isValid: boolean; + issues: string[]; +}; + +/** + * Runtime guard for the interpreter boundary. + * + * The turn manager uses this to ensure malformed interpreter output never + * reaches deterministic validation/mutation logic. + */ +export function validateInterpreterOutput(output: InterpreterOutput): InterpreterValidation { + const issues: string[] = []; + + if (!output || typeof output !== "object") { + return { isValid: false, issues: ["Interpreter output must be an object."] }; + } + + if (typeof output.interpreterVersion !== "string" || !output.interpreterVersion.trim()) { + issues.push("interpreterVersion must be a non-empty string."); + } + + if (typeof output.rawText !== "string") { + issues.push("rawText must be a string."); + } + + if (typeof output.actorId !== "string" || !output.actorId.trim()) { + issues.push("actorId must be a non-empty string."); + } + + if (!VALID_STATUSES.has(output.status)) { + issues.push("status must be one of: resolved, needs_clarification, rejected."); + } + + if (!Array.isArray(output.selectedActions)) { + issues.push("selectedActions must be an array."); + } + + if (!Array.isArray(output.candidates)) { + issues.push("candidates must be an array."); + } + + if (!Array.isArray(output.diagnostics)) { + issues.push("diagnostics must be an array."); + } + + if (typeof output.minConfidence !== "number" || output.minConfidence < 0 || output.minConfidence > 1) { + issues.push("minConfidence must be a number between 0 and 1."); + } + + if (output.selectedConfidence !== undefined) { + if ( + typeof output.selectedConfidence !== "number" || + output.selectedConfidence < 0 || + output.selectedConfidence > 1 + ) { + issues.push("selectedConfidence must be between 0 and 1 when provided."); + } + } + + for (const candidate of output.candidates) { + if (typeof candidate.confidence !== "number" || candidate.confidence < 0 || candidate.confidence > 1) { + issues.push("Every candidate confidence must be between 0 and 1."); + break; + } + } + + if (output.status === "resolved") { + if (output.selectedActions.length === 0) { + issues.push("resolved output must include at least one selected action."); + } + if (output.clarification) { + issues.push("resolved output must not include clarification."); + } + } + + if (output.status !== "resolved") { + if (output.selectedActions.length > 0) { + issues.push("unresolved/rejected output must not include selected actions."); + } + if (!output.clarification) { + issues.push("unresolved/rejected output must include clarification."); + } + } + + return { + isValid: issues.length === 0, + issues, + }; +} diff --git a/charactergarden/app/src/parser/parseTextToActions.ts b/charactergarden/app/src/parser/parseTextToActions.ts index 7d7b60d..ca7e814 100644 --- a/charactergarden/app/src/parser/parseTextToActions.ts +++ b/charactergarden/app/src/parser/parseTextToActions.ts @@ -12,6 +12,16 @@ function toDisplayName(value: string): string { .join(" "); } +function toItemSlug(value: string): string { + return ( + value + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "_") + .replace(/^_+|_+$/g, "") || "item" + ); +} + function extractIntroducedCharacterName(input: string): string | undefined { const match = input.match(/(?:introduce|bring in|invite|have)\s+(?:the\s+|a\s+|an\s+)?(.+?)(?:\s+join)?$/); const rawName = match?.[1]?.trim(); @@ -29,6 +39,52 @@ function extractActorAndAction(sentence: string): { actorName?: string; action: return { action: normalized_sent }; } +function extractTakenItemName(input: string): string | undefined { + const match = input.match(/(?:take|pick up|grab)\s+(?:the\s+|a\s+|an\s+)?(.+)$/); + const rawName = match?.[1]?.trim(); + if (!rawName) { + return undefined; + } + return rawName.replace(/^(the|a|an)\s+/, "").trim() || undefined; +} + +function extractTransferParts(input: string): { itemName: string; recipientName: string } | undefined { + const match = input.match( + /(?:give|hand|pass|transfer)\s+(?:the\s+|a\s+|an\s+)?(.+?)\s+(?:to|over to)\s+(?:the\s+)?(.+)$/ + ); + if (!match) { + return undefined; + } + + const itemName = match[1]?.trim().replace(/^(the|a|an)\s+/, "").trim(); + const recipientName = match[2]?.trim().replace(/^(the|a|an)\s+/, "").trim(); + if (!itemName || !recipientName) { + return undefined; + } + return { itemName, recipientName }; +} + +function toCharacterSlug(value: string): string { + return ( + value + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "_") + .replace(/^_+|_+$/g, "") || "character" + ); +} + +function resolveRecipientId(name: string): string { + const n = name.trim().toLowerCase(); + if (n === "player" || n === "me" || n === "myself") { + return "player"; + } + if (n === "groundskeeper") { + return "groundskeeper"; + } + return `character_${toCharacterSlug(name)}`; +} + function parseSingleAction(actionText: string, defaultActorId: string): Action | undefined { const input = normalized(actionText); if (!input) { @@ -60,7 +116,21 @@ function parseSingleAction(actionText: string, defaultActorId: string): Action | if (input.includes("key")) { return { actorId: defaultActorId, type: "take", targetId: "key_1" }; } - return undefined; + + const itemName = extractTakenItemName(input); + if (!itemName) { + return undefined; + } + + return { + actorId: defaultActorId, + type: "take", + targetId: `item_${toItemSlug(itemName)}`, + metadata: { + itemName: toDisplayName(itemName), + createIfMissing: true, + }, + }; } if (/(introduce|bring in|invite|have .* join)/.test(input)) { @@ -84,6 +154,26 @@ function parseSingleAction(actionText: string, defaultActorId: string): Action | }; } + if (/(give|hand|pass|transfer)/.test(input)) { + const parts = extractTransferParts(input); + if (!parts) { + return undefined; + } + + const itemId = parts.itemName.includes("key") ? "key_1" : `item_${toItemSlug(parts.itemName)}`; + + return { + actorId: defaultActorId, + type: "transfer", + targetId: resolveRecipientId(parts.recipientName), + metadata: { + itemId, + itemName: toDisplayName(parts.itemName), + recipientName: toDisplayName(parts.recipientName), + }, + }; + } + if (/(describe|is a|is an|has)/.test(input)) { // Match patterns like "describe the merchant as shrewd" or "the merchant is shrewd" const describeMatch = input.match(/(?:describe|tell about)\s+(?:the\s+)?([a-z\s_]+?)\s+as\s+(.+)$/) || diff --git a/charactergarden/app/src/rulebookEngine.ts b/charactergarden/app/src/rulebookEngine.ts index 4c7609f..358b323 100644 --- a/charactergarden/app/src/rulebookEngine.ts +++ b/charactergarden/app/src/rulebookEngine.ts @@ -116,6 +116,19 @@ function evaluate(expr: ConditionExpr, ctx: EvalContext): boolean { return expr.allowedNames.some((name) => name.trim().toLowerCase() === actorName); } + case "actionMetadataEq": { + return ctx.action.metadata?.[expr.key] === expr.value; + } + + case "itemInInventory": { + const holder = ctx.entities[expr.holderRole]; + const itemId = ctx.action.metadata?.[expr.itemMetadataKey]; + if (!holder || typeof itemId !== "string") return false; + const item = ctx.worldState.entities[itemId]; + if (!item) return false; + return String(item.attributes.location ?? "") === `inventory:${holder.id}`; + } + case "attributeRef": { const checkEntity = ctx.entities[expr.checkRole]; const refEntity = ctx.entities[expr.refRole]; diff --git a/charactergarden/app/src/tests/integrationRunner.ts b/charactergarden/app/src/tests/integrationRunner.ts new file mode 100644 index 0000000..ceb5921 --- /dev/null +++ b/charactergarden/app/src/tests/integrationRunner.ts @@ -0,0 +1,47 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; + +import { createCharacterGardenApp } from "../app"; +import { interpretTurn } from "../interpreter/interpretTurn"; + +async function run(): Promise { + const resolved = await interpretTurn("look around", "player"); + assert.equal(resolved.status, "resolved", "Expected 'look around' to resolve."); + + const hybridResolved = await interpretTurn("look around", "player", { mode: "hybrid" }); + assert.equal(hybridResolved.status, "resolved", "Expected hybrid mode to resolve via deterministic fallback when LLM is unavailable."); + assert.equal(hybridResolved.resolutionSource, "hybrid"); + + const empty = await interpretTurn("", "player"); + assert.equal(empty.status, "rejected", "Expected empty input to be rejected."); + assert.equal(empty.clarification?.reasonCode, "EMPTY_INPUT"); + + const dbPath = path.join("/tmp", `charactergarden_integration_${Date.now()}.db`); + if (fs.existsSync(dbPath)) { + fs.unlinkSync(dbPath); + } + + const app = createCharacterGardenApp(dbPath); + + const unresolved = await app.processTurn("blorb invalid nonsense"); + assert.equal(unresolved.interpreter.status, "needs_clarification"); + assert.equal(unresolved.actions.length, 0); + + const valid = await app.processTurn("look around"); + assert.equal(valid.interpreter.status, "resolved"); + + const snapshot = app.getSnapshot(); + assert.ok(snapshot.turns.length >= 2, "Expected persisted turns."); + const latestTurn = snapshot.turns[snapshot.turns.length - 1]; + assert.ok(latestTurn.interpreter, "Expected interpreter payload persisted on turn."); + + app.db.close(); + console.log("Integration checks passed."); +} + +void run().catch((error) => { + console.error("Integration checks failed:"); + console.error(error); + process.exit(1); +}); diff --git a/charactergarden/app/src/turns/processTurn.ts b/charactergarden/app/src/turns/processTurn.ts index e908e9e..1c7bcc1 100644 --- a/charactergarden/app/src/turns/processTurn.ts +++ b/charactergarden/app/src/turns/processTurn.ts @@ -1,50 +1,24 @@ -import { randomUUID } from "node:crypto"; - 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 { Turn } from "../contracts/turn"; import type { ValidationResult } from "../contracts/validation"; import type { WorldState } from "../contracts/world"; -import { parseTextToActions } from "../parser/parseTextToActions"; -import { validateActions } from "../truthEngine"; -import { applyActions } from "../world/applyActions"; +import { runTurnManager } from "./turnManager"; export type ProcessTurnResponse = { rawText: string; actions: Action[]; validation: ValidationResult[]; worldState: WorldState; + interpreter: InterpreterOutput; }; -export function processTurn( +export async function processTurn( rawText: string, worldState: WorldState, db: CharacterGardenDatabase, rulebook?: SceneRulebook -): ProcessTurnResponse { - const actions = parseTextToActions(rawText); - const validation = validateActions(actions, worldState, rulebook); - const nextWorldState = applyActions(actions, validation, worldState); - - const turn: Turn = { - id: randomUUID(), - rawText, - actions, - validation, - createdAt: Date.now(), - }; - - db.insertTurn(turn); - db.insertActions(turn.id, actions); - db.insertValidationResults(turn.id, validation); - db.upsertEntities(Object.values(nextWorldState.entities)); - db.insertWorldState(turn.id, nextWorldState); - - return { - rawText, - actions, - validation, - worldState: nextWorldState, - }; +): Promise { + return runTurnManager(rawText, worldState, db, rulebook); } diff --git a/charactergarden/app/src/turns/turnManager.ts b/charactergarden/app/src/turns/turnManager.ts new file mode 100644 index 0000000..e95d620 --- /dev/null +++ b/charactergarden/app/src/turns/turnManager.ts @@ -0,0 +1,109 @@ +import { randomUUID } from "node:crypto"; + +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 { Turn } from "../contracts/turn"; +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 { applyActions } from "../world/applyActions"; + +export type TurnManagerResponse = { + rawText: string; + actions: Action[]; + validation: ValidationResult[]; + worldState: WorldState; + interpreter: InterpreterOutput; +}; + +function persistTurn( + db: CharacterGardenDatabase, + turn: Turn, + interpreter: InterpreterOutput, + actions: Action[], + validation: ValidationResult[] +): void { + db.insertTurn(turn); + db.insertInterpreterOutput(turn.id, interpreter); + db.insertActions(turn.id, actions); + db.insertValidationResults(turn.id, validation); +} + +export async function runTurnManager( + rawText: string, + worldState: WorldState, + db: CharacterGardenDatabase, + rulebook?: SceneRulebook +): Promise { + const interpreted = await interpretTurn(rawText, "player"); + const boundaryCheck = validateInterpreterOutput(interpreted); + const interpreter: InterpreterOutput = boundaryCheck.isValid + ? interpreted + : { + interpreterVersion: interpreted.interpreterVersion, + rawText, + actorId: interpreted.actorId || "player", + resolutionSource: interpreted.resolutionSource, + minConfidence: interpreted.minConfidence, + status: "rejected", + selectedActions: [], + candidates: [], + diagnostics: [ + "Interpreter output failed boundary validation.", + ...boundaryCheck.issues, + ], + clarification: { + reasonCode: "INTERNAL_INVALID_OUTPUT", + question: "The interpreter returned an invalid output shape. Please retry the turn.", + field: "verb", + }, + }; + + if (interpreter.status !== "resolved") { + const turn: Turn = { + id: randomUUID(), + rawText, + actions: [], + validation: [], + createdAt: Date.now(), + }; + + persistTurn(db, turn, interpreter, [], []); + + return { + rawText, + actions: [], + validation: [], + worldState, + interpreter, + }; + } + + const actions = interpreter.selectedActions; + const validation = validateActions(actions, worldState, rulebook); + const nextWorldState = applyActions(actions, validation, worldState); + + const turn: Turn = { + id: randomUUID(), + rawText, + actions, + validation, + createdAt: Date.now(), + }; + + persistTurn(db, turn, interpreter, actions, validation); + db.upsertEntities(Object.values(nextWorldState.entities)); + db.insertWorldState(turn.id, nextWorldState); + + return { + rawText, + actions, + validation, + worldState: nextWorldState, + interpreter, + }; +} diff --git a/charactergarden/app/src/world/applyActions.ts b/charactergarden/app/src/world/applyActions.ts index 2b3b071..112f85c 100644 --- a/charactergarden/app/src/world/applyActions.ts +++ b/charactergarden/app/src/world/applyActions.ts @@ -43,6 +43,25 @@ function createCharacterId(worldState: WorldState, baseName: string): string { return `${baseId}_${suffix}`; } +function toDisplayName(value: string): string { + return value + .split(/[_\s]+/) + .filter(Boolean) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" "); +} + +function inferItemName(action: Action): string { + const itemName = action.metadata?.itemName; + if (typeof itemName === "string" && itemName.trim()) { + return itemName.trim(); + } + if (action.targetId?.startsWith("item_")) { + return toDisplayName(action.targetId.replace(/^item_/, "")); + } + return "Generated Item"; +} + function getActionCharacterName(action: Action): string | undefined { const displayName = action.metadata?.displayName; if (typeof displayName === "string" && displayName.trim()) { @@ -93,6 +112,18 @@ export function applyActions( if (target.id === "key_1") { actor.attributes.has_key_1 = true; } + } else if (actor && action.targetId && action.metadata?.createIfMissing === true) { + nextState.entities[action.targetId] = { + id: action.targetId, + name: inferItemName(action), + type: "item", + attributes: { + location: `inventory:${actor.id}`, + takeable: true, + created_by_action: "take", + created_by_actor: actor.id, + }, + }; } break; case "open": @@ -137,6 +168,19 @@ export function applyActions( } } break; + case "transfer": + if (target) { + const itemId = action.metadata?.itemId; + if (typeof itemId === "string") { + const item = nextState.entities[itemId]; + if (item) { + item.attributes.location = `inventory:${target.id}`; + item.attributes.last_transferred_by = action.actorId; + item.attributes.last_transferred_to = target.id; + } + } + } + break; case "inspect": default: break; diff --git a/charactergarden/frontend/src/App.tsx b/charactergarden/frontend/src/App.tsx index f572c49..e6c4ff6 100644 --- a/charactergarden/frontend/src/App.tsx +++ b/charactergarden/frontend/src/App.tsx @@ -27,6 +27,9 @@ type Turn = { actions: Action[]; validation: ValidationResult[]; createdAt: number; + interpreter?: { + status: "resolved" | "needs_clarification" | "rejected"; + }; }; type WorldState = { @@ -46,6 +49,21 @@ type ProcessTurnResponse = { actions: Action[]; validation: ValidationResult[]; worldState: WorldState; + interpreter: { + interpreterVersion: string; + status: "resolved" | "needs_clarification" | "rejected"; + selectedConfidence?: number; + diagnostics: string[]; + clarification?: { + reasonCode: string; + question: string; + options?: Array<{ + id: string; + label: string; + value: string; + }>; + }; + }; }; type RuleCheck = { @@ -65,6 +83,7 @@ type ActionRuleSet = { type SceneRulebook = { id: string; worldId: string; + version: number; name: string; description?: string; rules: ActionRuleSet[]; @@ -75,6 +94,7 @@ type SceneRulebook = { const starterPrompts = [ "look around", "take key", + "give key to groundskeeper", "open door", "move to exit", ]; @@ -220,6 +240,12 @@ function RulebookEditor() {

For character permissions, use {`{"op":"actorIdIn","allowedIds":["player"]}`} or {`{"op":"actorNameIn","allowedNames":["Player"]}`}.

+

+ For conditional creation, use {`{"op":"actionMetadataEq","key":"createIfMissing","value":true}`} together with actor checks. +

+

+ For transfer ownership checks, use {`{"op":"itemInInventory","itemMetadataKey":"itemId","holderRole":"actor"}`}. +

{ruleSet.checks.length > 0 ? (
    {ruleSet.checks.map((check) => ( @@ -354,6 +380,23 @@ export default function App() {

    Latest result

    Input: {latest.rawText}

    +

    + Interpreter: {latest.interpreter.status} + {typeof latest.interpreter.selectedConfidence === "number" + ? ` (${Math.round(latest.interpreter.selectedConfidence * 100)}% confidence)` + : ""} +

    + {latest.interpreter.clarification ? ( +

    + Clarification: {latest.interpreter.clarification.question} +

    + ) : null} + {latest.interpreter.clarification?.options?.length ? ( +

    + Options:{" "} + {latest.interpreter.clarification.options.map((o) => o.label).join(", ")} +

    + ) : null}
      {latest.validation.map((v) => (
    • @@ -361,8 +404,19 @@ export default function App() { {v.message ? ` - ${v.message}` : ""}
    • ))} - {latest.validation.length === 0 ?
    • No actions parsed.
    • : null} + {latest.validation.length === 0 ? ( +
    • + {latest.interpreter.status === "resolved" + ? "No actions parsed." + : "Turn requires clarification before any action can be validated."} +
    • + ) : null}
    + {latest.interpreter.diagnostics.length > 0 ? ( +

    + Diagnostics: {latest.interpreter.diagnostics.join(" | ")} +

    + ) : null}
    ) : null} @@ -408,6 +462,9 @@ export default function App() { {snapshot?.turns.slice().reverse().map((turn) => (
  • {turn.rawText} + {turn.interpreter ? ( + [interp:{turn.interpreter.status}] + ) : null} {turn.validation.map((v) => ( [{v.success ? "ok" : v.reason}] ))} @@ -425,211 +482,4 @@ export default function App() { ); -} - -type Entity = { - id: string; - type: string; - name: string; - attributes: Record; -}; - -type Action = { - actorId: string; - type: string; - targetId?: string; - locationId?: string; -}; - -type ValidationResult = { - actionIndex: number; - success: boolean; - reason?: string; - message?: string; -}; - -type Turn = { - id: string; - rawText: string; - actions: Action[]; - validation: ValidationResult[]; - createdAt: number; -}; - -type WorldState = { - id: string; - entities: Record; - metadata: Record; - createdAt: number; -}; - -type AppSnapshot = { - worldState: WorldState; - turns: Turn[]; -}; - -type ProcessTurnResponse = { - rawText: string; - actions: Action[]; - validation: ValidationResult[]; - worldState: WorldState; -}; - -const starterPrompts = [ - "look around", - "take key", - "open door", - "move to exit", -]; - -async function fetchJson(input: RequestInfo, init?: RequestInit): Promise { - const response = await fetch(input, init); - if (!response.ok) { - throw new Error(`Request failed: ${response.status}`); - } - return response.json() as Promise; -} - -export default function App() { - const [snapshot, setSnapshot] = useState(null); - const [latest, setLatest] = useState(null); - const [input, setInput] = useState("look around"); - const [loading, setLoading] = useState(true); - const [submitting, setSubmitting] = useState(false); - const [error, setError] = useState(null); - - useEffect(() => { - void fetchJson("/api/state") - .then((data) => { - setSnapshot(data); - setLoading(false); - }) - .catch((fetchError: Error) => { - setError(fetchError.message); - setLoading(false); - }); - }, []); - - async function onSubmit(event: FormEvent) { - event.preventDefault(); - setSubmitting(true); - setError(null); - - try { - const result = await fetchJson("/api/turn", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ input }), - }); - setLatest(result); - const nextSnapshot = await fetchJson("/api/state"); - setSnapshot(nextSnapshot); - } catch (submitError) { - setError(submitError instanceof Error ? submitError.message : "Unknown error"); - } finally { - setSubmitting(false); - } - } - - async function onReset() { - setError(null); - try { - const result = await fetchJson("/api/reset", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({}), - }); - setSnapshot(result); - setLatest(null); - } catch (resetError) { - setError(resetError instanceof Error ? resetError.message : "Unknown error"); - } - } - - const entities = snapshot ? Object.values(snapshot.worldState.entities) : []; - - return ( -
    -
    -

    CharacterGarden

    -

    Bootable narrative sandbox

    -

    - Submit a turn, inspect world state, and verify how the truth engine is mutating state. -

    - -
    - -