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
|
||||
- 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
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
NODE_ENV=development
|
||||
APP_PORT=3000
|
||||
APP_PORT=3024
|
||||
FRONTEND_PORT=5173
|
||||
DB_PATH=/data/sqlite/app.db
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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),
|
||||
}));
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
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 { 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 = {
|
||||
|
||||
@@ -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,
|
||||
|
||||
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
|
||||
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:
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
|
||||
19
thoughts.md
19
thoughts.md
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user