/**
* Raw LLM transcript logger โ a human-readable, append-only record of EVERY
* exchange the plugin has with the model during a turn.
*
* This is a debugging/inspection aid, distinct from `debugLogging` (which prints
* a one-shot summary to the dev server log). Here we write a separate file, one
* per save slot, capturing for each call:
* - exactly what the model RECEIVES (the assembled system prompt + every
* message, in order, with roles), and
* - exactly what the model RETURNS, raw (reasoning/<think> included โ nothing
* stripped).
*
* It covers the main narration call AND the auxiliary calls (sheet generation,
* rolling-summary, chat-title), so the whole picture of how the prompt is built
* and answered is in one place.
*
* The file lives next to the save state (`saves/<universe>[__<save>].transcript.md`,
* gitignored) and is plain Markdown. Append-only โ delete it to reset. All I/O is
* best-effort: a logging failure must NEVER break a turn, so every write swallows
* its error.
*/
import { appendFile, mkdir, unlink } from "node:fs/promises";
import { join, resolve } from "node:path";
/** One message as the model sees it. */
export interface TranscriptMessage {
role: string;
content: string;
}
/**
* Whether an exchange is the actual role-play narration ("main") or a side
* helper call that never narrates ("auxiliary" โ summary, title, sheet, cost
* extraction). Surfaced prominently in the log so the two are never confused:
* only the main call's prompt drives the story; an auxiliary call's `[user]`
* block is its own working input (e.g. the text to summarize), NOT the game's
* prompt.
*/
export type TranscriptKind = "main" | "auxiliary";
/** A single request/response exchange to record. */
export interface TranscriptEntry {
/** Short label, e.g. "MAIN GENERATION", "SUMMARY", "SHEET GENERATION". */
label: string;
/** Narration call vs. a non-narrating helper call. */
kind: TranscriptKind;
/** One-line plain-English explanation of what this call is for. */
purpose: string;
/** The messages sent, in order (system first by convention). */
messages: TranscriptMessage[];
/** The sampling/generation config passed to respond(), if any. */
config?: unknown;
/** The model's chain-of-thought / reasoning fragments, concatenated (raw). */
reasoning?: string;
/** The model's user-facing output, concatenated raw (trailer included). */
response: string;
/** Optional free-text technical note (e.g. "structured output (schema)"). */
note?: string;
}
export interface TranscriptOptions {
/** Override the directory (defaults to the saves dir / `saves/`). */
savesDir?: string;
/** Save slot โ selects the file, mirroring the state file naming. */
save?: string;
}
/** Default directory: the same `saves/` folder the state lives in. */
function defaultDir(): string {
return resolve(process.cwd(), "saves");
}
/** Sanitize one id segment into a safe file-name fragment (mirrors the store). */
function sanitizeSegment(id: string): string {
return id.replace(/[^a-zA-Z0-9._-]/g, "_").slice(0, 100);
}
/**
* Transcript file name for a universe + optional save slot โ mirrors
* `stateFileName` but with a `.transcript.md` suffix, so it sits beside the
* matching `<universe>[__<save>].json` and is never picked up by the save-slot
* scanner (which requires a `.json` extension). Pure.
*/
export function transcriptFileName(universe: string, save?: string): string {
const u = sanitizeSegment(universe) || "default";
const s = sanitizeSegment((save ?? "").trim());
return s ? `${u}__${s}.transcript.md` : `${u}.transcript.md`;
}
/**
* Delete the transcript file for a universe + optional save slot, if it exists.
* Used by `/mj restart` so a wiped playthrough also drops its append-only log
* (otherwise the old turns would linger under the fresh story). Best-effort: a
* missing file or any I/O error is swallowed โ clearing a debug log must never
* break the reset. Mirrors the store's directory resolution.
*/
export async function deleteTranscript(
universe: string,
options?: TranscriptOptions,
): Promise<void> {
const dir = options?.savesDir?.trim()
? resolve(options.savesDir)
: defaultDir();
const file = join(dir, transcriptFileName(universe, options?.save));
try {
await unlink(file);
} catch {
// Missing file or unlink failure โ nothing to clean up, or not our problem.
}
}
function hr(char = "="): string {
return char.repeat(80);
}
/**
* An append-only transcript writer scoped to one turn of one save. Construct it
* once per turn (cheap โ no I/O until {@link record} is called) and call
* {@link record} for each model exchange. The first record of the turn emits a
* turn header.
*/
export class TranscriptLogger {
private readonly file: string;
private headerWritten = false;
/** Sequential per-turn call counter, so each entry shows "call #K this turn". */
private callNo = 0;
/**
* Tail of the serialized write chain. End-of-turn passes run in parallel (the
* rolling summary โ the relationship pass), so two `record()` calls can be
* in flight at once โ racing the header flag and the file appends. Chaining
* each write onto the previous keeps the file well-formed (one header, no
* byte-interleaving) without any external lock.
*/
private tail: Promise<void> = Promise.resolve();
constructor(
private readonly universe: string,
private readonly turn: number,
options?: TranscriptOptions,
) {
const dir = options?.savesDir?.trim()
? resolve(options.savesDir)
: defaultDir();
this.file = join(dir, transcriptFileName(universe, options?.save));
}
/** Format one exchange as Markdown โ phase title, then IN, then OUT. */
private format(entry: TranscriptEntry): string {
const tag = entry.kind === "main" ? "MAIN" : "AUX";
const lines: string[] = [];
lines.push(hr());
lines.push(`## TURN ${this.turn} ยท call #${this.callNo} ยท [${tag}] ${entry.label}`);
lines.push(hr());
lines.push("");
lines.push("### โ IN (to LLM)");
entry.messages.forEach((msg) => {
lines.push("");
lines.push(`[${msg.role}]`);
lines.push(msg.content === "" ? "(empty)" : msg.content);
});
lines.push("");
lines.push("### โ OUT (from LLM)");
if (entry.reasoning && entry.reasoning.trim() !== "") {
lines.push("");
lines.push("[reasoning]");
lines.push(entry.reasoning);
}
lines.push("");
lines.push("[output]");
lines.push(entry.response === "" ? "(empty)" : entry.response);
lines.push("");
return lines.join("\n");
}
/**
* Append one exchange. Best-effort; never throws. Serialized: concurrent calls
* (parallel end-of-turn passes) are queued so the file stays well-formed and
* the call counter reflects true append order.
*/
record(entry: TranscriptEntry): Promise<void> {
const run = this.tail.then(() => this.write(entry));
// Swallow on the stored tail so one failed write can't break the chain; the
// returned promise still settles for the awaiting caller.
this.tail = run.catch(() => {});
return run;
}
/** Format + append a single entry (runs serialized via {@link record}). */
private async write(entry: TranscriptEntry): Promise<void> {
this.callNo += 1;
try {
await mkdir(resolve(this.file, ".."), { recursive: true });
let body = "";
if (!this.headerWritten) {
const bar = hr("#");
body += `\n\n${bar}\n# TURN ${this.turn} โ ${this.universe}\n${bar}\n`;
this.headerWritten = true;
}
body += this.format(entry) + "\n";
await appendFile(this.file, body, "utf8");
} catch {
// Logging is a debug aid โ a write failure must never break the turn.
}
}
}