Compare commits

...

2 Commits

7 changed files with 244 additions and 18 deletions

View File

@@ -262,6 +262,7 @@ export function createDatabase(config: DatabaseConfig): CharacterGardenDatabase
DELETE FROM world_states; DELETE FROM world_states;
DELETE FROM turns; DELETE FROM turns;
DELETE FROM entities; DELETE FROM entities;
DELETE FROM rulebooks;
`); `);
}, },

View File

@@ -0,0 +1,6 @@
import Database from "better-sqlite3";
const db = new Database(process.env.DB_PATH ?? "/var/lib/charactergarden/app.db");
db.prepare("CREATE TABLE IF NOT EXISTS probe(value TEXT)").run();
db.close();
console.log("sqlite_probe_ok");

View File

@@ -0,0 +1,34 @@
import assert from "node:assert/strict";
import { createCharacterGardenApp } from "../app";
function run(): void {
const app = createCharacterGardenApp(process.env.DB_PATH ?? "/var/lib/charactergarden/app.db");
const snapshot = app.getSnapshot();
const rulebook = app.getRulebook();
assert.equal(snapshot.turns.length, 0, "Expected no persisted turns after database reset.");
assert.ok(snapshot.worldState.entities.player, "Expected player in default world state.");
assert.ok(snapshot.worldState.entities.room_start, "Expected room_start in default world state.");
assert.equal(rulebook.id, "rulebook_default", "Expected default rulebook to be active.");
assert.equal(rulebook.version, 1, "Expected default rulebook version to be 1.");
console.log(
JSON.stringify(
{
ok: true,
entityCount: Object.keys(snapshot.worldState.entities).length,
turnCount: snapshot.turns.length,
rulebookId: rulebook.id,
rulebookVersion: rulebook.version,
ruleCount: rulebook.rules.length,
},
null,
2
)
);
app.db.close();
}
run();

View File

@@ -7,10 +7,10 @@ services:
environment: environment:
- APP_PORT=3000 - APP_PORT=3000
- NODE_ENV=${NODE_ENV:-development} - NODE_ENV=${NODE_ENV:-development}
- DB_PATH=/data/sqlite/app.db - DB_PATH=/var/lib/charactergarden/app.db
- OLLAMA_URL=${OLLAMA_URL:-http://ollama:11434} - OLLAMA_URL=${OLLAMA_URL:-http://ollama:11434}
volumes: volumes:
- ./data:/data - sqlite_data:/var/lib/charactergarden
- ./app/src:/app/src - ./app/src:/app/src
frontend: frontend:
@@ -38,3 +38,4 @@ services:
volumes: volumes:
ollama_data: ollama_data:
sqlite_data:

View File

@@ -1,4 +1,4 @@
import { FormEvent, useEffect, useState } from "react"; import { FormEvent, useEffect, useMemo, useState } from "react";
type Entity = { type Entity = {
id: string; id: string;
@@ -28,7 +28,27 @@ type Turn = {
validation: ValidationResult[]; validation: ValidationResult[];
createdAt: number; createdAt: number;
interpreter?: { interpreter?: {
interpreterVersion: string;
resolutionSource: "deterministic" | "llm" | "hybrid";
minConfidence: number;
status: "resolved" | "needs_clarification" | "rejected"; status: "resolved" | "needs_clarification" | "rejected";
selectedConfidence?: number;
candidates?: Array<{
action: Action;
confidence: number;
rationale?: string;
}>;
diagnostics: string[];
clarification?: {
reasonCode: string;
question: string;
field?: string;
options?: Array<{
id: string;
label: string;
value: string;
}>;
};
}; };
}; };
@@ -51,12 +71,20 @@ type ProcessTurnResponse = {
worldState: WorldState; worldState: WorldState;
interpreter: { interpreter: {
interpreterVersion: string; interpreterVersion: string;
resolutionSource: "deterministic" | "llm" | "hybrid";
minConfidence: number;
status: "resolved" | "needs_clarification" | "rejected"; status: "resolved" | "needs_clarification" | "rejected";
selectedConfidence?: number; selectedConfidence?: number;
candidates: Array<{
action: Action;
confidence: number;
rationale?: string;
}>;
diagnostics: string[]; diagnostics: string[];
clarification?: { clarification?: {
reasonCode: string; reasonCode: string;
question: string; question: string;
field?: string;
options?: Array<{ options?: Array<{
id: string; id: string;
label: string; label: string;
@@ -91,14 +119,37 @@ type SceneRulebook = {
updatedAt: number; updatedAt: number;
}; };
type RulebookListItem = {
id: string;
name: string;
version: number;
updatedAt: number;
};
const starterPrompts = [ const starterPrompts = [
"look around", "look around",
"take key", "take key",
"take lantern",
"give key to groundskeeper", "give key to groundskeeper",
"introduce jeff",
"describe groundskeeper as patient",
"open door", "open door",
"move to exit", "move to exit",
]; ];
function formatConfidence(value: number | undefined): string {
if (typeof value !== "number") return "n/a";
return `${Math.round(value * 100)}%`;
}
function formatTurnTime(epochMs: number): string {
try {
return new Date(epochMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
} catch {
return String(epochMs);
}
}
async function fetchJson<T>(input: RequestInfo, init?: RequestInit): Promise<T> { async function fetchJson<T>(input: RequestInfo, init?: RequestInit): Promise<T> {
const response = await fetch(input, init); const response = await fetch(input, init);
if (!response.ok) { if (!response.ok) {
@@ -111,7 +162,7 @@ async function fetchJson<T>(input: RequestInfo, init?: RequestInit): Promise<T>
// Rulebook editor component // Rulebook editor component
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function RulebookEditor() { function RulebookEditor(props: { onSaved?: (rulebook: SceneRulebook) => void }) {
const [rulebook, setRulebook] = useState<SceneRulebook | null>(null); const [rulebook, setRulebook] = useState<SceneRulebook | null>(null);
const [drafts, setDrafts] = useState<Record<string, string>>({}); const [drafts, setDrafts] = useState<Record<string, string>>({});
const [parseErrors, setParseErrors] = useState<Record<string, string>>({}); const [parseErrors, setParseErrors] = useState<Record<string, string>>({});
@@ -182,6 +233,7 @@ function RulebookEditor() {
body: JSON.stringify({ ...rulebook, rules: updatedRules }), body: JSON.stringify({ ...rulebook, rules: updatedRules }),
}); });
setRulebook(updated); setRulebook(updated);
props.onSaved?.(updated);
setSaved(true); setSaved(true);
setTimeout(() => setSaved(false), 2500); setTimeout(() => setSaved(false), 2500);
} catch (err) { } catch (err) {
@@ -198,6 +250,9 @@ function RulebookEditor() {
<div className="rulebook-header"> <div className="rulebook-header">
<div> <div>
<p className="rulebook-name">{rulebook.name}</p> <p className="rulebook-name">{rulebook.name}</p>
<p className="rulebook-desc">
Version {rulebook.version} | Updated {formatTurnTime(rulebook.updatedAt)}
</p>
{rulebook.description ? ( {rulebook.description ? (
<p className="rulebook-desc">{rulebook.description}</p> <p className="rulebook-desc">{rulebook.description}</p>
) : null} ) : null}
@@ -290,10 +345,21 @@ export default function App() {
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [tab, setTab] = useState<"world" | "rulebook">("world"); const [tab, setTab] = useState<"world" | "rulebook">("world");
const [activeRulebook, setActiveRulebook] = useState<SceneRulebook | null>(null);
const [rulebooks, setRulebooks] = useState<RulebookListItem[]>([]);
async function refreshRulebookState() {
const [active, list] = await Promise.all([
fetchJson<SceneRulebook>("/api/rulebook"),
fetchJson<RulebookListItem[]>("/api/rulebooks"),
]);
setActiveRulebook(active);
setRulebooks(list);
}
useEffect(() => { useEffect(() => {
void fetchJson<AppSnapshot>("/api/state") void Promise.all([fetchJson<AppSnapshot>("/api/state"), refreshRulebookState()])
.then((data) => { .then(([data]) => {
setSnapshot(data); setSnapshot(data);
setLoading(false); setLoading(false);
}) })
@@ -315,7 +381,10 @@ export default function App() {
body: JSON.stringify({ input }), body: JSON.stringify({ input }),
}); });
setLatest(result); setLatest(result);
const nextSnapshot = await fetchJson<AppSnapshot>("/api/state"); const [nextSnapshot] = await Promise.all([
fetchJson<AppSnapshot>("/api/state"),
refreshRulebookState(),
]);
setSnapshot(nextSnapshot); setSnapshot(nextSnapshot);
} catch (submitError) { } catch (submitError) {
setError(submitError instanceof Error ? submitError.message : "Unknown error"); setError(submitError instanceof Error ? submitError.message : "Unknown error");
@@ -334,12 +403,22 @@ export default function App() {
}); });
setSnapshot(result); setSnapshot(result);
setLatest(null); setLatest(null);
await refreshRulebookState();
} catch (resetError) { } catch (resetError) {
setError(resetError instanceof Error ? resetError.message : "Unknown error"); setError(resetError instanceof Error ? resetError.message : "Unknown error");
} }
} }
const entities = snapshot ? Object.values(snapshot.worldState.entities) : []; const entities = snapshot ? Object.values(snapshot.worldState.entities) : [];
const turns = useMemo(() => snapshot?.turns.slice().reverse() ?? [], [snapshot]);
function applyClarificationOption(optionValue: string) {
setInput((prev) => {
const trimmed = prev.trim();
if (!trimmed) return optionValue;
return `${trimmed} ${optionValue}`;
});
}
return ( return (
<main className="page-shell"> <main className="page-shell">
@@ -382,20 +461,32 @@ export default function App() {
<p><strong>Input:</strong> {latest.rawText}</p> <p><strong>Input:</strong> {latest.rawText}</p>
<p> <p>
<strong>Interpreter:</strong> {latest.interpreter.status} <strong>Interpreter:</strong> {latest.interpreter.status}
{typeof latest.interpreter.selectedConfidence === "number" {` via ${latest.interpreter.resolutionSource}`}
? ` (${Math.round(latest.interpreter.selectedConfidence * 100)}% confidence)` {` | model threshold ${formatConfidence(latest.interpreter.minConfidence)}`}
: ""} {` | selected ${formatConfidence(latest.interpreter.selectedConfidence)}`}
</p>
<p>
<strong>Interpreter version:</strong> {latest.interpreter.interpreterVersion}
</p> </p>
{latest.interpreter.clarification ? ( {latest.interpreter.clarification ? (
<p> <p>
<strong>Clarification:</strong> {latest.interpreter.clarification.question} <strong>Clarification ({latest.interpreter.clarification.reasonCode}):</strong>{" "}
{latest.interpreter.clarification.question}
</p> </p>
) : null} ) : null}
{latest.interpreter.clarification?.options?.length ? ( {latest.interpreter.clarification?.options?.length ? (
<p> <div className="chips">
<strong>Options:</strong>{" "} {latest.interpreter.clarification.options.map((o) => (
{latest.interpreter.clarification.options.map((o) => o.label).join(", ")} <button
</p> key={o.id}
type="button"
className="chip"
onClick={() => applyClarificationOption(o.value)}
>
clarify: {o.label}
</button>
))}
</div>
) : null} ) : null}
<ul className="timeline-list compact"> <ul className="timeline-list compact">
{latest.validation.map((v) => ( {latest.validation.map((v) => (
@@ -417,6 +508,25 @@ export default function App() {
<strong>Diagnostics:</strong> {latest.interpreter.diagnostics.join(" | ")} <strong>Diagnostics:</strong> {latest.interpreter.diagnostics.join(" | ")}
</p> </p>
) : null} ) : null}
{latest.actions.length > 0 ? (
<details>
<summary>Selected actions ({latest.actions.length})</summary>
<pre>{JSON.stringify(latest.actions, null, 2)}</pre>
</details>
) : null}
{latest.interpreter.candidates.length > 0 ? (
<details>
<summary>Interpreter candidates ({latest.interpreter.candidates.length})</summary>
<ul className="timeline-list compact">
{latest.interpreter.candidates.map((candidate, index) => (
<li key={`${candidate.action.type}-${index}`}>
{candidate.action.type} at {formatConfidence(candidate.confidence)}
{candidate.rationale ? ` - ${candidate.rationale}` : ""}
</li>
))}
</ul>
</details>
) : null}
</section> </section>
) : null} ) : null}
@@ -446,6 +556,14 @@ export default function App() {
<article className="panel"> <article className="panel">
<h2>World state</h2> <h2>World state</h2>
{loading && !snapshot ? <p>Loading...</p> : null} {loading && !snapshot ? <p>Loading...</p> : null}
{snapshot ? (
<div className="meta-grid">
<p className="meta-kv"><strong>World ID:</strong> {snapshot.worldState.id}</p>
<p className="meta-kv"><strong>Created:</strong> {formatTurnTime(snapshot.worldState.createdAt)}</p>
<p className="meta-kv"><strong>Domain:</strong> {String(snapshot.worldState.metadata?.domain ?? "unknown")}</p>
<p className="meta-kv"><strong>World schema:</strong> {String(snapshot.worldState.metadata?.version ?? "unknown")}</p>
</div>
) : null}
<ul className="entity-list"> <ul className="entity-list">
{entities.map((entity) => ( {entities.map((entity) => (
<li key={entity.id}> <li key={entity.id}>
@@ -459,11 +577,25 @@ export default function App() {
<article className="panel"> <article className="panel">
<h2>Turn log</h2> <h2>Turn log</h2>
<ul className="timeline-list"> <ul className="timeline-list">
{snapshot?.turns.slice().reverse().map((turn) => ( {turns.map((turn) => (
<li key={turn.id}> <li key={turn.id}>
<strong>{turn.rawText}</strong> <strong>{turn.rawText}</strong>
<span className="turn-time"> at {formatTurnTime(turn.createdAt)}</span>
{turn.interpreter ? ( {turn.interpreter ? (
<span> [interp:{turn.interpreter.status}]</span> <span>
{" "}[interp:{turn.interpreter.status} via {turn.interpreter.resolutionSource};
conf {formatConfidence(turn.interpreter.selectedConfidence)}]
</span>
) : null}
{turn.interpreter?.clarification ? (
<p className="parser-hint">
Clarification ({turn.interpreter.clarification.reasonCode}): {turn.interpreter.clarification.question}
</p>
) : null}
{turn.interpreter?.diagnostics?.length ? (
<p className="turn-diagnostics">
Diagnostics: {turn.interpreter.diagnostics.join(" | ")}
</p>
) : null} ) : null}
{turn.validation.map((v) => ( {turn.validation.map((v) => (
<span key={v.actionIndex}> [{v.success ? "ok" : v.reason}]</span> <span key={v.actionIndex}> [{v.success ? "ok" : v.reason}]</span>
@@ -472,11 +604,35 @@ export default function App() {
))} ))}
</ul> </ul>
</article> </article>
<article className="panel">
<h2>System</h2>
{activeRulebook ? (
<div className="meta-grid">
<p className="meta-kv"><strong>Active rulebook:</strong> {activeRulebook.name}</p>
<p className="meta-kv"><strong>Rulebook ID:</strong> {activeRulebook.id}</p>
<p className="meta-kv"><strong>Rulebook version:</strong> {activeRulebook.version}</p>
<p className="meta-kv"><strong>Updated:</strong> {formatTurnTime(activeRulebook.updatedAt)}</p>
<p className="meta-kv"><strong>Saved rulebooks:</strong> {rulebooks.length}</p>
</div>
) : (
<p>Loading rulebook info...</p>
)}
{rulebooks.length > 0 ? (
<ul className="timeline-list compact">
{rulebooks.map((rb) => (
<li key={rb.id}>
<strong>{rb.name}</strong> ({rb.id}) v{rb.version} - updated {formatTurnTime(rb.updatedAt)}
</li>
))}
</ul>
) : null}
</article>
</> </>
) : ( ) : (
<article className="panel panel--full"> <article className="panel panel--full">
<h2>Rulebook editor</h2> <h2>Rulebook editor</h2>
<RulebookEditor /> <RulebookEditor onSaved={(rulebook) => setActiveRulebook(rulebook)} />
</article> </article>
)} )}
</section> </section>

View File

@@ -161,6 +161,19 @@ pre {
padding: 8px 10px; padding: 8px 10px;
} }
.meta-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 6px 12px;
margin-bottom: 10px;
}
.meta-kv {
margin: 0;
font-size: 0.86rem;
color: rgba(244, 239, 228, 0.86);
}
.error-banner { .error-banner {
margin-top: 16px; margin-top: 16px;
color: #ffd2b8; color: #ffd2b8;
@@ -375,4 +388,19 @@ pre {
.panel { .panel {
padding: 14px; padding: 14px;
} }
.meta-grid {
grid-template-columns: 1fr;
}
}
.turn-time {
opacity: 0.7;
font-size: 0.82rem;
}
.turn-diagnostics {
margin: 6px 0 0;
color: rgba(244, 239, 228, 0.74);
font-size: 0.82rem;
} }