feat: implement processTurn function to handle turn processing and world state updates

refactor: remove legacy types.ts file and update frontend to use new contracts

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

chore: remove empty .gitkeep file from sqlite data directory

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

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

docs: update thoughts.md with current status, architecture decisions, and remaining checks
This commit is contained in:
2026-04-24 01:04:17 -04:00
parent 2f6af46c79
commit 998635f542
21 changed files with 1472 additions and 1740 deletions

485
Implementation_plan.md Normal file
View File

@@ -0,0 +1,485 @@
# CharacterGarden — Iterative Implementation Plan
## Copilot Operating Rules
Work in small, reviewable steps.
After every completed step:
1. Update `thoughts.md`
2. Record files changed
3. Record assumptions made
4. Record next step
5. Do not skip ahead unless the current step is complete
Do not redesign the project without updating this plan.
---
# Phase 1 — Contracts First
## Step 1.1 — Create contracts folder
Create:
```txt
app/src/contracts/
```
Add:
```txt
app/src/contracts/action.ts
app/src/contracts/turn.ts
app/src/contracts/validation.ts
app/src/contracts/world.ts
app/src/contracts/entity.ts
```
Goal: all shared types live here.
---
## Step 1.2 — Define Action contract
In `action.ts`:
```ts
export type Action = {
actorId: string;
type: string;
targetId?: string;
locationId?: string;
metadata?: Record<string, unknown>;
};
```
No other action shape should be used.
---
## Step 1.3 — Define ValidationResult contract
In `validation.ts`:
```ts
export type ValidationResult = {
actionIndex: number;
success: boolean;
reason?: string;
message?: string;
};
```
---
## Step 1.4 — Define Turn contract
In `turn.ts`:
```ts
import type { Action } from "./action";
import type { ValidationResult } from "./validation";
export type Turn = {
id: string;
rawText: string;
actions: Action[];
validation: ValidationResult[];
createdAt: number;
};
```
---
## Step 1.5 — Define Entity contract
In `entity.ts`:
```ts
export type Entity = {
id: string;
name: string;
type: string;
attributes: Record<string, unknown>;
};
```
---
## Step 1.6 — Define WorldState contract
In `world.ts`:
```ts
import type { Entity } from "./entity";
export type WorldState = {
id: string;
entities: Record<string, Entity>;
metadata: Record<string, unknown>;
createdAt: number;
};
```
---
# Phase 2 — Enforce Layer Boundaries
## Step 2.1 — Refactor truth engine imports
Update `truthEngine.ts` so it imports:
```ts
import type { Action } from "./contracts/action";
import type { ValidationResult } from "./contracts/validation";
import type { WorldState } from "./contracts/world";
```
Truth engine must only receive structured actions.
---
## Step 2.2 — Remove text parsing from truth engine
Search `truthEngine.ts` for:
* string parsing
* natural language interpretation
* prompt logic
* LLM calls
Move any such logic out.
Truth engine should expose:
```ts
export function validateActions(
actions: Action[],
worldState: WorldState
): ValidationResult[] {
// deterministic validation only
}
```
---
## Step 2.3 — Create parser layer
Create:
```txt
app/src/parser/
app/src/parser/parseTextToActions.ts
```
Function:
```ts
import type { Action } from "../contracts/action";
export function parseTextToActions(text: string): Action[] {
// temporary simple parser
return [];
}
```
For now, returning `[]` is acceptable.
---
## Step 2.4 — Create world state engine
Create:
```txt
app/src/world/
app/src/world/applyActions.ts
```
Function:
```ts
import type { Action } from "../contracts/action";
import type { ValidationResult } from "../contracts/validation";
import type { WorldState } from "../contracts/world";
export function applyActions(
actions: Action[],
results: ValidationResult[],
worldState: WorldState
): WorldState {
// apply only successful actions
return worldState;
}
```
---
# Phase 3 — Build First Deterministic Test Domain
Use a simple door/key room before anything complex.
## Step 3.1 — Seed initial world
Create initial world state:
* actor: `player`
* room: `room_start`
* door: `door_1`
* key: `key_1`
Door starts locked.
Key starts in room.
Player starts in room.
---
## Step 3.2 — Support action types
Truth engine should recognize:
```txt
inspect
take
open
move
```
Unknown action types fail with:
```txt
reason: "unknown_action"
```
---
## Step 3.3 — Validate take action
Rules:
* Actor must exist
* Target must exist
* Target must be in same location
* Target must be takeable
Failure reasons:
* `actor_not_found`
* `target_not_found`
* `not_in_same_location`
* `not_takeable`
---
## Step 3.4 — Validate open action
Rules:
* Actor must exist
* Target must exist
* Target must be openable
* If locked, actor must have matching key
Failure reasons:
* `actor_not_found`
* `target_not_found`
* `not_openable`
* `locked_requires_key`
---
## Step 3.5 — Apply successful take
If `take` succeeds:
* move item into actor inventory
---
## Step 3.6 — Apply successful open
If `open` succeeds:
* set door attribute `open: true`
---
# Phase 4 — Wire Full Turn Processing
## Step 4.1 — Create turn processor
Create:
```txt
app/src/turns/processTurn.ts
```
Function:
```ts
export async function processTurn(rawText: string): Promise<Turn> {
// parse
// validate
// apply
// persist
// return turn
}
```
---
## Step 4.2 — Enforce pipeline order
The turn processor must call:
```txt
parseTextToActions
validateActions
applyActions
persistTurn
```
In that order.
No layer may skip ahead.
---
## Step 4.3 — Add debug response
API should return:
```ts
{
rawText,
actions,
validation,
worldState
}
```
This is for MVP debugging.
---
# Phase 5 — Persistence
## Step 5.1 — Add database tables
Minimum SQLite tables:
```sql
turns
actions
validation_results
entities
world_states
```
---
## Step 5.2 — Persist every turn
Each call to `processTurn` must save:
* raw text
* parsed actions
* validation results
* resulting world state snapshot
---
## Step 5.3 — Add reset endpoint
Add an endpoint to reset world state to seed state.
This is needed for testing.
---
# Phase 6 — LLM Adapter Reintroduction
Only after deterministic flow works.
## Step 6.1 — LLM parser adapter
Add optional LLM parser:
```ts
parseTextToActionsWithLLM(text: string, worldState: WorldState): Promise<Action[]>
```
It must output only valid `Action[]`.
---
## Step 6.2 — LLM narrative adapter
Add:
```ts
generateNarrative(turn: Turn, worldState: WorldState): Promise<string>
```
The narrative adapter may describe results but must not alter them.
---
# Phase 7 — Frontend Debug UI
## Step 7.1 — Show raw text input
User can submit a turn.
---
## Step 7.2 — Show parsed actions
Display action JSON.
---
## Step 7.3 — Show validation results
Display success/failure reasons.
---
## Step 7.4 — Show world state
Display current world state JSON.
---
# MVP Completion Criteria
MVP is complete when this works:
1. User enters: `take key`
2. Parser returns a `take` action
3. Truth engine validates it
4. World state moves key to inventory
5. User enters: `open door`
6. Truth engine verifies key ownership
7. Door becomes open
8. All steps are visible in debug UI
9. All turns are persisted
---
# Do Not Do Yet
Do not implement:
* autonomous agents
* complex memory retrieval
* embeddings
* relationship simulation
* long-term summaries
* branching timelines
Until the deterministic MVP is working.

BIN
charactergarden.zip Normal file

Binary file not shown.

View File

@@ -1,329 +1,127 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { createDatabase, CharacterGardenDatabase } from "./db"; import { createDatabase, CharacterGardenDatabase } from "./db";
import { resolveLatentEntity } from "./latentEntities"; import type { Entity } from "./contracts/entity";
import { extractActionsFromProse } from "./llmAdapter"; import type { Turn } from "./contracts/turn";
import { applyChanges, createOffsceneRoom, OFFSCENE_ROOM_ID, validate, WorldState } from "./truthEngine"; import type { WorldState } from "./contracts/world";
import { Action, Belief, Entity, GameEvent, Summary, Turn } from "./types"; import { processTurn, ProcessTurnResponse } from "./turns/processTurn";
export interface AppStateSnapshot { export interface AppSnapshot {
entities: Entity[]; worldState: WorldState;
events: GameEvent[];
turns: Turn[]; turns: Turn[];
beliefs: Belief[];
summaries: Summary[];
}
export interface TurnResult {
narration: string;
parser: "fallback";
parser_feedback?: string;
actions: Action[];
accepted: Action[];
rejected: { action: Action; reason: string }[];
latent_resolution?: {
accepted: boolean;
reason: string;
entity_id?: string;
};
snapshot: AppStateSnapshot;
} }
export interface CharacterGardenApp { export interface CharacterGardenApp {
db: CharacterGardenDatabase; db: CharacterGardenDatabase;
getSnapshot(): AppStateSnapshot; getSnapshot(): AppSnapshot;
processTurn(input: string): TurnResult; processTurn(rawText: string): ProcessTurnResponse;
reset(): AppSnapshot;
} }
function createSeedEntities(): Entity[] { function createSeedWorldState(): WorldState {
return [ const now = Date.now();
createOffsceneRoom(),
{ const entities: Record<string, Entity> = {
id: "garden", room_start: {
id: "room_start",
name: "Start Room",
type: "room", type: "room",
name: "Garden",
attributes: { attributes: {
description: "A small overgrown garden with a weathered bench and a shed door nearby.", description: "A plain room with a locked door.",
}, },
}, },
{ room_exit: {
id: "shed", id: "room_exit",
name: "Exit Room",
type: "room", type: "room",
name: "Shed",
attributes: { attributes: {
description: "A cramped tool shed that smells of old wood and oil.", description: "A simple room beyond the door.",
}, },
}, },
{ player: {
id: "player", id: "player",
type: "character",
name: "Player", name: "Player",
attributes: {
location: "garden",
clothed: true,
pocket_count: 4,
has_bag: false,
},
},
{
id: "groundskeeper",
type: "character", type: "character",
name: "Groundskeeper",
attributes: { attributes: {
location: "garden", location: "room_start",
has_key_1: false,
}, },
}, },
{ door_1: {
id: "gate", id: "door_1",
type: "object", name: "Old Door",
name: "Garden Gate", type: "door",
attributes: { attributes: {
location: "garden", location: "room_start",
openable: true,
locked: true,
requiredKey: "key_1",
open: false, open: false,
locked: false,
}, },
}, },
{ key_1: {
id: "bench", id: "key_1",
type: "object", name: "Brass Key",
name: "Bench", type: "item",
attributes: { attributes: {
location: "garden", location: "room_start",
takeable: true,
}, },
}, },
]; };
}
function worldStateFromEntities(entities: Entity[]): WorldState {
return { return {
entities: new Map(entities.map((entity) => [entity.id, entity])), id: randomUUID(),
entities,
metadata: {
domain: "door_key_mvp",
version: 1,
},
createdAt: now,
}; };
} }
function entitiesFromWorldState(worldState: WorldState): Entity[] { function ensureSeedState(db: CharacterGardenDatabase): WorldState {
return Array.from(worldState.entities.values()).sort((left, right) =>
left.id.localeCompare(right.id)
);
}
function sameRoom(worldState: WorldState, leftId: string, rightId: string): boolean {
const left = worldState.entities.get(leftId);
const right = worldState.entities.get(rightId);
return left?.attributes["location"] === right?.attributes["location"];
}
function describeTarget(worldState: WorldState, targetId: string | undefined): string {
if (!targetId) {
return "nothing in particular";
}
const entity = worldState.entities.get(targetId);
return entity?.name ?? targetId;
}
function narrateAction(action: Action, worldState: WorldState): string {
switch (action.verb) {
case "move": {
const targetName = describeTarget(worldState, action.target);
if (action.target === OFFSCENE_ROOM_ID) {
return `You step out of the active scene and into ${targetName.toLowerCase()}.`;
}
return `You move to ${targetName}.`;
}
case "open":
return `You open ${describeTarget(worldState, action.target)}.`;
case "close":
return `You close ${describeTarget(worldState, action.target)}.`;
case "take":
return `You take ${describeTarget(worldState, action.target)}.`;
case "drop":
return `You drop ${describeTarget(worldState, action.target)}.`;
case "use":
return `You use ${describeTarget(worldState, action.target)}.`;
case "inspect":
return `You inspect ${describeTarget(worldState, action.target)}.`;
case "speak":
return `You speak to ${describeTarget(worldState, action.target)}.`;
default:
return "You act.";
}
}
function narrateResult(
worldState: WorldState,
accepted: Action[],
rejected: { action: Action; reason: string }[],
latentReason?: string,
parserFeedback?: string
): string {
const lines: string[] = [];
if (parserFeedback) {
lines.push(parserFeedback);
}
if (latentReason) {
lines.push(latentReason);
}
for (const action of accepted) {
lines.push(narrateAction(action, worldState));
}
for (const rejection of rejected) {
lines.push(`Action failed: ${rejection.reason}.`);
}
if (lines.length === 0) {
lines.push("Nothing changes.");
}
return lines.join(" ");
}
function persistWorldState(db: CharacterGardenDatabase, worldState: WorldState): void {
for (const entity of worldState.entities.values()) {
db.upsertEntity(entity);
}
}
function hydrateInitialState(db: CharacterGardenDatabase): WorldState {
db.init(); db.init();
const existing = db.listEntities();
if (existing.length > 0) { const latest = db.getLatestWorldState();
return worldStateFromEntities(existing); if (latest) {
return latest;
} }
const seeded = createSeedEntities(); const seed = createSeedWorldState();
for (const entity of seeded) { db.upsertEntities(Object.values(seed.entities));
db.upsertEntity(entity); db.insertWorldState(null, seed);
} return seed;
return worldStateFromEntities(seeded);
} }
export function createCharacterGardenApp(dbPath: string): CharacterGardenApp { export function createCharacterGardenApp(dbPath: string): CharacterGardenApp {
const db = createDatabase({ dbPath }); const db = createDatabase({ dbPath });
let worldState = hydrateInitialState(db); let worldState = ensureSeedState(db);
function getSnapshot(): AppStateSnapshot {
return {
entities: entitiesFromWorldState(worldState),
events: db.listEvents(),
turns: db.listTurns(),
beliefs: db.listBeliefs(),
summaries: db.listSummaries(),
};
}
function processTurn(input: string): TurnResult {
const turnNumber = db.listTurns().length + 1;
const { actions, parser, parser_feedback: parserFeedback } = extractActionsFromProse(input);
let activeWorldState = worldState;
let latentResolution: TurnResult["latent_resolution"];
const latentNoun = typeof actions[0]?.params?.["latent_item"] === "string"
? String(actions[0].params?.["latent_item"])
: null;
if (latentNoun) {
const resolution = resolveLatentEntity(
{ actor_id: actions[0].actor, noun: latentNoun, turn: turnNumber },
activeWorldState
);
latentResolution = {
accepted: resolution.accepted,
reason: resolution.reason,
entity_id: resolution.entity?.id,
};
if (resolution.accepted && resolution.entity) {
activeWorldState = {
entities: new Map(activeWorldState.entities).set(
resolution.entity.id,
resolution.entity
),
};
db.upsertEntity(resolution.entity);
for (const belief of resolution.beliefs) {
db.insertBelief(belief);
}
}
}
const normalizedActions = actions.map((action) => {
if (latentNoun && latentResolution?.accepted && latentResolution.entity_id) {
return {
actor: action.actor,
verb: "take" as const,
target: latentResolution.entity_id,
};
}
return action;
});
const validation = validate(normalizedActions, activeWorldState);
const nextWorldState = applyChanges(activeWorldState, validation.state_changes);
const narration = narrateResult(
nextWorldState,
validation.accepted,
validation.rejected,
latentResolution?.reason,
parserFeedback
);
const turnRecord: Turn = {
id: randomUUID(),
turn: turnNumber,
input,
output: narration,
timestamp: Date.now(),
};
db.insertTurn(turnRecord);
for (const action of validation.accepted) {
const event: GameEvent = {
id: randomUUID(),
turn: turnNumber,
action,
result: "success",
timestamp: Date.now(),
};
db.insertEvent(event);
}
for (const rejection of validation.rejected) {
const event: GameEvent = {
id: randomUUID(),
turn: turnNumber,
action: rejection.action,
result: "fail",
timestamp: Date.now(),
};
db.insertEvent(event);
}
worldState = nextWorldState;
persistWorldState(db, worldState);
return {
narration,
parser,
parser_feedback: parserFeedback,
actions: normalizedActions,
accepted: validation.accepted,
rejected: validation.rejected,
latent_resolution: latentResolution,
snapshot: getSnapshot(),
};
}
return { return {
db, db,
getSnapshot,
processTurn, getSnapshot() {
return {
worldState,
turns: db.listTurns(),
};
},
processTurn(rawText: string) {
const result = processTurn(rawText, worldState, db);
worldState = result.worldState;
return result;
},
reset() {
db.wipe();
worldState = ensureSeedState(db);
return {
worldState,
turns: db.listTurns(),
};
},
}; };
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,8 @@
import type { Entity } from "./entity";
export type WorldState = {
id: string;
entities: Record<string, Entity>;
metadata: Record<string, unknown>;
createdAt: number;
};

View File

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

View File

@@ -13,6 +13,8 @@ server.get("/health", async () => ({ ok: true }));
server.get("/api/state", async () => game.getSnapshot()); server.get("/api/state", async () => game.getSnapshot());
server.post("/api/reset", async () => game.reset());
server.post<{ Body: { input?: string } }>("/api/turn", async (request, reply) => { server.post<{ Body: { input?: string } }>("/api/turn", async (request, reply) => {
const input = request.body?.input?.trim(); const input = request.body?.input?.trim();
if (!input) { if (!input) {

View File

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

View File

@@ -1,210 +0,0 @@
import { Action, ALLOWED_VERBS, Entity } from "./types";
export interface ExtractedActions {
actions: Action[];
parser: "fallback";
parser_feedback?: string;
}
export interface ActionExtractionPrompt {
system: string;
user: string;
}
function toEntityLine(entity: Entity): string {
const location = typeof entity.attributes["location"] === "string"
? ` location=${entity.attributes["location"]}`
: "";
return `- ${entity.id} [${entity.type}] "${entity.name}"${location}`;
}
export function buildActionExtractionPrompt(input: string, entities: Entity[], actorId = "player"): ActionExtractionPrompt {
const entityDigest = entities
.slice()
.sort((left, right) => left.id.localeCompare(right.id))
.map(toEntityLine)
.join("\n");
const system = [
"You convert player prose into canonical game actions.",
"Only produce actions that are valid in the current world snapshot.",
`Allowed verbs: ${ALLOWED_VERBS.join(", ")}`,
"Use exact entity ids from the world snapshot for actor and target.",
"If intent is unclear or target is missing, return no actions and a parser_feedback string suggesting rephrasing.",
"Return strict JSON only: {\"actions\": Action[], \"parser_feedback\"?: string}",
].join("\n");
const user = [
`Actor id: ${actorId}`,
"World snapshot entities:",
entityDigest || "- (none)",
`Player input: ${input}`,
].join("\n");
return { system, user };
}
const REPHRASE_EXAMPLES = "Try rephrasing like: 'look around', 'go to the shed', 'open the gate', or 'pull out my phone'.";
const ROOM_ALIASES: Record<string, string> = {
garden: "garden",
shed: "shed",
offscene: "offscene",
outside: "offscene",
away: "offscene",
};
const TARGET_ALIASES: Record<string, string> = {
gate: "gate",
bench: "bench",
groundskeeper: "groundskeeper",
keeper: "groundskeeper",
shed: "shed",
garden: "garden",
offscene: "offscene",
};
function normalized(input: string): string {
return input.trim().toLowerCase();
}
function extractQuotedOrTrailingNoun(input: string): string | null {
const quoted = input.match(/"([^"]+)"|'([^']+)'/);
if (quoted) {
return (quoted[1] ?? quoted[2]).trim().toLowerCase();
}
const pulled = input.match(/(?:pull|pulls|pulled|take|takes|took)\s+(?:out\s+)?(?:a|an|the|my|their|his|her)?\s*([a-z0-9 ]+)$/i);
if (pulled?.[1]) {
return pulled[1].trim().toLowerCase();
}
return null;
}
function resolveTarget(input: string): string | undefined {
const direct = Object.entries(TARGET_ALIASES).find(([alias]) =>
input.includes(alias)
);
return direct?.[1];
}
export function extractActionsFromProse(input: string, actorId = "player"): ExtractedActions {
const text = normalized(input);
if (!text) {
return {
actions: [],
parser: "fallback",
parser_feedback: `I couldn't parse an empty turn. ${REPHRASE_EXAMPLES}`,
};
}
const room = Object.entries(ROOM_ALIASES).find(([alias]) => text.includes(alias))?.[1];
if (/(go|move|walk|head|travel)/.test(text) && room) {
return {
actions: [{ actor: actorId, verb: "move", target: room }],
parser: "fallback",
};
}
if (/(go|move|walk|head|travel)/.test(text) && !room) {
return {
actions: [],
parser: "fallback",
parser_feedback: `I understood movement, but not the destination. Try 'go to the shed' or 'go to the garden'.`,
};
}
if (/(open)/.test(text)) {
return {
actions: [{ actor: actorId, verb: "open", target: resolveTarget(text) ?? "gate" }],
parser: "fallback",
};
}
if (/(close|shut)/.test(text)) {
return {
actions: [{ actor: actorId, verb: "close", target: resolveTarget(text) ?? "gate" }],
parser: "fallback",
};
}
if (/(take|pick up|grab)/.test(text)) {
const target = resolveTarget(text);
if (!target) {
return {
actions: [],
parser: "fallback",
parser_feedback: `I understood 'take' but not what item you meant. Try 'take the bench' or 'pull out my phone'.`,
};
}
return {
actions: [{ actor: actorId, verb: "take", target }],
parser: "fallback",
};
}
if (/(drop|put down|set down)/.test(text)) {
const target = resolveTarget(text);
if (!target) {
return {
actions: [],
parser: "fallback",
parser_feedback: `I understood 'drop' but not which item. Try 'drop phone' or 'drop keys'.`,
};
}
return {
actions: [{ actor: actorId, verb: "drop", target }],
parser: "fallback",
};
}
if (/(talk|speak|ask|say)/.test(text)) {
return {
actions: [{ actor: actorId, verb: "speak", target: resolveTarget(text) ?? "groundskeeper", params: { utterance: input } }],
parser: "fallback",
};
}
if (/(use|press|activate)/.test(text)) {
const target = resolveTarget(text);
if (!target) {
return {
actions: [],
parser: "fallback",
parser_feedback: `I understood 'use' but not the target. Try 'use gate' or 'use phone'.`,
};
}
return {
actions: [{ actor: actorId, verb: "use", target }],
parser: "fallback",
};
}
if (/(look|inspect|examine)/.test(text)) {
return {
actions: [{ actor: actorId, verb: "inspect", target: resolveTarget(text) ?? actorId }],
parser: "fallback",
};
}
const latentNoun = extractQuotedOrTrailingNoun(text);
if (latentNoun && /(pull|pulls|pulled|take|takes|took).*(out)/.test(text)) {
return {
actions: [{ actor: actorId, verb: "inspect", params: { latent_item: latentNoun } }],
parser: "fallback",
};
}
return {
actions: [],
parser: "fallback",
parser_feedback: `I couldn't map that request to a game action. ${REPHRASE_EXAMPLES}`,
};
}

View File

@@ -0,0 +1,42 @@
import type { Action } from "../contracts/action";
function normalized(input: string): string {
return input.trim().toLowerCase();
}
export function parseTextToActions(text: string, actorId = "player"): Action[] {
const input = normalized(text);
if (!input) {
return [];
}
if (/(look|inspect|examine)/.test(input)) {
return [{ actorId, type: "inspect", targetId: actorId }];
}
if (/(go|move|walk|head|travel)/.test(input)) {
if (input.includes("exit") || input.includes("next room") || input.includes("through door")) {
return [{ actorId, type: "move", targetId: "room_exit" }];
}
if (input.includes("start")) {
return [{ actorId, type: "move", targetId: "room_start" }];
}
return [];
}
if (/(open)/.test(input)) {
if (input.includes("door")) {
return [{ actorId, type: "open", targetId: "door_1" }];
}
return [];
}
if (/(take|pick up|grab)/.test(input)) {
if (input.includes("key")) {
return [{ actorId, type: "take", targetId: "key_1" }];
}
return [];
}
return [];
}

View File

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

View File

@@ -0,0 +1,48 @@
import { randomUUID } from "node:crypto";
import type { CharacterGardenDatabase } from "../db";
import type { Action } from "../contracts/action";
import type { Turn } from "../contracts/turn";
import type { ValidationResult } from "../contracts/validation";
import type { WorldState } from "../contracts/world";
import { parseTextToActions } from "../parser/parseTextToActions";
import { validateActions } from "../truthEngine";
import { applyActions } from "../world/applyActions";
export type ProcessTurnResponse = {
rawText: string;
actions: Action[];
validation: ValidationResult[];
worldState: WorldState;
};
export function processTurn(
rawText: string,
worldState: WorldState,
db: CharacterGardenDatabase
): ProcessTurnResponse {
const actions = parseTextToActions(rawText);
const validation = validateActions(actions, worldState);
const nextWorldState = applyActions(actions, validation, worldState);
const turn: Turn = {
id: randomUUID(),
rawText,
actions,
validation,
createdAt: Date.now(),
};
db.insertTurn(turn);
db.insertActions(turn.id, actions);
db.insertValidationResults(turn.id, validation);
db.upsertEntities(Object.values(nextWorldState.entities));
db.insertWorldState(turn.id, nextWorldState);
return {
rawText,
actions,
validation,
worldState: nextWorldState,
};
}

View File

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

View File

@@ -0,0 +1,73 @@
import { randomUUID } from "node:crypto";
import type { Action } from "../contracts/action";
import type { ValidationResult } from "../contracts/validation";
import type { Entity } from "../contracts/entity";
import type { WorldState } from "../contracts/world";
function cloneWorldState(worldState: WorldState): WorldState {
const entities: Record<string, Entity> = {};
for (const [id, entity] of Object.entries(worldState.entities)) {
entities[id] = {
...entity,
attributes: { ...entity.attributes },
};
}
return {
...worldState,
entities,
metadata: { ...worldState.metadata },
};
}
export function applyActions(
actions: Action[],
results: ValidationResult[],
worldState: WorldState
): WorldState {
const nextState = cloneWorldState(worldState);
for (const result of results) {
if (!result.success) {
continue;
}
const action = actions[result.actionIndex];
if (!action) {
continue;
}
const actor = nextState.entities[action.actorId];
const target = action.targetId ? nextState.entities[action.targetId] : undefined;
switch (action.type) {
case "move":
if (actor && action.targetId) {
actor.attributes.location = action.targetId;
}
break;
case "take":
if (actor && target) {
target.attributes.location = `inventory:${actor.id}`;
if (target.id === "key_1") {
actor.attributes.has_key_1 = true;
}
}
break;
case "open":
if (target) {
target.attributes.open = true;
}
break;
case "inspect":
default:
break;
}
}
nextState.id = randomUUID();
nextState.createdAt = Date.now();
return nextState;
}

View File

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

View File

@@ -7,60 +7,52 @@ type Entity = {
attributes: Record<string, unknown>; attributes: Record<string, unknown>;
}; };
type GameEvent = { type Action = {
id: string; actorId: string;
turn: number; type: string;
result: "success" | "fail"; targetId?: string;
action: Record<string, unknown>; locationId?: string;
timestamp: number; };
type ValidationResult = {
actionIndex: number;
success: boolean;
reason?: string;
message?: string;
}; };
type Turn = { type Turn = {
id: string; id: string;
turn: number; rawText: string;
input: string; actions: Action[];
output: string; validation: ValidationResult[];
timestamp: number; createdAt: number;
}; };
type Belief = { type WorldState = {
entity_id: string;
claim: string;
confidence: number;
};
type Summary = {
id: string; id: string;
turn_range: [number, number]; entities: Record<string, Entity>;
text: string; metadata: Record<string, unknown>;
timestamp: number; createdAt: number;
}; };
type Snapshot = { type AppSnapshot = {
entities: Entity[]; worldState: WorldState;
events: GameEvent[];
turns: Turn[]; turns: Turn[];
beliefs: Belief[];
summaries: Summary[];
}; };
type TurnResult = { type ProcessTurnResponse = {
narration: string; rawText: string;
parser: string; actions: Action[];
parser_feedback?: string; validation: ValidationResult[];
actions: Array<Record<string, unknown>>; worldState: WorldState;
accepted: Array<Record<string, unknown>>;
rejected: Array<{ action: Record<string, unknown>; reason: string }>;
latent_resolution?: { accepted: boolean; reason: string; entity_id?: string };
snapshot: Snapshot;
}; };
const starterPrompts = [ const starterPrompts = [
"look around", "look around",
"open the gate", "take key",
"talk to the groundskeeper", "open door",
"go to the shed", "move to exit",
"pull out my phone",
]; ];
async function fetchJson<T>(input: RequestInfo, init?: RequestInit): Promise<T> { async function fetchJson<T>(input: RequestInfo, init?: RequestInit): Promise<T> {
@@ -72,15 +64,15 @@ async function fetchJson<T>(input: RequestInfo, init?: RequestInit): Promise<T>
} }
export default function App() { export default function App() {
const [snapshot, setSnapshot] = useState<Snapshot | null>(null); const [snapshot, setSnapshot] = useState<AppSnapshot | null>(null);
const [latest, setLatest] = useState<TurnResult | null>(null); const [latest, setLatest] = useState<ProcessTurnResponse | null>(null);
const [input, setInput] = useState("look around"); const [input, setInput] = useState("look around");
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
void fetchJson<Snapshot>("/api/state") void fetchJson<AppSnapshot>("/api/state")
.then((data) => { .then((data) => {
setSnapshot(data); setSnapshot(data);
setLoading(false); setLoading(false);
@@ -97,13 +89,14 @@ export default function App() {
setError(null); setError(null);
try { try {
const result = await fetchJson<TurnResult>("/api/turn", { const result = await fetchJson<ProcessTurnResponse>("/api/turn", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ input }), body: JSON.stringify({ input }),
}); });
setLatest(result); setLatest(result);
setSnapshot(result.snapshot); const nextSnapshot = await fetchJson<AppSnapshot>("/api/state");
setSnapshot(nextSnapshot);
} catch (submitError) { } catch (submitError) {
setError(submitError instanceof Error ? submitError.message : "Unknown error"); setError(submitError instanceof Error ? submitError.message : "Unknown error");
} finally { } finally {
@@ -111,13 +104,30 @@ export default function App() {
} }
} }
async function onReset() {
setError(null);
try {
const result = await fetchJson<AppSnapshot>("/api/reset", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
setSnapshot(result);
setLatest(null);
} catch (resetError) {
setError(resetError instanceof Error ? resetError.message : "Unknown error");
}
}
const entities = snapshot ? Object.values(snapshot.worldState.entities) : [];
return ( return (
<main className="page-shell"> <main className="page-shell">
<section className="hero-panel"> <section className="hero-panel">
<p className="eyebrow">CharacterGarden</p> <p className="eyebrow">CharacterGarden</p>
<h1>Bootable narrative sandbox</h1> <h1>Bootable narrative sandbox</h1>
<p className="lede"> <p className="lede">
Submit a turn, inspect the current entities and events, and verify how the truth engine is mutating state. Submit a turn, inspect world state, and verify how the truth engine is mutating state.
</p> </p>
<form className="turn-form" onSubmit={onSubmit}> <form className="turn-form" onSubmit={onSubmit}>
@@ -133,6 +143,9 @@ export default function App() {
<button type="submit" disabled={submitting}> <button type="submit" disabled={submitting}>
{submitting ? "Submitting..." : "Run turn"} {submitting ? "Submitting..." : "Run turn"}
</button> </button>
<button type="button" className="chip" onClick={onReset}>
Reset world
</button>
<div className="chips"> <div className="chips">
{starterPrompts.map((prompt) => ( {starterPrompts.map((prompt) => (
<button key={prompt} type="button" className="chip" onClick={() => setInput(prompt)}> <button key={prompt} type="button" className="chip" onClick={() => setInput(prompt)}>
@@ -146,11 +159,16 @@ export default function App() {
{latest ? ( {latest ? (
<section className="result-card"> <section className="result-card">
<h2>Latest result</h2> <h2>Latest result</h2>
<p>{latest.narration}</p> <p><strong>Input:</strong> {latest.rawText}</p>
{latest.parser_feedback ? ( <ul className="timeline-list compact">
<p className="parser-hint">Parser guidance: {latest.parser_feedback}</p> {latest.validation.map((v) => (
) : null} <li key={v.actionIndex}>
<pre>{JSON.stringify({ actions: latest.actions, rejected: latest.rejected, latent: latest.latent_resolution }, null, 0)}</pre> Action {v.actionIndex}: {v.success ? "ok" : `failed (${v.reason ?? "unknown"})`}
{v.message ? ` - ${v.message}` : ""}
</li>
))}
{latest.validation.length === 0 ? <li>No actions parsed.</li> : null}
</ul>
</section> </section>
) : null} ) : null}
@@ -162,7 +180,7 @@ export default function App() {
<h2>World state</h2> <h2>World state</h2>
{loading && !snapshot ? <p>Loading...</p> : null} {loading && !snapshot ? <p>Loading...</p> : null}
<ul className="entity-list"> <ul className="entity-list">
{snapshot?.entities.map((entity) => ( {entities.map((entity) => (
<li key={entity.id}> <li key={entity.id}>
<strong>{entity.name}</strong> <span>{entity.type}</span> <strong>{entity.name}</strong> <span>{entity.type}</span>
<pre>{JSON.stringify(entity.attributes, null, 0)}</pre> <pre>{JSON.stringify(entity.attributes, null, 0)}</pre>
@@ -176,38 +194,10 @@ export default function App() {
<ul className="timeline-list"> <ul className="timeline-list">
{snapshot?.turns.slice().reverse().map((turn) => ( {snapshot?.turns.slice().reverse().map((turn) => (
<li key={turn.id}> <li key={turn.id}>
<strong>Turn {turn.turn}:</strong> {turn.input} {turn.output} <strong>{turn.rawText}</strong>
</li> {turn.validation.map((v) => (
<span key={v.actionIndex}> [{v.success ? "ok" : v.reason}]</span>
))} ))}
</ul>
</article>
<article className="panel">
<h2>Events</h2>
<ul className="timeline-list compact">
{snapshot?.events.slice().reverse().map((event) => (
<li key={event.id}>
<strong>{event.result}:</strong> <pre>{JSON.stringify(event.action, null, 0)}</pre>
</li>
))}
</ul>
</article>
<article className="panel">
<h2>Beliefs & summaries</h2>
<h3>Beliefs</h3>
<ul className="timeline-list compact">
{snapshot?.beliefs.map((belief) => (
<li key={`${belief.entity_id}-${belief.claim}`}>
<strong>{belief.entity_id}:</strong> {belief.claim} ({belief.confidence})
</li>
))}
</ul>
<h3>Summaries</h3>
<ul className="timeline-list compact">
{snapshot?.summaries.map((summary) => (
<li key={summary.id}>
<strong>{summary.turn_range.join("-")}:</strong> {summary.text}
</li> </li>
))} ))}
</ul> </ul>

View File

@@ -1,367 +1,355 @@
# CharacterGarden AI-Oriented Design Spec (Copilot-Ready) # CharacterGarden — System Bible (MVP Architecture)
## 0. Purpose ## Purpose
This document defines **hard contracts and system boundaries** for CharacterGarden. CharacterGarden is a deterministic roleplay and simulation framework.
Goal: enable an AI coding assistant (e.g. Copilot) to implement the system step-by-step without ambiguity. The system separates:
Core principle: * Natural language (LLM / user input)
* Structured intent (parsed actions)
* Truth validation (rules engine)
* World state (persistent simulation)
> The application owns truth. The LLM only translates and narrates. The system MUST remain deterministic at the core.
--- ---
## 1. System Overview ## Core Principle
### Pipeline The LLM is NOT the source of truth.
The LLM is used ONLY for:
* Parsing natural language → structured actions
* Generating narrative output
ALL validation, state changes, and rules are handled by deterministic code.
---
## System Pipeline
All input MUST pass through the following pipeline:
1. PROSE INPUT
* Source: user or AI
* Format: free text
2. PARSER LAYER
* Converts text → structured actions
* May use LLM or rule-based parsing
* Output MUST follow strict schema (see Action Contract)
3. ACTION CONTRACT (STRICT)
* Only valid structured objects allowed past this point
* No free text allowed beyond this stage
4. TRUTH ENGINE
* Deterministic validation of actions
* No LLM usage allowed
* Returns validation results
5. WORLD STATE UPDATE
* Applies valid actions
* Rejects or partially applies invalid ones
6. RESPONSE GENERATION
* LLM converts results into narrative output
---
## Hard Rule
The Truth Engine MUST NEVER:
* Parse natural language
* Infer missing intent
* Use probabilistic logic
* Call an LLM
If it does, the architecture is broken.
---
## Action Contract (REQUIRED)
All actions MUST conform to this schema:
```ts
export type Action = {
actorId: string;
type: string;
targetId?: string;
locationId?: string;
metadata?: Record<string, any>;
};
``` ```
Prose Input
→ Intent Extraction No additional fields allowed unless explicitly added here.
→ Canonical Actions
→ Truth Engine Validation ---
→ State Changes + Events
→ Memory Storage ## Turn Structure
→ Narration Output
Each turn MUST be stored and processed as:
```ts
export type Turn = {
id: string;
rawText: string;
actions: Action[];
validation: ValidationResult[];
createdAt: number;
};
``` ```
--- ---
## 2. Core Contracts (STRICT) ## Validation Result Contract
### 2.1 Entity ```ts
export type ValidationResult = {
``` actionIndex: number;
Entity { success: boolean;
id: string reason?: string;
type: string message?: string;
name: string };
attributes: object
}
``` ```
### 2.2 Action (Canonical) Examples:
``` * "not_your_turn"
Action { * "object_not_found"
actor: string (entity id) * "door_locked"
verb: string (enum)
target?: string (entity id)
params?: object
}
```
Allowed verbs (MVP):
```
move, open, close, take, drop, use, inspect, speak
```
### 2.3 Validation Result
```
ValidationResult {
accepted: Action[]
rejected: { action: Action, reason: string }[]
state_changes: StateChange[]
}
```
### 2.4 State Change
```
StateChange {
entity_id: string
field: string
old_value: any
new_value: any
}
```
### 2.5 Event
```
Event {
id: string
turn: number
action: Action
result: "success" | "fail"
timestamp: number
}
```
--- ---
## 3. Truth Rules ## Required System Layers
1. Only the **Truth Engine** can modify world state ### 1. Parser Layer
2. LLM output is NEVER directly trusted
3. Every state change must be traceable to an Event
4. Invalid actions must return explicit failure reasons
--- Function:
## 4. Memory Model
Memory is NOT a single blob.
### Types
#### 4.1 Turn
Raw input/output
#### 4.2 Event
Accepted or rejected actions
#### 4.3 Fact
Current world state (derived, not duplicated)
#### 4.4 Belief
```ts
parseTextToActions(text: string): Action[]
``` ```
Belief {
entity_id: string
claim: string
confidence: number
}
```
#### 4.5 Summary
Compressed narrative context
---
## 5. Services
### 5.1 App (Core Service)
Responsibilities: Responsibilities:
* orchestrate turns * Convert text → valid Action[]
* call LLM (optional) * Resolve references ("he", "the door")
* run truth engine * May fail or return empty list
* manage memory
Tech: Node.js (Express or Fastify) Allowed:
* LLM usage
* Heuristics
Not allowed:
* World state mutation
* Validation logic
--- ---
### 5.2 Truth Engine (Module inside App) ### 2. Truth Engine
Responsibilities: Function:
* validate actions ```ts
* enforce rules validateActions(actions: Action[], worldState: WorldState): ValidationResult[]
* apply state changes
* emit events
NO LLM USAGE
---
### 5.3 LLM Adapter (Optional)
Responsibilities:
* extract actions from prose
* resolve references
* generate narration
* summarize memory
Backend: Ollama
---
### 5.4 Frontend
Responsibilities:
* send input
* display output
* optionally inspect state/events
Minimal React or Vue app
---
## 6. Turn Flow (IMPLEMENT EXACTLY)
```
1. Receive user input (string)
2. Store raw turn
3. Extract actions (LLM or fallback parser)
4. Validate actions (truth engine)
5. Apply accepted changes
6. Store events
7. Generate narration
8. Return response
``` ```
Responsibilities:
* Validate each action deterministically
* No mutation
--- ---
## 7. Storage ### 3. World State Engine
### SQLite (MVP) Function:
Tables: ```ts
applyActions(actions: Action[], results: ValidationResult[], worldState: WorldState): WorldState
```
Responsibilities:
* Apply ONLY valid actions
* Maintain consistency
---
## World State Requirements
World state MUST:
* Be serializable
* Be versionable
* Support rollback
* Support branching
---
## Database Requirements
Use SQLite.
Minimum tables:
* entities
* events
* turns * turns
* beliefs * actions
* summaries * validation_results
* entities
* world_states
File location: Each turn MUST be persisted.
``` ---
/data/sqlite/app.db
## Entity System
Entities MUST have stable IDs.
Example:
```ts
type Entity = {
id: string;
name: string;
type: string;
attributes: Record<string, any>;
};
``` ```
Parser MUST resolve references to entity IDs.
--- ---
## 8. Docker Design ## Failure Handling
### Services Failure is FIRST-CLASS.
``` Example:
services:
app ```ts
frontend {
ollama (optional) success: false,
reason: "door_locked",
message: "The door cannot be opened."
}
``` ```
### Principles The system MUST:
* No host dependencies beyond Docker * Return failures clearly
* Persist only `/data` * Allow LLM to narrate failures
* Use `.env` for config * NOT silently fix invalid actions
* No hidden setup scripts
--- ---
## 9. Folder Structure ## LLM Adapter Rules
The LLM Adapter MUST:
* Never mutate world state
* Never validate actions
* Only transform data
---
## Development Phases
### Phase 1 — Contract Enforcement (CURRENT)
* Define Action, Turn, ValidationResult
* Refactor all code to use contracts
* Remove any free-text logic from truth engine
---
### Phase 2 — Minimal Truth Engine
Implement test domain:
* Tic-tac-toe OR simple door system
Goal:
* Fully deterministic validation
* No LLM required for correctness
---
### Phase 3 — Parser Improvement
* Add LLM parsing
* Add reference resolution
* Improve action extraction
---
### Phase 4 — Memory System
* Persist entities
* Track history
* Add retrieval support
---
## Non-Goals (For Now)
* No complex AI reasoning inside truth engine
* No autonomous agents
* No multi-agent planning
---
## thoughts.md Requirement
Copilot MUST maintain a file:
``` ```
charactergarden/ /thoughts.md
docker-compose.yml
.env
app/
frontend/
data/
sqlite/
``` ```
--- After each major change, it MUST append:
## 10. MVP Scope * What was implemented
* Why it was implemented
* What assumptions were made
* What remains unclear
STRICT LIMITS: This is REQUIRED to maintain continuity across sessions.
* 12 rooms
* ≤3 characters
* ≤10 actions
* no complex AI autonomy
* no multi-agent loops yet
--- ---
## 11. Non-Goals (DO NOT BUILD YET) ## Definition of Done (MVP)
* microservices The system is complete when:
* distributed systems
* plugin frameworks * A user can input text
* advanced agent loops * It is parsed into structured actions
* cloud dependencies * Actions are validated deterministically
* World state updates correctly
* A narrative response is generated
WITHOUT requiring the LLM for correctness.
--- ---
## 12. Development Workflow Rule ## Final Rule
A root-level file named `thoughts.md` must exist and be maintained throughout development. If any part of the system depends on the LLM to maintain logical correctness,
the architecture has failed.
Purpose of `thoughts.md`: The LLM is an interface layer, not a reasoning authority.
* record current implementation status
* record the next planned steps
* record blockers, assumptions, and unresolved questions
* summarize architectural decisions already made
* preserve continuity across editor sessions or context loss
Rules for `thoughts.md`:
* update it after each meaningful implementation step
* keep entries concise and factual
* do not use it for chain-of-thought dumping or vague brainstorming
* use it as a project progress log and working memory
* when resuming work, review `thoughts.md` first before making changes
* when changing architecture, record what changed and why
* when a task is incomplete, note exactly what remains
Recommended structure:
```md
# thoughts.md
## Current Status
- what is implemented
- what is partially implemented
- what is broken or unverified
## Current Architecture Decisions
- key decisions and constraints
## Next Steps
- ordered checklist of immediate tasks
## Open Questions
- unresolved design or implementation questions
## Session Notes
- short dated notes describing recent progress
```
Copilot instruction:
> Before starting work, read `thoughts.md`. After completing any meaningful change, update `thoughts.md` to reflect current status, next steps, and any unresolved issues.
## 13. Development Order
1. Define entities + actions
2. Implement truth engine
3. Add SQLite persistence
4. Build API endpoints
5. Add minimal UI
6. Add LLM integration
---
## 14. Key Rule
> If the system works without an LLM, the architecture is correct.
---
## 15. Expected Behavior
* deterministic state
* explainable failures
* replayable sessions
* inspectable memory
---
## 16. Future Extensions (NOT NOW)
* branching timelines
* advanced belief systems
* multi-agent arbitration
* long-term memory compression
---
## END SPEC

View File

@@ -1,97 +1,54 @@
# thoughts.md # thoughts.md
## Current Status ## Current Status (clean-break)
- Scaffold complete: `charactergarden/` folder structure created per spec section 9 - Backend is running the new contract-native API shape:
- Core contracts defined in `app/src/types.ts`: Entity, Action, Verb, ValidationResult, StateChange, GameEvent, Turn, Belief, Fact, Affordance, Summary - `GET /api/state` -> `{ worldState, turns }`
- `docker-compose.yml` created; ollama service gated behind `--profile llm` (not required for MVP) - `POST /api/turn` -> `{ rawText, actions, validation, worldState }`
- `.env` / `.env.example` / `.gitignore` in place - Runtime blocker is resolved:
- Container-first runtime files added: app/frontend Dockerfiles and `.dockerignore`s - old SQLite schema conflict (`turns.raw_text`) was fixed by wiping old DB state
- **Truth Engine implemented** in `app/src/truthEngine.ts` — pure function, no I/O, no LLM - stale legacy files removed from backend source tree:
- `validate(actions, worldState)` → ValidationResult - `app/src/types.ts`
- `applyChanges(worldState, changes)` → new WorldState (immutable) - `app/src/latentEntities.ts`
- All 8 verbs handled with explicit rejection reasons - `app/src/llmAdapter.ts`
- `move` now supports a built-in offscene room convention via `createOffsceneRoom()` - Door/key MVP smoke checks pass:
- `latentEntities.ts` can promote plausible personal items from belief to fact when the actor has carrying context - `open door` before key -> `locked_requires_key`
- `db.ts` added with SQLite schema + persistence helpers for `entities`, `events`, `turns`, `beliefs`, and `summaries` - `take key` -> success
- Minimal Fastify server + app pipeline added with seeded world state and fallback parser - `open door` after key -> success
- Minimal Vite React inspector added for visual boot testing and state inspection
## Current Architecture Decisions ## Architecture Now
- App: Node.js + Fastify + TypeScript - Core contracts in `app/src/contracts/`:
- Frontend: React + Vite + TypeScript - `action.ts`
- Database: better-sqlite3 (synchronous, no ORM) - `validation.ts`
- Ollama is optional; system must work without it (per section 14) - `turn.ts`
- `Event` type renamed `GameEvent` in code to avoid collision with the DOM `Event` global - `entity.ts`
- Latent personal items are gated by facts-derived affordances, not accepted directly from beliefs - `world.ts`
- The offscene room is represented as a normal room entity with id `offscene` - Deterministic truth engine:
- App and frontend should be run and validated through Docker Compose rather than host-installed Node - `app/src/truthEngine.ts` with `validateActions(actions, worldState)`
- Ordered turn pipeline:
- `app/src/turns/processTurn.ts` parse -> validate -> apply -> persist
- World mutation:
- `app/src/world/applyActions.ts`
- Persistence:
- `app/src/db.ts` with tables `turns`, `actions`, `validation_results`, `entities`, `world_states`
- App seed domain:
- `app/src/app.ts` door/key world (`room_start`, `room_exit`, `player`, `door_1`, `key_1`)
## MVP Readiness (2026-04-23) ## New Endpoint
- Estimated completion: ~90% - Added `POST /api/reset` in `app/src/index.ts`
- Completed against spec: - App-level reset implementation in `app/src/app.ts`
- Turn pipeline implemented end-to-end (input -> parse -> validate -> apply -> events -> narration) - DB wipe support in `app/src/db.ts`
- Truth Engine is authoritative for state mutation - Verified reset behavior:
- SQLite persistence for entities/events/turns/beliefs/summaries - returns `{ worldState, turns }`
- Dockerized app + frontend stack, with optional Ollama profile - `turns` is empty after reset
- Minimal inspector UI for state/events/turns - `player` and `key_1` return to `room_start`
- MVP world bounded to 2 rooms and <=3 characters
- Remaining to call MVP done:
- Run one clean boot smoke pass from current branch and record expected outputs in this file
- Add a short "MVP acceptance checklist" section and mark each contract as pass/fail
- Tighten fallback parser behavior for known starter prompts and ensure no regression on latent-item turns
## MVP Acceptance Checklist ## Frontend Migration Notes
- PASS: Core contracts implemented (Entity, Action, ValidationResult, StateChange, GameEvent) - `frontend/src/App.tsx` migrated to consume new backend contracts.
- PASS: Truth Engine is sole authority for state mutation - Removed dependencies on old fields (`narration`, `events`, `beliefs`, `summaries`, `parser_feedback`).
- PASS: Invalid actions produce explicit rejection reasons - Turn submit flow now refreshes snapshot via `GET /api/state` after `POST /api/turn`.
- PASS: Turn flow wired end-to-end in app service - Reset call now sends JSON body/content-type to satisfy Fastify media-type validation.
- PASS: SQLite tables for entities/events/turns/beliefs/summaries
- PASS: Dockerized app + frontend, no host Node dependency required
- PASS: MVP scope bounds respected (2 rooms, <=3 characters, limited verb set)
- PASS: Frontend inspector can submit turns and inspect state/events
- PASS: Latent personal-item flow works in runtime (`pull out my phone` => promoted + accepted)
- PASS: Parser now fails gracefully with explicit rephrase guidance for unknown/underspecified input
## Next Steps ## Remaining Checks
1. Execute and capture a final Docker boot smoke check (`/health`, `/api/state`, `/api/turn` happy path + latent-item turn) 1. Frontend build pass completed in container (`docker compose exec frontend npm run build`).
2. Add explicit MVP acceptance checklist results here (contracts, scope limits, runtime constraints) 2. Validate inspector UX manually against the door/key flow.
3. Do a small parser-hardening pass for prompt coverage, then freeze MVP scope 3. Expand parser coverage only within current clean MVP domain.
4. Defer non-MVP work (container-entity plausibility, richer narration, full LLM adapter)
## Open Questions
- None currently blocking MVP implementation.
## Resolved Decisions (2026-04-23)
- Room/location model: keep `location` as an entity attribute that points to another entity id. Rooms remain normal entities (`type: room`) and inventory uses `inventory:<actor_id>`.
- MVP initial world: lock to 2 rooms (`garden`, `shed`) and 2 characters (`player`, `groundskeeper`), plus static scene objects (`gate`, `bench`) and built-in `offscene` room.
- Latent personal-item plausibility: use actor attributes as the MVP source of truth (`clothed`, `pocket_count`, `has_bag`, `searched_empty`).
- Container/worn-item entities: defer to post-MVP. Add this as a planned extension, not a current gate for plausibility.
## Follow-up Implications
1. Keep truth rules keyed to entity `attributes.location` and avoid introducing a separate room-graph subsystem yet.
2. Keep fallback parser and truth engine behavior tuned to the locked MVP world and verb set.
3. Add a backlog item for container-aware plausibility (`worn`, `container`, nested inventory checks) after MVP boot/test milestone.
## Turn Contract (Higher Stack)
- End-user turn response should always include:
- `narration` (what happened)
- `parser_feedback` when intent is unclear or underspecified
- accepted/rejected action detail for debugger/inspector views
- UX rule: when `parser_feedback` is present, UI should explicitly surface it and encourage rephrasing.
## LLM Prompting Contract (In-Environment)
- Added prompt builder in `app/src/llmAdapter.ts` (`buildActionExtractionPrompt`) to enforce world-grounded extraction.
- Prompt constraints:
- only allowed verbs
- only known entity ids from current world snapshot
- strict JSON output shape (`actions`, optional `parser_feedback`)
- no freeform world mutation outside truth-engine validation
## Session Notes
- 2026-04-23: Project started. Scaffold, type contracts, .gitignore, and .env.example created.
- 2026-04-23: Truth Engine implemented. Pure validation with per-verb handlers and immutable applyChanges helper.
- 2026-04-23: Added facts/affordances + latent entity resolver for improv-style personal items, plus offscene room support.
- 2026-04-23: Added SQLite schema module. Host `npm install` is blocked by `better-sqlite3` on Windows Node 25, so runtime validation should happen inside Docker on an LTS Node image instead.
- 2026-04-23: Added minimal backend/frontend boot slice so the project can be tested visually through Docker.
- 2026-04-23: Runtime smoke check passed with live services (`/health` ok, state returned, `look around` accepted, `pull out my phone` accepted with zero rejections).
- 2026-04-23: Fallback parser hardened to return human-readable guidance on unclear input (e.g., unknown intent, missing take target, missing move destination) and suggest concrete rephrasing examples.