/**
* Outgoing-context compaction — trim what the model actually receives.
*
* The chat sent each turn carries the full verbatim history, yet the system
* prompt already holds `# Story so far` (the rolling summary of everything up
* to `summarizedCount`) and `# Relevant past events` (RAG recall). The
* summarized stretch is therefore duplicated in the window. Two pure passes
* shrink it — exactly the "summary + recent messages = bounded but continuous
* context" design the rolling summary was built for (see `memory/summarize.ts`):
*
* - **expand picks** — a bare numbered answer ("2") is opaque once its option
* list scrolls out of the window; rewrite each such player message into an
* explicit `I choose: <option text>` using the immediately-preceding
* narration's options. Free-text actions are left untouched. Runs BEFORE
* pruning, while every referenced narration is still present.
* - **prune** — drop the user/assistant messages already folded into the
* summary, keeping a short `bridge` of the most-recent summarized ones so
* the narration still flows into the un-summarized tail (the tail, never
* summarized, always stays verbatim).
*
* Pure (no SDK dependency): the handler maps the live `Chat` to/from these
* plain messages, so this stays unit-testable alongside the rest of `director`.
*/
import {
detectSelection,
expandSelection,
parseChoices,
stripChoices,
} from "./choices.js";
/** A user/assistant turn, stripped to the fields windowing needs. */
export interface ConvoMessage {
role: "user" | "assistant";
content: string;
}
export interface WindowOptions {
/** Conversation messages already folded into `# Story so far`. */
summarizedCount: number;
/** Keep this many of the most-recent summarized messages as a bridge. */
bridge: number;
/** Prune messages already covered by the summary. */
prune: boolean;
/** Expand bare numbered picks into explicit "I choose: …" actions. */
expandPicks: boolean;
/** Phrase prefixed to an expanded pick (e.g. "I choose: "). */
pickPrefix: string;
}
export interface WindowResult {
/** The compacted conversation to send (system prompt added by the caller). */
messages: ConvoMessage[];
/** Leading messages dropped — surfaced so the caller never truncates silently. */
pruned: number;
/** Bare picks rewritten into explicit actions (for the debug log). */
expanded: number;
}
/**
* Expand every bare-number player message into an explicit action, in place.
* For a user message that is *only* a selection (e.g. "2"), the nearest earlier
* assistant message is parsed for its options and the chosen one is inlined as
* `${pickPrefix}<text>`. Anything that isn't a lone number (free actions, the
* already-expanded current action) is left untouched, as is a pick whose option
* can't be matched (unknown number / no parseable options). Returns the count
* rewritten.
*/
function expandPicks(messages: ConvoMessage[], pickPrefix: string): number {
let expanded = 0;
for (let i = 0; i < messages.length; i++) {
if (messages[i].role !== "user") continue;
const selection = detectSelection(messages[i].content);
if (selection === null) continue;
// Find the narration this pick answers: the nearest preceding assistant.
let text: string | null = null;
for (let j = i - 1; j >= 0; j--) {
if (messages[j].role !== "assistant") continue;
text = expandSelection(selection, parseChoices(messages[j].content));
break;
}
if (text) {
messages[i] = { role: "user", content: `${pickPrefix}${text}` };
expanded++;
}
}
return expanded;
}
/**
* The index of the first message kept after pruning — i.e. how many leading,
* already-summarized messages are dropped. Pure arithmetic, exported so the
* handler can compute which messages will remain in the sent window *before*
* planning (to keep RAG recall from re-surfacing them). Pruning is skipped when
* the marker is ahead of the live history (a stale summary after an edit —
* `reconcile` resets it after this turn). Never drops the last message (the
* current action) or empties the window entirely.
*/
export function windowStart(
length: number,
options: Pick<WindowOptions, "summarizedCount" | "bridge" | "prune">,
): number {
const summarized = Math.max(0, Math.floor(options.summarizedCount));
const bridge = Math.max(0, Math.floor(options.bridge));
if (!options.prune || summarized <= 0 || summarized > length) return 0;
return Math.min(Math.max(0, summarized - bridge), Math.max(0, length - 1));
}
/**
* Clean the conversation for what long-term memory digests (the rolling summary
* and the RAG store) — distinct from {@link windowConversation}, which prepares
* the chat actually sent to the model. Memory should record what HAPPENED, so:
*
* - **Game-master turns** keep only their narration prose: the trailing
* numbered options (and the "act freely" invitation) are stripped — they are
* an interface menu, not events (see {@link stripChoices}).
* - **Player turns** keep only the action the player actually took: a bare
* numbered pick ("2") is expanded to `${pickPrefix}<chosen option text>`
* using the preceding narration's options; free-text actions pass through.
*
* Returns one labelled line per message (`Player: …` / `Narrator: …`), in order,
* **1:1 with the input** — never dropping or merging entries, so the count stays
* aligned with the summary/store markers the handler advances. A turn whose text
* becomes empty after cleaning still yields its (empty-bodied) label; the
* summary prompt filters those out for display without disturbing the count. Pure.
*/
export function conversationForMemory(
messages: ConvoMessage[],
pickPrefix: string,
): string[] {
return messages.map((msg, i) => {
if (msg.role !== "user") {
return `Narrator: ${stripChoices(msg.content)}`;
}
const selection = detectSelection(msg.content);
if (selection !== null) {
for (let j = i - 1; j >= 0; j--) {
if (messages[j].role !== "assistant") continue;
const text = expandSelection(selection, parseChoices(messages[j].content));
if (text) return `Player: ${pickPrefix}${text}`;
break;
}
}
return `Player: ${msg.content.trim()}`;
});
}
/**
* Compact the conversation for the outgoing chat. Pure: takes the ordered
* user/assistant messages (the current action already substituted into the last
* user message by the caller) and returns the trimmed list plus the counts the
* handler logs.
*/
export function windowConversation(
messages: ConvoMessage[],
options: WindowOptions,
): WindowResult {
// Work on a copy so the caller's snapshot (what memory digests) is untouched.
const out = messages.map((m) => ({ ...m }));
const expanded = options.expandPicks ? expandPicks(out, options.pickPrefix) : 0;
const pruned = windowStart(out.length, options);
return { messages: out.slice(pruned), pruned, expanded };
}