feat: implement core application structure with Fastify server and SQLite persistence

- Add Fastify server in `app/src/index.ts` with health check and API routes for game state and turn processing.
- Create `latentEntities.ts` to handle personal item plausibility and promote beliefs to facts based on actor context.
- Introduce `llmAdapter.ts` for action extraction from prose input.
- Develop `truthEngine.ts` for pure validation logic, handling all verbs with explicit rejection reasons.
- Define new types in `types.ts` for facts, affordances, and latent entity requests/resolutions.
- Update `docker-compose.yml` for improved service structure and volume management.
- Create frontend structure with React, including Dockerfile, Vite configuration, and initial components for state inspection.
- Implement basic styles and HTML structure for the frontend application.
- Document current status and next steps in `thoughts.md`.
This commit is contained in:
2026-04-23 21:08:38 -04:00
parent 14a07bca7a
commit 1df2ae8164
20 changed files with 1830 additions and 14 deletions

View File

@@ -0,0 +1,287 @@
import fs from "node:fs";
import path from "node:path";
import Database from "better-sqlite3";
import { Action, Belief, Entity, GameEvent, Summary, Turn } from "./types";
export interface DatabaseConfig {
dbPath: string;
}
export interface CharacterGardenDatabase {
sqlite: Database.Database;
init(): void;
close(): void;
upsertEntity(entity: Entity): void;
listEntities(): Entity[];
insertEvent(event: GameEvent): void;
listEvents(): GameEvent[];
insertTurn(turn: Turn): void;
listTurns(): Turn[];
insertBelief(belief: Belief): void;
listBeliefs(entityId?: string): Belief[];
insertSummary(summary: Summary): void;
listSummaries(): Summary[];
}
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 {
const directory = path.dirname(dbPath);
fs.mkdirSync(directory, { recursive: true });
}
function parseJson<T>(value: string): T {
return JSON.parse(value) as T;
}
export function createDatabase(config: DatabaseConfig): CharacterGardenDatabase {
ensureParentDirectory(config.dbPath);
const sqlite = new Database(config.dbPath);
const initStatements = [
`
CREATE TABLE IF NOT EXISTS entities (
id TEXT PRIMARY KEY,
type TEXT NOT NULL,
name TEXT NOT NULL,
attributes_json TEXT NOT NULL
)
`,
`
CREATE TABLE IF NOT EXISTS events (
id TEXT PRIMARY KEY,
turn INTEGER NOT NULL,
action_json TEXT NOT NULL,
result TEXT NOT NULL CHECK(result IN ('success', 'fail')),
timestamp INTEGER NOT NULL
)
`,
`
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
)
`,
];
for (const statement of initStatements) {
sqlite.exec(statement);
}
const upsertEntityStatement = sqlite.prepare(`
INSERT INTO entities (id, type, name, attributes_json)
VALUES (@id, @type, @name, @attributes_json)
ON CONFLICT(id) DO UPDATE SET
type = excluded.type,
name = excluded.name,
attributes_json = excluded.attributes_json
`);
const listEntitiesStatement = sqlite.prepare(`
SELECT id, type, name, attributes_json
FROM entities
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(`
INSERT INTO turns (id, turn, input, output, timestamp)
VALUES (@id, @turn, @input, @output, @timestamp)
`);
const listTurnsStatement = sqlite.prepare(`
SELECT id, turn, input, output, timestamp
FROM turns
ORDER BY turn ASC
`);
const insertBeliefStatement = sqlite.prepare(`
INSERT INTO beliefs (entity_id, claim, confidence)
VALUES (@entity_id, @claim, @confidence)
ON CONFLICT(entity_id, claim) DO UPDATE SET
confidence = excluded.confidence
`);
const listBeliefsStatement = sqlite.prepare(`
SELECT entity_id, claim, confidence
FROM beliefs
ORDER BY entity_id ASC, claim ASC
`);
const listBeliefsByEntityStatement = sqlite.prepare(`
SELECT entity_id, claim, confidence
FROM beliefs
WHERE entity_id = ?
ORDER BY claim ASC
`);
const insertSummaryStatement = sqlite.prepare(`
INSERT INTO summaries (id, turn_start, turn_end, text, timestamp)
VALUES (@id, @turn_start, @turn_end, @text, @timestamp)
`);
const listSummariesStatement = sqlite.prepare(`
SELECT id, turn_start, turn_end, text, timestamp
FROM summaries
ORDER BY turn_start ASC, turn_end ASC
`);
return {
sqlite,
init() {
// Schema is applied on database construction so prepared statements are valid.
},
close() {
sqlite.close();
},
upsertEntity(entity) {
upsertEntityStatement.run({
id: entity.id,
type: entity.type,
name: entity.name,
attributes_json: JSON.stringify(entity.attributes),
});
},
listEntities() {
const rows = listEntitiesStatement.all() as EntityRow[];
return rows.map((row) => ({
id: row.id,
type: row.type,
name: row.name,
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) {
insertTurnStatement.run(turn);
},
listTurns() {
return listTurnsStatement.all() as TurnRow[];
},
insertBelief(belief) {
insertBeliefStatement.run(belief);
},
listBeliefs(entityId) {
if (entityId) {
return listBeliefsByEntityStatement.all(entityId) as BeliefRow[];
}
return listBeliefsStatement.all() as BeliefRow[];
},
insertSummary(summary) {
insertSummaryStatement.run({
id: summary.id,
turn_start: summary.turn_range[0],
turn_end: summary.turn_range[1],
text: summary.text,
timestamp: summary.timestamp,
});
},
listSummaries() {
const rows = listSummariesStatement.all() as SummaryRow[];
return rows.map((row) => ({
id: row.id,
turn_range: [row.turn_start, row.turn_end],
text: row.text,
timestamp: row.timestamp,
}));
},
};
}