@riemannre3/dsh-roleplay 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,288 @@
1
+ import { DEFAULT_TAVERN_PRESET } from "./preset-runtime.js";
2
+ import { placeWorldbook, substituteCardMacros } from "./worldbook.js";
3
+ import { renderPersonaPrompt } from "./persona-runtime.js";
4
+ export { DEFAULT_TAVERN_PRESET } from "./preset-runtime.js";
5
+ function clean(text) { return text.trim(); }
6
+ function formatWorldInfo(template, content) { return content.length === 0 ? "" : template.includes("{0}") ? template.replaceAll("{0}", content) : content; }
7
+ function resolveOutlets(text, outlets) { return text.replace(/\{\{outlet::([^{}]+)\}\}/gu, (_match, name) => outlets[name.trim()] ?? ""); }
8
+ function estimateMessageTokens(message) { return Math.ceil(message.content.length / 4) + 4; }
9
+ function sourceFor(definition, kind, extra = {}) {
10
+ return {
11
+ kind,
12
+ blockId: definition.marker ?? definition.identifier,
13
+ promptIdentifier: definition.identifier,
14
+ ...(definition.marker === undefined ? {} : { marker: definition.marker }),
15
+ ...extra,
16
+ };
17
+ }
18
+ function parseExamples(text, values, definition) {
19
+ const substituted = clean(substituteCardMacros(text, values));
20
+ if (substituted.length === 0)
21
+ return [];
22
+ const messages = [];
23
+ let current;
24
+ for (const rawLine of substituted.replaceAll("<START>", "").split(/\r?\n/gu)) {
25
+ const line = rawLine.trim();
26
+ if (line.length === 0)
27
+ continue;
28
+ const match = line.match(/^([^::]{1,80})[::]\s*(.*)$/u);
29
+ if (match !== null) {
30
+ const speaker = (match[1] ?? "").trim();
31
+ const resolvedRole = speaker === values.userName || /^user$/iu.test(speaker) ? "user" : speaker === values.characterName || /^char(?:acter)?$/iu.test(speaker) ? "assistant" : undefined;
32
+ if (resolvedRole !== undefined) {
33
+ current = { role: resolvedRole, content: match[2] ?? "", source: sourceFor(definition, "example") };
34
+ messages.push(current);
35
+ continue;
36
+ }
37
+ }
38
+ if (current === undefined) {
39
+ current = { role: "system", content: line, source: sourceFor(definition, "example") };
40
+ messages.push(current);
41
+ }
42
+ else
43
+ current.content += `\n${line}`;
44
+ }
45
+ return messages;
46
+ }
47
+ function depthMessages(placement, chatDefinition) {
48
+ const grouped = new Map();
49
+ for (const entry of placement.entries.filter((candidate) => candidate.position === "at_depth")) {
50
+ const key = `${entry.depth}:${entry.role}`;
51
+ grouped.set(key, [...(grouped.get(key) ?? []), entry]);
52
+ }
53
+ return [...grouped.values()].map((entries) => ({
54
+ role: entries[0].role,
55
+ content: entries.map((entry) => entry.content).join("\n\n"),
56
+ source: sourceFor(chatDefinition, "worldbook", { entryIds: entries.map((entry) => entry.id), position: "at-depth", depth: entries[0].depth }),
57
+ })).sort((left, right) => (right.source.depth ?? 0) - (left.source.depth ?? 0) || ["system", "user", "assistant"].indexOf(left.role) - ["system", "user", "assistant"].indexOf(right.role));
58
+ }
59
+ function insertAtDepth(messages, injections) {
60
+ const roleOrder = { user: 0, assistant: 1, system: 2 };
61
+ const grouped = new Map();
62
+ for (const injection of injections) {
63
+ const index = Math.max(0, messages.length - Math.min(messages.length, injection.depth));
64
+ grouped.set(index, [...(grouped.get(index) ?? []), injection]);
65
+ }
66
+ for (const [index, rows] of [...grouped.entries()].sort((left, right) => right[0] - left[0])) {
67
+ rows.sort((left, right) => left.order - right.order || roleOrder[left.message.role] - roleOrder[right.message.role]);
68
+ messages.splice(index, 0, ...rows.map((row) => row.message));
69
+ }
70
+ }
71
+ function compileChatHistory(chat, placement, definition, inChatPrompts, values) {
72
+ const result = chat.map((message) => ({
73
+ role: message.role,
74
+ content: message.content,
75
+ source: sourceFor(definition, "chat", { sourceIndex: message.sourceIndex }),
76
+ }));
77
+ insertAtDepth(result, [
78
+ ...depthMessages(placement, definition).map((message) => ({ message, depth: message.source.depth ?? 0, order: 100 })),
79
+ ...inChatPrompts.flatMap((prompt) => {
80
+ const content = clean(resolveOutlets(substituteCardMacros(prompt.content, values), placement.outlets));
81
+ return content.length === 0 ? [] : [{ message: { role: prompt.role, content, source: sourceFor(prompt, "preset", { position: "in-chat", depth: prompt.injectionDepth }) }, depth: prompt.injectionDepth, order: prompt.injectionOrder }];
82
+ }),
83
+ ]);
84
+ return result;
85
+ }
86
+ export function normalizeTavernChatMessages(messages) {
87
+ return messages.flatMap((message, sourceIndex) => {
88
+ if (message?.role !== "user" && message?.role !== "assistant")
89
+ return [];
90
+ if (message?.source?.kind === "plugin" && message.source.plugin !== "compact")
91
+ return [];
92
+ const content = typeof message.content === "string" ? message.content : Array.isArray(message.content) ? message.content.filter((part) => part?.type === "text" && typeof part.text === "string").map((part) => part.text).join("\n") : "";
93
+ if (content.trim().length === 0 || /^\[DSH_RE3_RP_(?:WORLD_CONTEXT|ASSEMBLY)\]/u.test(content))
94
+ return [];
95
+ return [{ role: message.role, content, sourceIndex }];
96
+ });
97
+ }
98
+ function markerMessages(input) {
99
+ const { definition, card, placement, values } = input;
100
+ switch (definition.marker) {
101
+ case "main-prompt": {
102
+ const text = clean(substituteCardMacros(card.systemPrompt, values)) || clean(substituteCardMacros(definition.content, values)) || `你正在扮演 ${card.title}。请始终以角色身份自然地延续当前对话。`;
103
+ return [{ role: definition.role, content: text, source: sourceFor(definition, "preset") }];
104
+ }
105
+ case "world-info-before": {
106
+ const text = formatWorldInfo(input.worldInfoFormat, placement.beforeCharacter);
107
+ return text.length === 0 ? [] : [{ role: definition.role, content: text, source: sourceFor(definition, "worldbook", { entryIds: placement.entries.filter((entry) => entry.position === "before_char").map((entry) => entry.id), position: "before-char" }) }];
108
+ }
109
+ case "persona-description": {
110
+ const text = clean(substituteCardMacros(renderPersonaPrompt(values.userName, input.personaDescription ?? ""), values));
111
+ return text.length === 0 ? [] : [{ role: definition.role, content: text, source: sourceFor(definition, "preset") }];
112
+ }
113
+ case "character-description": {
114
+ const text = clean(substituteCardMacros(card.description, values));
115
+ return text.length === 0 ? [] : [{ role: definition.role, content: text, source: sourceFor(definition, "preset") }];
116
+ }
117
+ case "character-personality": {
118
+ const text = clean(substituteCardMacros(card.personality, values));
119
+ return text.length === 0 ? [] : [{ role: definition.role, content: text, source: sourceFor(definition, "preset") }];
120
+ }
121
+ case "scenario": {
122
+ const text = clean(substituteCardMacros(card.scenario, values));
123
+ return text.length === 0 ? [] : [{ role: definition.role, content: text, source: sourceFor(definition, "preset") }];
124
+ }
125
+ case "world-info-after": {
126
+ const text = formatWorldInfo(input.worldInfoFormat, placement.afterCharacter);
127
+ return text.length === 0 ? [] : [{ role: definition.role, content: text, source: sourceFor(definition, "worldbook", { entryIds: placement.entries.filter((entry) => entry.position === "after_char").map((entry) => entry.id), position: "after-char" }) }];
128
+ }
129
+ case "example-messages": {
130
+ const messages = [];
131
+ if (placement.beforeExamples.length > 0)
132
+ messages.push({ role: definition.role, content: placement.beforeExamples, source: sourceFor(definition, "worldbook", { entryIds: placement.entries.filter((entry) => entry.position === "before_examples").map((entry) => entry.id), position: "before-examples" }) });
133
+ messages.push(...parseExamples(card.messageExample, values, definition));
134
+ if (placement.afterExamples.length > 0)
135
+ messages.push({ role: definition.role, content: placement.afterExamples, source: sourceFor(definition, "worldbook", { entryIds: placement.entries.filter((entry) => entry.position === "after_examples").map((entry) => entry.id), position: "after-examples" }) });
136
+ return messages;
137
+ }
138
+ case "authors-note": {
139
+ const text = [placement.authorNoteTop, placement.authorNoteBottom].filter(Boolean).join("\n\n");
140
+ return text.length === 0 ? [] : [{ role: definition.role, content: text, source: sourceFor(definition, "worldbook", { entryIds: placement.entries.filter((entry) => entry.position === "an_top" || entry.position === "an_bottom").map((entry) => entry.id), position: "authors-note" }) }];
141
+ }
142
+ case "chat-history": return compileChatHistory(input.chat, placement, definition, input.inChatPrompts, values);
143
+ case "post-history-instructions": {
144
+ const text = clean(substituteCardMacros(card.postHistoryInstructions, values)) || clean(substituteCardMacros(definition.content, values));
145
+ return text.length === 0 ? [] : [{ role: definition.role, content: text, source: sourceFor(definition, "preset") }];
146
+ }
147
+ default: return [];
148
+ }
149
+ }
150
+ function trimToContext(messages, contextTokens) {
151
+ let current = messages;
152
+ let estimatedTokens = current.reduce((sum, message) => sum + estimateMessageTokens(message), 0);
153
+ let prunedChatMessages = 0;
154
+ let prunedExampleMessages = 0;
155
+ if (contextTokens === null)
156
+ return { messages: current, prunedChatMessages, prunedExampleMessages, estimatedTokens, contextExceeded: false };
157
+ if (estimatedTokens > contextTokens) {
158
+ const latestChatIndex = current.reduce((last, message, index) => message.source.kind === "chat" ? index : last, -1);
159
+ const removable = current.flatMap((message, index) => message.source.kind === "chat" && index !== latestChatIndex ? [index] : []);
160
+ const removed = new Set();
161
+ for (const index of removable) {
162
+ if (estimatedTokens <= contextTokens)
163
+ break;
164
+ estimatedTokens -= estimateMessageTokens(current[index]);
165
+ removed.add(index);
166
+ prunedChatMessages += 1;
167
+ }
168
+ current = current.filter((_message, index) => !removed.has(index));
169
+ }
170
+ if (estimatedTokens > contextTokens) {
171
+ const removed = new Set();
172
+ for (let index = 0; index < current.length; index += 1) {
173
+ if (estimatedTokens <= contextTokens)
174
+ break;
175
+ if (current[index].source.kind !== "example")
176
+ continue;
177
+ estimatedTokens -= estimateMessageTokens(current[index]);
178
+ removed.add(index);
179
+ prunedExampleMessages += 1;
180
+ }
181
+ current = current.filter((_message, index) => !removed.has(index));
182
+ }
183
+ return { messages: current, prunedChatMessages, prunedExampleMessages, estimatedTokens, contextExceeded: estimatedTokens > contextTokens };
184
+ }
185
+ export function compileTavernPrompt(input) {
186
+ const preset = input.preset ?? DEFAULT_TAVERN_PRESET;
187
+ const values = { userName: input.userName, characterName: input.card.title, messageVariables: input.messageVariables, localVariables: {}, macroSeed: input.macroSeed };
188
+ const placement = placeWorldbook(input.activation.active, values);
189
+ const definitions = new Map(preset.prompts.map((prompt) => [prompt.identifier, prompt]));
190
+ const enabled = new Map(preset.promptOrder.map((item) => [item.identifier, item.enabled]));
191
+ const inChatPrompts = preset.prompts.filter((prompt) => prompt.marker === undefined && prompt.injectionPosition === "in-chat" && enabled.get(prompt.identifier) === true);
192
+ const assembled = [];
193
+ for (const item of preset.promptOrder) {
194
+ if (!item.enabled)
195
+ continue;
196
+ const definition = definitions.get(item.identifier);
197
+ if (definition === undefined || definition.injectionPosition === "in-chat" && definition.marker === undefined)
198
+ continue;
199
+ if (definition.marker !== undefined)
200
+ assembled.push(...markerMessages({ definition, card: input.card, personaDescription: input.personaDescription, placement, chat: input.chat, inChatPrompts, values, worldInfoFormat: preset.worldInfoFormat }));
201
+ else {
202
+ const content = clean(resolveOutlets(substituteCardMacros(definition.content, values), placement.outlets));
203
+ if (content.length > 0)
204
+ assembled.push({ role: definition.role, content, source: sourceFor(definition, "preset") });
205
+ }
206
+ }
207
+ const trimmed = trimToContext(assembled, preset.settings.contextTokens);
208
+ const messages = trimmed.messages;
209
+ const blocks = preset.promptOrder.map((item) => {
210
+ const definition = definitions.get(item.identifier) ?? { identifier: item.identifier, name: item.identifier, role: "system", content: "", systemPrompt: false, injectionPosition: "relative", injectionDepth: 4, injectionOrder: 100, extra: {} };
211
+ const indexes = messages.flatMap((message, index) => message.source.promptIdentifier === item.identifier ? [index] : []);
212
+ const text = indexes.map((index) => messages[index].content).join("\n");
213
+ return {
214
+ id: definition.marker ?? definition.identifier,
215
+ promptIdentifier: definition.identifier,
216
+ ...(definition.marker === undefined ? {} : { marker: definition.marker }),
217
+ label: definition.name,
218
+ enabled: item.enabled,
219
+ role: definition.role,
220
+ injectionPosition: definition.injectionPosition,
221
+ injectionDepth: definition.injectionDepth,
222
+ characterCount: text.length,
223
+ entryIds: [...new Set(indexes.flatMap((index) => messages[index].source.entryIds ?? []))],
224
+ messageIndexes: indexes,
225
+ preview: text.replace(/\s+/gu, " ").trim().slice(0, 180),
226
+ };
227
+ });
228
+ return {
229
+ preset: { id: preset.id, name: preset.name, source: preset.source, revision: preset.revision },
230
+ settings: { ...preset.settings },
231
+ messages,
232
+ blocks,
233
+ placement,
234
+ activation: input.activation,
235
+ stats: {
236
+ messageCount: messages.length,
237
+ characterCount: messages.reduce((sum, message) => sum + message.content.length, 0),
238
+ estimatedTokens: trimmed.estimatedTokens,
239
+ contextTokens: preset.settings.contextTokens,
240
+ prunedChatMessages: trimmed.prunedChatMessages,
241
+ prunedExampleMessages: trimmed.prunedExampleMessages,
242
+ contextExceeded: trimmed.contextExceeded,
243
+ activeWorldbookEntries: input.activation.active.length,
244
+ filteredWorldbookEntries: input.activation.trace.filter((row) => !row.activated).length,
245
+ depthInjections: placement.entries.filter((entry) => entry.position === "at_depth").length + inChatPrompts.length,
246
+ },
247
+ };
248
+ }
249
+ export function compiledTavernSystemPrompt(compiled) {
250
+ return compiled.messages.filter((message) => message.source.marker === "main-prompt").map((message) => message.content).join("\n\n");
251
+ }
252
+ export function renderTavernContextEnvelope(compiled) {
253
+ const labels = new Map(compiled.blocks.map((block) => [block.id, block.label]));
254
+ const sections = compiled.messages.flatMap((message) => {
255
+ if (message.source.marker === "main-prompt" || message.source.kind === "chat")
256
+ return [];
257
+ const label = message.source.position === "at-depth" ? `World Info · @Depth ${message.source.depth ?? 0}` : message.source.blockId === undefined ? "Prompt module" : labels.get(message.source.blockId) ?? "Prompt module";
258
+ const entrySuffix = (message.source.entryIds?.length ?? 0) > 0 ? ` · entries ${message.source.entryIds.join(", ")}` : "";
259
+ return [`[${label} · ${message.role}${entrySuffix}]\n${message.content}`];
260
+ });
261
+ return ["[DSH_RE3_RP_COMPILED_CONTEXT]", `Preset: ${compiled.preset.name}`, "The following sections are assembled roleplay context. Follow them without mentioning prompt modules, world info, presets, or this envelope.", ...sections].join("\n\n");
262
+ }
263
+ export function applyCompiledPromptToRequest(options, compiled) {
264
+ const messages = compiled.messages.map((message) => ({
265
+ id: crypto.randomUUID(),
266
+ role: message.role,
267
+ content: [{ type: "text", text: message.content }],
268
+ source: message.role === "assistant" ? { kind: "model", provider: "dsh-roleplay", model: "compiled-context" } : { kind: "plugin", plugin: "dsh-roleplay", form: "compiled", compiledSource: message.source },
269
+ }));
270
+ const leadingSystem = [];
271
+ while (messages[0]?.role === "system")
272
+ leadingSystem.push(messages.shift().content[0].text);
273
+ options.system = leadingSystem.join("\n\n");
274
+ options.messages = messages;
275
+ options.tools = [];
276
+ options.temperature = compiled.settings.temperature;
277
+ if (compiled.settings.maxReplyTokens !== null)
278
+ options.maxTokens = compiled.settings.maxReplyTokens;
279
+ options.tavernPresetRuntime = {
280
+ presetId: compiled.preset.id,
281
+ revision: compiled.preset.revision,
282
+ stream: compiled.settings.stream,
283
+ contextTokens: compiled.settings.contextTokens,
284
+ topP: compiled.settings.topP,
285
+ frequencyPenalty: compiled.settings.frequencyPenalty,
286
+ presencePenalty: compiled.settings.presencePenalty,
287
+ };
288
+ }
@@ -0,0 +1,176 @@
1
+ const rawHtmlDocument = /^\s*<(?:!doctype|html|body|style|div|section|details)\b/iu;
2
+ const htmlFragment = /^\s*<(?:!doctype|[a-z][\w:-]*)(?:\s|>)[\s\S]*>\s*$/iu;
3
+ const fencedHtml = /```html\s*\r?\n([\s\S]*?)\r?\n```/giu;
4
+ const fencedUntypedHtmlDocument = /```[ \t]*\r?\n(\s*<(?:!doctype|html)\b[\s\S]*?)\r?\n```/giu;
5
+ const htmlTag = /<!--[\s\S]*?-->|<![^>]*>|<\/?([a-z][\w:-]*)\b(?:\s+(?:"[^"]*"|'[^']*'|[^'"<>])*)?\s*\/?>/giu;
6
+ const rawTextElement = /<(script|style|template)\b(?:\s+(?:"[^"]*"|'[^']*'|[^'"<>])*)?\s*>[\s\S]*?<\/\1\s*>/giu;
7
+ const voidHtmlTags = new Set(["area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source", "track", "wbr"]);
8
+ const crossBoundaryContainerTags = new Set(["article", "aside", "body", "details", "dialog", "div", "fieldset", "footer", "form", "header", "html", "main", "nav", "ol", "section", "table", "tbody", "tfoot", "thead", "ul"]);
9
+ function leavesOpenHtmlContainer(value) {
10
+ const stack = [];
11
+ const structuralHtml = value.replace(rawTextElement, "");
12
+ for (const match of structuralHtml.matchAll(new RegExp(htmlTag.source, htmlTag.flags))) {
13
+ const token = match[0];
14
+ const tag = match[1]?.toLowerCase();
15
+ if (tag === undefined || token.startsWith("<!--") || token.startsWith("<!"))
16
+ continue;
17
+ if (token.startsWith("</")) {
18
+ const matchingIndex = stack.lastIndexOf(tag);
19
+ if (matchingIndex >= 0)
20
+ stack.splice(matchingIndex);
21
+ continue;
22
+ }
23
+ if (token.endsWith("/>") || voidHtmlTags.has(tag))
24
+ continue;
25
+ stack.push(tag);
26
+ }
27
+ return stack.some((tag) => crossBoundaryContainerTags.has(tag) || tag.includes("-"));
28
+ }
29
+ function escapeCrossBoundaryProse(value) {
30
+ // `<content>` is a common card control wrapper, not player prose. In the
31
+ // original shared message DOM the browser consumes it as markup; do the same
32
+ // without allowing arbitrary prose HTML to become executable in the iframe.
33
+ const visible = value
34
+ .replace(/(?:^|\r?\n)[ \t]*<\/?content\b[^>]*>[ \t]*(?=\r?\n|$)/giu, "\n")
35
+ .trim();
36
+ if (visible.length === 0)
37
+ return "";
38
+ const escaped = visible
39
+ .replaceAll("&", "&amp;")
40
+ .replaceAll("<", "&lt;")
41
+ .replaceAll(">", "&gt;")
42
+ .replaceAll('"', "&quot;")
43
+ .replaceAll("'", "&#39;");
44
+ return `<div data-dsh-cross-boundary-prose style="white-space: pre-wrap">${escaped}</div>`;
45
+ }
46
+ function stitchCrossBoundaryWrapper(blocks) {
47
+ const openingIndex = blocks.findIndex((block, index) => block.kind === "html"
48
+ && index < blocks.length - 1
49
+ && leavesOpenHtmlContainer(block.content));
50
+ if (openingIndex < 0)
51
+ return [...blocks];
52
+ const content = blocks.slice(openingIndex)
53
+ .map((block) => block.kind === "html" ? block.content : escapeCrossBoundaryProse(block.content))
54
+ .filter((value) => value.length > 0)
55
+ .join("\n");
56
+ return [...blocks.slice(0, openingIndex), { kind: "html", content }];
57
+ }
58
+ // Message iframes are expanded by the Host, so their previous viewport height
59
+ // must never participate in the next measurement. In particular,
60
+ // documentElement.scrollHeight is pinned to an already-tall iframe and cannot
61
+ // shrink after a card switches from a long page to a short page.
62
+ export function measureRichFrameContentHeight(metrics) {
63
+ const finite = (value) => Number.isFinite(value) ? value : 0;
64
+ const bodyTop = Math.min(0, finite(metrics.bodyRectTop));
65
+ const bodyBottom = Math.max(finite(metrics.bodyScrollHeight), finite(metrics.bodyOffsetHeight), finite(metrics.bodyRectBottom) - bodyTop);
66
+ // A viewport-relative card can keep body at exactly the current iframe
67
+ // height while moving the rest of its UI into overflow:auto descendants.
68
+ // Add that clipped extent so repeated resize receipts grow the viewport
69
+ // until the outer DSH conversation owns the vertical scroll surface.
70
+ const nestedOverflow = Math.max(0, finite(metrics.nestedScrollableOverflowHeight ?? 0));
71
+ return Math.max(72, Math.ceil(bodyBottom + nestedOverflow));
72
+ }
73
+ // Real cards can legitimately be many screens tall, so the Host owns one
74
+ // generous outer-scroll surface. Still cap a forged or broken resize receipt
75
+ // before it can create a billion-pixel layout in the conversation.
76
+ export function clampRichFrameHeight(value) {
77
+ if (!Number.isFinite(value))
78
+ return 72;
79
+ return Math.max(72, Math.min(40_000, Math.ceil(value)));
80
+ }
81
+ // The accepted SillyTavern reference profile exposes EjsTemplate as enabled.
82
+ // Real-card environment checks read this exact public context path. Keeping the
83
+ // compatibility value in one builder prevents the iframe bootstrap from
84
+ // drifting into an incomplete, card-specific mock.
85
+ export function buildSillyTavernCompatibilityContext(messageCount, currentMessageId) {
86
+ const safeCount = Number.isFinite(messageCount) ? Math.max(0, Math.trunc(messageCount)) : 0;
87
+ const safeMessageId = Number.isFinite(currentMessageId) ? Math.trunc(currentMessageId) : -1;
88
+ return {
89
+ chat: Array.from({ length: safeCount }, () => ({})),
90
+ chatId: safeMessageId,
91
+ extensionSettings: { EjsTemplate: { enabled: true } },
92
+ };
93
+ }
94
+ export function alignProjectedRoles(flowRoles, projectedRoles) {
95
+ const aligned = Array.from({ length: flowRoles.length }, () => null);
96
+ let projectedIndex = 0;
97
+ for (let flowIndex = 0; flowIndex < flowRoles.length && projectedIndex < projectedRoles.length; flowIndex += 1) {
98
+ if (flowRoles[flowIndex] !== projectedRoles[projectedIndex])
99
+ continue;
100
+ aligned[flowIndex] = projectedIndex;
101
+ projectedIndex += 1;
102
+ }
103
+ return projectedIndex === projectedRoles.length ? aligned : null;
104
+ }
105
+ export function splitRichMessage(text) {
106
+ if (rawHtmlDocument.test(text))
107
+ return [{ kind: "html", content: text.trim() }];
108
+ // Some real Tavern cards prepend a fenced <style> block to an opening whose
109
+ // document root is a custom element (for example <welcome>). Treat that
110
+ // combination as one frontend document so the style and markup share an
111
+ // iframe instead of leaking hundreds of HTML lines into native prose.
112
+ const fencedDocumentCount = [...text.matchAll(new RegExp(fencedHtml.source, fencedHtml.flags))].length
113
+ + [...text.matchAll(new RegExp(fencedUntypedHtmlDocument.source, fencedUntypedHtmlDocument.flags))].length;
114
+ if (fencedDocumentCount <= 1) {
115
+ const stitched = text
116
+ .replace(fencedHtml, (_match, content) => content)
117
+ // SillyTavern cards in the wild also fence complete HTML documents without
118
+ // an `html` language label. Only unwrap documents whose first tag proves
119
+ // their intent; ordinary untyped code fences remain prose/code.
120
+ .replace(fencedUntypedHtmlDocument, (_match, content) => content)
121
+ .trim();
122
+ if (htmlFragment.test(stitched))
123
+ return [{ kind: "html", content: stitched }];
124
+ }
125
+ const blocks = [];
126
+ const expression = new RegExp(fencedHtml.source, fencedHtml.flags);
127
+ let cursor = 0;
128
+ for (const match of text.matchAll(expression)) {
129
+ const index = match.index ?? cursor;
130
+ const before = text.slice(cursor, index).trim();
131
+ if (before.length > 0)
132
+ blocks.push({ kind: "prose", content: before });
133
+ blocks.push({ kind: "html", content: match[1] ?? "" });
134
+ cursor = index + match[0].length;
135
+ }
136
+ const after = text.slice(cursor).trim();
137
+ if (after.length > 0)
138
+ blocks.push({ kind: "prose", content: after });
139
+ if (blocks.length === 0)
140
+ blocks.push({ kind: "prose", content: text });
141
+ // Some Tavern display Regex rules deliberately open a visual wrapper when a
142
+ // control tag starts and rely on the shared SillyTavern message DOM to close
143
+ // it at the end of the message. An iframe boundary would otherwise close the
144
+ // wrapper before the following prose. Compose only that structural case;
145
+ // balanced authored fragments keep the ordinary native prose/iframe flow.
146
+ return stitchCrossBoundaryWrapper(blocks);
147
+ }
148
+ // The authored documents run in a sandboxed data document with a unique
149
+ // origin. Rewrite only the well-known SillyTavern parent-window access points
150
+ // used by the accepted real cards; ordinary parent.postMessage remains
151
+ // untouched for resize notices.
152
+ export function adaptRealCardFrontendHtml(value) {
153
+ return value
154
+ .replaceAll("window.parent.document", "window.__dshCompatDocument")
155
+ .replaceAll("window.top.document", "window.__dshCompatDocument")
156
+ .replaceAll("parent.document", "window.__dshCompatDocument")
157
+ .replaceAll("top.document", "window.__dshCompatDocument")
158
+ .replaceAll("window.parent?.SillyTavern", "window.__dshSillyTavern")
159
+ .replaceAll("window.top?.SillyTavern", "window.__dshSillyTavern")
160
+ .replaceAll("window.parent.SillyTavern", "window.__dshSillyTavern")
161
+ .replaceAll("window.top.SillyTavern", "window.__dshSillyTavern")
162
+ .replaceAll("window.parent?.TavernHelper", "window.__dshTavernHelper")
163
+ .replaceAll("window.top?.TavernHelper", "window.__dshTavernHelper")
164
+ .replaceAll("window.parent.TavernHelper", "window.__dshTavernHelper")
165
+ .replaceAll("window.top.TavernHelper", "window.__dshTavernHelper")
166
+ .replaceAll("window.parent?.Mvu", "window.__dshMvu")
167
+ .replaceAll("window.top?.Mvu", "window.__dshMvu")
168
+ .replaceAll("window.parent.Mvu", "window.__dshMvu")
169
+ .replaceAll("window.top.Mvu", "window.__dshMvu")
170
+ // Bundled card code sometimes aliases the frame first (`const host =
171
+ // window.top`) and only reads SillyTavern APIs afterwards. Keep resize
172
+ // postMessages pointed at the host, then collapse any remaining top/parent
173
+ // aliases to the compatibility globals installed inside the data document.
174
+ .replaceAll("window.parent.postMessage", "parent.postMessage")
175
+ .replace(/window\.(?:parent|top)(?![\w$])/gu, "window");
176
+ }