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.
After every completed step:
After each completed step:
1. Update `thoughts.md`
1. Update thoughts.md
2. Record files changed
3. Record assumptions made
4. Record next step
@@ -16,388 +16,233 @@ Do not redesign the project without updating this plan.
---
# Phase 1 — Contracts First
# Phase 0 — Completed Baseline
## Step 1.1 — Create contracts folder
Status: COMPLETE
Create:
```txt
app/src/contracts/
```
Add:
```txt
app/src/contracts/action.ts
app/src/contracts/turn.ts
app/src/contracts/validation.ts
app/src/contracts/world.ts
app/src/contracts/entity.ts
```
Goal: all shared types live here.
- Contracts are established under app/src/contracts/
- Deterministic truth engine is active
- Rulebook-driven validation is active and editable
- Core actions supported: inspect, move, open, take, introduce, describe, transfer
- take createIfMissing path implemented with rulebook authorization
- transfer action implemented with ownership/location checks
- Turn orchestration moved behind turn manager seam
- Interpreter contract and first interpreter module created
- Docker container builds are passing for app and frontend
---
## Step 1.2 — Define Action contract
# Phase 1 — Intent Interpreter Boundary Hardening
In `action.ts`:
Status: IN PROGRESS
```ts
export type Action = {
actorId: string;
type: string;
targetId?: string;
locationId?: string;
metadata?: Record<string, unknown>;
};
```
## Step 1.1 — Strict interpreter envelope validation
No other action shape should be used.
Goal:
- Validate InterpreterOutput shape before it reaches validation/mutation.
- Reject malformed interpreter payloads deterministically.
Target files:
- app/src/contracts/intent.ts
- app/src/interpreter/interpretTurn.ts
- app/src/turns/turnManager.ts
## Step 1.2 — Clarification model expansion
Goal:
- Add structured clarification options and optional entity candidates.
- Distinguish unrecognized-intent from reference ambiguity consistently.
Target files:
- app/src/contracts/intent.ts
- app/src/interpreter/interpretTurn.ts
## Step 1.3 — API compatibility for unresolved turns
Goal:
- Ensure /api/turn returns interpreter status and clarification payload reliably.
- Keep existing fields backward-compatible for current frontend behavior.
Target files:
- app/src/turns/processTurn.ts
- app/src/index.ts
---
## Step 1.3 — Define ValidationResult contract
# Phase 2 — Turn Trace and Persistence Enrichment
In `validation.ts`:
Status: NOT STARTED
```ts
export type ValidationResult = {
actionIndex: number;
success: boolean;
reason?: string;
message?: string;
};
```
## Step 2.1 — Persist interpreter envelope per turn
Goal:
- Persist interpreter status, diagnostics, and clarification metadata.
Target files:
- app/src/db.ts
- app/src/contracts/turn.ts
- app/src/turns/turnManager.ts
## Step 2.2 — Extend read models and API snapshots
Goal:
- Include interpreter trace data in turn history returned by /api/state.
Target files:
- app/src/db.ts
- app/src/app.ts
- app/src/index.ts
## Step 2.3 — Frontend turn inspector updates
Goal:
- Display interpreter status and clarification prompts in timeline and latest result.
Target files:
- frontend/src/App.tsx
---
## Step 1.4 — Define Turn contract
# Phase 3 — Resolver Plug-in Architecture
In `turn.ts`:
Status: COMPLETE
```ts
import type { Action } from "./action";
import type { ValidationResult } from "./validation";
## Step 3.1 — Introduce resolver interface
export type Turn = {
id: string;
rawText: string;
actions: Action[];
validation: ValidationResult[];
createdAt: number;
};
```
Goal:
- Define a stable resolver interface that returns InterpreterOutput.
Target files:
- app/src/interpreter/resolveIntent.ts (new)
- app/src/interpreter/interpretTurn.ts
## Step 3.2 — Deterministic adapter extraction
Goal:
- Move current parser-backed behavior into a deterministic adapter.
Target files:
- app/src/interpreter/adapters/deterministicResolver.ts (new)
- app/src/interpreter/interpretTurn.ts
## Step 3.3 — LLM resolver adapter
Goal:
- Add LLM adapter behind config, without making it authoritative over deterministic validation.
Target files:
- app/src/interpreter/adapters/llmResolver.ts (new)
- app/src/app.ts
## Step 3.4 — Fallback strategy
Goal:
- Support deterministic fallback when LLM resolver fails or is low confidence.
Target files:
- app/src/interpreter/interpretTurn.ts
---
## Step 1.5 — Define Entity contract
# Phase 4 — Rulebook Governance and Compatibility
In `entity.ts`:
Status: IN PROGRESS
```ts
export type Entity = {
id: string;
name: string;
type: string;
attributes: Record<string, unknown>;
};
```
## Step 4.1 — Rulebook versioning
Goal:
- Add version field and migration path for existing saved rulebooks.
Status: COMPLETE
Target files:
- app/src/contracts/rulebook.ts
- app/src/defaultRulebook.ts
- app/src/db.ts
## Step 4.2 — Policy pack structure
Goal:
- Organize rules into policy packs (creation, transfer, social) while retaining current behavior.
Target files:
- app/src/defaultRulebook.ts
## Step 4.3 — Rulebook editor affordances
Goal:
- Surface policy grouping and version in the frontend editor.
Target files:
- frontend/src/App.tsx
---
## Step 1.6 — Define WorldState contract
# Phase 5 — Docker-First Test Harness
In `world.ts`:
Status: NOT STARTED
```ts
import type { Entity } from "./entity";
## Step 5.1 — Backend integration tests
export type WorldState = {
id: string;
entities: Record<string, Entity>;
metadata: Record<string, unknown>;
createdAt: number;
};
```
Goal:
---
- Cover deterministic scenarios:
- unauthorized createIfMissing denied
- authorized createIfMissing allowed
- transfer success and failure matrix
- unresolved intent clarification behavior
# Phase 2 — Enforce Layer Boundaries
Target files:
## Step 2.1 — Refactor truth engine imports
- app/src/** tests (new)
Update `truthEngine.ts` so it imports:
## Step 5.2 — Containerized test commands
```ts
import type { Action } from "./contracts/action";
import type { ValidationResult } from "./contracts/validation";
import type { WorldState } from "./contracts/world";
```
Goal:
Truth engine must only receive structured actions.
- Provide canonical Docker commands for app/frontend build and tests.
---
Target files:
## Step 2.2 — Remove text parsing from truth engine
- charactergarden/docker-compose.yml
- project.md
- thoughts.md
Search `truthEngine.ts` for:
## Step 5.3 — CI readiness checklist
* string parsing
* natural language interpretation
* prompt logic
* LLM calls
Goal:
Move any such logic out.
- Ensure all checks are containerized and reproducible.
Truth engine should expose:
Deliverable:
```ts
export function validateActions(
actions: Action[],
worldState: WorldState
): ValidationResult[] {
// deterministic validation only
}
```
---
## Step 2.3 — Create parser layer
Create:
```txt
app/src/parser/
app/src/parser/parseTextToActions.ts
```
Function:
```ts
import type { Action } from "../contracts/action";
export function parseTextToActions(text: string): Action[] {
// temporary simple parser
return [];
}
```
For now, returning `[]` is acceptable.
---
## Step 2.4 — Create world state engine
Create:
```txt
app/src/world/
app/src/world/applyActions.ts
```
Function:
```ts
import type { Action } from "../contracts/action";
import type { ValidationResult } from "../contracts/validation";
import type { WorldState } from "../contracts/world";
export function applyActions(
actions: Action[],
results: ValidationResult[],
worldState: WorldState
): WorldState {
// apply only successful actions
return worldState;
}
```
---
# Phase 3 — Build First Deterministic Test Domain
Use a simple door/key room before anything complex.
## Step 3.1 — Seed initial world
Create initial world state:
* actor: `player`
* room: `room_start`
* door: `door_1`
* key: `key_1`
Door starts locked.
Key starts in room.
Player starts in room.
---
## Step 3.2 — Support action types
Truth engine should recognize:
```txt
inspect
take
open
move
```
Unknown action types fail with:
```txt
reason: "unknown_action"
```
---
## Step 3.3 — Validate take action
Rules:
* Actor must exist
* Target must exist
* Target must be in same location
* Target must be takeable
Failure reasons:
* `actor_not_found`
* `target_not_found`
* `not_in_same_location`
* `not_takeable`
---
## Step 3.4 — Validate open action
Rules:
* Actor must exist
* Target must exist
* Target must be openable
* If locked, actor must have matching key
Failure reasons:
* `actor_not_found`
* `target_not_found`
* `not_openable`
* `locked_requires_key`
---
## Step 3.5 — Apply successful take
If `take` succeeds:
* move item into actor inventory
---
## Step 3.6 — Apply successful open
If `open` succeeds:
* set door attribute `open: true`
---
# Phase 4 — Wire Full Turn Processing
## Step 4.1 — Create turn processor
Create:
```txt
app/src/turns/processTurn.ts
```
Function:
```ts
export async function processTurn(rawText: string): Promise<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.
- One documented command sequence that reproduces local validation.
---

View File

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

View File

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

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

View File

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

View File

@@ -4,6 +4,7 @@ import Database from "better-sqlite3";
import type { Action } from "./contracts/action";
import type { Entity } from "./contracts/entity";
import type { InterpreterOutput } from "./contracts/intent";
import type { SceneRulebook } from "./contracts/rulebook";
import type { Turn } from "./contracts/turn";
import type { ValidationResult } from "./contracts/validation";
@@ -21,6 +22,7 @@ export interface CharacterGardenDatabase {
listEntities(): Entity[];
insertTurn(turn: Turn): void;
listTurns(): Turn[];
insertInterpreterOutput(turnId: string, interpreter: InterpreterOutput): void;
insertActions(turnId: string, actions: Action[]): void;
insertValidationResults(turnId: string, results: ValidationResult[]): void;
insertWorldState(turnId: string | null, worldState: WorldState): void;
@@ -97,6 +99,7 @@ export function createDatabase(config: DatabaseConfig): CharacterGardenDatabase
CREATE TABLE IF NOT EXISTS rulebooks (
id TEXT PRIMARY KEY,
world_id TEXT NOT NULL,
version INTEGER NOT NULL DEFAULT 1,
name TEXT NOT NULL,
description TEXT,
rules_json TEXT NOT NULL,
@@ -104,12 +107,27 @@ export function createDatabase(config: DatabaseConfig): CharacterGardenDatabase
updated_at INTEGER NOT NULL
)
`,
`
CREATE TABLE IF NOT EXISTS interpreter_events (
turn_id TEXT PRIMARY KEY,
interpreter_json TEXT NOT NULL,
created_at INTEGER NOT NULL,
FOREIGN KEY(turn_id) REFERENCES turns(id)
)
`,
];
for (const statement of initStatements) {
sqlite.exec(statement);
}
// Backward-compatible migration for pre-versioned databases.
try {
sqlite.exec("ALTER TABLE rulebooks ADD COLUMN version INTEGER NOT NULL DEFAULT 1");
} catch {
// Column already exists.
}
const clearEntitiesStatement = sqlite.prepare("DELETE FROM entities");
const upsertEntityStatement = sqlite.prepare(`
INSERT INTO entities (id, name, type, attributes_json)
@@ -137,6 +155,19 @@ export function createDatabase(config: DatabaseConfig): CharacterGardenDatabase
ORDER BY created_at ASC
`);
const listInterpreterEventsStatement = sqlite.prepare(`
SELECT turn_id, interpreter_json
FROM interpreter_events
`);
const insertInterpreterOutputStatement = sqlite.prepare(`
INSERT INTO interpreter_events (turn_id, interpreter_json, created_at)
VALUES (@turn_id, @interpreter_json, @created_at)
ON CONFLICT(turn_id) DO UPDATE SET
interpreter_json = excluded.interpreter_json,
created_at = excluded.created_at
`);
const insertActionStatement = sqlite.prepare(`
INSERT INTO actions (
turn_id,
@@ -186,9 +217,10 @@ export function createDatabase(config: DatabaseConfig): CharacterGardenDatabase
`);
const upsertRulebookStatement = sqlite.prepare(`
INSERT INTO rulebooks (id, world_id, name, description, rules_json, created_at, updated_at)
VALUES (@id, @world_id, @name, @description, @rules_json, @created_at, @updated_at)
INSERT INTO rulebooks (id, world_id, version, name, description, rules_json, created_at, updated_at)
VALUES (@id, @world_id, @version, @name, @description, @rules_json, @created_at, @updated_at)
ON CONFLICT(id) DO UPDATE SET
version = excluded.version,
name = excluded.name,
description = excluded.description,
rules_json = excluded.rules_json,
@@ -196,13 +228,13 @@ export function createDatabase(config: DatabaseConfig): CharacterGardenDatabase
`);
const getRulebookStatement = sqlite.prepare(`
SELECT id, world_id, name, description, rules_json, created_at, updated_at
SELECT id, world_id, version, name, description, rules_json, created_at, updated_at
FROM rulebooks
WHERE id = @id
`);
const listRulebooksStatement = sqlite.prepare(`
SELECT id, world_id, name, description, rules_json, created_at, updated_at
SELECT id, world_id, version, name, description, rules_json, created_at, updated_at
FROM rulebooks
ORDER BY created_at ASC
`);
@@ -224,6 +256,7 @@ export function createDatabase(config: DatabaseConfig): CharacterGardenDatabase
wipe() {
sqlite.exec(`
DELETE FROM interpreter_events;
DELETE FROM validation_results;
DELETE FROM actions;
DELETE FROM world_states;
@@ -279,15 +312,33 @@ export function createDatabase(config: DatabaseConfig): CharacterGardenDatabase
created_at: number;
}>;
const interpreterRows = listInterpreterEventsStatement.all() as Array<{
turn_id: string;
interpreter_json: string;
}>;
const interpreterByTurnId = new Map<string, InterpreterOutput>();
for (const row of interpreterRows) {
interpreterByTurnId.set(row.turn_id, parseJson<InterpreterOutput>(row.interpreter_json));
}
return rows.map((row) => ({
id: row.id,
rawText: row.raw_text,
actions: [],
validation: [],
createdAt: row.created_at,
interpreter: interpreterByTurnId.get(row.id),
}));
},
insertInterpreterOutput(turnId, interpreter) {
insertInterpreterOutputStatement.run({
turn_id: turnId,
interpreter_json: JSON.stringify(interpreter),
created_at: Date.now(),
});
},
insertActions(turnId, actions) {
const tx = sqlite.transaction((actionList: Action[]) => {
actionList.forEach((action, index) => {
@@ -343,6 +394,7 @@ export function createDatabase(config: DatabaseConfig): CharacterGardenDatabase
upsertRulebookStatement.run({
id: rulebook.id,
world_id: rulebook.worldId,
version: rulebook.version,
name: rulebook.name,
description: rulebook.description ?? null,
rules_json: JSON.stringify(rulebook.rules),
@@ -356,6 +408,7 @@ export function createDatabase(config: DatabaseConfig): CharacterGardenDatabase
| {
id: string;
world_id: string;
version: number;
name: string;
description: string | null;
rules_json: string;
@@ -367,6 +420,7 @@ export function createDatabase(config: DatabaseConfig): CharacterGardenDatabase
return {
id: row.id,
worldId: row.world_id,
version: row.version ?? 1,
name: row.name,
description: row.description ?? undefined,
rules: parseJson(row.rules_json),
@@ -379,6 +433,7 @@ export function createDatabase(config: DatabaseConfig): CharacterGardenDatabase
const rows = listRulebooksStatement.all() as Array<{
id: string;
world_id: string;
version: number;
name: string;
description: string | null;
rules_json: string;
@@ -388,6 +443,7 @@ export function createDatabase(config: DatabaseConfig): CharacterGardenDatabase
return rows.map((row) => ({
id: row.id,
worldId: row.world_id,
version: row.version ?? 1,
name: row.name,
description: row.description ?? undefined,
rules: parseJson(row.rules_json),

View File

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

View File

@@ -17,13 +17,13 @@ server.get("/api/state", async () => game.getSnapshot());
server.post("/api/reset", async () => game.reset());
server.post<{ Body: { input?: string } }>("/api/turn", async (request, reply) => {
const input = request.body?.input?.trim();
if (!input) {
const input = request.body?.input;
if (typeof input !== "string") {
reply.code(400);
return { error: "input is required" };
return { error: "input is required and must be a string" };
}
return game.processTurn(input);
return await game.processTurn(input);
});
// ---------------------------------------------------------------------------

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

View File

@@ -116,6 +116,19 @@ function evaluate(expr: ConditionExpr, ctx: EvalContext): boolean {
return expr.allowedNames.some((name) => name.trim().toLowerCase() === actorName);
}
case "actionMetadataEq": {
return ctx.action.metadata?.[expr.key] === expr.value;
}
case "itemInInventory": {
const holder = ctx.entities[expr.holderRole];
const itemId = ctx.action.metadata?.[expr.itemMetadataKey];
if (!holder || typeof itemId !== "string") return false;
const item = ctx.worldState.entities[itemId];
if (!item) return false;
return String(item.attributes.location ?? "") === `inventory:${holder.id}`;
}
case "attributeRef": {
const checkEntity = ctx.entities[expr.checkRole];
const refEntity = ctx.entities[expr.refRole];

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

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}`;
}
function toDisplayName(value: string): string {
return value
.split(/[_\s]+/)
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(" ");
}
function inferItemName(action: Action): string {
const itemName = action.metadata?.itemName;
if (typeof itemName === "string" && itemName.trim()) {
return itemName.trim();
}
if (action.targetId?.startsWith("item_")) {
return toDisplayName(action.targetId.replace(/^item_/, ""));
}
return "Generated Item";
}
function getActionCharacterName(action: Action): string | undefined {
const displayName = action.metadata?.displayName;
if (typeof displayName === "string" && displayName.trim()) {
@@ -93,6 +112,18 @@ export function applyActions(
if (target.id === "key_1") {
actor.attributes.has_key_1 = true;
}
} else if (actor && action.targetId && action.metadata?.createIfMissing === true) {
nextState.entities[action.targetId] = {
id: action.targetId,
name: inferItemName(action),
type: "item",
attributes: {
location: `inventory:${actor.id}`,
takeable: true,
created_by_action: "take",
created_by_actor: actor.id,
},
};
}
break;
case "open":
@@ -137,6 +168,19 @@ export function applyActions(
}
}
break;
case "transfer":
if (target) {
const itemId = action.metadata?.itemId;
if (typeof itemId === "string") {
const item = nextState.entities[itemId];
if (item) {
item.attributes.location = `inventory:${target.id}`;
item.attributes.last_transferred_by = action.actorId;
item.attributes.last_transferred_to = target.id;
}
}
}
break;
case "inspect":
default:
break;

View File

@@ -27,6 +27,9 @@ type Turn = {
actions: Action[];
validation: ValidationResult[];
createdAt: number;
interpreter?: {
status: "resolved" | "needs_clarification" | "rejected";
};
};
type WorldState = {
@@ -46,6 +49,21 @@ type ProcessTurnResponse = {
actions: Action[];
validation: ValidationResult[];
worldState: WorldState;
interpreter: {
interpreterVersion: string;
status: "resolved" | "needs_clarification" | "rejected";
selectedConfidence?: number;
diagnostics: string[];
clarification?: {
reasonCode: string;
question: string;
options?: Array<{
id: string;
label: string;
value: string;
}>;
};
};
};
type RuleCheck = {
@@ -65,6 +83,7 @@ type ActionRuleSet = {
type SceneRulebook = {
id: string;
worldId: string;
version: number;
name: string;
description?: string;
rules: ActionRuleSet[];
@@ -75,6 +94,7 @@ type SceneRulebook = {
const starterPrompts = [
"look around",
"take key",
"give key to groundskeeper",
"open door",
"move to exit",
];
@@ -220,6 +240,12 @@ function RulebookEditor() {
<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) => (
@@ -354,6 +380,23 @@ export default function App() {
<section className="result-card">
<h2>Latest result</h2>
<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">
{latest.validation.map((v) => (
<li key={v.actionIndex}>
@@ -361,8 +404,19 @@ export default function App() {
{v.message ? ` - ${v.message}` : ""}
</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>
{latest.interpreter.diagnostics.length > 0 ? (
<p>
<strong>Diagnostics:</strong> {latest.interpreter.diagnostics.join(" | ")}
</p>
) : null}
</section>
) : null}
@@ -408,6 +462,9 @@ export default function App() {
{snapshot?.turns.slice().reverse().map((turn) => (
<li key={turn.id}>
<strong>{turn.rawText}</strong>
{turn.interpreter ? (
<span> [interp:{turn.interpreter.status}]</span>
) : null}
{turn.validation.map((v) => (
<span key={v.actionIndex}> [{v.success ? "ok" : v.reason}]</span>
))}
@@ -425,211 +482,4 @@ export default function App() {
</section>
</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 LLM is **not a source of truth**. It is an **input/output translator only**.
The simulation engine is deterministic and authoritative.
The LLM layer is an intent interpreter and resolver, not a source of truth.
---
# 🧩 System Architecture
## Live System Layers
User / LLM Input (Prose)
[Parser Layer]
[Normalization Layer]
[Truth Engine (Validation)]
[State Mutation Engine]
|
[Intent Interpreter Layer]
|
[Turn Manager]
|
[Truth Engine + Scene Rulebook Validation]
|
[World Mutation Engine]
|
[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
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
## Current Canonical Actions
---
- 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
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> };
```
## Scene Rulebook (Data-Driven Validation)
⚠️ 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
export type NormalizedAction = Action;
```
This supports:
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
export type ValidationResult = {
success: boolean;
reasonCode:
| "OK"
| "NOT_FOUND"
| "NOT_PRESENT"
| "LOCKED"
| "INVALID_TARGET"
| "MISSING_REQUIREMENT"
| "OUT_OF_TURN"
| "UNKNOWN";
message: string;
};
```
## Interpreter + Turn Manager (New)
---
- 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
export type Turn = {
id: string;
rawText: string;
- resolved
- needs_clarification
- rejected
parsedActions: unknown[];
normalizedActions: NormalizedAction[];
## Current Domain Behaviors
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:
---
- turns
- actions
- validation_results
- entities
- world_states
- rulebooks
# 🌍 World State (STRICT SCHEMA)
## API Surface (Current)
```ts
export type Entity = {
id: string;
name: string;
locationId?: string;
attributes?: Record<string, unknown>;
};
- GET /api/state
- POST /api/turn
- POST /api/reset
- GET /api/rulebook
- PUT /api/rulebook
- GET /api/rulebooks
export type Location = {
id: string;
name: string;
connectedTo: string[];
};
## Operational Rule: Validation in Docker
export type WorldState = {
entities: Record<string, Entity>;
locations: Record<string, Location>;
inventory: Record<string, string[]>; // actorId → itemIds
flags: Record<string, boolean>;
};
```
Build and runtime checks should run in containers, not host Node.
---
- Backend build: docker compose run --rm app npm run build
- Frontend build: docker compose run --rm frontend npm run build
# 🧠 System Layers
## Immediate Path Forward
---
1. LLM adapter hardening
- Tune prompt/schema validation for model drift.
- Add configurable model + timeout policy per environment.
## 1. Parser Layer (`parser/`)
2. Rulebook governance
- Keep versioned rulebook migration path active as rule schema evolves.
- Split rules into policy packs: creation, transfer, social.
**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
- history
---
# 🔄 Turn Execution Pipeline
```ts
function processTurn(rawText: string): Turn {
const parsed = parse(rawText);
const normalized = normalize(parsed);
const validationResults = normalized.map(action =>
validate(action, worldState)
);
const successfulActions = normalized.filter((_, i) =>
validationResults[i].success
);
const newState = apply(successfulActions, worldState);
const turn: Turn = {
id: generateId(),
rawText,
parsedActions: parsed,
normalizedActions: normalized,
validationResults,
appliedActions: successfulActions,
timestamp: Date.now()
};
persist(turn, newState);
return turn;
}
```
---
# 🧠 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
3. Testing depth
- Expand Docker-executed integration tests for:
- createIfMissing authorization matrix
- transfer ownership/location checks
- unresolved clarification flows
- multi-action turn behavior

View File

@@ -1,89 +1,88 @@
# thoughts.md
## Current Status (clean-break)
- 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
## Documentation Sync
## Architecture Now
- Core contracts in `app/src/contracts/`:
- `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`)
- Implementation plan refreshed in Implementation_plan.md to match current codebase state.
- Next executable phase is Phase 1: Intent Interpreter Boundary Hardening.
## Scene Entry Rules
- `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.
## Current Snapshot (April 2026)
## Character Description (NEW)
- `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`
### What is now working
## New Endpoint
- 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`
- Rulebook-driven validation is active and editable through API/frontend.
- Character authorization rules are in place (actorIdIn / actorNameIn).
- take supports createIfMissing, gated by rulebook permissions.
- 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.
## Frontend Migration Notes
- `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.
### Confirmed via containerized validation
## Remaining Checks
1. Frontend build pass completed in container (`docker compose exec frontend npm run build`).
2. Validate inspector UX manually against the door/key plus introduce flow.
3. Expand parser coverage only within current clean MVP domain.
- 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.
## 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.