Consolidate engine docs and naming
This commit is contained in:
@@ -1,16 +1,16 @@
|
||||
/**
|
||||
* Zork LLM Engine
|
||||
* Z-code LLM Engine
|
||||
*
|
||||
* Runs Zork I (or any Z-machine story file) as a headless subprocess via the
|
||||
* Runs a Z-machine story file as a headless subprocess via the
|
||||
* `ifvms` CLI, and wraps every I/O exchange with OpenRouter LLM calls that
|
||||
* translate free natural-language player input into parser commands and
|
||||
* re-voice the Z-machine's raw output as polished narrative prose.
|
||||
*
|
||||
* Configuration (environment variables):
|
||||
* ZORK_STORY_FILE – path to the .z5/.z8/.bin story file (default: ./data/z-code/zork1.bin)
|
||||
* ZORK_MAX_RETRIES – maximum command retry attempts per turn (default: 3)
|
||||
* ZORK_HISTORY_SIZE – player-facing outputs stored per room (default: 5)
|
||||
* OPENROUTER_API_KEY, OPENROUTER_MODEL – required
|
||||
* ZCODE_STORY_FILE - path to the .z5/.z8/.bin story file (default: ./data/z-code/zork1.bin)
|
||||
* ZCODE_MAX_RETRIES - maximum command retry attempts per turn (default: 3)
|
||||
* ZCODE_HISTORY_SIZE - player-facing outputs stored per room (default: 5)
|
||||
* OPENROUTER_API_KEY, OPENROUTER_MODEL - required
|
||||
*/
|
||||
|
||||
import { spawn, ChildProcess } from 'child_process';
|
||||
@@ -27,15 +27,15 @@ import {
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const DEBUG_ENABLED = /^(1|true|yes|on)$/i.test(process.env.ZORK_DEBUG ?? '');
|
||||
const DEBUG_ENABLED = /^(1|true|yes|on)$/i.test(process.env.ZCODE_DEBUG ?? '');
|
||||
|
||||
function debugLog(message: string, details?: unknown): void {
|
||||
if (!DEBUG_ENABLED) return;
|
||||
if (typeof details === 'undefined') {
|
||||
console.log(`[ZorkLlm:debug] ${message}`);
|
||||
console.log(`[ZcodeLlm:debug] ${message}`);
|
||||
return;
|
||||
}
|
||||
console.log(`[ZorkLlm:debug] ${message}`, details);
|
||||
console.log(`[ZcodeLlm:debug] ${message}`, details);
|
||||
}
|
||||
|
||||
function compactText(text: string, maxLength = 12_000): string {
|
||||
@@ -80,7 +80,7 @@ function withReasoningDefaults(
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ZorkSession {
|
||||
export interface ZcodeSession {
|
||||
characterDescription: string;
|
||||
notes: string[];
|
||||
recentParagraphs: string[];
|
||||
@@ -89,20 +89,20 @@ export interface ZorkSession {
|
||||
timeOfDay: string;
|
||||
weather: string;
|
||||
virtualInventory: string[];
|
||||
/** roomName → last N player-facing output strings */
|
||||
/** roomName -> last N player-facing output strings */
|
||||
roomHistory: Record<string, string[]>;
|
||||
currentRoom: string;
|
||||
running: boolean;
|
||||
}
|
||||
|
||||
export type ZorkTurnResult = TurnResult;
|
||||
export type ZcodeTurnResult = TurnResult;
|
||||
|
||||
interface PromptConfig {
|
||||
system: string;
|
||||
user_template: string;
|
||||
}
|
||||
|
||||
interface ZorkPrompts {
|
||||
interface ZcodePrompts {
|
||||
characterGeneration: PromptConfig;
|
||||
textRewriter: PromptConfig;
|
||||
commandTranslator: PromptConfig;
|
||||
@@ -184,10 +184,10 @@ function isParserComplaint(output: string): boolean {
|
||||
].some(fragment => text.includes(fragment));
|
||||
}
|
||||
|
||||
function formatExactReadOutput(command: string, zorkOutput: string): string {
|
||||
function formatExactReadOutput(command: string, zcodeOutput: string): string {
|
||||
const object = command.replace(/^READ\s+/i, '').trim().toLowerCase();
|
||||
const label = object ? `the ${object}` : 'it';
|
||||
const cleanedOutput = zorkOutput
|
||||
const cleanedOutput = zcodeOutput
|
||||
.split('\n')
|
||||
.filter((line, index) => index !== 0 || line.trim().toUpperCase() !== command.trim().toUpperCase())
|
||||
.join('\n')
|
||||
@@ -233,10 +233,10 @@ function evolveWeather(previous: string, turnCount: number): string {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ZorkProcess – manages the ifvms zvm child process
|
||||
// ZcodeProcess – manages the ifvms zvm child process
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class ZorkProcess {
|
||||
class ZcodeProcess {
|
||||
private proc: ChildProcess | null = null;
|
||||
private outputBuffer = '';
|
||||
private pendingResolve: ((text: string) => void) | null = null;
|
||||
@@ -326,7 +326,7 @@ class ZorkProcess {
|
||||
});
|
||||
}
|
||||
|
||||
/** Debounced check: resolve when the buffer ends with Zork's '>' prompt. */
|
||||
/** Debounced check: resolve when the buffer ends with a Z-machine prompt. */
|
||||
private scheduleResolve(): void {
|
||||
if (!/\n>\s*$/.test(this.outputBuffer)) return;
|
||||
|
||||
@@ -361,7 +361,7 @@ class ZorkProcess {
|
||||
// Prompt loader
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function loadPrompts(promptDir: string): ZorkPrompts {
|
||||
function loadPrompts(promptDir: string): ZcodePrompts {
|
||||
function load(filename: string): PromptConfig {
|
||||
const filePath = path.join(promptDir, filename);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
@@ -384,32 +384,32 @@ function renderTemplate(template: string, vars: Record<string, string>): string
|
||||
function logLlmError(scope: string, err: unknown): void {
|
||||
if (axios.isAxiosError(err)) {
|
||||
const ax = err as AxiosError;
|
||||
console.error(`[ZorkLlm] ${scope} failed: ${ax.message}`);
|
||||
console.error(`[ZcodeLlm] ${scope} failed: ${ax.message}`);
|
||||
if (ax.response) {
|
||||
console.error(
|
||||
`[ZorkLlm] ${scope} status=${ax.response.status} data=`,
|
||||
`[ZcodeLlm] ${scope} status=${ax.response.status} data=`,
|
||||
ax.response.data,
|
||||
);
|
||||
if (ax.response.status === 404) {
|
||||
console.error(
|
||||
'[ZorkLlm] Hint: OPENROUTER_MODEL is likely invalid or unavailable for your API key.',
|
||||
'[ZcodeLlm] Hint: OPENROUTER_MODEL is likely invalid or unavailable for your API key.',
|
||||
);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(`[ZorkLlm] ${scope} failed:`, err);
|
||||
console.error(`[ZcodeLlm] ${scope} failed:`, err);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ZorkLlmEngine
|
||||
// ZcodeLlmEngine
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class ZorkLlmEngine {
|
||||
private zork = new ZorkProcess();
|
||||
private session: ZorkSession | null = null;
|
||||
private prompts: ZorkPrompts;
|
||||
export class ZcodeLlmEngine {
|
||||
private zmachine = new ZcodeProcess();
|
||||
private session: ZcodeSession | null = null;
|
||||
private prompts: ZcodePrompts;
|
||||
private llm: AxiosInstance;
|
||||
private model: string;
|
||||
private resolvedFallbackModel: string | null = null;
|
||||
@@ -433,11 +433,11 @@ export class ZorkLlmEngine {
|
||||
);
|
||||
}
|
||||
const replacement =
|
||||
ZorkLlmEngine.DEPRECATED_MODEL_REPLACEMENTS[model] ?? null;
|
||||
ZcodeLlmEngine.DEPRECATED_MODEL_REPLACEMENTS[model] ?? null;
|
||||
if (replacement) {
|
||||
this.model = replacement;
|
||||
console.warn(
|
||||
`[ZorkLlm] Replacing deprecated model '${model}' with '${replacement}'.`,
|
||||
`[ZcodeLlm] Replacing deprecated model '${model}' with '${replacement}'.`,
|
||||
);
|
||||
} else {
|
||||
this.model = model;
|
||||
@@ -446,13 +446,13 @@ export class ZorkLlmEngine {
|
||||
requestedModel: model,
|
||||
activeModel: this.model,
|
||||
});
|
||||
this.maxRetries = parseInt(process.env.ZORK_MAX_RETRIES ?? '3', 10);
|
||||
this.historySize = parseInt(process.env.ZORK_HISTORY_SIZE ?? '5', 10);
|
||||
this.maxRetries = parseInt(process.env.ZCODE_MAX_RETRIES ?? '3', 10);
|
||||
this.historySize = parseInt(process.env.ZCODE_HISTORY_SIZE ?? '5', 10);
|
||||
this.storyPath = path.resolve(
|
||||
options.storyPath ?? process.env.ZORK_STORY_FILE ?? './data/z-code/zork1.bin',
|
||||
options.storyPath ?? process.env.ZCODE_STORY_FILE ?? './data/z-code/zork1.bin',
|
||||
);
|
||||
|
||||
const promptDir = path.resolve(options.promptDir ?? './data/zork-prompts');
|
||||
const promptDir = path.resolve(options.promptDir ?? './data/zcode-prompts');
|
||||
this.prompts = loadPrompts(promptDir);
|
||||
|
||||
this.llm = axios.create({
|
||||
@@ -489,7 +489,7 @@ export class ZorkLlmEngine {
|
||||
const fallbackModel = await this.resolveFallbackModel();
|
||||
this.model = fallbackModel;
|
||||
console.warn(
|
||||
`[ZorkLlm] Switching active model to '${fallbackModel}'.`,
|
||||
`[ZcodeLlm] Switching active model to '${fallbackModel}'.`,
|
||||
);
|
||||
const withFallbackModel = {
|
||||
...withReasoningDefaults(payload, fallbackModel),
|
||||
@@ -571,16 +571,16 @@ export class ZorkLlmEngine {
|
||||
// ---- Public API -----------------------------------------------------------
|
||||
|
||||
isRunning(): boolean {
|
||||
return this.session?.running === true && this.zork.isAlive();
|
||||
return this.session?.running === true && this.zmachine.isAlive();
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a new game: launch Zork, generate the player character, rewrite the
|
||||
* Start a new game: launch the Z-machine story, generate the player character, rewrite the
|
||||
* intro text, and return the first TurnResult for the client.
|
||||
*/
|
||||
async newGame(): Promise<ZorkTurnResult> {
|
||||
async newGame(): Promise<ZcodeTurnResult> {
|
||||
// Kill any existing game
|
||||
if (this.zork.isAlive()) this.zork.kill();
|
||||
if (this.zmachine.isAlive()) this.zmachine.kill();
|
||||
this.nextTurnId = 1;
|
||||
|
||||
if (!fs.existsSync(this.storyPath)) {
|
||||
@@ -591,7 +591,7 @@ export class ZorkLlmEngine {
|
||||
}
|
||||
|
||||
debugLog('launching Z-machine', { storyPath: this.storyPath });
|
||||
const rawIntro = await this.zork.launch(this.storyPath);
|
||||
const rawIntro = await this.zmachine.launch(this.storyPath);
|
||||
debugLog('Z-machine intro output', compactText(rawIntro));
|
||||
|
||||
// Generate the player character before showing any text
|
||||
@@ -628,7 +628,7 @@ export class ZorkLlmEngine {
|
||||
/**
|
||||
* Process player free-text input. Returns the next TurnResult.
|
||||
*/
|
||||
async processInput(userInput: string): Promise<ZorkTurnResult> {
|
||||
async processInput(userInput: string): Promise<ZcodeTurnResult> {
|
||||
if (!this.session?.running) {
|
||||
throw new Error('No active game session');
|
||||
}
|
||||
@@ -661,7 +661,7 @@ export class ZorkLlmEngine {
|
||||
for (const tool of cmdResponse.tools) {
|
||||
this.executeTool(tool);
|
||||
}
|
||||
// If the translator also supplied a Zork command, continue to game loop
|
||||
// If the translator also supplied a Z-machine command, continue to game loop
|
||||
if (!cmdResponse.command && !cmdResponse.commands?.length) {
|
||||
// Pure tool action — generate a brief acknowledgement via the rewriter
|
||||
const ack = await this.rewriteText(
|
||||
@@ -692,7 +692,7 @@ export class ZorkLlmEngine {
|
||||
private async runCommandPlan(
|
||||
userInput: string,
|
||||
commands: string[],
|
||||
): Promise<ZorkTurnResult> {
|
||||
): Promise<ZcodeTurnResult> {
|
||||
const texts: string[] = [];
|
||||
for (const command of commands) {
|
||||
const text = await this.runSingleCommandLoop(userInput, command);
|
||||
@@ -711,18 +711,18 @@ export class ZorkLlmEngine {
|
||||
async saveGame(): Promise<string> {
|
||||
if (!this.session) throw new Error('No active session to save');
|
||||
|
||||
const tmpFile = path.join(os.tmpdir(), `zork-save-${Date.now()}.qzl`);
|
||||
const tmpFile = path.join(os.tmpdir(), `zcode-save-${Date.now()}.qzl`);
|
||||
try {
|
||||
// Ask Zork to save, supply the temp file path, and discard the output
|
||||
await this.zork.sendLine('SAVE');
|
||||
await this.zork.sendLine(tmpFile);
|
||||
// Ask the Z-machine to save, supply the temp file path, and discard the output
|
||||
await this.zmachine.sendLine('SAVE');
|
||||
await this.zmachine.sendLine(tmpFile);
|
||||
|
||||
let zorkSave = '';
|
||||
let zcodeSave = '';
|
||||
if (fs.existsSync(tmpFile)) {
|
||||
zorkSave = fs.readFileSync(tmpFile).toString('base64');
|
||||
zcodeSave = fs.readFileSync(tmpFile).toString('base64');
|
||||
}
|
||||
|
||||
return JSON.stringify({ session: this.session, zorkSave });
|
||||
return JSON.stringify({ session: this.session, zcodeSave });
|
||||
} finally {
|
||||
if (fs.existsSync(tmpFile)) fs.unlinkSync(tmpFile);
|
||||
}
|
||||
@@ -731,21 +731,21 @@ export class ZorkLlmEngine {
|
||||
/**
|
||||
* Load a previously saved game. Returns the first TurnResult after restore.
|
||||
*/
|
||||
async loadGame(savedJson: string): Promise<ZorkTurnResult> {
|
||||
const { session, zorkSave } = JSON.parse(savedJson) as {
|
||||
session: ZorkSession;
|
||||
zorkSave: string;
|
||||
async loadGame(savedJson: string): Promise<ZcodeTurnResult> {
|
||||
const { session, zcodeSave } = JSON.parse(savedJson) as {
|
||||
session: ZcodeSession;
|
||||
zcodeSave: string;
|
||||
};
|
||||
|
||||
if (this.zork.isAlive()) this.zork.kill();
|
||||
if (this.zmachine.isAlive()) this.zmachine.kill();
|
||||
|
||||
const tmpFile = path.join(os.tmpdir(), `zork-restore-${Date.now()}.qzl`);
|
||||
const tmpFile = path.join(os.tmpdir(), `zcode-restore-${Date.now()}.qzl`);
|
||||
try {
|
||||
fs.writeFileSync(tmpFile, Buffer.from(zorkSave, 'base64'));
|
||||
fs.writeFileSync(tmpFile, Buffer.from(zcodeSave, 'base64'));
|
||||
|
||||
await this.zork.launch(this.storyPath);
|
||||
await this.zork.sendLine('RESTORE');
|
||||
const restoreOutput = await this.zork.sendLine(tmpFile);
|
||||
await this.zmachine.launch(this.storyPath);
|
||||
await this.zmachine.sendLine('RESTORE');
|
||||
const restoreOutput = await this.zmachine.sendLine(tmpFile);
|
||||
|
||||
this.session = { ...session, running: true };
|
||||
this.session.rawTranscript ??= [];
|
||||
@@ -779,7 +779,7 @@ export class ZorkLlmEngine {
|
||||
attempt,
|
||||
maxRetries: this.maxRetries,
|
||||
});
|
||||
const rawOutput = await this.zork.sendLine(command);
|
||||
const rawOutput = await this.zmachine.sendLine(command);
|
||||
lastOutput = rawOutput;
|
||||
this.appendRawTranscript(command, rawOutput);
|
||||
debugLog('received Z-machine output', {
|
||||
@@ -856,10 +856,10 @@ export class ZorkLlmEngine {
|
||||
}
|
||||
}
|
||||
|
||||
private async rewriteText(zorkOutput: string): Promise<string> {
|
||||
private async rewriteText(zcodeOutput: string): Promise<string> {
|
||||
const cfg = this.prompts.textRewriter;
|
||||
const vars = this.buildCommonVars();
|
||||
vars['zorkOutput'] = zorkOutput;
|
||||
vars['zcodeOutput'] = zcodeOutput;
|
||||
|
||||
try {
|
||||
const response = await this.createCompletion({
|
||||
@@ -873,7 +873,7 @@ export class ZorkLlmEngine {
|
||||
return getAssistantContent(response.data).trim();
|
||||
} catch (err) {
|
||||
logLlmError('rewriteText', err);
|
||||
return zorkOutput;
|
||||
return zcodeOutput;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -896,7 +896,7 @@ export class ZorkLlmEngine {
|
||||
return parsed;
|
||||
} catch (err) {
|
||||
logLlmError('translateCommand', err);
|
||||
// Fallback: pass input directly to Zork parser
|
||||
// Fallback: pass input directly to Z-machine parser
|
||||
return { type: 'command', command: userInput.toUpperCase() };
|
||||
}
|
||||
}
|
||||
@@ -904,14 +904,14 @@ export class ZorkLlmEngine {
|
||||
private async evaluateOutput(
|
||||
userIntent: string,
|
||||
commandTried: string,
|
||||
zorkOutput: string,
|
||||
zcodeOutput: string,
|
||||
attempt: number,
|
||||
): Promise<EvaluatorResponse> {
|
||||
const cfg = this.prompts.outputEvaluator;
|
||||
const vars = this.buildCommonVars();
|
||||
vars['userIntent'] = userIntent;
|
||||
vars['commandTried'] = commandTried;
|
||||
vars['zorkOutput'] = zorkOutput;
|
||||
vars['zcodeOutput'] = zcodeOutput;
|
||||
vars['attempt'] = String(attempt);
|
||||
vars['maxAttempts'] = String(this.maxRetries);
|
||||
|
||||
@@ -929,7 +929,7 @@ export class ZorkLlmEngine {
|
||||
} catch (err) {
|
||||
logLlmError('evaluateOutput', err);
|
||||
// Fallback: accept the raw output as-is
|
||||
return { decision: 'accept', text: zorkOutput };
|
||||
return { decision: 'accept', text: zcodeOutput };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1145,8 +1145,8 @@ export class ZorkLlmEngine {
|
||||
};
|
||||
}
|
||||
|
||||
private buildTurnResult(text: string): ZorkTurnResult {
|
||||
const alive = this.zork.isAlive();
|
||||
private buildTurnResult(text: string): ZcodeTurnResult {
|
||||
const alive = this.zmachine.isAlive();
|
||||
if (!alive && this.session) this.session.running = false;
|
||||
const paragraphs = textToParagraphs(text);
|
||||
return {
|
||||
Reference in New Issue
Block a user