Compare commits
14 Commits
fc10e46ccc
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 28229d8d69 | |||
| 81e2a7828f | |||
| b4a2968399 | |||
| 3112b6e9fe | |||
| d38c799b27 | |||
| 7a022bc085 | |||
| 665646bc18 | |||
| 56c9cce4c7 | |||
| 76dee7e73f | |||
| ca49565117 | |||
| 5189446c73 | |||
| 0da62785d5 | |||
| c32fa977a8 | |||
| fca69d3cb5 |
@@ -30,6 +30,15 @@ Status: COMPLETE
|
|||||||
- Interpreter contract and first interpreter module created
|
- Interpreter contract and first interpreter module created
|
||||||
- Docker container builds are passing for app and frontend
|
- 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
|
# Phase 1 — Intent Interpreter Boundary Hardening
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
|
||||||
|
|
||||||
import { createDatabase, CharacterGardenDatabase } from "./db";
|
import { createDatabase, CharacterGardenDatabase } from "./db";
|
||||||
import type { Entity } from "./contracts/entity";
|
|
||||||
import type { SceneRulebook } from "./contracts/rulebook";
|
import type { SceneRulebook } from "./contracts/rulebook";
|
||||||
import type { Turn } from "./contracts/turn";
|
import type { Turn } from "./contracts/turn";
|
||||||
import type { WorldState } from "./contracts/world";
|
import type { WorldState } from "./contracts/world";
|
||||||
import { createDefaultRulebook, DEFAULT_RULEBOOK_ID } from "./defaultRulebook";
|
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 {
|
export interface AppSnapshot {
|
||||||
worldState: WorldState;
|
worldState: WorldState;
|
||||||
@@ -16,170 +14,13 @@ export interface AppSnapshot {
|
|||||||
export interface CharacterGardenApp {
|
export interface CharacterGardenApp {
|
||||||
db: CharacterGardenDatabase;
|
db: CharacterGardenDatabase;
|
||||||
getSnapshot(): AppSnapshot;
|
getSnapshot(): AppSnapshot;
|
||||||
processTurn(rawText: string): Promise<ProcessTurnResponse>;
|
processTurn(rawText: string): Promise<TurnManagerResponse>;
|
||||||
getRulebook(): SceneRulebook;
|
getRulebook(): SceneRulebook;
|
||||||
upsertRulebook(rulebook: SceneRulebook): SceneRulebook;
|
upsertRulebook(rulebook: SceneRulebook): SceneRulebook;
|
||||||
listRulebooks(): SceneRulebook[];
|
listRulebooks(): SceneRulebook[];
|
||||||
reset(): AppSnapshot;
|
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(
|
function ensureDefaultRulebook(
|
||||||
db: CharacterGardenDatabase,
|
db: CharacterGardenDatabase,
|
||||||
worldState: WorldState
|
worldState: WorldState
|
||||||
@@ -221,7 +62,7 @@ export function createCharacterGardenApp(dbPath: string): CharacterGardenApp {
|
|||||||
|
|
||||||
async processTurn(rawText: string) {
|
async processTurn(rawText: string) {
|
||||||
const rulebook = loadActiveRulebook();
|
const rulebook = loadActiveRulebook();
|
||||||
const result = await processTurn(rawText, worldState, db, rulebook);
|
const result = await runTurnManager(rawText, worldState, db, rulebook);
|
||||||
worldState = result.worldState;
|
worldState = result.worldState;
|
||||||
return result;
|
return result;
|
||||||
},
|
},
|
||||||
@@ -238,6 +79,9 @@ export function createCharacterGardenApp(dbPath: string): CharacterGardenApp {
|
|||||||
};
|
};
|
||||||
db.upsertRulebook(updated);
|
db.upsertRulebook(updated);
|
||||||
activeRulebookId = updated.id;
|
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;
|
return updated;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
*
|
*
|
||||||
* Rules are stored per-scene in the database and evaluated by rulebookEngine.ts.
|
* 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
|
* 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. */
|
/** Which entity in the action context a condition refers to. */
|
||||||
|
|||||||
@@ -155,6 +155,18 @@ export function createDatabase(config: DatabaseConfig): CharacterGardenDatabase
|
|||||||
ORDER BY created_at ASC
|
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(`
|
const listInterpreterEventsStatement = sqlite.prepare(`
|
||||||
SELECT turn_id, interpreter_json
|
SELECT turn_id, interpreter_json
|
||||||
FROM interpreter_events
|
FROM interpreter_events
|
||||||
@@ -262,6 +274,7 @@ export function createDatabase(config: DatabaseConfig): CharacterGardenDatabase
|
|||||||
DELETE FROM world_states;
|
DELETE FROM world_states;
|
||||||
DELETE FROM turns;
|
DELETE FROM turns;
|
||||||
DELETE FROM entities;
|
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));
|
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) => ({
|
return rows.map((row) => ({
|
||||||
id: row.id,
|
id: row.id,
|
||||||
rawText: row.raw_text,
|
rawText: row.raw_text,
|
||||||
actions: [],
|
actions: actionsByTurnId.get(row.id) ?? [],
|
||||||
validation: [],
|
validation: validationByTurnId.get(row.id) ?? [],
|
||||||
createdAt: row.created_at,
|
createdAt: row.created_at,
|
||||||
interpreter: interpreterByTurnId.get(row.id),
|
interpreter: interpreterByTurnId.get(row.id),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ export const DEFAULT_RULEBOOK_ID = "rulebook_default";
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds the default SceneRulebook, encoding all validation logic that was
|
* 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 {
|
export function createDefaultRulebook(worldId: string): SceneRulebook {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { Action } from "../../contracts/action";
|
import type { Action } from "../../contracts/action";
|
||||||
import type { InterpreterOutput } from "../../contracts/intent";
|
import type { InterpreterOutput } from "../../contracts/intent";
|
||||||
|
import type { WorldState } from "../../contracts/world";
|
||||||
import type { ResolveIntentInput } from "../resolveIntent";
|
import type { ResolveIntentInput } from "../resolveIntent";
|
||||||
|
|
||||||
export const LLM_INTERPRETER_VERSION = "llm-v1-ollama";
|
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 } {
|
function buildPrompt(input: ResolveIntentInput): { system: string; user: string } {
|
||||||
const system = [
|
const system = [
|
||||||
"You are an intent-to-actions resolver for a text adventure engine.",
|
"You are an intent-to-actions resolver for a text adventure engine.",
|
||||||
"Return ONLY JSON with this shape:",
|
"You will receive the current world state and a player command.",
|
||||||
'{"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"}',
|
"Return ONLY a JSON object with this exact shape (no markdown, no prose):",
|
||||||
"If unresolved, selectedActions must be an empty array and clarification must be present.",
|
JSON.stringify({
|
||||||
"Use canonical action types only. Do not invent fields.",
|
status: "resolved|needs_clarification|rejected",
|
||||||
].join(" ");
|
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 = [
|
const user = [
|
||||||
`actorId: ${input.actorId}`,
|
`actorId: ${input.actorId}`,
|
||||||
`input: ${JSON.stringify(input.rawText)}`,
|
|
||||||
`minimum_confidence: ${input.minConfidence}`,
|
`minimum_confidence: ${input.minConfidence}`,
|
||||||
].join("\n");
|
`world_context:\n${worldContext}`,
|
||||||
|
`player_input: ${JSON.stringify(input.rawText)}`,
|
||||||
|
].join("\n\n");
|
||||||
|
|
||||||
return { system, user };
|
return { system, user };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { InterpreterOutput } from "../contracts/intent";
|
import type { InterpreterOutput } from "../contracts/intent";
|
||||||
|
import type { WorldState } from "../contracts/world";
|
||||||
import { resolveDeterministicIntent } from "./adapters/deterministicResolver";
|
import { resolveDeterministicIntent } from "./adapters/deterministicResolver";
|
||||||
import { resolveLlmIntent } from "./adapters/llmResolver";
|
import { resolveLlmIntent } from "./adapters/llmResolver";
|
||||||
import {
|
import {
|
||||||
@@ -11,6 +12,7 @@ const DEFAULT_MIN_CONFIDENCE = 0.65;
|
|||||||
type InterpretTurnOptions = {
|
type InterpretTurnOptions = {
|
||||||
mode?: ResolverMode;
|
mode?: ResolverMode;
|
||||||
minConfidence?: number;
|
minConfidence?: number;
|
||||||
|
worldState?: WorldState;
|
||||||
};
|
};
|
||||||
|
|
||||||
function getResolverMode(options?: InterpretTurnOptions): ResolverMode {
|
function getResolverMode(options?: InterpretTurnOptions): ResolverMode {
|
||||||
@@ -25,6 +27,7 @@ function buildInput(rawText: string, actorId: string, options?: InterpretTurnOpt
|
|||||||
rawText,
|
rawText,
|
||||||
actorId,
|
actorId,
|
||||||
minConfidence: options?.minConfidence ?? DEFAULT_MIN_CONFIDENCE,
|
minConfidence: options?.minConfidence ?? DEFAULT_MIN_CONFIDENCE,
|
||||||
|
worldState: options?.worldState,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { InterpreterOutput } from "../contracts/intent";
|
import type { InterpreterOutput } from "../contracts/intent";
|
||||||
|
import type { WorldState } from "../contracts/world";
|
||||||
|
|
||||||
export type ResolverMode = "deterministic" | "llm" | "hybrid";
|
export type ResolverMode = "deterministic" | "llm" | "hybrid";
|
||||||
|
|
||||||
@@ -6,6 +7,8 @@ export type ResolveIntentInput = {
|
|||||||
rawText: string;
|
rawText: string;
|
||||||
actorId: string;
|
actorId: string;
|
||||||
minConfidence: number;
|
minConfidence: number;
|
||||||
|
/** Optional world state — passed to LLM resolvers to provide entity context. */
|
||||||
|
worldState?: WorldState;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type IntentResolver = {
|
export type IntentResolver = {
|
||||||
|
|||||||
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,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);
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -9,7 +9,8 @@ import type { ValidationResult } from "../contracts/validation";
|
|||||||
import type { WorldState } from "../contracts/world";
|
import type { WorldState } from "../contracts/world";
|
||||||
import { interpretTurn } from "../interpreter/interpretTurn";
|
import { interpretTurn } from "../interpreter/interpretTurn";
|
||||||
import { validateInterpreterOutput } from "../interpreter/validateInterpreterOutput";
|
import { validateInterpreterOutput } from "../interpreter/validateInterpreterOutput";
|
||||||
import { validateActions } from "../truthEngine";
|
import { createDefaultRulebook } from "../defaultRulebook";
|
||||||
|
import { validateWithRulebook } from "../rulebookEngine";
|
||||||
import { applyActions } from "../world/applyActions";
|
import { applyActions } from "../world/applyActions";
|
||||||
|
|
||||||
export type TurnManagerResponse = {
|
export type TurnManagerResponse = {
|
||||||
@@ -39,7 +40,7 @@ export async function runTurnManager(
|
|||||||
db: CharacterGardenDatabase,
|
db: CharacterGardenDatabase,
|
||||||
rulebook?: SceneRulebook
|
rulebook?: SceneRulebook
|
||||||
): Promise<TurnManagerResponse> {
|
): Promise<TurnManagerResponse> {
|
||||||
const interpreted = await interpretTurn(rawText, "player");
|
const interpreted = await interpretTurn(rawText, "player", { worldState });
|
||||||
const boundaryCheck = validateInterpreterOutput(interpreted);
|
const boundaryCheck = validateInterpreterOutput(interpreted);
|
||||||
const interpreter: InterpreterOutput = boundaryCheck.isValid
|
const interpreter: InterpreterOutput = boundaryCheck.isValid
|
||||||
? interpreted
|
? interpreted
|
||||||
@@ -84,7 +85,8 @@ export async function runTurnManager(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const actions = interpreter.selectedActions;
|
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 nextWorldState = applyActions(actions, validation, worldState);
|
||||||
|
|
||||||
const turn: Turn = {
|
const turn: Turn = {
|
||||||
|
|||||||
@@ -109,9 +109,9 @@ export function applyActions(
|
|||||||
case "take":
|
case "take":
|
||||||
if (actor && target) {
|
if (actor && target) {
|
||||||
target.attributes.location = `inventory:${actor.id}`;
|
target.attributes.location = `inventory:${actor.id}`;
|
||||||
if (target.id === "key_1") {
|
// Set has_<item_id> on the actor so attributeRef rulebook checks work
|
||||||
actor.attributes.has_key_1 = true;
|
// 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) {
|
} else if (actor && action.targetId && action.metadata?.createIfMissing === true) {
|
||||||
nextState.entities[action.targetId] = {
|
nextState.entities[action.targetId] = {
|
||||||
id: action.targetId,
|
id: action.targetId,
|
||||||
|
|||||||
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;
|
||||||
|
}
|
||||||
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;
|
||||||
@@ -28,7 +28,27 @@ type Turn = {
|
|||||||
validation: ValidationResult[];
|
validation: ValidationResult[];
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
interpreter?: {
|
interpreter?: {
|
||||||
|
interpreterVersion: string;
|
||||||
|
resolutionSource: "deterministic" | "llm" | "hybrid";
|
||||||
|
minConfidence: number;
|
||||||
status: "resolved" | "needs_clarification" | "rejected";
|
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;
|
worldState: WorldState;
|
||||||
interpreter: {
|
interpreter: {
|
||||||
interpreterVersion: string;
|
interpreterVersion: string;
|
||||||
|
resolutionSource: "deterministic" | "llm" | "hybrid";
|
||||||
|
minConfidence: number;
|
||||||
status: "resolved" | "needs_clarification" | "rejected";
|
status: "resolved" | "needs_clarification" | "rejected";
|
||||||
selectedConfidence?: number;
|
selectedConfidence?: number;
|
||||||
|
candidates: Array<{
|
||||||
|
action: Action;
|
||||||
|
confidence: number;
|
||||||
|
rationale?: string;
|
||||||
|
}>;
|
||||||
diagnostics: string[];
|
diagnostics: string[];
|
||||||
clarification?: {
|
clarification?: {
|
||||||
reasonCode: string;
|
reasonCode: string;
|
||||||
question: string;
|
question: string;
|
||||||
|
field?: string;
|
||||||
options?: Array<{
|
options?: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -91,14 +119,37 @@ type SceneRulebook = {
|
|||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type RulebookListItem = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
version: number;
|
||||||
|
updatedAt: number;
|
||||||
|
};
|
||||||
|
|
||||||
const starterPrompts = [
|
const starterPrompts = [
|
||||||
"look around",
|
"look around",
|
||||||
"take key",
|
"take key",
|
||||||
|
"take lantern",
|
||||||
"give key to groundskeeper",
|
"give key to groundskeeper",
|
||||||
|
"introduce jeff",
|
||||||
|
"describe groundskeeper as patient",
|
||||||
"open door",
|
"open door",
|
||||||
"move to exit",
|
"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) {
|
||||||
@@ -111,7 +162,7 @@ async function fetchJson<T>(input: RequestInfo, init?: RequestInit): Promise<T>
|
|||||||
// Rulebook editor component
|
// Rulebook editor component
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
function RulebookEditor() {
|
function RulebookEditor(props: { onSaved?: (rulebook: SceneRulebook) => void }) {
|
||||||
const [rulebook, setRulebook] = useState<SceneRulebook | null>(null);
|
const [rulebook, setRulebook] = useState<SceneRulebook | null>(null);
|
||||||
const [drafts, setDrafts] = useState<Record<string, string>>({});
|
const [drafts, setDrafts] = useState<Record<string, string>>({});
|
||||||
const [parseErrors, setParseErrors] = useState<Record<string, string>>({});
|
const [parseErrors, setParseErrors] = useState<Record<string, string>>({});
|
||||||
@@ -182,6 +233,7 @@ function RulebookEditor() {
|
|||||||
body: JSON.stringify({ ...rulebook, rules: updatedRules }),
|
body: JSON.stringify({ ...rulebook, rules: updatedRules }),
|
||||||
});
|
});
|
||||||
setRulebook(updated);
|
setRulebook(updated);
|
||||||
|
props.onSaved?.(updated);
|
||||||
setSaved(true);
|
setSaved(true);
|
||||||
setTimeout(() => setSaved(false), 2500);
|
setTimeout(() => setSaved(false), 2500);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -198,6 +250,9 @@ function RulebookEditor() {
|
|||||||
<div className="rulebook-header">
|
<div className="rulebook-header">
|
||||||
<div>
|
<div>
|
||||||
<p className="rulebook-name">{rulebook.name}</p>
|
<p className="rulebook-name">{rulebook.name}</p>
|
||||||
|
<p className="rulebook-desc">
|
||||||
|
Version {rulebook.version} | Updated {formatTurnTime(rulebook.updatedAt)}
|
||||||
|
</p>
|
||||||
{rulebook.description ? (
|
{rulebook.description ? (
|
||||||
<p className="rulebook-desc">{rulebook.description}</p>
|
<p className="rulebook-desc">{rulebook.description}</p>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -290,10 +345,21 @@ export default function App() {
|
|||||||
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 [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<AppSnapshot>("/api/state")
|
void Promise.all([fetchJson<AppSnapshot>("/api/state"), refreshRulebookState()])
|
||||||
.then((data) => {
|
.then(([data]) => {
|
||||||
setSnapshot(data);
|
setSnapshot(data);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
})
|
})
|
||||||
@@ -315,7 +381,10 @@ export default function App() {
|
|||||||
body: JSON.stringify({ input }),
|
body: JSON.stringify({ input }),
|
||||||
});
|
});
|
||||||
setLatest(result);
|
setLatest(result);
|
||||||
const nextSnapshot = await fetchJson<AppSnapshot>("/api/state");
|
const [nextSnapshot] = await Promise.all([
|
||||||
|
fetchJson<AppSnapshot>("/api/state"),
|
||||||
|
refreshRulebookState(),
|
||||||
|
]);
|
||||||
setSnapshot(nextSnapshot);
|
setSnapshot(nextSnapshot);
|
||||||
} catch (submitError) {
|
} catch (submitError) {
|
||||||
setError(submitError instanceof Error ? submitError.message : "Unknown error");
|
setError(submitError instanceof Error ? submitError.message : "Unknown error");
|
||||||
@@ -334,12 +403,22 @@ export default function App() {
|
|||||||
});
|
});
|
||||||
setSnapshot(result);
|
setSnapshot(result);
|
||||||
setLatest(null);
|
setLatest(null);
|
||||||
|
await refreshRulebookState();
|
||||||
} catch (resetError) {
|
} catch (resetError) {
|
||||||
setError(resetError instanceof Error ? resetError.message : "Unknown error");
|
setError(resetError instanceof Error ? resetError.message : "Unknown error");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const entities = snapshot ? Object.values(snapshot.worldState.entities) : [];
|
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">
|
||||||
@@ -382,20 +461,32 @@ export default function App() {
|
|||||||
<p><strong>Input:</strong> {latest.rawText}</p>
|
<p><strong>Input:</strong> {latest.rawText}</p>
|
||||||
<p>
|
<p>
|
||||||
<strong>Interpreter:</strong> {latest.interpreter.status}
|
<strong>Interpreter:</strong> {latest.interpreter.status}
|
||||||
{typeof latest.interpreter.selectedConfidence === "number"
|
{` via ${latest.interpreter.resolutionSource}`}
|
||||||
? ` (${Math.round(latest.interpreter.selectedConfidence * 100)}% confidence)`
|
{` | model threshold ${formatConfidence(latest.interpreter.minConfidence)}`}
|
||||||
: ""}
|
{` | selected ${formatConfidence(latest.interpreter.selectedConfidence)}`}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Interpreter version:</strong> {latest.interpreter.interpreterVersion}
|
||||||
</p>
|
</p>
|
||||||
{latest.interpreter.clarification ? (
|
{latest.interpreter.clarification ? (
|
||||||
<p>
|
<p>
|
||||||
<strong>Clarification:</strong> {latest.interpreter.clarification.question}
|
<strong>Clarification ({latest.interpreter.clarification.reasonCode}):</strong>{" "}
|
||||||
|
{latest.interpreter.clarification.question}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
{latest.interpreter.clarification?.options?.length ? (
|
{latest.interpreter.clarification?.options?.length ? (
|
||||||
<p>
|
<div className="chips">
|
||||||
<strong>Options:</strong>{" "}
|
{latest.interpreter.clarification.options.map((o) => (
|
||||||
{latest.interpreter.clarification.options.map((o) => o.label).join(", ")}
|
<button
|
||||||
</p>
|
key={o.id}
|
||||||
|
type="button"
|
||||||
|
className="chip"
|
||||||
|
onClick={() => applyClarificationOption(o.value)}
|
||||||
|
>
|
||||||
|
clarify: {o.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<ul className="timeline-list compact">
|
<ul className="timeline-list compact">
|
||||||
{latest.validation.map((v) => (
|
{latest.validation.map((v) => (
|
||||||
@@ -417,6 +508,25 @@ export default function App() {
|
|||||||
<strong>Diagnostics:</strong> {latest.interpreter.diagnostics.join(" | ")}
|
<strong>Diagnostics:</strong> {latest.interpreter.diagnostics.join(" | ")}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : 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}
|
||||||
|
|
||||||
@@ -446,6 +556,14 @@ export default function App() {
|
|||||||
<article className="panel">
|
<article className="panel">
|
||||||
<h2>World state</h2>
|
<h2>World state</h2>
|
||||||
{loading && !snapshot ? <p>Loading...</p> : null}
|
{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">
|
<ul className="entity-list">
|
||||||
{entities.map((entity) => (
|
{entities.map((entity) => (
|
||||||
<li key={entity.id}>
|
<li key={entity.id}>
|
||||||
@@ -459,11 +577,25 @@ export default function App() {
|
|||||||
<article className="panel">
|
<article className="panel">
|
||||||
<h2>Turn log</h2>
|
<h2>Turn log</h2>
|
||||||
<ul className="timeline-list">
|
<ul className="timeline-list">
|
||||||
{snapshot?.turns.slice().reverse().map((turn) => (
|
{turns.map((turn) => (
|
||||||
<li key={turn.id}>
|
<li key={turn.id}>
|
||||||
<strong>{turn.rawText}</strong>
|
<strong>{turn.rawText}</strong>
|
||||||
|
<span className="turn-time"> at {formatTurnTime(turn.createdAt)}</span>
|
||||||
{turn.interpreter ? (
|
{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}
|
) : null}
|
||||||
{turn.validation.map((v) => (
|
{turn.validation.map((v) => (
|
||||||
<span key={v.actionIndex}> [{v.success ? "ok" : v.reason}]</span>
|
<span key={v.actionIndex}> [{v.success ? "ok" : v.reason}]</span>
|
||||||
@@ -472,11 +604,35 @@ export default function App() {
|
|||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</article>
|
</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">
|
<article className="panel panel--full">
|
||||||
<h2>Rulebook editor</h2>
|
<h2>Rulebook editor</h2>
|
||||||
<RulebookEditor />
|
<RulebookEditor onSaved={(rulebook) => setActiveRulebook(rulebook)} />
|
||||||
</article>
|
</article>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -161,6 +161,19 @@ 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;
|
||||||
@@ -375,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,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
19
thoughts.md
19
thoughts.md
@@ -18,6 +18,18 @@
|
|||||||
- Interpreter envelopes are persisted per turn and surfaced to the UI timeline.
|
- 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.
|
- 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.
|
- 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
|
### Confirmed via containerized validation
|
||||||
|
|
||||||
@@ -58,6 +70,13 @@
|
|||||||
- Existing DBs may hold older rulebooks missing new action rule sets.
|
- Existing DBs may hold older rulebooks missing new action rule sets.
|
||||||
- Need explicit upgrade path/versioning.
|
- 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)
|
## Path Forward (Next 3 Iterations)
|
||||||
|
|
||||||
### Iteration 1: LLM adapter hardening
|
### Iteration 1: LLM adapter hardening
|
||||||
|
|||||||
Reference in New Issue
Block a user