Compare commits

..

16 Commits

Author SHA1 Message Date
28229d8d69 fix: update app port to 3024 in docker-compose.yml and vite.config.ts
Co-authored-by: Copilot <copilot@github.com>
2026-04-27 15:58:41 -04:00
81e2a7828f fix: correct allowedHosts configuration format in vite.config.ts 2026-04-27 01:06:54 -04:00
b4a2968399 fix: update allowedHosts configuration in vite.config.ts to 'beepc' 2026-04-27 00:57:52 -04:00
3112b6e9fe fix: add allowedHosts configuration in vite.config.ts
Co-authored-by: Copilot <copilot@github.com>
2026-04-27 00:54:55 -04:00
d38c799b27 fix: update app and frontend port mappings to 3023 in .env.example and docker-compose.yml
Co-authored-by: Copilot <copilot@github.com>
2026-04-27 00:51:01 -04:00
7a022bc085 fix: update app port in .env and .env.example to 3024 and 3023 respectively 2026-04-27 00:47:47 -04:00
665646bc18 fix: correct app port mapping in docker-compose.yml to use 3000 2026-04-27 00:46:10 -04:00
56c9cce4c7 fix: correct app port mapping in docker-compose.yml to use 3000 2026-04-27 00:44:57 -04:00
76dee7e73f fix: update app port mapping in docker-compose.yml from 3000 to 3023
Co-authored-by: Copilot <copilot@github.com>
2026-04-27 00:36:21 -04:00
ca49565117 fix: correct app port mapping in docker-compose.yml to match environment variable
Co-authored-by: Copilot <copilot@github.com>
2026-04-27 00:31:25 -04:00
5189446c73 fix: update app port mapping in docker-compose.yml from 3000 to 3023 2026-04-27 00:15:17 -04:00
0da62785d5 feat: refactor turn processing and world state management; remove obsolete files and enhance database interactions
Co-authored-by: Copilot <copilot@github.com>
2026-04-27 00:09:11 -04:00
c32fa977a8 feat: add database reset functionality and implement rulebook tests 2026-04-26 16:27:26 -04:00
fca69d3cb5 feat: enhance turn display with time and diagnostics styling 2026-04-26 14:15:46 -04:00
fc10e46ccc feat(interpreter): implement hybrid intent resolution with LLM and deterministic fallback
- Added new contracts for intent interpretation, including InterpreterOutput and ResolverMode.
- Implemented deterministic intent resolver with clarity checks for ambiguous references and empty input.
- Developed LLM intent resolver that communicates with an external model, handling JSON responses and fallback clarifications.
- Created an interpretTurn function to manage intent resolution based on the selected resolver mode.
- Introduced validation for interpreter output to ensure integrity before processing actions.
- Established a turn manager to orchestrate turn processing, including action validation and world state mutation.
- Added integration tests to verify the functionality of the new intent resolution system.

Co-authored-by: Copilot <copilot@github.com>
2026-04-26 14:06:14 -04:00
ff9b86c3e9 feat: Implement scene rulebook and validation engine
- Added a new SceneRulebook system to manage data-driven validation rules for actions.
- Introduced rule checks for actions like "take", "open", "move", "introduce", and "describe".
- Created a rulebook engine to evaluate conditions and enforce rules during action validation.
- Enhanced action handling with support for scene entry and character descriptions.
- Updated the architecture documentation to reflect the new rule-based validation approach.
- Added new endpoints and improved the persistence layer for rulebooks.
2026-04-26 13:33:05 -04:00
35 changed files with 3537 additions and 1026 deletions

View File

@@ -1,12 +1,12 @@
# CharacterGarden — Iterative Implementation Plan # CharacterGarden — Iterative Implementation Plan (Updated)
## Copilot Operating Rules ## Planning Rules
Work in small, reviewable steps. 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 2. Record files changed
3. Record assumptions made 3. Record assumptions made
4. Record next step 4. Record next step
@@ -16,388 +16,242 @@ 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: - 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
```txt ## Phase 0 Structural Cleanup (April 2026) — COMPLETE
app/src/contracts/
```
Add: - Deleted turns/processTurn.ts (was a pointless shim over runTurnManager)
- Deleted truthEngine.ts (was a thin wrapper over rulebookEngine.validateWithRulebook)
```txt - Extracted world/seedWorld.ts — seed world logic out of app.ts; app factory is now lean
app/src/contracts/action.ts - Fixed db.listTurns() to return real actions and validation results (previously always empty arrays)
app/src/contracts/turn.ts - Fixed worldState.rulebookId persistence — active rulebook now survives restarts
app/src/contracts/validation.ts - Generalized has_<item_id> in applyActions — no longer hardcoded to key_1 only
app/src/contracts/world.ts
app/src/contracts/entity.ts
```
Goal: all shared types live here.
--- ---
## Step 1.2 — Define Action contract # Phase 1 — Intent Interpreter Boundary Hardening
In `action.ts`: Status: IN PROGRESS
```ts ## Step 1.1 — Strict interpreter envelope validation
export type Action = {
actorId: string;
type: string;
targetId?: string;
locationId?: string;
metadata?: Record<string, unknown>;
};
```
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 ## Step 2.1 — Persist interpreter envelope per turn
export type ValidationResult = {
actionIndex: number; Goal:
success: boolean;
reason?: string; - Persist interpreter status, diagnostics, and clarification metadata.
message?: string;
}; 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 ## Step 3.1 — Introduce resolver interface
import type { Action } from "./action";
import type { ValidationResult } from "./validation";
export type Turn = { Goal:
id: string;
rawText: string; - Define a stable resolver interface that returns InterpreterOutput.
actions: Action[];
validation: ValidationResult[]; Target files:
createdAt: number;
}; - 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 ## Step 4.1 — Rulebook versioning
export type Entity = {
id: string; Goal:
name: string;
type: string; - Add version field and migration path for existing saved rulebooks.
attributes: Record<string, unknown>;
}; 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 ## Step 5.1 — Backend integration tests
import type { Entity } from "./entity";
export type WorldState = { Goal:
id: string;
entities: Record<string, Entity>;
metadata: Record<string, unknown>;
createdAt: number;
};
```
--- - 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 Goal:
import type { Action } from "./contracts/action";
import type { ValidationResult } from "./contracts/validation";
import type { WorldState } from "./contracts/world";
```
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 Goal:
* natural language interpretation
* prompt logic
* LLM calls
Move any such logic out. - Ensure all checks are containerized and reproducible.
Truth engine should expose: Deliverable:
```ts - One documented command sequence that reproduces local validation.
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<Turn> {
// 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.
--- ---

Binary file not shown.

View File

@@ -1,5 +1,5 @@
NODE_ENV=development NODE_ENV=development
APP_PORT=3000 APP_PORT=3024
FRONTEND_PORT=5173 FRONTEND_PORT=5173
DB_PATH=/data/sqlite/app.db DB_PATH=/data/sqlite/app.db

View File

@@ -1,11 +1,26 @@
# Copy this file to .env and adjust values as needed. # Copy this file to .env and adjust values as needed.
# Never commit .env — it is gitignored. # Never commit .env — it is gitignored.
NODE_ENV=development # Host port the app is exposed on. Must match the container-internal port (right side of ports mapping).
APP_PORT=3000 APP_PORT=3023
FRONTEND_PORT=5173
DB_PATH=/data/sqlite/app.db
# Optional — only required when running with: docker compose --profile llm up # Host port the frontend dev server is exposed on.
OLLAMA_URL=http://ollama:11434 FRONTEND_PORT=5173
OLLAMA_MODEL=llama3
NODE_ENV=development
# ---------------------------------------------------------------------------
# LLM resolver — only active when running: docker compose --profile llm up
# ---------------------------------------------------------------------------
# URL of the Ollama-compatible backend (default points to the compose ollama service).
LLM_RESOLVER_URL=http://ollama:11434
# Model to pull and use. Must be available on the Ollama instance.
LLM_RESOLVER_MODEL=llama3.2:3b
# Request timeout in milliseconds. Increase for larger/slower models.
LLM_RESOLVER_TIMEOUT_MS=15000
# Resolver mode: hybrid (llm with deterministic fallback) | llm | deterministic
INTENT_RESOLVER_MODE=hybrid

View File

@@ -5,7 +5,8 @@
"scripts": { "scripts": {
"dev": "ts-node-dev --respawn --transpile-only src/index.ts", "dev": "ts-node-dev --respawn --transpile-only src/index.ts",
"build": "tsc", "build": "tsc",
"start": "node dist/index.js" "start": "node dist/index.js",
"test:integration": "npm run build && node dist/tests/integrationRunner.js"
}, },
"dependencies": { "dependencies": {
"fastify": "^4.28.1", "fastify": "^4.28.1",

View File

@@ -1,10 +1,10 @@
import { randomUUID } from "node:crypto";
import { createDatabase, CharacterGardenDatabase } from "./db"; 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 { Turn } from "./contracts/turn";
import type { WorldState } from "./contracts/world"; import type { WorldState } from "./contracts/world";
import { processTurn, ProcessTurnResponse } from "./turns/processTurn"; import { createDefaultRulebook, DEFAULT_RULEBOOK_ID } from "./defaultRulebook";
import { runTurnManager, TurnManagerResponse } from "./turns/turnManager";
import { ensureSeedState } from "./world/seedWorld";
export interface AppSnapshot { export interface AppSnapshot {
worldState: WorldState; worldState: WorldState;
@@ -14,90 +14,41 @@ export interface AppSnapshot {
export interface CharacterGardenApp { export interface CharacterGardenApp {
db: CharacterGardenDatabase; db: CharacterGardenDatabase;
getSnapshot(): AppSnapshot; getSnapshot(): AppSnapshot;
processTurn(rawText: string): ProcessTurnResponse; processTurn(rawText: string): Promise<TurnManagerResponse>;
getRulebook(): SceneRulebook;
upsertRulebook(rulebook: SceneRulebook): SceneRulebook;
listRulebooks(): SceneRulebook[];
reset(): AppSnapshot; reset(): AppSnapshot;
} }
function createSeedWorldState(): WorldState { function ensureDefaultRulebook(
const now = Date.now(); db: CharacterGardenDatabase,
worldState: WorldState
const entities: Record<string, Entity> = { ): SceneRulebook {
room_start: { const existing = db.getRulebook(DEFAULT_RULEBOOK_ID);
id: "room_start", if (existing) return existing;
name: "Start Room", const defaultRulebook = createDefaultRulebook(worldState.id);
type: "room", db.upsertRulebook(defaultRulebook);
attributes: { return defaultRulebook;
description: "A plain room with a locked door.",
},
},
room_exit: {
id: "room_exit",
name: "Exit Room",
type: "room",
attributes: {
description: "A simple room beyond the door.",
},
},
player: {
id: "player",
name: "Player",
type: "character",
attributes: {
location: "room_start",
has_key_1: 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 ensureSeedState(db: CharacterGardenDatabase): WorldState {
db.init();
const latest = db.getLatestWorldState();
if (latest) {
return latest;
}
const seed = createSeedWorldState();
db.upsertEntities(Object.values(seed.entities));
db.insertWorldState(null, seed);
return seed;
} }
export function createCharacterGardenApp(dbPath: string): CharacterGardenApp { export function createCharacterGardenApp(dbPath: string): CharacterGardenApp {
const db = createDatabase({ dbPath }); const db = createDatabase({ dbPath });
let worldState = ensureSeedState(db); let worldState = ensureSeedState(db);
// Active rulebook ID — tracks which rulebook the world is using.
let activeRulebookId: string =
worldState.rulebookId ?? DEFAULT_RULEBOOK_ID;
// Ensure the default rulebook is present on first boot.
ensureDefaultRulebook(db, worldState);
function loadActiveRulebook(): SceneRulebook {
const rulebook = db.getRulebook(activeRulebookId);
if (rulebook) return rulebook;
// Fall back to default if the active one was deleted.
activeRulebookId = DEFAULT_RULEBOOK_ID;
return ensureDefaultRulebook(db, worldState);
}
return { return {
db, db,
@@ -109,15 +60,40 @@ export function createCharacterGardenApp(dbPath: string): CharacterGardenApp {
}; };
}, },
processTurn(rawText: string) { async processTurn(rawText: string) {
const result = processTurn(rawText, worldState, db); const rulebook = loadActiveRulebook();
const result = await runTurnManager(rawText, worldState, db, rulebook);
worldState = result.worldState; worldState = result.worldState;
return result; return result;
}, },
getRulebook() {
return loadActiveRulebook();
},
upsertRulebook(rulebook: SceneRulebook) {
const updated: SceneRulebook = {
...rulebook,
version: Number.isInteger(rulebook.version) ? rulebook.version : 1,
updatedAt: Date.now(),
};
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;
},
listRulebooks() {
return db.listRulebooks();
},
reset() { reset() {
db.wipe(); db.wipe();
worldState = ensureSeedState(db); worldState = ensureSeedState(db);
activeRulebookId = DEFAULT_RULEBOOK_ID;
ensureDefaultRulebook(db, worldState);
return { return {
worldState, worldState,
turns: db.listTurns(), turns: db.listTurns(),

View File

@@ -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;
};

View File

@@ -0,0 +1,100 @@
/**
* SceneRulebook — data-driven validation rules for the truth engine.
*
* 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 previously in the truth engine as editable, data-driven rules.
*/
/** Which entity in the action context a condition refers to. */
export type EntityRole = "actor" | "target" | "actorRoom" | "targetRoom";
/**
* A composable, JSON-serialisable condition expression.
*
* Combinators: and | or | not
* Predicates:
* entityExists — entity referenced by role is present in world state
* entityExistsOrWillBeCreated — entity exists OR will be created earlier in this turn
* entityType — entity.type === requiredType
* eq / neq — entity field comparison (id, name, type, or attributes[attribute])
* attributeExists — entity.attributes[attribute] is not undefined
* 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]
*/
export type ConditionExpr =
| { op: "and"; conditions: ConditionExpr[] }
| { op: "or"; conditions: ConditionExpr[] }
| { op: "not"; condition: ConditionExpr }
| { op: "entityExists"; role: EntityRole }
| { op: "entityExistsOrWillBeCreated"; role: EntityRole }
| { op: "entityType"; role: EntityRole; requiredType: string }
| { op: "eq"; role: EntityRole; attribute: string; value: unknown }
| { op: "neq"; role: EntityRole; attribute: string; value: unknown }
| { op: "attributeExists"; role: EntityRole; attribute: string }
| { 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 */
checkRole: EntityRole;
/** Optional string prepended to the resolved key (e.g. "has_") */
prefix?: string;
/** Entity that provides the dynamic attribute name */
refRole: EntityRole;
/** Attribute on refRole whose value supplies the key name */
refAttribute: string;
}
| {
op: "metaValueNotInRoom";
/** Key in action.metadata whose value to match against entity names */
metaKey: string;
/** Only match entities of this type */
entityType: string;
};
/** A single named check within an action rule set. */
export type RuleCheck = {
id: string;
/** Human-readable label shown in the rulebook editor. */
description: string;
condition: ConditionExpr;
failReason: string;
/**
* Failure message template.
* Supports: {actor.id}, {actor.name}, {target.id}, {target.name}
*/
failMessage: string;
};
/** All checks that apply to a specific action type. */
export type ActionRuleSet = {
actionType: string;
/**
* When false, all checks are skipped and the action always passes.
* Useful for quickly disabling enforcement without deleting the rules.
*/
enabled: boolean;
checks: RuleCheck[];
};
/** The full rulebook attached to a scene/world. */
export type SceneRulebook = {
id: string;
worldId: string;
/** Increment when schema/policy format changes in breaking ways. */
version: number;
name: string;
description?: string;
rules: ActionRuleSet[];
createdAt: number;
updatedAt: number;
};

View File

@@ -1,4 +1,5 @@
import type { Action } from "./action"; import type { Action } from "./action";
import type { InterpreterOutput } from "./intent";
import type { ValidationResult } from "./validation"; import type { ValidationResult } from "./validation";
export type Turn = { export type Turn = {
@@ -7,4 +8,5 @@ export type Turn = {
actions: Action[]; actions: Action[];
validation: ValidationResult[]; validation: ValidationResult[];
createdAt: number; createdAt: number;
interpreter?: InterpreterOutput;
}; };

View File

@@ -5,4 +5,6 @@ export type WorldState = {
entities: Record<string, Entity>; entities: Record<string, Entity>;
metadata: Record<string, unknown>; metadata: Record<string, unknown>;
createdAt: number; createdAt: number;
/** ID of the SceneRulebook currently active for this world. */
rulebookId?: string;
}; };

View File

@@ -4,6 +4,8 @@ import Database from "better-sqlite3";
import type { Action } from "./contracts/action"; import type { Action } from "./contracts/action";
import type { Entity } from "./contracts/entity"; 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 { Turn } from "./contracts/turn";
import type { ValidationResult } from "./contracts/validation"; import type { ValidationResult } from "./contracts/validation";
import type { WorldState } from "./contracts/world"; import type { WorldState } from "./contracts/world";
@@ -20,10 +22,15 @@ export interface CharacterGardenDatabase {
listEntities(): Entity[]; listEntities(): Entity[];
insertTurn(turn: Turn): void; insertTurn(turn: Turn): void;
listTurns(): Turn[]; listTurns(): Turn[];
insertInterpreterOutput(turnId: string, interpreter: InterpreterOutput): void;
insertActions(turnId: string, actions: Action[]): void; insertActions(turnId: string, actions: Action[]): void;
insertValidationResults(turnId: string, results: ValidationResult[]): void; insertValidationResults(turnId: string, results: ValidationResult[]): void;
insertWorldState(turnId: string | null, worldState: WorldState): void; insertWorldState(turnId: string | null, worldState: WorldState): void;
getLatestWorldState(): WorldState | null; getLatestWorldState(): WorldState | null;
upsertRulebook(rulebook: SceneRulebook): void;
getRulebook(id: string): SceneRulebook | null;
listRulebooks(): SceneRulebook[];
deleteRulebook(id: string): void;
wipe(): void; wipe(): void;
} }
@@ -88,12 +95,39 @@ export function createDatabase(config: DatabaseConfig): CharacterGardenDatabase
FOREIGN KEY(turn_id) REFERENCES turns(id) FOREIGN KEY(turn_id) REFERENCES turns(id)
) )
`, `,
`
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,
created_at INTEGER NOT NULL,
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) { for (const statement of initStatements) {
sqlite.exec(statement); 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 clearEntitiesStatement = sqlite.prepare("DELETE FROM entities");
const upsertEntityStatement = sqlite.prepare(` const upsertEntityStatement = sqlite.prepare(`
INSERT INTO entities (id, name, type, attributes_json) INSERT INTO entities (id, name, type, attributes_json)
@@ -121,6 +155,31 @@ export function createDatabase(config: DatabaseConfig): CharacterGardenDatabase
ORDER BY created_at ASC 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
`);
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(` const insertActionStatement = sqlite.prepare(`
INSERT INTO actions ( INSERT INTO actions (
turn_id, turn_id,
@@ -169,6 +228,33 @@ export function createDatabase(config: DatabaseConfig): CharacterGardenDatabase
LIMIT 1 LIMIT 1
`); `);
const upsertRulebookStatement = sqlite.prepare(`
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,
updated_at = excluded.updated_at
`);
const getRulebookStatement = sqlite.prepare(`
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, version, name, description, rules_json, created_at, updated_at
FROM rulebooks
ORDER BY created_at ASC
`);
const deleteRulebookStatement = sqlite.prepare(`
DELETE FROM rulebooks WHERE id = @id
`);
return { return {
sqlite, sqlite,
@@ -182,11 +268,13 @@ export function createDatabase(config: DatabaseConfig): CharacterGardenDatabase
wipe() { wipe() {
sqlite.exec(` sqlite.exec(`
DELETE FROM interpreter_events;
DELETE FROM validation_results; DELETE FROM validation_results;
DELETE FROM actions; DELETE FROM actions;
DELETE FROM world_states; DELETE FROM world_states;
DELETE FROM turns; DELETE FROM turns;
DELETE FROM entities; DELETE FROM entities;
DELETE FROM rulebooks;
`); `);
}, },
@@ -237,15 +325,74 @@ export function createDatabase(config: DatabaseConfig): CharacterGardenDatabase
created_at: number; created_at: number;
}>; }>;
const interpreterRows = listInterpreterEventsStatement.all() as Array<{
turn_id: string;
interpreter_json: string;
}>;
const interpreterByTurnId = new Map<string, InterpreterOutput>();
for (const row of interpreterRows) {
interpreterByTurnId.set(row.turn_id, parseJson<InterpreterOutput>(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<string, Action[]>();
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<Record<string, unknown>>(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<string, ValidationResult[]>();
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) => ({ return rows.map((row) => ({
id: row.id, id: row.id,
rawText: row.raw_text, rawText: row.raw_text,
actions: [], actions: actionsByTurnId.get(row.id) ?? [],
validation: [], validation: validationByTurnId.get(row.id) ?? [],
createdAt: row.created_at, 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) { insertActions(turnId, actions) {
const tx = sqlite.transaction((actionList: Action[]) => { const tx = sqlite.transaction((actionList: Action[]) => {
actionList.forEach((action, index) => { actionList.forEach((action, index) => {
@@ -296,5 +443,71 @@ export function createDatabase(config: DatabaseConfig): CharacterGardenDatabase
} }
return parseJson<WorldState>(row.state_json); return parseJson<WorldState>(row.state_json);
}, },
upsertRulebook(rulebook) {
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),
created_at: rulebook.createdAt,
updated_at: rulebook.updatedAt,
});
},
getRulebook(id) {
const row = getRulebookStatement.get({ id }) as
| {
id: string;
world_id: string;
version: number;
name: string;
description: string | null;
rules_json: string;
created_at: number;
updated_at: number;
}
| undefined;
if (!row) return null;
return {
id: row.id,
worldId: row.world_id,
version: row.version ?? 1,
name: row.name,
description: row.description ?? undefined,
rules: parseJson(row.rules_json),
createdAt: row.created_at,
updatedAt: row.updated_at,
};
},
listRulebooks() {
const rows = listRulebooksStatement.all() as Array<{
id: string;
world_id: string;
version: number;
name: string;
description: string | null;
rules_json: string;
created_at: number;
updated_at: number;
}>;
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),
createdAt: row.created_at,
updatedAt: row.updated_at,
}));
},
deleteRulebook(id) {
deleteRulebookStatement.run({ id });
},
}; };
} }

View File

@@ -0,0 +1,307 @@
import type { SceneRulebook } from "./contracts/rulebook";
export const DEFAULT_RULEBOOK_ID = "rulebook_default";
/**
* Builds the default SceneRulebook, encoding all validation logic that was
* previously hardcoded in the truth engine as editable, data-driven rules.
*/
export function createDefaultRulebook(worldId: string): SceneRulebook {
const now = Date.now();
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,
updatedAt: now,
rules: [
{
actionType: "inspect",
enabled: true,
checks: [],
},
{
actionType: "take",
enabled: true,
checks: [
{
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, and actor '{actor.id}' is not allowed to create missing items.",
},
{
id: "take_same_location",
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: "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.",
},
],
},
{
actionType: "open",
enabled: true,
checks: [
{
id: "open_target_exists",
description: "Target entity must exist in the world",
condition: { op: "entityExists", role: "target" },
failReason: "target_not_found",
failMessage: "Target '{target.id}' does not exist.",
},
{
id: "open_openable",
description: "Target must have openable attribute set to true",
condition: { op: "eq", role: "target", attribute: "openable", value: true },
failReason: "not_openable",
failMessage: "Target '{target.id}' is not openable.",
},
{
id: "open_lock_check",
description: "If target is locked, actor must possess the required key (has_<requiredKey> attribute)",
condition: {
op: "or",
conditions: [
{
op: "not",
condition: { op: "eq", role: "target", attribute: "locked", value: true },
},
{
op: "attributeRef",
checkRole: "actor",
prefix: "has_",
refRole: "target",
refAttribute: "requiredKey",
},
],
},
failReason: "locked_requires_key",
failMessage: "Target '{target.id}' is locked and requires a key.",
},
],
},
{
actionType: "move",
enabled: true,
checks: [
{
id: "move_target_is_room",
description: "Target must be an existing entity of type 'room'",
condition: {
op: "and",
conditions: [
{ op: "entityExists", role: "target" },
{ op: "entityType", role: "target", requiredType: "room" },
],
},
failReason: "target_not_found",
failMessage: "Move target '{target.id}' is not a valid room.",
},
],
},
{
actionType: "introduce",
enabled: true,
checks: [
{
id: "introduce_actor_authorized",
description: "Only approved characters can introduce/create characters in-scene",
condition: {
op: "actorIdIn",
allowedIds: ["player"],
},
failReason: "actor_not_authorized",
failMessage: "Actor '{actor.id}' is not allowed to introduce new characters.",
},
{
id: "introduce_actor_in_room",
description: "Actor must be located in a valid room entity",
condition: {
op: "and",
conditions: [
{ op: "entityExists", role: "actorRoom" },
{ op: "entityType", role: "actorRoom", requiredType: "room" },
],
},
failReason: "room_not_found",
failMessage: "Actor '{actor.id}' is not currently in a valid room.",
},
{
id: "introduce_room_joinable",
description: "Actor's room must allow new arrivals (is_joinable: true)",
condition: { op: "eq", role: "actorRoom", attribute: "is_joinable", value: true },
failReason: "room_not_joinable",
failMessage: "Room is not available for new arrivals.",
},
{
id: "introduce_target_is_character",
description: "If target entity exists, it must be of type 'character'",
condition: {
op: "or",
conditions: [
{ op: "not", condition: { op: "entityExists", role: "target" } },
{ op: "entityType", role: "target", requiredType: "character" },
],
},
failReason: "target_not_character",
failMessage: "Target '{target.id}' is not a character and cannot join the scene.",
},
{
id: "introduce_target_social",
description: "If target exists, it must be socially available (is_social: true)",
condition: {
op: "or",
conditions: [
{ op: "not", condition: { op: "entityExists", role: "target" } },
{ op: "eq", role: "target", attribute: "is_social", value: true },
],
},
failReason: "target_not_social",
failMessage: "Target '{target.id}' is not socially available to join the scene.",
},
{
id: "introduce_not_already_present",
description: "If target exists, it must not already be in the same room as the actor",
condition: {
op: "or",
conditions: [
{ op: "not", condition: { op: "entityExists", role: "target" } },
{
op: "not",
condition: { op: "sameLocation", roleA: "actor", roleB: "target" },
},
],
},
failReason: "already_in_scene",
failMessage: "Target '{target.id}' is already present in the scene.",
},
{
id: "introduce_no_name_duplicate",
description: "When introducing a new character by name, no character with that name may already be in the room",
condition: {
op: "metaValueNotInRoom",
metaKey: "characterName",
entityType: "character",
},
failReason: "already_in_scene",
failMessage: "A character with this name is already present in the scene.",
},
],
},
{
actionType: "describe",
enabled: true,
checks: [
{
id: "describe_target_exists",
description: "Target must exist in the world or be created earlier in this turn",
condition: { op: "entityExistsOrWillBeCreated", role: "target" },
failReason: "target_not_found",
failMessage: "Target '{target.id}' does not exist.",
},
{
id: "describe_target_is_character",
description: "If target exists, it must be of type 'character'",
condition: {
op: "or",
conditions: [
{ op: "not", condition: { op: "entityExists", role: "target" } },
{ op: "entityType", role: "target", requiredType: "character" },
],
},
failReason: "target_not_character",
failMessage: "Target '{target.id}' is not a character and cannot be described.",
},
{
id: "describe_same_location",
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}'.",
},
],
},
{
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.",
},
],
},
],
};
}

View File

@@ -1,6 +1,7 @@
import Fastify from "fastify"; import Fastify from "fastify";
import { createCharacterGardenApp } from "./app"; import { createCharacterGardenApp } from "./app";
import type { SceneRulebook } from "./contracts/rulebook";
const port = Number(process.env.APP_PORT ?? 3000); const port = Number(process.env.APP_PORT ?? 3000);
const host = process.env.APP_HOST ?? "0.0.0.0"; const host = process.env.APP_HOST ?? "0.0.0.0";
@@ -16,15 +17,35 @@ server.get("/api/state", async () => game.getSnapshot());
server.post("/api/reset", async () => game.reset()); server.post("/api/reset", async () => game.reset());
server.post<{ Body: { input?: string } }>("/api/turn", async (request, reply) => { server.post<{ Body: { input?: string } }>("/api/turn", async (request, reply) => {
const input = request.body?.input?.trim(); const input = request.body?.input;
if (!input) { if (typeof input !== "string") {
reply.code(400); 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);
}); });
// ---------------------------------------------------------------------------
// Rulebook endpoints
// ---------------------------------------------------------------------------
/** GET /api/rulebook — returns the currently active rulebook. */
server.get("/api/rulebook", async () => game.getRulebook());
/** PUT /api/rulebook — replace (or create) the active rulebook. */
server.put<{ Body: SceneRulebook }>("/api/rulebook", async (request, reply) => {
const body = request.body;
if (!body || typeof body.id !== "string" || !Array.isArray(body.rules)) {
reply.code(400);
return { error: "Invalid rulebook payload. Must include id (string) and rules (array)." };
}
return game.upsertRulebook(body);
});
/** GET /api/rulebooks — list all saved rulebooks (name + id summary). */
server.get("/api/rulebooks", async () => game.listRulebooks());
async function start(): Promise<void> { async function start(): Promise<void> {
try { try {
await server.listen({ host, port }); await server.listen({ host, port });

View File

@@ -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" },
],
},
};
}

View File

@@ -0,0 +1,361 @@
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";
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<string, unknown>;
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<string, unknown>;
}
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";
}
}
/**
* 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.",
"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}`,
`minimum_confidence: ${input.minConfidence}`,
`world_context:\n${worldContext}`,
`player_input: ${JSON.stringify(input.rawText)}`,
].join("\n\n");
return { system, user };
}
export async function resolveLlmIntent(input: ResolveIntentInput): Promise<InterpreterOutput> {
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);
}
}

View File

@@ -0,0 +1,70 @@
import type { InterpreterOutput } from "../contracts/intent";
import type { WorldState } from "../contracts/world";
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;
worldState?: WorldState;
};
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,
worldState: options?.worldState,
};
}
export async function interpretTurn(
rawText: string,
actorId = "player",
options?: InterpretTurnOptions
): Promise<InterpreterOutput> {
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,
],
};
}

View File

@@ -0,0 +1,25 @@
import type { InterpreterOutput } from "../contracts/intent";
import type { WorldState } from "../contracts/world";
export type ResolverMode = "deterministic" | "llm" | "hybrid";
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 = {
name: string;
resolve(input: ResolveIntentInput): Promise<InterpreterOutput> | InterpreterOutput;
};
export function normalizeResolverMode(value: string | undefined): ResolverMode {
const normalized = (value ?? "").trim().toLowerCase();
if (normalized === "deterministic" || normalized === "llm" || normalized === "hybrid") {
return normalized;
}
return "hybrid";
}

View File

@@ -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,
};
}

View File

@@ -4,39 +4,218 @@ function normalized(input: string): string {
return input.trim().toLowerCase(); return input.trim().toLowerCase();
} }
export function parseTextToActions(text: string, actorId = "player"): Action[] { function toDisplayName(value: string): string {
const input = normalized(text); return value
.split(/\s+/)
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.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();
if (!rawName) {
return undefined;
}
return rawName.replace(/^(the|a|an)\s+/, "").trim() || undefined;
}
function extractActorAndAction(sentence: string): { actorName?: string; action: string } {
const normalized_sent = normalized(sentence);
// For now, treat the entire sentence as an action with no explicit actor
// In future, we can add patterns like "spencer introduces jeff" -> { actorName: "spencer", action: "introduces jeff" }
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) { if (!input) {
return []; return undefined;
} }
if (/(look|inspect|examine)/.test(input)) { if (/(look|inspect|examine)/.test(input)) {
return [{ actorId, type: "inspect", targetId: actorId }]; return { actorId: defaultActorId, type: "inspect", targetId: defaultActorId };
} }
if (/(go|move|walk|head|travel)/.test(input)) { if (/(go|move|walk|head|travel)/.test(input)) {
if (input.includes("exit") || input.includes("next room") || input.includes("through door")) { if (input.includes("exit") || input.includes("next room") || input.includes("through door")) {
return [{ actorId, type: "move", targetId: "room_exit" }]; return { actorId: defaultActorId, type: "move", targetId: "room_exit" };
} }
if (input.includes("start")) { if (input.includes("start")) {
return [{ actorId, type: "move", targetId: "room_start" }]; return { actorId: defaultActorId, type: "move", targetId: "room_start" };
} }
return []; return undefined;
} }
if (/(open)/.test(input)) { if (/(open)/.test(input)) {
if (input.includes("door")) { if (input.includes("door")) {
return [{ actorId, type: "open", targetId: "door_1" }]; return { actorId: defaultActorId, type: "open", targetId: "door_1" };
} }
return []; return undefined;
} }
if (/(take|pick up|grab)/.test(input)) { if (/(take|pick up|grab)/.test(input)) {
if (input.includes("key")) { if (input.includes("key")) {
return [{ actorId, type: "take", targetId: "key_1" }]; return { actorId: defaultActorId, type: "take", targetId: "key_1" };
} }
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)) {
if (input.includes("groundskeeper")) {
return { actorId: defaultActorId, type: "introduce", targetId: "groundskeeper" };
}
const characterName = extractIntroducedCharacterName(input);
if (!characterName) {
return undefined;
}
return {
actorId: defaultActorId,
type: "introduce",
metadata: {
characterName,
displayName: toDisplayName(characterName),
createIfMissing: true,
},
};
}
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+(.+)$/) ||
input.match(/(?:the\s+)?([a-z\s_]+?)\s+(?:is|has)\s+(.+)$/);
if (describeMatch) {
const [_, targetNameRaw, trait] = describeMatch;
const targetName = targetNameRaw.trim().replace(/^the\s+/, "").trim();
const targetId = `character_${targetName.replace(/\s+/g, "_")}`;
return {
actorId: defaultActorId,
type: "describe",
targetId,
metadata: {
trait: trait.trim(),
},
};
}
return undefined;
}
return undefined;
}
export function parseTextToActions(text: string, actorId = "player"): Action[] {
if (!text || !text.trim()) {
return []; return [];
} }
return []; // Split by sentence terminators
const sentences = text.split(/[.!?]+/).map((s) => s.trim()).filter(Boolean);
const actions: Action[] = [];
for (const sentence of sentences) {
const { actorName, action } = extractActorAndAction(sentence);
const resolvedActorId = actorName ? `character_${actorName}` : actorId;
const parsedAction = parseSingleAction(action, resolvedActorId);
if (parsedAction) {
actions.push(parsedAction);
} }
}
return actions;
}

View File

@@ -0,0 +1,299 @@
import type { Action } from "./contracts/action";
import type { Entity } from "./contracts/entity";
import type {
ActionRuleSet,
ConditionExpr,
EntityRole,
SceneRulebook,
} from "./contracts/rulebook";
import type { ValidationResult } from "./contracts/validation";
import type { WorldState } from "./contracts/world";
// ---------------------------------------------------------------------------
// Internal evaluation context
// ---------------------------------------------------------------------------
interface EvalContext {
action: Action;
worldState: WorldState;
/** Entity IDs that will be created by introduce actions earlier in this turn. */
willBeCreated: Set<string>;
entities: Record<EntityRole, Entity | undefined>;
}
function resolveEntities(
action: Action,
worldState: WorldState
): Record<EntityRole, Entity | undefined> {
const actor = worldState.entities[action.actorId];
const target = action.targetId ? worldState.entities[action.targetId] : undefined;
const actorRoom = actor
? worldState.entities[String(actor.attributes.location ?? "")]
: undefined;
const targetRoom = target
? worldState.entities[String(target.attributes.location ?? "")]
: undefined;
return { actor, target, actorRoom, targetRoom };
}
/** Read a field from an entity — id/name/type are first-class; anything else reads from attributes. */
function getEntityField(entity: Entity, attribute: string): unknown {
if (attribute === "id") return entity.id;
if (attribute === "name") return entity.name;
if (attribute === "type") return entity.type;
return entity.attributes[attribute];
}
// ---------------------------------------------------------------------------
// Condition evaluator
// ---------------------------------------------------------------------------
function evaluate(expr: ConditionExpr, ctx: EvalContext): boolean {
switch (expr.op) {
case "and":
return expr.conditions.every((c) => evaluate(c, ctx));
case "or":
return expr.conditions.some((c) => evaluate(c, ctx));
case "not":
return !evaluate(expr.condition, ctx);
case "entityExists": {
return ctx.entities[expr.role] !== undefined;
}
case "entityExistsOrWillBeCreated": {
const entity = ctx.entities[expr.role];
if (entity) return true;
const roleId =
expr.role === "target" ? ctx.action.targetId : undefined;
return roleId !== undefined && ctx.willBeCreated.has(roleId);
}
case "entityType": {
const entity = ctx.entities[expr.role];
if (!entity) return false;
return entity.type === expr.requiredType;
}
case "eq": {
const entity = ctx.entities[expr.role];
if (!entity) return false;
return getEntityField(entity, expr.attribute) === expr.value;
}
case "neq": {
const entity = ctx.entities[expr.role];
if (!entity) return false;
return getEntityField(entity, expr.attribute) !== expr.value;
}
case "attributeExists": {
const entity = ctx.entities[expr.role];
if (!entity) return false;
return entity.attributes[expr.attribute] !== undefined;
}
case "sameLocation": {
const entityA = ctx.entities[expr.roleA];
const entityB = ctx.entities[expr.roleB];
if (!entityA || !entityB) return false;
const locA = String(entityA.attributes.location ?? "");
const locB = String(entityB.attributes.location ?? "");
return locA !== "" && locA === locB;
}
case "actorIdIn": {
return expr.allowedIds.includes(ctx.action.actorId);
}
case "actorNameIn": {
const actor = ctx.entities.actor;
if (!actor) return false;
const actorName = actor.name.trim().toLowerCase();
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];
if (!checkEntity || !refEntity) return false;
const refValue = String(refEntity.attributes[expr.refAttribute] ?? "");
if (!refValue) return false;
const dynamicKey = (expr.prefix ?? "") + refValue;
return checkEntity.attributes[dynamicKey] === true;
}
case "metaValueNotInRoom": {
// Passes when: no target exists yet (new-character path) AND no entity
// of the given type in the actor's room already has the same name.
if (ctx.entities.target) {
// Target already exists — this check is not applicable (handled by
// introduce_not_already_present instead).
return true;
}
const metaValue = ctx.action.metadata?.[expr.metaKey];
if (typeof metaValue !== "string" || !metaValue.trim()) {
return true; // No name supplied → nothing to deduplicate.
}
const actor = ctx.entities.actor;
if (!actor) return true;
const actorLocation = String(actor.attributes.location ?? "");
const normalizedName = metaValue.trim().toLowerCase();
const hasDuplicate = Object.values(ctx.worldState.entities).some(
(e) =>
e.type === expr.entityType &&
String(e.attributes.location ?? "") === actorLocation &&
e.name.trim().toLowerCase() === normalizedName
);
return !hasDuplicate;
}
default: {
// Exhaustiveness guard — TypeScript will warn if a new op is added
// to ConditionExpr without a case here.
const exhaustiveCheck: never = expr;
console.warn("rulebookEngine: unhandled condition op", exhaustiveCheck);
return false;
}
}
}
// ---------------------------------------------------------------------------
// Message template resolution
// ---------------------------------------------------------------------------
function resolveMessage(
template: string,
action: Action,
worldState: WorldState
): string {
const actor = worldState.entities[action.actorId];
const target = action.targetId ? worldState.entities[action.targetId] : undefined;
return template
.replace(/\{actor\.id\}/g, action.actorId)
.replace(/\{actor\.name\}/g, actor?.name ?? action.actorId)
.replace(/\{target\.id\}/g, action.targetId ?? "(missing)")
.replace(/\{target\.name\}/g, target?.name ?? action.targetId ?? "(missing)");
}
// ---------------------------------------------------------------------------
// Pre-pass: collect entity IDs that will be created by introduce actions
// ---------------------------------------------------------------------------
function collectWillBeCreated(
actions: Action[],
worldState: WorldState
): Set<string> {
const willBeCreated = new Set<string>();
for (const action of actions) {
if (action.type !== "introduce") continue;
if (worldState.entities[action.targetId ?? ""]) continue; // target already exists
const characterName =
typeof action.metadata?.characterName === "string"
? action.metadata.characterName.trim()
: typeof action.metadata?.displayName === "string"
? action.metadata.displayName.trim()
: null;
if (!characterName) continue;
// Mirror the ID scheme used in applyActions.ts createCharacterId / slugify.
const slug = characterName
.toLowerCase()
.replace(/[^a-z0-9]+/g, "_")
.replace(/^_+|_+$/g, "") || "character";
willBeCreated.add(`character_${slug}`);
}
return willBeCreated;
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Validate a list of actions against a SceneRulebook.
*
* - If an action's type has no matching ActionRuleSet, it fails as "unknown_action".
* - If a matching ActionRuleSet has enabled: false, all checks are skipped and
* the action passes (useful for temporarily disabling enforcement).
* - Checks run in order; the first failing check short-circuits the rest.
*/
export function validateWithRulebook(
actions: Action[],
worldState: WorldState,
rulebook: SceneRulebook
): ValidationResult[] {
const ruleIndex = new Map<string, ActionRuleSet>();
for (const ruleSet of rulebook.rules) {
ruleIndex.set(ruleSet.actionType, ruleSet);
}
const willBeCreated = collectWillBeCreated(actions, worldState);
return actions.map((action, actionIndex): ValidationResult => {
const actor = worldState.entities[action.actorId];
if (!actor) {
return {
actionIndex,
success: false,
reason: "actor_not_found",
message: `Actor '${action.actorId}' does not exist.`,
};
}
const ruleSet = ruleIndex.get(action.type);
if (!ruleSet) {
return {
actionIndex,
success: false,
reason: "unknown_action",
message: `Action type '${action.type}' is not supported.`,
};
}
if (!ruleSet.enabled) {
return { actionIndex, success: true };
}
const ctx: EvalContext = {
action,
worldState,
willBeCreated,
entities: resolveEntities(action, worldState),
};
for (const check of ruleSet.checks) {
const passes = evaluate(check.condition, ctx);
if (!passes) {
return {
actionIndex,
success: false,
reason: check.failReason,
message: resolveMessage(check.failMessage, action, worldState),
};
}
}
return { actionIndex, success: true };
});
}

View File

@@ -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<void> {
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);
});

View File

@@ -0,0 +1,6 @@
import Database from "better-sqlite3";
const db = new Database(process.env.DB_PATH ?? "/var/lib/charactergarden/app.db");
db.prepare("CREATE TABLE IF NOT EXISTS probe(value TEXT)").run();
db.close();
console.log("sqlite_probe_ok");

View File

@@ -0,0 +1,34 @@
import assert from "node:assert/strict";
import { createCharacterGardenApp } from "../app";
function run(): void {
const app = createCharacterGardenApp(process.env.DB_PATH ?? "/var/lib/charactergarden/app.db");
const snapshot = app.getSnapshot();
const rulebook = app.getRulebook();
assert.equal(snapshot.turns.length, 0, "Expected no persisted turns after database reset.");
assert.ok(snapshot.worldState.entities.player, "Expected player in default world state.");
assert.ok(snapshot.worldState.entities.room_start, "Expected room_start in default world state.");
assert.equal(rulebook.id, "rulebook_default", "Expected default rulebook to be active.");
assert.equal(rulebook.version, 1, "Expected default rulebook version to be 1.");
console.log(
JSON.stringify(
{
ok: true,
entityCount: Object.keys(snapshot.worldState.entities).length,
turnCount: snapshot.turns.length,
rulebookId: rulebook.id,
rulebookVersion: rulebook.version,
ruleCount: rulebook.rules.length,
},
null,
2
)
);
app.db.close();
}
run();

View File

@@ -1,125 +0,0 @@
import type { Action } from "./contracts/action";
import type { Entity } from "./contracts/entity";
import type { ValidationResult } from "./contracts/validation";
import type { WorldState } from "./contracts/world";
function getEntity(worldState: WorldState, entityId: string | undefined): Entity | undefined {
if (!entityId) {
return undefined;
}
return worldState.entities[entityId];
}
function hasKey(actor: Entity, requiredKeyId: string): boolean {
return actor.attributes[`has_${requiredKeyId}`] === true;
}
export function validateActions(actions: Action[], worldState: WorldState): ValidationResult[] {
return actions.map((action, actionIndex): ValidationResult => {
const actor = getEntity(worldState, action.actorId);
if (!actor) {
return {
actionIndex,
success: false,
reason: "actor_not_found",
message: `Actor '${action.actorId}' does not exist.`,
};
}
switch (action.type) {
case "inspect":
return { actionIndex, success: true };
case "take": {
const target = getEntity(worldState, action.targetId);
if (!target) {
return {
actionIndex,
success: false,
reason: "target_not_found",
message: `Target '${action.targetId ?? "(missing)"}' does not exist.`,
};
}
const actorLocation = String(actor.attributes.location ?? "");
const targetLocation = String(target.attributes.location ?? "");
if (actorLocation !== targetLocation) {
return {
actionIndex,
success: false,
reason: "not_in_same_location",
message: `Target '${target.id}' is not in the same location as '${actor.id}'.`,
};
}
if (target.attributes.takeable !== true) {
return {
actionIndex,
success: false,
reason: "not_takeable",
message: `Target '${target.id}' cannot be taken.`,
};
}
return { actionIndex, success: true };
}
case "open": {
const target = getEntity(worldState, action.targetId);
if (!target) {
return {
actionIndex,
success: false,
reason: "target_not_found",
message: `Target '${action.targetId ?? "(missing)"}' does not exist.`,
};
}
if (target.attributes.openable !== true) {
return {
actionIndex,
success: false,
reason: "not_openable",
message: `Target '${target.id}' is not openable.`,
};
}
if (target.attributes.locked === true) {
const requiredKey = String(target.attributes.requiredKey ?? "key_1");
if (!hasKey(actor, requiredKey)) {
return {
actionIndex,
success: false,
reason: "locked_requires_key",
message: `Target '${target.id}' is locked and requires '${requiredKey}'.`,
};
}
}
return { actionIndex, success: true };
}
case "move": {
const target = getEntity(worldState, action.targetId);
if (!target || target.type !== "room") {
return {
actionIndex,
success: false,
reason: "target_not_found",
message: `Move target '${action.targetId ?? "(missing)"}' is not a valid room.`,
};
}
return { actionIndex, success: true };
}
default:
return {
actionIndex,
success: false,
reason: "unknown_action",
message: `Action type '${action.type}' is not supported.`,
};
}
});
}

View File

@@ -1,48 +0,0 @@
import { randomUUID } from "node:crypto";
import type { CharacterGardenDatabase } from "../db";
import type { Action } from "../contracts/action";
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";
export type ProcessTurnResponse = {
rawText: string;
actions: Action[];
validation: ValidationResult[];
worldState: WorldState;
};
export function processTurn(
rawText: string,
worldState: WorldState,
db: CharacterGardenDatabase
): ProcessTurnResponse {
const actions = parseTextToActions(rawText);
const validation = validateActions(actions, worldState);
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,
};
}

View File

@@ -0,0 +1,111 @@
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 { createDefaultRulebook } from "../defaultRulebook";
import { validateWithRulebook } from "../rulebookEngine";
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<TurnManagerResponse> {
const interpreted = await interpretTurn(rawText, "player", { worldState });
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 activeRulebook = rulebook ?? createDefaultRulebook(worldState.id);
const validation = validateWithRulebook(actions, worldState, activeRulebook);
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,
};
}

View File

@@ -21,6 +21,65 @@ function cloneWorldState(worldState: WorldState): WorldState {
}; };
} }
function slugify(value: string): string {
return value
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "_")
.replace(/^_+|_+$/g, "") || "character";
}
function createCharacterId(worldState: WorldState, baseName: string): string {
const baseId = `character_${slugify(baseName)}`;
if (!worldState.entities[baseId]) {
return baseId;
}
let suffix = 2;
while (worldState.entities[`${baseId}_${suffix}`]) {
suffix += 1;
}
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()) {
return displayName.trim();
}
const characterName = action.metadata?.characterName;
if (typeof characterName === "string" && characterName.trim()) {
return characterName
.trim()
.split(/\s+/)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(" ");
}
return undefined;
}
export function applyActions( export function applyActions(
actions: Action[], actions: Action[],
results: ValidationResult[], results: ValidationResult[],
@@ -50,9 +109,21 @@ export function applyActions(
case "take": case "take":
if (actor && target) { if (actor && target) {
target.attributes.location = `inventory:${actor.id}`; target.attributes.location = `inventory:${actor.id}`;
if (target.id === "key_1") { // Set has_<item_id> on the actor so attributeRef rulebook checks work
actor.attributes.has_key_1 = true; // 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,
name: inferItemName(action),
type: "item",
attributes: {
location: `inventory:${actor.id}`,
takeable: true,
created_by_action: "take",
created_by_actor: actor.id,
},
};
} }
break; break;
case "open": case "open":
@@ -60,6 +131,56 @@ export function applyActions(
target.attributes.open = true; target.attributes.open = true;
} }
break; break;
case "introduce":
if (actor && target) {
target.attributes.location = actor.attributes.location;
target.attributes.in_scene = true;
target.attributes.last_introduced_by = actor.id;
} else if (actor) {
const characterName = getActionCharacterName(action);
if (!characterName) {
break;
}
const characterId = createCharacterId(nextState, characterName);
nextState.entities[characterId] = {
id: characterId,
name: characterName,
type: "character",
attributes: {
location: actor.attributes.location,
is_social: true,
in_scene: true,
created_by_action: "introduce",
last_introduced_by: actor.id,
},
};
}
break;
case "describe":
if (target) {
const trait = action.metadata?.trait;
if (typeof trait === "string" && trait.trim()) {
const traits = Array.isArray(target.attributes.traits)
? target.attributes.traits
: [];
target.attributes.traits = [...traits, trait.trim()];
}
}
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": case "inspect":
default: default:
break; break;

View File

@@ -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<string, Entity> = {
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<string, Entity> = {};
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;
}

View File

@@ -3,14 +3,18 @@ services:
build: ./app build: ./app
working_dir: /app working_dir: /app
ports: ports:
- "${APP_PORT:-3000}:3000" - "${APP_PORT:-3024}:3024"
environment: environment:
- APP_PORT=3000 - APP_PORT=3024
- NODE_ENV=${NODE_ENV:-development} - NODE_ENV=${NODE_ENV:-development}
- DB_PATH=/data/sqlite/app.db - DB_PATH=/var/lib/charactergarden/app.db
- OLLAMA_URL=${OLLAMA_URL:-http://ollama:11434} - 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: volumes:
- ./data:/data - sqlite_data:/var/lib/charactergarden
- ./app/src:/app/src - ./app/src:/app/src
frontend: frontend:
@@ -35,6 +39,24 @@ services:
- ollama_data:/root/.ollama - ollama_data:/root/.ollama
profiles: profiles:
- llm - 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: volumes:
ollama_data: ollama_data:
sqlite_data:

View File

@@ -1,4 +1,4 @@
import { FormEvent, useEffect, useState } from "react"; import { FormEvent, useEffect, useMemo, useState } from "react";
type Entity = { type Entity = {
id: string; id: string;
@@ -27,6 +27,29 @@ type Turn = {
actions: Action[]; actions: Action[];
validation: ValidationResult[]; validation: ValidationResult[];
createdAt: number; createdAt: number;
interpreter?: {
interpreterVersion: string;
resolutionSource: "deterministic" | "llm" | "hybrid";
minConfidence: number;
status: "resolved" | "needs_clarification" | "rejected";
selectedConfidence?: number;
candidates?: Array<{
action: Action;
confidence: number;
rationale?: string;
}>;
diagnostics: string[];
clarification?: {
reasonCode: string;
question: string;
field?: string;
options?: Array<{
id: string;
label: string;
value: string;
}>;
};
};
}; };
type WorldState = { type WorldState = {
@@ -46,15 +69,87 @@ type ProcessTurnResponse = {
actions: Action[]; actions: Action[];
validation: ValidationResult[]; validation: ValidationResult[];
worldState: WorldState; worldState: WorldState;
interpreter: {
interpreterVersion: string;
resolutionSource: "deterministic" | "llm" | "hybrid";
minConfidence: number;
status: "resolved" | "needs_clarification" | "rejected";
selectedConfidence?: number;
candidates: Array<{
action: Action;
confidence: number;
rationale?: string;
}>;
diagnostics: string[];
clarification?: {
reasonCode: string;
question: string;
field?: string;
options?: Array<{
id: string;
label: string;
value: string;
}>;
};
};
};
type RuleCheck = {
id: string;
description: string;
condition: unknown;
failReason: string;
failMessage: string;
};
type ActionRuleSet = {
actionType: string;
enabled: boolean;
checks: RuleCheck[];
};
type SceneRulebook = {
id: string;
worldId: string;
version: number;
name: string;
description?: string;
rules: ActionRuleSet[];
createdAt: number;
updatedAt: number;
};
type RulebookListItem = {
id: string;
name: string;
version: number;
updatedAt: number;
}; };
const starterPrompts = [ const starterPrompts = [
"look around", "look around",
"take key", "take key",
"take lantern",
"give key to groundskeeper",
"introduce jeff",
"describe groundskeeper as patient",
"open door", "open door",
"move to exit", "move to exit",
]; ];
function formatConfidence(value: number | undefined): string {
if (typeof value !== "number") return "n/a";
return `${Math.round(value * 100)}%`;
}
function formatTurnTime(epochMs: number): string {
try {
return new Date(epochMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
} catch {
return String(epochMs);
}
}
async function fetchJson<T>(input: RequestInfo, init?: RequestInit): Promise<T> { async function fetchJson<T>(input: RequestInfo, init?: RequestInit): Promise<T> {
const response = await fetch(input, init); const response = await fetch(input, init);
if (!response.ok) { if (!response.ok) {
@@ -63,6 +158,185 @@ async function fetchJson<T>(input: RequestInfo, init?: RequestInit): Promise<T>
return response.json() as Promise<T>; return response.json() as Promise<T>;
} }
// ---------------------------------------------------------------------------
// Rulebook editor component
// ---------------------------------------------------------------------------
function RulebookEditor(props: { onSaved?: (rulebook: SceneRulebook) => void }) {
const [rulebook, setRulebook] = useState<SceneRulebook | null>(null);
const [drafts, setDrafts] = useState<Record<string, string>>({});
const [parseErrors, setParseErrors] = useState<Record<string, string>>({});
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [saved, setSaved] = useState(false);
useEffect(() => {
void fetchJson<SceneRulebook>("/api/rulebook").then((rb) => {
setRulebook(rb);
const initial: Record<string, string> = {};
for (const ruleSet of rb.rules) {
initial[ruleSet.actionType] = JSON.stringify(ruleSet.checks, null, 2);
}
setDrafts(initial);
});
}, []);
function toggleEnabled(actionType: string) {
if (!rulebook) return;
setRulebook({
...rulebook,
rules: rulebook.rules.map((r) =>
r.actionType === actionType ? { ...r, enabled: !r.enabled } : r
),
});
}
function updateDraft(actionType: string, value: string) {
setDrafts((prev) => ({ ...prev, [actionType]: value }));
setParseErrors((prev) => {
const next = { ...prev };
delete next[actionType];
return next;
});
}
async function saveRulebook() {
if (!rulebook) return;
setSaveError(null);
setSaved(false);
// Validate and apply all drafts.
const updatedRules: ActionRuleSet[] = [];
const newErrors: Record<string, string> = {};
for (const ruleSet of rulebook.rules) {
const draft = drafts[ruleSet.actionType] ?? JSON.stringify(ruleSet.checks, null, 2);
try {
const parsedChecks = JSON.parse(draft) as RuleCheck[];
updatedRules.push({ ...ruleSet, checks: parsedChecks });
} catch {
newErrors[ruleSet.actionType] = "Invalid JSON";
updatedRules.push(ruleSet);
}
}
if (Object.keys(newErrors).length > 0) {
setParseErrors(newErrors);
return;
}
setSaving(true);
try {
const updated = await fetchJson<SceneRulebook>("/api/rulebook", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...rulebook, rules: updatedRules }),
});
setRulebook(updated);
props.onSaved?.(updated);
setSaved(true);
setTimeout(() => setSaved(false), 2500);
} catch (err) {
setSaveError(err instanceof Error ? err.message : "Save failed");
} finally {
setSaving(false);
}
}
if (!rulebook) return <p>Loading rulebook</p>;
return (
<div className="rulebook-editor">
<div className="rulebook-header">
<div>
<p className="rulebook-name">{rulebook.name}</p>
<p className="rulebook-desc">
Version {rulebook.version} | Updated {formatTurnTime(rulebook.updatedAt)}
</p>
{rulebook.description ? (
<p className="rulebook-desc">{rulebook.description}</p>
) : null}
</div>
<button
type="button"
onClick={() => void saveRulebook()}
disabled={saving}
className="save-btn"
>
{saving ? "Saving…" : saved ? "Saved" : "Save rulebook"}
</button>
</div>
{saveError ? <p className="error-banner">{saveError}</p> : null}
<div className="rule-list">
{rulebook.rules.map((ruleSet) => (
<details key={ruleSet.actionType} className="rule-panel">
<summary className="rule-summary">
<span className="rule-action-type">{ruleSet.actionType}</span>
<span className="rule-check-count">{ruleSet.checks.length} check{ruleSet.checks.length !== 1 ? "s" : ""}</span>
<label
className="rule-toggle"
onClick={(e) => e.stopPropagation()}
>
<input
type="checkbox"
checked={ruleSet.enabled}
onChange={() => toggleEnabled(ruleSet.actionType)}
/>
{ruleSet.enabled ? "enforced" : "disabled"}
</label>
</summary>
<div className="rule-body">
<p className="rule-hint">
Edit the checks array below. Each check has: <code>id</code>, <code>description</code>, <code>condition</code>, <code>failReason</code>, <code>failMessage</code>.
</p>
<p className="rule-hint">
For character permissions, use <code>{`{"op":"actorIdIn","allowedIds":["player"]}`}</code> or <code>{`{"op":"actorNameIn","allowedNames":["Player"]}`}</code>.
</p>
<p className="rule-hint">
For conditional creation, use <code>{`{"op":"actionMetadataEq","key":"createIfMissing","value":true}`}</code> together with actor checks.
</p>
<p className="rule-hint">
For transfer ownership checks, use <code>{`{"op":"itemInInventory","itemMetadataKey":"itemId","holderRole":"actor"}`}</code>.
</p>
{ruleSet.checks.length > 0 ? (
<ul className="check-list">
{ruleSet.checks.map((check) => (
<li key={check.id} title={check.failReason}>
<span className="check-desc">{check.description}</span>
</li>
))}
</ul>
) : (
<p className="rule-hint">No checks action always passes.</p>
)}
<label className="json-label">
Checks JSON
<textarea
className={`json-editor${parseErrors[ruleSet.actionType] ? " json-error" : ""}`}
value={drafts[ruleSet.actionType] ?? ""}
onChange={(e) => updateDraft(ruleSet.actionType, e.target.value)}
rows={10}
spellCheck={false}
/>
</label>
{parseErrors[ruleSet.actionType] ? (
<p className="error-banner">{parseErrors[ruleSet.actionType]}</p>
) : null}
</div>
</details>
))}
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Main app
// ---------------------------------------------------------------------------
export default function App() { export default function App() {
const [snapshot, setSnapshot] = useState<AppSnapshot | null>(null); const [snapshot, setSnapshot] = useState<AppSnapshot | null>(null);
const [latest, setLatest] = useState<ProcessTurnResponse | null>(null); const [latest, setLatest] = useState<ProcessTurnResponse | null>(null);
@@ -70,10 +344,22 @@ export default function App() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [tab, setTab] = useState<"world" | "rulebook">("world");
const [activeRulebook, setActiveRulebook] = useState<SceneRulebook | null>(null);
const [rulebooks, setRulebooks] = useState<RulebookListItem[]>([]);
async function refreshRulebookState() {
const [active, list] = await Promise.all([
fetchJson<SceneRulebook>("/api/rulebook"),
fetchJson<RulebookListItem[]>("/api/rulebooks"),
]);
setActiveRulebook(active);
setRulebooks(list);
}
useEffect(() => { useEffect(() => {
void fetchJson<AppSnapshot>("/api/state") void Promise.all([fetchJson<AppSnapshot>("/api/state"), refreshRulebookState()])
.then((data) => { .then(([data]) => {
setSnapshot(data); setSnapshot(data);
setLoading(false); setLoading(false);
}) })
@@ -95,7 +381,10 @@ export default function App() {
body: JSON.stringify({ input }), body: JSON.stringify({ input }),
}); });
setLatest(result); setLatest(result);
const nextSnapshot = await fetchJson<AppSnapshot>("/api/state"); const [nextSnapshot] = await Promise.all([
fetchJson<AppSnapshot>("/api/state"),
refreshRulebookState(),
]);
setSnapshot(nextSnapshot); setSnapshot(nextSnapshot);
} catch (submitError) { } catch (submitError) {
setError(submitError instanceof Error ? submitError.message : "Unknown error"); setError(submitError instanceof Error ? submitError.message : "Unknown error");
@@ -114,12 +403,22 @@ export default function App() {
}); });
setSnapshot(result); setSnapshot(result);
setLatest(null); setLatest(null);
await refreshRulebookState();
} catch (resetError) { } catch (resetError) {
setError(resetError instanceof Error ? resetError.message : "Unknown error"); setError(resetError instanceof Error ? resetError.message : "Unknown error");
} }
} }
const entities = snapshot ? Object.values(snapshot.worldState.entities) : []; const entities = snapshot ? Object.values(snapshot.worldState.entities) : [];
const turns = useMemo(() => snapshot?.turns.slice().reverse() ?? [], [snapshot]);
function applyClarificationOption(optionValue: string) {
setInput((prev) => {
const trimmed = prev.trim();
if (!trimmed) return optionValue;
return `${trimmed} ${optionValue}`;
});
}
return ( return (
<main className="page-shell"> <main className="page-shell">
@@ -160,6 +459,35 @@ export default function App() {
<section className="result-card"> <section className="result-card">
<h2>Latest result</h2> <h2>Latest result</h2>
<p><strong>Input:</strong> {latest.rawText}</p> <p><strong>Input:</strong> {latest.rawText}</p>
<p>
<strong>Interpreter:</strong> {latest.interpreter.status}
{` via ${latest.interpreter.resolutionSource}`}
{` | model threshold ${formatConfidence(latest.interpreter.minConfidence)}`}
{` | selected ${formatConfidence(latest.interpreter.selectedConfidence)}`}
</p>
<p>
<strong>Interpreter version:</strong> {latest.interpreter.interpreterVersion}
</p>
{latest.interpreter.clarification ? (
<p>
<strong>Clarification ({latest.interpreter.clarification.reasonCode}):</strong>{" "}
{latest.interpreter.clarification.question}
</p>
) : null}
{latest.interpreter.clarification?.options?.length ? (
<div className="chips">
{latest.interpreter.clarification.options.map((o) => (
<button
key={o.id}
type="button"
className="chip"
onClick={() => applyClarificationOption(o.value)}
>
clarify: {o.label}
</button>
))}
</div>
) : null}
<ul className="timeline-list compact"> <ul className="timeline-list compact">
{latest.validation.map((v) => ( {latest.validation.map((v) => (
<li key={v.actionIndex}> <li key={v.actionIndex}>
@@ -167,8 +495,38 @@ export default function App() {
{v.message ? ` - ${v.message}` : ""} {v.message ? ` - ${v.message}` : ""}
</li> </li>
))} ))}
{latest.validation.length === 0 ? <li>No actions parsed.</li> : null} {latest.validation.length === 0 ? (
<li>
{latest.interpreter.status === "resolved"
? "No actions parsed."
: "Turn requires clarification before any action can be validated."}
</li>
) : null}
</ul> </ul>
{latest.interpreter.diagnostics.length > 0 ? (
<p>
<strong>Diagnostics:</strong> {latest.interpreter.diagnostics.join(" | ")}
</p>
) : null}
{latest.actions.length > 0 ? (
<details>
<summary>Selected actions ({latest.actions.length})</summary>
<pre>{JSON.stringify(latest.actions, null, 2)}</pre>
</details>
) : null}
{latest.interpreter.candidates.length > 0 ? (
<details>
<summary>Interpreter candidates ({latest.interpreter.candidates.length})</summary>
<ul className="timeline-list compact">
{latest.interpreter.candidates.map((candidate, index) => (
<li key={`${candidate.action.type}-${index}`}>
{candidate.action.type} at {formatConfidence(candidate.confidence)}
{candidate.rationale ? ` - ${candidate.rationale}` : ""}
</li>
))}
</ul>
</details>
) : null}
</section> </section>
) : null} ) : null}
@@ -176,9 +534,36 @@ export default function App() {
</section> </section>
<section className="inspector-grid"> <section className="inspector-grid">
<nav className="tab-bar">
<button
type="button"
className={`tab-btn${tab === "world" ? " active" : ""}`}
onClick={() => setTab("world")}
>
World inspector
</button>
<button
type="button"
className={`tab-btn${tab === "rulebook" ? " active" : ""}`}
onClick={() => setTab("rulebook")}
>
Rulebook
</button>
</nav>
{tab === "world" ? (
<>
<article className="panel"> <article className="panel">
<h2>World state</h2> <h2>World state</h2>
{loading && !snapshot ? <p>Loading...</p> : null} {loading && !snapshot ? <p>Loading...</p> : null}
{snapshot ? (
<div className="meta-grid">
<p className="meta-kv"><strong>World ID:</strong> {snapshot.worldState.id}</p>
<p className="meta-kv"><strong>Created:</strong> {formatTurnTime(snapshot.worldState.createdAt)}</p>
<p className="meta-kv"><strong>Domain:</strong> {String(snapshot.worldState.metadata?.domain ?? "unknown")}</p>
<p className="meta-kv"><strong>World schema:</strong> {String(snapshot.worldState.metadata?.version ?? "unknown")}</p>
</div>
) : null}
<ul className="entity-list"> <ul className="entity-list">
{entities.map((entity) => ( {entities.map((entity) => (
<li key={entity.id}> <li key={entity.id}>
@@ -192,9 +577,26 @@ export default function App() {
<article className="panel"> <article className="panel">
<h2>Turn log</h2> <h2>Turn log</h2>
<ul className="timeline-list"> <ul className="timeline-list">
{snapshot?.turns.slice().reverse().map((turn) => ( {turns.map((turn) => (
<li key={turn.id}> <li key={turn.id}>
<strong>{turn.rawText}</strong> <strong>{turn.rawText}</strong>
<span className="turn-time"> at {formatTurnTime(turn.createdAt)}</span>
{turn.interpreter ? (
<span>
{" "}[interp:{turn.interpreter.status} via {turn.interpreter.resolutionSource};
conf {formatConfidence(turn.interpreter.selectedConfidence)}]
</span>
) : null}
{turn.interpreter?.clarification ? (
<p className="parser-hint">
Clarification ({turn.interpreter.clarification.reasonCode}): {turn.interpreter.clarification.question}
</p>
) : null}
{turn.interpreter?.diagnostics?.length ? (
<p className="turn-diagnostics">
Diagnostics: {turn.interpreter.diagnostics.join(" | ")}
</p>
) : null}
{turn.validation.map((v) => ( {turn.validation.map((v) => (
<span key={v.actionIndex}> [{v.success ? "ok" : v.reason}]</span> <span key={v.actionIndex}> [{v.success ? "ok" : v.reason}]</span>
))} ))}
@@ -202,6 +604,37 @@ export default function App() {
))} ))}
</ul> </ul>
</article> </article>
<article className="panel">
<h2>System</h2>
{activeRulebook ? (
<div className="meta-grid">
<p className="meta-kv"><strong>Active rulebook:</strong> {activeRulebook.name}</p>
<p className="meta-kv"><strong>Rulebook ID:</strong> {activeRulebook.id}</p>
<p className="meta-kv"><strong>Rulebook version:</strong> {activeRulebook.version}</p>
<p className="meta-kv"><strong>Updated:</strong> {formatTurnTime(activeRulebook.updatedAt)}</p>
<p className="meta-kv"><strong>Saved rulebooks:</strong> {rulebooks.length}</p>
</div>
) : (
<p>Loading rulebook info...</p>
)}
{rulebooks.length > 0 ? (
<ul className="timeline-list compact">
{rulebooks.map((rb) => (
<li key={rb.id}>
<strong>{rb.name}</strong> ({rb.id}) v{rb.version} - updated {formatTurnTime(rb.updatedAt)}
</li>
))}
</ul>
) : null}
</article>
</>
) : (
<article className="panel panel--full">
<h2>Rulebook editor</h2>
<RulebookEditor onSaved={(rulebook) => setActiveRulebook(rulebook)} />
</article>
)}
</section> </section>
</main> </main>
); );

View File

@@ -161,6 +161,19 @@ pre {
padding: 8px 10px; padding: 8px 10px;
} }
.meta-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 6px 12px;
margin-bottom: 10px;
}
.meta-kv {
margin: 0;
font-size: 0.86rem;
color: rgba(244, 239, 228, 0.86);
}
.error-banner { .error-banner {
margin-top: 16px; margin-top: 16px;
color: #ffd2b8; color: #ffd2b8;
@@ -176,6 +189,191 @@ pre {
font-size: 0.9rem; font-size: 0.9rem;
} }
/* ---------------------------------------------------------------------------
Tab bar
--------------------------------------------------------------------------- */
.tab-bar {
grid-column: 1 / -1;
display: flex;
gap: 8px;
padding-bottom: 4px;
}
.tab-btn {
border: 1px solid rgba(244, 239, 228, 0.14);
border-radius: 999px;
padding: 8px 18px;
background: rgba(244, 239, 228, 0.05);
color: rgba(244, 239, 228, 0.6);
cursor: pointer;
}
.tab-btn.active {
background: rgba(244, 239, 228, 0.12);
color: #f4efe4;
border-color: rgba(244, 239, 228, 0.26);
}
.panel--full {
grid-column: 1 / -1;
}
/* ---------------------------------------------------------------------------
Rulebook editor
--------------------------------------------------------------------------- */
.rulebook-editor {
display: grid;
gap: 12px;
}
.rulebook-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.rulebook-name {
font-weight: 600;
font-size: 1rem;
margin-bottom: 4px;
}
.rulebook-desc {
font-size: 0.85rem;
color: rgba(244, 239, 228, 0.6);
margin: 0;
}
.save-btn {
border: 0;
border-radius: 999px;
padding: 10px 20px;
background: linear-gradient(135deg, #d8b16f, #a96c36);
color: #1d1b17;
cursor: pointer;
white-space: nowrap;
}
.save-btn:disabled {
opacity: 0.6;
cursor: default;
}
.rule-list {
display: grid;
gap: 8px;
}
.rule-panel {
border: 1px solid rgba(244, 239, 228, 0.1);
border-radius: 14px;
overflow: hidden;
background: rgba(255, 255, 255, 0.02);
}
.rule-summary {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 14px;
cursor: pointer;
list-style: none;
user-select: none;
}
.rule-summary::-webkit-details-marker {
display: none;
}
.rule-action-type {
font-weight: 600;
font-family: "IBM Plex Mono", monospace;
font-size: 0.9rem;
color: #dcbf8d;
min-width: 90px;
}
.rule-check-count {
font-size: 0.8rem;
color: rgba(244, 239, 228, 0.5);
flex: 1;
}
.rule-toggle {
display: flex;
align-items: center;
gap: 6px;
font-size: 0.8rem;
color: rgba(244, 239, 228, 0.7);
cursor: pointer;
}
.rule-body {
padding: 0 14px 14px;
display: grid;
gap: 8px;
border-top: 1px solid rgba(244, 239, 228, 0.07);
}
.rule-hint {
font-size: 0.82rem;
color: rgba(244, 239, 228, 0.5);
margin: 8px 0 0;
}
.rule-hint code {
font-family: "IBM Plex Mono", monospace;
color: #dcbf8d;
font-size: 0.8rem;
}
.check-list {
list-style: none;
padding: 0;
margin: 0;
display: grid;
gap: 4px;
}
.check-list li {
padding: 6px 10px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.05);
font-size: 0.82rem;
}
.check-desc {
color: rgba(244, 239, 228, 0.8);
}
.json-label {
display: grid;
gap: 6px;
font-size: 0.82rem;
color: rgba(244, 239, 228, 0.6);
}
.json-editor {
width: 100%;
border: 1px solid rgba(244, 239, 228, 0.14);
background: rgba(0, 0, 0, 0.4);
color: #cfd9c2;
border-radius: 10px;
padding: 12px;
font-family: "IBM Plex Mono", monospace;
font-size: 0.78rem;
resize: vertical;
}
.json-editor.json-error {
border-color: #ffd2b8;
}
@media (max-width: 900px) { @media (max-width: 900px) {
.inspector-grid { .inspector-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
@@ -190,4 +388,19 @@ pre {
.panel { .panel {
padding: 14px; padding: 14px;
} }
.meta-grid {
grid-template-columns: 1fr;
}
}
.turn-time {
opacity: 0.7;
font-size: 0.82rem;
}
.turn-diagnostics {
margin: 6px 0 0;
color: rgba(244, 239, 228, 0.74);
font-size: 0.82rem;
} }

View File

@@ -4,15 +4,16 @@ import react from "@vitejs/plugin-react";
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
server: { server: {
allowedHosts: ["beepc","cg.sketchferret.com"],
host: "0.0.0.0", host: "0.0.0.0",
port: 5173, port: 5173,
proxy: { proxy: {
"/api": { "/api": {
target: "http://app:3000", target: "http://app:3024",
changeOrigin: true, changeOrigin: true,
}, },
"/health": { "/health": {
target: "http://app:3000", target: "http://app:3024",
changeOrigin: true, changeOrigin: true,
}, },
}, },

View File

@@ -1,355 +1,138 @@
# CharacterGarden — System Bible (MVP Architecture) # CharacterGarden MVP - Current Architecture (April 2026)
## Purpose
CharacterGarden is a deterministic roleplay and simulation framework.
The system separates:
* Natural language (LLM / user input)
* Structured intent (parsed actions)
* Truth validation (rules engine)
* World state (persistent simulation)
The system MUST remain deterministic at the core.
---
## Core Principle ## Core Principle
The LLM is NOT the source of truth. The simulation engine is deterministic and authoritative.
The LLM layer is an intent interpreter and resolver, not a source of truth.
The LLM is used ONLY for: ## Live System Layers
* Parsing natural language → structured actions User / LLM Input (Prose)
* Generating narrative output |
[Intent Interpreter Layer]
|
[Turn Manager]
|
[Truth Engine + Scene Rulebook Validation]
|
[World Mutation Engine]
|
[Persistence Layer]
|
[Frontend/API Response]
ALL validation, state changes, and rules are handled by deterministic code. ## Non-Negotiable Rules
--- 1. Truth engine must never parse natural language.
2. Only structured actions can mutate world state.
3. Every mutation must pass validation before apply.
4. Rulebook rules are data-driven and editable.
5. Interpreter output is traceable and never auto-trusted when unresolved.
6. Every turn remains replayable end-to-end.
## System Pipeline ## Current Canonical Actions
All input MUST pass through the following pipeline: - inspect
- move
- open
- take
- introduce
- describe
- transfer
1. PROSE INPUT ## Action Contracts (Current)
* Source: user or AI - Action shape is contract-based in app/src/contracts/action.ts
* Format: free text - Validation contracts in app/src/contracts/validation.ts
- Turn contracts in app/src/contracts/turn.ts
- Interpreter contracts in app/src/contracts/intent.ts
2. PARSER LAYER ## Scene Rulebook (Data-Driven Validation)
* Converts text → structured actions Validation is now externalized into SceneRulebook definitions.
* May use LLM or rule-based parsing
* Output MUST follow strict schema (see Action Contract)
3. ACTION CONTRACT (STRICT) Key capabilities already implemented:
* Only valid structured objects allowed past this point - Actor authorization checks (actorIdIn, actorNameIn)
* No free text allowed beyond this stage - Conditional creation checks (actionMetadataEq)
- Inventory ownership checks (itemInInventory)
- Existing deterministic checks (entity type/exists, same location, attribute checks)
4. TRUTH ENGINE This supports:
* Deterministic validation of actions - Restricting who can introduce characters
* No LLM usage allowed - Restricting who can create missing items via take
* Returns validation results - Validating transfer only when actor owns item and recipient is valid
5. WORLD STATE UPDATE ## Turn Execution (Current)
* Applies valid actions 1. Interpret raw turn text using interpreter module.
* Rejects or partially applies invalid ones 2. If unresolved:
- return clarification/rejection state
- persist trace turn with no applied actions
3. If resolved:
- validate actions with truth engine + active rulebook
- apply successful actions
- persist turn, actions, validation results, and world state
6. RESPONSE GENERATION ## Interpreter + Turn Manager (New)
* LLM converts results into narrative output - Interpreter module: app/src/interpreter/interpretTurn.ts
- Turn manager orchestrator: app/src/turns/turnManager.ts
- processTurn delegates to turn manager: app/src/turns/processTurn.ts
--- Current interpreter statuses:
## Hard Rule - resolved
- needs_clarification
- rejected
The Truth Engine MUST NEVER: ## Current Domain Behaviors
* Parse natural language - take can create missing items when createIfMissing is present and actor is authorized by rulebook
* Infer missing intent - introduce can create missing characters when rulebook allows
* Use probabilistic logic - transfer moves items between inventories when rulebook checks pass
* Call an LLM
If it does, the architecture is broken. ## Persistence (Current)
--- SQLite tables already backing turns and world snapshots:
## Action Contract (REQUIRED) - turns
- actions
- validation_results
- entities
- world_states
- rulebooks
All actions MUST conform to this schema: ## API Surface (Current)
```ts - GET /api/state
export type Action = { - POST /api/turn
actorId: string; - POST /api/reset
type: string; - GET /api/rulebook
targetId?: string; - PUT /api/rulebook
locationId?: string; - GET /api/rulebooks
metadata?: Record<string, any>;
};
```
No additional fields allowed unless explicitly added here. ## Operational Rule: Validation in Docker
--- Build and runtime checks should run in containers, not host Node.
## Turn Structure - Backend build: docker compose run --rm app npm run build
- Frontend build: docker compose run --rm frontend npm run build
Each turn MUST be stored and processed as: ## Immediate Path Forward
```ts 1. LLM adapter hardening
export type Turn = { - Tune prompt/schema validation for model drift.
id: string; - Add configurable model + timeout policy per environment.
rawText: string;
actions: Action[];
validation: ValidationResult[];
createdAt: number;
};
```
--- 2. Rulebook governance
- Keep versioned rulebook migration path active as rule schema evolves.
- Split rules into policy packs: creation, transfer, social.
## Validation Result Contract 3. Testing depth
- Expand Docker-executed integration tests for:
```ts - createIfMissing authorization matrix
export type ValidationResult = { - transfer ownership/location checks
actionIndex: number; - unresolved clarification flows
success: boolean; - multi-action turn behavior
reason?: string;
message?: string;
};
```
Examples:
* "not_your_turn"
* "object_not_found"
* "door_locked"
---
## Required System Layers
### 1. Parser Layer
Function:
```ts
parseTextToActions(text: string): Action[]
```
Responsibilities:
* Convert text → valid Action[]
* Resolve references ("he", "the door")
* May fail or return empty list
Allowed:
* LLM usage
* Heuristics
Not allowed:
* World state mutation
* Validation logic
---
### 2. Truth Engine
Function:
```ts
validateActions(actions: Action[], worldState: WorldState): ValidationResult[]
```
Responsibilities:
* Validate each action deterministically
* No mutation
---
### 3. World State Engine
Function:
```ts
applyActions(actions: Action[], results: ValidationResult[], worldState: WorldState): WorldState
```
Responsibilities:
* Apply ONLY valid actions
* Maintain consistency
---
## World State Requirements
World state MUST:
* Be serializable
* Be versionable
* Support rollback
* Support branching
---
## Database Requirements
Use SQLite.
Minimum tables:
* turns
* actions
* validation_results
* entities
* world_states
Each turn MUST be persisted.
---
## Entity System
Entities MUST have stable IDs.
Example:
```ts
type Entity = {
id: string;
name: string;
type: string;
attributes: Record<string, any>;
};
```
Parser MUST resolve references to entity IDs.
---
## Failure Handling
Failure is FIRST-CLASS.
Example:
```ts
{
success: false,
reason: "door_locked",
message: "The door cannot be opened."
}
```
The system MUST:
* Return failures clearly
* Allow LLM to narrate failures
* NOT silently fix invalid actions
---
## LLM Adapter Rules
The LLM Adapter MUST:
* Never mutate world state
* Never validate actions
* Only transform data
---
## Development Phases
### Phase 1 — Contract Enforcement (CURRENT)
* Define Action, Turn, ValidationResult
* Refactor all code to use contracts
* Remove any free-text logic from truth engine
---
### Phase 2 — Minimal Truth Engine
Implement test domain:
* Tic-tac-toe OR simple door system
Goal:
* Fully deterministic validation
* No LLM required for correctness
---
### Phase 3 — Parser Improvement
* Add LLM parsing
* Add reference resolution
* Improve action extraction
---
### Phase 4 — Memory System
* Persist entities
* Track history
* Add retrieval support
---
## Non-Goals (For Now)
* No complex AI reasoning inside truth engine
* No autonomous agents
* No multi-agent planning
---
## thoughts.md Requirement
Copilot MUST maintain a file:
```
/thoughts.md
```
After each major change, it MUST append:
* What was implemented
* Why it was implemented
* What assumptions were made
* What remains unclear
This is REQUIRED to maintain continuity across sessions.
---
## Definition of Done (MVP)
The system is complete when:
* A user can input text
* It is parsed into structured actions
* Actions are validated deterministically
* World state updates correctly
* A narrative response is generated
WITHOUT requiring the LLM for correctness.
---
## Final Rule
If any part of the system depends on the LLM to maintain logical correctness,
the architecture has failed.
The LLM is an interface layer, not a reasoning authority.

View File

@@ -1,54 +1,107 @@
# thoughts.md # thoughts.md
## Current Status (clean-break) ## Documentation Sync
- Backend is running the new contract-native API shape:
- `GET /api/state` -> `{ worldState, turns }`
- `POST /api/turn` -> `{ rawText, actions, validation, worldState }`
- Runtime blocker is resolved:
- old SQLite schema conflict (`turns.raw_text`) was fixed by wiping old DB state
- stale legacy files removed from backend source tree:
- `app/src/types.ts`
- `app/src/latentEntities.ts`
- `app/src/llmAdapter.ts`
- Door/key MVP smoke checks pass:
- `open door` before key -> `locked_requires_key`
- `take key` -> success
- `open door` after key -> success
## Architecture Now - Implementation plan refreshed in Implementation_plan.md to match current codebase state.
- Core contracts in `app/src/contracts/`: - Next executable phase is Phase 1: Intent Interpreter Boundary Hardening.
- `action.ts`
- `validation.ts`
- `turn.ts`
- `entity.ts`
- `world.ts`
- Deterministic truth engine:
- `app/src/truthEngine.ts` with `validateActions(actions, worldState)`
- Ordered turn pipeline:
- `app/src/turns/processTurn.ts` parse -> validate -> apply -> persist
- World mutation:
- `app/src/world/applyActions.ts`
- Persistence:
- `app/src/db.ts` with tables `turns`, `actions`, `validation_results`, `entities`, `world_states`
- App seed domain:
- `app/src/app.ts` door/key world (`room_start`, `room_exit`, `player`, `door_1`, `key_1`)
## New Endpoint ## Current Snapshot (April 2026)
- Added `POST /api/reset` in `app/src/index.ts`
- App-level reset implementation in `app/src/app.ts`
- DB wipe support in `app/src/db.ts`
- Verified reset behavior:
- returns `{ worldState, turns }`
- `turns` is empty after reset
- `player` and `key_1` return to `room_start`
## Frontend Migration Notes ### What is now working
- `frontend/src/App.tsx` migrated to consume new backend contracts.
- Removed dependencies on old fields (`narration`, `events`, `beliefs`, `summaries`, `parser_feedback`).
- Turn submit flow now refreshes snapshot via `GET /api/state` after `POST /api/turn`.
- Reset call now sends JSON body/content-type to satisfy Fastify media-type validation.
## Remaining Checks - Rulebook-driven validation is active and editable through API/frontend.
1. Frontend build pass completed in container (`docker compose exec frontend npm run build`). - Character authorization rules are in place (actorIdIn / actorNameIn).
2. Validate inspector UX manually against the door/key flow. - take supports createIfMissing, gated by rulebook permissions.
3. Expand parser coverage only within current clean MVP domain. - transfer action is live with ownership + recipient + location validation.
- Turn processing now goes through a dedicated turn manager layer.
- Intent interpreter contract exists with resolved / needs_clarification / rejected statuses.
- 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_<item_id>` 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_<item_id>` in applyActions** — `take` now sets `has_<item_id>` on the actor for all taken items, not just `key_1`. The attributeRef rulebook check continues to work generically.
### Confirmed via containerized validation
- Backend build passes in Docker:
- docker compose run --rm app npm run build
- Frontend build passes in Docker:
- docker compose run --rm frontend npm run build
- Host Node build is intentionally not relied on.
## Architecture Feedback
### Good decisions worth keeping
1. Rulebook externalization
- Pulling edge-case logic out of hardcoded truth engine branches was the right move.
- It now supports editable policy without code deployment.
2. Explicit authorization checks in rules
- Authorization for creation-style actions now belongs to policy, not parser guesswork.
- This aligns with deterministic governance.
3. Introducing transfer as first-class action
- This avoids overloading take semantics and keeps intent/action boundaries cleaner.
4. Turn manager seam
- processTurn delegating to a turn manager creates a stable orchestration point for interpreter upgrades.
### Risks / cleanup still needed
1. Frontend contract drift risk
- App.tsx has historically duplicated blocks during rapid edits.
- Keep one canonical component and avoid append-style merges.
2. Interpreter observability depth
- Interpreter traces are persisted, but aggregate analytics/counters are still missing.
3. Rulebook migration strategy
- 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
- Harden prompt + response schema handling for model drift and malformed JSON.
- Add environment-specific model/timeouts and failure policy guidance.
- Add tests covering unavailable model backend and malformed payload fallback.
### Iteration 2: Traceability + observability
- Surface interpreter status in frontend turn log.
- Add reason-code analytics counters for failed validations and unresolved intents.
### Iteration 3: Rulebook lifecycle and test harness
- Add policy packs (creation, transfer, social).
- Add Docker-run integration tests for:
- unauthorized createIfMissing
- authorized createIfMissing
- transfer success/failure matrix
- clarification path for ambiguous/unrecognized input
## Operating Guidance
- Keep all build/test checks containerized.
- Treat interpreter as replaceable adapter behind a stable contract.
- Keep truth engine deterministic and side-effect free.
- Keep mutation logic pure relative to validated actions only.