Compare commits
18 Commits
c2d12ffcc9
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 28229d8d69 | |||
| 81e2a7828f | |||
| b4a2968399 | |||
| 3112b6e9fe | |||
| d38c799b27 | |||
| 7a022bc085 | |||
| 665646bc18 | |||
| 56c9cce4c7 | |||
| 76dee7e73f | |||
| ca49565117 | |||
| 5189446c73 | |||
| 0da62785d5 | |||
| c32fa977a8 | |||
| fca69d3cb5 | |||
| fc10e46ccc | |||
| ff9b86c3e9 | |||
| 998635f542 | |||
| 2f6af46c79 |
339
Implementation_plan.md
Normal file
339
Implementation_plan.md
Normal 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.
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
NODE_ENV=development
|
NODE_ENV=development
|
||||||
APP_PORT=3000
|
APP_PORT=3024
|
||||||
FRONTEND_PORT=5173
|
FRONTEND_PORT=5173
|
||||||
DB_PATH=/data/sqlite/app.db
|
DB_PATH=/data/sqlite/app.db
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,26 @@
|
|||||||
# Copy this file to .env and adjust values as needed.
|
# Copy this file to .env and adjust values as needed.
|
||||||
# Never commit .env — it is gitignored.
|
# Never commit .env — it is gitignored.
|
||||||
|
|
||||||
NODE_ENV=development
|
# Host port the app is exposed on. Must match the container-internal port (right side of ports mapping).
|
||||||
APP_PORT=3000
|
APP_PORT=3023
|
||||||
FRONTEND_PORT=5173
|
|
||||||
DB_PATH=/data/sqlite/app.db
|
|
||||||
|
|
||||||
# Optional — only required when running with: docker compose --profile llm up
|
# Host port the frontend dev server is exposed on.
|
||||||
OLLAMA_URL=http://ollama:11434
|
FRONTEND_PORT=5173
|
||||||
OLLAMA_MODEL=llama3
|
|
||||||
|
NODE_ENV=development
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# LLM resolver — only active when running: docker compose --profile llm up
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# URL of the Ollama-compatible backend (default points to the compose ollama service).
|
||||||
|
LLM_RESOLVER_URL=http://ollama:11434
|
||||||
|
|
||||||
|
# Model to pull and use. Must be available on the Ollama instance.
|
||||||
|
LLM_RESOLVER_MODEL=llama3.2:3b
|
||||||
|
|
||||||
|
# Request timeout in milliseconds. Increase for larger/slower models.
|
||||||
|
LLM_RESOLVER_TIMEOUT_MS=15000
|
||||||
|
|
||||||
|
# Resolver mode: hybrid (llm with deterministic fallback) | llm | deterministic
|
||||||
|
INTENT_RESOLVER_MODE=hybrid
|
||||||
|
|||||||
@@ -5,7 +5,8 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "ts-node-dev --respawn --transpile-only src/index.ts",
|
"dev": "ts-node-dev --respawn --transpile-only src/index.ts",
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"start": "node dist/index.js"
|
"start": "node dist/index.js",
|
||||||
|
"test:integration": "npm run build && node dist/tests/integrationRunner.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fastify": "^4.28.1",
|
"fastify": "^4.28.1",
|
||||||
|
|||||||
@@ -1,316 +1,103 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
|
||||||
|
|
||||||
import { createDatabase, CharacterGardenDatabase } from "./db";
|
import { createDatabase, CharacterGardenDatabase } from "./db";
|
||||||
import { resolveLatentEntity } from "./latentEntities";
|
import type { SceneRulebook } from "./contracts/rulebook";
|
||||||
import { extractActionsFromProse } from "./llmAdapter";
|
import type { Turn } from "./contracts/turn";
|
||||||
import { applyChanges, createOffsceneRoom, OFFSCENE_ROOM_ID, validate, WorldState } from "./truthEngine";
|
import type { WorldState } from "./contracts/world";
|
||||||
import { Action, Belief, Entity, GameEvent, Summary, Turn } from "./types";
|
import { createDefaultRulebook, DEFAULT_RULEBOOK_ID } from "./defaultRulebook";
|
||||||
|
import { runTurnManager, TurnManagerResponse } from "./turns/turnManager";
|
||||||
|
import { ensureSeedState } from "./world/seedWorld";
|
||||||
|
|
||||||
export interface AppStateSnapshot {
|
export interface AppSnapshot {
|
||||||
entities: Entity[];
|
worldState: WorldState;
|
||||||
events: GameEvent[];
|
|
||||||
turns: Turn[];
|
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 {
|
export interface CharacterGardenApp {
|
||||||
db: CharacterGardenDatabase;
|
db: CharacterGardenDatabase;
|
||||||
getSnapshot(): AppStateSnapshot;
|
getSnapshot(): AppSnapshot;
|
||||||
processTurn(input: string): TurnResult;
|
processTurn(rawText: string): Promise<TurnManagerResponse>;
|
||||||
|
getRulebook(): SceneRulebook;
|
||||||
|
upsertRulebook(rulebook: SceneRulebook): SceneRulebook;
|
||||||
|
listRulebooks(): SceneRulebook[];
|
||||||
|
reset(): AppSnapshot;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createSeedEntities(): Entity[] {
|
function ensureDefaultRulebook(
|
||||||
return [
|
db: CharacterGardenDatabase,
|
||||||
createOffsceneRoom(),
|
worldState: WorldState
|
||||||
{
|
): SceneRulebook {
|
||||||
id: "garden",
|
const existing = db.getRulebook(DEFAULT_RULEBOOK_ID);
|
||||||
type: "room",
|
if (existing) return existing;
|
||||||
name: "Garden",
|
const defaultRulebook = createDefaultRulebook(worldState.id);
|
||||||
attributes: {
|
db.upsertRulebook(defaultRulebook);
|
||||||
description: "A small overgrown garden with a weathered bench and a shed door nearby.",
|
return defaultRulebook;
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createCharacterGardenApp(dbPath: string): CharacterGardenApp {
|
export function createCharacterGardenApp(dbPath: string): CharacterGardenApp {
|
||||||
const db = createDatabase({ dbPath });
|
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 {
|
// Ensure the default rulebook is present on first boot.
|
||||||
return {
|
ensureDefaultRulebook(db, worldState);
|
||||||
entities: entitiesFromWorldState(worldState),
|
|
||||||
events: db.listEvents(),
|
|
||||||
turns: db.listTurns(),
|
|
||||||
beliefs: db.listBeliefs(),
|
|
||||||
summaries: db.listSummaries(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function processTurn(input: string): TurnResult {
|
function loadActiveRulebook(): SceneRulebook {
|
||||||
const turnNumber = db.listTurns().length + 1;
|
const rulebook = db.getRulebook(activeRulebookId);
|
||||||
const { actions, parser } = extractActionsFromProse(input);
|
if (rulebook) return rulebook;
|
||||||
|
// Fall back to default if the active one was deleted.
|
||||||
let activeWorldState = worldState;
|
activeRulebookId = DEFAULT_RULEBOOK_ID;
|
||||||
let latentResolution: TurnResult["latent_resolution"];
|
return ensureDefaultRulebook(db, worldState);
|
||||||
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(),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
db,
|
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(),
|
||||||
|
};
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
7
charactergarden/app/src/contracts/action.ts
Normal file
7
charactergarden/app/src/contracts/action.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export type Action = {
|
||||||
|
actorId: string;
|
||||||
|
type: string;
|
||||||
|
targetId?: string;
|
||||||
|
locationId?: string;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
};
|
||||||
6
charactergarden/app/src/contracts/entity.ts
Normal file
6
charactergarden/app/src/contracts/entity.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export type Entity = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
attributes: Record<string, unknown>;
|
||||||
|
};
|
||||||
48
charactergarden/app/src/contracts/intent.ts
Normal file
48
charactergarden/app/src/contracts/intent.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import type { Action } from "./action";
|
||||||
|
|
||||||
|
export type InterpreterStatus =
|
||||||
|
| "resolved"
|
||||||
|
| "needs_clarification"
|
||||||
|
| "rejected";
|
||||||
|
|
||||||
|
export type ClarificationReasonCode =
|
||||||
|
| "UNRECOGNIZED_INTENT"
|
||||||
|
| "AMBIGUOUS_REFERENCE"
|
||||||
|
| "EMPTY_INPUT"
|
||||||
|
| "LOW_CONFIDENCE"
|
||||||
|
| "INTERNAL_INVALID_OUTPUT";
|
||||||
|
|
||||||
|
export type ClarificationOption = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
entityId?: string;
|
||||||
|
entityType?: "character" | "item" | "room" | "unknown";
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ClarificationRequest = {
|
||||||
|
reasonCode: ClarificationReasonCode;
|
||||||
|
question: string;
|
||||||
|
field?: "verb" | "target" | "item" | "recipient" | "location";
|
||||||
|
options?: ClarificationOption[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type InterpreterCandidate = {
|
||||||
|
action: Action;
|
||||||
|
confidence: number;
|
||||||
|
rationale?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type InterpreterOutput = {
|
||||||
|
interpreterVersion: string;
|
||||||
|
rawText: string;
|
||||||
|
actorId: string;
|
||||||
|
resolutionSource: "deterministic" | "llm" | "hybrid";
|
||||||
|
minConfidence: number;
|
||||||
|
selectedConfidence?: number;
|
||||||
|
status: InterpreterStatus;
|
||||||
|
selectedActions: Action[];
|
||||||
|
candidates: InterpreterCandidate[];
|
||||||
|
diagnostics: string[];
|
||||||
|
clarification?: ClarificationRequest;
|
||||||
|
};
|
||||||
100
charactergarden/app/src/contracts/rulebook.ts
Normal file
100
charactergarden/app/src/contracts/rulebook.ts
Normal 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;
|
||||||
|
};
|
||||||
12
charactergarden/app/src/contracts/turn.ts
Normal file
12
charactergarden/app/src/contracts/turn.ts
Normal 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;
|
||||||
|
};
|
||||||
6
charactergarden/app/src/contracts/validation.ts
Normal file
6
charactergarden/app/src/contracts/validation.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export type ValidationResult = {
|
||||||
|
actionIndex: number;
|
||||||
|
success: boolean;
|
||||||
|
reason?: string;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
10
charactergarden/app/src/contracts/world.ts
Normal file
10
charactergarden/app/src/contracts/world.ts
Normal 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;
|
||||||
|
};
|
||||||
@@ -2,7 +2,13 @@ import fs from "node:fs";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import Database from "better-sqlite3";
|
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 {
|
export interface DatabaseConfig {
|
||||||
dbPath: string;
|
dbPath: string;
|
||||||
@@ -12,58 +18,24 @@ export interface CharacterGardenDatabase {
|
|||||||
sqlite: Database.Database;
|
sqlite: Database.Database;
|
||||||
init(): void;
|
init(): void;
|
||||||
close(): void;
|
close(): void;
|
||||||
upsertEntity(entity: Entity): void;
|
upsertEntities(entities: Entity[]): void;
|
||||||
listEntities(): Entity[];
|
listEntities(): Entity[];
|
||||||
insertEvent(event: GameEvent): void;
|
|
||||||
listEvents(): GameEvent[];
|
|
||||||
insertTurn(turn: Turn): void;
|
insertTurn(turn: Turn): void;
|
||||||
listTurns(): Turn[];
|
listTurns(): Turn[];
|
||||||
insertBelief(belief: Belief): void;
|
insertInterpreterOutput(turnId: string, interpreter: InterpreterOutput): void;
|
||||||
listBeliefs(entityId?: string): Belief[];
|
insertActions(turnId: string, actions: Action[]): void;
|
||||||
insertSummary(summary: Summary): void;
|
insertValidationResults(turnId: string, results: ValidationResult[]): void;
|
||||||
listSummaries(): Summary[];
|
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 {
|
function ensureParentDirectory(dbPath: string): void {
|
||||||
const directory = path.dirname(dbPath);
|
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
|
||||||
fs.mkdirSync(directory, { recursive: true });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseJson<T>(value: string): T {
|
function parseJson<T>(value: string): T {
|
||||||
@@ -72,51 +44,75 @@ function parseJson<T>(value: string): T {
|
|||||||
|
|
||||||
export function createDatabase(config: DatabaseConfig): CharacterGardenDatabase {
|
export function createDatabase(config: DatabaseConfig): CharacterGardenDatabase {
|
||||||
ensureParentDirectory(config.dbPath);
|
ensureParentDirectory(config.dbPath);
|
||||||
|
|
||||||
const sqlite = new Database(config.dbPath);
|
const sqlite = new Database(config.dbPath);
|
||||||
|
|
||||||
const initStatements = [
|
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 (
|
CREATE TABLE IF NOT EXISTS entities (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
type TEXT NOT NULL,
|
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
|
type TEXT NOT NULL,
|
||||||
attributes_json 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,
|
id TEXT PRIMARY KEY,
|
||||||
turn INTEGER NOT NULL,
|
turn_id TEXT,
|
||||||
action_json TEXT NOT NULL,
|
state_json TEXT NOT NULL,
|
||||||
result TEXT NOT NULL CHECK(result IN ('success', 'fail')),
|
created_at INTEGER NOT NULL,
|
||||||
timestamp 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,
|
id TEXT PRIMARY KEY,
|
||||||
turn INTEGER NOT NULL UNIQUE,
|
world_id TEXT NOT NULL,
|
||||||
input TEXT NOT NULL,
|
version INTEGER NOT NULL DEFAULT 1,
|
||||||
output TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
timestamp INTEGER 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 (
|
CREATE TABLE IF NOT EXISTS interpreter_events (
|
||||||
entity_id TEXT NOT NULL,
|
turn_id TEXT PRIMARY KEY,
|
||||||
claim TEXT NOT NULL,
|
interpreter_json TEXT NOT NULL,
|
||||||
confidence REAL NOT NULL,
|
created_at INTEGER NOT NULL,
|
||||||
PRIMARY KEY (entity_id, claim)
|
FOREIGN KEY(turn_id) REFERENCES turns(id)
|
||||||
)
|
|
||||||
`,
|
|
||||||
`
|
|
||||||
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
|
|
||||||
)
|
)
|
||||||
`,
|
`,
|
||||||
];
|
];
|
||||||
@@ -125,163 +121,393 @@ export function createDatabase(config: DatabaseConfig): CharacterGardenDatabase
|
|||||||
sqlite.exec(statement);
|
sqlite.exec(statement);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Backward-compatible migration for pre-versioned databases.
|
||||||
|
try {
|
||||||
|
sqlite.exec("ALTER TABLE rulebooks ADD COLUMN version INTEGER NOT NULL DEFAULT 1");
|
||||||
|
} catch {
|
||||||
|
// Column already exists.
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearEntitiesStatement = sqlite.prepare("DELETE FROM entities");
|
||||||
const upsertEntityStatement = sqlite.prepare(`
|
const upsertEntityStatement = sqlite.prepare(`
|
||||||
INSERT INTO entities (id, type, name, attributes_json)
|
INSERT INTO entities (id, name, type, attributes_json)
|
||||||
VALUES (@id, @type, @name, @attributes_json)
|
VALUES (@id, @name, @type, @attributes_json)
|
||||||
ON CONFLICT(id) DO UPDATE SET
|
ON CONFLICT(id) DO UPDATE SET
|
||||||
type = excluded.type,
|
|
||||||
name = excluded.name,
|
name = excluded.name,
|
||||||
|
type = excluded.type,
|
||||||
attributes_json = excluded.attributes_json
|
attributes_json = excluded.attributes_json
|
||||||
`);
|
`);
|
||||||
|
|
||||||
const listEntitiesStatement = sqlite.prepare(`
|
const listEntitiesStatement = sqlite.prepare(`
|
||||||
SELECT id, type, name, attributes_json
|
SELECT id, name, type, attributes_json
|
||||||
FROM entities
|
FROM entities
|
||||||
ORDER BY id ASC
|
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(`
|
const insertTurnStatement = sqlite.prepare(`
|
||||||
INSERT INTO turns (id, turn, input, output, timestamp)
|
INSERT INTO turns (id, raw_text, created_at)
|
||||||
VALUES (@id, @turn, @input, @output, @timestamp)
|
VALUES (@id, @raw_text, @created_at)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
const listTurnsStatement = sqlite.prepare(`
|
const listTurnsStatement = sqlite.prepare(`
|
||||||
SELECT id, turn, input, output, timestamp
|
SELECT id, raw_text, created_at
|
||||||
FROM turns
|
FROM turns
|
||||||
ORDER BY turn ASC
|
ORDER BY created_at ASC
|
||||||
`);
|
`);
|
||||||
|
|
||||||
const insertBeliefStatement = sqlite.prepare(`
|
const listAllActionsStatement = sqlite.prepare(`
|
||||||
INSERT INTO beliefs (entity_id, claim, confidence)
|
SELECT turn_id, action_index, actor_id, type, target_id, location_id, metadata_json
|
||||||
VALUES (@entity_id, @claim, @confidence)
|
FROM actions
|
||||||
ON CONFLICT(entity_id, claim) DO UPDATE SET
|
ORDER BY turn_id, action_index ASC
|
||||||
confidence = excluded.confidence
|
|
||||||
`);
|
`);
|
||||||
|
|
||||||
const listBeliefsStatement = sqlite.prepare(`
|
const listAllValidationStatement = sqlite.prepare(`
|
||||||
SELECT entity_id, claim, confidence
|
SELECT turn_id, action_index, success, reason, message
|
||||||
FROM beliefs
|
FROM validation_results
|
||||||
ORDER BY entity_id ASC, claim ASC
|
ORDER BY turn_id, action_index ASC
|
||||||
`);
|
`);
|
||||||
|
|
||||||
const listBeliefsByEntityStatement = sqlite.prepare(`
|
const listInterpreterEventsStatement = sqlite.prepare(`
|
||||||
SELECT entity_id, claim, confidence
|
SELECT turn_id, interpreter_json
|
||||||
FROM beliefs
|
FROM interpreter_events
|
||||||
WHERE entity_id = ?
|
|
||||||
ORDER BY claim ASC
|
|
||||||
`);
|
`);
|
||||||
|
|
||||||
const insertSummaryStatement = sqlite.prepare(`
|
const insertInterpreterOutputStatement = sqlite.prepare(`
|
||||||
INSERT INTO summaries (id, turn_start, turn_end, text, timestamp)
|
INSERT INTO interpreter_events (turn_id, interpreter_json, created_at)
|
||||||
VALUES (@id, @turn_start, @turn_end, @text, @timestamp)
|
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(`
|
const insertActionStatement = sqlite.prepare(`
|
||||||
SELECT id, turn_start, turn_end, text, timestamp
|
INSERT INTO actions (
|
||||||
FROM summaries
|
turn_id,
|
||||||
ORDER BY turn_start ASC, turn_end ASC
|
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 {
|
return {
|
||||||
sqlite,
|
sqlite,
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
// Schema is applied on database construction so prepared statements are valid.
|
// Tables are initialized on construction.
|
||||||
},
|
},
|
||||||
|
|
||||||
close() {
|
close() {
|
||||||
sqlite.close();
|
sqlite.close();
|
||||||
},
|
},
|
||||||
|
|
||||||
upsertEntity(entity) {
|
wipe() {
|
||||||
upsertEntityStatement.run({
|
sqlite.exec(`
|
||||||
id: entity.id,
|
DELETE FROM interpreter_events;
|
||||||
type: entity.type,
|
DELETE FROM validation_results;
|
||||||
name: entity.name,
|
DELETE FROM actions;
|
||||||
attributes_json: JSON.stringify(entity.attributes),
|
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,
|
||||||
|
name: entity.name,
|
||||||
|
type: entity.type,
|
||||||
|
attributes_json: JSON.stringify(entity.attributes),
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
tx(entities);
|
||||||
},
|
},
|
||||||
|
|
||||||
listEntities() {
|
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) => ({
|
return rows.map((row) => ({
|
||||||
id: row.id,
|
id: row.id,
|
||||||
type: row.type,
|
|
||||||
name: row.name,
|
name: row.name,
|
||||||
|
type: row.type,
|
||||||
attributes: parseJson<Record<string, unknown>>(row.attributes_json),
|
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) {
|
insertTurn(turn) {
|
||||||
insertTurnStatement.run(turn);
|
insertTurnStatement.run({
|
||||||
|
id: turn.id,
|
||||||
|
raw_text: turn.rawText,
|
||||||
|
created_at: turn.createdAt,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
listTurns() {
|
listTurns() {
|
||||||
return listTurnsStatement.all() as TurnRow[];
|
const rows = listTurnsStatement.all() as Array<{
|
||||||
},
|
id: string;
|
||||||
|
raw_text: string;
|
||||||
|
created_at: number;
|
||||||
|
}>;
|
||||||
|
|
||||||
insertBelief(belief) {
|
const interpreterRows = listInterpreterEventsStatement.all() as Array<{
|
||||||
insertBeliefStatement.run(belief);
|
turn_id: string;
|
||||||
},
|
interpreter_json: string;
|
||||||
|
}>;
|
||||||
listBeliefs(entityId) {
|
const interpreterByTurnId = new Map<string, InterpreterOutput>();
|
||||||
if (entityId) {
|
for (const row of interpreterRows) {
|
||||||
return listBeliefsByEntityStatement.all(entityId) as BeliefRow[];
|
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) {
|
insertInterpreterOutput(turnId, interpreter) {
|
||||||
insertSummaryStatement.run({
|
insertInterpreterOutputStatement.run({
|
||||||
id: summary.id,
|
turn_id: turnId,
|
||||||
turn_start: summary.turn_range[0],
|
interpreter_json: JSON.stringify(interpreter),
|
||||||
turn_end: summary.turn_range[1],
|
created_at: Date.now(),
|
||||||
text: summary.text,
|
|
||||||
timestamp: summary.timestamp,
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
listSummaries() {
|
insertActions(turnId, actions) {
|
||||||
const rows = listSummariesStatement.all() as SummaryRow[];
|
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) => ({
|
return rows.map((row) => ({
|
||||||
id: row.id,
|
id: row.id,
|
||||||
turn_range: [row.turn_start, row.turn_end],
|
worldId: row.world_id,
|
||||||
text: row.text,
|
version: row.version ?? 1,
|
||||||
timestamp: row.timestamp,
|
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 });
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
307
charactergarden/app/src/defaultRulebook.ts
Normal file
307
charactergarden/app/src/defaultRulebook.ts
Normal 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.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import Fastify from "fastify";
|
import Fastify from "fastify";
|
||||||
|
|
||||||
import { createCharacterGardenApp } from "./app";
|
import { createCharacterGardenApp } from "./app";
|
||||||
|
import type { SceneRulebook } from "./contracts/rulebook";
|
||||||
|
|
||||||
const port = Number(process.env.APP_PORT ?? 3000);
|
const port = Number(process.env.APP_PORT ?? 3000);
|
||||||
const host = process.env.APP_HOST ?? "0.0.0.0";
|
const host = process.env.APP_HOST ?? "0.0.0.0";
|
||||||
@@ -13,16 +14,38 @@ server.get("/health", async () => ({ ok: true }));
|
|||||||
|
|
||||||
server.get("/api/state", async () => game.getSnapshot());
|
server.get("/api/state", async () => game.getSnapshot());
|
||||||
|
|
||||||
|
server.post("/api/reset", async () => game.reset());
|
||||||
|
|
||||||
server.post<{ Body: { input?: string } }>("/api/turn", async (request, reply) => {
|
server.post<{ Body: { input?: string } }>("/api/turn", async (request, reply) => {
|
||||||
const input = request.body?.input?.trim();
|
const input = request.body?.input;
|
||||||
if (!input) {
|
if (typeof input !== "string") {
|
||||||
reply.code(400);
|
reply.code(400);
|
||||||
return { error: "input is required" };
|
return { error: "input is required and must be a string" };
|
||||||
}
|
}
|
||||||
|
|
||||||
return game.processTurn(input);
|
return await game.processTurn(input);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Rulebook endpoints
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** GET /api/rulebook — returns the currently active rulebook. */
|
||||||
|
server.get("/api/rulebook", async () => game.getRulebook());
|
||||||
|
|
||||||
|
/** PUT /api/rulebook — replace (or create) the active rulebook. */
|
||||||
|
server.put<{ Body: SceneRulebook }>("/api/rulebook", async (request, reply) => {
|
||||||
|
const body = request.body;
|
||||||
|
if (!body || typeof body.id !== "string" || !Array.isArray(body.rules)) {
|
||||||
|
reply.code(400);
|
||||||
|
return { error: "Invalid rulebook payload. Must include id (string) and rules (array)." };
|
||||||
|
}
|
||||||
|
return game.upsertRulebook(body);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** GET /api/rulebooks — list all saved rulebooks (name + id summary). */
|
||||||
|
server.get("/api/rulebooks", async () => game.listRulebooks());
|
||||||
|
|
||||||
async function start(): Promise<void> {
|
async function start(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await server.listen({ host, port });
|
await server.listen({ host, port });
|
||||||
|
|||||||
@@ -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" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
361
charactergarden/app/src/interpreter/adapters/llmResolver.ts
Normal file
361
charactergarden/app/src/interpreter/adapters/llmResolver.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
70
charactergarden/app/src/interpreter/interpretTurn.ts
Normal file
70
charactergarden/app/src/interpreter/interpretTurn.ts
Normal 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,
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
25
charactergarden/app/src/interpreter/resolveIntent.ts
Normal file
25
charactergarden/app/src/interpreter/resolveIntent.ts
Normal 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";
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
221
charactergarden/app/src/parser/parseTextToActions.ts
Normal file
221
charactergarden/app/src/parser/parseTextToActions.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
299
charactergarden/app/src/rulebookEngine.ts
Normal file
299
charactergarden/app/src/rulebookEngine.ts
Normal 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 };
|
||||||
|
});
|
||||||
|
}
|
||||||
47
charactergarden/app/src/tests/integrationRunner.ts
Normal file
47
charactergarden/app/src/tests/integrationRunner.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
import { createCharacterGardenApp } from "../app";
|
||||||
|
import { interpretTurn } from "../interpreter/interpretTurn";
|
||||||
|
|
||||||
|
async function run(): Promise<void> {
|
||||||
|
const resolved = await interpretTurn("look around", "player");
|
||||||
|
assert.equal(resolved.status, "resolved", "Expected 'look around' to resolve.");
|
||||||
|
|
||||||
|
const hybridResolved = await interpretTurn("look around", "player", { mode: "hybrid" });
|
||||||
|
assert.equal(hybridResolved.status, "resolved", "Expected hybrid mode to resolve via deterministic fallback when LLM is unavailable.");
|
||||||
|
assert.equal(hybridResolved.resolutionSource, "hybrid");
|
||||||
|
|
||||||
|
const empty = await interpretTurn("", "player");
|
||||||
|
assert.equal(empty.status, "rejected", "Expected empty input to be rejected.");
|
||||||
|
assert.equal(empty.clarification?.reasonCode, "EMPTY_INPUT");
|
||||||
|
|
||||||
|
const dbPath = path.join("/tmp", `charactergarden_integration_${Date.now()}.db`);
|
||||||
|
if (fs.existsSync(dbPath)) {
|
||||||
|
fs.unlinkSync(dbPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
const app = createCharacterGardenApp(dbPath);
|
||||||
|
|
||||||
|
const unresolved = await app.processTurn("blorb invalid nonsense");
|
||||||
|
assert.equal(unresolved.interpreter.status, "needs_clarification");
|
||||||
|
assert.equal(unresolved.actions.length, 0);
|
||||||
|
|
||||||
|
const valid = await app.processTurn("look around");
|
||||||
|
assert.equal(valid.interpreter.status, "resolved");
|
||||||
|
|
||||||
|
const snapshot = app.getSnapshot();
|
||||||
|
assert.ok(snapshot.turns.length >= 2, "Expected persisted turns.");
|
||||||
|
const latestTurn = snapshot.turns[snapshot.turns.length - 1];
|
||||||
|
assert.ok(latestTurn.interpreter, "Expected interpreter payload persisted on turn.");
|
||||||
|
|
||||||
|
app.db.close();
|
||||||
|
console.log("Integration checks passed.");
|
||||||
|
}
|
||||||
|
|
||||||
|
void run().catch((error) => {
|
||||||
|
console.error("Integration checks failed:");
|
||||||
|
console.error(error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
6
charactergarden/app/src/tests/sqliteProbe.ts
Normal file
6
charactergarden/app/src/tests/sqliteProbe.ts
Normal 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");
|
||||||
34
charactergarden/app/src/tests/verifyDefaultSeed.ts
Normal file
34
charactergarden/app/src/tests/verifyDefaultSeed.ts
Normal 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();
|
||||||
@@ -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,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
111
charactergarden/app/src/turns/turnManager.ts
Normal file
111
charactergarden/app/src/turns/turnManager.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
194
charactergarden/app/src/world/applyActions.ts
Normal file
194
charactergarden/app/src/world/applyActions.ts
Normal 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;
|
||||||
|
}
|
||||||
162
charactergarden/app/src/world/seedWorld.ts
Normal file
162
charactergarden/app/src/world/seedWorld.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -1 +0,0 @@
|
|||||||
# sqlite data directory — tracked by git, contents ignored
|
|
||||||
0
charactergarden/data/sqlite/test_write_probe.txt
Normal file
0
charactergarden/data/sqlite/test_write_probe.txt
Normal file
@@ -3,14 +3,18 @@ services:
|
|||||||
build: ./app
|
build: ./app
|
||||||
working_dir: /app
|
working_dir: /app
|
||||||
ports:
|
ports:
|
||||||
- "${APP_PORT:-3000}:3000"
|
- "${APP_PORT:-3024}:3024"
|
||||||
environment:
|
environment:
|
||||||
- APP_PORT=3000
|
- APP_PORT=3024
|
||||||
- NODE_ENV=${NODE_ENV:-development}
|
- NODE_ENV=${NODE_ENV:-development}
|
||||||
- DB_PATH=/data/sqlite/app.db
|
- DB_PATH=/var/lib/charactergarden/app.db
|
||||||
- OLLAMA_URL=${OLLAMA_URL:-http://ollama:11434}
|
- OLLAMA_URL=${OLLAMA_URL:-http://ollama:11434}
|
||||||
|
- LLM_RESOLVER_URL=${LLM_RESOLVER_URL:-http://ollama:11434}
|
||||||
|
- LLM_RESOLVER_MODEL=${LLM_RESOLVER_MODEL:-llama3.2:3b}
|
||||||
|
- LLM_RESOLVER_TIMEOUT_MS=${LLM_RESOLVER_TIMEOUT_MS:-15000}
|
||||||
|
- INTENT_RESOLVER_MODE=${INTENT_RESOLVER_MODE:-hybrid}
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/data
|
- sqlite_data:/var/lib/charactergarden
|
||||||
- ./app/src:/app/src
|
- ./app/src:/app/src
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
@@ -35,6 +39,24 @@ services:
|
|||||||
- ollama_data:/root/.ollama
|
- ollama_data:/root/.ollama
|
||||||
profiles:
|
profiles:
|
||||||
- llm
|
- llm
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "ollama", "list"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
start_period: 20s
|
||||||
|
|
||||||
|
ollama-init:
|
||||||
|
image: ollama/ollama:latest
|
||||||
|
depends_on:
|
||||||
|
ollama:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
- OLLAMA_HOST=http://ollama:11434
|
||||||
|
entrypoint: ["ollama", "pull", "${LLM_RESOLVER_MODEL:-llama3.2:3b}"]
|
||||||
|
profiles:
|
||||||
|
- llm
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
ollama_data:
|
ollama_data:
|
||||||
|
sqlite_data:
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { FormEvent, useEffect, useState } from "react";
|
import { FormEvent, useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
type Entity = {
|
type Entity = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -7,61 +7,149 @@ type Entity = {
|
|||||||
attributes: Record<string, unknown>;
|
attributes: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
type GameEvent = {
|
type Action = {
|
||||||
id: string;
|
actorId: string;
|
||||||
turn: number;
|
type: string;
|
||||||
result: "success" | "fail";
|
targetId?: string;
|
||||||
action: Record<string, unknown>;
|
locationId?: string;
|
||||||
timestamp: number;
|
};
|
||||||
|
|
||||||
|
type ValidationResult = {
|
||||||
|
actionIndex: number;
|
||||||
|
success: boolean;
|
||||||
|
reason?: string;
|
||||||
|
message?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Turn = {
|
type Turn = {
|
||||||
id: string;
|
id: string;
|
||||||
turn: number;
|
rawText: string;
|
||||||
input: string;
|
actions: Action[];
|
||||||
output: string;
|
validation: ValidationResult[];
|
||||||
timestamp: number;
|
createdAt: number;
|
||||||
|
interpreter?: {
|
||||||
|
interpreterVersion: string;
|
||||||
|
resolutionSource: "deterministic" | "llm" | "hybrid";
|
||||||
|
minConfidence: number;
|
||||||
|
status: "resolved" | "needs_clarification" | "rejected";
|
||||||
|
selectedConfidence?: number;
|
||||||
|
candidates?: Array<{
|
||||||
|
action: Action;
|
||||||
|
confidence: number;
|
||||||
|
rationale?: string;
|
||||||
|
}>;
|
||||||
|
diagnostics: string[];
|
||||||
|
clarification?: {
|
||||||
|
reasonCode: string;
|
||||||
|
question: string;
|
||||||
|
field?: string;
|
||||||
|
options?: Array<{
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
type Belief = {
|
type WorldState = {
|
||||||
entity_id: string;
|
|
||||||
claim: string;
|
|
||||||
confidence: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
type Summary = {
|
|
||||||
id: string;
|
id: string;
|
||||||
turn_range: [number, number];
|
entities: Record<string, Entity>;
|
||||||
text: string;
|
metadata: Record<string, unknown>;
|
||||||
timestamp: number;
|
createdAt: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Snapshot = {
|
type AppSnapshot = {
|
||||||
entities: Entity[];
|
worldState: WorldState;
|
||||||
events: GameEvent[];
|
|
||||||
turns: Turn[];
|
turns: Turn[];
|
||||||
beliefs: Belief[];
|
|
||||||
summaries: Summary[];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type TurnResult = {
|
type ProcessTurnResponse = {
|
||||||
narration: string;
|
rawText: string;
|
||||||
parser: string;
|
actions: Action[];
|
||||||
actions: Array<Record<string, unknown>>;
|
validation: ValidationResult[];
|
||||||
accepted: Array<Record<string, unknown>>;
|
worldState: WorldState;
|
||||||
rejected: Array<{ action: Record<string, unknown>; reason: string }>;
|
interpreter: {
|
||||||
latent_resolution?: { accepted: boolean; reason: string; entity_id?: string };
|
interpreterVersion: string;
|
||||||
snapshot: Snapshot;
|
resolutionSource: "deterministic" | "llm" | "hybrid";
|
||||||
|
minConfidence: number;
|
||||||
|
status: "resolved" | "needs_clarification" | "rejected";
|
||||||
|
selectedConfidence?: number;
|
||||||
|
candidates: Array<{
|
||||||
|
action: Action;
|
||||||
|
confidence: number;
|
||||||
|
rationale?: string;
|
||||||
|
}>;
|
||||||
|
diagnostics: string[];
|
||||||
|
clarification?: {
|
||||||
|
reasonCode: string;
|
||||||
|
question: string;
|
||||||
|
field?: string;
|
||||||
|
options?: Array<{
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type RuleCheck = {
|
||||||
|
id: string;
|
||||||
|
description: string;
|
||||||
|
condition: unknown;
|
||||||
|
failReason: string;
|
||||||
|
failMessage: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ActionRuleSet = {
|
||||||
|
actionType: string;
|
||||||
|
enabled: boolean;
|
||||||
|
checks: RuleCheck[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type SceneRulebook = {
|
||||||
|
id: string;
|
||||||
|
worldId: string;
|
||||||
|
version: number;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
rules: ActionRuleSet[];
|
||||||
|
createdAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RulebookListItem = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
version: number;
|
||||||
|
updatedAt: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
const starterPrompts = [
|
const starterPrompts = [
|
||||||
"look around",
|
"look around",
|
||||||
"open the gate",
|
"take key",
|
||||||
"talk to the groundskeeper",
|
"take lantern",
|
||||||
"go to the shed",
|
"give key to groundskeeper",
|
||||||
"pull out my phone",
|
"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> {
|
async function fetchJson<T>(input: RequestInfo, init?: RequestInit): Promise<T> {
|
||||||
const response = await fetch(input, init);
|
const response = await fetch(input, init);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -70,17 +158,208 @@ async function fetchJson<T>(input: RequestInfo, init?: RequestInit): Promise<T>
|
|||||||
return response.json() as Promise<T>;
|
return response.json() as Promise<T>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Rulebook editor component
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function RulebookEditor(props: { onSaved?: (rulebook: SceneRulebook) => void }) {
|
||||||
|
const [rulebook, setRulebook] = useState<SceneRulebook | null>(null);
|
||||||
|
const [drafts, setDrafts] = useState<Record<string, string>>({});
|
||||||
|
const [parseErrors, setParseErrors] = useState<Record<string, string>>({});
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [saveError, setSaveError] = useState<string | null>(null);
|
||||||
|
const [saved, setSaved] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void fetchJson<SceneRulebook>("/api/rulebook").then((rb) => {
|
||||||
|
setRulebook(rb);
|
||||||
|
const initial: Record<string, string> = {};
|
||||||
|
for (const ruleSet of rb.rules) {
|
||||||
|
initial[ruleSet.actionType] = JSON.stringify(ruleSet.checks, null, 2);
|
||||||
|
}
|
||||||
|
setDrafts(initial);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function toggleEnabled(actionType: string) {
|
||||||
|
if (!rulebook) return;
|
||||||
|
setRulebook({
|
||||||
|
...rulebook,
|
||||||
|
rules: rulebook.rules.map((r) =>
|
||||||
|
r.actionType === actionType ? { ...r, enabled: !r.enabled } : r
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateDraft(actionType: string, value: string) {
|
||||||
|
setDrafts((prev) => ({ ...prev, [actionType]: value }));
|
||||||
|
setParseErrors((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[actionType];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveRulebook() {
|
||||||
|
if (!rulebook) return;
|
||||||
|
setSaveError(null);
|
||||||
|
setSaved(false);
|
||||||
|
|
||||||
|
// Validate and apply all drafts.
|
||||||
|
const updatedRules: ActionRuleSet[] = [];
|
||||||
|
const newErrors: Record<string, string> = {};
|
||||||
|
|
||||||
|
for (const ruleSet of rulebook.rules) {
|
||||||
|
const draft = drafts[ruleSet.actionType] ?? JSON.stringify(ruleSet.checks, null, 2);
|
||||||
|
try {
|
||||||
|
const parsedChecks = JSON.parse(draft) as RuleCheck[];
|
||||||
|
updatedRules.push({ ...ruleSet, checks: parsedChecks });
|
||||||
|
} catch {
|
||||||
|
newErrors[ruleSet.actionType] = "Invalid JSON";
|
||||||
|
updatedRules.push(ruleSet);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(newErrors).length > 0) {
|
||||||
|
setParseErrors(newErrors);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const updated = await fetchJson<SceneRulebook>("/api/rulebook", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ ...rulebook, rules: updatedRules }),
|
||||||
|
});
|
||||||
|
setRulebook(updated);
|
||||||
|
props.onSaved?.(updated);
|
||||||
|
setSaved(true);
|
||||||
|
setTimeout(() => setSaved(false), 2500);
|
||||||
|
} catch (err) {
|
||||||
|
setSaveError(err instanceof Error ? err.message : "Save failed");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!rulebook) return <p>Loading rulebook…</p>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rulebook-editor">
|
||||||
|
<div className="rulebook-header">
|
||||||
|
<div>
|
||||||
|
<p className="rulebook-name">{rulebook.name}</p>
|
||||||
|
<p className="rulebook-desc">
|
||||||
|
Version {rulebook.version} | Updated {formatTurnTime(rulebook.updatedAt)}
|
||||||
|
</p>
|
||||||
|
{rulebook.description ? (
|
||||||
|
<p className="rulebook-desc">{rulebook.description}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void saveRulebook()}
|
||||||
|
disabled={saving}
|
||||||
|
className="save-btn"
|
||||||
|
>
|
||||||
|
{saving ? "Saving…" : saved ? "Saved" : "Save rulebook"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{saveError ? <p className="error-banner">{saveError}</p> : null}
|
||||||
|
|
||||||
|
<div className="rule-list">
|
||||||
|
{rulebook.rules.map((ruleSet) => (
|
||||||
|
<details key={ruleSet.actionType} className="rule-panel">
|
||||||
|
<summary className="rule-summary">
|
||||||
|
<span className="rule-action-type">{ruleSet.actionType}</span>
|
||||||
|
<span className="rule-check-count">{ruleSet.checks.length} check{ruleSet.checks.length !== 1 ? "s" : ""}</span>
|
||||||
|
<label
|
||||||
|
className="rule-toggle"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={ruleSet.enabled}
|
||||||
|
onChange={() => toggleEnabled(ruleSet.actionType)}
|
||||||
|
/>
|
||||||
|
{ruleSet.enabled ? "enforced" : "disabled"}
|
||||||
|
</label>
|
||||||
|
</summary>
|
||||||
|
|
||||||
|
<div className="rule-body">
|
||||||
|
<p className="rule-hint">
|
||||||
|
Edit the checks array below. Each check has: <code>id</code>, <code>description</code>, <code>condition</code>, <code>failReason</code>, <code>failMessage</code>.
|
||||||
|
</p>
|
||||||
|
<p className="rule-hint">
|
||||||
|
For character permissions, use <code>{`{"op":"actorIdIn","allowedIds":["player"]}`}</code> or <code>{`{"op":"actorNameIn","allowedNames":["Player"]}`}</code>.
|
||||||
|
</p>
|
||||||
|
<p className="rule-hint">
|
||||||
|
For conditional creation, use <code>{`{"op":"actionMetadataEq","key":"createIfMissing","value":true}`}</code> together with actor checks.
|
||||||
|
</p>
|
||||||
|
<p className="rule-hint">
|
||||||
|
For transfer ownership checks, use <code>{`{"op":"itemInInventory","itemMetadataKey":"itemId","holderRole":"actor"}`}</code>.
|
||||||
|
</p>
|
||||||
|
{ruleSet.checks.length > 0 ? (
|
||||||
|
<ul className="check-list">
|
||||||
|
{ruleSet.checks.map((check) => (
|
||||||
|
<li key={check.id} title={check.failReason}>
|
||||||
|
<span className="check-desc">{check.description}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<p className="rule-hint">No checks — action always passes.</p>
|
||||||
|
)}
|
||||||
|
<label className="json-label">
|
||||||
|
Checks JSON
|
||||||
|
<textarea
|
||||||
|
className={`json-editor${parseErrors[ruleSet.actionType] ? " json-error" : ""}`}
|
||||||
|
value={drafts[ruleSet.actionType] ?? ""}
|
||||||
|
onChange={(e) => updateDraft(ruleSet.actionType, e.target.value)}
|
||||||
|
rows={10}
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{parseErrors[ruleSet.actionType] ? (
|
||||||
|
<p className="error-banner">{parseErrors[ruleSet.actionType]}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Main app
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [snapshot, setSnapshot] = useState<Snapshot | null>(null);
|
const [snapshot, setSnapshot] = useState<AppSnapshot | null>(null);
|
||||||
const [latest, setLatest] = useState<TurnResult | null>(null);
|
const [latest, setLatest] = useState<ProcessTurnResponse | null>(null);
|
||||||
const [input, setInput] = useState("look around");
|
const [input, setInput] = useState("look around");
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [tab, setTab] = useState<"world" | "rulebook">("world");
|
||||||
|
const [activeRulebook, setActiveRulebook] = useState<SceneRulebook | null>(null);
|
||||||
|
const [rulebooks, setRulebooks] = useState<RulebookListItem[]>([]);
|
||||||
|
|
||||||
|
async function refreshRulebookState() {
|
||||||
|
const [active, list] = await Promise.all([
|
||||||
|
fetchJson<SceneRulebook>("/api/rulebook"),
|
||||||
|
fetchJson<RulebookListItem[]>("/api/rulebooks"),
|
||||||
|
]);
|
||||||
|
setActiveRulebook(active);
|
||||||
|
setRulebooks(list);
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void fetchJson<Snapshot>("/api/state")
|
void Promise.all([fetchJson<AppSnapshot>("/api/state"), refreshRulebookState()])
|
||||||
.then((data) => {
|
.then(([data]) => {
|
||||||
setSnapshot(data);
|
setSnapshot(data);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
})
|
})
|
||||||
@@ -96,13 +375,17 @@ export default function App() {
|
|||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await fetchJson<TurnResult>("/api/turn", {
|
const result = await fetchJson<ProcessTurnResponse>("/api/turn", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ input }),
|
body: JSON.stringify({ input }),
|
||||||
});
|
});
|
||||||
setLatest(result);
|
setLatest(result);
|
||||||
setSnapshot(result.snapshot);
|
const [nextSnapshot] = await Promise.all([
|
||||||
|
fetchJson<AppSnapshot>("/api/state"),
|
||||||
|
refreshRulebookState(),
|
||||||
|
]);
|
||||||
|
setSnapshot(nextSnapshot);
|
||||||
} catch (submitError) {
|
} catch (submitError) {
|
||||||
setError(submitError instanceof Error ? submitError.message : "Unknown error");
|
setError(submitError instanceof Error ? submitError.message : "Unknown error");
|
||||||
} finally {
|
} 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 (
|
return (
|
||||||
<main className="page-shell">
|
<main className="page-shell">
|
||||||
<section className="hero-panel">
|
<section className="hero-panel">
|
||||||
<p className="eyebrow">CharacterGarden</p>
|
<p className="eyebrow">CharacterGarden</p>
|
||||||
<h1>Bootable narrative sandbox</h1>
|
<h1>Bootable narrative sandbox</h1>
|
||||||
<p className="lede">
|
<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>
|
</p>
|
||||||
|
|
||||||
<form className="turn-form" onSubmit={onSubmit}>
|
<form className="turn-form" onSubmit={onSubmit}>
|
||||||
@@ -132,6 +442,9 @@ export default function App() {
|
|||||||
<button type="submit" disabled={submitting}>
|
<button type="submit" disabled={submitting}>
|
||||||
{submitting ? "Submitting..." : "Run turn"}
|
{submitting ? "Submitting..." : "Run turn"}
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" className="chip" onClick={onReset}>
|
||||||
|
Reset world
|
||||||
|
</button>
|
||||||
<div className="chips">
|
<div className="chips">
|
||||||
{starterPrompts.map((prompt) => (
|
{starterPrompts.map((prompt) => (
|
||||||
<button key={prompt} type="button" className="chip" onClick={() => setInput(prompt)}>
|
<button key={prompt} type="button" className="chip" onClick={() => setInput(prompt)}>
|
||||||
@@ -145,8 +458,75 @@ export default function App() {
|
|||||||
{latest ? (
|
{latest ? (
|
||||||
<section className="result-card">
|
<section className="result-card">
|
||||||
<h2>Latest result</h2>
|
<h2>Latest result</h2>
|
||||||
<p>{latest.narration}</p>
|
<p><strong>Input:</strong> {latest.rawText}</p>
|
||||||
<pre>{JSON.stringify({ actions: latest.actions, rejected: latest.rejected, latent: latest.latent_resolution }, null, 0)}</pre>
|
<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>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@@ -154,60 +534,107 @@ export default function App() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="inspector-grid">
|
<section className="inspector-grid">
|
||||||
<article className="panel">
|
<nav className="tab-bar">
|
||||||
<h2>World state</h2>
|
<button
|
||||||
{loading && !snapshot ? <p>Loading...</p> : null}
|
type="button"
|
||||||
<ul className="entity-list">
|
className={`tab-btn${tab === "world" ? " active" : ""}`}
|
||||||
{snapshot?.entities.map((entity) => (
|
onClick={() => setTab("world")}
|
||||||
<li key={entity.id}>
|
>
|
||||||
<strong>{entity.name}</strong> <span>{entity.type}</span>
|
World inspector
|
||||||
<pre>{JSON.stringify(entity.attributes, null, 0)}</pre>
|
</button>
|
||||||
</li>
|
<button
|
||||||
))}
|
type="button"
|
||||||
</ul>
|
className={`tab-btn${tab === "rulebook" ? " active" : ""}`}
|
||||||
</article>
|
onClick={() => setTab("rulebook")}
|
||||||
|
>
|
||||||
|
Rulebook
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
<article className="panel">
|
{tab === "world" ? (
|
||||||
<h2>Turn log</h2>
|
<>
|
||||||
<ul className="timeline-list">
|
<article className="panel">
|
||||||
{snapshot?.turns.slice().reverse().map((turn) => (
|
<h2>World state</h2>
|
||||||
<li key={turn.id}>
|
{loading && !snapshot ? <p>Loading...</p> : null}
|
||||||
<strong>Turn {turn.turn}:</strong> {turn.input} → {turn.output}
|
{snapshot ? (
|
||||||
</li>
|
<div className="meta-grid">
|
||||||
))}
|
<p className="meta-kv"><strong>World ID:</strong> {snapshot.worldState.id}</p>
|
||||||
</ul>
|
<p className="meta-kv"><strong>Created:</strong> {formatTurnTime(snapshot.worldState.createdAt)}</p>
|
||||||
</article>
|
<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">
|
||||||
|
{entities.map((entity) => (
|
||||||
|
<li key={entity.id}>
|
||||||
|
<strong>{entity.name}</strong> <span>{entity.type}</span>
|
||||||
|
<pre>{JSON.stringify(entity.attributes, null, 0)}</pre>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</article>
|
||||||
|
|
||||||
<article className="panel">
|
<article className="panel">
|
||||||
<h2>Events</h2>
|
<h2>Turn log</h2>
|
||||||
<ul className="timeline-list compact">
|
<ul className="timeline-list">
|
||||||
{snapshot?.events.slice().reverse().map((event) => (
|
{turns.map((turn) => (
|
||||||
<li key={event.id}>
|
<li key={turn.id}>
|
||||||
<strong>{event.result}:</strong> <pre>{JSON.stringify(event.action, null, 0)}</pre>
|
<strong>{turn.rawText}</strong>
|
||||||
</li>
|
<span className="turn-time"> at {formatTurnTime(turn.createdAt)}</span>
|
||||||
))}
|
{turn.interpreter ? (
|
||||||
</ul>
|
<span>
|
||||||
</article>
|
{" "}[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">
|
<article className="panel">
|
||||||
<h2>Beliefs & summaries</h2>
|
<h2>System</h2>
|
||||||
<h3>Beliefs</h3>
|
{activeRulebook ? (
|
||||||
<ul className="timeline-list compact">
|
<div className="meta-grid">
|
||||||
{snapshot?.beliefs.map((belief) => (
|
<p className="meta-kv"><strong>Active rulebook:</strong> {activeRulebook.name}</p>
|
||||||
<li key={`${belief.entity_id}-${belief.claim}`}>
|
<p className="meta-kv"><strong>Rulebook ID:</strong> {activeRulebook.id}</p>
|
||||||
<strong>{belief.entity_id}:</strong> {belief.claim} ({belief.confidence})
|
<p className="meta-kv"><strong>Rulebook version:</strong> {activeRulebook.version}</p>
|
||||||
</li>
|
<p className="meta-kv"><strong>Updated:</strong> {formatTurnTime(activeRulebook.updatedAt)}</p>
|
||||||
))}
|
<p className="meta-kv"><strong>Saved rulebooks:</strong> {rulebooks.length}</p>
|
||||||
</ul>
|
</div>
|
||||||
<h3>Summaries</h3>
|
) : (
|
||||||
<ul className="timeline-list compact">
|
<p>Loading rulebook info...</p>
|
||||||
{snapshot?.summaries.map((summary) => (
|
)}
|
||||||
<li key={summary.id}>
|
{rulebooks.length > 0 ? (
|
||||||
<strong>{summary.turn_range.join("-")}:</strong> {summary.text}
|
<ul className="timeline-list compact">
|
||||||
</li>
|
{rulebooks.map((rb) => (
|
||||||
))}
|
<li key={rb.id}>
|
||||||
</ul>
|
<strong>{rb.name}</strong> ({rb.id}) v{rb.version} - updated {formatTurnTime(rb.updatedAt)}
|
||||||
</article>
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : null}
|
||||||
|
</article>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<article className="panel panel--full">
|
||||||
|
<h2>Rulebook editor</h2>
|
||||||
|
<RulebookEditor onSaved={(rulebook) => setActiveRulebook(rulebook)} />
|
||||||
|
</article>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -161,11 +161,219 @@ pre {
|
|||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.meta-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 6px 12px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-kv {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.86rem;
|
||||||
|
color: rgba(244, 239, 228, 0.86);
|
||||||
|
}
|
||||||
|
|
||||||
.error-banner {
|
.error-banner {
|
||||||
margin-top: 16px;
|
margin-top: 16px;
|
||||||
color: #ffd2b8;
|
color: #ffd2b8;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.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) {
|
@media (max-width: 900px) {
|
||||||
.inspector-grid {
|
.inspector-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
@@ -180,4 +388,19 @@ pre {
|
|||||||
.panel {
|
.panel {
|
||||||
padding: 14px;
|
padding: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.meta-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.turn-time {
|
||||||
|
opacity: 0.7;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.turn-diagnostics {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
color: rgba(244, 239, 228, 0.74);
|
||||||
|
font-size: 0.82rem;
|
||||||
}
|
}
|
||||||
@@ -4,15 +4,16 @@ import react from "@vitejs/plugin-react";
|
|||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
server: {
|
server: {
|
||||||
|
allowedHosts: ["beepc","cg.sketchferret.com"],
|
||||||
host: "0.0.0.0",
|
host: "0.0.0.0",
|
||||||
port: 5173,
|
port: 5173,
|
||||||
proxy: {
|
proxy: {
|
||||||
"/api": {
|
"/api": {
|
||||||
target: "http://app:3000",
|
target: "http://app:3024",
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
},
|
},
|
||||||
"/health": {
|
"/health": {
|
||||||
target: "http://app:3000",
|
target: "http://app:3024",
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
433
project.md
433
project.md
@@ -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
|
||||||
|
|
||||||
```
|
## Action Contracts (Current)
|
||||||
Prose Input
|
|
||||||
→ Intent Extraction
|
|
||||||
→ Canonical Actions
|
|
||||||
→ Truth Engine Validation
|
|
||||||
→ State Changes + Events
|
|
||||||
→ Memory Storage
|
|
||||||
→ Narration Output
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
- 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.
|
||||||
|
|
||||||
```
|
Key capabilities already implemented:
|
||||||
Entity {
|
|
||||||
id: string
|
|
||||||
type: string
|
|
||||||
name: string
|
|
||||||
attributes: object
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 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)
|
||||||
|
|
||||||
```
|
This supports:
|
||||||
Action {
|
|
||||||
actor: string (entity id)
|
|
||||||
verb: string (enum)
|
|
||||||
target?: string (entity id)
|
|
||||||
params?: object
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
```
|
## Turn Execution (Current)
|
||||||
move, open, close, take, drop, use, inspect, speak
|
|
||||||
```
|
|
||||||
|
|
||||||
### 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
|
||||||
|
|
||||||
```
|
## Interpreter + Turn Manager (New)
|
||||||
ValidationResult {
|
|
||||||
accepted: Action[]
|
|
||||||
rejected: { action: Action, reason: string }[]
|
|
||||||
state_changes: StateChange[]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 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
|
||||||
|
|
||||||
```
|
Current interpreter statuses:
|
||||||
StateChange {
|
|
||||||
entity_id: string
|
|
||||||
field: string
|
|
||||||
old_value: any
|
|
||||||
new_value: any
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2.5 Event
|
- resolved
|
||||||
|
- needs_clarification
|
||||||
|
- rejected
|
||||||
|
|
||||||
```
|
## Current Domain Behaviors
|
||||||
Event {
|
|
||||||
id: string
|
|
||||||
turn: number
|
|
||||||
action: Action
|
|
||||||
result: "success" | "fail"
|
|
||||||
timestamp: number
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
- 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
|
SQLite tables already backing turns and world snapshots:
|
||||||
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
|
|
||||||
|
|
||||||
---
|
- 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)
|
3. Testing depth
|
||||||
|
- Expand Docker-executed integration tests for:
|
||||||
#### 4.4 Belief
|
- createIfMissing authorization matrix
|
||||||
|
- transfer ownership/location checks
|
||||||
```
|
- unresolved clarification flows
|
||||||
Belief {
|
- multi-action turn behavior
|
||||||
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:
|
|
||||||
|
|
||||||
* 1–2 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
|
|
||||||
|
|||||||
140
thoughts.md
140
thoughts.md
@@ -1,45 +1,107 @@
|
|||||||
# thoughts.md
|
# thoughts.md
|
||||||
|
|
||||||
## Current Status
|
## Documentation Sync
|
||||||
- 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
|
|
||||||
|
|
||||||
## Current Architecture Decisions
|
- Implementation plan refreshed in Implementation_plan.md to match current codebase state.
|
||||||
- App: Node.js + Fastify + TypeScript
|
- Next executable phase is Phase 1: Intent Interpreter Boundary Hardening.
|
||||||
- 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
|
|
||||||
|
|
||||||
## Next Steps
|
## Current Snapshot (April 2026)
|
||||||
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
|
|
||||||
|
|
||||||
## Open Questions
|
### What is now working
|
||||||
- Should room/location be an Entity attribute or a separate entity type?
|
|
||||||
- What is the initial world state for the MVP (1–2 rooms, ≤3 characters)?
|
|
||||||
- Should latent personal-item plausibility live only on actor attributes, or also look at worn item/container entities?
|
|
||||||
|
|
||||||
## Session Notes
|
- Rulebook-driven validation is active and editable through API/frontend.
|
||||||
- 2026-04-23: Project started. Scaffold, type contracts, .gitignore, and .env.example created.
|
- Character authorization rules are in place (actorIdIn / actorNameIn).
|
||||||
- 2026-04-23: Truth Engine implemented. Pure validation with per-verb handlers and immutable applyChanges helper.
|
- take supports createIfMissing, gated by rulebook permissions.
|
||||||
- 2026-04-23: Added facts/affordances + latent entity resolver for improv-style personal items, plus offscene room support.
|
- transfer action is live with ownership + recipient + location validation.
|
||||||
- 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.
|
- Turn processing now goes through a dedicated turn manager layer.
|
||||||
- 2026-04-23: Added minimal backend/frontend boot slice so the project can be tested visually through Docker.
|
- 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.
|
||||||
|
|||||||
Reference in New Issue
Block a user