Compare commits

...

18 Commits

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

Co-authored-by: Copilot <copilot@github.com>
2026-04-26 14:06:14 -04:00
ff9b86c3e9 feat: Implement scene rulebook and validation engine
- Added a new SceneRulebook system to manage data-driven validation rules for actions.
- Introduced rule checks for actions like "take", "open", "move", "introduce", and "describe".
- Created a rulebook engine to evaluate conditions and enforce rules during action validation.
- Enhanced action handling with support for scene entry and character descriptions.
- Updated the architecture documentation to reflect the new rule-based validation approach.
- Added new endpoints and improved the persistence layer for rulebooks.
2026-04-26 13:33:05 -04:00
998635f542 feat: implement processTurn function to handle turn processing and world state updates
refactor: remove legacy types.ts file and update frontend to use new contracts

feat: add applyActions function to manage action application and world state mutation

chore: remove empty .gitkeep file from sqlite data directory

refactor: update frontend App component to align with new API contracts and improve UX

docs: revise project.md to reflect updated architecture and system requirements

docs: update thoughts.md with current status, architecture decisions, and remaining checks
2026-04-24 01:04:17 -04:00
2f6af46c79 feat: enhance parser feedback mechanism for improved user guidance
Co-authored-by: Copilot <copilot@github.com>
2026-04-23 22:55:16 -04:00
40 changed files with 4098 additions and 1697 deletions

339
Implementation_plan.md Normal file
View File

@@ -0,0 +1,339 @@
# CharacterGarden — Iterative Implementation Plan (Updated)
## Planning Rules
Work in small, reviewable steps.
After each completed step:
1. Update thoughts.md
2. Record files changed
3. Record assumptions made
4. Record next step
5. Do not skip ahead unless the current step is complete
Do not redesign the project without updating this plan.
---
# Phase 0 — Completed Baseline
Status: COMPLETE
- 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
## Phase 0 Structural Cleanup (April 2026) — COMPLETE
- Deleted turns/processTurn.ts (was a pointless shim over runTurnManager)
- Deleted truthEngine.ts (was a thin wrapper over rulebookEngine.validateWithRulebook)
- Extracted world/seedWorld.ts — seed world logic out of app.ts; app factory is now lean
- Fixed db.listTurns() to return real actions and validation results (previously always empty arrays)
- Fixed worldState.rulebookId persistence — active rulebook now survives restarts
- Generalized has_<item_id> in applyActions — no longer hardcoded to key_1 only
---
# Phase 1 — Intent Interpreter Boundary Hardening
Status: IN PROGRESS
## Step 1.1 — Strict interpreter envelope validation
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
---
# Phase 2 — Turn Trace and Persistence Enrichment
Status: NOT STARTED
## 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
---
# Phase 3 — Resolver Plug-in Architecture
Status: COMPLETE
## Step 3.1 — Introduce resolver interface
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
---
# Phase 4 — Rulebook Governance and Compatibility
Status: IN PROGRESS
## 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
---
# Phase 5 — Docker-First Test Harness
Status: NOT STARTED
## Step 5.1 — Backend integration tests
Goal:
- Cover deterministic scenarios:
- unauthorized createIfMissing denied
- authorized createIfMissing allowed
- transfer success and failure matrix
- unresolved intent clarification behavior
Target files:
- app/src/** tests (new)
## Step 5.2 — Containerized test commands
Goal:
- Provide canonical Docker commands for app/frontend build and tests.
Target files:
- charactergarden/docker-compose.yml
- project.md
- thoughts.md
## Step 5.3 — CI readiness checklist
Goal:
- Ensure all checks are containerized and reproducible.
Deliverable:
- One documented command sequence that reproduces local validation.
---
# Phase 6 — LLM Adapter Reintroduction
Only after deterministic flow works.
## Step 6.1 — LLM parser adapter
Add optional LLM parser:
```ts
parseTextToActionsWithLLM(text: string, worldState: WorldState): Promise<Action[]>
```
It must output only valid `Action[]`.
---
## Step 6.2 — LLM narrative adapter
Add:
```ts
generateNarrative(turn: Turn, worldState: WorldState): Promise<string>
```
The narrative adapter may describe results but must not alter them.
---
# Phase 7 — Frontend Debug UI
## Step 7.1 — Show raw text input
User can submit a turn.
---
## Step 7.2 — Show parsed actions
Display action JSON.
---
## Step 7.3 — Show validation results
Display success/failure reasons.
---
## Step 7.4 — Show world state
Display current world state JSON.
---
# MVP Completion Criteria
MVP is complete when this works:
1. User enters: `take key`
2. Parser returns a `take` action
3. Truth engine validates it
4. World state moves key to inventory
5. User enters: `open door`
6. Truth engine verifies key ownership
7. Door becomes open
8. All steps are visible in debug UI
9. All turns are persisted
---
# Do Not Do Yet
Do not implement:
* autonomous agents
* complex memory retrieval
* embeddings
* relationship simulation
* long-term summaries
* branching timelines
Until the deterministic MVP is working.

View File

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

View File

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

View File

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

View File

@@ -1,316 +1,103 @@
import { randomUUID } from "node:crypto";
import { createDatabase, CharacterGardenDatabase } from "./db";
import { resolveLatentEntity } from "./latentEntities";
import { extractActionsFromProse } from "./llmAdapter";
import { applyChanges, createOffsceneRoom, OFFSCENE_ROOM_ID, validate, WorldState } from "./truthEngine";
import { Action, Belief, Entity, GameEvent, Summary, Turn } from "./types";
import type { SceneRulebook } from "./contracts/rulebook";
import type { Turn } from "./contracts/turn";
import type { WorldState } from "./contracts/world";
import { createDefaultRulebook, DEFAULT_RULEBOOK_ID } from "./defaultRulebook";
import { runTurnManager, TurnManagerResponse } from "./turns/turnManager";
import { ensureSeedState } from "./world/seedWorld";
export interface AppStateSnapshot {
entities: Entity[];
events: GameEvent[];
export interface AppSnapshot {
worldState: WorldState;
turns: Turn[];
beliefs: Belief[];
summaries: Summary[];
}
export interface TurnResult {
narration: string;
parser: "fallback";
actions: Action[];
accepted: Action[];
rejected: { action: Action; reason: string }[];
latent_resolution?: {
accepted: boolean;
reason: string;
entity_id?: string;
};
snapshot: AppStateSnapshot;
}
export interface CharacterGardenApp {
db: CharacterGardenDatabase;
getSnapshot(): AppStateSnapshot;
processTurn(input: string): TurnResult;
getSnapshot(): AppSnapshot;
processTurn(rawText: string): Promise<TurnManagerResponse>;
getRulebook(): SceneRulebook;
upsertRulebook(rulebook: SceneRulebook): SceneRulebook;
listRulebooks(): SceneRulebook[];
reset(): AppSnapshot;
}
function createSeedEntities(): Entity[] {
return [
createOffsceneRoom(),
{
id: "garden",
type: "room",
name: "Garden",
attributes: {
description: "A small overgrown garden with a weathered bench and a shed door nearby.",
},
},
{
id: "shed",
type: "room",
name: "Shed",
attributes: {
description: "A cramped tool shed that smells of old wood and oil.",
},
},
{
id: "player",
type: "character",
name: "Player",
attributes: {
location: "garden",
clothed: true,
pocket_count: 4,
has_bag: false,
},
},
{
id: "groundskeeper",
type: "character",
name: "Groundskeeper",
attributes: {
location: "garden",
},
},
{
id: "gate",
type: "object",
name: "Garden Gate",
attributes: {
location: "garden",
open: false,
locked: false,
},
},
{
id: "bench",
type: "object",
name: "Bench",
attributes: {
location: "garden",
},
},
];
}
function worldStateFromEntities(entities: Entity[]): WorldState {
return {
entities: new Map(entities.map((entity) => [entity.id, entity])),
};
}
function entitiesFromWorldState(worldState: WorldState): Entity[] {
return Array.from(worldState.entities.values()).sort((left, right) =>
left.id.localeCompare(right.id)
);
}
function sameRoom(worldState: WorldState, leftId: string, rightId: string): boolean {
const left = worldState.entities.get(leftId);
const right = worldState.entities.get(rightId);
return left?.attributes["location"] === right?.attributes["location"];
}
function describeTarget(worldState: WorldState, targetId: string | undefined): string {
if (!targetId) {
return "nothing in particular";
}
const entity = worldState.entities.get(targetId);
return entity?.name ?? targetId;
}
function narrateAction(action: Action, worldState: WorldState): string {
switch (action.verb) {
case "move": {
const targetName = describeTarget(worldState, action.target);
if (action.target === OFFSCENE_ROOM_ID) {
return `You step out of the active scene and into ${targetName.toLowerCase()}.`;
}
return `You move to ${targetName}.`;
}
case "open":
return `You open ${describeTarget(worldState, action.target)}.`;
case "close":
return `You close ${describeTarget(worldState, action.target)}.`;
case "take":
return `You take ${describeTarget(worldState, action.target)}.`;
case "drop":
return `You drop ${describeTarget(worldState, action.target)}.`;
case "use":
return `You use ${describeTarget(worldState, action.target)}.`;
case "inspect":
return `You inspect ${describeTarget(worldState, action.target)}.`;
case "speak":
return `You speak to ${describeTarget(worldState, action.target)}.`;
default:
return "You act.";
}
}
function narrateResult(
worldState: WorldState,
accepted: Action[],
rejected: { action: Action; reason: string }[],
latentReason?: string
): string {
const lines: string[] = [];
if (latentReason) {
lines.push(latentReason);
}
for (const action of accepted) {
lines.push(narrateAction(action, worldState));
}
for (const rejection of rejected) {
lines.push(`Action failed: ${rejection.reason}.`);
}
if (lines.length === 0) {
lines.push("Nothing changes.");
}
return lines.join(" ");
}
function persistWorldState(db: CharacterGardenDatabase, worldState: WorldState): void {
for (const entity of worldState.entities.values()) {
db.upsertEntity(entity);
}
}
function hydrateInitialState(db: CharacterGardenDatabase): WorldState {
db.init();
const existing = db.listEntities();
if (existing.length > 0) {
return worldStateFromEntities(existing);
}
const seeded = createSeedEntities();
for (const entity of seeded) {
db.upsertEntity(entity);
}
return worldStateFromEntities(seeded);
function ensureDefaultRulebook(
db: CharacterGardenDatabase,
worldState: WorldState
): SceneRulebook {
const existing = db.getRulebook(DEFAULT_RULEBOOK_ID);
if (existing) return existing;
const defaultRulebook = createDefaultRulebook(worldState.id);
db.upsertRulebook(defaultRulebook);
return defaultRulebook;
}
export function createCharacterGardenApp(dbPath: string): CharacterGardenApp {
const db = createDatabase({ dbPath });
let worldState = hydrateInitialState(db);
let worldState = ensureSeedState(db);
// Active rulebook ID — tracks which rulebook the world is using.
let activeRulebookId: string =
worldState.rulebookId ?? DEFAULT_RULEBOOK_ID;
function getSnapshot(): AppStateSnapshot {
return {
entities: entitiesFromWorldState(worldState),
events: db.listEvents(),
turns: db.listTurns(),
beliefs: db.listBeliefs(),
summaries: db.listSummaries(),
};
}
// Ensure the default rulebook is present on first boot.
ensureDefaultRulebook(db, worldState);
function processTurn(input: string): TurnResult {
const turnNumber = db.listTurns().length + 1;
const { actions, parser } = extractActionsFromProse(input);
let activeWorldState = worldState;
let latentResolution: TurnResult["latent_resolution"];
const latentNoun = typeof actions[0]?.params?.["latent_item"] === "string"
? String(actions[0].params?.["latent_item"])
: null;
if (latentNoun) {
const resolution = resolveLatentEntity(
{ actor_id: actions[0].actor, noun: latentNoun, turn: turnNumber },
activeWorldState
);
latentResolution = {
accepted: resolution.accepted,
reason: resolution.reason,
entity_id: resolution.entity?.id,
};
if (resolution.accepted && resolution.entity) {
activeWorldState = {
entities: new Map(activeWorldState.entities).set(
resolution.entity.id,
resolution.entity
),
};
db.upsertEntity(resolution.entity);
for (const belief of resolution.beliefs) {
db.insertBelief(belief);
}
}
}
const normalizedActions = actions.map((action) => {
if (latentNoun && latentResolution?.accepted && latentResolution.entity_id) {
return {
actor: action.actor,
verb: "take" as const,
target: latentResolution.entity_id,
};
}
return action;
});
const validation = validate(normalizedActions, activeWorldState);
const nextWorldState = applyChanges(activeWorldState, validation.state_changes);
const narration = narrateResult(nextWorldState, validation.accepted, validation.rejected, latentResolution?.reason);
const turnRecord: Turn = {
id: randomUUID(),
turn: turnNumber,
input,
output: narration,
timestamp: Date.now(),
};
db.insertTurn(turnRecord);
for (const action of validation.accepted) {
const event: GameEvent = {
id: randomUUID(),
turn: turnNumber,
action,
result: "success",
timestamp: Date.now(),
};
db.insertEvent(event);
}
for (const rejection of validation.rejected) {
const event: GameEvent = {
id: randomUUID(),
turn: turnNumber,
action: rejection.action,
result: "fail",
timestamp: Date.now(),
};
db.insertEvent(event);
}
worldState = nextWorldState;
persistWorldState(db, worldState);
return {
narration,
parser,
actions: normalizedActions,
accepted: validation.accepted,
rejected: validation.rejected,
latent_resolution: latentResolution,
snapshot: getSnapshot(),
};
function loadActiveRulebook(): SceneRulebook {
const rulebook = db.getRulebook(activeRulebookId);
if (rulebook) return rulebook;
// Fall back to default if the active one was deleted.
activeRulebookId = DEFAULT_RULEBOOK_ID;
return ensureDefaultRulebook(db, worldState);
}
return {
db,
getSnapshot,
processTurn,
getSnapshot() {
return {
worldState,
turns: db.listTurns(),
};
},
async processTurn(rawText: string) {
const rulebook = loadActiveRulebook();
const result = await runTurnManager(rawText, worldState, db, rulebook);
worldState = result.worldState;
return result;
},
getRulebook() {
return loadActiveRulebook();
},
upsertRulebook(rulebook: SceneRulebook) {
const updated: SceneRulebook = {
...rulebook,
version: Number.isInteger(rulebook.version) ? rulebook.version : 1,
updatedAt: Date.now(),
};
db.upsertRulebook(updated);
activeRulebookId = updated.id;
// Persist the active rulebook ID on the world state so it survives restarts.
worldState = { ...worldState, rulebookId: updated.id };
db.insertWorldState(null, worldState);
return updated;
},
listRulebooks() {
return db.listRulebooks();
},
reset() {
db.wipe();
worldState = ensureSeedState(db);
activeRulebookId = DEFAULT_RULEBOOK_ID;
ensureDefaultRulebook(db, worldState);
return {
worldState,
turns: db.listTurns(),
};
},
};
}

View File

@@ -0,0 +1,7 @@
export type Action = {
actorId: string;
type: string;
targetId?: string;
locationId?: string;
metadata?: Record<string, unknown>;
};

View File

@@ -0,0 +1,6 @@
export type Entity = {
id: string;
name: string;
type: string;
attributes: Record<string, unknown>;
};

View File

@@ -0,0 +1,48 @@
import type { Action } from "./action";
export type InterpreterStatus =
| "resolved"
| "needs_clarification"
| "rejected";
export type ClarificationReasonCode =
| "UNRECOGNIZED_INTENT"
| "AMBIGUOUS_REFERENCE"
| "EMPTY_INPUT"
| "LOW_CONFIDENCE"
| "INTERNAL_INVALID_OUTPUT";
export type ClarificationOption = {
id: string;
label: string;
value: string;
entityId?: string;
entityType?: "character" | "item" | "room" | "unknown";
};
export type ClarificationRequest = {
reasonCode: ClarificationReasonCode;
question: string;
field?: "verb" | "target" | "item" | "recipient" | "location";
options?: ClarificationOption[];
};
export type InterpreterCandidate = {
action: Action;
confidence: number;
rationale?: string;
};
export type InterpreterOutput = {
interpreterVersion: string;
rawText: string;
actorId: string;
resolutionSource: "deterministic" | "llm" | "hybrid";
minConfidence: number;
selectedConfidence?: number;
status: InterpreterStatus;
selectedActions: Action[];
candidates: InterpreterCandidate[];
diagnostics: string[];
clarification?: ClarificationRequest;
};

View File

@@ -0,0 +1,100 @@
/**
* SceneRulebook — data-driven validation rules for the truth engine.
*
* Rules are stored per-scene in the database and evaluated by rulebookEngine.ts.
* The default set is seeded from defaultRulebook.ts and mirrors the original
* hardcoded logic previously in the truth engine as editable, data-driven rules.
*/
/** Which entity in the action context a condition refers to. */
export type EntityRole = "actor" | "target" | "actorRoom" | "targetRoom";
/**
* A composable, JSON-serialisable condition expression.
*
* Combinators: and | or | not
* Predicates:
* entityExists — entity referenced by role is present in world state
* entityExistsOrWillBeCreated — entity exists OR will be created earlier in this turn
* entityType — entity.type === requiredType
* eq / neq — entity field comparison (id, name, type, or attributes[attribute])
* attributeExists — entity.attributes[attribute] is not undefined
* sameLocation — two entities share the same location attribute value
* actorIdIn — action.actorId is included in an allowed list
* actorNameIn — actor.name matches one of an allowed list (case-insensitive)
* actionMetadataEq — action.metadata[key] === value
* itemInInventory — item entity referenced by metadata key is in inventory of holder role
* attributeRef — entities[checkRole].attributes[prefix + entities[refRole].attributes[refAttribute]] === true
* metaValueNotInRoom — no entity of entityType in actor's room has name === action.metadata[metaKey]
*/
export type ConditionExpr =
| { op: "and"; conditions: ConditionExpr[] }
| { op: "or"; conditions: ConditionExpr[] }
| { op: "not"; condition: ConditionExpr }
| { op: "entityExists"; role: EntityRole }
| { op: "entityExistsOrWillBeCreated"; role: EntityRole }
| { op: "entityType"; role: EntityRole; requiredType: string }
| { op: "eq"; role: EntityRole; attribute: string; value: unknown }
| { op: "neq"; role: EntityRole; attribute: string; value: unknown }
| { op: "attributeExists"; role: EntityRole; attribute: string }
| { op: "sameLocation"; roleA: EntityRole; roleB: EntityRole }
| { op: "actorIdIn"; allowedIds: string[] }
| { op: "actorNameIn"; allowedNames: string[] }
| { op: "actionMetadataEq"; key: string; value: unknown }
| { op: "itemInInventory"; itemMetadataKey: string; holderRole: EntityRole }
| {
op: "attributeRef";
/** Entity whose attribute is being tested */
checkRole: EntityRole;
/** Optional string prepended to the resolved key (e.g. "has_") */
prefix?: string;
/** Entity that provides the dynamic attribute name */
refRole: EntityRole;
/** Attribute on refRole whose value supplies the key name */
refAttribute: string;
}
| {
op: "metaValueNotInRoom";
/** Key in action.metadata whose value to match against entity names */
metaKey: string;
/** Only match entities of this type */
entityType: string;
};
/** A single named check within an action rule set. */
export type RuleCheck = {
id: string;
/** Human-readable label shown in the rulebook editor. */
description: string;
condition: ConditionExpr;
failReason: string;
/**
* Failure message template.
* Supports: {actor.id}, {actor.name}, {target.id}, {target.name}
*/
failMessage: string;
};
/** All checks that apply to a specific action type. */
export type ActionRuleSet = {
actionType: string;
/**
* When false, all checks are skipped and the action always passes.
* Useful for quickly disabling enforcement without deleting the rules.
*/
enabled: boolean;
checks: RuleCheck[];
};
/** The full rulebook attached to a scene/world. */
export type SceneRulebook = {
id: string;
worldId: string;
/** Increment when schema/policy format changes in breaking ways. */
version: number;
name: string;
description?: string;
rules: ActionRuleSet[];
createdAt: number;
updatedAt: number;
};

View File

@@ -0,0 +1,12 @@
import type { Action } from "./action";
import type { InterpreterOutput } from "./intent";
import type { ValidationResult } from "./validation";
export type Turn = {
id: string;
rawText: string;
actions: Action[];
validation: ValidationResult[];
createdAt: number;
interpreter?: InterpreterOutput;
};

View File

@@ -0,0 +1,6 @@
export type ValidationResult = {
actionIndex: number;
success: boolean;
reason?: string;
message?: string;
};

View File

@@ -0,0 +1,10 @@
import type { Entity } from "./entity";
export type WorldState = {
id: string;
entities: Record<string, Entity>;
metadata: Record<string, unknown>;
createdAt: number;
/** ID of the SceneRulebook currently active for this world. */
rulebookId?: string;
};

View File

@@ -2,7 +2,13 @@ import fs from "node:fs";
import path from "node:path";
import Database from "better-sqlite3";
import { Action, Belief, Entity, GameEvent, Summary, Turn } from "./types";
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";
import type { WorldState } from "./contracts/world";
export interface DatabaseConfig {
dbPath: string;
@@ -12,58 +18,24 @@ export interface CharacterGardenDatabase {
sqlite: Database.Database;
init(): void;
close(): void;
upsertEntity(entity: Entity): void;
upsertEntities(entities: Entity[]): void;
listEntities(): Entity[];
insertEvent(event: GameEvent): void;
listEvents(): GameEvent[];
insertTurn(turn: Turn): void;
listTurns(): Turn[];
insertBelief(belief: Belief): void;
listBeliefs(entityId?: string): Belief[];
insertSummary(summary: Summary): void;
listSummaries(): Summary[];
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;
getLatestWorldState(): WorldState | null;
upsertRulebook(rulebook: SceneRulebook): void;
getRulebook(id: string): SceneRulebook | null;
listRulebooks(): SceneRulebook[];
deleteRulebook(id: string): void;
wipe(): void;
}
type EntityRow = {
id: string;
type: string;
name: string;
attributes_json: string;
};
type EventRow = {
id: string;
turn: number;
action_json: string;
result: "success" | "fail";
timestamp: number;
};
type TurnRow = {
id: string;
turn: number;
input: string;
output: string;
timestamp: number;
};
type BeliefRow = {
entity_id: string;
claim: string;
confidence: number;
};
type SummaryRow = {
id: string;
turn_start: number;
turn_end: number;
text: string;
timestamp: number;
};
function ensureParentDirectory(dbPath: string): void {
const directory = path.dirname(dbPath);
fs.mkdirSync(directory, { recursive: true });
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
}
function parseJson<T>(value: string): T {
@@ -72,51 +44,75 @@ function parseJson<T>(value: string): T {
export function createDatabase(config: DatabaseConfig): CharacterGardenDatabase {
ensureParentDirectory(config.dbPath);
const sqlite = new Database(config.dbPath);
const initStatements = [
`
CREATE TABLE IF NOT EXISTS turns (
id TEXT PRIMARY KEY,
raw_text TEXT NOT NULL,
created_at INTEGER NOT NULL
)
`,
`
CREATE TABLE IF NOT EXISTS actions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
turn_id TEXT NOT NULL,
action_index INTEGER NOT NULL,
actor_id TEXT NOT NULL,
type TEXT NOT NULL,
target_id TEXT,
location_id TEXT,
metadata_json TEXT,
FOREIGN KEY(turn_id) REFERENCES turns(id)
)
`,
`
CREATE TABLE IF NOT EXISTS validation_results (
id INTEGER PRIMARY KEY AUTOINCREMENT,
turn_id TEXT NOT NULL,
action_index INTEGER NOT NULL,
success INTEGER NOT NULL,
reason TEXT,
message TEXT,
FOREIGN KEY(turn_id) REFERENCES turns(id)
)
`,
`
CREATE TABLE IF NOT EXISTS entities (
id TEXT PRIMARY KEY,
type TEXT NOT NULL,
name TEXT NOT NULL,
type TEXT NOT NULL,
attributes_json TEXT NOT NULL
)
`,
`
CREATE TABLE IF NOT EXISTS events (
CREATE TABLE IF NOT EXISTS world_states (
id TEXT PRIMARY KEY,
turn INTEGER NOT NULL,
action_json TEXT NOT NULL,
result TEXT NOT NULL CHECK(result IN ('success', 'fail')),
timestamp INTEGER NOT NULL
turn_id TEXT,
state_json TEXT NOT NULL,
created_at INTEGER NOT NULL,
FOREIGN KEY(turn_id) REFERENCES turns(id)
)
`,
`
CREATE TABLE IF NOT EXISTS turns (
CREATE TABLE IF NOT EXISTS rulebooks (
id TEXT PRIMARY KEY,
turn INTEGER NOT NULL UNIQUE,
input TEXT NOT NULL,
output TEXT NOT NULL,
timestamp INTEGER NOT NULL
world_id TEXT NOT NULL,
version INTEGER NOT NULL DEFAULT 1,
name TEXT NOT NULL,
description TEXT,
rules_json TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)
`,
`
CREATE TABLE IF NOT EXISTS beliefs (
entity_id TEXT NOT NULL,
claim TEXT NOT NULL,
confidence REAL NOT NULL,
PRIMARY KEY (entity_id, claim)
)
`,
`
CREATE TABLE IF NOT EXISTS summaries (
id TEXT PRIMARY KEY,
turn_start INTEGER NOT NULL,
turn_end INTEGER NOT NULL,
text TEXT NOT NULL,
timestamp 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)
)
`,
];
@@ -125,163 +121,393 @@ export function createDatabase(config: DatabaseConfig): CharacterGardenDatabase
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, type, name, attributes_json)
VALUES (@id, @type, @name, @attributes_json)
INSERT INTO entities (id, name, type, attributes_json)
VALUES (@id, @name, @type, @attributes_json)
ON CONFLICT(id) DO UPDATE SET
type = excluded.type,
name = excluded.name,
type = excluded.type,
attributes_json = excluded.attributes_json
`);
const listEntitiesStatement = sqlite.prepare(`
SELECT id, type, name, attributes_json
SELECT id, name, type, attributes_json
FROM entities
ORDER BY id ASC
`);
const insertEventStatement = sqlite.prepare(`
INSERT INTO events (id, turn, action_json, result, timestamp)
VALUES (@id, @turn, @action_json, @result, @timestamp)
`);
const listEventsStatement = sqlite.prepare(`
SELECT id, turn, action_json, result, timestamp
FROM events
ORDER BY turn ASC, timestamp ASC, id ASC
`);
const insertTurnStatement = sqlite.prepare(`
INSERT INTO turns (id, turn, input, output, timestamp)
VALUES (@id, @turn, @input, @output, @timestamp)
INSERT INTO turns (id, raw_text, created_at)
VALUES (@id, @raw_text, @created_at)
`);
const listTurnsStatement = sqlite.prepare(`
SELECT id, turn, input, output, timestamp
SELECT id, raw_text, created_at
FROM turns
ORDER BY turn ASC
ORDER BY created_at ASC
`);
const insertBeliefStatement = sqlite.prepare(`
INSERT INTO beliefs (entity_id, claim, confidence)
VALUES (@entity_id, @claim, @confidence)
ON CONFLICT(entity_id, claim) DO UPDATE SET
confidence = excluded.confidence
const listAllActionsStatement = sqlite.prepare(`
SELECT turn_id, action_index, actor_id, type, target_id, location_id, metadata_json
FROM actions
ORDER BY turn_id, action_index ASC
`);
const listBeliefsStatement = sqlite.prepare(`
SELECT entity_id, claim, confidence
FROM beliefs
ORDER BY entity_id ASC, claim ASC
const listAllValidationStatement = sqlite.prepare(`
SELECT turn_id, action_index, success, reason, message
FROM validation_results
ORDER BY turn_id, action_index ASC
`);
const listBeliefsByEntityStatement = sqlite.prepare(`
SELECT entity_id, claim, confidence
FROM beliefs
WHERE entity_id = ?
ORDER BY claim ASC
const listInterpreterEventsStatement = sqlite.prepare(`
SELECT turn_id, interpreter_json
FROM interpreter_events
`);
const insertSummaryStatement = sqlite.prepare(`
INSERT INTO summaries (id, turn_start, turn_end, text, timestamp)
VALUES (@id, @turn_start, @turn_end, @text, @timestamp)
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 listSummariesStatement = sqlite.prepare(`
SELECT id, turn_start, turn_end, text, timestamp
FROM summaries
ORDER BY turn_start ASC, turn_end ASC
const insertActionStatement = sqlite.prepare(`
INSERT INTO actions (
turn_id,
action_index,
actor_id,
type,
target_id,
location_id,
metadata_json
) VALUES (
@turn_id,
@action_index,
@actor_id,
@type,
@target_id,
@location_id,
@metadata_json
)
`);
const insertValidationStatement = sqlite.prepare(`
INSERT INTO validation_results (
turn_id,
action_index,
success,
reason,
message
) VALUES (
@turn_id,
@action_index,
@success,
@reason,
@message
)
`);
const insertWorldStateStatement = sqlite.prepare(`
INSERT INTO world_states (id, turn_id, state_json, created_at)
VALUES (@id, @turn_id, @state_json, @created_at)
`);
const latestWorldStateStatement = sqlite.prepare(`
SELECT state_json
FROM world_states
ORDER BY created_at DESC
LIMIT 1
`);
const upsertRulebookStatement = sqlite.prepare(`
INSERT INTO rulebooks (id, world_id, version, name, description, rules_json, created_at, updated_at)
VALUES (@id, @world_id, @version, @name, @description, @rules_json, @created_at, @updated_at)
ON CONFLICT(id) DO UPDATE SET
version = excluded.version,
name = excluded.name,
description = excluded.description,
rules_json = excluded.rules_json,
updated_at = excluded.updated_at
`);
const getRulebookStatement = sqlite.prepare(`
SELECT id, world_id, version, name, description, rules_json, created_at, updated_at
FROM rulebooks
WHERE id = @id
`);
const listRulebooksStatement = sqlite.prepare(`
SELECT id, world_id, version, name, description, rules_json, created_at, updated_at
FROM rulebooks
ORDER BY created_at ASC
`);
const deleteRulebookStatement = sqlite.prepare(`
DELETE FROM rulebooks WHERE id = @id
`);
return {
sqlite,
init() {
// Schema is applied on database construction so prepared statements are valid.
// Tables are initialized on construction.
},
close() {
sqlite.close();
},
upsertEntity(entity) {
wipe() {
sqlite.exec(`
DELETE FROM interpreter_events;
DELETE FROM validation_results;
DELETE FROM actions;
DELETE FROM world_states;
DELETE FROM turns;
DELETE FROM entities;
DELETE FROM rulebooks;
`);
},
upsertEntities(entities) {
const tx = sqlite.transaction((entityList: Entity[]) => {
clearEntitiesStatement.run();
for (const entity of entityList) {
upsertEntityStatement.run({
id: entity.id,
type: entity.type,
name: entity.name,
type: entity.type,
attributes_json: JSON.stringify(entity.attributes),
});
}
});
tx(entities);
},
listEntities() {
const rows = listEntitiesStatement.all() as EntityRow[];
const rows = listEntitiesStatement.all() as Array<{
id: string;
name: string;
type: string;
attributes_json: string;
}>;
return rows.map((row) => ({
id: row.id,
type: row.type,
name: row.name,
type: row.type,
attributes: parseJson<Record<string, unknown>>(row.attributes_json),
}));
},
insertEvent(event) {
insertEventStatement.run({
id: event.id,
turn: event.turn,
action_json: JSON.stringify(event.action),
result: event.result,
timestamp: event.timestamp,
});
},
listEvents() {
const rows = listEventsStatement.all() as EventRow[];
return rows.map((row) => ({
id: row.id,
turn: row.turn,
action: parseJson<Action>(row.action_json),
result: row.result,
timestamp: row.timestamp,
}));
},
insertTurn(turn) {
insertTurnStatement.run(turn);
insertTurnStatement.run({
id: turn.id,
raw_text: turn.rawText,
created_at: turn.createdAt,
});
},
listTurns() {
return listTurnsStatement.all() as TurnRow[];
},
const rows = listTurnsStatement.all() as Array<{
id: string;
raw_text: string;
created_at: number;
}>;
insertBelief(belief) {
insertBeliefStatement.run(belief);
},
listBeliefs(entityId) {
if (entityId) {
return listBeliefsByEntityStatement.all(entityId) as BeliefRow[];
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 listBeliefsStatement.all() as BeliefRow[];
const actionRows = listAllActionsStatement.all() as Array<{
turn_id: string;
action_index: number;
actor_id: string;
type: string;
target_id: string | null;
location_id: string | null;
metadata_json: string | null;
}>;
const actionsByTurnId = new Map<string, Action[]>();
for (const row of actionRows) {
const list = actionsByTurnId.get(row.turn_id) ?? [];
list.push({
actorId: row.actor_id,
type: row.type,
...(row.target_id != null ? { targetId: row.target_id } : {}),
...(row.location_id != null ? { locationId: row.location_id } : {}),
...(row.metadata_json != null ? { metadata: parseJson<Record<string, unknown>>(row.metadata_json) } : {}),
});
actionsByTurnId.set(row.turn_id, list);
}
const validationRows = listAllValidationStatement.all() as Array<{
turn_id: string;
action_index: number;
success: number;
reason: string | null;
message: string | null;
}>;
const validationByTurnId = new Map<string, ValidationResult[]>();
for (const row of validationRows) {
const list = validationByTurnId.get(row.turn_id) ?? [];
list.push({
actionIndex: row.action_index,
success: row.success === 1,
...(row.reason != null ? { reason: row.reason } : {}),
...(row.message != null ? { message: row.message } : {}),
});
validationByTurnId.set(row.turn_id, list);
}
return rows.map((row) => ({
id: row.id,
rawText: row.raw_text,
actions: actionsByTurnId.get(row.id) ?? [],
validation: validationByTurnId.get(row.id) ?? [],
createdAt: row.created_at,
interpreter: interpreterByTurnId.get(row.id),
}));
},
insertSummary(summary) {
insertSummaryStatement.run({
id: summary.id,
turn_start: summary.turn_range[0],
turn_end: summary.turn_range[1],
text: summary.text,
timestamp: summary.timestamp,
insertInterpreterOutput(turnId, interpreter) {
insertInterpreterOutputStatement.run({
turn_id: turnId,
interpreter_json: JSON.stringify(interpreter),
created_at: Date.now(),
});
},
listSummaries() {
const rows = listSummariesStatement.all() as SummaryRow[];
insertActions(turnId, actions) {
const tx = sqlite.transaction((actionList: Action[]) => {
actionList.forEach((action, index) => {
insertActionStatement.run({
turn_id: turnId,
action_index: index,
actor_id: action.actorId,
type: action.type,
target_id: action.targetId ?? null,
location_id: action.locationId ?? null,
metadata_json: action.metadata ? JSON.stringify(action.metadata) : null,
});
});
});
tx(actions);
},
insertValidationResults(turnId, results) {
const tx = sqlite.transaction((validationList: ValidationResult[]) => {
for (const result of validationList) {
insertValidationStatement.run({
turn_id: turnId,
action_index: result.actionIndex,
success: result.success ? 1 : 0,
reason: result.reason ?? null,
message: result.message ?? null,
});
}
});
tx(results);
},
insertWorldState(turnId, worldState) {
insertWorldStateStatement.run({
id: worldState.id,
turn_id: turnId,
state_json: JSON.stringify(worldState),
created_at: worldState.createdAt,
});
},
getLatestWorldState() {
const row = latestWorldStateStatement.get() as { state_json: string } | undefined;
if (!row) {
return null;
}
return parseJson<WorldState>(row.state_json);
},
upsertRulebook(rulebook) {
upsertRulebookStatement.run({
id: rulebook.id,
world_id: rulebook.worldId,
version: rulebook.version,
name: rulebook.name,
description: rulebook.description ?? null,
rules_json: JSON.stringify(rulebook.rules),
created_at: rulebook.createdAt,
updated_at: rulebook.updatedAt,
});
},
getRulebook(id) {
const row = getRulebookStatement.get({ id }) as
| {
id: string;
world_id: string;
version: number;
name: string;
description: string | null;
rules_json: string;
created_at: number;
updated_at: number;
}
| undefined;
if (!row) return null;
return {
id: row.id,
worldId: row.world_id,
version: row.version ?? 1,
name: row.name,
description: row.description ?? undefined,
rules: parseJson(row.rules_json),
createdAt: row.created_at,
updatedAt: row.updated_at,
};
},
listRulebooks() {
const rows = listRulebooksStatement.all() as Array<{
id: string;
world_id: string;
version: number;
name: string;
description: string | null;
rules_json: string;
created_at: number;
updated_at: number;
}>;
return rows.map((row) => ({
id: row.id,
turn_range: [row.turn_start, row.turn_end],
text: row.text,
timestamp: row.timestamp,
worldId: row.world_id,
version: row.version ?? 1,
name: row.name,
description: row.description ?? undefined,
rules: parseJson(row.rules_json),
createdAt: row.created_at,
updatedAt: row.updated_at,
}));
},
deleteRulebook(id) {
deleteRulebookStatement.run({ id });
},
};
}

View File

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

View File

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

View File

@@ -0,0 +1,131 @@
import type { InterpreterOutput } from "../../contracts/intent";
import type { ResolveIntentInput } from "../resolveIntent";
import { parseTextToActions } from "../../parser/parseTextToActions";
export const DETERMINISTIC_INTERPRETER_VERSION = "deterministic-v1";
function hasAmbiguousReference(input: string): boolean {
return /\b(it|them|that|this|him|her|there|here)\b/i.test(input);
}
export function resolveDeterministicIntent(input: ResolveIntentInput): InterpreterOutput {
const trimmed = input.rawText.trim();
if (!trimmed) {
return {
interpreterVersion: DETERMINISTIC_INTERPRETER_VERSION,
rawText: input.rawText,
actorId: input.actorId,
resolutionSource: "deterministic",
minConfidence: input.minConfidence,
status: "rejected",
selectedActions: [],
candidates: [],
diagnostics: ["Input was empty after trimming whitespace."],
clarification: {
reasonCode: "EMPTY_INPUT",
question: "What would you like to do?",
field: "verb",
},
};
}
const actions = parseTextToActions(trimmed, input.actorId);
if (actions.length > 0) {
const candidates = actions.map((action) => ({
action,
confidence: 0.85,
rationale: "Matched deterministic parser pattern and normalized to canonical action.",
}));
const selectedConfidence = candidates.reduce((min, c) => Math.min(min, c.confidence), 1);
if (selectedConfidence < input.minConfidence) {
return {
interpreterVersion: DETERMINISTIC_INTERPRETER_VERSION,
rawText: input.rawText,
actorId: input.actorId,
resolutionSource: "deterministic",
minConfidence: input.minConfidence,
selectedConfidence,
status: "needs_clarification",
selectedActions: [],
candidates,
diagnostics: [
"Parser produced candidates but confidence did not meet threshold.",
],
clarification: {
reasonCode: "LOW_CONFIDENCE",
question: "I found a possible action but confidence is low. Can you rephrase your intent?",
field: "verb",
options: [
{ id: "inspect", label: "Inspect", value: "inspect" },
{ id: "move", label: "Move", value: "move" },
{ id: "take", label: "Take", value: "take" },
{ id: "open", label: "Open", value: "open" },
{ id: "introduce", label: "Introduce", value: "introduce" },
{ id: "describe", label: "Describe", value: "describe" },
{ id: "transfer", label: "Transfer", value: "transfer" },
],
},
};
}
return {
interpreterVersion: DETERMINISTIC_INTERPRETER_VERSION,
rawText: input.rawText,
actorId: input.actorId,
resolutionSource: "deterministic",
minConfidence: input.minConfidence,
selectedConfidence,
status: "resolved",
selectedActions: actions,
candidates,
diagnostics: ["Resolved by deterministic parser rules."],
};
}
if (hasAmbiguousReference(trimmed)) {
return {
interpreterVersion: DETERMINISTIC_INTERPRETER_VERSION,
rawText: input.rawText,
actorId: input.actorId,
resolutionSource: "deterministic",
minConfidence: input.minConfidence,
status: "needs_clarification",
selectedActions: [],
candidates: [],
diagnostics: ["Could not resolve pronoun/reference to a concrete entity."],
clarification: {
reasonCode: "AMBIGUOUS_REFERENCE",
question: "I am not sure what that refers to. Which item, character, or location did you mean?",
field: "target",
},
};
}
return {
interpreterVersion: DETERMINISTIC_INTERPRETER_VERSION,
rawText: input.rawText,
actorId: input.actorId,
resolutionSource: "deterministic",
minConfidence: input.minConfidence,
status: "needs_clarification",
selectedActions: [],
candidates: [],
diagnostics: ["No parser pattern matched this input."],
clarification: {
reasonCode: "UNRECOGNIZED_INTENT",
question: "I could not map that to a known action. Try verbs like inspect, move, take, open, introduce, describe, or transfer.",
field: "verb",
options: [
{ id: "inspect", label: "Inspect", value: "inspect" },
{ id: "move", label: "Move", value: "move" },
{ id: "take", label: "Take", value: "take" },
{ id: "open", label: "Open", value: "open" },
{ id: "introduce", label: "Introduce", value: "introduce" },
{ id: "describe", label: "Describe", value: "describe" },
{ id: "transfer", label: "Transfer", value: "transfer" },
],
},
};
}

View File

@@ -0,0 +1,361 @@
import type { Action } from "../../contracts/action";
import type { InterpreterOutput } from "../../contracts/intent";
import type { WorldState } from "../../contracts/world";
import type { ResolveIntentInput } from "../resolveIntent";
export const LLM_INTERPRETER_VERSION = "llm-v1-ollama";
type LlmClarification = {
reasonCode?: string;
question?: string;
field?: "verb" | "target" | "item" | "recipient" | "location";
options?: Array<{
id?: string;
label?: string;
value?: string;
entityId?: string;
entityType?: "character" | "item" | "room" | "unknown";
}>;
};
type LlmIntentResponse = {
status?: "resolved" | "needs_clarification" | "rejected";
selectedActions?: unknown;
selectedConfidence?: unknown;
clarification?: LlmClarification;
rationale?: string;
};
function fallbackClarification(input: ResolveIntentInput, diagnostic: string): InterpreterOutput {
return {
interpreterVersion: LLM_INTERPRETER_VERSION,
rawText: input.rawText,
actorId: input.actorId,
resolutionSource: "llm",
minConfidence: input.minConfidence,
status: "needs_clarification",
selectedActions: [],
candidates: [],
diagnostics: [diagnostic],
clarification: {
reasonCode: "UNRECOGNIZED_INTENT",
question: "I could not confidently resolve that intent. Please rephrase with a clear verb.",
field: "verb",
options: [
{ id: "inspect", label: "Inspect", value: "inspect" },
{ id: "move", label: "Move", value: "move" },
{ id: "take", label: "Take", value: "take" },
{ id: "open", label: "Open", value: "open" },
{ id: "introduce", label: "Introduce", value: "introduce" },
{ id: "describe", label: "Describe", value: "describe" },
{ id: "transfer", label: "Transfer", value: "transfer" },
],
},
};
}
function extractFirstJsonObject(text: string): string | null {
const trimmed = text.trim();
if (trimmed.startsWith("{") && trimmed.endsWith("}")) {
return trimmed;
}
const codeFenceMatch = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i);
if (codeFenceMatch?.[1]) {
const fenced = codeFenceMatch[1].trim();
if (fenced.startsWith("{") && fenced.endsWith("}")) {
return fenced;
}
}
const firstBrace = trimmed.indexOf("{");
const lastBrace = trimmed.lastIndexOf("}");
if (firstBrace >= 0 && lastBrace > firstBrace) {
return trimmed.slice(firstBrace, lastBrace + 1);
}
return null;
}
function toActionArray(value: unknown, actorId: string): Action[] {
if (!Array.isArray(value)) return [];
const actions: Action[] = [];
for (const item of value) {
if (!item || typeof item !== "object") continue;
const action = item as Record<string, unknown>;
const type = typeof action.type === "string" ? action.type.trim() : "";
if (!type) continue;
const normalized: Action = {
actorId,
type,
};
if (typeof action.actorId === "string" && action.actorId.trim()) {
normalized.actorId = action.actorId;
}
if (typeof action.targetId === "string" && action.targetId.trim()) {
normalized.targetId = action.targetId;
}
if (typeof action.locationId === "string" && action.locationId.trim()) {
normalized.locationId = action.locationId;
}
if (action.metadata && typeof action.metadata === "object" && !Array.isArray(action.metadata)) {
normalized.metadata = action.metadata as Record<string, unknown>;
}
actions.push(normalized);
}
return actions;
}
function toConfidence(value: unknown, fallback: number): number {
if (typeof value !== "number" || Number.isNaN(value)) {
return fallback;
}
if (value < 0) return 0;
if (value > 1) return 1;
return value;
}
function toReasonCode(value: string | undefined):
| "UNRECOGNIZED_INTENT"
| "AMBIGUOUS_REFERENCE"
| "EMPTY_INPUT"
| "LOW_CONFIDENCE"
| "INTERNAL_INVALID_OUTPUT" {
const normalized = (value ?? "").trim().toUpperCase();
switch (normalized) {
case "AMBIGUOUS_REFERENCE":
return "AMBIGUOUS_REFERENCE";
case "EMPTY_INPUT":
return "EMPTY_INPUT";
case "LOW_CONFIDENCE":
return "LOW_CONFIDENCE";
case "INTERNAL_INVALID_OUTPUT":
return "INTERNAL_INVALID_OUTPUT";
default:
return "UNRECOGNIZED_INTENT";
}
}
/**
* Build a compact world-context block so the model can resolve entity references
* (e.g. "the key" → targetId: "key_1") without inventing IDs.
*
* Includes:
* - The actor's current room
* - Every entity visible in that room (items, doors, characters)
* - Items in the actor's inventory
* - All room entities (needed to populate locationId for move actions)
*/
function buildWorldContext(worldState: WorldState, actorId: string): string {
const actor = worldState.entities[actorId];
const actorLocation = actor ? String(actor.attributes.location ?? "") : "";
const lines: string[] = [];
if (actorLocation) {
const room = worldState.entities[actorLocation];
lines.push(`actor_location_id: ${actorLocation}${room ? ` (${room.name})` : ""}`);
}
for (const entity of Object.values(worldState.entities)) {
if (entity.id === actorId) continue;
const loc = String(entity.attributes.location ?? "");
const inRoom = loc === actorLocation;
const inActorInventory = loc === `inventory:${actorId}`;
const isRoom = entity.type === "room";
if (inRoom || inActorInventory || isRoom) {
const context = inActorInventory
? "actor_inventory"
: isRoom
? "room"
: "in_actor_room";
// Emit only fields the model needs to build actions.
const extras: string[] = [];
if (entity.attributes.locked === true) extras.push("locked");
if (entity.attributes.open === true) extras.push("open");
if (entity.attributes.takeable === true) extras.push("takeable");
if (entity.attributes.openable === true) extras.push("openable");
const extrasStr = extras.length ? ` [${extras.join(",")}]` : "";
lines.push(
`entity id=${entity.id} name=${JSON.stringify(entity.name)} type=${entity.type} context=${context}${extrasStr}`
);
}
}
return lines.length ? lines.join("\n") : "(no world context available)";
}
function buildPrompt(input: ResolveIntentInput): { system: string; user: string } {
const system = [
"You are an intent-to-actions resolver for a text adventure engine.",
"You will receive the current world state and a player command.",
"Return ONLY a JSON object with this exact shape (no markdown, no prose):",
JSON.stringify({
status: "resolved|needs_clarification|rejected",
selectedActions: [
{
type: "inspect|move|open|take|introduce|describe|transfer",
targetId: "entity id from world context, or omit",
locationId: "room id for move, or omit",
metadata: { note: "omit if unused" },
},
],
selectedConfidence: 0.0,
clarification: {
reasonCode:
"UNRECOGNIZED_INTENT|AMBIGUOUS_REFERENCE|EMPTY_INPUT|LOW_CONFIDENCE|INTERNAL_INVALID_OUTPUT",
question: "question to ask player",
field: "verb|target|item|recipient|location",
},
rationale: "one sentence",
}),
"Rules:",
"- Use entity ids from the world context for targetId and locationId. Never invent ids.",
"- If status is resolved, selectedActions must be non-empty and clarification must be omitted.",
"- If status is not resolved, selectedActions must be empty and clarification must be present.",
"- Use only the canonical action types listed above.",
].join("\n");
const worldContext = input.worldState
? buildWorldContext(input.worldState, input.actorId)
: "(no world context provided)";
const user = [
`actorId: ${input.actorId}`,
`minimum_confidence: ${input.minConfidence}`,
`world_context:\n${worldContext}`,
`player_input: ${JSON.stringify(input.rawText)}`,
].join("\n\n");
return { system, user };
}
export async function resolveLlmIntent(input: ResolveIntentInput): Promise<InterpreterOutput> {
const baseUrl = (process.env.LLM_RESOLVER_URL ?? process.env.OLLAMA_URL ?? "").trim();
const model = (process.env.LLM_RESOLVER_MODEL ?? "llama3.2:3b").trim();
const timeoutMs = Number(process.env.LLM_RESOLVER_TIMEOUT_MS ?? 6000);
if (!baseUrl) {
return fallbackClarification(input, "LLM resolver disabled: no LLM_RESOLVER_URL/OLLAMA_URL configured.");
}
const prompt = buildPrompt(input);
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), Number.isFinite(timeoutMs) ? timeoutMs : 6000);
try {
const response = await fetch(`${baseUrl.replace(/\/$/, "")}/api/chat`, {
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify({
model,
stream: false,
format: "json",
options: {
temperature: 0,
},
messages: [
{ role: "system", content: prompt.system },
{ role: "user", content: prompt.user },
],
}),
signal: controller.signal,
});
if (!response.ok) {
return fallbackClarification(
input,
`LLM resolver HTTP error: ${response.status} ${response.statusText}`
);
}
const payload = (await response.json()) as {
message?: { content?: string };
};
const text = payload.message?.content ?? "";
const jsonText = extractFirstJsonObject(text);
if (!jsonText) {
return fallbackClarification(input, "LLM resolver returned non-JSON content.");
}
let parsed: LlmIntentResponse;
try {
parsed = JSON.parse(jsonText) as LlmIntentResponse;
} catch {
return fallbackClarification(input, "LLM resolver returned malformed JSON.");
}
const status = parsed.status ?? "needs_clarification";
const selectedActions = toActionArray(parsed.selectedActions, input.actorId);
const selectedConfidence = toConfidence(parsed.selectedConfidence, 0.7);
const diagnostics = [
"Resolved via LLM resolver.",
...(parsed.rationale ? [parsed.rationale] : []),
];
if (status === "resolved" && selectedActions.length > 0 && selectedConfidence >= input.minConfidence) {
return {
interpreterVersion: LLM_INTERPRETER_VERSION,
rawText: input.rawText,
actorId: input.actorId,
resolutionSource: "llm",
minConfidence: input.minConfidence,
selectedConfidence,
status: "resolved",
selectedActions,
candidates: selectedActions.map((action) => ({
action,
confidence: selectedConfidence,
rationale: "Selected by configured LLM resolver.",
})),
diagnostics,
};
}
return {
interpreterVersion: LLM_INTERPRETER_VERSION,
rawText: input.rawText,
actorId: input.actorId,
resolutionSource: "llm",
minConfidence: input.minConfidence,
selectedConfidence,
status: status === "rejected" ? "rejected" : "needs_clarification",
selectedActions: [],
candidates: [],
diagnostics: [
"LLM resolver did not produce a high-confidence resolved action set.",
...diagnostics,
],
clarification: {
reasonCode: toReasonCode(parsed.clarification?.reasonCode),
question:
parsed.clarification?.question ??
"I need a clearer command. Please rephrase with a specific verb and target.",
field: parsed.clarification?.field,
options: parsed.clarification?.options
?.filter((option) => !!option && typeof option.value === "string" && option.value.trim())
.map((option, index) => ({
id: option.id ?? `llm-option-${index + 1}`,
label: option.label ?? option.value ?? "Option",
value: option.value ?? "",
entityId: option.entityId,
entityType: option.entityType,
})),
},
};
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown LLM resolver error.";
return fallbackClarification(input, `LLM resolver request failed: ${message}`);
} finally {
clearTimeout(timeout);
}
}

View File

@@ -0,0 +1,70 @@
import type { InterpreterOutput } from "../contracts/intent";
import type { WorldState } from "../contracts/world";
import { resolveDeterministicIntent } from "./adapters/deterministicResolver";
import { resolveLlmIntent } from "./adapters/llmResolver";
import {
type ResolverMode,
normalizeResolverMode,
} from "./resolveIntent";
const DEFAULT_MIN_CONFIDENCE = 0.65;
type InterpretTurnOptions = {
mode?: ResolverMode;
minConfidence?: number;
worldState?: WorldState;
};
function getResolverMode(options?: InterpretTurnOptions): ResolverMode {
if (options?.mode) {
return options.mode;
}
return normalizeResolverMode(process.env.INTENT_RESOLVER_MODE);
}
function buildInput(rawText: string, actorId: string, options?: InterpretTurnOptions) {
return {
rawText,
actorId,
minConfidence: options?.minConfidence ?? DEFAULT_MIN_CONFIDENCE,
worldState: options?.worldState,
};
}
export async function interpretTurn(
rawText: string,
actorId = "player",
options?: InterpretTurnOptions
): Promise<InterpreterOutput> {
const mode = getResolverMode(options);
const input = buildInput(rawText, actorId, options);
if (mode === "deterministic") {
return resolveDeterministicIntent(input);
}
if (mode === "llm") {
return resolveLlmIntent(input);
}
// hybrid mode: prefer LLM when available, but deterministically fall back.
const llmOutput = await resolveLlmIntent(input);
if (llmOutput.status === "resolved") {
return {
...llmOutput,
resolutionSource: "hybrid",
diagnostics: ["Hybrid mode: resolved via LLM adapter.", ...llmOutput.diagnostics],
};
}
const deterministicOutput = resolveDeterministicIntent(input);
return {
...deterministicOutput,
resolutionSource: "hybrid",
diagnostics: [
"Hybrid mode: LLM adapter did not resolve intent; used deterministic fallback.",
...deterministicOutput.diagnostics,
...llmOutput.diagnostics,
],
};
}

View File

@@ -0,0 +1,25 @@
import type { InterpreterOutput } from "../contracts/intent";
import type { WorldState } from "../contracts/world";
export type ResolverMode = "deterministic" | "llm" | "hybrid";
export type ResolveIntentInput = {
rawText: string;
actorId: string;
minConfidence: number;
/** Optional world state — passed to LLM resolvers to provide entity context. */
worldState?: WorldState;
};
export type IntentResolver = {
name: string;
resolve(input: ResolveIntentInput): Promise<InterpreterOutput> | InterpreterOutput;
};
export function normalizeResolverMode(value: string | undefined): ResolverMode {
const normalized = (value ?? "").trim().toLowerCase();
if (normalized === "deterministic" || normalized === "llm" || normalized === "hybrid") {
return normalized;
}
return "hybrid";
}

View File

@@ -0,0 +1,94 @@
import type { InterpreterOutput } from "../contracts/intent";
const VALID_STATUSES = new Set(["resolved", "needs_clarification", "rejected"]);
export type InterpreterValidation = {
isValid: boolean;
issues: string[];
};
/**
* Runtime guard for the interpreter boundary.
*
* The turn manager uses this to ensure malformed interpreter output never
* reaches deterministic validation/mutation logic.
*/
export function validateInterpreterOutput(output: InterpreterOutput): InterpreterValidation {
const issues: string[] = [];
if (!output || typeof output !== "object") {
return { isValid: false, issues: ["Interpreter output must be an object."] };
}
if (typeof output.interpreterVersion !== "string" || !output.interpreterVersion.trim()) {
issues.push("interpreterVersion must be a non-empty string.");
}
if (typeof output.rawText !== "string") {
issues.push("rawText must be a string.");
}
if (typeof output.actorId !== "string" || !output.actorId.trim()) {
issues.push("actorId must be a non-empty string.");
}
if (!VALID_STATUSES.has(output.status)) {
issues.push("status must be one of: resolved, needs_clarification, rejected.");
}
if (!Array.isArray(output.selectedActions)) {
issues.push("selectedActions must be an array.");
}
if (!Array.isArray(output.candidates)) {
issues.push("candidates must be an array.");
}
if (!Array.isArray(output.diagnostics)) {
issues.push("diagnostics must be an array.");
}
if (typeof output.minConfidence !== "number" || output.minConfidence < 0 || output.minConfidence > 1) {
issues.push("minConfidence must be a number between 0 and 1.");
}
if (output.selectedConfidence !== undefined) {
if (
typeof output.selectedConfidence !== "number" ||
output.selectedConfidence < 0 ||
output.selectedConfidence > 1
) {
issues.push("selectedConfidence must be between 0 and 1 when provided.");
}
}
for (const candidate of output.candidates) {
if (typeof candidate.confidence !== "number" || candidate.confidence < 0 || candidate.confidence > 1) {
issues.push("Every candidate confidence must be between 0 and 1.");
break;
}
}
if (output.status === "resolved") {
if (output.selectedActions.length === 0) {
issues.push("resolved output must include at least one selected action.");
}
if (output.clarification) {
issues.push("resolved output must not include clarification.");
}
}
if (output.status !== "resolved") {
if (output.selectedActions.length > 0) {
issues.push("unresolved/rejected output must not include selected actions.");
}
if (!output.clarification) {
issues.push("unresolved/rejected output must include clarification.");
}
}
return {
isValid: issues.length === 0,
issues,
};
}

View File

@@ -1,209 +0,0 @@
import {
Affordance,
Belief,
Entity,
Fact,
LatentEntityRequest,
LatentEntityResolution,
} from "./types";
import { WorldState } from "./truthEngine";
const PERSONAL_ITEM_NOUNS = new Set([
"phone",
"wallet",
"keys",
"notebook",
"pen",
"coin",
"id card",
"card",
]);
function asBoolean(value: unknown): boolean | null {
if (typeof value === "boolean") {
return value;
}
return null;
}
function asNumber(value: unknown): number | null {
if (typeof value === "number" && Number.isFinite(value)) {
return value;
}
return null;
}
function slugify(value: string): string {
return value
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "") || "item";
}
function deriveAffordances(actor: Entity): Affordance[] {
const clothed = asBoolean(actor.attributes["clothed"]);
const pocketCount = asNumber(actor.attributes["pocket_count"]);
const hasBag = asBoolean(actor.attributes["has_bag"]);
const searchedEmpty = asBoolean(actor.attributes["searched_empty"]);
const canConcealSmallItems =
searchedEmpty !== true &&
((clothed === true && (pocketCount ?? 0) > 0) || hasBag === true);
const reason = searchedEmpty === true
? "actor was previously established as carrying nothing"
: hasBag === true
? "actor is carrying a bag or container"
: clothed === true && (pocketCount ?? 0) > 0
? "actor is clothed and has pockets"
: "actor has no established carrying context for concealed items";
return [
{
entity_id: actor.id,
key: "can_conceal_small_items",
enabled: canConcealSmallItems,
reason,
},
];
}
function buildBelief(actor: Entity, noun: string): Belief {
return {
entity_id: actor.id,
claim: `${actor.name} may be carrying a ${noun}`,
confidence: PERSONAL_ITEM_NOUNS.has(noun) ? 0.8 : 0.5,
};
}
function createEntityId(actorId: string, noun: string, worldState: WorldState): string {
const base = `${actorId}-${slugify(noun)}`;
if (!worldState.entities.has(base)) {
return base;
}
let suffix = 2;
while (worldState.entities.has(`${base}-${suffix}`)) {
suffix += 1;
}
return `${base}-${suffix}`;
}
function createLatentEntity(
actor: Entity,
noun: string,
turn: number | undefined,
worldState: WorldState
): Entity {
const entityId = createEntityId(actor.id, noun, worldState);
return {
id: entityId,
type: "item",
name: noun,
attributes: {
location: `inventory:${actor.id}`,
takeable: true,
useable: true,
provenance: {
introduced_turn: turn,
introduced_by: actor.id,
introduced_reason: "plausible_personal_item",
latent_from_belief: `${actor.name} may be carrying a ${noun}`,
},
},
};
}
export function resolveLatentEntity(
request: LatentEntityRequest,
worldState: WorldState
): LatentEntityResolution {
const actor = worldState.entities.get(request.actor_id);
if (!actor) {
return {
accepted: false,
reason: `actor entity '${request.actor_id}' does not exist`,
facts: [],
beliefs: [],
affordances: [],
};
}
const noun = request.noun.trim().toLowerCase();
if (!noun) {
return {
accepted: false,
reason: "latent entity request requires a noun",
facts: [],
beliefs: [],
affordances: [],
};
}
const affordances = deriveAffordances(actor);
const concealment = affordances.find(
(affordance) => affordance.key === "can_conceal_small_items"
);
const belief = buildBelief(actor, noun);
if (asBoolean(actor.attributes["naked"]) === true) {
return {
accepted: false,
reason: `${actor.id} is established as naked and cannot plausibly conceal a ${noun}`,
facts: [],
beliefs: [belief],
affordances,
};
}
if (concealment?.enabled !== true) {
return {
accepted: false,
reason: concealment?.reason ?? `no carrying context supports introducing a ${noun}`,
facts: [],
beliefs: [belief],
affordances,
};
}
if (!PERSONAL_ITEM_NOUNS.has(noun)) {
return {
accepted: false,
reason: `${noun} is not in the MVP plausible personal-item set`,
facts: [],
beliefs: [belief],
affordances,
};
}
const entity = createLatentEntity(actor, noun, request.turn, worldState);
const facts: Fact[] = [
{
entity_id: actor.id,
key: `may_have_${slugify(noun)}`,
value: true,
source: "inference",
},
{
entity_id: entity.id,
key: "location",
value: `inventory:${actor.id}`,
source: "inference",
},
];
return {
accepted: true,
reason: `${noun} promoted from plausible latent belief to fact`,
entity,
facts,
beliefs: [belief],
affordances,
};
}

View File

@@ -1,131 +0,0 @@
import { Action } from "./types";
export interface ExtractedActions {
actions: Action[];
parser: "fallback";
}
const ROOM_ALIASES: Record<string, string> = {
garden: "garden",
shed: "shed",
offscene: "offscene",
outside: "offscene",
away: "offscene",
};
const TARGET_ALIASES: Record<string, string> = {
gate: "gate",
bench: "bench",
groundskeeper: "groundskeeper",
keeper: "groundskeeper",
shed: "shed",
garden: "garden",
offscene: "offscene",
};
function normalized(input: string): string {
return input.trim().toLowerCase();
}
function extractQuotedOrTrailingNoun(input: string): string | null {
const quoted = input.match(/"([^"]+)"|'([^']+)'/);
if (quoted) {
return (quoted[1] ?? quoted[2]).trim().toLowerCase();
}
const pulled = input.match(/(?:pull|pulls|pulled|take|takes|took)\s+(?:out\s+)?(?:a|an|the|my|their|his|her)?\s*([a-z0-9 ]+)$/i);
if (pulled?.[1]) {
return pulled[1].trim().toLowerCase();
}
return null;
}
function resolveTarget(input: string): string | undefined {
const direct = Object.entries(TARGET_ALIASES).find(([alias]) =>
input.includes(alias)
);
return direct?.[1];
}
export function extractActionsFromProse(input: string, actorId = "player"): ExtractedActions {
const text = normalized(input);
if (!text) {
return {
actions: [{ actor: actorId, verb: "inspect", target: actorId }],
parser: "fallback",
};
}
const room = Object.entries(ROOM_ALIASES).find(([alias]) => text.includes(alias))?.[1];
if (/(go|move|walk|head|travel)/.test(text) && room) {
return {
actions: [{ actor: actorId, verb: "move", target: room }],
parser: "fallback",
};
}
if (/(open)/.test(text)) {
return {
actions: [{ actor: actorId, verb: "open", target: resolveTarget(text) ?? "gate" }],
parser: "fallback",
};
}
if (/(close|shut)/.test(text)) {
return {
actions: [{ actor: actorId, verb: "close", target: resolveTarget(text) ?? "gate" }],
parser: "fallback",
};
}
if (/(take|pick up|grab)/.test(text)) {
return {
actions: [{ actor: actorId, verb: "take", target: resolveTarget(text) ?? undefined }],
parser: "fallback",
};
}
if (/(drop|put down|set down)/.test(text)) {
return {
actions: [{ actor: actorId, verb: "drop", target: resolveTarget(text) ?? undefined }],
parser: "fallback",
};
}
if (/(talk|speak|ask|say)/.test(text)) {
return {
actions: [{ actor: actorId, verb: "speak", target: resolveTarget(text) ?? "groundskeeper", params: { utterance: input } }],
parser: "fallback",
};
}
if (/(use|press|activate)/.test(text)) {
return {
actions: [{ actor: actorId, verb: "use", target: resolveTarget(text) ?? undefined }],
parser: "fallback",
};
}
if (/(look|inspect|examine)/.test(text)) {
return {
actions: [{ actor: actorId, verb: "inspect", target: resolveTarget(text) ?? actorId }],
parser: "fallback",
};
}
const latentNoun = extractQuotedOrTrailingNoun(text);
if (latentNoun && /(pull|pulls|pulled|take|takes|took).*(out)/.test(text)) {
return {
actions: [{ actor: actorId, verb: "inspect", params: { latent_item: latentNoun } }],
parser: "fallback",
};
}
return {
actions: [{ actor: actorId, verb: "inspect", target: actorId, params: { raw_input: input } }],
parser: "fallback",
};
}

View File

@@ -0,0 +1,221 @@
import type { Action } from "../contracts/action";
function normalized(input: string): string {
return input.trim().toLowerCase();
}
function toDisplayName(value: string): string {
return value
.split(/\s+/)
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(" ");
}
function toItemSlug(value: string): string {
return (
value
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "_")
.replace(/^_+|_+$/g, "") || "item"
);
}
function extractIntroducedCharacterName(input: string): string | undefined {
const match = input.match(/(?:introduce|bring in|invite|have)\s+(?:the\s+|a\s+|an\s+)?(.+?)(?:\s+join)?$/);
const rawName = match?.[1]?.trim();
if (!rawName) {
return undefined;
}
return rawName.replace(/^(the|a|an)\s+/, "").trim() || undefined;
}
function extractActorAndAction(sentence: string): { actorName?: string; action: string } {
const normalized_sent = normalized(sentence);
// For now, treat the entire sentence as an action with no explicit actor
// In future, we can add patterns like "spencer introduces jeff" -> { actorName: "spencer", action: "introduces jeff" }
return { action: normalized_sent };
}
function extractTakenItemName(input: string): string | undefined {
const match = input.match(/(?:take|pick up|grab)\s+(?:the\s+|a\s+|an\s+)?(.+)$/);
const rawName = match?.[1]?.trim();
if (!rawName) {
return undefined;
}
return rawName.replace(/^(the|a|an)\s+/, "").trim() || undefined;
}
function extractTransferParts(input: string): { itemName: string; recipientName: string } | undefined {
const match = input.match(
/(?:give|hand|pass|transfer)\s+(?:the\s+|a\s+|an\s+)?(.+?)\s+(?:to|over to)\s+(?:the\s+)?(.+)$/
);
if (!match) {
return undefined;
}
const itemName = match[1]?.trim().replace(/^(the|a|an)\s+/, "").trim();
const recipientName = match[2]?.trim().replace(/^(the|a|an)\s+/, "").trim();
if (!itemName || !recipientName) {
return undefined;
}
return { itemName, recipientName };
}
function toCharacterSlug(value: string): string {
return (
value
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "_")
.replace(/^_+|_+$/g, "") || "character"
);
}
function resolveRecipientId(name: string): string {
const n = name.trim().toLowerCase();
if (n === "player" || n === "me" || n === "myself") {
return "player";
}
if (n === "groundskeeper") {
return "groundskeeper";
}
return `character_${toCharacterSlug(name)}`;
}
function parseSingleAction(actionText: string, defaultActorId: string): Action | undefined {
const input = normalized(actionText);
if (!input) {
return undefined;
}
if (/(look|inspect|examine)/.test(input)) {
return { actorId: defaultActorId, type: "inspect", targetId: defaultActorId };
}
if (/(go|move|walk|head|travel)/.test(input)) {
if (input.includes("exit") || input.includes("next room") || input.includes("through door")) {
return { actorId: defaultActorId, type: "move", targetId: "room_exit" };
}
if (input.includes("start")) {
return { actorId: defaultActorId, type: "move", targetId: "room_start" };
}
return undefined;
}
if (/(open)/.test(input)) {
if (input.includes("door")) {
return { actorId: defaultActorId, type: "open", targetId: "door_1" };
}
return undefined;
}
if (/(take|pick up|grab)/.test(input)) {
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" };
}
const characterName = extractIntroducedCharacterName(input);
if (!characterName) {
return undefined;
}
return {
actorId: defaultActorId,
type: "introduce",
metadata: {
characterName,
displayName: toDisplayName(characterName),
createIfMissing: true,
},
};
}
if (/(give|hand|pass|transfer)/.test(input)) {
const parts = extractTransferParts(input);
if (!parts) {
return undefined;
}
const itemId = parts.itemName.includes("key") ? "key_1" : `item_${toItemSlug(parts.itemName)}`;
return {
actorId: defaultActorId,
type: "transfer",
targetId: resolveRecipientId(parts.recipientName),
metadata: {
itemId,
itemName: toDisplayName(parts.itemName),
recipientName: toDisplayName(parts.recipientName),
},
};
}
if (/(describe|is a|is an|has)/.test(input)) {
// Match patterns like "describe the merchant as shrewd" or "the merchant is shrewd"
const describeMatch = input.match(/(?:describe|tell about)\s+(?:the\s+)?([a-z\s_]+?)\s+as\s+(.+)$/) ||
input.match(/(?:the\s+)?([a-z\s_]+?)\s+(?:is|has)\s+(.+)$/);
if (describeMatch) {
const [_, targetNameRaw, trait] = describeMatch;
const targetName = targetNameRaw.trim().replace(/^the\s+/, "").trim();
const targetId = `character_${targetName.replace(/\s+/g, "_")}`;
return {
actorId: defaultActorId,
type: "describe",
targetId,
metadata: {
trait: trait.trim(),
},
};
}
return undefined;
}
return undefined;
}
export function parseTextToActions(text: string, actorId = "player"): Action[] {
if (!text || !text.trim()) {
return [];
}
// Split by sentence terminators
const sentences = text.split(/[.!?]+/).map((s) => s.trim()).filter(Boolean);
const actions: Action[] = [];
for (const sentence of sentences) {
const { actorName, action } = extractActorAndAction(sentence);
const resolvedActorId = actorName ? `character_${actorName}` : actorId;
const parsedAction = parseSingleAction(action, resolvedActorId);
if (parsedAction) {
actions.push(parsedAction);
}
}
return actions;
}

View File

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

View File

@@ -0,0 +1,47 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import { createCharacterGardenApp } from "../app";
import { interpretTurn } from "../interpreter/interpretTurn";
async function run(): Promise<void> {
const resolved = await interpretTurn("look around", "player");
assert.equal(resolved.status, "resolved", "Expected 'look around' to resolve.");
const hybridResolved = await interpretTurn("look around", "player", { mode: "hybrid" });
assert.equal(hybridResolved.status, "resolved", "Expected hybrid mode to resolve via deterministic fallback when LLM is unavailable.");
assert.equal(hybridResolved.resolutionSource, "hybrid");
const empty = await interpretTurn("", "player");
assert.equal(empty.status, "rejected", "Expected empty input to be rejected.");
assert.equal(empty.clarification?.reasonCode, "EMPTY_INPUT");
const dbPath = path.join("/tmp", `charactergarden_integration_${Date.now()}.db`);
if (fs.existsSync(dbPath)) {
fs.unlinkSync(dbPath);
}
const app = createCharacterGardenApp(dbPath);
const unresolved = await app.processTurn("blorb invalid nonsense");
assert.equal(unresolved.interpreter.status, "needs_clarification");
assert.equal(unresolved.actions.length, 0);
const valid = await app.processTurn("look around");
assert.equal(valid.interpreter.status, "resolved");
const snapshot = app.getSnapshot();
assert.ok(snapshot.turns.length >= 2, "Expected persisted turns.");
const latestTurn = snapshot.turns[snapshot.turns.length - 1];
assert.ok(latestTurn.interpreter, "Expected interpreter payload persisted on turn.");
app.db.close();
console.log("Integration checks passed.");
}
void run().catch((error) => {
console.error("Integration checks failed:");
console.error(error);
process.exit(1);
});

View File

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

View File

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

View File

@@ -1,294 +0,0 @@
/**
* Truth Engine — section 5.2
*
* Pure validation logic. No LLM. No I/O. No side effects.
* Receives a world state snapshot and a list of actions.
* Returns what is accepted, what is rejected, and what would change.
*
* Rules (section 3):
* 1. Only the Truth Engine may produce StateChanges.
* 2. LLM output is never directly trusted.
* 3. Every state change must be traceable to an accepted action.
* 4. Invalid actions must return explicit failure reasons.
*/
import { Action, Entity, StateChange, ValidationResult, ALLOWED_VERBS } from "./types";
export const OFFSCENE_ROOM_ID = "offscene";
// ── World state snapshot passed into validate() ──────────────
export interface WorldState {
entities: Map<string, Entity>;
}
// ── Per-verb rule handlers ────────────────────────────────────
type RuleResult =
| { ok: true; changes: StateChange[] }
| { ok: false; reason: string };
type VerbHandler = (
action: Action,
actor: Entity,
world: WorldState
) => RuleResult;
// ── Helpers ───────────────────────────────────────────────────
function requireTarget(
action: Action,
world: WorldState
): { ok: true; target: Entity } | { ok: false; reason: string } {
if (!action.target) {
return { ok: false, reason: `'${action.verb}' requires a target` };
}
const target = world.entities.get(action.target);
if (!target) {
return {
ok: false,
reason: `target entity '${action.target}' does not exist`,
};
}
return { ok: true, target };
}
function attributeChange(
entity: Entity,
field: string,
newValue: unknown
): StateChange {
return {
entity_id: entity.id,
field,
old_value: entity.attributes[field] ?? null,
new_value: newValue,
};
}
// ── Verb handlers ─────────────────────────────────────────────
const verbHandlers: Record<string, VerbHandler> = {
move(action, actor, world) {
const t = requireTarget(action, world);
if (!t.ok) return t;
// Target must be a room/location. The built-in offscene room is valid.
if (t.target.type !== "room") {
return {
ok: false,
reason: `cannot move to '${t.target.id}': not a room`,
};
}
return {
ok: true,
changes: [attributeChange(actor, "location", t.target.id)],
};
},
open(action, actor, world) {
const t = requireTarget(action, world);
if (!t.ok) return t;
if (t.target.attributes["locked"] === true) {
return {
ok: false,
reason: `'${t.target.id}' is locked and cannot be opened`,
};
}
if (t.target.attributes["open"] === true) {
return { ok: false, reason: `'${t.target.id}' is already open` };
}
return {
ok: true,
changes: [attributeChange(t.target, "open", true)],
};
},
close(action, actor, world) {
const t = requireTarget(action, world);
if (!t.ok) return t;
if (t.target.attributes["open"] === false) {
return { ok: false, reason: `'${t.target.id}' is already closed` };
}
return {
ok: true,
changes: [attributeChange(t.target, "open", false)],
};
},
take(action, actor, world) {
const t = requireTarget(action, world);
if (!t.ok) return t;
if (t.target.attributes["takeable"] === false) {
return {
ok: false,
reason: `'${t.target.id}' cannot be taken`,
};
}
// Item must be in the same location as actor (unless already in inventory)
const actorLocation = actor.attributes["location"];
const itemLocation = t.target.attributes["location"];
const expectedInventory = `inventory:${actor.id}`;
// If already in inventory, it's a no-op (already holding it)
if (itemLocation === expectedInventory) {
return {
ok: true,
changes: [],
};
}
if (actorLocation !== itemLocation) {
return {
ok: false,
reason: `'${t.target.id}' is not in the same location as '${actor.id}'`,
};
}
return {
ok: true,
changes: [attributeChange(t.target, "location", expectedInventory)],
};
},
drop(action, actor, world) {
const t = requireTarget(action, world);
if (!t.ok) return t;
const expectedLocation = `inventory:${actor.id}`;
if (t.target.attributes["location"] !== expectedLocation) {
return {
ok: false,
reason: `'${t.target.id}' is not in '${actor.id}' inventory`,
};
}
return {
ok: true,
changes: [
attributeChange(t.target, "location", actor.attributes["location"]),
],
};
},
use(action, actor, world) {
const t = requireTarget(action, world);
if (!t.ok) return t;
if (t.target.attributes["useable"] === false) {
return { ok: false, reason: `'${t.target.id}' cannot be used` };
}
// Generic "use" records a state change marking last user; concrete effects
// are handled by higher-level game logic layered on top.
return {
ok: true,
changes: [attributeChange(t.target, "last_used_by", actor.id)],
};
},
inspect(_action, _actor, _world) {
// inspect is always valid — it has no side effects
return { ok: true, changes: [] };
},
speak(action, actor, world) {
const t = requireTarget(action, world);
if (!t.ok) return t;
if (t.target.type !== "character") {
return {
ok: false,
reason: `cannot speak to '${t.target.id}': not a character`,
};
}
return { ok: true, changes: [] };
},
};
// ── Main export ───────────────────────────────────────────────
/**
* Validate a list of actions against the current world state.
* Pure function — does NOT mutate worldState.
*/
export function validate(
actions: Action[],
worldState: WorldState
): ValidationResult {
const accepted: Action[] = [];
const rejected: { action: Action; reason: string }[] = [];
const state_changes: StateChange[] = [];
for (const action of actions) {
// 1. Verb must be in the allowed set
if (!(ALLOWED_VERBS as readonly string[]).includes(action.verb)) {
rejected.push({ action, reason: `unknown verb '${action.verb}'` });
continue;
}
// 2. Actor must exist
const actor = worldState.entities.get(action.actor);
if (!actor) {
rejected.push({
action,
reason: `actor entity '${action.actor}' does not exist`,
});
continue;
}
// 3. Run verb-specific handler
const handler = verbHandlers[action.verb];
const result = handler(action, actor, worldState);
if (!result.ok) {
rejected.push({ action, reason: result.reason });
} else {
accepted.push(action);
state_changes.push(...result.changes);
}
}
return { accepted, rejected, state_changes };
}
/**
* Apply a validated set of StateChanges to a WorldState snapshot.
* Returns a new Map — does NOT mutate the original.
*/
export function applyChanges(
worldState: WorldState,
changes: StateChange[]
): WorldState {
const next = new Map(
Array.from(worldState.entities.entries()).map(([id, entity]) => [
id,
{ ...entity, attributes: { ...entity.attributes } },
])
);
for (const change of changes) {
const entity = next.get(change.entity_id);
if (entity) {
entity.attributes[change.field] = change.new_value;
}
}
return { entities: next };
}
export function createOffsceneRoom(): Entity {
return {
id: OFFSCENE_ROOM_ID,
type: "room",
name: "Offscene",
attributes: {
offscene: true,
visible: false,
},
};
}

View File

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

View File

@@ -1,112 +0,0 @@
// Core contracts — DO NOT modify without updating project.md
// ── Section 2.1 ─────────────────────────────────────────────
export interface Entity {
id: string;
type: string;
name: string;
attributes: Record<string, unknown>;
}
// ── Section 2.2 ─────────────────────────────────────────────
export const ALLOWED_VERBS = [
"move",
"open",
"close",
"take",
"drop",
"use",
"inspect",
"speak",
] as const;
export type Verb = (typeof ALLOWED_VERBS)[number];
export interface Action {
actor: string; // entity id
verb: Verb;
target?: string; // entity id
params?: Record<string, unknown>;
}
// ── Section 2.3 ─────────────────────────────────────────────
export interface ValidationResult {
accepted: Action[];
rejected: { action: Action; reason: string }[];
state_changes: StateChange[];
}
// ── Section 2.4 ─────────────────────────────────────────────
export interface StateChange {
entity_id: string;
field: string;
old_value: unknown;
new_value: unknown;
}
// ── Section 2.5 ─────────────────────────────────────────────
export interface GameEvent {
id: string;
turn: number;
action: Action;
result: "success" | "fail";
timestamp: number;
}
// ── Section 4 — Memory types ─────────────────────────────────
export interface Turn {
id: string;
turn: number;
input: string;
output: string;
timestamp: number;
}
export interface Belief {
entity_id: string;
claim: string;
confidence: number;
}
export interface Fact {
entity_id: string;
key: string;
value: unknown;
source: "seed" | "action" | "inference";
}
export interface Affordance {
entity_id: string;
key: string;
enabled: boolean;
reason: string;
}
export interface EntityProvenance {
introduced_turn?: number;
introduced_by?: string;
introduced_reason?: string;
latent_from_belief?: string;
}
export interface LatentEntityRequest {
actor_id: string;
noun: string;
turn?: number;
}
export interface LatentEntityResolution {
accepted: boolean;
reason: string;
entity?: Entity;
facts: Fact[];
beliefs: Belief[];
affordances: Affordance[];
}
export interface Summary {
id: string;
turn_range: [number, number];
text: string;
timestamp: number;
}

View File

@@ -0,0 +1,194 @@
import { randomUUID } from "node:crypto";
import type { Action } from "../contracts/action";
import type { ValidationResult } from "../contracts/validation";
import type { Entity } from "../contracts/entity";
import type { WorldState } from "../contracts/world";
function cloneWorldState(worldState: WorldState): WorldState {
const entities: Record<string, Entity> = {};
for (const [id, entity] of Object.entries(worldState.entities)) {
entities[id] = {
...entity,
attributes: { ...entity.attributes },
};
}
return {
...worldState,
entities,
metadata: { ...worldState.metadata },
};
}
function slugify(value: string): string {
return value
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "_")
.replace(/^_+|_+$/g, "") || "character";
}
function createCharacterId(worldState: WorldState, baseName: string): string {
const baseId = `character_${slugify(baseName)}`;
if (!worldState.entities[baseId]) {
return baseId;
}
let suffix = 2;
while (worldState.entities[`${baseId}_${suffix}`]) {
suffix += 1;
}
return `${baseId}_${suffix}`;
}
function toDisplayName(value: string): string {
return value
.split(/[_\s]+/)
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(" ");
}
function inferItemName(action: Action): string {
const itemName = action.metadata?.itemName;
if (typeof itemName === "string" && itemName.trim()) {
return itemName.trim();
}
if (action.targetId?.startsWith("item_")) {
return toDisplayName(action.targetId.replace(/^item_/, ""));
}
return "Generated Item";
}
function getActionCharacterName(action: Action): string | undefined {
const displayName = action.metadata?.displayName;
if (typeof displayName === "string" && displayName.trim()) {
return displayName.trim();
}
const characterName = action.metadata?.characterName;
if (typeof characterName === "string" && characterName.trim()) {
return characterName
.trim()
.split(/\s+/)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(" ");
}
return undefined;
}
export function applyActions(
actions: Action[],
results: ValidationResult[],
worldState: WorldState
): WorldState {
const nextState = cloneWorldState(worldState);
for (const result of results) {
if (!result.success) {
continue;
}
const action = actions[result.actionIndex];
if (!action) {
continue;
}
const actor = nextState.entities[action.actorId];
const target = action.targetId ? nextState.entities[action.targetId] : undefined;
switch (action.type) {
case "move":
if (actor && action.targetId) {
actor.attributes.location = action.targetId;
}
break;
case "take":
if (actor && target) {
target.attributes.location = `inventory:${actor.id}`;
// Set has_<item_id> on the actor so attributeRef rulebook checks work
// generically (e.g. has_key_1 for opening a locked door).
actor.attributes[`has_${target.id}`] = true;
} else if (actor && action.targetId && action.metadata?.createIfMissing === true) {
nextState.entities[action.targetId] = {
id: action.targetId,
name: inferItemName(action),
type: "item",
attributes: {
location: `inventory:${actor.id}`,
takeable: true,
created_by_action: "take",
created_by_actor: actor.id,
},
};
}
break;
case "open":
if (target) {
target.attributes.open = true;
}
break;
case "introduce":
if (actor && target) {
target.attributes.location = actor.attributes.location;
target.attributes.in_scene = true;
target.attributes.last_introduced_by = actor.id;
} else if (actor) {
const characterName = getActionCharacterName(action);
if (!characterName) {
break;
}
const characterId = createCharacterId(nextState, characterName);
nextState.entities[characterId] = {
id: characterId,
name: characterName,
type: "character",
attributes: {
location: actor.attributes.location,
is_social: true,
in_scene: true,
created_by_action: "introduce",
last_introduced_by: actor.id,
},
};
}
break;
case "describe":
if (target) {
const trait = action.metadata?.trait;
if (typeof trait === "string" && trait.trim()) {
const traits = Array.isArray(target.attributes.traits)
? target.attributes.traits
: [];
target.attributes.traits = [...traits, trait.trim()];
}
}
break;
case "transfer":
if (target) {
const itemId = action.metadata?.itemId;
if (typeof itemId === "string") {
const item = nextState.entities[itemId];
if (item) {
item.attributes.location = `inventory:${target.id}`;
item.attributes.last_transferred_by = action.actorId;
item.attributes.last_transferred_to = target.id;
}
}
}
break;
case "inspect":
default:
break;
}
}
nextState.id = randomUUID();
nextState.createdAt = Date.now();
return nextState;
}

View File

@@ -0,0 +1,162 @@
import { randomUUID } from "node:crypto";
import type { CharacterGardenDatabase } from "../db";
import type { Entity } from "../contracts/entity";
import type { WorldState } from "../contracts/world";
export function createSeedWorldState(): WorldState {
const now = Date.now();
const entities: Record<string, Entity> = {
room_offstage: {
id: "room_offstage",
name: "Offstage",
type: "room",
attributes: {
description: "A holding area for characters not currently in the active scene.",
is_joinable: false,
},
},
room_start: {
id: "room_start",
name: "Start Room",
type: "room",
attributes: {
description: "A plain room with a locked door.",
is_joinable: true,
},
},
room_exit: {
id: "room_exit",
name: "Exit Room",
type: "room",
attributes: {
description: "A simple room beyond the door.",
is_joinable: true,
},
},
player: {
id: "player",
name: "Player",
type: "character",
attributes: {
location: "room_start",
has_key_1: false,
},
},
groundskeeper: {
id: "groundskeeper",
name: "Groundskeeper",
type: "character",
attributes: {
location: "room_offstage",
is_social: true,
in_scene: false,
},
},
door_1: {
id: "door_1",
name: "Old Door",
type: "door",
attributes: {
location: "room_start",
openable: true,
locked: true,
requiredKey: "key_1",
open: false,
},
},
key_1: {
id: "key_1",
name: "Brass Key",
type: "item",
attributes: {
location: "room_start",
takeable: true,
},
},
};
return {
id: randomUUID(),
entities,
metadata: {
domain: "door_key_mvp",
version: 1,
},
createdAt: now,
};
}
export function mergeSeedWorldState(worldState: WorldState): { worldState: WorldState; changed: boolean } {
const seed = createSeedWorldState();
const mergedEntities: Record<string, Entity> = {};
let changed = false;
for (const [entityId, seedEntity] of Object.entries(seed.entities)) {
const existingEntity = worldState.entities[entityId];
if (!existingEntity) {
mergedEntities[entityId] = seedEntity;
changed = true;
continue;
}
const mergedAttributes = {
...seedEntity.attributes,
...existingEntity.attributes,
};
if (JSON.stringify(existingEntity.attributes) !== JSON.stringify(mergedAttributes)) {
changed = true;
}
mergedEntities[entityId] = {
...existingEntity,
attributes: mergedAttributes,
};
}
for (const [entityId, existingEntity] of Object.entries(worldState.entities)) {
if (!mergedEntities[entityId]) {
mergedEntities[entityId] = existingEntity;
}
}
const mergedWorldState: WorldState = {
...worldState,
entities: mergedEntities,
metadata: {
...seed.metadata,
...worldState.metadata,
},
};
if (changed) {
mergedWorldState.id = randomUUID();
mergedWorldState.createdAt = Date.now();
}
return {
worldState: mergedWorldState,
changed,
};
}
export function ensureSeedState(db: CharacterGardenDatabase): WorldState {
db.init();
const latest = db.getLatestWorldState();
if (latest) {
const merged = mergeSeedWorldState(latest);
db.upsertEntities(Object.values(merged.worldState.entities));
if (merged.changed) {
db.insertWorldState(null, merged.worldState);
}
return merged.worldState;
}
const seed = createSeedWorldState();
db.upsertEntities(Object.values(seed.entities));
db.insertWorldState(null, seed);
return seed;
}

View File

@@ -1 +0,0 @@
# sqlite data directory — tracked by git, contents ignored

View File

@@ -3,14 +3,18 @@ services:
build: ./app
working_dir: /app
ports:
- "${APP_PORT:-3000}:3000"
- "${APP_PORT:-3024}:3024"
environment:
- APP_PORT=3000
- APP_PORT=3024
- NODE_ENV=${NODE_ENV:-development}
- DB_PATH=/data/sqlite/app.db
- DB_PATH=/var/lib/charactergarden/app.db
- OLLAMA_URL=${OLLAMA_URL:-http://ollama:11434}
- LLM_RESOLVER_URL=${LLM_RESOLVER_URL:-http://ollama:11434}
- LLM_RESOLVER_MODEL=${LLM_RESOLVER_MODEL:-llama3.2:3b}
- LLM_RESOLVER_TIMEOUT_MS=${LLM_RESOLVER_TIMEOUT_MS:-15000}
- INTENT_RESOLVER_MODE=${INTENT_RESOLVER_MODE:-hybrid}
volumes:
- ./data:/data
- sqlite_data:/var/lib/charactergarden
- ./app/src:/app/src
frontend:
@@ -35,6 +39,24 @@ services:
- ollama_data:/root/.ollama
profiles:
- llm
healthcheck:
test: ["CMD", "ollama", "list"]
interval: 10s
timeout: 5s
retries: 10
start_period: 20s
ollama-init:
image: ollama/ollama:latest
depends_on:
ollama:
condition: service_healthy
environment:
- OLLAMA_HOST=http://ollama:11434
entrypoint: ["ollama", "pull", "${LLM_RESOLVER_MODEL:-llama3.2:3b}"]
profiles:
- llm
volumes:
ollama_data:
sqlite_data:

View File

@@ -1,4 +1,4 @@
import { FormEvent, useEffect, useState } from "react";
import { FormEvent, useEffect, useMemo, useState } from "react";
type Entity = {
id: string;
@@ -7,61 +7,149 @@ type Entity = {
attributes: Record<string, unknown>;
};
type GameEvent = {
id: string;
turn: number;
result: "success" | "fail";
action: Record<string, unknown>;
timestamp: number;
type Action = {
actorId: string;
type: string;
targetId?: string;
locationId?: string;
};
type ValidationResult = {
actionIndex: number;
success: boolean;
reason?: string;
message?: string;
};
type Turn = {
id: string;
turn: number;
input: string;
output: string;
timestamp: number;
};
type Belief = {
entity_id: string;
claim: string;
rawText: string;
actions: Action[];
validation: ValidationResult[];
createdAt: number;
interpreter?: {
interpreterVersion: string;
resolutionSource: "deterministic" | "llm" | "hybrid";
minConfidence: number;
status: "resolved" | "needs_clarification" | "rejected";
selectedConfidence?: number;
candidates?: Array<{
action: Action;
confidence: number;
};
type Summary = {
rationale?: string;
}>;
diagnostics: string[];
clarification?: {
reasonCode: string;
question: string;
field?: string;
options?: Array<{
id: string;
turn_range: [number, number];
text: string;
timestamp: number;
label: string;
value: string;
}>;
};
};
};
type Snapshot = {
entities: Entity[];
events: GameEvent[];
type WorldState = {
id: string;
entities: Record<string, Entity>;
metadata: Record<string, unknown>;
createdAt: number;
};
type AppSnapshot = {
worldState: WorldState;
turns: Turn[];
beliefs: Belief[];
summaries: Summary[];
};
type TurnResult = {
narration: string;
parser: string;
actions: Array<Record<string, unknown>>;
accepted: Array<Record<string, unknown>>;
rejected: Array<{ action: Record<string, unknown>; reason: string }>;
latent_resolution?: { accepted: boolean; reason: string; entity_id?: string };
snapshot: Snapshot;
type ProcessTurnResponse = {
rawText: string;
actions: Action[];
validation: ValidationResult[];
worldState: WorldState;
interpreter: {
interpreterVersion: string;
resolutionSource: "deterministic" | "llm" | "hybrid";
minConfidence: number;
status: "resolved" | "needs_clarification" | "rejected";
selectedConfidence?: number;
candidates: Array<{
action: Action;
confidence: number;
rationale?: string;
}>;
diagnostics: string[];
clarification?: {
reasonCode: string;
question: string;
field?: string;
options?: Array<{
id: string;
label: string;
value: string;
}>;
};
};
};
type RuleCheck = {
id: string;
description: string;
condition: unknown;
failReason: string;
failMessage: string;
};
type ActionRuleSet = {
actionType: string;
enabled: boolean;
checks: RuleCheck[];
};
type SceneRulebook = {
id: string;
worldId: string;
version: number;
name: string;
description?: string;
rules: ActionRuleSet[];
createdAt: number;
updatedAt: number;
};
type RulebookListItem = {
id: string;
name: string;
version: number;
updatedAt: number;
};
const starterPrompts = [
"look around",
"open the gate",
"talk to the groundskeeper",
"go to the shed",
"pull out my phone",
"take key",
"take lantern",
"give key to groundskeeper",
"introduce jeff",
"describe groundskeeper as patient",
"open door",
"move to exit",
];
function formatConfidence(value: number | undefined): string {
if (typeof value !== "number") return "n/a";
return `${Math.round(value * 100)}%`;
}
function formatTurnTime(epochMs: number): string {
try {
return new Date(epochMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
} catch {
return String(epochMs);
}
}
async function fetchJson<T>(input: RequestInfo, init?: RequestInit): Promise<T> {
const response = await fetch(input, init);
if (!response.ok) {
@@ -70,17 +158,208 @@ async function fetchJson<T>(input: RequestInfo, init?: RequestInit): Promise<T>
return response.json() as Promise<T>;
}
// ---------------------------------------------------------------------------
// Rulebook editor component
// ---------------------------------------------------------------------------
function RulebookEditor(props: { onSaved?: (rulebook: SceneRulebook) => void }) {
const [rulebook, setRulebook] = useState<SceneRulebook | null>(null);
const [drafts, setDrafts] = useState<Record<string, string>>({});
const [parseErrors, setParseErrors] = useState<Record<string, string>>({});
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [saved, setSaved] = useState(false);
useEffect(() => {
void fetchJson<SceneRulebook>("/api/rulebook").then((rb) => {
setRulebook(rb);
const initial: Record<string, string> = {};
for (const ruleSet of rb.rules) {
initial[ruleSet.actionType] = JSON.stringify(ruleSet.checks, null, 2);
}
setDrafts(initial);
});
}, []);
function toggleEnabled(actionType: string) {
if (!rulebook) return;
setRulebook({
...rulebook,
rules: rulebook.rules.map((r) =>
r.actionType === actionType ? { ...r, enabled: !r.enabled } : r
),
});
}
function updateDraft(actionType: string, value: string) {
setDrafts((prev) => ({ ...prev, [actionType]: value }));
setParseErrors((prev) => {
const next = { ...prev };
delete next[actionType];
return next;
});
}
async function saveRulebook() {
if (!rulebook) return;
setSaveError(null);
setSaved(false);
// Validate and apply all drafts.
const updatedRules: ActionRuleSet[] = [];
const newErrors: Record<string, string> = {};
for (const ruleSet of rulebook.rules) {
const draft = drafts[ruleSet.actionType] ?? JSON.stringify(ruleSet.checks, null, 2);
try {
const parsedChecks = JSON.parse(draft) as RuleCheck[];
updatedRules.push({ ...ruleSet, checks: parsedChecks });
} catch {
newErrors[ruleSet.actionType] = "Invalid JSON";
updatedRules.push(ruleSet);
}
}
if (Object.keys(newErrors).length > 0) {
setParseErrors(newErrors);
return;
}
setSaving(true);
try {
const updated = await fetchJson<SceneRulebook>("/api/rulebook", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...rulebook, rules: updatedRules }),
});
setRulebook(updated);
props.onSaved?.(updated);
setSaved(true);
setTimeout(() => setSaved(false), 2500);
} catch (err) {
setSaveError(err instanceof Error ? err.message : "Save failed");
} finally {
setSaving(false);
}
}
if (!rulebook) return <p>Loading rulebook</p>;
return (
<div className="rulebook-editor">
<div className="rulebook-header">
<div>
<p className="rulebook-name">{rulebook.name}</p>
<p className="rulebook-desc">
Version {rulebook.version} | Updated {formatTurnTime(rulebook.updatedAt)}
</p>
{rulebook.description ? (
<p className="rulebook-desc">{rulebook.description}</p>
) : null}
</div>
<button
type="button"
onClick={() => void saveRulebook()}
disabled={saving}
className="save-btn"
>
{saving ? "Saving…" : saved ? "Saved" : "Save rulebook"}
</button>
</div>
{saveError ? <p className="error-banner">{saveError}</p> : null}
<div className="rule-list">
{rulebook.rules.map((ruleSet) => (
<details key={ruleSet.actionType} className="rule-panel">
<summary className="rule-summary">
<span className="rule-action-type">{ruleSet.actionType}</span>
<span className="rule-check-count">{ruleSet.checks.length} check{ruleSet.checks.length !== 1 ? "s" : ""}</span>
<label
className="rule-toggle"
onClick={(e) => e.stopPropagation()}
>
<input
type="checkbox"
checked={ruleSet.enabled}
onChange={() => toggleEnabled(ruleSet.actionType)}
/>
{ruleSet.enabled ? "enforced" : "disabled"}
</label>
</summary>
<div className="rule-body">
<p className="rule-hint">
Edit the checks array below. Each check has: <code>id</code>, <code>description</code>, <code>condition</code>, <code>failReason</code>, <code>failMessage</code>.
</p>
<p className="rule-hint">
For character permissions, use <code>{`{"op":"actorIdIn","allowedIds":["player"]}`}</code> or <code>{`{"op":"actorNameIn","allowedNames":["Player"]}`}</code>.
</p>
<p className="rule-hint">
For conditional creation, use <code>{`{"op":"actionMetadataEq","key":"createIfMissing","value":true}`}</code> together with actor checks.
</p>
<p className="rule-hint">
For transfer ownership checks, use <code>{`{"op":"itemInInventory","itemMetadataKey":"itemId","holderRole":"actor"}`}</code>.
</p>
{ruleSet.checks.length > 0 ? (
<ul className="check-list">
{ruleSet.checks.map((check) => (
<li key={check.id} title={check.failReason}>
<span className="check-desc">{check.description}</span>
</li>
))}
</ul>
) : (
<p className="rule-hint">No checks action always passes.</p>
)}
<label className="json-label">
Checks JSON
<textarea
className={`json-editor${parseErrors[ruleSet.actionType] ? " json-error" : ""}`}
value={drafts[ruleSet.actionType] ?? ""}
onChange={(e) => updateDraft(ruleSet.actionType, e.target.value)}
rows={10}
spellCheck={false}
/>
</label>
{parseErrors[ruleSet.actionType] ? (
<p className="error-banner">{parseErrors[ruleSet.actionType]}</p>
) : null}
</div>
</details>
))}
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Main app
// ---------------------------------------------------------------------------
export default function App() {
const [snapshot, setSnapshot] = useState<Snapshot | null>(null);
const [latest, setLatest] = useState<TurnResult | null>(null);
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);
const [tab, setTab] = useState<"world" | "rulebook">("world");
const [activeRulebook, setActiveRulebook] = useState<SceneRulebook | null>(null);
const [rulebooks, setRulebooks] = useState<RulebookListItem[]>([]);
async function refreshRulebookState() {
const [active, list] = await Promise.all([
fetchJson<SceneRulebook>("/api/rulebook"),
fetchJson<RulebookListItem[]>("/api/rulebooks"),
]);
setActiveRulebook(active);
setRulebooks(list);
}
useEffect(() => {
void fetchJson<Snapshot>("/api/state")
.then((data) => {
void Promise.all([fetchJson<AppSnapshot>("/api/state"), refreshRulebookState()])
.then(([data]) => {
setSnapshot(data);
setLoading(false);
})
@@ -96,13 +375,17 @@ export default function App() {
setError(null);
try {
const result = await fetchJson<TurnResult>("/api/turn", {
const result = await fetchJson<ProcessTurnResponse>("/api/turn", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ input }),
});
setLatest(result);
setSnapshot(result.snapshot);
const [nextSnapshot] = await Promise.all([
fetchJson<AppSnapshot>("/api/state"),
refreshRulebookState(),
]);
setSnapshot(nextSnapshot);
} catch (submitError) {
setError(submitError instanceof Error ? submitError.message : "Unknown error");
} finally {
@@ -110,13 +393,40 @@ export default function App() {
}
}
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);
await refreshRulebookState();
} catch (resetError) {
setError(resetError instanceof Error ? resetError.message : "Unknown error");
}
}
const entities = snapshot ? Object.values(snapshot.worldState.entities) : [];
const turns = useMemo(() => snapshot?.turns.slice().reverse() ?? [], [snapshot]);
function applyClarificationOption(optionValue: string) {
setInput((prev) => {
const trimmed = prev.trim();
if (!trimmed) return optionValue;
return `${trimmed} ${optionValue}`;
});
}
return (
<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 the current entities and events, and verify how the truth engine is mutating state.
Submit a turn, inspect world state, and verify how the truth engine is mutating state.
</p>
<form className="turn-form" onSubmit={onSubmit}>
@@ -132,6 +442,9 @@ export default function App() {
<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)}>
@@ -145,8 +458,75 @@ export default function App() {
{latest ? (
<section className="result-card">
<h2>Latest result</h2>
<p>{latest.narration}</p>
<pre>{JSON.stringify({ actions: latest.actions, rejected: latest.rejected, latent: latest.latent_resolution }, null, 0)}</pre>
<p><strong>Input:</strong> {latest.rawText}</p>
<p>
<strong>Interpreter:</strong> {latest.interpreter.status}
{` via ${latest.interpreter.resolutionSource}`}
{` | model threshold ${formatConfidence(latest.interpreter.minConfidence)}`}
{` | selected ${formatConfidence(latest.interpreter.selectedConfidence)}`}
</p>
<p>
<strong>Interpreter version:</strong> {latest.interpreter.interpreterVersion}
</p>
{latest.interpreter.clarification ? (
<p>
<strong>Clarification ({latest.interpreter.clarification.reasonCode}):</strong>{" "}
{latest.interpreter.clarification.question}
</p>
) : null}
{latest.interpreter.clarification?.options?.length ? (
<div className="chips">
{latest.interpreter.clarification.options.map((o) => (
<button
key={o.id}
type="button"
className="chip"
onClick={() => applyClarificationOption(o.value)}
>
clarify: {o.label}
</button>
))}
</div>
) : null}
<ul className="timeline-list compact">
{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>
{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}
{latest.actions.length > 0 ? (
<details>
<summary>Selected actions ({latest.actions.length})</summary>
<pre>{JSON.stringify(latest.actions, null, 2)}</pre>
</details>
) : null}
{latest.interpreter.candidates.length > 0 ? (
<details>
<summary>Interpreter candidates ({latest.interpreter.candidates.length})</summary>
<ul className="timeline-list compact">
{latest.interpreter.candidates.map((candidate, index) => (
<li key={`${candidate.action.type}-${index}`}>
{candidate.action.type} at {formatConfidence(candidate.confidence)}
{candidate.rationale ? ` - ${candidate.rationale}` : ""}
</li>
))}
</ul>
</details>
) : null}
</section>
) : null}
@@ -154,11 +534,38 @@ export default function App() {
</section>
<section className="inspector-grid">
<nav className="tab-bar">
<button
type="button"
className={`tab-btn${tab === "world" ? " active" : ""}`}
onClick={() => setTab("world")}
>
World inspector
</button>
<button
type="button"
className={`tab-btn${tab === "rulebook" ? " active" : ""}`}
onClick={() => setTab("rulebook")}
>
Rulebook
</button>
</nav>
{tab === "world" ? (
<>
<article className="panel">
<h2>World state</h2>
{loading && !snapshot ? <p>Loading...</p> : null}
{snapshot ? (
<div className="meta-grid">
<p className="meta-kv"><strong>World ID:</strong> {snapshot.worldState.id}</p>
<p className="meta-kv"><strong>Created:</strong> {formatTurnTime(snapshot.worldState.createdAt)}</p>
<p className="meta-kv"><strong>Domain:</strong> {String(snapshot.worldState.metadata?.domain ?? "unknown")}</p>
<p className="meta-kv"><strong>World schema:</strong> {String(snapshot.worldState.metadata?.version ?? "unknown")}</p>
</div>
) : null}
<ul className="entity-list">
{snapshot?.entities.map((entity) => (
{entities.map((entity) => (
<li key={entity.id}>
<strong>{entity.name}</strong> <span>{entity.type}</span>
<pre>{JSON.stringify(entity.attributes, null, 0)}</pre>
@@ -170,44 +577,64 @@ export default function App() {
<article className="panel">
<h2>Turn log</h2>
<ul className="timeline-list">
{snapshot?.turns.slice().reverse().map((turn) => (
{turns.map((turn) => (
<li key={turn.id}>
<strong>Turn {turn.turn}:</strong> {turn.input} {turn.output}
<strong>{turn.rawText}</strong>
<span className="turn-time"> at {formatTurnTime(turn.createdAt)}</span>
{turn.interpreter ? (
<span>
{" "}[interp:{turn.interpreter.status} via {turn.interpreter.resolutionSource};
conf {formatConfidence(turn.interpreter.selectedConfidence)}]
</span>
) : null}
{turn.interpreter?.clarification ? (
<p className="parser-hint">
Clarification ({turn.interpreter.clarification.reasonCode}): {turn.interpreter.clarification.question}
</p>
) : null}
{turn.interpreter?.diagnostics?.length ? (
<p className="turn-diagnostics">
Diagnostics: {turn.interpreter.diagnostics.join(" | ")}
</p>
) : null}
{turn.validation.map((v) => (
<span key={v.actionIndex}> [{v.success ? "ok" : v.reason}]</span>
))}
</li>
))}
</ul>
</article>
<article className="panel">
<h2>Events</h2>
<h2>System</h2>
{activeRulebook ? (
<div className="meta-grid">
<p className="meta-kv"><strong>Active rulebook:</strong> {activeRulebook.name}</p>
<p className="meta-kv"><strong>Rulebook ID:</strong> {activeRulebook.id}</p>
<p className="meta-kv"><strong>Rulebook version:</strong> {activeRulebook.version}</p>
<p className="meta-kv"><strong>Updated:</strong> {formatTurnTime(activeRulebook.updatedAt)}</p>
<p className="meta-kv"><strong>Saved rulebooks:</strong> {rulebooks.length}</p>
</div>
) : (
<p>Loading rulebook info...</p>
)}
{rulebooks.length > 0 ? (
<ul className="timeline-list compact">
{snapshot?.events.slice().reverse().map((event) => (
<li key={event.id}>
<strong>{event.result}:</strong> <pre>{JSON.stringify(event.action, null, 0)}</pre>
{rulebooks.map((rb) => (
<li key={rb.id}>
<strong>{rb.name}</strong> ({rb.id}) v{rb.version} - updated {formatTurnTime(rb.updatedAt)}
</li>
))}
</ul>
) : null}
</article>
<article className="panel">
<h2>Beliefs & summaries</h2>
<h3>Beliefs</h3>
<ul className="timeline-list compact">
{snapshot?.beliefs.map((belief) => (
<li key={`${belief.entity_id}-${belief.claim}`}>
<strong>{belief.entity_id}:</strong> {belief.claim} ({belief.confidence})
</li>
))}
</ul>
<h3>Summaries</h3>
<ul className="timeline-list compact">
{snapshot?.summaries.map((summary) => (
<li key={summary.id}>
<strong>{summary.turn_range.join("-")}:</strong> {summary.text}
</li>
))}
</ul>
</>
) : (
<article className="panel panel--full">
<h2>Rulebook editor</h2>
<RulebookEditor onSaved={(rulebook) => setActiveRulebook(rulebook)} />
</article>
)}
</section>
</main>
);

View File

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

View File

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

View File

@@ -1,367 +1,138 @@
# CharacterGarden AI-Oriented Design Spec (Copilot-Ready)
# CharacterGarden MVP - Current Architecture (April 2026)
## 0. Purpose
## Core Principle
This document defines **hard contracts and system boundaries** for CharacterGarden.
The simulation engine is deterministic and authoritative.
The LLM layer is an intent interpreter and resolver, not a source of truth.
Goal: enable an AI coding assistant (e.g. Copilot) to implement the system step-by-step without ambiguity.
## Live System Layers
Core principle:
User / LLM Input (Prose)
|
[Intent Interpreter Layer]
|
[Turn Manager]
|
[Truth Engine + Scene Rulebook Validation]
|
[World Mutation Engine]
|
[Persistence Layer]
|
[Frontend/API Response]
> The application owns truth. The LLM only translates and narrates.
## 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. System Overview
## Current Canonical Actions
### Pipeline
- inspect
- move
- open
- take
- introduce
- describe
- transfer
```
Prose Input
→ Intent Extraction
→ Canonical Actions
→ Truth Engine Validation
→ State Changes + Events
→ Memory Storage
→ Narration Output
```
## Action Contracts (Current)
---
- 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
## 2. Core Contracts (STRICT)
## Scene Rulebook (Data-Driven Validation)
### 2.1 Entity
Validation is now externalized into SceneRulebook definitions.
```
Entity {
id: string
type: string
name: string
attributes: object
}
```
Key capabilities already implemented:
### 2.2 Action (Canonical)
- Actor authorization checks (actorIdIn, actorNameIn)
- Conditional creation checks (actionMetadataEq)
- Inventory ownership checks (itemInInventory)
- Existing deterministic checks (entity type/exists, same location, attribute checks)
```
Action {
actor: string (entity id)
verb: string (enum)
target?: string (entity id)
params?: object
}
```
This supports:
Allowed verbs (MVP):
- Restricting who can introduce characters
- Restricting who can create missing items via take
- Validating transfer only when actor owns item and recipient is valid
```
move, open, close, take, drop, use, inspect, speak
```
## Turn Execution (Current)
### 2.3 Validation Result
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
```
ValidationResult {
accepted: Action[]
rejected: { action: Action, reason: string }[]
state_changes: StateChange[]
}
```
## Interpreter + Turn Manager (New)
### 2.4 State Change
- 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
```
StateChange {
entity_id: string
field: string
old_value: any
new_value: any
}
```
Current interpreter statuses:
### 2.5 Event
- resolved
- needs_clarification
- rejected
```
Event {
id: string
turn: number
action: Action
result: "success" | "fail"
timestamp: number
}
```
## Current Domain Behaviors
---
- 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
## 3. Truth Rules
## Persistence (Current)
1. Only the **Truth Engine** can modify world state
2. LLM output is NEVER directly trusted
3. Every state change must be traceable to an Event
4. Invalid actions must return explicit failure reasons
SQLite tables already backing turns and world snapshots:
---
- turns
- actions
- validation_results
- entities
- world_states
- rulebooks
## 4. Memory Model
## API Surface (Current)
Memory is NOT a single blob.
- GET /api/state
- POST /api/turn
- POST /api/reset
- GET /api/rulebook
- PUT /api/rulebook
- GET /api/rulebooks
### Types
## Operational Rule: Validation in Docker
#### 4.1 Turn
Build and runtime checks should run in containers, not host Node.
Raw input/output
- Backend build: docker compose run --rm app npm run build
- Frontend build: docker compose run --rm frontend npm run build
#### 4.2 Event
## Immediate Path Forward
Accepted or rejected actions
1. LLM adapter hardening
- Tune prompt/schema validation for model drift.
- Add configurable model + timeout policy per environment.
#### 4.3 Fact
2. Rulebook governance
- Keep versioned rulebook migration path active as rule schema evolves.
- Split rules into policy packs: creation, transfer, social.
Current world state (derived, not duplicated)
#### 4.4 Belief
```
Belief {
entity_id: string
claim: string
confidence: number
}
```
#### 4.5 Summary
Compressed narrative context
---
## 5. Services
### 5.1 App (Core Service)
Responsibilities:
* orchestrate turns
* call LLM (optional)
* run truth engine
* manage memory
Tech: Node.js (Express or Fastify)
---
### 5.2 Truth Engine (Module inside App)
Responsibilities:
* validate actions
* enforce rules
* apply state changes
* emit events
NO LLM USAGE
---
### 5.3 LLM Adapter (Optional)
Responsibilities:
* extract actions from prose
* resolve references
* generate narration
* summarize memory
Backend: Ollama
---
### 5.4 Frontend
Responsibilities:
* send input
* display output
* optionally inspect state/events
Minimal React or Vue app
---
## 6. Turn Flow (IMPLEMENT EXACTLY)
```
1. Receive user input (string)
2. Store raw turn
3. Extract actions (LLM or fallback parser)
4. Validate actions (truth engine)
5. Apply accepted changes
6. Store events
7. Generate narration
8. Return response
```
---
## 7. Storage
### SQLite (MVP)
Tables:
* entities
* events
* turns
* beliefs
* summaries
File location:
```
/data/sqlite/app.db
```
---
## 8. Docker Design
### Services
```
services:
app
frontend
ollama (optional)
```
### Principles
* No host dependencies beyond Docker
* Persist only `/data`
* Use `.env` for config
* No hidden setup scripts
---
## 9. Folder Structure
```
charactergarden/
docker-compose.yml
.env
app/
frontend/
data/
sqlite/
```
---
## 10. MVP Scope
STRICT LIMITS:
* 12 rooms
* ≤3 characters
* ≤10 actions
* no complex AI autonomy
* no multi-agent loops yet
---
## 11. Non-Goals (DO NOT BUILD YET)
* microservices
* distributed systems
* plugin frameworks
* advanced agent loops
* cloud dependencies
---
## 12. Development Workflow Rule
A root-level file named `thoughts.md` must exist and be maintained throughout development.
Purpose of `thoughts.md`:
* record current implementation status
* record the next planned steps
* record blockers, assumptions, and unresolved questions
* summarize architectural decisions already made
* preserve continuity across editor sessions or context loss
Rules for `thoughts.md`:
* update it after each meaningful implementation step
* keep entries concise and factual
* do not use it for chain-of-thought dumping or vague brainstorming
* use it as a project progress log and working memory
* when resuming work, review `thoughts.md` first before making changes
* when changing architecture, record what changed and why
* when a task is incomplete, note exactly what remains
Recommended structure:
```md
# thoughts.md
## Current Status
- what is implemented
- what is partially implemented
- what is broken or unverified
## Current Architecture Decisions
- key decisions and constraints
## Next Steps
- ordered checklist of immediate tasks
## Open Questions
- unresolved design or implementation questions
## Session Notes
- short dated notes describing recent progress
```
Copilot instruction:
> Before starting work, read `thoughts.md`. After completing any meaningful change, update `thoughts.md` to reflect current status, next steps, and any unresolved issues.
## 13. Development Order
1. Define entities + actions
2. Implement truth engine
3. Add SQLite persistence
4. Build API endpoints
5. Add minimal UI
6. Add LLM integration
---
## 14. Key Rule
> If the system works without an LLM, the architecture is correct.
---
## 15. Expected Behavior
* deterministic state
* explainable failures
* replayable sessions
* inspectable memory
---
## 16. Future Extensions (NOT NOW)
* branching timelines
* advanced belief systems
* multi-agent arbitration
* long-term memory compression
---
## END SPEC
3. Testing depth
- Expand Docker-executed integration tests for:
- createIfMissing authorization matrix
- transfer ownership/location checks
- unresolved clarification flows
- multi-action turn behavior

View File

@@ -1,45 +1,107 @@
# thoughts.md
## Current Status
- Scaffold complete: `charactergarden/` folder structure created per spec section 9
- Core contracts defined in `app/src/types.ts`: Entity, Action, Verb, ValidationResult, StateChange, GameEvent, Turn, Belief, Fact, Affordance, Summary
- `docker-compose.yml` created; ollama service gated behind `--profile llm` (not required for MVP)
- `.env` / `.env.example` / `.gitignore` in place
- Container-first runtime files added: app/frontend Dockerfiles and `.dockerignore`s
- **Truth Engine implemented** in `app/src/truthEngine.ts` — pure function, no I/O, no LLM
- `validate(actions, worldState)` → ValidationResult
- `applyChanges(worldState, changes)` → new WorldState (immutable)
- All 8 verbs handled with explicit rejection reasons
- `move` now supports a built-in offscene room convention via `createOffsceneRoom()`
- `latentEntities.ts` can promote plausible personal items from belief to fact when the actor has carrying context
- `db.ts` added with SQLite schema + persistence helpers for `entities`, `events`, `turns`, `beliefs`, and `summaries`
- Minimal Fastify server + app pipeline added with seeded world state and fallback parser
- Minimal Vite React inspector added for visual boot testing and state inspection
## Documentation Sync
## Current Architecture Decisions
- App: Node.js + Fastify + TypeScript
- Frontend: React + Vite + TypeScript
- Database: better-sqlite3 (synchronous, no ORM)
- Ollama is optional; system must work without it (per section 14)
- `Event` type renamed `GameEvent` in code to avoid collision with the DOM `Event` global
- Latent personal items are gated by facts-derived affordances, not accepted directly from beliefs
- The offscene room is represented as a normal room entity with id `offscene`
- App and frontend should be run and validated through Docker Compose rather than host-installed Node
- Implementation plan refreshed in Implementation_plan.md to match current codebase state.
- Next executable phase is Phase 1: Intent Interpreter Boundary Hardening.
## Next Steps
1. Implement App service / turn flow (`app/src/app.ts`) per section 6
2. Validate Docker boot and iterate on any compile/runtime failures
3. Expand fallback parser coverage and tighten truth-engine world rules
4. Add LLM adapter implementation beyond fallback parsing
## Current Snapshot (April 2026)
## Open Questions
- Should room/location be an Entity attribute or a separate entity type?
- What is the initial world state for the MVP (12 rooms, ≤3 characters)?
- Should latent personal-item plausibility live only on actor attributes, or also look at worn item/container entities?
### What is now working
## Session Notes
- 2026-04-23: Project started. Scaffold, type contracts, .gitignore, and .env.example created.
- 2026-04-23: Truth Engine implemented. Pure validation with per-verb handlers and immutable applyChanges helper.
- 2026-04-23: Added facts/affordances + latent entity resolver for improv-style personal items, plus offscene room support.
- 2026-04-23: Added SQLite schema module. Host `npm install` is blocked by `better-sqlite3` on Windows Node 25, so runtime validation should happen inside Docker on an LTS Node image instead.
- 2026-04-23: Added minimal backend/frontend boot slice so the project can be tested visually through Docker.
- 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.
- Turn log now returns populated actions and validation results per turn (previously always empty).
- Active rulebook ID is now persisted on worldState and survives restarts.
- `take` sets `has_<item_id>` generically on the actor (was hardcoded to key_1 only).
### Structural refactoring completed (April 2026)
- **Deleted `turns/processTurn.ts`** — was a 3-line shim over runTurnManager. app.ts now calls runTurnManager directly.
- **Deleted `truthEngine.ts`** — was a thin wrapper over rulebookEngine.validateWithRulebook. turnManager.ts now calls validateWithRulebook directly.
- **Extracted `world/seedWorld.ts`** — createSeedWorldState, mergeSeedWorldState, ensureSeedState moved out of app.ts. App factory is now clean.
- **Fixed `db.listTurns()`** — now reads back actions and validation_results from their tables. Frontend turn log now has real data.
- **Fixed `worldState.rulebookId` persistence** — upsertRulebook now updates worldState.rulebookId and persists a world snapshot so the active rulebook survives restarts.
- **Generalized `has_<item_id>` in applyActions** — `take` now sets `has_<item_id>` on the actor for all taken items, not just `key_1`. The attributeRef rulebook check continues to work generically.
### Confirmed via containerized validation
- Backend build passes in Docker:
- docker compose run --rm app npm run build
- Frontend build passes in Docker:
- docker compose run --rm frontend npm run build
- Host Node build is intentionally not relied on.
## Architecture Feedback
### Good decisions worth keeping
1. Rulebook externalization
- Pulling edge-case logic out of hardcoded truth engine branches was the right move.
- It now supports editable policy without code deployment.
2. Explicit authorization checks in rules
- Authorization for creation-style actions now belongs to policy, not parser guesswork.
- This aligns with deterministic governance.
3. Introducing transfer as first-class action
- This avoids overloading take semantics and keeps intent/action boundaries cleaner.
4. Turn manager seam
- processTurn delegating to a turn manager creates a stable orchestration point for interpreter upgrades.
### Risks / cleanup still needed
1. Frontend contract drift risk
- App.tsx has historically duplicated blocks during rapid edits.
- Keep one canonical component and avoid append-style merges.
2. Interpreter observability depth
- Interpreter traces are persisted, but aggregate analytics/counters are still missing.
3. Rulebook migration strategy
- Existing DBs may hold older rulebooks missing new action rule sets.
- Need explicit upgrade path/versioning.
4. Parser is world-specific
- parseTextToActions.ts hardcodes entity IDs (room_exit, door_1, key_1, groundskeeper).
- The parser is used only inside the deterministic resolver adapter; keeping it isolated limits blast radius, but a future world-context-aware resolver would eliminate this entirely.
5. Entity table vs world_state blob redundancy
- `entities` table and `world_states.state_json` both store entity data. The entities table is the live read target; world_states is the history log. No query capability on history. Acceptable for MVP.
## Path Forward (Next 3 Iterations)
### Iteration 1: LLM adapter hardening
- Harden prompt + response schema handling for model drift and malformed JSON.
- Add environment-specific model/timeouts and failure policy guidance.
- Add tests covering unavailable model backend and malformed payload fallback.
### Iteration 2: Traceability + observability
- Surface interpreter status in frontend turn log.
- Add reason-code analytics counters for failed validations and unresolved intents.
### Iteration 3: Rulebook lifecycle and test harness
- Add policy packs (creation, transfer, social).
- Add Docker-run integration tests for:
- unauthorized createIfMissing
- authorized createIfMissing
- transfer success/failure matrix
- clarification path for ambiguous/unrecognized input
## Operating Guidance
- Keep all build/test checks containerized.
- Treat interpreter as replaceable adapter behind a stable contract.
- Keep truth engine deterministic and side-effect free.
- Keep mutation logic pure relative to validated actions only.