feat(interpreter): implement hybrid intent resolution with LLM and deterministic fallback

- Added new contracts for intent interpretation, including InterpreterOutput and ResolverMode.
- Implemented deterministic intent resolver with clarity checks for ambiguous references and empty input.
- Developed LLM intent resolver that communicates with an external model, handling JSON responses and fallback clarifications.
- Created an interpretTurn function to manage intent resolution based on the selected resolver mode.
- Introduced validation for interpreter output to ensure integrity before processing actions.
- Established a turn manager to orchestrate turn processing, including action validation and world state mutation.
- Added integration tests to verify the functionality of the new intent resolution system.

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
2026-04-26 14:06:14 -04:00
parent ff9b86c3e9
commit fc10e46ccc
23 changed files with 1530 additions and 1012 deletions

View File

@@ -5,7 +5,8 @@
"scripts": {
"dev": "ts-node-dev --respawn --transpile-only src/index.ts",
"build": "tsc",
"start": "node dist/index.js"
"start": "node dist/index.js",
"test:integration": "npm run build && node dist/tests/integrationRunner.js"
},
"dependencies": {
"fastify": "^4.28.1",

View File

@@ -16,7 +16,7 @@ export interface AppSnapshot {
export interface CharacterGardenApp {
db: CharacterGardenDatabase;
getSnapshot(): AppSnapshot;
processTurn(rawText: string): ProcessTurnResponse;
processTurn(rawText: string): Promise<ProcessTurnResponse>;
getRulebook(): SceneRulebook;
upsertRulebook(rulebook: SceneRulebook): SceneRulebook;
listRulebooks(): SceneRulebook[];
@@ -219,9 +219,9 @@ export function createCharacterGardenApp(dbPath: string): CharacterGardenApp {
};
},
processTurn(rawText: string) {
async processTurn(rawText: string) {
const rulebook = loadActiveRulebook();
const result = processTurn(rawText, worldState, db, rulebook);
const result = await processTurn(rawText, worldState, db, rulebook);
worldState = result.worldState;
return result;
},
@@ -231,7 +231,11 @@ export function createCharacterGardenApp(dbPath: string): CharacterGardenApp {
},
upsertRulebook(rulebook: SceneRulebook) {
const updated: SceneRulebook = { ...rulebook, updatedAt: Date.now() };
const updated: SceneRulebook = {
...rulebook,
version: Number.isInteger(rulebook.version) ? rulebook.version : 1,
updatedAt: Date.now(),
};
db.upsertRulebook(updated);
activeRulebookId = updated.id;
return updated;

View File

@@ -0,0 +1,48 @@
import type { Action } from "./action";
export type InterpreterStatus =
| "resolved"
| "needs_clarification"
| "rejected";
export type ClarificationReasonCode =
| "UNRECOGNIZED_INTENT"
| "AMBIGUOUS_REFERENCE"
| "EMPTY_INPUT"
| "LOW_CONFIDENCE"
| "INTERNAL_INVALID_OUTPUT";
export type ClarificationOption = {
id: string;
label: string;
value: string;
entityId?: string;
entityType?: "character" | "item" | "room" | "unknown";
};
export type ClarificationRequest = {
reasonCode: ClarificationReasonCode;
question: string;
field?: "verb" | "target" | "item" | "recipient" | "location";
options?: ClarificationOption[];
};
export type InterpreterCandidate = {
action: Action;
confidence: number;
rationale?: string;
};
export type InterpreterOutput = {
interpreterVersion: string;
rawText: string;
actorId: string;
resolutionSource: "deterministic" | "llm" | "hybrid";
minConfidence: number;
selectedConfidence?: number;
status: InterpreterStatus;
selectedActions: Action[];
candidates: InterpreterCandidate[];
diagnostics: string[];
clarification?: ClarificationRequest;
};

View File

@@ -22,6 +22,8 @@ export type EntityRole = "actor" | "target" | "actorRoom" | "targetRoom";
* sameLocation — two entities share the same location attribute value
* actorIdIn — action.actorId is included in an allowed list
* actorNameIn — actor.name matches one of an allowed list (case-insensitive)
* actionMetadataEq — action.metadata[key] === value
* itemInInventory — item entity referenced by metadata key is in inventory of holder role
* attributeRef — entities[checkRole].attributes[prefix + entities[refRole].attributes[refAttribute]] === true
* metaValueNotInRoom — no entity of entityType in actor's room has name === action.metadata[metaKey]
*/
@@ -38,6 +40,8 @@ export type ConditionExpr =
| { op: "sameLocation"; roleA: EntityRole; roleB: EntityRole }
| { op: "actorIdIn"; allowedIds: string[] }
| { op: "actorNameIn"; allowedNames: string[] }
| { op: "actionMetadataEq"; key: string; value: unknown }
| { op: "itemInInventory"; itemMetadataKey: string; holderRole: EntityRole }
| {
op: "attributeRef";
/** Entity whose attribute is being tested */
@@ -86,6 +90,8 @@ export type ActionRuleSet = {
export type SceneRulebook = {
id: string;
worldId: string;
/** Increment when schema/policy format changes in breaking ways. */
version: number;
name: string;
description?: string;
rules: ActionRuleSet[];

View File

@@ -1,4 +1,5 @@
import type { Action } from "./action";
import type { InterpreterOutput } from "./intent";
import type { ValidationResult } from "./validation";
export type Turn = {
@@ -7,4 +8,5 @@ export type Turn = {
actions: Action[];
validation: ValidationResult[];
createdAt: number;
interpreter?: InterpreterOutput;
};

View File

@@ -4,6 +4,7 @@ import Database from "better-sqlite3";
import type { Action } from "./contracts/action";
import type { Entity } from "./contracts/entity";
import type { InterpreterOutput } from "./contracts/intent";
import type { SceneRulebook } from "./contracts/rulebook";
import type { Turn } from "./contracts/turn";
import type { ValidationResult } from "./contracts/validation";
@@ -21,6 +22,7 @@ export interface CharacterGardenDatabase {
listEntities(): Entity[];
insertTurn(turn: Turn): void;
listTurns(): Turn[];
insertInterpreterOutput(turnId: string, interpreter: InterpreterOutput): void;
insertActions(turnId: string, actions: Action[]): void;
insertValidationResults(turnId: string, results: ValidationResult[]): void;
insertWorldState(turnId: string | null, worldState: WorldState): void;
@@ -97,6 +99,7 @@ export function createDatabase(config: DatabaseConfig): CharacterGardenDatabase
CREATE TABLE IF NOT EXISTS rulebooks (
id TEXT PRIMARY KEY,
world_id TEXT NOT NULL,
version INTEGER NOT NULL DEFAULT 1,
name TEXT NOT NULL,
description TEXT,
rules_json TEXT NOT NULL,
@@ -104,12 +107,27 @@ export function createDatabase(config: DatabaseConfig): CharacterGardenDatabase
updated_at INTEGER NOT NULL
)
`,
`
CREATE TABLE IF NOT EXISTS interpreter_events (
turn_id TEXT PRIMARY KEY,
interpreter_json TEXT NOT NULL,
created_at INTEGER NOT NULL,
FOREIGN KEY(turn_id) REFERENCES turns(id)
)
`,
];
for (const statement of initStatements) {
sqlite.exec(statement);
}
// Backward-compatible migration for pre-versioned databases.
try {
sqlite.exec("ALTER TABLE rulebooks ADD COLUMN version INTEGER NOT NULL DEFAULT 1");
} catch {
// Column already exists.
}
const clearEntitiesStatement = sqlite.prepare("DELETE FROM entities");
const upsertEntityStatement = sqlite.prepare(`
INSERT INTO entities (id, name, type, attributes_json)
@@ -137,6 +155,19 @@ export function createDatabase(config: DatabaseConfig): CharacterGardenDatabase
ORDER BY created_at ASC
`);
const listInterpreterEventsStatement = sqlite.prepare(`
SELECT turn_id, interpreter_json
FROM interpreter_events
`);
const insertInterpreterOutputStatement = sqlite.prepare(`
INSERT INTO interpreter_events (turn_id, interpreter_json, created_at)
VALUES (@turn_id, @interpreter_json, @created_at)
ON CONFLICT(turn_id) DO UPDATE SET
interpreter_json = excluded.interpreter_json,
created_at = excluded.created_at
`);
const insertActionStatement = sqlite.prepare(`
INSERT INTO actions (
turn_id,
@@ -186,9 +217,10 @@ export function createDatabase(config: DatabaseConfig): CharacterGardenDatabase
`);
const upsertRulebookStatement = sqlite.prepare(`
INSERT INTO rulebooks (id, world_id, name, description, rules_json, created_at, updated_at)
VALUES (@id, @world_id, @name, @description, @rules_json, @created_at, @updated_at)
INSERT INTO rulebooks (id, world_id, version, name, description, rules_json, created_at, updated_at)
VALUES (@id, @world_id, @version, @name, @description, @rules_json, @created_at, @updated_at)
ON CONFLICT(id) DO UPDATE SET
version = excluded.version,
name = excluded.name,
description = excluded.description,
rules_json = excluded.rules_json,
@@ -196,13 +228,13 @@ export function createDatabase(config: DatabaseConfig): CharacterGardenDatabase
`);
const getRulebookStatement = sqlite.prepare(`
SELECT id, world_id, name, description, rules_json, created_at, updated_at
SELECT id, world_id, version, name, description, rules_json, created_at, updated_at
FROM rulebooks
WHERE id = @id
`);
const listRulebooksStatement = sqlite.prepare(`
SELECT id, world_id, name, description, rules_json, created_at, updated_at
SELECT id, world_id, version, name, description, rules_json, created_at, updated_at
FROM rulebooks
ORDER BY created_at ASC
`);
@@ -224,6 +256,7 @@ export function createDatabase(config: DatabaseConfig): CharacterGardenDatabase
wipe() {
sqlite.exec(`
DELETE FROM interpreter_events;
DELETE FROM validation_results;
DELETE FROM actions;
DELETE FROM world_states;
@@ -279,15 +312,33 @@ export function createDatabase(config: DatabaseConfig): CharacterGardenDatabase
created_at: number;
}>;
const interpreterRows = listInterpreterEventsStatement.all() as Array<{
turn_id: string;
interpreter_json: string;
}>;
const interpreterByTurnId = new Map<string, InterpreterOutput>();
for (const row of interpreterRows) {
interpreterByTurnId.set(row.turn_id, parseJson<InterpreterOutput>(row.interpreter_json));
}
return rows.map((row) => ({
id: row.id,
rawText: row.raw_text,
actions: [],
validation: [],
createdAt: row.created_at,
interpreter: interpreterByTurnId.get(row.id),
}));
},
insertInterpreterOutput(turnId, interpreter) {
insertInterpreterOutputStatement.run({
turn_id: turnId,
interpreter_json: JSON.stringify(interpreter),
created_at: Date.now(),
});
},
insertActions(turnId, actions) {
const tx = sqlite.transaction((actionList: Action[]) => {
actionList.forEach((action, index) => {
@@ -343,6 +394,7 @@ export function createDatabase(config: DatabaseConfig): CharacterGardenDatabase
upsertRulebookStatement.run({
id: rulebook.id,
world_id: rulebook.worldId,
version: rulebook.version,
name: rulebook.name,
description: rulebook.description ?? null,
rules_json: JSON.stringify(rulebook.rules),
@@ -356,6 +408,7 @@ export function createDatabase(config: DatabaseConfig): CharacterGardenDatabase
| {
id: string;
world_id: string;
version: number;
name: string;
description: string | null;
rules_json: string;
@@ -367,6 +420,7 @@ export function createDatabase(config: DatabaseConfig): CharacterGardenDatabase
return {
id: row.id,
worldId: row.world_id,
version: row.version ?? 1,
name: row.name,
description: row.description ?? undefined,
rules: parseJson(row.rules_json),
@@ -379,6 +433,7 @@ export function createDatabase(config: DatabaseConfig): CharacterGardenDatabase
const rows = listRulebooksStatement.all() as Array<{
id: string;
world_id: string;
version: number;
name: string;
description: string | null;
rules_json: string;
@@ -388,6 +443,7 @@ export function createDatabase(config: DatabaseConfig): CharacterGardenDatabase
return rows.map((row) => ({
id: row.id,
worldId: row.world_id,
version: row.version ?? 1,
name: row.name,
description: row.description ?? undefined,
rules: parseJson(row.rules_json),

View File

@@ -11,6 +11,7 @@ export function createDefaultRulebook(worldId: string): SceneRulebook {
return {
id: DEFAULT_RULEBOOK_ID,
worldId,
version: 1,
name: "Default Rulebook",
description: "Built-in scene rules for the CharacterGarden engine. Edit freely — the engine re-evaluates on every turn.",
createdAt: now,
@@ -27,23 +28,47 @@ export function createDefaultRulebook(worldId: string): SceneRulebook {
enabled: true,
checks: [
{
id: "take_target_exists",
description: "Target entity must exist in the world",
condition: { op: "entityExists", role: "target" },
id: "take_target_exists_or_actor_can_create",
description: "Target must exist, or actor must be authorized to create it when createIfMissing is true",
condition: {
op: "or",
conditions: [
{ op: "entityExists", role: "target" },
{
op: "and",
conditions: [
{ op: "actionMetadataEq", key: "createIfMissing", value: true },
{ op: "actorIdIn", allowedIds: ["player"] },
],
},
],
},
failReason: "target_not_found",
failMessage: "Target '{target.id}' does not exist.",
failMessage: "Target '{target.id}' does not exist, and actor '{actor.id}' is not allowed to create missing items.",
},
{
id: "take_same_location",
description: "Actor and target must be in the same location",
condition: { op: "sameLocation", roleA: "actor", roleB: "target" },
description: "If target exists, actor and target must be in the same location",
condition: {
op: "or",
conditions: [
{ op: "not", condition: { op: "entityExists", role: "target" } },
{ op: "sameLocation", roleA: "actor", roleB: "target" },
],
},
failReason: "not_in_same_location",
failMessage: "Target '{target.id}' is not in the same location as '{actor.id}'.",
},
{
id: "take_takeable",
description: "Target must have takeable attribute set to true",
condition: { op: "eq", role: "target", attribute: "takeable", value: true },
description: "If target exists, it must have takeable attribute set to true",
condition: {
op: "or",
conditions: [
{ op: "not", condition: { op: "entityExists", role: "target" } },
{ op: "eq", role: "target", attribute: "takeable", value: true },
],
},
failReason: "not_takeable",
failMessage: "Target '{target.id}' cannot be taken.",
},
@@ -242,6 +267,41 @@ export function createDefaultRulebook(worldId: string): SceneRulebook {
},
],
},
{
actionType: "transfer",
enabled: true,
checks: [
{
id: "transfer_recipient_exists",
description: "Recipient must exist in the world",
condition: { op: "entityExists", role: "target" },
failReason: "target_not_found",
failMessage: "Recipient '{target.id}' does not exist.",
},
{
id: "transfer_recipient_character",
description: "Recipient must be a character",
condition: { op: "entityType", role: "target", requiredType: "character" },
failReason: "target_not_character",
failMessage: "Recipient '{target.id}' is not a character.",
},
{
id: "transfer_same_location",
description: "Actor and recipient must be in the same location",
condition: { op: "sameLocation", roleA: "actor", roleB: "target" },
failReason: "not_in_same_location",
failMessage: "Recipient '{target.id}' is not in the same location as '{actor.id}'.",
},
{
id: "transfer_actor_holds_item",
description: "Actor must currently hold the specified item in inventory",
condition: { op: "itemInInventory", itemMetadataKey: "itemId", holderRole: "actor" },
failReason: "item_not_in_inventory",
failMessage: "Actor '{actor.id}' is not holding the requested item.",
},
],
},
],
};
}

View File

@@ -17,13 +17,13 @@ server.get("/api/state", async () => game.getSnapshot());
server.post("/api/reset", async () => game.reset());
server.post<{ Body: { input?: string } }>("/api/turn", async (request, reply) => {
const input = request.body?.input?.trim();
if (!input) {
const input = request.body?.input;
if (typeof input !== "string") {
reply.code(400);
return { error: "input is required" };
return { error: "input is required and must be a string" };
}
return game.processTurn(input);
return await game.processTurn(input);
});
// ---------------------------------------------------------------------------

View File

@@ -0,0 +1,131 @@
import type { InterpreterOutput } from "../../contracts/intent";
import type { ResolveIntentInput } from "../resolveIntent";
import { parseTextToActions } from "../../parser/parseTextToActions";
export const DETERMINISTIC_INTERPRETER_VERSION = "deterministic-v1";
function hasAmbiguousReference(input: string): boolean {
return /\b(it|them|that|this|him|her|there|here)\b/i.test(input);
}
export function resolveDeterministicIntent(input: ResolveIntentInput): InterpreterOutput {
const trimmed = input.rawText.trim();
if (!trimmed) {
return {
interpreterVersion: DETERMINISTIC_INTERPRETER_VERSION,
rawText: input.rawText,
actorId: input.actorId,
resolutionSource: "deterministic",
minConfidence: input.minConfidence,
status: "rejected",
selectedActions: [],
candidates: [],
diagnostics: ["Input was empty after trimming whitespace."],
clarification: {
reasonCode: "EMPTY_INPUT",
question: "What would you like to do?",
field: "verb",
},
};
}
const actions = parseTextToActions(trimmed, input.actorId);
if (actions.length > 0) {
const candidates = actions.map((action) => ({
action,
confidence: 0.85,
rationale: "Matched deterministic parser pattern and normalized to canonical action.",
}));
const selectedConfidence = candidates.reduce((min, c) => Math.min(min, c.confidence), 1);
if (selectedConfidence < input.minConfidence) {
return {
interpreterVersion: DETERMINISTIC_INTERPRETER_VERSION,
rawText: input.rawText,
actorId: input.actorId,
resolutionSource: "deterministic",
minConfidence: input.minConfidence,
selectedConfidence,
status: "needs_clarification",
selectedActions: [],
candidates,
diagnostics: [
"Parser produced candidates but confidence did not meet threshold.",
],
clarification: {
reasonCode: "LOW_CONFIDENCE",
question: "I found a possible action but confidence is low. Can you rephrase your intent?",
field: "verb",
options: [
{ id: "inspect", label: "Inspect", value: "inspect" },
{ id: "move", label: "Move", value: "move" },
{ id: "take", label: "Take", value: "take" },
{ id: "open", label: "Open", value: "open" },
{ id: "introduce", label: "Introduce", value: "introduce" },
{ id: "describe", label: "Describe", value: "describe" },
{ id: "transfer", label: "Transfer", value: "transfer" },
],
},
};
}
return {
interpreterVersion: DETERMINISTIC_INTERPRETER_VERSION,
rawText: input.rawText,
actorId: input.actorId,
resolutionSource: "deterministic",
minConfidence: input.minConfidence,
selectedConfidence,
status: "resolved",
selectedActions: actions,
candidates,
diagnostics: ["Resolved by deterministic parser rules."],
};
}
if (hasAmbiguousReference(trimmed)) {
return {
interpreterVersion: DETERMINISTIC_INTERPRETER_VERSION,
rawText: input.rawText,
actorId: input.actorId,
resolutionSource: "deterministic",
minConfidence: input.minConfidence,
status: "needs_clarification",
selectedActions: [],
candidates: [],
diagnostics: ["Could not resolve pronoun/reference to a concrete entity."],
clarification: {
reasonCode: "AMBIGUOUS_REFERENCE",
question: "I am not sure what that refers to. Which item, character, or location did you mean?",
field: "target",
},
};
}
return {
interpreterVersion: DETERMINISTIC_INTERPRETER_VERSION,
rawText: input.rawText,
actorId: input.actorId,
resolutionSource: "deterministic",
minConfidence: input.minConfidence,
status: "needs_clarification",
selectedActions: [],
candidates: [],
diagnostics: ["No parser pattern matched this input."],
clarification: {
reasonCode: "UNRECOGNIZED_INTENT",
question: "I could not map that to a known action. Try verbs like inspect, move, take, open, introduce, describe, or transfer.",
field: "verb",
options: [
{ id: "inspect", label: "Inspect", value: "inspect" },
{ id: "move", label: "Move", value: "move" },
{ id: "take", label: "Take", value: "take" },
{ id: "open", label: "Open", value: "open" },
{ id: "introduce", label: "Introduce", value: "introduce" },
{ id: "describe", label: "Describe", value: "describe" },
{ id: "transfer", label: "Transfer", value: "transfer" },
],
},
};
}

View File

@@ -0,0 +1,282 @@
import type { Action } from "../../contracts/action";
import type { InterpreterOutput } from "../../contracts/intent";
import type { ResolveIntentInput } from "../resolveIntent";
export const LLM_INTERPRETER_VERSION = "llm-v1-ollama";
type LlmClarification = {
reasonCode?: string;
question?: string;
field?: "verb" | "target" | "item" | "recipient" | "location";
options?: Array<{
id?: string;
label?: string;
value?: string;
entityId?: string;
entityType?: "character" | "item" | "room" | "unknown";
}>;
};
type LlmIntentResponse = {
status?: "resolved" | "needs_clarification" | "rejected";
selectedActions?: unknown;
selectedConfidence?: unknown;
clarification?: LlmClarification;
rationale?: string;
};
function fallbackClarification(input: ResolveIntentInput, diagnostic: string): InterpreterOutput {
return {
interpreterVersion: LLM_INTERPRETER_VERSION,
rawText: input.rawText,
actorId: input.actorId,
resolutionSource: "llm",
minConfidence: input.minConfidence,
status: "needs_clarification",
selectedActions: [],
candidates: [],
diagnostics: [diagnostic],
clarification: {
reasonCode: "UNRECOGNIZED_INTENT",
question: "I could not confidently resolve that intent. Please rephrase with a clear verb.",
field: "verb",
options: [
{ id: "inspect", label: "Inspect", value: "inspect" },
{ id: "move", label: "Move", value: "move" },
{ id: "take", label: "Take", value: "take" },
{ id: "open", label: "Open", value: "open" },
{ id: "introduce", label: "Introduce", value: "introduce" },
{ id: "describe", label: "Describe", value: "describe" },
{ id: "transfer", label: "Transfer", value: "transfer" },
],
},
};
}
function extractFirstJsonObject(text: string): string | null {
const trimmed = text.trim();
if (trimmed.startsWith("{") && trimmed.endsWith("}")) {
return trimmed;
}
const codeFenceMatch = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i);
if (codeFenceMatch?.[1]) {
const fenced = codeFenceMatch[1].trim();
if (fenced.startsWith("{") && fenced.endsWith("}")) {
return fenced;
}
}
const firstBrace = trimmed.indexOf("{");
const lastBrace = trimmed.lastIndexOf("}");
if (firstBrace >= 0 && lastBrace > firstBrace) {
return trimmed.slice(firstBrace, lastBrace + 1);
}
return null;
}
function toActionArray(value: unknown, actorId: string): Action[] {
if (!Array.isArray(value)) return [];
const actions: Action[] = [];
for (const item of value) {
if (!item || typeof item !== "object") continue;
const action = item as Record<string, unknown>;
const type = typeof action.type === "string" ? action.type.trim() : "";
if (!type) continue;
const normalized: Action = {
actorId,
type,
};
if (typeof action.actorId === "string" && action.actorId.trim()) {
normalized.actorId = action.actorId;
}
if (typeof action.targetId === "string" && action.targetId.trim()) {
normalized.targetId = action.targetId;
}
if (typeof action.locationId === "string" && action.locationId.trim()) {
normalized.locationId = action.locationId;
}
if (action.metadata && typeof action.metadata === "object" && !Array.isArray(action.metadata)) {
normalized.metadata = action.metadata as Record<string, unknown>;
}
actions.push(normalized);
}
return actions;
}
function toConfidence(value: unknown, fallback: number): number {
if (typeof value !== "number" || Number.isNaN(value)) {
return fallback;
}
if (value < 0) return 0;
if (value > 1) return 1;
return value;
}
function toReasonCode(value: string | undefined):
| "UNRECOGNIZED_INTENT"
| "AMBIGUOUS_REFERENCE"
| "EMPTY_INPUT"
| "LOW_CONFIDENCE"
| "INTERNAL_INVALID_OUTPUT" {
const normalized = (value ?? "").trim().toUpperCase();
switch (normalized) {
case "AMBIGUOUS_REFERENCE":
return "AMBIGUOUS_REFERENCE";
case "EMPTY_INPUT":
return "EMPTY_INPUT";
case "LOW_CONFIDENCE":
return "LOW_CONFIDENCE";
case "INTERNAL_INVALID_OUTPUT":
return "INTERNAL_INVALID_OUTPUT";
default:
return "UNRECOGNIZED_INTENT";
}
}
function buildPrompt(input: ResolveIntentInput): { system: string; user: string } {
const system = [
"You are an intent-to-actions resolver for a text adventure engine.",
"Return ONLY JSON with this shape:",
'{"status":"resolved|needs_clarification|rejected","selectedActions":[{"type":"inspect|move|take|open|introduce|describe|transfer","targetId":"optional","locationId":"optional","metadata":{"optional":"object"}}],"selectedConfidence":0.0,"clarification":{"reasonCode":"UNRECOGNIZED_INTENT|AMBIGUOUS_REFERENCE|EMPTY_INPUT|LOW_CONFIDENCE|INTERNAL_INVALID_OUTPUT","question":"string","field":"verb|target|item|recipient|location"},"rationale":"brief"}',
"If unresolved, selectedActions must be an empty array and clarification must be present.",
"Use canonical action types only. Do not invent fields.",
].join(" ");
const user = [
`actorId: ${input.actorId}`,
`input: ${JSON.stringify(input.rawText)}`,
`minimum_confidence: ${input.minConfidence}`,
].join("\n");
return { system, user };
}
export async function resolveLlmIntent(input: ResolveIntentInput): Promise<InterpreterOutput> {
const baseUrl = (process.env.LLM_RESOLVER_URL ?? process.env.OLLAMA_URL ?? "").trim();
const model = (process.env.LLM_RESOLVER_MODEL ?? "llama3.2:3b").trim();
const timeoutMs = Number(process.env.LLM_RESOLVER_TIMEOUT_MS ?? 6000);
if (!baseUrl) {
return fallbackClarification(input, "LLM resolver disabled: no LLM_RESOLVER_URL/OLLAMA_URL configured.");
}
const prompt = buildPrompt(input);
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), Number.isFinite(timeoutMs) ? timeoutMs : 6000);
try {
const response = await fetch(`${baseUrl.replace(/\/$/, "")}/api/chat`, {
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify({
model,
stream: false,
format: "json",
options: {
temperature: 0,
},
messages: [
{ role: "system", content: prompt.system },
{ role: "user", content: prompt.user },
],
}),
signal: controller.signal,
});
if (!response.ok) {
return fallbackClarification(
input,
`LLM resolver HTTP error: ${response.status} ${response.statusText}`
);
}
const payload = (await response.json()) as {
message?: { content?: string };
};
const text = payload.message?.content ?? "";
const jsonText = extractFirstJsonObject(text);
if (!jsonText) {
return fallbackClarification(input, "LLM resolver returned non-JSON content.");
}
let parsed: LlmIntentResponse;
try {
parsed = JSON.parse(jsonText) as LlmIntentResponse;
} catch {
return fallbackClarification(input, "LLM resolver returned malformed JSON.");
}
const status = parsed.status ?? "needs_clarification";
const selectedActions = toActionArray(parsed.selectedActions, input.actorId);
const selectedConfidence = toConfidence(parsed.selectedConfidence, 0.7);
const diagnostics = [
"Resolved via LLM resolver.",
...(parsed.rationale ? [parsed.rationale] : []),
];
if (status === "resolved" && selectedActions.length > 0 && selectedConfidence >= input.minConfidence) {
return {
interpreterVersion: LLM_INTERPRETER_VERSION,
rawText: input.rawText,
actorId: input.actorId,
resolutionSource: "llm",
minConfidence: input.minConfidence,
selectedConfidence,
status: "resolved",
selectedActions,
candidates: selectedActions.map((action) => ({
action,
confidence: selectedConfidence,
rationale: "Selected by configured LLM resolver.",
})),
diagnostics,
};
}
return {
interpreterVersion: LLM_INTERPRETER_VERSION,
rawText: input.rawText,
actorId: input.actorId,
resolutionSource: "llm",
minConfidence: input.minConfidence,
selectedConfidence,
status: status === "rejected" ? "rejected" : "needs_clarification",
selectedActions: [],
candidates: [],
diagnostics: [
"LLM resolver did not produce a high-confidence resolved action set.",
...diagnostics,
],
clarification: {
reasonCode: toReasonCode(parsed.clarification?.reasonCode),
question:
parsed.clarification?.question ??
"I need a clearer command. Please rephrase with a specific verb and target.",
field: parsed.clarification?.field,
options: parsed.clarification?.options
?.filter((option) => !!option && typeof option.value === "string" && option.value.trim())
.map((option, index) => ({
id: option.id ?? `llm-option-${index + 1}`,
label: option.label ?? option.value ?? "Option",
value: option.value ?? "",
entityId: option.entityId,
entityType: option.entityType,
})),
},
};
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown LLM resolver error.";
return fallbackClarification(input, `LLM resolver request failed: ${message}`);
} finally {
clearTimeout(timeout);
}
}

View File

@@ -0,0 +1,67 @@
import type { InterpreterOutput } from "../contracts/intent";
import { resolveDeterministicIntent } from "./adapters/deterministicResolver";
import { resolveLlmIntent } from "./adapters/llmResolver";
import {
type ResolverMode,
normalizeResolverMode,
} from "./resolveIntent";
const DEFAULT_MIN_CONFIDENCE = 0.65;
type InterpretTurnOptions = {
mode?: ResolverMode;
minConfidence?: number;
};
function getResolverMode(options?: InterpretTurnOptions): ResolverMode {
if (options?.mode) {
return options.mode;
}
return normalizeResolverMode(process.env.INTENT_RESOLVER_MODE);
}
function buildInput(rawText: string, actorId: string, options?: InterpretTurnOptions) {
return {
rawText,
actorId,
minConfidence: options?.minConfidence ?? DEFAULT_MIN_CONFIDENCE,
};
}
export async function interpretTurn(
rawText: string,
actorId = "player",
options?: InterpretTurnOptions
): Promise<InterpreterOutput> {
const mode = getResolverMode(options);
const input = buildInput(rawText, actorId, options);
if (mode === "deterministic") {
return resolveDeterministicIntent(input);
}
if (mode === "llm") {
return resolveLlmIntent(input);
}
// hybrid mode: prefer LLM when available, but deterministically fall back.
const llmOutput = await resolveLlmIntent(input);
if (llmOutput.status === "resolved") {
return {
...llmOutput,
resolutionSource: "hybrid",
diagnostics: ["Hybrid mode: resolved via LLM adapter.", ...llmOutput.diagnostics],
};
}
const deterministicOutput = resolveDeterministicIntent(input);
return {
...deterministicOutput,
resolutionSource: "hybrid",
diagnostics: [
"Hybrid mode: LLM adapter did not resolve intent; used deterministic fallback.",
...deterministicOutput.diagnostics,
...llmOutput.diagnostics,
],
};
}

View File

@@ -0,0 +1,22 @@
import type { InterpreterOutput } from "../contracts/intent";
export type ResolverMode = "deterministic" | "llm" | "hybrid";
export type ResolveIntentInput = {
rawText: string;
actorId: string;
minConfidence: number;
};
export type IntentResolver = {
name: string;
resolve(input: ResolveIntentInput): Promise<InterpreterOutput> | InterpreterOutput;
};
export function normalizeResolverMode(value: string | undefined): ResolverMode {
const normalized = (value ?? "").trim().toLowerCase();
if (normalized === "deterministic" || normalized === "llm" || normalized === "hybrid") {
return normalized;
}
return "hybrid";
}

View File

@@ -0,0 +1,94 @@
import type { InterpreterOutput } from "../contracts/intent";
const VALID_STATUSES = new Set(["resolved", "needs_clarification", "rejected"]);
export type InterpreterValidation = {
isValid: boolean;
issues: string[];
};
/**
* Runtime guard for the interpreter boundary.
*
* The turn manager uses this to ensure malformed interpreter output never
* reaches deterministic validation/mutation logic.
*/
export function validateInterpreterOutput(output: InterpreterOutput): InterpreterValidation {
const issues: string[] = [];
if (!output || typeof output !== "object") {
return { isValid: false, issues: ["Interpreter output must be an object."] };
}
if (typeof output.interpreterVersion !== "string" || !output.interpreterVersion.trim()) {
issues.push("interpreterVersion must be a non-empty string.");
}
if (typeof output.rawText !== "string") {
issues.push("rawText must be a string.");
}
if (typeof output.actorId !== "string" || !output.actorId.trim()) {
issues.push("actorId must be a non-empty string.");
}
if (!VALID_STATUSES.has(output.status)) {
issues.push("status must be one of: resolved, needs_clarification, rejected.");
}
if (!Array.isArray(output.selectedActions)) {
issues.push("selectedActions must be an array.");
}
if (!Array.isArray(output.candidates)) {
issues.push("candidates must be an array.");
}
if (!Array.isArray(output.diagnostics)) {
issues.push("diagnostics must be an array.");
}
if (typeof output.minConfidence !== "number" || output.minConfidence < 0 || output.minConfidence > 1) {
issues.push("minConfidence must be a number between 0 and 1.");
}
if (output.selectedConfidence !== undefined) {
if (
typeof output.selectedConfidence !== "number" ||
output.selectedConfidence < 0 ||
output.selectedConfidence > 1
) {
issues.push("selectedConfidence must be between 0 and 1 when provided.");
}
}
for (const candidate of output.candidates) {
if (typeof candidate.confidence !== "number" || candidate.confidence < 0 || candidate.confidence > 1) {
issues.push("Every candidate confidence must be between 0 and 1.");
break;
}
}
if (output.status === "resolved") {
if (output.selectedActions.length === 0) {
issues.push("resolved output must include at least one selected action.");
}
if (output.clarification) {
issues.push("resolved output must not include clarification.");
}
}
if (output.status !== "resolved") {
if (output.selectedActions.length > 0) {
issues.push("unresolved/rejected output must not include selected actions.");
}
if (!output.clarification) {
issues.push("unresolved/rejected output must include clarification.");
}
}
return {
isValid: issues.length === 0,
issues,
};
}

View File

@@ -12,6 +12,16 @@ function toDisplayName(value: string): string {
.join(" ");
}
function toItemSlug(value: string): string {
return (
value
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "_")
.replace(/^_+|_+$/g, "") || "item"
);
}
function extractIntroducedCharacterName(input: string): string | undefined {
const match = input.match(/(?:introduce|bring in|invite|have)\s+(?:the\s+|a\s+|an\s+)?(.+?)(?:\s+join)?$/);
const rawName = match?.[1]?.trim();
@@ -29,6 +39,52 @@ function extractActorAndAction(sentence: string): { actorName?: string; action:
return { action: normalized_sent };
}
function extractTakenItemName(input: string): string | undefined {
const match = input.match(/(?:take|pick up|grab)\s+(?:the\s+|a\s+|an\s+)?(.+)$/);
const rawName = match?.[1]?.trim();
if (!rawName) {
return undefined;
}
return rawName.replace(/^(the|a|an)\s+/, "").trim() || undefined;
}
function extractTransferParts(input: string): { itemName: string; recipientName: string } | undefined {
const match = input.match(
/(?:give|hand|pass|transfer)\s+(?:the\s+|a\s+|an\s+)?(.+?)\s+(?:to|over to)\s+(?:the\s+)?(.+)$/
);
if (!match) {
return undefined;
}
const itemName = match[1]?.trim().replace(/^(the|a|an)\s+/, "").trim();
const recipientName = match[2]?.trim().replace(/^(the|a|an)\s+/, "").trim();
if (!itemName || !recipientName) {
return undefined;
}
return { itemName, recipientName };
}
function toCharacterSlug(value: string): string {
return (
value
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "_")
.replace(/^_+|_+$/g, "") || "character"
);
}
function resolveRecipientId(name: string): string {
const n = name.trim().toLowerCase();
if (n === "player" || n === "me" || n === "myself") {
return "player";
}
if (n === "groundskeeper") {
return "groundskeeper";
}
return `character_${toCharacterSlug(name)}`;
}
function parseSingleAction(actionText: string, defaultActorId: string): Action | undefined {
const input = normalized(actionText);
if (!input) {
@@ -60,7 +116,21 @@ function parseSingleAction(actionText: string, defaultActorId: string): Action |
if (input.includes("key")) {
return { actorId: defaultActorId, type: "take", targetId: "key_1" };
}
return undefined;
const itemName = extractTakenItemName(input);
if (!itemName) {
return undefined;
}
return {
actorId: defaultActorId,
type: "take",
targetId: `item_${toItemSlug(itemName)}`,
metadata: {
itemName: toDisplayName(itemName),
createIfMissing: true,
},
};
}
if (/(introduce|bring in|invite|have .* join)/.test(input)) {
@@ -84,6 +154,26 @@ function parseSingleAction(actionText: string, defaultActorId: string): Action |
};
}
if (/(give|hand|pass|transfer)/.test(input)) {
const parts = extractTransferParts(input);
if (!parts) {
return undefined;
}
const itemId = parts.itemName.includes("key") ? "key_1" : `item_${toItemSlug(parts.itemName)}`;
return {
actorId: defaultActorId,
type: "transfer",
targetId: resolveRecipientId(parts.recipientName),
metadata: {
itemId,
itemName: toDisplayName(parts.itemName),
recipientName: toDisplayName(parts.recipientName),
},
};
}
if (/(describe|is a|is an|has)/.test(input)) {
// Match patterns like "describe the merchant as shrewd" or "the merchant is shrewd"
const describeMatch = input.match(/(?:describe|tell about)\s+(?:the\s+)?([a-z\s_]+?)\s+as\s+(.+)$/) ||

View File

@@ -116,6 +116,19 @@ function evaluate(expr: ConditionExpr, ctx: EvalContext): boolean {
return expr.allowedNames.some((name) => name.trim().toLowerCase() === actorName);
}
case "actionMetadataEq": {
return ctx.action.metadata?.[expr.key] === expr.value;
}
case "itemInInventory": {
const holder = ctx.entities[expr.holderRole];
const itemId = ctx.action.metadata?.[expr.itemMetadataKey];
if (!holder || typeof itemId !== "string") return false;
const item = ctx.worldState.entities[itemId];
if (!item) return false;
return String(item.attributes.location ?? "") === `inventory:${holder.id}`;
}
case "attributeRef": {
const checkEntity = ctx.entities[expr.checkRole];
const refEntity = ctx.entities[expr.refRole];

View File

@@ -0,0 +1,47 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import { createCharacterGardenApp } from "../app";
import { interpretTurn } from "../interpreter/interpretTurn";
async function run(): Promise<void> {
const resolved = await interpretTurn("look around", "player");
assert.equal(resolved.status, "resolved", "Expected 'look around' to resolve.");
const hybridResolved = await interpretTurn("look around", "player", { mode: "hybrid" });
assert.equal(hybridResolved.status, "resolved", "Expected hybrid mode to resolve via deterministic fallback when LLM is unavailable.");
assert.equal(hybridResolved.resolutionSource, "hybrid");
const empty = await interpretTurn("", "player");
assert.equal(empty.status, "rejected", "Expected empty input to be rejected.");
assert.equal(empty.clarification?.reasonCode, "EMPTY_INPUT");
const dbPath = path.join("/tmp", `charactergarden_integration_${Date.now()}.db`);
if (fs.existsSync(dbPath)) {
fs.unlinkSync(dbPath);
}
const app = createCharacterGardenApp(dbPath);
const unresolved = await app.processTurn("blorb invalid nonsense");
assert.equal(unresolved.interpreter.status, "needs_clarification");
assert.equal(unresolved.actions.length, 0);
const valid = await app.processTurn("look around");
assert.equal(valid.interpreter.status, "resolved");
const snapshot = app.getSnapshot();
assert.ok(snapshot.turns.length >= 2, "Expected persisted turns.");
const latestTurn = snapshot.turns[snapshot.turns.length - 1];
assert.ok(latestTurn.interpreter, "Expected interpreter payload persisted on turn.");
app.db.close();
console.log("Integration checks passed.");
}
void run().catch((error) => {
console.error("Integration checks failed:");
console.error(error);
process.exit(1);
});

View File

@@ -1,50 +1,24 @@
import { randomUUID } from "node:crypto";
import type { CharacterGardenDatabase } from "../db";
import type { Action } from "../contracts/action";
import type { InterpreterOutput } from "../contracts/intent";
import type { SceneRulebook } from "../contracts/rulebook";
import type { 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";
import { runTurnManager } from "./turnManager";
export type ProcessTurnResponse = {
rawText: string;
actions: Action[];
validation: ValidationResult[];
worldState: WorldState;
interpreter: InterpreterOutput;
};
export function processTurn(
export async function processTurn(
rawText: string,
worldState: WorldState,
db: CharacterGardenDatabase,
rulebook?: SceneRulebook
): ProcessTurnResponse {
const actions = parseTextToActions(rawText);
const validation = validateActions(actions, worldState, rulebook);
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,
};
): Promise<ProcessTurnResponse> {
return runTurnManager(rawText, worldState, db, rulebook);
}

View File

@@ -0,0 +1,109 @@
import { randomUUID } from "node:crypto";
import type { CharacterGardenDatabase } from "../db";
import type { Action } from "../contracts/action";
import type { InterpreterOutput } from "../contracts/intent";
import type { SceneRulebook } from "../contracts/rulebook";
import type { Turn } from "../contracts/turn";
import type { ValidationResult } from "../contracts/validation";
import type { WorldState } from "../contracts/world";
import { interpretTurn } from "../interpreter/interpretTurn";
import { validateInterpreterOutput } from "../interpreter/validateInterpreterOutput";
import { validateActions } from "../truthEngine";
import { applyActions } from "../world/applyActions";
export type TurnManagerResponse = {
rawText: string;
actions: Action[];
validation: ValidationResult[];
worldState: WorldState;
interpreter: InterpreterOutput;
};
function persistTurn(
db: CharacterGardenDatabase,
turn: Turn,
interpreter: InterpreterOutput,
actions: Action[],
validation: ValidationResult[]
): void {
db.insertTurn(turn);
db.insertInterpreterOutput(turn.id, interpreter);
db.insertActions(turn.id, actions);
db.insertValidationResults(turn.id, validation);
}
export async function runTurnManager(
rawText: string,
worldState: WorldState,
db: CharacterGardenDatabase,
rulebook?: SceneRulebook
): Promise<TurnManagerResponse> {
const interpreted = await interpretTurn(rawText, "player");
const boundaryCheck = validateInterpreterOutput(interpreted);
const interpreter: InterpreterOutput = boundaryCheck.isValid
? interpreted
: {
interpreterVersion: interpreted.interpreterVersion,
rawText,
actorId: interpreted.actorId || "player",
resolutionSource: interpreted.resolutionSource,
minConfidence: interpreted.minConfidence,
status: "rejected",
selectedActions: [],
candidates: [],
diagnostics: [
"Interpreter output failed boundary validation.",
...boundaryCheck.issues,
],
clarification: {
reasonCode: "INTERNAL_INVALID_OUTPUT",
question: "The interpreter returned an invalid output shape. Please retry the turn.",
field: "verb",
},
};
if (interpreter.status !== "resolved") {
const turn: Turn = {
id: randomUUID(),
rawText,
actions: [],
validation: [],
createdAt: Date.now(),
};
persistTurn(db, turn, interpreter, [], []);
return {
rawText,
actions: [],
validation: [],
worldState,
interpreter,
};
}
const actions = interpreter.selectedActions;
const validation = validateActions(actions, worldState, rulebook);
const nextWorldState = applyActions(actions, validation, worldState);
const turn: Turn = {
id: randomUUID(),
rawText,
actions,
validation,
createdAt: Date.now(),
};
persistTurn(db, turn, interpreter, actions, validation);
db.upsertEntities(Object.values(nextWorldState.entities));
db.insertWorldState(turn.id, nextWorldState);
return {
rawText,
actions,
validation,
worldState: nextWorldState,
interpreter,
};
}

View File

@@ -43,6 +43,25 @@ function createCharacterId(worldState: WorldState, baseName: string): string {
return `${baseId}_${suffix}`;
}
function toDisplayName(value: string): string {
return value
.split(/[_\s]+/)
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(" ");
}
function inferItemName(action: Action): string {
const itemName = action.metadata?.itemName;
if (typeof itemName === "string" && itemName.trim()) {
return itemName.trim();
}
if (action.targetId?.startsWith("item_")) {
return toDisplayName(action.targetId.replace(/^item_/, ""));
}
return "Generated Item";
}
function getActionCharacterName(action: Action): string | undefined {
const displayName = action.metadata?.displayName;
if (typeof displayName === "string" && displayName.trim()) {
@@ -93,6 +112,18 @@ export function applyActions(
if (target.id === "key_1") {
actor.attributes.has_key_1 = true;
}
} else if (actor && action.targetId && action.metadata?.createIfMissing === true) {
nextState.entities[action.targetId] = {
id: action.targetId,
name: inferItemName(action),
type: "item",
attributes: {
location: `inventory:${actor.id}`,
takeable: true,
created_by_action: "take",
created_by_actor: actor.id,
},
};
}
break;
case "open":
@@ -137,6 +168,19 @@ export function applyActions(
}
}
break;
case "transfer":
if (target) {
const itemId = action.metadata?.itemId;
if (typeof itemId === "string") {
const item = nextState.entities[itemId];
if (item) {
item.attributes.location = `inventory:${target.id}`;
item.attributes.last_transferred_by = action.actorId;
item.attributes.last_transferred_to = target.id;
}
}
}
break;
case "inspect":
default:
break;