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:
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
48
charactergarden/app/src/contracts/intent.ts
Normal file
48
charactergarden/app/src/contracts/intent.ts
Normal 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;
|
||||
};
|
||||
@@ -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[];
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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.",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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" },
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
282
charactergarden/app/src/interpreter/adapters/llmResolver.ts
Normal file
282
charactergarden/app/src/interpreter/adapters/llmResolver.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
67
charactergarden/app/src/interpreter/interpretTurn.ts
Normal file
67
charactergarden/app/src/interpreter/interpretTurn.ts
Normal 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,
|
||||
],
|
||||
};
|
||||
}
|
||||
22
charactergarden/app/src/interpreter/resolveIntent.ts
Normal file
22
charactergarden/app/src/interpreter/resolveIntent.ts
Normal 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";
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,9 +116,23 @@ function parseSingleAction(actionText: string, defaultActorId: string): Action |
|
||||
if (input.includes("key")) {
|
||||
return { actorId: defaultActorId, type: "take", targetId: "key_1" };
|
||||
}
|
||||
|
||||
const itemName = extractTakenItemName(input);
|
||||
if (!itemName) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
actorId: defaultActorId,
|
||||
type: "take",
|
||||
targetId: `item_${toItemSlug(itemName)}`,
|
||||
metadata: {
|
||||
itemName: toDisplayName(itemName),
|
||||
createIfMissing: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (/(introduce|bring in|invite|have .* join)/.test(input)) {
|
||||
if (input.includes("groundskeeper")) {
|
||||
return { actorId: defaultActorId, type: "introduce", targetId: "groundskeeper" };
|
||||
@@ -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+(.+)$/) ||
|
||||
|
||||
@@ -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];
|
||||
|
||||
47
charactergarden/app/src/tests/integrationRunner.ts
Normal file
47
charactergarden/app/src/tests/integrationRunner.ts
Normal 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);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
109
charactergarden/app/src/turns/turnManager.ts
Normal file
109
charactergarden/app/src/turns/turnManager.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
@@ -426,210 +483,3 @@ export default function App() {
|
||||
</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>
|
||||
);
|
||||
}
|
||||
424
project.md
424
project.md
@@ -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:
|
||||
|
||||
---
|
||||
|
||||
# 🌍 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
|
||||
- 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
|
||||
function processTurn(rawText: string): Turn {
|
||||
const parsed = parse(rawText);
|
||||
## Operational Rule: Validation in Docker
|
||||
|
||||
const normalized = normalize(parsed);
|
||||
Build and runtime checks should run in containers, not host Node.
|
||||
|
||||
const validationResults = normalized.map(action =>
|
||||
validate(action, worldState)
|
||||
);
|
||||
- Backend build: docker compose run --rm app npm run build
|
||||
- Frontend build: docker compose run --rm frontend npm run build
|
||||
|
||||
const successfulActions = normalized.filter((_, i) =>
|
||||
validationResults[i].success
|
||||
);
|
||||
## Immediate Path Forward
|
||||
|
||||
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 = {
|
||||
id: generateId(),
|
||||
rawText,
|
||||
parsedActions: parsed,
|
||||
normalizedActions: normalized,
|
||||
validationResults,
|
||||
appliedActions: successfulActions,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
2. Rulebook governance
|
||||
- Keep versioned rulebook migration path active as rule schema evolves.
|
||||
- Split rules into policy packs: creation, transfer, social.
|
||||
|
||||
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
|
||||
|
||||
161
thoughts.md
161
thoughts.md
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user