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>
This commit is contained in:
2026-04-26 14:06:14 -04:00
parent ff9b86c3e9
commit fc10e46ccc
23 changed files with 1530 additions and 1012 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,233 @@ Do not redesign the project without updating this plan.
--- ---
# Phase 1 — Contracts First # Phase 0 — Completed Baseline
## Step 1.1 — Create contracts folder Status: COMPLETE
Create: - Contracts are established under app/src/contracts/
- Deterministic truth engine is active
```txt - Rulebook-driven validation is active and editable
app/src/contracts/ - Core actions supported: inspect, move, open, take, introduce, describe, transfer
``` - take createIfMissing path implemented with rulebook authorization
- transfer action implemented with ownership/location checks
Add: - Turn orchestration moved behind turn manager seam
- Interpreter contract and first interpreter module created
```txt - Docker container builds are passing for app and frontend
app/src/contracts/action.ts
app/src/contracts/turn.ts
app/src/contracts/validation.ts
app/src/contracts/world.ts
app/src/contracts/entity.ts
```
Goal: all shared types live here.
--- ---
## 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.
--- ---

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

@@ -16,7 +16,7 @@ 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<ProcessTurnResponse>;
getRulebook(): SceneRulebook; getRulebook(): SceneRulebook;
upsertRulebook(rulebook: SceneRulebook): SceneRulebook; upsertRulebook(rulebook: SceneRulebook): SceneRulebook;
listRulebooks(): SceneRulebook[]; listRulebooks(): SceneRulebook[];
@@ -219,9 +219,9 @@ export function createCharacterGardenApp(dbPath: string): CharacterGardenApp {
}; };
}, },
processTurn(rawText: string) { async processTurn(rawText: string) {
const rulebook = loadActiveRulebook(); const rulebook = loadActiveRulebook();
const result = processTurn(rawText, worldState, db, rulebook); const result = await processTurn(rawText, worldState, db, rulebook);
worldState = result.worldState; worldState = result.worldState;
return result; return result;
}, },
@@ -231,7 +231,11 @@ export function createCharacterGardenApp(dbPath: string): CharacterGardenApp {
}, },
upsertRulebook(rulebook: SceneRulebook) { upsertRulebook(rulebook: SceneRulebook) {
const updated: SceneRulebook = { ...rulebook, updatedAt: Date.now() }; const updated: SceneRulebook = {
...rulebook,
version: Number.isInteger(rulebook.version) ? rulebook.version : 1,
updatedAt: Date.now(),
};
db.upsertRulebook(updated); db.upsertRulebook(updated);
activeRulebookId = updated.id; activeRulebookId = updated.id;
return updated; return updated;

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

@@ -22,6 +22,8 @@ export type EntityRole = "actor" | "target" | "actorRoom" | "targetRoom";
* sameLocation — two entities share the same location attribute value * sameLocation — two entities share the same location attribute value
* actorIdIn — action.actorId is included in an allowed list * actorIdIn — action.actorId is included in an allowed list
* actorNameIn — actor.name matches one of an allowed list (case-insensitive) * 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 * attributeRef — entities[checkRole].attributes[prefix + entities[refRole].attributes[refAttribute]] === true
* metaValueNotInRoom — no entity of entityType in actor's room has name === action.metadata[metaKey] * metaValueNotInRoom — no entity of entityType in actor's room has name === action.metadata[metaKey]
*/ */
@@ -38,6 +40,8 @@ export type ConditionExpr =
| { op: "sameLocation"; roleA: EntityRole; roleB: EntityRole } | { op: "sameLocation"; roleA: EntityRole; roleB: EntityRole }
| { op: "actorIdIn"; allowedIds: string[] } | { op: "actorIdIn"; allowedIds: string[] }
| { op: "actorNameIn"; allowedNames: string[] } | { op: "actorNameIn"; allowedNames: string[] }
| { op: "actionMetadataEq"; key: string; value: unknown }
| { op: "itemInInventory"; itemMetadataKey: string; holderRole: EntityRole }
| { | {
op: "attributeRef"; op: "attributeRef";
/** Entity whose attribute is being tested */ /** Entity whose attribute is being tested */
@@ -86,6 +90,8 @@ export type ActionRuleSet = {
export type SceneRulebook = { export type SceneRulebook = {
id: string; id: string;
worldId: string; worldId: string;
/** Increment when schema/policy format changes in breaking ways. */
version: number;
name: string; name: string;
description?: string; description?: string;
rules: ActionRuleSet[]; rules: ActionRuleSet[];

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

@@ -4,6 +4,7 @@ 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 { 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";
@@ -21,6 +22,7 @@ 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;
@@ -97,6 +99,7 @@ export function createDatabase(config: DatabaseConfig): CharacterGardenDatabase
CREATE TABLE IF NOT EXISTS rulebooks ( CREATE TABLE IF NOT EXISTS rulebooks (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
world_id TEXT NOT NULL, world_id TEXT NOT NULL,
version INTEGER NOT NULL DEFAULT 1,
name TEXT NOT NULL, name TEXT NOT NULL,
description TEXT, description TEXT,
rules_json TEXT NOT NULL, rules_json TEXT NOT NULL,
@@ -104,12 +107,27 @@ export function createDatabase(config: DatabaseConfig): CharacterGardenDatabase
updated_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)
@@ -137,6 +155,19 @@ export function createDatabase(config: DatabaseConfig): CharacterGardenDatabase
ORDER BY created_at ASC ORDER BY created_at ASC
`); `);
const listInterpreterEventsStatement = sqlite.prepare(`
SELECT turn_id, interpreter_json
FROM interpreter_events
`);
const insertInterpreterOutputStatement = sqlite.prepare(`
INSERT INTO interpreter_events (turn_id, interpreter_json, created_at)
VALUES (@turn_id, @interpreter_json, @created_at)
ON CONFLICT(turn_id) DO UPDATE SET
interpreter_json = excluded.interpreter_json,
created_at = excluded.created_at
`);
const insertActionStatement = sqlite.prepare(` const insertActionStatement = sqlite.prepare(`
INSERT INTO actions ( INSERT INTO actions (
turn_id, turn_id,
@@ -186,9 +217,10 @@ export function createDatabase(config: DatabaseConfig): CharacterGardenDatabase
`); `);
const upsertRulebookStatement = sqlite.prepare(` const upsertRulebookStatement = sqlite.prepare(`
INSERT INTO rulebooks (id, world_id, name, description, rules_json, created_at, updated_at) INSERT INTO rulebooks (id, world_id, version, name, description, rules_json, created_at, updated_at)
VALUES (@id, @world_id, @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 ON CONFLICT(id) DO UPDATE SET
version = excluded.version,
name = excluded.name, name = excluded.name,
description = excluded.description, description = excluded.description,
rules_json = excluded.rules_json, rules_json = excluded.rules_json,
@@ -196,13 +228,13 @@ export function createDatabase(config: DatabaseConfig): CharacterGardenDatabase
`); `);
const getRulebookStatement = sqlite.prepare(` const getRulebookStatement = sqlite.prepare(`
SELECT id, world_id, name, description, rules_json, created_at, updated_at SELECT id, world_id, version, name, description, rules_json, created_at, updated_at
FROM rulebooks FROM rulebooks
WHERE id = @id WHERE id = @id
`); `);
const listRulebooksStatement = sqlite.prepare(` const listRulebooksStatement = sqlite.prepare(`
SELECT id, world_id, name, description, rules_json, created_at, updated_at SELECT id, world_id, version, name, description, rules_json, created_at, updated_at
FROM rulebooks FROM rulebooks
ORDER BY created_at ASC ORDER BY created_at ASC
`); `);
@@ -224,6 +256,7 @@ 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;
@@ -279,15 +312,33 @@ 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));
}
return rows.map((row) => ({ return rows.map((row) => ({
id: row.id, id: row.id,
rawText: row.raw_text, rawText: row.raw_text,
actions: [], actions: [],
validation: [], validation: [],
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) => {
@@ -343,6 +394,7 @@ export function createDatabase(config: DatabaseConfig): CharacterGardenDatabase
upsertRulebookStatement.run({ upsertRulebookStatement.run({
id: rulebook.id, id: rulebook.id,
world_id: rulebook.worldId, world_id: rulebook.worldId,
version: rulebook.version,
name: rulebook.name, name: rulebook.name,
description: rulebook.description ?? null, description: rulebook.description ?? null,
rules_json: JSON.stringify(rulebook.rules), rules_json: JSON.stringify(rulebook.rules),
@@ -356,6 +408,7 @@ export function createDatabase(config: DatabaseConfig): CharacterGardenDatabase
| { | {
id: string; id: string;
world_id: string; world_id: string;
version: number;
name: string; name: string;
description: string | null; description: string | null;
rules_json: string; rules_json: string;
@@ -367,6 +420,7 @@ export function createDatabase(config: DatabaseConfig): CharacterGardenDatabase
return { return {
id: row.id, id: row.id,
worldId: row.world_id, worldId: row.world_id,
version: row.version ?? 1,
name: row.name, name: row.name,
description: row.description ?? undefined, description: row.description ?? undefined,
rules: parseJson(row.rules_json), rules: parseJson(row.rules_json),
@@ -379,6 +433,7 @@ export function createDatabase(config: DatabaseConfig): CharacterGardenDatabase
const rows = listRulebooksStatement.all() as Array<{ const rows = listRulebooksStatement.all() as Array<{
id: string; id: string;
world_id: string; world_id: string;
version: number;
name: string; name: string;
description: string | null; description: string | null;
rules_json: string; rules_json: string;
@@ -388,6 +443,7 @@ export function createDatabase(config: DatabaseConfig): CharacterGardenDatabase
return rows.map((row) => ({ return rows.map((row) => ({
id: row.id, id: row.id,
worldId: row.world_id, worldId: row.world_id,
version: row.version ?? 1,
name: row.name, name: row.name,
description: row.description ?? undefined, description: row.description ?? undefined,
rules: parseJson(row.rules_json), rules: parseJson(row.rules_json),

View File

@@ -11,6 +11,7 @@ export function createDefaultRulebook(worldId: string): SceneRulebook {
return { return {
id: DEFAULT_RULEBOOK_ID, id: DEFAULT_RULEBOOK_ID,
worldId, worldId,
version: 1,
name: "Default Rulebook", name: "Default Rulebook",
description: "Built-in scene rules for the CharacterGarden engine. Edit freely — the engine re-evaluates on every turn.", description: "Built-in scene rules for the CharacterGarden engine. Edit freely — the engine re-evaluates on every turn.",
createdAt: now, createdAt: now,
@@ -27,23 +28,47 @@ export function createDefaultRulebook(worldId: string): SceneRulebook {
enabled: true, enabled: true,
checks: [ checks: [
{ {
id: "take_target_exists", id: "take_target_exists_or_actor_can_create",
description: "Target entity must exist in the world", description: "Target must exist, or actor must be authorized to create it when createIfMissing is true",
condition: { op: "entityExists", role: "target" }, 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", failReason: "target_not_found",
failMessage: "Target '{target.id}' does not exist.", failMessage: "Target '{target.id}' does not exist, and actor '{actor.id}' is not allowed to create missing items.",
}, },
{ {
id: "take_same_location", id: "take_same_location",
description: "Actor and target must be in the same location", description: "If target exists, actor and target must be in the same location",
condition: { op: "sameLocation", roleA: "actor", roleB: "target" }, condition: {
op: "or",
conditions: [
{ op: "not", condition: { op: "entityExists", role: "target" } },
{ op: "sameLocation", roleA: "actor", roleB: "target" },
],
},
failReason: "not_in_same_location", failReason: "not_in_same_location",
failMessage: "Target '{target.id}' is not in the same location as '{actor.id}'.", failMessage: "Target '{target.id}' is not in the same location as '{actor.id}'.",
}, },
{ {
id: "take_takeable", id: "take_takeable",
description: "Target must have takeable attribute set to true", description: "If target exists, it must have takeable attribute set to true",
condition: { op: "eq", role: "target", attribute: "takeable", value: true }, condition: {
op: "or",
conditions: [
{ op: "not", condition: { op: "entityExists", role: "target" } },
{ op: "eq", role: "target", attribute: "takeable", value: true },
],
},
failReason: "not_takeable", failReason: "not_takeable",
failMessage: "Target '{target.id}' cannot be taken.", failMessage: "Target '{target.id}' cannot be taken.",
}, },
@@ -242,6 +267,41 @@ export function createDefaultRulebook(worldId: string): SceneRulebook {
}, },
], ],
}, },
{
actionType: "transfer",
enabled: true,
checks: [
{
id: "transfer_recipient_exists",
description: "Recipient must exist in the world",
condition: { op: "entityExists", role: "target" },
failReason: "target_not_found",
failMessage: "Recipient '{target.id}' does not exist.",
},
{
id: "transfer_recipient_character",
description: "Recipient must be a character",
condition: { op: "entityType", role: "target", requiredType: "character" },
failReason: "target_not_character",
failMessage: "Recipient '{target.id}' is not a character.",
},
{
id: "transfer_same_location",
description: "Actor and recipient must be in the same location",
condition: { op: "sameLocation", roleA: "actor", roleB: "target" },
failReason: "not_in_same_location",
failMessage: "Recipient '{target.id}' is not in the same location as '{actor.id}'.",
},
{
id: "transfer_actor_holds_item",
description: "Actor must currently hold the specified item in inventory",
condition: { op: "itemInInventory", itemMetadataKey: "itemId", holderRole: "actor" },
failReason: "item_not_in_inventory",
failMessage: "Actor '{actor.id}' is not holding the requested item.",
},
],
},
], ],
}; };
} }

View File

@@ -17,13 +17,13 @@ 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);
}); });
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

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,282 @@
import type { Action } from "../../contracts/action";
import type { InterpreterOutput } from "../../contracts/intent";
import type { ResolveIntentInput } from "../resolveIntent";
export const LLM_INTERPRETER_VERSION = "llm-v1-ollama";
type LlmClarification = {
reasonCode?: string;
question?: string;
field?: "verb" | "target" | "item" | "recipient" | "location";
options?: Array<{
id?: string;
label?: string;
value?: string;
entityId?: string;
entityType?: "character" | "item" | "room" | "unknown";
}>;
};
type LlmIntentResponse = {
status?: "resolved" | "needs_clarification" | "rejected";
selectedActions?: unknown;
selectedConfidence?: unknown;
clarification?: LlmClarification;
rationale?: string;
};
function fallbackClarification(input: ResolveIntentInput, diagnostic: string): InterpreterOutput {
return {
interpreterVersion: LLM_INTERPRETER_VERSION,
rawText: input.rawText,
actorId: input.actorId,
resolutionSource: "llm",
minConfidence: input.minConfidence,
status: "needs_clarification",
selectedActions: [],
candidates: [],
diagnostics: [diagnostic],
clarification: {
reasonCode: "UNRECOGNIZED_INTENT",
question: "I could not confidently resolve that intent. Please rephrase with a clear verb.",
field: "verb",
options: [
{ id: "inspect", label: "Inspect", value: "inspect" },
{ id: "move", label: "Move", value: "move" },
{ id: "take", label: "Take", value: "take" },
{ id: "open", label: "Open", value: "open" },
{ id: "introduce", label: "Introduce", value: "introduce" },
{ id: "describe", label: "Describe", value: "describe" },
{ id: "transfer", label: "Transfer", value: "transfer" },
],
},
};
}
function extractFirstJsonObject(text: string): string | null {
const trimmed = text.trim();
if (trimmed.startsWith("{") && trimmed.endsWith("}")) {
return trimmed;
}
const codeFenceMatch = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i);
if (codeFenceMatch?.[1]) {
const fenced = codeFenceMatch[1].trim();
if (fenced.startsWith("{") && fenced.endsWith("}")) {
return fenced;
}
}
const firstBrace = trimmed.indexOf("{");
const lastBrace = trimmed.lastIndexOf("}");
if (firstBrace >= 0 && lastBrace > firstBrace) {
return trimmed.slice(firstBrace, lastBrace + 1);
}
return null;
}
function toActionArray(value: unknown, actorId: string): Action[] {
if (!Array.isArray(value)) return [];
const actions: Action[] = [];
for (const item of value) {
if (!item || typeof item !== "object") continue;
const action = item as Record<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";
}
}
function buildPrompt(input: ResolveIntentInput): { system: string; user: string } {
const system = [
"You are an intent-to-actions resolver for a text adventure engine.",
"Return ONLY JSON with this shape:",
'{"status":"resolved|needs_clarification|rejected","selectedActions":[{"type":"inspect|move|take|open|introduce|describe|transfer","targetId":"optional","locationId":"optional","metadata":{"optional":"object"}}],"selectedConfidence":0.0,"clarification":{"reasonCode":"UNRECOGNIZED_INTENT|AMBIGUOUS_REFERENCE|EMPTY_INPUT|LOW_CONFIDENCE|INTERNAL_INVALID_OUTPUT","question":"string","field":"verb|target|item|recipient|location"},"rationale":"brief"}',
"If unresolved, selectedActions must be an empty array and clarification must be present.",
"Use canonical action types only. Do not invent fields.",
].join(" ");
const user = [
`actorId: ${input.actorId}`,
`input: ${JSON.stringify(input.rawText)}`,
`minimum_confidence: ${input.minConfidence}`,
].join("\n");
return { system, user };
}
export async function resolveLlmIntent(input: ResolveIntentInput): Promise<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,67 @@
import type { InterpreterOutput } from "../contracts/intent";
import { resolveDeterministicIntent } from "./adapters/deterministicResolver";
import { resolveLlmIntent } from "./adapters/llmResolver";
import {
type ResolverMode,
normalizeResolverMode,
} from "./resolveIntent";
const DEFAULT_MIN_CONFIDENCE = 0.65;
type InterpretTurnOptions = {
mode?: ResolverMode;
minConfidence?: number;
};
function getResolverMode(options?: InterpretTurnOptions): ResolverMode {
if (options?.mode) {
return options.mode;
}
return normalizeResolverMode(process.env.INTENT_RESOLVER_MODE);
}
function buildInput(rawText: string, actorId: string, options?: InterpretTurnOptions) {
return {
rawText,
actorId,
minConfidence: options?.minConfidence ?? DEFAULT_MIN_CONFIDENCE,
};
}
export async function interpretTurn(
rawText: string,
actorId = "player",
options?: InterpretTurnOptions
): Promise<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,22 @@
import type { InterpreterOutput } from "../contracts/intent";
export type ResolverMode = "deterministic" | "llm" | "hybrid";
export type ResolveIntentInput = {
rawText: string;
actorId: string;
minConfidence: number;
};
export type IntentResolver = {
name: string;
resolve(input: ResolveIntentInput): Promise<InterpreterOutput> | 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

@@ -12,6 +12,16 @@ function toDisplayName(value: string): string {
.join(" "); .join(" ");
} }
function toItemSlug(value: string): string {
return (
value
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "_")
.replace(/^_+|_+$/g, "") || "item"
);
}
function extractIntroducedCharacterName(input: string): string | undefined { 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 match = input.match(/(?:introduce|bring in|invite|have)\s+(?:the\s+|a\s+|an\s+)?(.+?)(?:\s+join)?$/);
const rawName = match?.[1]?.trim(); const rawName = match?.[1]?.trim();
@@ -29,6 +39,52 @@ function extractActorAndAction(sentence: string): { actorName?: string; action:
return { action: normalized_sent }; 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 { function parseSingleAction(actionText: string, defaultActorId: string): Action | undefined {
const input = normalized(actionText); const input = normalized(actionText);
if (!input) { if (!input) {
@@ -60,9 +116,23 @@ function parseSingleAction(actionText: string, defaultActorId: string): Action |
if (input.includes("key")) { if (input.includes("key")) {
return { actorId: defaultActorId, type: "take", targetId: "key_1" }; return { actorId: defaultActorId, type: "take", targetId: "key_1" };
} }
const itemName = extractTakenItemName(input);
if (!itemName) {
return undefined; 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 (/(introduce|bring in|invite|have .* join)/.test(input)) {
if (input.includes("groundskeeper")) { if (input.includes("groundskeeper")) {
return { actorId: defaultActorId, type: "introduce", targetId: "groundskeeper" }; return { actorId: defaultActorId, type: "introduce", targetId: "groundskeeper" };
@@ -84,6 +154,26 @@ function parseSingleAction(actionText: string, defaultActorId: string): Action |
}; };
} }
if (/(give|hand|pass|transfer)/.test(input)) {
const parts = extractTransferParts(input);
if (!parts) {
return undefined;
}
const itemId = parts.itemName.includes("key") ? "key_1" : `item_${toItemSlug(parts.itemName)}`;
return {
actorId: defaultActorId,
type: "transfer",
targetId: resolveRecipientId(parts.recipientName),
metadata: {
itemId,
itemName: toDisplayName(parts.itemName),
recipientName: toDisplayName(parts.recipientName),
},
};
}
if (/(describe|is a|is an|has)/.test(input)) { if (/(describe|is a|is an|has)/.test(input)) {
// Match patterns like "describe the merchant as shrewd" or "the merchant is shrewd" // 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+(.+)$/) || const describeMatch = input.match(/(?:describe|tell about)\s+(?:the\s+)?([a-z\s_]+?)\s+as\s+(.+)$/) ||

View File

@@ -116,6 +116,19 @@ function evaluate(expr: ConditionExpr, ctx: EvalContext): boolean {
return expr.allowedNames.some((name) => name.trim().toLowerCase() === actorName); 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": { case "attributeRef": {
const checkEntity = ctx.entities[expr.checkRole]; const checkEntity = ctx.entities[expr.checkRole];
const refEntity = ctx.entities[expr.refRole]; const refEntity = ctx.entities[expr.refRole];

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

@@ -1,50 +1,24 @@
import { randomUUID } from "node:crypto";
import type { CharacterGardenDatabase } from "../db"; import type { CharacterGardenDatabase } from "../db";
import type { Action } from "../contracts/action"; import type { Action } from "../contracts/action";
import type { InterpreterOutput } from "../contracts/intent";
import type { SceneRulebook } from "../contracts/rulebook"; import type { SceneRulebook } from "../contracts/rulebook";
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";
import { parseTextToActions } from "../parser/parseTextToActions"; import { runTurnManager } from "./turnManager";
import { validateActions } from "../truthEngine";
import { applyActions } from "../world/applyActions";
export type ProcessTurnResponse = { export type ProcessTurnResponse = {
rawText: string; rawText: string;
actions: Action[]; actions: Action[];
validation: ValidationResult[]; validation: ValidationResult[];
worldState: WorldState; worldState: WorldState;
interpreter: InterpreterOutput;
}; };
export function processTurn( export async function processTurn(
rawText: string, rawText: string,
worldState: WorldState, worldState: WorldState,
db: CharacterGardenDatabase, db: CharacterGardenDatabase,
rulebook?: SceneRulebook rulebook?: SceneRulebook
): ProcessTurnResponse { ): Promise<ProcessTurnResponse> {
const actions = parseTextToActions(rawText); return runTurnManager(rawText, worldState, db, rulebook);
const validation = validateActions(actions, worldState, rulebook);
const nextWorldState = applyActions(actions, validation, worldState);
const turn: Turn = {
id: randomUUID(),
rawText,
actions,
validation,
createdAt: Date.now(),
};
db.insertTurn(turn);
db.insertActions(turn.id, actions);
db.insertValidationResults(turn.id, validation);
db.upsertEntities(Object.values(nextWorldState.entities));
db.insertWorldState(turn.id, nextWorldState);
return {
rawText,
actions,
validation,
worldState: nextWorldState,
};
} }

View File

@@ -0,0 +1,109 @@
import { randomUUID } from "node:crypto";
import type { CharacterGardenDatabase } from "../db";
import type { Action } from "../contracts/action";
import type { InterpreterOutput } from "../contracts/intent";
import type { SceneRulebook } from "../contracts/rulebook";
import type { Turn } from "../contracts/turn";
import type { ValidationResult } from "../contracts/validation";
import type { WorldState } from "../contracts/world";
import { interpretTurn } from "../interpreter/interpretTurn";
import { validateInterpreterOutput } from "../interpreter/validateInterpreterOutput";
import { validateActions } from "../truthEngine";
import { applyActions } from "../world/applyActions";
export type TurnManagerResponse = {
rawText: string;
actions: Action[];
validation: ValidationResult[];
worldState: WorldState;
interpreter: InterpreterOutput;
};
function persistTurn(
db: CharacterGardenDatabase,
turn: Turn,
interpreter: InterpreterOutput,
actions: Action[],
validation: ValidationResult[]
): void {
db.insertTurn(turn);
db.insertInterpreterOutput(turn.id, interpreter);
db.insertActions(turn.id, actions);
db.insertValidationResults(turn.id, validation);
}
export async function runTurnManager(
rawText: string,
worldState: WorldState,
db: CharacterGardenDatabase,
rulebook?: SceneRulebook
): Promise<TurnManagerResponse> {
const interpreted = await interpretTurn(rawText, "player");
const boundaryCheck = validateInterpreterOutput(interpreted);
const interpreter: InterpreterOutput = boundaryCheck.isValid
? interpreted
: {
interpreterVersion: interpreted.interpreterVersion,
rawText,
actorId: interpreted.actorId || "player",
resolutionSource: interpreted.resolutionSource,
minConfidence: interpreted.minConfidence,
status: "rejected",
selectedActions: [],
candidates: [],
diagnostics: [
"Interpreter output failed boundary validation.",
...boundaryCheck.issues,
],
clarification: {
reasonCode: "INTERNAL_INVALID_OUTPUT",
question: "The interpreter returned an invalid output shape. Please retry the turn.",
field: "verb",
},
};
if (interpreter.status !== "resolved") {
const turn: Turn = {
id: randomUUID(),
rawText,
actions: [],
validation: [],
createdAt: Date.now(),
};
persistTurn(db, turn, interpreter, [], []);
return {
rawText,
actions: [],
validation: [],
worldState,
interpreter,
};
}
const actions = interpreter.selectedActions;
const validation = validateActions(actions, worldState, rulebook);
const nextWorldState = applyActions(actions, validation, worldState);
const turn: Turn = {
id: randomUUID(),
rawText,
actions,
validation,
createdAt: Date.now(),
};
persistTurn(db, turn, interpreter, actions, validation);
db.upsertEntities(Object.values(nextWorldState.entities));
db.insertWorldState(turn.id, nextWorldState);
return {
rawText,
actions,
validation,
worldState: nextWorldState,
interpreter,
};
}

View File

@@ -43,6 +43,25 @@ function createCharacterId(worldState: WorldState, baseName: string): string {
return `${baseId}_${suffix}`; 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 { function getActionCharacterName(action: Action): string | undefined {
const displayName = action.metadata?.displayName; const displayName = action.metadata?.displayName;
if (typeof displayName === "string" && displayName.trim()) { if (typeof displayName === "string" && displayName.trim()) {
@@ -93,6 +112,18 @@ export function applyActions(
if (target.id === "key_1") { if (target.id === "key_1") {
actor.attributes.has_key_1 = true; actor.attributes.has_key_1 = true;
} }
} else if (actor && action.targetId && action.metadata?.createIfMissing === true) {
nextState.entities[action.targetId] = {
id: action.targetId,
name: inferItemName(action),
type: "item",
attributes: {
location: `inventory:${actor.id}`,
takeable: true,
created_by_action: "take",
created_by_actor: actor.id,
},
};
} }
break; break;
case "open": case "open":
@@ -137,6 +168,19 @@ export function applyActions(
} }
} }
break; 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

@@ -27,6 +27,9 @@ type Turn = {
actions: Action[]; actions: Action[];
validation: ValidationResult[]; validation: ValidationResult[];
createdAt: number; createdAt: number;
interpreter?: {
status: "resolved" | "needs_clarification" | "rejected";
};
}; };
type WorldState = { type WorldState = {
@@ -46,6 +49,21 @@ type ProcessTurnResponse = {
actions: Action[]; actions: Action[];
validation: ValidationResult[]; validation: ValidationResult[];
worldState: WorldState; worldState: WorldState;
interpreter: {
interpreterVersion: string;
status: "resolved" | "needs_clarification" | "rejected";
selectedConfidence?: number;
diagnostics: string[];
clarification?: {
reasonCode: string;
question: string;
options?: Array<{
id: string;
label: string;
value: string;
}>;
};
};
}; };
type RuleCheck = { type RuleCheck = {
@@ -65,6 +83,7 @@ type ActionRuleSet = {
type SceneRulebook = { type SceneRulebook = {
id: string; id: string;
worldId: string; worldId: string;
version: number;
name: string; name: string;
description?: string; description?: string;
rules: ActionRuleSet[]; rules: ActionRuleSet[];
@@ -75,6 +94,7 @@ type SceneRulebook = {
const starterPrompts = [ const starterPrompts = [
"look around", "look around",
"take key", "take key",
"give key to groundskeeper",
"open door", "open door",
"move to exit", "move to exit",
]; ];
@@ -220,6 +240,12 @@ function RulebookEditor() {
<p className="rule-hint"> <p className="rule-hint">
For character permissions, use <code>{`{"op":"actorIdIn","allowedIds":["player"]}`}</code> or <code>{`{"op":"actorNameIn","allowedNames":["Player"]}`}</code>. For character permissions, use <code>{`{"op":"actorIdIn","allowedIds":["player"]}`}</code> or <code>{`{"op":"actorNameIn","allowedNames":["Player"]}`}</code>.
</p> </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 ? ( {ruleSet.checks.length > 0 ? (
<ul className="check-list"> <ul className="check-list">
{ruleSet.checks.map((check) => ( {ruleSet.checks.map((check) => (
@@ -354,6 +380,23 @@ 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}
{typeof latest.interpreter.selectedConfidence === "number"
? ` (${Math.round(latest.interpreter.selectedConfidence * 100)}% confidence)`
: ""}
</p>
{latest.interpreter.clarification ? (
<p>
<strong>Clarification:</strong> {latest.interpreter.clarification.question}
</p>
) : null}
{latest.interpreter.clarification?.options?.length ? (
<p>
<strong>Options:</strong>{" "}
{latest.interpreter.clarification.options.map((o) => o.label).join(", ")}
</p>
) : 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}>
@@ -361,8 +404,19 @@ 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}
</section> </section>
) : null} ) : null}
@@ -408,6 +462,9 @@ export default function App() {
{snapshot?.turns.slice().reverse().map((turn) => ( {snapshot?.turns.slice().reverse().map((turn) => (
<li key={turn.id}> <li key={turn.id}>
<strong>{turn.rawText}</strong> <strong>{turn.rawText}</strong>
{turn.interpreter ? (
<span> [interp:{turn.interpreter.status}]</span>
) : 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>
))} ))}
@@ -426,210 +483,3 @@ export default function App() {
</main> </main>
); );
} }
type Entity = {
id: string;
type: string;
name: string;
attributes: Record<string, unknown>;
};
type Action = {
actorId: string;
type: string;
targetId?: string;
locationId?: string;
};
type ValidationResult = {
actionIndex: number;
success: boolean;
reason?: string;
message?: string;
};
type Turn = {
id: string;
rawText: string;
actions: Action[];
validation: ValidationResult[];
createdAt: number;
};
type WorldState = {
id: string;
entities: Record<string, Entity>;
metadata: Record<string, unknown>;
createdAt: number;
};
type AppSnapshot = {
worldState: WorldState;
turns: Turn[];
};
type ProcessTurnResponse = {
rawText: string;
actions: Action[];
validation: ValidationResult[];
worldState: WorldState;
};
const starterPrompts = [
"look around",
"take key",
"open door",
"move to exit",
];
async function fetchJson<T>(input: RequestInfo, init?: RequestInit): Promise<T> {
const response = await fetch(input, init);
if (!response.ok) {
throw new Error(`Request failed: ${response.status}`);
}
return response.json() as Promise<T>;
}
export default function App() {
const [snapshot, setSnapshot] = useState<AppSnapshot | null>(null);
const [latest, setLatest] = useState<ProcessTurnResponse | null>(null);
const [input, setInput] = useState("look around");
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
void fetchJson<AppSnapshot>("/api/state")
.then((data) => {
setSnapshot(data);
setLoading(false);
})
.catch((fetchError: Error) => {
setError(fetchError.message);
setLoading(false);
});
}, []);
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setSubmitting(true);
setError(null);
try {
const result = await fetchJson<ProcessTurnResponse>("/api/turn", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ input }),
});
setLatest(result);
const nextSnapshot = await fetchJson<AppSnapshot>("/api/state");
setSnapshot(nextSnapshot);
} catch (submitError) {
setError(submitError instanceof Error ? submitError.message : "Unknown error");
} finally {
setSubmitting(false);
}
}
async function onReset() {
setError(null);
try {
const result = await fetchJson<AppSnapshot>("/api/reset", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
setSnapshot(result);
setLatest(null);
} catch (resetError) {
setError(resetError instanceof Error ? resetError.message : "Unknown error");
}
}
const entities = snapshot ? Object.values(snapshot.worldState.entities) : [];
return (
<main className="page-shell">
<section className="hero-panel">
<p className="eyebrow">CharacterGarden</p>
<h1>Bootable narrative sandbox</h1>
<p className="lede">
Submit a turn, inspect world state, and verify how the truth engine is mutating state.
</p>
<form className="turn-form" onSubmit={onSubmit}>
<label htmlFor="turn-input">Turn input</label>
<textarea
id="turn-input"
value={input}
onChange={(event) => setInput(event.target.value)}
rows={4}
placeholder="Type what the player does..."
/>
<div className="actions-row">
<button type="submit" disabled={submitting}>
{submitting ? "Submitting..." : "Run turn"}
</button>
<button type="button" className="chip" onClick={onReset}>
Reset world
</button>
<div className="chips">
{starterPrompts.map((prompt) => (
<button key={prompt} type="button" className="chip" onClick={() => setInput(prompt)}>
{prompt}
</button>
))}
</div>
</div>
</form>
{latest ? (
<section className="result-card">
<h2>Latest result</h2>
<p><strong>Input:</strong> {latest.rawText}</p>
<ul className="timeline-list compact">
{latest.validation.map((v) => (
<li key={v.actionIndex}>
Action {v.actionIndex}: {v.success ? "ok" : `failed (${v.reason ?? "unknown"})`}
{v.message ? ` - ${v.message}` : ""}
</li>
))}
{latest.validation.length === 0 ? <li>No actions parsed.</li> : null}
</ul>
</section>
) : null}
{error ? <p className="error-banner">{error}</p> : null}
</section>
<section className="inspector-grid">
<article className="panel">
<h2>World state</h2>
{loading && !snapshot ? <p>Loading...</p> : null}
<ul className="entity-list">
{entities.map((entity) => (
<li key={entity.id}>
<strong>{entity.name}</strong> <span>{entity.type}</span>
<pre>{JSON.stringify(entity.attributes, null, 0)}</pre>
</li>
))}
</ul>
</article>
<article className="panel">
<h2>Turn log</h2>
<ul className="timeline-list">
{snapshot?.turns.slice().reverse().map((turn) => (
<li key={turn.id}>
<strong>{turn.rawText}</strong>
{turn.validation.map((v) => (
<span key={v.actionIndex}> [{v.success ? "ok" : v.reason}]</span>
))}
</li>
))}
</ul>
</article>
</section>
</main>
);
}

View File

@@ -1,364 +1,138 @@
# CharacterGarden MVP — Strict Architecture # CharacterGarden MVP - Current Architecture (April 2026)
## 🧠 Core Principle ## Core Principle
> The system is a **deterministic simulation engine**. The simulation engine is deterministic and authoritative.
> The LLM is **not a source of truth**. It is an **input/output translator only**. The LLM layer is an intent interpreter and resolver, not a source of truth.
--- ## Live System Layers
# 🧩 System Architecture
User / LLM Input (Prose) User / LLM Input (Prose)
|
[Parser Layer] [Intent Interpreter Layer]
|
[Normalization Layer] [Turn Manager]
|
[Truth Engine (Validation)] [Truth Engine + Scene Rulebook Validation]
|
[State Mutation Engine] [World Mutation Engine]
|
[Persistence Layer] [Persistence Layer]
|
[LLM Output Generation] [Frontend/API Response]
--- ## Non-Negotiable Rules
# 🔒 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.
1. Truth Engine MUST NEVER parse natural language ## Current Canonical Actions
2. Only structured Actions may mutate world state
3. All mutations must be validated before execution
4. World state is modified ONLY through engine functions
5. LLM output is never trusted without validation
6. Every turn must be fully traceable
--- - inspect
- move
- open
- take
- introduce
- describe
- transfer
# 📦 Core Data Contracts ## Action Contracts (Current)
## Action (STRICT UNION TYPE) - Action shape is contract-based in app/src/contracts/action.ts
- Validation contracts in app/src/contracts/validation.ts
- Turn contracts in app/src/contracts/turn.ts
- Interpreter contracts in app/src/contracts/intent.ts
```ts ## Scene Rulebook (Data-Driven Validation)
export type Action =
| { type: "move"; actorId: string; targetId: string }
| { type: "take"; actorId: string; targetId: string }
| { type: "open"; actorId: string; targetId: string }
| { type: "close"; actorId: string; targetId: string }
| { type: "use"; actorId: string; targetId: string; toolId?: string }
| { type: "speak"; actorId: string; content: string }
| { type: "introduce"; actorId: string; targetId?: string; metadata?: Record<string, unknown> };
```
⚠️ DO NOT use generic `type: string` actions. Validation is now externalized into SceneRulebook definitions.
--- Key capabilities already implemented:
## NormalizedAction - Actor authorization checks (actorIdIn, actorNameIn)
- Conditional creation checks (actionMetadataEq)
- Inventory ownership checks (itemInInventory)
- Existing deterministic checks (entity type/exists, same location, attribute checks)
```ts This supports:
export type NormalizedAction = Action;
```
If invalid → reject BEFORE validation. - Restricting who can introduce characters
- Restricting who can create missing items via take
- Validating transfer only when actor owns item and recipient is valid
--- ## Turn Execution (Current)
## ValidationResult 1. Interpret raw turn text using interpreter module.
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
```ts ## Interpreter + Turn Manager (New)
export type ValidationResult = {
success: boolean;
reasonCode:
| "OK"
| "NOT_FOUND"
| "NOT_PRESENT"
| "LOCKED"
| "INVALID_TARGET"
| "MISSING_REQUIREMENT"
| "OUT_OF_TURN"
| "UNKNOWN";
message: string;
};
```
--- - 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
## Turn (Traceable Execution Unit) Current interpreter statuses:
```ts - resolved
export type Turn = { - needs_clarification
id: string; - rejected
rawText: string;
parsedActions: unknown[]; ## Current Domain Behaviors
normalizedActions: NormalizedAction[];
validationResults: ValidationResult[]; - take can create missing items when createIfMissing is present and actor is authorized by rulebook
- introduce can create missing characters when rulebook allows
- transfer moves items between inventories when rulebook checks pass
appliedActions: NormalizedAction[]; ## Persistence (Current)
timestamp: number; SQLite tables already backing turns and world snapshots:
};
```
---
# 🌍 World State (STRICT SCHEMA)
```ts
export type Entity = {
id: string;
name: string;
locationId?: string;
attributes?: Record<string, unknown>;
};
export type Location = {
id: string;
name: string;
connectedTo: string[];
};
export type WorldState = {
entities: Record<string, Entity>;
locations: Record<string, Location>;
inventory: Record<string, string[]>; // actorId → itemIds
flags: Record<string, boolean>;
};
```
---
# 🧠 System Layers
---
## 1. Parser Layer (`parser/`)
**Responsibility:**
- Convert prose → rough actions
- May use LLM
**Output:**
```ts
unknown[]
```
⚠️ Parser output is NOT trusted.
---
## 2. Normalization Layer (`parser/normalizeActions.ts`)
**Responsibility:**
- Enforce schema
- Resolve references (`he``john`)
- Fill missing fields
- Reject invalid structures
**Input:**
```ts
unknown[]
```
**Output:**
```ts
NormalizedAction[]
```
---
## 3. Truth Engine (`engine/validate.ts`)
**Responsibility:**
- Determine if action is valid
- MUST be deterministic
- MUST NOT mutate state
**Input:**
```ts
NormalizedAction + WorldState
```
**Output:**
```ts
ValidationResult
```
---
## 4. Mutation Engine (`engine/apply.ts`)
**Responsibility:**
- Apply ONLY successful actions
- Mutate world state
**Input:**
```ts
NormalizedAction + WorldState
```
**Output:**
```ts
WorldState
```
---
## 5. Persistence Layer (`storage/`)
**Responsibility:**
- Store:
- world state
- turns - turns
- history - actions
- validation_results
- entities
- world_states
- rulebooks
--- ## API Surface (Current)
# 🔄 Turn Execution Pipeline - GET /api/state
- POST /api/turn
- POST /api/reset
- GET /api/rulebook
- PUT /api/rulebook
- GET /api/rulebooks
```ts ## Operational Rule: Validation in Docker
function processTurn(rawText: string): Turn {
const parsed = parse(rawText);
const normalized = normalize(parsed); Build and runtime checks should run in containers, not host Node.
const validationResults = normalized.map(action => - Backend build: docker compose run --rm app npm run build
validate(action, worldState) - Frontend build: docker compose run --rm frontend npm run build
);
const successfulActions = normalized.filter((_, i) => ## Immediate Path Forward
validationResults[i].success
);
const newState = apply(successfulActions, worldState); 1. LLM adapter hardening
- Tune prompt/schema validation for model drift.
- Add configurable model + timeout policy per environment.
const turn: Turn = { 2. Rulebook governance
id: generateId(), - Keep versioned rulebook migration path active as rule schema evolves.
rawText, - Split rules into policy packs: creation, transfer, social.
parsedActions: parsed,
normalizedActions: normalized,
validationResults,
appliedActions: successfulActions,
timestamp: Date.now()
};
persist(turn, newState); 3. Testing depth
- Expand Docker-executed integration tests for:
return turn; - createIfMissing authorization matrix
} - transfer ownership/location checks
``` - unresolved clarification flows
- multi-action turn behavior
---
# 🧠 Reference Resolution Rules
Handled in normalization layer.
Examples:
| Input | Output |
|-------------|--------------|
| "he" | actorId |
| "the door" | door_1 |
| "my key" | key_owned_by_actor |
Must be:
- deterministic
- context-aware
- testable
---
# 🧪 Debug & Traceability (REQUIRED)
Every turn MUST store:
- raw input
- parsed output
- normalized actions
- validation results
- applied actions
This enables:
- replay
- debugging
- AI correction
---
# ⚙️ Initial Action Rules (MVP)
## move
- actor must exist
- target must be connected location
## take
- item must be in same location
- item not already owned
## open
- target must exist
- if locked → fail unless key present
## introduce
- creates entity OR brings into scene
---
# 🚫 Anti-Patterns (DO NOT DO)
❌ Let LLM mutate state
❌ Store unvalidated actions
❌ Use dynamic/untyped actions
❌ Skip normalization
❌ Combine validation + mutation
❌ Allow hidden side effects
---
# 🚀 Future Extensions (Planned)
- Memory system (vector + summaries)
- Belief vs truth separation
- Multi-agent turns
- Time-based simulation
- Rule plugins per scenario
- UI action inspector
---
# 🧠 Mental Model
This system is:
> A deterministic simulation engine with LLM-based I/O
NOT:
> A chatbot with memory
---
# ✅ Definition of Done (MVP)
- [ ] Actions fully typed
- [ ] Normalization layer implemented
- [ ] Validation fully deterministic
- [ ] State mutation isolated
- [ ] Turn trace persisted
- [ ] Simple scenario (door + key) works
---
# 💬 Guidance for Copilot
When generating code:
- Prefer explicit types over generic objects
- Avoid dynamic structures
- Keep functions pure where possible
- Do not introduce hidden state
- Follow pipeline strictly

View File

@@ -1,89 +1,88 @@
# 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
- Scene-entry support added:
- `introduce` is now a first-class action
- rooms can declare `is_joinable`
- characters can declare `is_social`
- successful introduction moves a character from offstage into the actor's current room
- `introduce` can also create a new social character when the named target does not already exist
## 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`, `room_offstage`, `player`, `groundskeeper`, `door_1`, `key_1`)
## Scene Entry Rules ## Current Snapshot (April 2026)
- `introduce` validates against deterministic affordances:
- target must exist
- target must be a character
- target must have `is_social: true`
- actor must be in a valid room
- room must have `is_joinable: true`
- target must not already be in that room
- If no existing target entity is resolved but a character name is present, `introduce` may create a new character directly into the current room.
## Character Description (NEW) ### What is now working
- `describe` action adds traits to characters:
- Syntax: `"describe the merchant as shrewd and quick"`
- Traits are stored in `character.attributes.traits[]`
- Multi-sentence support: `"introduce a merchant. describe the merchant as shrewd and quick."`
- Validation rules:
- target character must exist (or will be created by introduce in same turn)
- actor and target must be in same room (for existing targets)
- supports forward-reference to entities created in the same turn
- Multi-action parsing:
- Sentences split on `/[.!?]+/`
- Each sentence becomes a separate action
- Validation accounts for entities created by introduce actions in the same turn
- On success:
- target location becomes the actor's location
- `in_scene` is set to `true`
- `last_introduced_by` is recorded
- newly created characters default to `type: character`, `is_social: true`, `in_scene: true`
## New Endpoint - Rulebook-driven validation is active and editable through API/frontend.
- Added `POST /api/reset` in `app/src/index.ts` - Character authorization rules are in place (actorIdIn / actorNameIn).
- App-level reset implementation in `app/src/app.ts` - take supports createIfMissing, gated by rulebook permissions.
- DB wipe support in `app/src/db.ts` - transfer action is live with ownership + recipient + location validation.
- Verified reset behavior: - Turn processing now goes through a dedicated turn manager layer.
- returns `{ worldState, turns }` - Intent interpreter contract exists with resolved / needs_clarification / rejected statuses.
- `turns` is empty after reset - Interpreter envelopes are persisted per turn and surfaced to the UI timeline.
- `player` and `key_1` return to `room_start` - 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.
## Frontend Migration Notes ### Confirmed via containerized validation
- `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 - Backend build passes in Docker:
1. Frontend build pass completed in container (`docker compose exec frontend npm run build`). - docker compose run --rm app npm run build
2. Validate inspector UX manually against the door/key plus introduce flow. - Frontend build passes in Docker:
3. Expand parser coverage only within current clean MVP domain. - 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.
## 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.