/**
* Rolling-summary memory β the pure core (no LM Studio dependency).
*
* Long sessions outgrow the model's context window: the `rollingWindow`
* overflow policy trims old messages mechanically, losing their substance. This
* module preserves that substance in a single, incrementally-updated summary
* that is reinjected every turn (the `# Story so far` block). Summary + recent
* messages = a bounded but continuous context.
*
* The shape is the one both role-play tools (SillyTavern, qvink) and the
* research line (MemGPT recursive summarization) converge on:
* - trigger on **messages OR words**, whichever comes first;
* - **protect the tail** β never digest the most recent N messages, so they
* stay verbatim in the window (qvink's "Message Lag");
* - **recursive recompression** β fold new events into the *existing* summary
* and recompress under a word ceiling (MemGPT), rather than appending;
* - **edit robustness** β a marker counts how many messages are folded in; if
* history shrinks below it (edit / regen / delete), the summary is no longer
* trustworthy and is invalidated.
*
* Everything here is pure arithmetic/string work: the actual model call that
* produces the new summary text lives in the handler (mirrors `embedLore`), and
* `buildSummaryPrompt` just produces the text for it. This keeps the module
* unit-testable and decoupled from `world/` and `characters/` (per CLAUDE.md).
*/
import { MemoryState } from "../state/schema.js";
/** Count words in a string: whitespace-delimited non-empty tokens. */
export function countWords(text: string): number {
const matches = (text ?? "").trim().match(/\S+/g);
return matches ? matches.length : 0;
}
export interface ShouldSummarizeOptions {
/** Summarize once the unprotected delta reaches this many messages. */
intervalMessages: number;
/** β¦or this many words, whichever first. */
intervalWords: number;
/** Most-recent messages never digested (kept verbatim in the window). */
protectTail: number;
}
export interface SummarizeDecision {
/** Whether the delta is large enough to trigger a summary this turn. */
should: boolean;
/**
* The messages to fold into the summary β everything past the marker, minus
* the protected tail. Empty when there is nothing new to digest.
*/
delta: string[];
/** Word count of `delta` (exposed for the debug log). */
deltaWords: number;
/**
* The `summarizedMessageCount` to persist *once the summary is updated*. The
* handler ignores this when `should` is false (the marker only advances when
* a summary is actually produced).
*/
newCount: number;
}
/**
* Decide whether to (re)summarize, and over which messages. Pure.
*
* The delta is `messages[summarizedMessageCount : len - protectTail]` β the
* messages that are new since the last summary and old enough to leave the
* protected tail. It triggers when the delta reaches `intervalMessages` OR
* `intervalWords`, whichever first.
*/
export function shouldSummarize(
memory: MemoryState,
messages: string[],
options: ShouldSummarizeOptions,
): SummarizeDecision {
const protectTail = Math.max(0, Math.floor(options.protectTail));
const intervalMessages = Math.max(1, Math.floor(options.intervalMessages));
const intervalWords = Math.max(1, Math.floor(options.intervalWords));
const start = Math.max(0, Math.min(memory.summarizedMessageCount, messages.length));
const end = Math.max(start, messages.length - protectTail);
const delta = messages.slice(start, end);
const deltaWords = delta.reduce((n, m) => n + countWords(m), 0);
const should =
delta.length > 0 &&
(delta.length >= intervalMessages || deltaWords >= intervalWords);
return { should, delta, deltaWords, newCount: end };
}
/** The system + user text the handler sends to the model to update the summary. */
export interface SummaryPrompt {
system: string;
user: string;
}
/**
* Build the summarization prompt. Recursive recompression: the model folds the
* new messages into the existing summary and rewrites the whole thing, preserving
* everything that matters for continuity (named characters, unresolved threads,
* commitments, key facts). `targetWords` is a SOFT ceiling the model recompresses
* toward (0 = no ceiling): it keeps the summary from ballooning over a long
* chapter, while the priority-ordered preservation rules ensure that when the
* material runs long it sheds the lowest-priority, oldest detail first β never an
* unresolved thread or an active commitment.
*/
export function buildSummaryPrompt(
existingSummary: string,
delta: string[],
bridge: string[] = [],
targetWords = 0,
): SummaryPrompt {
const ceiling = Math.max(0, Math.floor(targetWords));
const lengthRule = ceiling
? `Keep the whole summary to about ${ceiling} words β a firm target, not a ` +
"hard cut mid-sentence. Compress to hit it: cut atmosphere and merge facts. " +
"When the material genuinely runs long, keep the higher-priority categories " +
"above and shed the lowest-priority, oldest detail first β but never drop an " +
"unresolved thread or an active commitment to save space."
: "Do not limit the length: never omit a relevant fact to save space β " +
"include everything that bears on what happens next.";
const system =
"You maintain the long-term memory of an ongoing role-play: a single running " +
"summary, reinjected every turn. It is the ONLY record of everything that " +
"has scrolled out of the model's context window β a fact dropped here is lost " +
"for the rest of the story. Treat it as a dense dossier, not a recap.\n\n" +
"Fold the new events into the existing summary (rewrite it as a whole, do " +
"NOT append a new section). Maximize information density: cut atmosphere, " +
"repetition, and prose flourishes, and merge related points into compact " +
"fact-rich sentences β but never drop information that could matter later.\n\n" +
"Preserve, in priority order:\n" +
"1. Named characters β their current state, location, relationships, and " +
"attitude toward the player.\n" +
"2. What the player did and decided β actions taken, promises made, goals, " +
"debts, and commitments (theirs and others').\n" +
"3. Unresolved threads β open questions, secrets hinted at, threats, " +
"deadlines, and anything left mid-action.\n" +
"4. Concrete facts β places, objects, clues, names, numbers, and who knows " +
"what.\n\n" +
lengthRule + " Write plain past-tense, " +
"third-person prose: no headings, no bullet points, no preamble, no " +
"commentary β output only the updated summary text.";
const prev = existingSummary.trim() || "(no summary yet)";
const recent =
delta
.map((m) => m.trim())
.filter(Boolean)
.join("\n\n") || "(no new messages)";
// The last already-summarized moment, shown so the first new action (often a
// bare "I choose: β¦") has the narration that prompted it β the new events
// otherwise begin mid-exchange with an answer whose question is only in the
// summary above. Context only: already folded in, never to be re-added.
const lead = bridge
.map((m) => m.trim())
.filter(Boolean)
.join("\n\n");
const user =
`# Story summary so far\n\n${prev}\n\n` +
(lead
? `# Bridge β where the new events pick up\n\n` +
`*Already covered by the summary above. Context only β do NOT ` +
`re-summarize it; it is the scene the first new action responds to.*\n\n` +
`${lead}\n\n`
: "") +
`# New events to integrate\n\n` +
`*Game-master narration and the player's actions, in order.*\n\n` +
`${recent}\n\n` +
`---\n\n` +
`# Your task\n\n` +
`Rewrite the story summary, folding in the new events above. Output only the ` +
`updated summary β dense${ceiling ? `, about ${ceiling} words` : ", no length limit"}, keeping every relevant fact:`;
return { system, user };
}
/**
* Build the chapter-close integration prompt β the "summary of summaries" step.
* When the conductor advances the act, the just-ended chapter's summary is
* folded into the whole-story summary by a dedicated pass (this prompt). Unlike
* `buildSummaryPrompt`, the input here is already-compressed text (the story
* summary so far + the closing chapter's summary + its final scene), not raw
* messages: the model integrates the chapter as one coherent movement of the
* larger story, keeping the story summary roughly stable in size rather than
* letting it grow per chapter. Plain prose out, like `buildSummaryPrompt`.
*/
export function buildStoryIntegrationPrompt(
storySummary: string,
chapterSummary: string,
finalScene: string,
chapterTitle = "",
targetWords = 0,
): SummaryPrompt {
const ceiling = Math.max(0, Math.floor(targetWords));
const lengthRule = ceiling
? `Keep the whole story summary to about ${ceiling} words. It is the permanent ` +
"backbone, reinjected every turn, so it must stay flat as chapters accumulate: " +
"tighten older chapters further with each new one. Never drop a fact that could still matter."
: "Earlier chapters may be tightened further as the story lengthens, but " +
"never drop a fact that could still matter.";
const system =
"You maintain the whole-story summary of an ongoing role-play: a single " +
"running account of everything that has happened across all chapters so far, " +
"reinjected every turn as the story's backbone. A chapter has just ended; " +
"fold it into the story summary as one coherent movement of the larger arc.\n\n" +
"Rewrite the story summary as a whole (do NOT append a new section). Integrate " +
"the chapter at the right altitude: keep what bears on the rest of the story β " +
"named characters and their standing, the player's choices, commitments and " +
"debts, unresolved threads, and concrete facts (places, clues, names) β and " +
"compress the chapter's moment-to-moment detail. " + lengthRule + " Write plain " +
"past-tense, third-person prose: no headings, no " +
"bullet points, no preamble β output only the updated story summary.";
const prev = storySummary.trim() || "(no story summary yet β this is the first chapter to close)";
const chapter = chapterSummary.trim() || "(the chapter left no summary)";
const scene = finalScene.trim();
const user =
`# Story summary so far (all previous chapters)\n\n${prev}\n\n` +
`# The chapter that just ended${chapterTitle.trim() ? ` β ${chapterTitle.trim()}` : ""}\n\n` +
`${chapter}\n\n` +
(scene
? `# Its final scene (most recent, may not yet be in the chapter summary)\n\n${scene}\n\n`
: "") +
`---\n\n` +
`# Your task\n\n` +
`Rewrite the whole-story summary, folding in the chapter that just ended. ` +
`Output only the updated story summary${ceiling ? `, about ${ceiling} words` : ""} β keep every fact that bears on what comes next:`;
return { system, user };
}
/**
* Edit robustness: if the conversation shrank below a marker (a message was
* edited, regenerated or deleted), the artifact built from it may describe
* events that no longer exist. Invalidate independently β the rolling summary
* (Phase C) and the RAG store (Phase F) each track their own marker, so only the
* one that is now ahead of reality is reset; the other is preserved. MVP-level:
* a finer, per-message anchoring is deferred (see the spec's locked decisions).
* Pure.
*/
export function reconcile(memory: MemoryState, messageCount: number): MemoryState {
let next = memory;
if (next.summarizedMessageCount > messageCount) {
next = { ...next, summary: "", summarizedMessageCount: 0 };
}
if (next.storedMessageCount > messageCount) {
next = { ...next, store: [], storedMessageCount: 0 };
}
return next;
}