Compare commits

...

14 Commits

Author SHA1 Message Date
28229d8d69 fix: update app port to 3024 in docker-compose.yml and vite.config.ts
Co-authored-by: Copilot <copilot@github.com>
2026-04-27 15:58:41 -04:00
81e2a7828f fix: correct allowedHosts configuration format in vite.config.ts 2026-04-27 01:06:54 -04:00
b4a2968399 fix: update allowedHosts configuration in vite.config.ts to 'beepc' 2026-04-27 00:57:52 -04:00
3112b6e9fe fix: add allowedHosts configuration in vite.config.ts
Co-authored-by: Copilot <copilot@github.com>
2026-04-27 00:54:55 -04:00
d38c799b27 fix: update app and frontend port mappings to 3023 in .env.example and docker-compose.yml
Co-authored-by: Copilot <copilot@github.com>
2026-04-27 00:51:01 -04:00
7a022bc085 fix: update app port in .env and .env.example to 3024 and 3023 respectively 2026-04-27 00:47:47 -04:00
665646bc18 fix: correct app port mapping in docker-compose.yml to use 3000 2026-04-27 00:46:10 -04:00
56c9cce4c7 fix: correct app port mapping in docker-compose.yml to use 3000 2026-04-27 00:44:57 -04:00
76dee7e73f fix: update app port mapping in docker-compose.yml from 3000 to 3023
Co-authored-by: Copilot <copilot@github.com>
2026-04-27 00:36:21 -04:00
ca49565117 fix: correct app port mapping in docker-compose.yml to match environment variable
Co-authored-by: Copilot <copilot@github.com>
2026-04-27 00:31:25 -04:00
5189446c73 fix: update app port mapping in docker-compose.yml from 3000 to 3023 2026-04-27 00:15:17 -04:00
0da62785d5 feat: refactor turn processing and world state management; remove obsolete files and enhance database interactions
Co-authored-by: Copilot <copilot@github.com>
2026-04-27 00:09:11 -04:00
c32fa977a8 feat: add database reset functionality and implement rulebook tests 2026-04-26 16:27:26 -04:00
fca69d3cb5 feat: enhance turn display with time and diagnostics styling 2026-04-26 14:15:46 -04:00
23 changed files with 647 additions and 255 deletions

View File

@@ -30,6 +30,15 @@ Status: COMPLETE
- 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

View File

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

View File

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

View File

@@ -1,12 +1,10 @@
import { randomUUID } from "node:crypto";
import { createDatabase, CharacterGardenDatabase } from "./db";
import type { Entity } from "./contracts/entity";
import type { SceneRulebook } from "./contracts/rulebook";
import type { Turn } from "./contracts/turn";
import type { WorldState } from "./contracts/world";
import { createDefaultRulebook, DEFAULT_RULEBOOK_ID } from "./defaultRulebook";
import { processTurn, ProcessTurnResponse } from "./turns/processTurn";
import { runTurnManager, TurnManagerResponse } from "./turns/turnManager";
import { ensureSeedState } from "./world/seedWorld";
export interface AppSnapshot {
worldState: WorldState;
@@ -16,170 +14,13 @@ export interface AppSnapshot {
export interface CharacterGardenApp {
db: CharacterGardenDatabase;
getSnapshot(): AppSnapshot;
processTurn(rawText: string): Promise<ProcessTurnResponse>;
processTurn(rawText: string): Promise<TurnManagerResponse>;
getRulebook(): SceneRulebook;
upsertRulebook(rulebook: SceneRulebook): SceneRulebook;
listRulebooks(): SceneRulebook[];
reset(): AppSnapshot;
}
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,
};
}
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,
};
}
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;
}
function ensureDefaultRulebook(
db: CharacterGardenDatabase,
worldState: WorldState
@@ -221,7 +62,7 @@ export function createCharacterGardenApp(dbPath: string): CharacterGardenApp {
async processTurn(rawText: string) {
const rulebook = loadActiveRulebook();
const result = await processTurn(rawText, worldState, db, rulebook);
const result = await runTurnManager(rawText, worldState, db, rulebook);
worldState = result.worldState;
return result;
},
@@ -238,6 +79,9 @@ export function createCharacterGardenApp(dbPath: string): CharacterGardenApp {
};
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;
},

View File

@@ -3,7 +3,7 @@
*
* 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 in truthEngine.ts.
* hardcoded logic previously in the truth engine as editable, data-driven rules.
*/
/** Which entity in the action context a condition refers to. */

View File

@@ -155,6 +155,18 @@ export function createDatabase(config: DatabaseConfig): CharacterGardenDatabase
ORDER BY created_at ASC
`);
const listAllActionsStatement = sqlite.prepare(`
SELECT turn_id, action_index, actor_id, type, target_id, location_id, metadata_json
FROM actions
ORDER BY turn_id, action_index ASC
`);
const listAllValidationStatement = sqlite.prepare(`
SELECT turn_id, action_index, success, reason, message
FROM validation_results
ORDER BY turn_id, action_index ASC
`);
const listInterpreterEventsStatement = sqlite.prepare(`
SELECT turn_id, interpreter_json
FROM interpreter_events
@@ -262,6 +274,7 @@ export function createDatabase(config: DatabaseConfig): CharacterGardenDatabase
DELETE FROM world_states;
DELETE FROM turns;
DELETE FROM entities;
DELETE FROM rulebooks;
`);
},
@@ -321,11 +334,52 @@ export function createDatabase(config: DatabaseConfig): CharacterGardenDatabase
interpreterByTurnId.set(row.turn_id, parseJson<InterpreterOutput>(row.interpreter_json));
}
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: [],
validation: [],
actions: actionsByTurnId.get(row.id) ?? [],
validation: validationByTurnId.get(row.id) ?? [],
createdAt: row.created_at,
interpreter: interpreterByTurnId.get(row.id),
}));

View File

@@ -4,7 +4,7 @@ export const DEFAULT_RULEBOOK_ID = "rulebook_default";
/**
* Builds the default SceneRulebook, encoding all validation logic that was
* previously hardcoded in truthEngine.ts as editable, data-driven rules.
* previously hardcoded in the truth engine as editable, data-driven rules.
*/
export function createDefaultRulebook(worldId: string): SceneRulebook {
const now = Date.now();

View File

@@ -1,5 +1,6 @@
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";
@@ -140,20 +141,98 @@ function toReasonCode(value: string | undefined):
}
}
/**
* 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.",
"Return ONLY JSON with this shape:",
'{"status":"resolved|needs_clarification|rejected","selectedActions":[{"type":"inspect|move|take|open|introduce|describe|transfer","targetId":"optional","locationId":"optional","metadata":{"optional":"object"}}],"selectedConfidence":0.0,"clarification":{"reasonCode":"UNRECOGNIZED_INTENT|AMBIGUOUS_REFERENCE|EMPTY_INPUT|LOW_CONFIDENCE|INTERNAL_INVALID_OUTPUT","question":"string","field":"verb|target|item|recipient|location"},"rationale":"brief"}',
"If unresolved, selectedActions must be an empty array and clarification must be present.",
"Use canonical action types only. Do not invent fields.",
].join(" ");
"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}`,
`input: ${JSON.stringify(input.rawText)}`,
`minimum_confidence: ${input.minConfidence}`,
].join("\n");
`world_context:\n${worldContext}`,
`player_input: ${JSON.stringify(input.rawText)}`,
].join("\n\n");
return { system, user };
}

View File

@@ -1,4 +1,5 @@
import type { InterpreterOutput } from "../contracts/intent";
import type { WorldState } from "../contracts/world";
import { resolveDeterministicIntent } from "./adapters/deterministicResolver";
import { resolveLlmIntent } from "./adapters/llmResolver";
import {
@@ -11,6 +12,7 @@ const DEFAULT_MIN_CONFIDENCE = 0.65;
type InterpretTurnOptions = {
mode?: ResolverMode;
minConfidence?: number;
worldState?: WorldState;
};
function getResolverMode(options?: InterpretTurnOptions): ResolverMode {
@@ -25,6 +27,7 @@ function buildInput(rawText: string, actorId: string, options?: InterpretTurnOpt
rawText,
actorId,
minConfidence: options?.minConfidence ?? DEFAULT_MIN_CONFIDENCE,
worldState: options?.worldState,
};
}

View File

@@ -1,4 +1,5 @@
import type { InterpreterOutput } from "../contracts/intent";
import type { WorldState } from "../contracts/world";
export type ResolverMode = "deterministic" | "llm" | "hybrid";
@@ -6,6 +7,8 @@ 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 = {

View File

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

View File

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

View File

@@ -1,21 +0,0 @@
import type { Action } from "./contracts/action";
import type { SceneRulebook } from "./contracts/rulebook";
import type { ValidationResult } from "./contracts/validation";
import type { WorldState } from "./contracts/world";
import { createDefaultRulebook } from "./defaultRulebook";
import { validateWithRulebook } from "./rulebookEngine";
/**
* Validate a list of parsed actions against the world state.
*
* Pass a SceneRulebook to use data-driven scene rules.
* Falls back to the built-in default rulebook when none is provided.
*/
export function validateActions(
actions: Action[],
worldState: WorldState,
rulebook?: SceneRulebook
): ValidationResult[] {
const activeRulebook = rulebook ?? createDefaultRulebook(worldState.id);
return validateWithRulebook(actions, worldState, activeRulebook);
}

View File

@@ -1,24 +0,0 @@
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 { ValidationResult } from "../contracts/validation";
import type { WorldState } from "../contracts/world";
import { runTurnManager } from "./turnManager";
export type ProcessTurnResponse = {
rawText: string;
actions: Action[];
validation: ValidationResult[];
worldState: WorldState;
interpreter: InterpreterOutput;
};
export async function processTurn(
rawText: string,
worldState: WorldState,
db: CharacterGardenDatabase,
rulebook?: SceneRulebook
): Promise<ProcessTurnResponse> {
return runTurnManager(rawText, worldState, db, rulebook);
}

View File

@@ -9,7 +9,8 @@ import type { ValidationResult } from "../contracts/validation";
import type { WorldState } from "../contracts/world";
import { interpretTurn } from "../interpreter/interpretTurn";
import { validateInterpreterOutput } from "../interpreter/validateInterpreterOutput";
import { validateActions } from "../truthEngine";
import { createDefaultRulebook } from "../defaultRulebook";
import { validateWithRulebook } from "../rulebookEngine";
import { applyActions } from "../world/applyActions";
export type TurnManagerResponse = {
@@ -39,7 +40,7 @@ export async function runTurnManager(
db: CharacterGardenDatabase,
rulebook?: SceneRulebook
): Promise<TurnManagerResponse> {
const interpreted = await interpretTurn(rawText, "player");
const interpreted = await interpretTurn(rawText, "player", { worldState });
const boundaryCheck = validateInterpreterOutput(interpreted);
const interpreter: InterpreterOutput = boundaryCheck.isValid
? interpreted
@@ -84,7 +85,8 @@ export async function runTurnManager(
}
const actions = interpreter.selectedActions;
const validation = validateActions(actions, worldState, rulebook);
const activeRulebook = rulebook ?? createDefaultRulebook(worldState.id);
const validation = validateWithRulebook(actions, worldState, activeRulebook);
const nextWorldState = applyActions(actions, validation, worldState);
const turn: Turn = {

View File

@@ -109,9 +109,9 @@ export function applyActions(
case "take":
if (actor && target) {
target.attributes.location = `inventory:${actor.id}`;
if (target.id === "key_1") {
actor.attributes.has_key_1 = true;
}
// 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,

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
import { FormEvent, useEffect, useState } from "react";
import { FormEvent, useEffect, useMemo, useState } from "react";
type Entity = {
id: string;
@@ -28,7 +28,27 @@ type Turn = {
validation: ValidationResult[];
createdAt: number;
interpreter?: {
interpreterVersion: string;
resolutionSource: "deterministic" | "llm" | "hybrid";
minConfidence: number;
status: "resolved" | "needs_clarification" | "rejected";
selectedConfidence?: number;
candidates?: Array<{
action: Action;
confidence: number;
rationale?: string;
}>;
diagnostics: string[];
clarification?: {
reasonCode: string;
question: string;
field?: string;
options?: Array<{
id: string;
label: string;
value: string;
}>;
};
};
};
@@ -51,12 +71,20 @@ type ProcessTurnResponse = {
worldState: WorldState;
interpreter: {
interpreterVersion: string;
resolutionSource: "deterministic" | "llm" | "hybrid";
minConfidence: number;
status: "resolved" | "needs_clarification" | "rejected";
selectedConfidence?: number;
candidates: Array<{
action: Action;
confidence: number;
rationale?: string;
}>;
diagnostics: string[];
clarification?: {
reasonCode: string;
question: string;
field?: string;
options?: Array<{
id: string;
label: string;
@@ -91,14 +119,37 @@ type SceneRulebook = {
updatedAt: number;
};
type RulebookListItem = {
id: string;
name: string;
version: number;
updatedAt: number;
};
const starterPrompts = [
"look around",
"take key",
"take lantern",
"give key to groundskeeper",
"introduce jeff",
"describe groundskeeper as patient",
"open door",
"move to exit",
];
function formatConfidence(value: number | undefined): string {
if (typeof value !== "number") return "n/a";
return `${Math.round(value * 100)}%`;
}
function formatTurnTime(epochMs: number): string {
try {
return new Date(epochMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
} catch {
return String(epochMs);
}
}
async function fetchJson<T>(input: RequestInfo, init?: RequestInit): Promise<T> {
const response = await fetch(input, init);
if (!response.ok) {
@@ -111,7 +162,7 @@ async function fetchJson<T>(input: RequestInfo, init?: RequestInit): Promise<T>
// Rulebook editor component
// ---------------------------------------------------------------------------
function RulebookEditor() {
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>>({});
@@ -182,6 +233,7 @@ function RulebookEditor() {
body: JSON.stringify({ ...rulebook, rules: updatedRules }),
});
setRulebook(updated);
props.onSaved?.(updated);
setSaved(true);
setTimeout(() => setSaved(false), 2500);
} catch (err) {
@@ -198,6 +250,9 @@ function RulebookEditor() {
<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}
@@ -290,10 +345,21 @@ export default function App() {
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [tab, setTab] = useState<"world" | "rulebook">("world");
const [activeRulebook, setActiveRulebook] = useState<SceneRulebook | null>(null);
const [rulebooks, setRulebooks] = useState<RulebookListItem[]>([]);
async function refreshRulebookState() {
const [active, list] = await Promise.all([
fetchJson<SceneRulebook>("/api/rulebook"),
fetchJson<RulebookListItem[]>("/api/rulebooks"),
]);
setActiveRulebook(active);
setRulebooks(list);
}
useEffect(() => {
void fetchJson<AppSnapshot>("/api/state")
.then((data) => {
void Promise.all([fetchJson<AppSnapshot>("/api/state"), refreshRulebookState()])
.then(([data]) => {
setSnapshot(data);
setLoading(false);
})
@@ -315,7 +381,10 @@ export default function App() {
body: JSON.stringify({ input }),
});
setLatest(result);
const nextSnapshot = await fetchJson<AppSnapshot>("/api/state");
const [nextSnapshot] = await Promise.all([
fetchJson<AppSnapshot>("/api/state"),
refreshRulebookState(),
]);
setSnapshot(nextSnapshot);
} catch (submitError) {
setError(submitError instanceof Error ? submitError.message : "Unknown error");
@@ -334,12 +403,22 @@ export default function App() {
});
setSnapshot(result);
setLatest(null);
await refreshRulebookState();
} catch (resetError) {
setError(resetError instanceof Error ? resetError.message : "Unknown error");
}
}
const entities = snapshot ? Object.values(snapshot.worldState.entities) : [];
const turns = useMemo(() => snapshot?.turns.slice().reverse() ?? [], [snapshot]);
function applyClarificationOption(optionValue: string) {
setInput((prev) => {
const trimmed = prev.trim();
if (!trimmed) return optionValue;
return `${trimmed} ${optionValue}`;
});
}
return (
<main className="page-shell">
@@ -382,20 +461,32 @@ export default function App() {
<p><strong>Input:</strong> {latest.rawText}</p>
<p>
<strong>Interpreter:</strong> {latest.interpreter.status}
{typeof latest.interpreter.selectedConfidence === "number"
? ` (${Math.round(latest.interpreter.selectedConfidence * 100)}% confidence)`
: ""}
{` 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:</strong> {latest.interpreter.clarification.question}
<strong>Clarification ({latest.interpreter.clarification.reasonCode}):</strong>{" "}
{latest.interpreter.clarification.question}
</p>
) : null}
{latest.interpreter.clarification?.options?.length ? (
<p>
<strong>Options:</strong>{" "}
{latest.interpreter.clarification.options.map((o) => o.label).join(", ")}
</p>
<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) => (
@@ -417,6 +508,25 @@ export default function App() {
<strong>Diagnostics:</strong> {latest.interpreter.diagnostics.join(" | ")}
</p>
) : null}
{latest.actions.length > 0 ? (
<details>
<summary>Selected actions ({latest.actions.length})</summary>
<pre>{JSON.stringify(latest.actions, null, 2)}</pre>
</details>
) : null}
{latest.interpreter.candidates.length > 0 ? (
<details>
<summary>Interpreter candidates ({latest.interpreter.candidates.length})</summary>
<ul className="timeline-list compact">
{latest.interpreter.candidates.map((candidate, index) => (
<li key={`${candidate.action.type}-${index}`}>
{candidate.action.type} at {formatConfidence(candidate.confidence)}
{candidate.rationale ? ` - ${candidate.rationale}` : ""}
</li>
))}
</ul>
</details>
) : null}
</section>
) : null}
@@ -446,6 +556,14 @@ export default function App() {
<article className="panel">
<h2>World state</h2>
{loading && !snapshot ? <p>Loading...</p> : null}
{snapshot ? (
<div className="meta-grid">
<p className="meta-kv"><strong>World ID:</strong> {snapshot.worldState.id}</p>
<p className="meta-kv"><strong>Created:</strong> {formatTurnTime(snapshot.worldState.createdAt)}</p>
<p className="meta-kv"><strong>Domain:</strong> {String(snapshot.worldState.metadata?.domain ?? "unknown")}</p>
<p className="meta-kv"><strong>World schema:</strong> {String(snapshot.worldState.metadata?.version ?? "unknown")}</p>
</div>
) : null}
<ul className="entity-list">
{entities.map((entity) => (
<li key={entity.id}>
@@ -459,11 +577,25 @@ export default function App() {
<article className="panel">
<h2>Turn log</h2>
<ul className="timeline-list">
{snapshot?.turns.slice().reverse().map((turn) => (
{turns.map((turn) => (
<li key={turn.id}>
<strong>{turn.rawText}</strong>
<span className="turn-time"> at {formatTurnTime(turn.createdAt)}</span>
{turn.interpreter ? (
<span> [interp:{turn.interpreter.status}]</span>
<span>
{" "}[interp:{turn.interpreter.status} via {turn.interpreter.resolutionSource};
conf {formatConfidence(turn.interpreter.selectedConfidence)}]
</span>
) : null}
{turn.interpreter?.clarification ? (
<p className="parser-hint">
Clarification ({turn.interpreter.clarification.reasonCode}): {turn.interpreter.clarification.question}
</p>
) : null}
{turn.interpreter?.diagnostics?.length ? (
<p className="turn-diagnostics">
Diagnostics: {turn.interpreter.diagnostics.join(" | ")}
</p>
) : null}
{turn.validation.map((v) => (
<span key={v.actionIndex}> [{v.success ? "ok" : v.reason}]</span>
@@ -472,11 +604,35 @@ export default function App() {
))}
</ul>
</article>
<article className="panel">
<h2>System</h2>
{activeRulebook ? (
<div className="meta-grid">
<p className="meta-kv"><strong>Active rulebook:</strong> {activeRulebook.name}</p>
<p className="meta-kv"><strong>Rulebook ID:</strong> {activeRulebook.id}</p>
<p className="meta-kv"><strong>Rulebook version:</strong> {activeRulebook.version}</p>
<p className="meta-kv"><strong>Updated:</strong> {formatTurnTime(activeRulebook.updatedAt)}</p>
<p className="meta-kv"><strong>Saved rulebooks:</strong> {rulebooks.length}</p>
</div>
) : (
<p>Loading rulebook info...</p>
)}
{rulebooks.length > 0 ? (
<ul className="timeline-list compact">
{rulebooks.map((rb) => (
<li key={rb.id}>
<strong>{rb.name}</strong> ({rb.id}) v{rb.version} - updated {formatTurnTime(rb.updatedAt)}
</li>
))}
</ul>
) : null}
</article>
</>
) : (
<article className="panel panel--full">
<h2>Rulebook editor</h2>
<RulebookEditor />
<RulebookEditor onSaved={(rulebook) => setActiveRulebook(rulebook)} />
</article>
)}
</section>

View File

@@ -161,6 +161,19 @@ pre {
padding: 8px 10px;
}
.meta-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 6px 12px;
margin-bottom: 10px;
}
.meta-kv {
margin: 0;
font-size: 0.86rem;
color: rgba(244, 239, 228, 0.86);
}
.error-banner {
margin-top: 16px;
color: #ffd2b8;
@@ -375,4 +388,19 @@ pre {
.panel {
padding: 14px;
}
.meta-grid {
grid-template-columns: 1fr;
}
}
.turn-time {
opacity: 0.7;
font-size: 0.82rem;
}
.turn-diagnostics {
margin: 6px 0 0;
color: rgba(244, 239, 228, 0.74);
font-size: 0.82rem;
}

View File

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

View File

@@ -18,6 +18,18 @@
- 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
@@ -58,6 +70,13 @@
- 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