/**
* Numbered-choice UX — the "3 options + a free action" loop.
*
* LM Studio offers no clickable buttons, so choices are text. The value the
* plugin adds is on the INPUT side: when the player answers with just a number,
* we look back at the game master's previous reply, recover the full text of
* that option, and feed the model an explicit action instead of a bare digit.
* Anything that isn't a lone number is treated as a free action and passed
* through untouched (the "4th choice").
*/
/** A parsed option from the game master's previous message. */
export interface Choice {
index: number;
text: string;
}
// Matches "1. ...", "2) ...", "(3) ...", "4 - ...", "5: ...", with optional
// leading bullet markup and optional spaces around the separator.
const OPTION_LINE = /^\s*(?:[-*]\s*)?\(?\s*(\d{1,2})\s*([.)\-:])\s+(.+?)\s*$/;
/** Extract numbered options from an assistant message. */
export function parseChoices(assistantText: string): Choice[] {
const choices: Choice[] = [];
for (const line of assistantText.split(/\r?\n/)) {
const m = line.match(OPTION_LINE);
if (!m) continue;
const index = Number(m[1]);
if (index >= 1 && index <= 20) {
choices.push({ index, text: m[3].trim() });
}
}
return choices;
}
// A player message that is *only* a selection: "2", "2.", "option 2", "#3".
const SELECTION_ONLY = /^\s*(?:option|choice|choix|#)?\s*(\d{1,2})\s*[.)]?\s*$/i;
/** If the message is a bare option pick, return its number; else null. */
export function detectSelection(playerText: string): number | null {
const m = playerText.match(SELECTION_ONLY);
if (!m) return null;
const n = Number(m[1]);
return n >= 1 && n <= 20 ? n : null;
}
/**
* Resolve a selection against the previous choices. Returns the option's text,
* or null when it can't be matched (unknown number, or no choices parsed) —
* the caller then falls back to passing the raw message through.
*/
export function expandSelection(
selection: number,
choices: Choice[],
): string | null {
const hit = choices.find((c) => c.index === selection);
return hit ? hit.text : null;
}
/**
* Strip the trailing "numbered options + invitation" block from a game-master
* reply, returning only the narration prose. The options are an interface
* artifact (offered, mostly unchosen), so they are noise for long-term memory:
* the summary should record what HAPPENED, not the menu of what could have.
*
* The options always sit at the very end of the reply, so we find the last
* option line, walk up over the contiguous run of option/blank lines that make
* up the list, and drop everything from there to the end (which also removes the
* "ignore these and act freely" invitation that follows the list). Prose that
* appears before the list — including any incidental numbered list mid-narration
* — is preserved. With no options present the text is returned unchanged. Pure.
*/
export function stripChoices(assistantText: string): string {
const lines = assistantText.split(/\r?\n/);
const isOption = (l: string): boolean => OPTION_LINE.test(l);
const isBlank = (l: string): boolean => l.trim() === "";
let lastOpt = -1;
for (let i = lines.length - 1; i >= 0; i--) {
if (isOption(lines[i])) {
lastOpt = i;
break;
}
}
if (lastOpt < 0) return assistantText.trim();
// First line of the trailing options group: walk up over option + blank lines,
// stopping at the first line of real prose above the list.
let start = lastOpt;
for (let i = lastOpt - 1; i >= 0; i--) {
if (isOption(lines[i]) || isBlank(lines[i])) start = i;
else break;
}
return lines.slice(0, start).join("\n").trim();
}