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:
485
Implementation_plan.md
Normal file
485
Implementation_plan.md
Normal 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
BIN
charactergarden.zip
Normal file
Binary file not shown.
@@ -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(),
|
||||||
|
};
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
7
charactergarden/app/src/contracts/action.ts
Normal file
7
charactergarden/app/src/contracts/action.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export type Action = {
|
||||||
|
actorId: string;
|
||||||
|
type: string;
|
||||||
|
targetId?: string;
|
||||||
|
locationId?: string;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
};
|
||||||
6
charactergarden/app/src/contracts/entity.ts
Normal file
6
charactergarden/app/src/contracts/entity.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export type Entity = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
attributes: Record<string, unknown>;
|
||||||
|
};
|
||||||
10
charactergarden/app/src/contracts/turn.ts
Normal file
10
charactergarden/app/src/contracts/turn.ts
Normal 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;
|
||||||
|
};
|
||||||
6
charactergarden/app/src/contracts/validation.ts
Normal file
6
charactergarden/app/src/contracts/validation.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export type ValidationResult = {
|
||||||
|
actionIndex: number;
|
||||||
|
success: boolean;
|
||||||
|
reason?: string;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
8
charactergarden/app/src/contracts/world.ts
Normal file
8
charactergarden/app/src/contracts/world.ts
Normal 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;
|
||||||
|
};
|
||||||
@@ -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,
|
|
||||||
}));
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -1,209 +0,0 @@
|
|||||||
import {
|
|
||||||
Affordance,
|
|
||||||
Belief,
|
|
||||||
Entity,
|
|
||||||
Fact,
|
|
||||||
LatentEntityRequest,
|
|
||||||
LatentEntityResolution,
|
|
||||||
} from "./types";
|
|
||||||
import { WorldState } from "./truthEngine";
|
|
||||||
|
|
||||||
const PERSONAL_ITEM_NOUNS = new Set([
|
|
||||||
"phone",
|
|
||||||
"wallet",
|
|
||||||
"keys",
|
|
||||||
"notebook",
|
|
||||||
"pen",
|
|
||||||
"coin",
|
|
||||||
"id card",
|
|
||||||
"card",
|
|
||||||
]);
|
|
||||||
|
|
||||||
function asBoolean(value: unknown): boolean | null {
|
|
||||||
if (typeof value === "boolean") {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function asNumber(value: unknown): number | null {
|
|
||||||
if (typeof value === "number" && Number.isFinite(value)) {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function slugify(value: string): string {
|
|
||||||
return value
|
|
||||||
.trim()
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/[^a-z0-9]+/g, "-")
|
|
||||||
.replace(/^-+|-+$/g, "") || "item";
|
|
||||||
}
|
|
||||||
|
|
||||||
function deriveAffordances(actor: Entity): Affordance[] {
|
|
||||||
const clothed = asBoolean(actor.attributes["clothed"]);
|
|
||||||
const pocketCount = asNumber(actor.attributes["pocket_count"]);
|
|
||||||
const hasBag = asBoolean(actor.attributes["has_bag"]);
|
|
||||||
const searchedEmpty = asBoolean(actor.attributes["searched_empty"]);
|
|
||||||
|
|
||||||
const canConcealSmallItems =
|
|
||||||
searchedEmpty !== true &&
|
|
||||||
((clothed === true && (pocketCount ?? 0) > 0) || hasBag === true);
|
|
||||||
|
|
||||||
const reason = searchedEmpty === true
|
|
||||||
? "actor was previously established as carrying nothing"
|
|
||||||
: hasBag === true
|
|
||||||
? "actor is carrying a bag or container"
|
|
||||||
: clothed === true && (pocketCount ?? 0) > 0
|
|
||||||
? "actor is clothed and has pockets"
|
|
||||||
: "actor has no established carrying context for concealed items";
|
|
||||||
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
entity_id: actor.id,
|
|
||||||
key: "can_conceal_small_items",
|
|
||||||
enabled: canConcealSmallItems,
|
|
||||||
reason,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildBelief(actor: Entity, noun: string): Belief {
|
|
||||||
return {
|
|
||||||
entity_id: actor.id,
|
|
||||||
claim: `${actor.name} may be carrying a ${noun}`,
|
|
||||||
confidence: PERSONAL_ITEM_NOUNS.has(noun) ? 0.8 : 0.5,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function createEntityId(actorId: string, noun: string, worldState: WorldState): string {
|
|
||||||
const base = `${actorId}-${slugify(noun)}`;
|
|
||||||
if (!worldState.entities.has(base)) {
|
|
||||||
return base;
|
|
||||||
}
|
|
||||||
|
|
||||||
let suffix = 2;
|
|
||||||
while (worldState.entities.has(`${base}-${suffix}`)) {
|
|
||||||
suffix += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${base}-${suffix}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function createLatentEntity(
|
|
||||||
actor: Entity,
|
|
||||||
noun: string,
|
|
||||||
turn: number | undefined,
|
|
||||||
worldState: WorldState
|
|
||||||
): Entity {
|
|
||||||
const entityId = createEntityId(actor.id, noun, worldState);
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: entityId,
|
|
||||||
type: "item",
|
|
||||||
name: noun,
|
|
||||||
attributes: {
|
|
||||||
location: `inventory:${actor.id}`,
|
|
||||||
takeable: true,
|
|
||||||
useable: true,
|
|
||||||
provenance: {
|
|
||||||
introduced_turn: turn,
|
|
||||||
introduced_by: actor.id,
|
|
||||||
introduced_reason: "plausible_personal_item",
|
|
||||||
latent_from_belief: `${actor.name} may be carrying a ${noun}`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveLatentEntity(
|
|
||||||
request: LatentEntityRequest,
|
|
||||||
worldState: WorldState
|
|
||||||
): LatentEntityResolution {
|
|
||||||
const actor = worldState.entities.get(request.actor_id);
|
|
||||||
if (!actor) {
|
|
||||||
return {
|
|
||||||
accepted: false,
|
|
||||||
reason: `actor entity '${request.actor_id}' does not exist`,
|
|
||||||
facts: [],
|
|
||||||
beliefs: [],
|
|
||||||
affordances: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const noun = request.noun.trim().toLowerCase();
|
|
||||||
if (!noun) {
|
|
||||||
return {
|
|
||||||
accepted: false,
|
|
||||||
reason: "latent entity request requires a noun",
|
|
||||||
facts: [],
|
|
||||||
beliefs: [],
|
|
||||||
affordances: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const affordances = deriveAffordances(actor);
|
|
||||||
const concealment = affordances.find(
|
|
||||||
(affordance) => affordance.key === "can_conceal_small_items"
|
|
||||||
);
|
|
||||||
const belief = buildBelief(actor, noun);
|
|
||||||
|
|
||||||
if (asBoolean(actor.attributes["naked"]) === true) {
|
|
||||||
return {
|
|
||||||
accepted: false,
|
|
||||||
reason: `${actor.id} is established as naked and cannot plausibly conceal a ${noun}`,
|
|
||||||
facts: [],
|
|
||||||
beliefs: [belief],
|
|
||||||
affordances,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (concealment?.enabled !== true) {
|
|
||||||
return {
|
|
||||||
accepted: false,
|
|
||||||
reason: concealment?.reason ?? `no carrying context supports introducing a ${noun}`,
|
|
||||||
facts: [],
|
|
||||||
beliefs: [belief],
|
|
||||||
affordances,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!PERSONAL_ITEM_NOUNS.has(noun)) {
|
|
||||||
return {
|
|
||||||
accepted: false,
|
|
||||||
reason: `${noun} is not in the MVP plausible personal-item set`,
|
|
||||||
facts: [],
|
|
||||||
beliefs: [belief],
|
|
||||||
affordances,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const entity = createLatentEntity(actor, noun, request.turn, worldState);
|
|
||||||
|
|
||||||
const facts: Fact[] = [
|
|
||||||
{
|
|
||||||
entity_id: actor.id,
|
|
||||||
key: `may_have_${slugify(noun)}`,
|
|
||||||
value: true,
|
|
||||||
source: "inference",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
entity_id: entity.id,
|
|
||||||
key: "location",
|
|
||||||
value: `inventory:${actor.id}`,
|
|
||||||
source: "inference",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return {
|
|
||||||
accepted: true,
|
|
||||||
reason: `${noun} promoted from plausible latent belief to fact`,
|
|
||||||
entity,
|
|
||||||
facts,
|
|
||||||
beliefs: [belief],
|
|
||||||
affordances,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,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}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
42
charactergarden/app/src/parser/parseTextToActions.ts
Normal file
42
charactergarden/app/src/parser/parseTextToActions.ts
Normal 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 [];
|
||||||
|
}
|
||||||
@@ -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,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
48
charactergarden/app/src/turns/processTurn.ts
Normal file
48
charactergarden/app/src/turns/processTurn.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
73
charactergarden/app/src/world/applyActions.ts
Normal file
73
charactergarden/app/src/world/applyActions.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -1 +0,0 @@
|
|||||||
# sqlite data directory — tracked by git, contents ignored
|
|
||||||
@@ -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>
|
||||||
|
|||||||
578
project.md
578
project.md
@@ -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.
|
||||||
|
|
||||||
* 1–2 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
|
|
||||||
|
|||||||
139
thoughts.md
139
thoughts.md
@@ -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.
|
|
||||||
|
|||||||
Reference in New Issue
Block a user