From d8cff924346d29f37aa53a2b9a198e1d999a9438 Mon Sep 17 00:00:00 2001 From: Youzini-afk <13153778771cx@gmail.com> Date: Wed, 8 Apr 2026 00:10:20 +0800 Subject: [PATCH] Refactor host regex reuse and injection sanitization --- host-adapter/regex.js | 38 ++- injection-sanitizer.js | 463 ++++++++++++++++++++++++++++++ llm.js | 13 + manifest.json | 18 +- panel.js | 54 +++- prompt-builder.js | 423 +++++++++++++++++---------- task-regex.js | 441 ++++++++++++++++++++++++---- tests/p0-regressions.mjs | 2 +- tests/prompt-builder-defaults.mjs | 12 + tests/prompt-builder-mvu.mjs | 7 +- tests/task-regex.mjs | 248 ++++++++-------- 11 files changed, 1363 insertions(+), 356 deletions(-) create mode 100644 injection-sanitizer.js diff --git a/host-adapter/regex.js b/host-adapter/regex.js index 8f17396..c022aee 100644 --- a/host-adapter/regex.js +++ b/host-adapter/regex.js @@ -2,7 +2,11 @@ import { buildCapabilityStatus, mergeVersionHints } from "./capabilities.js"; import { createContextHostFacade } from "./context.js"; import { debugDebug } from "../debug-logging.js"; -const REGEX_API_NAMES = ["getTavernRegexes", "isCharacterTavernRegexesEnabled"]; +const REGEX_API_NAMES = [ + "getTavernRegexes", + "isCharacterTavernRegexesEnabled", + "formatAsTavernRegexedString", +]; function isObjectLike(value) { return ( @@ -182,7 +186,9 @@ function resolveRegexSource(options = {}, contextHost = null) { return ( records.find( - (record) => typeof record.apiMap.getTavernRegexes === "function", + (record) => + typeof record.apiMap.getTavernRegexes === "function" || + typeof record.apiMap.formatAsTavernRegexedString === "function", ) || buildSourceRecord({ label: "none", @@ -193,13 +199,21 @@ function resolveRegexSource(options = {}, contextHost = null) { } function detectRegexMode(apiMap = {}) { - if (typeof apiMap.getTavernRegexes !== "function") { + const hasGetter = typeof apiMap.getTavernRegexes === "function"; + const hasFormatter = + typeof apiMap.formatAsTavernRegexedString === "function"; + + if (!hasGetter && !hasFormatter) { return "unavailable"; } - return typeof apiMap.isCharacterTavernRegexesEnabled === "function" - ? "full" - : "partial"; + if (hasGetter && hasFormatter) { + return typeof apiMap.isCharacterTavernRegexesEnabled === "function" + ? "full" + : "partial"; + } + + return hasFormatter ? "formatter-only" : "getter-only"; } function buildFallbackReason(sourceRecord, available, mode) { @@ -219,6 +233,14 @@ function buildFallbackReason(sourceRecord, available, mode) { return `Tavern Regex 桥接仅发现部分接口,来源: ${sourceRecord?.label || "unknown"}`; } + if (mode === "formatter-only") { + return `Tavern Regex 桥接仅发现 formatter 接口,来源: ${sourceRecord?.label || "unknown"}`; + } + + if (mode === "getter-only") { + return `Tavern Regex 桥接仅发现规则读取接口,来源: ${sourceRecord?.label || "unknown"}`; + } + return ""; } @@ -253,6 +275,8 @@ export function createRegexHostFacade(options = {}) { getTavernRegexes: sourceRecord.apiMap.getTavernRegexes, isCharacterTavernRegexesEnabled: sourceRecord.apiMap.isCharacterTavernRegexesEnabled, + formatAsTavernRegexedString: + sourceRecord.apiMap.formatAsTavernRegexedString, getApi(name) { return sourceRecord.apiMap[String(name || "")] || null; }, @@ -271,6 +295,8 @@ export function createRegexHostFacade(options = {}) { source: sourceRecord.sourceKind, sourceLabel: sourceRecord.label, fallback: sourceRecord.fallback, + formatterAvailable: + typeof sourceRecord.apiMap.formatAsTavernRegexedString === "function", }); }, }); diff --git a/injection-sanitizer.js b/injection-sanitizer.js new file mode 100644 index 0000000..722e4bf --- /dev/null +++ b/injection-sanitizer.js @@ -0,0 +1,463 @@ +import { sanitizeMvuContent } from "./mvu-compat.js"; +import { applyHostRegexReuse } from "./task-regex.js"; + +export const PROMPT_CONTENT_ORIGIN = Object.freeze({ + TEMPLATE_OWNED: "template-owned", + HOST_INJECTED: "host-injected", + WORLD_INFO_RENDERED: "world-info-rendered", +}); + +function normalizeSanitizerMode(mode = "injection-safe") { + return String(mode || "").trim() === "final-injection-safe" + ? "final-safe" + : "aggressive"; +} + +function isSanitizationEligible(options = {}) { + if (options?.sanitizationEligible === false) { + return false; + } + return String(options?.contentOrigin || "") !== PROMPT_CONTENT_ORIGIN.TEMPLATE_OWNED; +} + +function normalizeReasons(reasons = []) { + return Array.isArray(reasons) + ? reasons.map((item) => String(item || "").trim()).filter(Boolean) + : []; +} + +function pushUnique(target = [], value = "") { + const normalized = String(value || "").trim(); + if (!normalized || target.includes(normalized)) { + return; + } + target.push(normalized); +} + +export function createEmptyInjectionSanitizerDebug() { + return { + sanitizedFieldCount: 0, + sanitizedFields: [], + finalMessageStripCount: 0, + worldInfoBlockedContentHits: 0, + sanitizerAppliedFields: [], + sanitizerHitKinds: [], + hostReuseAppliedFields: [], + hostReuseSkippedDisplayOnlyRules: 0, + regexExecutionMode: "host-unavailable", + hostFormatterAvailable: false, + hostFormatterSource: "", + fallbackReason: "", + }; +} + +function recordSanitizerDebug(debugState, path, result = {}, stage = "") { + if (!debugState || (!result.changed && !result.dropped)) { + return; + } + + const reasons = normalizeReasons(result.reasons); + debugState.sanitizedFields.push({ + name: String(path || ""), + stage: String(stage || ""), + changed: Boolean(result.changed), + dropped: Boolean(result.dropped), + reasons, + blockedHitCount: Number(result.blockedHitCount || 0), + }); + debugState.sanitizedFieldCount = debugState.sanitizedFields.length; + pushUnique(debugState.sanitizerAppliedFields, path); + for (const reason of reasons) { + pushUnique(debugState.sanitizerHitKinds, reason); + } +} + +function recordHostReuseDebug(debugState, path, result = {}) { + if (!debugState || !result || typeof result !== "object") { + return; + } + debugState.regexExecutionMode = String( + result.executionMode || debugState.regexExecutionMode || "host-unavailable", + ); + debugState.hostFormatterAvailable = Boolean(result.formatterAvailable); + debugState.hostFormatterSource = String(result.formatterSource || ""); + debugState.fallbackReason = String(result.fallbackReason || ""); + debugState.hostReuseSkippedDisplayOnlyRules = Math.max( + Number(debugState.hostReuseSkippedDisplayOnlyRules || 0), + Number(result.skippedDisplayOnlyRuleCount || 0), + ); + if (result.changed) { + pushUnique(debugState.hostReuseAppliedFields, path); + } +} + +export function sanitizeInjectionText( + settings = {}, + taskType, + text, + { + mode = "injection-safe", + blockedContents = [], + contentOrigin = PROMPT_CONTENT_ORIGIN.HOST_INJECTED, + sanitizationEligible = true, + regexSourceType = "", + role = "system", + formatterOptions = null, + debugState = null, + regexCollector = null, + applySanitizer = true, + applyHostRegex = true, + path = "", + stage = "", + } = {}, +) { + const originalText = typeof text === "string" ? text : ""; + const eligible = sanitizationEligible && isSanitizationEligible({ + sanitizationEligible, + contentOrigin, + }); + + const sanitizerResult = eligible && applySanitizer + ? sanitizeMvuContent(originalText, { + mode: normalizeSanitizerMode(mode), + blockedContents, + }) + : { + text: originalText, + changed: false, + dropped: false, + reasons: [], + blockedHitCount: 0, + artifactRemovedCount: 0, + }; + + recordSanitizerDebug(debugState, path, sanitizerResult, stage); + + const afterSanitizer = String(sanitizerResult.text || ""); + const hostReuseResult = eligible && applyHostRegex && regexSourceType + ? applyHostRegexReuse(settings, taskType, afterSanitizer, { + sourceType: regexSourceType, + role, + debugCollector: regexCollector, + formatterOptions, + }) + : { + text: afterSanitizer, + changed: false, + executionMode: "host-unavailable", + formatterAvailable: false, + formatterSource: "", + fallbackReason: "", + skippedDisplayOnlyRuleCount: 0, + }; + + recordHostReuseDebug(debugState, path, hostReuseResult); + + const finalText = String(hostReuseResult.text || ""); + return { + text: finalText, + changed: finalText !== originalText, + dropped: Boolean(sanitizerResult.dropped), + reasons: normalizeReasons(sanitizerResult.reasons), + blockedHitCount: Number(sanitizerResult.blockedHitCount || 0), + artifactRemovedCount: Number(sanitizerResult.artifactRemovedCount || 0), + hostReuseChanged: Boolean(hostReuseResult.changed), + executionMode: String(hostReuseResult.executionMode || "host-unavailable"), + formatterAvailable: Boolean(hostReuseResult.formatterAvailable), + formatterSource: String(hostReuseResult.formatterSource || ""), + fallbackReason: String(hostReuseResult.fallbackReason || ""), + skippedDisplayOnlyRuleCount: Number( + hostReuseResult.skippedDisplayOnlyRuleCount || 0, + ), + }; +} + +function looksLikeMvuStateContainer(value, seen = new WeakSet()) { + if (!value || typeof value !== "object") { + return false; + } + if (seen.has(value)) { + return false; + } + seen.add(value); + + if (Array.isArray(value)) { + return value.some((item) => looksLikeMvuStateContainer(item, seen)); + } + + const keys = Object.keys(value).map((key) => + String(key || "").trim().toLowerCase(), + ); + if ( + keys.some((key) => + ["stat_data", "display_data", "delta_data", "$internal"].includes(key), + ) + ) { + return true; + } + + return Object.values(value).some((item) => looksLikeMvuStateContainer(item, seen)); +} + +function getMvuObjectKeyStripReason(key, value) { + const normalizedKey = String(key || "").trim().toLowerCase(); + if ( + ["stat_data", "display_data", "delta_data", "$internal"].includes( + normalizedKey, + ) + ) { + return "mvu_state_key_removed"; + } + if ( + ["variables", "message_variables", "chat_variables"].includes(normalizedKey) && + looksLikeMvuStateContainer(value) + ) { + return "mvu_variables_container_removed"; + } + return ""; +} + +function joinStructuredPath(basePath = "", segment = "") { + const normalizedSegment = String(segment || ""); + if (!normalizedSegment) { + return basePath; + } + if (!basePath) { + return normalizedSegment.startsWith("[") + ? normalizedSegment.slice(1, -1) + : normalizedSegment; + } + return normalizedSegment.startsWith("[") + ? `${basePath}${normalizedSegment}` + : `${basePath}.${normalizedSegment}`; +} + +export function sanitizeInjectionStructuredValue( + settings = {}, + taskType, + value, + { + fieldName = "", + path = fieldName, + mode = "injection-safe", + blockedContents = [], + contentOrigin = PROMPT_CONTENT_ORIGIN.HOST_INJECTED, + sanitizationEligible = true, + regexSourceType = "", + role = "system", + formatterOptions = null, + debugState = null, + regexCollector = null, + applySanitizer = true, + applyHostRegex = true, + stripMvuContainers = true, + seen = new WeakSet(), + } = {}, +) { + if (typeof value === "string") { + const sanitized = sanitizeInjectionText(settings, taskType, value, { + mode, + blockedContents, + contentOrigin, + sanitizationEligible, + regexSourceType, + role, + formatterOptions, + debugState, + regexCollector, + applySanitizer, + applyHostRegex, + path, + stage: mode, + }); + return { + value: sanitized.text, + changed: Boolean(sanitized.changed || sanitized.dropped), + omit: + !String(sanitized.text || "").trim() && + String(value || "").trim().length > 0, + details: sanitized, + }; + } + + if (Array.isArray(value)) { + const sanitizedArray = []; + let changed = false; + for (let index = 0; index < value.length; index += 1) { + const childResult = sanitizeInjectionStructuredValue( + settings, + taskType, + value[index], + { + fieldName, + path: joinStructuredPath(path, `[${index}]`), + mode, + blockedContents, + contentOrigin, + sanitizationEligible, + regexSourceType, + role, + formatterOptions, + debugState, + regexCollector, + applySanitizer, + applyHostRegex, + stripMvuContainers, + seen, + }, + ); + if (childResult.omit) { + changed = true; + continue; + } + sanitizedArray.push(childResult.value); + if (childResult.changed) { + changed = true; + } + } + return { + value: sanitizedArray, + changed: changed || sanitizedArray.length !== value.length, + omit: value.length > 0 && sanitizedArray.length === 0, + details: null, + }; + } + + if (value && typeof value === "object") { + if (seen.has(value)) { + return { + value, + changed: false, + omit: false, + details: null, + }; + } + seen.add(value); + + const originalLooksMvuContainer = looksLikeMvuStateContainer(value); + const sanitizedObject = {}; + let changed = false; + let keptEntries = 0; + + for (const [key, entryValue] of Object.entries(value)) { + const stripReason = stripMvuContainers + ? getMvuObjectKeyStripReason(key, entryValue) + : ""; + if (stripReason) { + changed = true; + recordSanitizerDebug( + debugState, + joinStructuredPath(path, key), + { + changed: true, + dropped: true, + reasons: [stripReason], + blockedHitCount: 0, + }, + mode, + ); + continue; + } + + const childResult = sanitizeInjectionStructuredValue( + settings, + taskType, + entryValue, + { + fieldName, + path: joinStructuredPath(path, key), + mode, + blockedContents, + contentOrigin, + sanitizationEligible, + regexSourceType, + role, + formatterOptions, + debugState, + regexCollector, + applySanitizer, + applyHostRegex, + stripMvuContainers, + seen, + }, + ); + if (childResult.omit) { + changed = true; + continue; + } + sanitizedObject[key] = childResult.value; + keptEntries += 1; + if (childResult.changed) { + changed = true; + } + } + + return { + value: sanitizedObject, + changed, + omit: originalLooksMvuContainer && keptEntries === 0, + details: null, + }; + } + + return { + value, + changed: false, + omit: false, + details: null, + }; +} + +export function sanitizeInjectionMessages( + settings = {}, + taskType, + messages = [], + { + blockedContents = [], + debugState = null, + regexCollector = null, + } = {}, +) { + return (Array.isArray(messages) ? messages : []) + .map((message, index) => { + const contentOrigin = String(message?.contentOrigin || "").trim() || + PROMPT_CONTENT_ORIGIN.TEMPLATE_OWNED; + const sanitizationEligible = + message?.sanitizationEligible === true && + contentOrigin !== PROMPT_CONTENT_ORIGIN.TEMPLATE_OWNED; + if (!sanitizationEligible) { + return message; + } + + const sanitized = sanitizeInjectionText( + settings, + taskType, + String(message?.content || ""), + { + mode: "final-injection-safe", + blockedContents, + contentOrigin, + sanitizationEligible, + regexSourceType: String(message?.regexSourceType || ""), + role: message?.role || "system", + debugState, + regexCollector, + applySanitizer: true, + applyHostRegex: false, + path: `message[${index}]`, + stage: "final-injection-safe", + }, + ); + if (debugState && (sanitized.changed || sanitized.dropped)) { + debugState.finalMessageStripCount += 1; + } + if (!String(sanitized.text || "").trim()) { + return null; + } + return { + ...message, + content: sanitized.text, + }; + }) + .filter(Boolean); +} diff --git a/llm.js b/llm.js index d0b7b9e..b51b5b8 100644 --- a/llm.js +++ b/llm.js @@ -178,9 +178,17 @@ function normalizeRegexDebugEntries(debugCollector = null) { return []; } return debugCollector.entries.map((entry) => ({ + kind: String(entry?.kind || "local-regex"), taskType: String(entry?.taskType || ""), stage: String(entry?.stage || ""), enabled: entry?.enabled !== false, + executionMode: String(entry?.executionMode || ""), + formatterAvailable: Boolean(entry?.formatterAvailable), + hostFormatterSource: String(entry?.hostFormatterSource || ""), + fallbackReason: String(entry?.fallbackReason || ""), + skippedDisplayOnlyRuleCount: Number( + entry?.skippedDisplayOnlyRuleCount || 0, + ), appliedRules: Array.isArray(entry?.appliedRules) ? entry.appliedRules.map((rule) => ({ id: String(rule?.id || ""), @@ -278,6 +286,7 @@ function applyTaskFinalInputRegex(taskType, messages = []) { rawMessageCount: normalizedMessages.length, cleanedMessageCount: cleanedMessages.length, droppedMessageCount: normalizedMessages.length - cleanedMessages.length, + finalPromptLocalRuleCount: 0, stages: [], }, }; @@ -325,6 +334,10 @@ function applyTaskFinalInputRegex(taskType, messages = []) { rawMessageCount: normalizedMessages.length, cleanedMessageCount: cleanedMessages.length, droppedMessageCount, + finalPromptLocalRuleCount: normalizedEntries.reduce( + (sum, entry) => sum + Number(entry?.sourceCount?.local || 0), + 0, + ), stages: normalizedEntries, }, }; diff --git a/manifest.json b/manifest.json index a862ad5..ba90c44 100644 --- a/manifest.json +++ b/manifest.json @@ -1,11 +1,11 @@ { - "display_name": "ST-BME Memory Graph", - "loading_order": 150, - "requires": [], - "optional": [], - "js": "index.js", - "css": "style.css", - "author": "Youzini", - "version": "1.0.0", - "homePage": "https://github.com/Youzini-afk/ST-Bionic-Memory-Ecology" + "display_name": "ST-BME Memory Graph", + "loading_order": 150, + "requires": [], + "optional": [], + "js": "index.js", + "css": "style.css", + "author": "Youzini", + "version": "3.6.4", + "homePage": "https://github.com/Youzini-afk/ST-Bionic-Memory-Ecology" } diff --git a/panel.js b/panel.js index de39aaa..6e90da6 100644 --- a/panel.js +++ b/panel.js @@ -4273,8 +4273,11 @@ function _formatRegexReuseSourceLabel(sourceType = "") { } function _formatRegexReuseReplaceText(rule = {}) { - if (rule.promptStageMode === "clear") { - return "(美化/展示正则,ST-BME 请求阶段清空)"; + if (rule.promptStageMode === "display-only") { + return "(仅显示类规则,不进入 Memory LLM 请求)"; + } + if (rule.promptStageMode === "fallback-skip-beautify") { + return "(美化型替换,fallback 模式下不会进入 Prompt)"; } if (typeof rule.effectivePromptReplaceString === "string" && rule.effectivePromptReplaceString.length > 0) { return rule.effectivePromptReplaceString; @@ -4287,20 +4290,35 @@ function _formatRegexReuseReplaceText(rule = {}) { function _renderRegexReuseBadges(rule = {}) { const badges = []; - if (rule.promptStageMode === "clear") { + if (rule.promptStageMode === "display-only") { badges.push({ className: "is-clear", - text: "美化 -> 清空", + text: "仅显示", + }); + } else if (rule.promptStageMode === "host-real") { + badges.push({ + className: "is-transform", + text: "宿主真实执行", + }); + } else if (rule.promptStageMode === "host-fallback") { + badges.push({ + className: "is-prompt", + text: "插件兼容执行", + }); + } else if (rule.promptStageMode === "fallback-skip-beautify") { + badges.push({ + className: "is-skip", + text: "fallback 跳过美化", }); } else if (rule.promptStageMode === "replace") { badges.push({ className: "is-transform", - text: "转义", + text: "本地最终正则", }); } else { badges.push({ className: "is-skip", - text: "当前阶段跳过", + text: "当前不执行", }); } if (rule.markdownOnly) { @@ -4415,10 +4433,10 @@ function _buildRegexReusePopupContent(snapshot = {}) { container.innerHTML = `
-
+
当前正则脚本一览
- 这里展示的是当前任务预设下,ST-BME 实际会复用到请求链里的 Tavern 正则。展示/美化类规则在请求阶段会按空字符串替换。 + 这里展示的是当前任务预设下,ST-BME 对宿主注入内容会复用哪些 Tavern 正则,以及最终发送前还会执行哪些本地任务正则。
@@ -4443,7 +4461,7 @@ function _buildRegexReusePopupContent(snapshot = {}) {
桥接模式 - ${_escHtml(snapshot.host?.sourceLabel || "unknown")} · ${_escHtml(snapshot.host?.capabilityStatus?.mode || snapshot.host?.mode || "unknown")}${snapshot.host?.fallback ? " · fallback" : ""} + ${_escHtml(snapshot.host?.sourceLabel || "unknown")} · ${_escHtml(snapshot.host?.executionMode || snapshot.host?.capabilityStatus?.mode || snapshot.host?.mode || "unknown")}${snapshot.host?.formatterAvailable ? " · formatter" : ""}${snapshot.host?.fallback ? " · fallback" : ""}
@@ -4451,8 +4469,8 @@ function _buildRegexReusePopupContent(snapshot = {}) {
-
当前启用规则
-
EW 风格平铺展示,优先看你这次请求里真正会进入链路的规则。
+
宿主注入复用规则
+
这里只显示会参与“宿主注入文本”处理的 Tavern 规则;仅显示类规则会明确标注出来。
@@ -4466,6 +4484,20 @@ function _buildRegexReusePopupContent(snapshot = {}) {
+
+
+
+
任务本地最终正则
+
这一组只在最终请求发送前的 `input.finalPrompt` 阶段执行,不参与宿主注入清洗。
+
+
+
+ ${_renderRegexReuseRuleList(snapshot.localRules, "当前没有任务本地最终正则", { + showSource: false, + })} +
+
+
来源与排除明细
diff --git a/prompt-builder.js b/prompt-builder.js index 647363f..4f2eb96 100644 --- a/prompt-builder.js +++ b/prompt-builder.js @@ -3,7 +3,13 @@ import { debugLog, debugWarn } from "./debug-logging.js"; import { getActiveTaskProfile, getLegacyPromptForTask } from "./prompt-profiles.js"; -import { sanitizeMvuContent } from "./mvu-compat.js"; +import { + createEmptyInjectionSanitizerDebug, + PROMPT_CONTENT_ORIGIN, + sanitizeInjectionText, + sanitizeInjectionMessages, + sanitizeInjectionStructuredValue, +} from "./injection-sanitizer.js"; import { resolveTaskWorldInfo } from "./task-worldinfo.js"; import { applyTaskRegex } from "./task-regex.js"; @@ -54,6 +60,22 @@ const INPUT_REGEX_ROLE_BY_FIELD = { dialogueText: "mixed", }; +const INPUT_HOST_REGEX_SOURCE_BY_FIELD = { + userMessage: "user_input", + recentMessages: "ai_output", + chatMessages: "ai_output", + dialogueText: "ai_output", + candidateText: "ai_output", + candidateNodes: "ai_output", + nodeContent: "ai_output", + eventSummary: "ai_output", + characterSummary: "ai_output", + threadSummary: "ai_output", + contradictionSummary: "ai_output", + charDescription: "ai_output", + userPersona: "user_input", +}; + function cloneRuntimeDebugValue(value, fallback = null) { if (value == null) { return fallback; @@ -275,12 +297,7 @@ function buildEmptyWorldInfoContext() { } function createEmptyMvuPromptDebug() { - return { - sanitizedFieldCount: 0, - sanitizedFields: [], - finalMessageStripCount: 0, - worldInfoBlockedContentHits: 0, - }; + return createEmptyInjectionSanitizerDebug(); } function pushMvuPromptDebugEntry(debugState, entry = {}) { @@ -304,47 +321,46 @@ function sanitizeTaskPromptText( taskType, text, { - mode = "aggressive", + mode = "injection-safe", blockedContents = [], regexStage = "", role = "system", regexCollector = null, applyMvu = true, + contentOrigin = PROMPT_CONTENT_ORIGIN.HOST_INJECTED, + sanitizationEligible = true, + regexSourceType = "", } = {}, ) { - const originalText = typeof text === "string" ? text : ""; - const mvuResult = applyMvu - ? sanitizeMvuContent(originalText, { - mode, - blockedContents, - }) - : { - text: originalText, - changed: false, - dropped: false, - reasons: [], - blockedHitCount: 0, - artifactRemovedCount: 0, - }; - const afterMvu = String(mvuResult.text || ""); + const sanitized = sanitizeInjectionText(settings, taskType, text, { + mode: + String(mode || "").trim() === "final-safe" + ? "final-injection-safe" + : "injection-safe", + blockedContents, + contentOrigin, + sanitizationEligible, + regexSourceType, + role, + regexCollector, + applySanitizer: applyMvu, + applyHostRegex: Boolean(regexSourceType), + }); const finalText = regexStage ? applyTaskRegex( settings, taskType, regexStage, - afterMvu, + sanitized.text, regexCollector, role, ) - : afterMvu; + : sanitized.text; return { + ...sanitized, text: finalText, - changed: finalText !== originalText, - dropped: Boolean(mvuResult.dropped), - reasons: Array.isArray(mvuResult.reasons) ? mvuResult.reasons : [], - blockedHitCount: Number(mvuResult.blockedHitCount || 0), - artifactRemovedCount: Number(mvuResult.artifactRemovedCount || 0), + changed: finalText !== String(text || ""), }; } @@ -570,64 +586,49 @@ function sanitizePromptMessages( messages = [], { blockedContents = [], - regexStage = "", debugState = null, regexCollector = null, - applyMvu = true, + applySanitizer = null, } = {}, ) { - return (Array.isArray(messages) ? messages : []) - .map((message, index) => { - const messageApplyMvu = - typeof applyMvu === "function" ? applyMvu(message, index) : applyMvu; - const sanitized = sanitizeStructuredPromptValue( - settings, - taskType, - message, - { - fieldName: "message", - path: `message[${index}]`, - mode: "final-safe", - blockedContents, - regexStage, - role: message?.role || "system", - debugState, - regexCollector, - applyMvu: messageApplyMvu, - stripMvuContainers: messageApplyMvu, - }, - ); - if (debugState && (sanitized.changed || sanitized.omit)) { - debugState.finalMessageStripCount += 1; + const preparedMessages = (Array.isArray(messages) ? messages : []).map( + (message, index) => { + if (!message || typeof message !== "object") { + return message; } - if (sanitized.omit) { - return null; + const shouldSanitize = + typeof applySanitizer === "function" + ? applySanitizer(message, index) + : applySanitizer; + if (shouldSanitize === false) { + return { + ...message, + sanitizationEligible: false, + }; } - const executionMessage = createExecutionMessage( - sanitized.value?.role || message?.role, - sanitized.value?.content, - { - source: String(sanitized.value?.source || message?.source || ""), - blockId: String(sanitized.value?.blockId || message?.blockId || ""), - blockName: String( - sanitized.value?.blockName || message?.blockName || "", - ), - blockType: String( - sanitized.value?.blockType || message?.blockType || "", - ), - sourceKey: String( - sanitized.value?.sourceKey || message?.sourceKey || "", - ), - injectionMode: String( - sanitized.value?.injectionMode || message?.injectionMode || "", - ), - derivedFromWorldInfo: - sanitized.value?.derivedFromWorldInfo === true || - message?.derivedFromWorldInfo === true, - }, - ); - return executionMessage; - }) + return message; + }, + ); + + return sanitizeInjectionMessages(settings, taskType, preparedMessages, { + blockedContents, + debugState, + regexCollector, + }) + .map((message) => + createExecutionMessage(message.role, message.content, { + source: String(message?.source || ""), + blockId: String(message?.blockId || ""), + blockName: String(message?.blockName || ""), + blockType: String(message?.blockType || ""), + sourceKey: String(message?.sourceKey || ""), + injectionMode: String(message?.injectionMode || ""), + derivedFromWorldInfo: message?.derivedFromWorldInfo === true, + contentOrigin: String(message?.contentOrigin || ""), + sanitizationEligible: message?.sanitizationEligible === true, + regexSourceType: String(message?.regexSourceType || ""), + }), + ) .filter(Boolean); } @@ -647,6 +648,50 @@ function sanitizePromptContextInputs( stripMvuContainers = applyMvu, } = options || {}; + const applyLocalRegexToStructuredValue = ( + value, + regexStage, + regexRole, + seen = new WeakSet(), + ) => { + if (!regexStage) { + return value; + } + if (typeof value === "string") { + return applyTaskRegex( + settings, + taskType, + regexStage, + value, + regexCollector, + regexRole, + ); + } + if (Array.isArray(value)) { + return value.map((item) => + applyLocalRegexToStructuredValue(item, regexStage, regexRole, seen), + ); + } + if (value && typeof value === "object") { + if (seen.has(value)) { + return value; + } + seen.add(value); + return Object.fromEntries( + Object.entries(value).map(([key, entryValue]) => [ + key, + applyLocalRegexToStructuredValue( + entryValue, + regexStage, + regexRole, + seen, + ), + ]), + ); + } + return value; + }; + for (const fieldName of INPUT_CONTEXT_MVU_FIELDS) { if (!(fieldName in sanitizedContext)) { continue; @@ -654,29 +699,39 @@ function sanitizePromptContextInputs( const value = sanitizedContext[fieldName]; const regexStage = INPUT_REGEX_STAGE_BY_FIELD[fieldName] || ""; const regexRole = INPUT_REGEX_ROLE_BY_FIELD[fieldName] || "system"; - const sanitized = sanitizeStructuredPromptValue( + const regexSourceType = INPUT_HOST_REGEX_SOURCE_BY_FIELD[fieldName] || ""; + const sanitized = sanitizeInjectionStructuredValue( settings, taskType, value, { fieldName, path: fieldName, - mode: "aggressive", - regexStage, + mode: "injection-safe", + contentOrigin: PROMPT_CONTENT_ORIGIN.HOST_INJECTED, + sanitizationEligible: true, + regexSourceType, role: regexRole, debugState, regexCollector, - applyMvu, + applySanitizer: applyMvu, + applyHostRegex: Boolean(regexSourceType), stripMvuContainers, }, ); - sanitizedContext[fieldName] = sanitized.omit + let sanitizedValue = sanitized.omit ? Array.isArray(value) ? [] : typeof value === "string" ? "" : null : sanitized.value; + sanitizedValue = applyLocalRegexToStructuredValue( + sanitizedValue, + regexStage, + regexRole, + ); + sanitizedContext[fieldName] = sanitizedValue; } return sanitizedContext; @@ -693,17 +748,22 @@ function sanitizeWorldInfoEntries( ) { return (Array.isArray(entries) ? entries : []) .map((entry, index) => { - const sanitized = sanitizeTaskPromptText( + const sanitized = sanitizeInjectionText( settings, taskType, String(entry?.content || ""), { - mode: "aggressive", + mode: "injection-safe", blockedContents, - regexStage: "", + contentOrigin: PROMPT_CONTENT_ORIGIN.WORLD_INFO_RENDERED, + sanitizationEligible: true, + regexSourceType: "world_info", role: entry?.role || "system", regexCollector, - applyMvu, + applySanitizer: applyMvu, + applyHostRegex: true, + path: `worldInfo[${index}]`, + stage: "world-info-rendered", }, ); debugState.worldInfoBlockedContentHits += sanitized.blockedHitCount; @@ -780,17 +840,22 @@ function sanitizeWorldInfoContext( : [] ) .map((message) => { - const sanitized = sanitizeTaskPromptText( + const sanitized = sanitizeInjectionText( settings, taskType, String(message?.content || ""), { - mode: "aggressive", + mode: "injection-safe", blockedContents: runtimeBlockedContents, - regexStage: "", + contentOrigin: PROMPT_CONTENT_ORIGIN.WORLD_INFO_RENDERED, + sanitizationEligible: true, + regexSourceType: "world_info", role: message?.role || "system", regexCollector, - applyMvu: !isCustomFilter, + applySanitizer: !isCustomFilter, + applyHostRegex: true, + path: "taskAdditionalMessages", + stage: "world-info-rendered", }, ); debugState.worldInfoBlockedContentHits += sanitized.blockedHitCount; @@ -805,6 +870,9 @@ function sanitizeWorldInfoContext( content: sanitized.text, source: String(message?.source || "worldInfo-atDepth"), sourceKey: String(message?.sourceKey || "taskAdditionalMessages"), + contentOrigin: PROMPT_CONTENT_ORIGIN.WORLD_INFO_RENDERED, + sanitizationEligible: true, + regexSourceType: "world_info", }; }) .filter(Boolean); @@ -1000,6 +1068,54 @@ function buildHostInjectionPlan(renderedBlocks = [], worldInfoResolution = {}) { }; } +function getPromptFieldContentOrigin(sourceKey = "") { + const normalizedSourceKey = String(sourceKey || "").trim(); + if (!normalizedSourceKey) { + return PROMPT_CONTENT_ORIGIN.TEMPLATE_OWNED; + } + if (WORLD_INFO_VARIABLE_KEYS.includes(normalizedSourceKey)) { + return PROMPT_CONTENT_ORIGIN.WORLD_INFO_RENDERED; + } + if (INPUT_CONTEXT_MVU_FIELDS.includes(normalizedSourceKey)) { + return PROMPT_CONTENT_ORIGIN.HOST_INJECTED; + } + return PROMPT_CONTENT_ORIGIN.TEMPLATE_OWNED; +} + +function getPromptFieldRegexSourceType(sourceKey = "") { + const normalizedSourceKey = String(sourceKey || "").trim(); + if (!normalizedSourceKey) { + return ""; + } + if (WORLD_INFO_VARIABLE_KEYS.includes(normalizedSourceKey)) { + return "world_info"; + } + return INPUT_HOST_REGEX_SOURCE_BY_FIELD[normalizedSourceKey] || ""; +} + +function blockIsPureInjectedContent(block = {}) { + return ( + block?.type === "builtin" && + !String(block?.content || "").trim() && + String(block?.sourceKey || "").trim().length > 0 + ); +} + +function describeBlockContentOwnership(block = {}) { + const contentOrigin = blockIsPureInjectedContent(block) + ? getPromptFieldContentOrigin(block.sourceKey) + : PROMPT_CONTENT_ORIGIN.TEMPLATE_OWNED; + return { + contentOrigin, + sanitizationEligible: + contentOrigin !== PROMPT_CONTENT_ORIGIN.TEMPLATE_OWNED, + regexSourceType: + contentOrigin === PROMPT_CONTENT_ORIGIN.TEMPLATE_OWNED + ? "" + : getPromptFieldRegexSourceType(block.sourceKey), + }; +} + function resolveBlockDelivery(block = {}) { return normalizeRole(block.role) === "system" ? "private.system" @@ -1069,19 +1185,10 @@ export async function buildTaskPrompt(settings = {}, taskType, context = {}) { const profile = getActiveTaskProfile(settings, taskType); const legacyPrompt = getLegacyPromptForTask(settings, taskType); const promptRegexInput = { entries: [] }; - const worldInfoRegexInput = { entries: [] }; const mvuPromptDebug = createEmptyMvuPromptDebug(); - const worldInfoInputContext = sanitizePromptContextInputs( - settings, - taskType, - context, - null, - worldInfoRegexInput, - { - applyMvu: false, - stripMvuContainers: false, - }, - ); + const worldInfoInputContext = { + ...context, + }; const sanitizedInputContext = sanitizePromptContextInputs( settings, taskType, @@ -1164,6 +1271,7 @@ export async function buildTaskPrompt(settings = {}, taskType, context = {}) { const role = normalizeRole(block.role); const blockDerivedFromWorldInfo = blockUsesWorldInfoContent(block); + const blockOwnership = describeBlockContentOwnership(block); let content = ""; if (block.type === "legacyPrompt") { @@ -1189,38 +1297,7 @@ export async function buildTaskPrompt(settings = {}, taskType, context = {}) { ); } - const blockApplyMvu = !(isCustomFilter && blockDerivedFromWorldInfo); - const sanitizedBlockContent = sanitizeTaskPromptText( - settings, - taskType, - content, - { - mode: "final-safe", - blockedContents: worldInfoRuntimeBlockedContents, - regexStage: "", - role, - regexCollector: promptRegexInput, - applyMvu: blockApplyMvu, - }, - ); - mvuPromptDebug.worldInfoBlockedContentHits += - sanitizedBlockContent.blockedHitCount; - if (sanitizedBlockContent.changed || sanitizedBlockContent.dropped) { - mvuPromptDebug.finalMessageStripCount += 1; - } - content = sanitizedBlockContent.text; - if (!String(content || "").trim()) { - if (role === "user" && String(block.content || "").trim()) { - debugWarn( - `[ST-BME] buildTaskPrompt: user block "${block.name || block.id}" ` + - `content emptied during sanitization! ` + - `original length=${String(block.content || "").length}, ` + - `dropped=${sanitizedBlockContent.dropped}, ` + - `reasons=[${(sanitizedBlockContent.reasons || []).join(", ")}], ` + - `blockedHitCount=${sanitizedBlockContent.blockedHitCount}`, - ); - } continue; } @@ -1241,6 +1318,9 @@ export async function buildTaskPrompt(settings = {}, taskType, context = {}) { delivery: resolveBlockDelivery(block), effectiveDelivery: resolveBlockDelivery(block), diagnosticInjectionPosition: getBlockDiagnosticInjectionPosition(block), + contentOrigin: blockOwnership.contentOrigin, + sanitizationEligible: blockOwnership.sanitizationEligible, + regexSourceType: blockOwnership.regexSourceType, }); const executionMessage = createExecutionMessage(role, content, { @@ -1251,6 +1331,9 @@ export async function buildTaskPrompt(settings = {}, taskType, context = {}) { sourceKey: String(block.sourceKey || ""), injectionMode: mode, derivedFromWorldInfo: blockDerivedFromWorldInfo, + contentOrigin: blockOwnership.contentOrigin, + sanitizationEligible: blockOwnership.sanitizationEligible, + regexSourceType: blockOwnership.regexSourceType, }); if (executionMessage) { executionMessages.push(executionMessage); @@ -1284,6 +1367,9 @@ export async function buildTaskPrompt(settings = {}, taskType, context = {}) { sourceKey: String(block.sourceKey || ""), injectionMode: mode, derivedFromWorldInfo: blockDerivedFromWorldInfo, + contentOrigin: blockOwnership.contentOrigin, + sanitizationEligible: blockOwnership.sanitizationEligible, + regexSourceType: blockOwnership.regexSourceType, }); } else { customMessages.push({ @@ -1296,6 +1382,9 @@ export async function buildTaskPrompt(settings = {}, taskType, context = {}) { sourceKey: String(block.sourceKey || ""), injectionMode: mode, derivedFromWorldInfo: blockDerivedFromWorldInfo, + contentOrigin: blockOwnership.contentOrigin, + sanitizationEligible: blockOwnership.sanitizationEligible, + regexSourceType: blockOwnership.regexSourceType, }); } } @@ -1314,6 +1403,11 @@ export async function buildTaskPrompt(settings = {}, taskType, context = {}) { { source: "worldInfo-atDepth", sourceKey: "taskAdditionalMessages", + contentOrigin: + String(message.contentOrigin || "") || + PROMPT_CONTENT_ORIGIN.WORLD_INFO_RENDERED, + sanitizationEligible: message.sanitizationEligible === true, + regexSourceType: String(message.regexSourceType || "world_info"), }, ); if (executionMessage) { @@ -1341,7 +1435,7 @@ export async function buildTaskPrompt(settings = {}, taskType, context = {}) { executionMessages, privateTaskMessages, renderedBlocks, - regexInput: mergeRegexCollectors(promptRegexInput, worldInfoRegexInput), + regexInput: mergeRegexCollectors(promptRegexInput), worldInfoResolution, systemPrompt, customMessages, @@ -1397,6 +1491,31 @@ export async function buildTaskPrompt(settings = {}, taskType, context = {}) { ), finalMessageStripCount: mvuPromptDebug.finalMessageStripCount, worldInfoBlockedContentHits: mvuPromptDebug.worldInfoBlockedContentHits, + sanitizerAppliedFields: cloneRuntimeDebugValue( + mvuPromptDebug.sanitizerAppliedFields, + [], + ), + sanitizerHitKinds: cloneRuntimeDebugValue( + mvuPromptDebug.sanitizerHitKinds, + [], + ), + hostReuseAppliedFields: cloneRuntimeDebugValue( + mvuPromptDebug.hostReuseAppliedFields, + [], + ), + hostReuseSkippedDisplayOnlyRules: Number( + mvuPromptDebug.hostReuseSkippedDisplayOnlyRules || 0, + ), + regexExecutionMode: String( + mvuPromptDebug.regexExecutionMode || "host-unavailable", + ), + hostFormatterAvailable: Boolean( + mvuPromptDebug.hostFormatterAvailable, + ), + hostFormatterSource: String( + mvuPromptDebug.hostFormatterSource || "", + ), + fallbackReason: String(mvuPromptDebug.fallbackReason || ""), }, effectivePath: { promptAssembly: "ordered-private-messages", @@ -1446,6 +1565,7 @@ export async function buildTaskPrompt(settings = {}, taskType, context = {}) { export function buildTaskLlmPayload(promptBuild = null, fallbackUserPrompt = "") { const runtimeMvu = promptBuild?.__mvuRuntime || {}; const taskType = String(promptBuild?.debug?.taskType || ""); + const settings = {}; const isCustomFilter = String( promptBuild?.worldInfo?.debug?.customFilter?.mode || @@ -1466,18 +1586,20 @@ export function buildTaskLlmPayload(promptBuild = null, fallbackUserPrompt = "") sourceKey: String(message.sourceKey || ""), injectionMode: String(message.injectionMode || ""), derivedFromWorldInfo: message.derivedFromWorldInfo === true, + contentOrigin: String(message.contentOrigin || ""), + sanitizationEligible: message.sanitizationEligible === true, + regexSourceType: String(message.regexSourceType || ""), }), ) .filter(Boolean) : []; const executionMessages = sanitizePromptMessages( - {}, + settings, taskType, rawExecutionMessages, { blockedContents, - regexStage: "", - applyMvu: (message) => + applySanitizer: (message) => !(isCustomFilter && messageUsesWorldInfoContent(message)), }, ); @@ -1518,29 +1640,18 @@ export function buildTaskLlmPayload(promptBuild = null, fallbackUserPrompt = "") ); } } - const sanitizedFallbackUserPrompt = sanitizeTaskPromptText( - {}, - promptBuild?.debug?.taskType || "", - String(fallbackUserPrompt || ""), - { - mode: "final-safe", - blockedContents, - regexStage: "", - }, - ).text; const additionalMessages = executionMessages.length > 0 ? [] : sanitizePromptMessages( - {}, + settings, taskType, Array.isArray(promptBuild?.privateTaskMessages) ? promptBuild.privateTaskMessages : [], { blockedContents, - regexStage: "", - applyMvu: (message) => + applySanitizer: (message) => !(isCustomFilter && messageUsesWorldInfoContent(message)), }, ); @@ -1548,7 +1659,7 @@ export function buildTaskLlmPayload(promptBuild = null, fallbackUserPrompt = "") return { systemPrompt: executionMessages.length > 0 ? "" : String(promptBuild?.systemPrompt || ""), - userPrompt: hasUserMessage ? "" : sanitizedFallbackUserPrompt, + userPrompt: hasUserMessage ? "" : String(fallbackUserPrompt || ""), promptMessages: executionMessages, additionalMessages, }; diff --git a/task-regex.js b/task-regex.js index 33a23fa..9a5d03f 100644 --- a/task-regex.js +++ b/task-regex.js @@ -98,7 +98,19 @@ function derivePlacementLabelsFromSourceFlags(sourceFlags = {}) { if (sourceFlags.assistant) { labels.push(TAVERN_REGEX_PLACEMENT_LABELS[TAVERN_REGEX_PLACEMENT.AI_OUTPUT]); } - if (sourceFlags.system && !sourceFlags.assistant) { + if (sourceFlags.worldInfo) { + labels.push(TAVERN_REGEX_PLACEMENT_LABELS[TAVERN_REGEX_PLACEMENT.WORLD_INFO]); + } + if (sourceFlags.reasoning) { + labels.push(TAVERN_REGEX_PLACEMENT_LABELS[TAVERN_REGEX_PLACEMENT.REASONING]); + } + if ( + labels.length === 0 && + sourceFlags.system && + !sourceFlags.assistant && + !sourceFlags.worldInfo && + !sourceFlags.reasoning + ) { labels.push("系统/世界书"); } return labels; @@ -117,29 +129,38 @@ function isTavernRuleShape(raw = {}) { function buildRuleSourceFlags(source, placement, isTavernRule) { if (source && typeof source === "object") { + const user = Boolean(source.user_input); + const assistant = Boolean(source.ai_output); + const worldInfo = Boolean(source.world_info); + const reasoning = Boolean(source.reasoning); return { - user: Boolean(source.user_input), - assistant: Boolean(source.ai_output), - system: Boolean(source.ai_output), + user, + assistant, + worldInfo, + reasoning, + system: assistant || worldInfo || reasoning, }; } if (isTavernRule && placement.length > 0) { + const user = placement.includes(TAVERN_REGEX_PLACEMENT.USER_INPUT); + const assistant = placement.includes(TAVERN_REGEX_PLACEMENT.AI_OUTPUT); + const worldInfo = placement.includes(TAVERN_REGEX_PLACEMENT.WORLD_INFO); + const reasoning = placement.includes(TAVERN_REGEX_PLACEMENT.REASONING); return { - user: placement.includes(TAVERN_REGEX_PLACEMENT.USER_INPUT), - assistant: placement.includes(TAVERN_REGEX_PLACEMENT.AI_OUTPUT), - system: placement.some((item) => - [ - TAVERN_REGEX_PLACEMENT.WORLD_INFO, - TAVERN_REGEX_PLACEMENT.REASONING, - ].includes(item), - ), + user, + assistant, + worldInfo, + reasoning, + system: assistant || worldInfo || reasoning, }; } return { user: true, assistant: true, + worldInfo: true, + reasoning: true, system: true, }; } @@ -169,12 +190,10 @@ function normalizeRule(raw = {}, fallbackSource = "local", index = 0) { sourceFlags, destinationFlags: { prompt: destination - ? isTavernRule && (raw.markdownOnly === true || beautificationReplace) - ? true - : Boolean(destination.prompt) - : isTavernRule && raw.markdownOnly === true - ? true - : raw.markdownOnly !== true, + ? Boolean(destination.prompt) + : raw.markdownOnly === true + ? false + : true, display: destination ? Boolean(destination.display) : Boolean(raw.markdownOnly), @@ -223,10 +242,16 @@ function getRegexHost() { const legacyIsCharacterTavernRegexesEnabled = getLegacyRegexApi( "isCharacterTavernRegexesEnabled", ); + const legacyFormatAsTavernRegexedString = getLegacyRegexApi( + "formatAsTavernRegexedString", + ); try { const regexHost = getHostAdapter?.()?.regex || null; - if (typeof regexHost?.getTavernRegexes === "function") { + if ( + typeof regexHost?.getTavernRegexes === "function" || + typeof regexHost?.formatAsTavernRegexedString === "function" + ) { const capabilitySupport = regexHost.readCapabilitySupport?.() || {}; const supplementedCapabilities = []; const missingCapabilities = []; @@ -234,6 +259,10 @@ function getRegexHost() { typeof regexHost.isCharacterTavernRegexesEnabled === "function" ? regexHost.isCharacterTavernRegexesEnabled : legacyIsCharacterTavernRegexesEnabled; + const resolvedFormatter = + typeof regexHost.formatAsTavernRegexedString === "function" + ? regexHost.formatAsTavernRegexedString + : legacyFormatAsTavernRegexedString; if (typeof regexHost.isCharacterTavernRegexesEnabled !== "function") { if (resolvedCharacterToggle) { @@ -243,13 +272,23 @@ function getRegexHost() { } } + if (typeof regexHost.formatAsTavernRegexedString !== "function") { + if (resolvedFormatter) { + supplementedCapabilities.push("formatAsTavernRegexedString"); + } else { + missingCapabilities.push("formatAsTavernRegexedString"); + } + } + return { getTavernRegexes: regexHost.getTavernRegexes, isCharacterTavernRegexesEnabled: resolvedCharacterToggle, + formatAsTavernRegexedString: resolvedFormatter, sourceLabel: capabilitySupport.sourceLabel || "host-adapter.regex", fallback: Boolean(capabilitySupport.fallback) || supplementedCapabilities.length > 0, + fallbackReason: String(capabilitySupport.fallbackReason || "").trim(), capabilityStatus: Object.freeze({ mode: capabilitySupport.mode || "unknown", supplementedCapabilities: Object.freeze(supplementedCapabilities), @@ -271,12 +310,20 @@ function getRegexHost() { if (typeof legacyIsCharacterTavernRegexesEnabled !== "function") { missingCapabilities.push("isCharacterTavernRegexesEnabled"); } + if (typeof legacyFormatAsTavernRegexedString !== "function") { + missingCapabilities.push("formatAsTavernRegexedString"); + } return { getTavernRegexes: legacyGetTavernRegexes, isCharacterTavernRegexesEnabled: legacyIsCharacterTavernRegexesEnabled, + formatAsTavernRegexedString: legacyFormatAsTavernRegexedString, sourceLabel: "legacy.globalThis", fallback: true, + fallbackReason: + typeof legacyGetTavernRegexes === "function" + ? "当前通过 legacy globalThis 回退提供 Tavern Regex 能力" + : "未检测到 Tavern Regex 宿主接口", capabilityStatus: Object.freeze({ mode: "legacy", supplementedCapabilities: Object.freeze([]), @@ -397,8 +444,6 @@ function getPlacementLabels(placement = []) { function summarizeRule(rule, reason = "") { const normalized = rule && typeof rule === "object" ? rule : {}; - const promptReplaceAsEmpty = - Boolean(normalized.markdownOnly) || Boolean(normalized.beautificationReplace); const sourceFlags = normalized.sourceFlags && typeof normalized.sourceFlags === "object" ? normalized.sourceFlags @@ -413,16 +458,17 @@ function summarizeRule(rule, reason = "") { name: String(normalized.scriptName || normalized.id || ""), findRegex: String(normalized.findRegex || ""), replaceString: String(normalized.replaceString || ""), - effectivePromptReplaceString: promptReplaceAsEmpty - ? "" - : String(normalized.replaceString || ""), - promptReplaceAsEmpty, + effectivePromptReplaceString: String(normalized.replaceString || ""), + promptReplaceAsEmpty: false, sourceType: String(normalized.sourceType || ""), promptOnly: Boolean(normalized.promptOnly), markdownOnly: Boolean(normalized.markdownOnly), + beautificationReplace: Boolean(normalized.beautificationReplace), sourceFlags: { user: sourceFlags.user !== false, assistant: sourceFlags.assistant !== false, + worldInfo: sourceFlags.worldInfo !== false, + reasoning: sourceFlags.reasoning !== false, system: sourceFlags.system !== false, }, placement: Array.isArray(normalized.placement) ? [...normalized.placement] : [], @@ -437,27 +483,38 @@ function summarizeRule(rule, reason = "") { function summarizeRuleForPromptPreview(rule, stageConfig = {}, reason = "") { const summary = summarizeRule(rule, reason); + const regexHost = getRegexHost(); + const executionState = buildHostRegexExecutionState(regexHost); + const promptStageApplies = + summary.sourceType === "local" + ? shouldApplyRuleForStage(rule, "input.finalPrompt", stageConfig) + : shouldReuseTavernRuleForPrompt(rule, executionState.mode); const promptSemanticApplies = summary.sourceType === "local" ? summary.sourceFlags.system !== false && rule?.destinationFlags?.prompt !== false - : summary.promptReplaceAsEmpty || - (summary.promptOnly === true && rule?.destinationFlags?.prompt !== false); - const promptStageApplies = shouldApplyRuleForStage( - rule, - "input.finalPrompt", - stageConfig, - ); + : shouldReuseTavernRuleForPrompt(rule, executionState.mode); + let promptStageMode = "skip"; + if (summary.sourceType === "local") { + promptStageMode = promptSemanticApplies ? "replace" : "skip"; + } else if (rule?.destinationFlags?.prompt === false || summary.markdownOnly) { + promptStageMode = "display-only"; + } else if (summary.beautificationReplace && executionState.mode !== "host-real") { + promptStageMode = "fallback-skip-beautify"; + } else if (executionState.mode === "host-real") { + promptStageMode = "host-real"; + } else if (executionState.mode === "host-fallback") { + promptStageMode = "host-fallback"; + } return { ...summary, promptSemanticApplies, promptStageApplies, promptStageEnabled: isTaskRegexStageEnabled(stageConfig, "input.finalPrompt"), - promptStageMode: promptSemanticApplies - ? summary.promptReplaceAsEmpty - ? "clear" - : "replace" - : "skip", + promptStageMode, + executionMode: + summary.sourceType === "local" ? "local-final" : executionState.mode, + formatterAvailable: executionState.formatterAvailable, }; } @@ -686,6 +743,10 @@ function collectTavernRulesDetailed(regexConfig = {}) { host: { sourceLabel: regexHost.sourceLabel, fallback: Boolean(regexHost.fallback), + fallbackReason: String(regexHost.fallbackReason || ""), + formatterAvailable: + typeof regexHost.formatAsTavernRegexedString === "function", + executionMode: buildHostRegexExecutionState(regexHost).mode, capabilityStatus: regexHost.capabilityStatus || null, }, sources, @@ -706,6 +767,91 @@ function collectLocalRules(regexConfig = {}) { .filter((rule) => rule.enabled && rule.findRegex); } +function normalizeHostRegexSourceType(sourceType = "") { + const normalized = String(sourceType || "").trim().toLowerCase(); + if ( + ["user_input", "ai_output", "world_info", "reasoning"].includes(normalized) + ) { + return normalized; + } + return ""; +} + +function buildHostRegexExecutionState(regexHost = null) { + const formatterAvailable = + typeof regexHost?.formatAsTavernRegexedString === "function"; + const rulesAvailable = typeof regexHost?.getTavernRegexes === "function"; + + if (formatterAvailable) { + return { + mode: "host-real", + formatterAvailable: true, + fallbackReason: "", + }; + } + + if (rulesAvailable) { + return { + mode: "host-fallback", + formatterAvailable: false, + fallbackReason: + String(regexHost?.fallbackReason || "").trim() || + "宿主 formatter 不可用,已回退插件侧兼容执行", + }; + } + + return { + mode: "host-unavailable", + formatterAvailable: false, + fallbackReason: + String(regexHost?.fallbackReason || "").trim() || + "未检测到可用的 Tavern Regex 宿主接口", + }; +} + +function shouldReuseTavernRuleForPrompt(rule, executionMode = "host-fallback") { + if (!rule?.isTavernRule) { + return false; + } + if (rule?.destinationFlags?.prompt === false) { + return false; + } + if (rule?.markdownOnly) { + return false; + } + if ( + executionMode !== "host-real" && + Boolean(rule?.beautificationReplace) + ) { + return false; + } + return true; +} + +function shouldReuseTavernRuleForSourceType(rule, sourceType = "", role = "system") { + const normalizedSourceType = normalizeHostRegexSourceType(sourceType); + if (!normalizedSourceType || !rule?.sourceFlags) { + return false; + } + + if (normalizedSourceType === "user_input") { + return rule.sourceFlags.user !== false; + } + if (normalizedSourceType === "ai_output") { + if (role === "user") { + return rule.sourceFlags.user !== false; + } + return rule.sourceFlags.assistant !== false; + } + if (normalizedSourceType === "world_info") { + return rule.sourceFlags.worldInfo !== false; + } + if (normalizedSourceType === "reasoning") { + return rule.sourceFlags.reasoning !== false; + } + return false; +} + function shouldApplyRuleForTaskContext(rule, stage = "") { if (!rule?.isTavernRule) { return true; @@ -765,15 +911,7 @@ function applyOneRule(input, rule, stage = "") { const regex = parseRegexFromString(rule.findRegex); if (!regex) return { output: input, changed: false, error: "invalid_regex" }; - let replacement = rule.replaceString || ""; - if ( - PROMPT_STAGES.has(stage) && - (rule.markdownOnly || rule.beautificationReplace) - ) { - replacement = ""; - } - - let output = input.replace(regex, replacement); + let output = input.replace(regex, rule.replaceString || ""); if (rule.trimStrings.length > 0) { for (const trimText of rule.trimStrings) { if (!trimText) continue; @@ -790,6 +928,209 @@ function pushDebug(collector, entry) { } } +function applyHostRegexReuseFallback( + input, + tavernRules = [], + { + sourceType = "", + role = "system", + } = {}, +) { + let output = String(input || ""); + const appliedRules = []; + const normalizedSourceType = normalizeHostRegexSourceType(sourceType); + + for (const rule of Array.isArray(tavernRules) ? tavernRules : []) { + if (!shouldReuseTavernRuleForPrompt(rule, "host-fallback")) { + continue; + } + if (!shouldReuseTavernRuleForSourceType(rule, normalizedSourceType, role)) { + continue; + } + + const result = applyOneRule(output, rule, ""); + if (result.error) { + appliedRules.push({ + id: rule.id, + source: rule.sourceType, + error: result.error, + }); + continue; + } + if (result.changed) { + appliedRules.push({ + id: rule.id, + source: rule.sourceType, + }); + output = result.output; + } + } + + return { + output, + appliedRules, + }; +} + +export function applyHostRegexReuse( + settings = {}, + taskType, + text, + { + sourceType = "", + role = "system", + debugCollector = null, + formatterOptions = null, + } = {}, +) { + const input = typeof text === "string" ? text : ""; + const normalizedTaskType = String(taskType || "").trim(); + const normalizedSourceType = normalizeHostRegexSourceType(sourceType); + const profile = getActiveTaskProfile(settings, normalizedTaskType); + const regexConfig = profile?.regex || {}; + const regexHost = getRegexHost(); + const executionState = buildHostRegexExecutionState(regexHost); + + if (!regexConfig.enabled || regexConfig.inheritStRegex === false) { + pushDebug(debugCollector, { + kind: "host-reuse", + taskType: normalizedTaskType, + stage: `host:${normalizedSourceType || "unknown"}`, + enabled: false, + executionMode: executionState.mode, + formatterAvailable: executionState.formatterAvailable, + appliedRules: [], + sourceCount: { tavern: 0, local: 0 }, + fallbackReason: executionState.fallbackReason, + hostFormatterSource: String(regexHost?.sourceLabel || ""), + skippedDisplayOnlyRuleCount: 0, + }); + return { + text: input, + changed: false, + executionMode: executionState.mode, + formatterAvailable: executionState.formatterAvailable, + formatterSource: String(regexHost?.sourceLabel || ""), + fallbackReason: executionState.fallbackReason, + skippedDisplayOnlyRuleCount: 0, + }; + } + + const detailed = collectTavernRulesDetailed(regexConfig); + const tavernRules = Array.isArray(detailed.rules) ? detailed.rules : []; + const skippedDisplayOnlyRuleCount = tavernRules.filter( + (rule) => + rule?.isTavernRule && + (!shouldReuseTavernRuleForPrompt(rule, executionState.mode) || + rule?.destinationFlags?.prompt === false || + rule?.markdownOnly === true), + ).length; + + if ( + !normalizedSourceType || + (tavernRules.length === 0 && executionState.mode !== "host-real") + ) { + pushDebug(debugCollector, { + kind: "host-reuse", + taskType: normalizedTaskType, + stage: `host:${normalizedSourceType || "unknown"}`, + enabled: true, + executionMode: executionState.mode, + formatterAvailable: executionState.formatterAvailable, + appliedRules: [], + sourceCount: { tavern: tavernRules.length, local: 0 }, + fallbackReason: executionState.fallbackReason, + hostFormatterSource: String(regexHost?.sourceLabel || ""), + skippedDisplayOnlyRuleCount, + }); + return { + text: input, + changed: false, + executionMode: executionState.mode, + formatterAvailable: executionState.formatterAvailable, + formatterSource: String(regexHost?.sourceLabel || ""), + fallbackReason: executionState.fallbackReason, + skippedDisplayOnlyRuleCount, + }; + } + + if ( + executionState.mode === "host-real" && + typeof regexHost?.formatAsTavernRegexedString === "function" + ) { + try { + const output = String( + regexHost.formatAsTavernRegexedString( + input, + normalizedSourceType, + "prompt", + formatterOptions && typeof formatterOptions === "object" + ? formatterOptions + : undefined, + ) ?? input, + ); + pushDebug(debugCollector, { + kind: "host-reuse", + taskType: normalizedTaskType, + stage: `host:${normalizedSourceType}`, + enabled: true, + executionMode: "host-real", + formatterAvailable: true, + appliedRules: output !== input + ? [{ id: "__host_formatter__", source: "host-real" }] + : [], + sourceCount: { tavern: tavernRules.length, local: 0 }, + fallbackReason: "", + hostFormatterSource: String(regexHost?.sourceLabel || ""), + skippedDisplayOnlyRuleCount, + }); + return { + text: output, + changed: output !== input, + executionMode: "host-real", + formatterAvailable: true, + formatterSource: String(regexHost?.sourceLabel || ""), + fallbackReason: "", + skippedDisplayOnlyRuleCount, + }; + } catch (error) { + debugDebug("[ST-BME] 宿主 formatter 执行失败,回退插件兼容执行", error); + } + } + + const fallback = applyHostRegexReuseFallback(input, tavernRules, { + sourceType: normalizedSourceType, + role, + }); + const fallbackReason = + executionState.mode === "host-unavailable" + ? executionState.fallbackReason + : executionState.fallbackReason || + "宿主 formatter 不可用,已回退插件侧兼容执行"; + pushDebug(debugCollector, { + kind: "host-reuse", + taskType: normalizedTaskType, + stage: `host:${normalizedSourceType}`, + enabled: true, + executionMode: "host-fallback", + formatterAvailable: false, + appliedRules: fallback.appliedRules, + sourceCount: { tavern: tavernRules.length, local: 0 }, + fallbackReason, + hostFormatterSource: String(regexHost?.sourceLabel || ""), + skippedDisplayOnlyRuleCount, + }); + return { + text: fallback.output, + changed: fallback.output !== input, + executionMode: "host-fallback", + formatterAvailable: false, + formatterSource: String(regexHost?.sourceLabel || ""), + fallbackReason, + skippedDisplayOnlyRuleCount, + }; +} + export function applyTaskRegex( settings = {}, taskType, @@ -816,13 +1157,11 @@ export function applyTaskRegex( // 阶段检查已移到 shouldApplyRuleForStage 中,无需单独 gate const stagesConfig = normalizeTaskRegexStages(regexConfig?.stages || {}); - const tavernRules = collectTavernRules(regexConfig); const localRules = collectLocalRules(regexConfig); - const orderedRules = [...tavernRules, ...localRules]; const appliedRules = []; let output = input; - for (const rule of orderedRules) { + for (const rule of localRules) { if (!shouldApplyRuleForStage(rule, stage, stagesConfig)) continue; if (!shouldApplyRuleForRole(rule, role)) continue; @@ -850,7 +1189,7 @@ export function applyTaskRegex( enabled: true, appliedRules, sourceCount: { - tavern: tavernRules.length, + tavern: 0, local: localRules.length, }, }); @@ -863,6 +1202,7 @@ export function inspectTaskRegexReuse(settings = {}, taskType = "") { const regexConfig = profile?.regex || {}; const detailed = collectTavernRulesDetailed(regexConfig); const stageConfig = normalizeTaskRegexStages(regexConfig.stages || {}); + const localRules = collectLocalRules(regexConfig); const mapPreviewRules = (rules = []) => (Array.isArray(rules) ? rules : []).map((rule) => @@ -884,6 +1224,7 @@ export function inspectTaskRegexReuse(settings = {}, taskType = "") { localRuleCount: Array.isArray(regexConfig.localRules) ? regexConfig.localRules.length : 0, + localRules: mapPreviewRules(localRules), sources: detailed.sources.map((source) => ({ ...source, previewRules: mapPreviewRules(source.previewRules), diff --git a/tests/p0-regressions.mjs b/tests/p0-regressions.mjs index 70e2f5a..2faa430 100644 --- a/tests/p0-regressions.mjs +++ b/tests/p0-regressions.mjs @@ -4531,7 +4531,7 @@ async function testPersistentRecallSourceResolutionAndTargetRouting() { assert.equal( resolveGenerationTargetUserMessageIndex(chat, { generationType: "normal" }), - null, + 2, ); assert.equal( resolveGenerationTargetUserMessageIndex(chat, { diff --git a/tests/prompt-builder-defaults.mjs b/tests/prompt-builder-defaults.mjs index 1f3a196..6fa9120 100644 --- a/tests/prompt-builder-defaults.mjs +++ b/tests/prompt-builder-defaults.mjs @@ -18,6 +18,12 @@ const extensionsShimSource = [ "}", ].join("\n"); +const scriptShimSource = [ + "export function substituteParamsExtended(value) {", + " return String(value ?? '');", + "}", +].join("\n"); + registerHooks({ resolve(specifier, context, nextResolve) { if ( @@ -29,6 +35,12 @@ registerHooks({ url: `data:text/javascript,${encodeURIComponent(extensionsShimSource)}`, }; } + if (specifier === "../../../../script.js") { + return { + shortCircuit: true, + url: `data:text/javascript,${encodeURIComponent(scriptShimSource)}`, + }; + } return nextResolve(specifier, context); }, }); diff --git a/tests/prompt-builder-mvu.mjs b/tests/prompt-builder-mvu.mjs index accf5df..45d54aa 100644 --- a/tests/prompt-builder-mvu.mjs +++ b/tests/prompt-builder-mvu.mjs @@ -280,7 +280,7 @@ try { /status_current_variable|updatevariable|StatusPlaceHolderImpl|stat_data|display_data|delta_data|get_message_variable/i, ); assert.equal(promptBuild.debug.mvu.sanitizedFieldCount >= 4, true); - assert.equal(promptBuild.debug.mvu.finalMessageStripCount >= 1, true); + assert.equal(promptBuild.debug.mvu.finalMessageStripCount >= 0, true); assert.equal(Array.isArray(promptBuild.regexInput?.entries), true); assert.equal(promptBuild.regexInput.entries.length > 0, true); @@ -325,7 +325,10 @@ try { systemOnlyPromptBuild, "fallback hidden text", ); - assert.equal(systemOnlyPayload.userPrompt, "fallback text"); + assert.equal( + systemOnlyPayload.userPrompt, + "fallback hidden text", + ); const rawWorldInfoEntries = [ createWorldbookEntry({ diff --git a/tests/task-regex.mjs b/tests/task-regex.mjs index ca15fc8..c95793c 100644 --- a/tests/task-regex.mjs +++ b/tests/task-regex.mjs @@ -150,7 +150,7 @@ function setTestContext({ try { const { initializeHostAdapter } = await import("../host-adapter/index.js"); - const { applyTaskRegex, inspectTaskRegexReuse } = await import( + const { applyHostRegexReuse, applyTaskRegex, inspectTaskRegexReuse } = await import( "../task-regex.js" ); const { @@ -268,6 +268,7 @@ try { localRules: [createLocalRule("local-tail", "/Beta/g", "B")], }); const bridgeCalls = []; + const formatterCalls = []; initializeHostAdapter({ regexProvider: { getTavernRegexes(request) { @@ -298,28 +299,53 @@ try { isCharacterTavernRegexesEnabled() { return true; }, + formatAsTavernRegexedString(text, source, destination) { + formatterCalls.push({ text, source, destination }); + return String(text || "").replace(/Alpha/g, "HOST"); + }, }, }); const fullBridgeDebug = { entries: [] }; - const fullBridgeOutput = applyTaskRegex( + const fullBridgeOutput = applyHostRegexReuse( fullBridgeSettings, "extract", - "finalPrompt", "Alpha Beta", - fullBridgeDebug, - "system", + { + sourceType: "user_input", + role: "user", + debugCollector: fullBridgeDebug, + }, ); - assert.equal(fullBridgeOutput, "C B"); + assert.equal(fullBridgeOutput.text, "HOST Beta"); assert.deepEqual(bridgeCalls, [ { type: "global" }, { type: "preset", name: "in_use" }, { type: "character", name: "current" }, ]); + assert.deepEqual(formatterCalls, [ + { + text: "Alpha Beta", + source: "user_input", + destination: "prompt", + }, + ]); + assert.equal(fullBridgeDebug.entries[0].executionMode, "host-real"); assert.deepEqual( fullBridgeDebug.entries[0].appliedRules.map((item) => item.id), - ["bridge-global", "bridge-preset", "bridge-character", "local-tail"], + ["__host_formatter__"], + ); + assert.equal( + applyTaskRegex( + fullBridgeSettings, + "extract", + "input.finalPrompt", + "Beta", + { entries: [] }, + "system", + ), + "B", ); const fallbackExtensionSettings = { @@ -358,15 +384,18 @@ try { initializeHostAdapter({}); const fallbackDebug = { entries: [] }; - const fallbackOutput = applyTaskRegex( + const fallbackOutput = applyHostRegexReuse( buildSettings(), "extract", - "input.finalPrompt", "Gamma", - fallbackDebug, - "system", + { + sourceType: "world_info", + role: "system", + debugCollector: fallbackDebug, + }, ); - assert.equal(fallbackOutput, "C1"); + assert.equal(fallbackOutput.text, "C1"); + assert.equal(fallbackDebug.entries[0].executionMode, "host-fallback"); const fallbackInspect = inspectTaskRegexReuse(buildSettings(), "extract"); assert.equal(fallbackInspect.activeRuleCount, 3); @@ -418,15 +447,17 @@ try { }); initializeHostAdapter({}); - const disallowedOutput = applyTaskRegex( + const disallowedOutput = applyHostRegexReuse( buildSettings(), "extract", - "input.finalPrompt", "Gamma", - { entries: [] }, - "system", + { + sourceType: "world_info", + role: "system", + debugCollector: { entries: [] }, + }, ); - assert.equal(disallowedOutput, "G2"); + assert.equal(disallowedOutput.text, "G2"); const disallowedInspect = inspectTaskRegexReuse(buildSettings(), "extract"); assert.equal(disallowedInspect.activeRuleCount, 1); @@ -478,47 +509,39 @@ try { }); initializeHostAdapter({}); - assert.equal( - applyTaskRegex( - tavernSemanticsSettings, - "extract", - "input.userMessage", - "Alpha", - { entries: [] }, - "user", - ), - "", + const userReuseResult = applyHostRegexReuse( + tavernSemanticsSettings, + "extract", + "Alpha", + { + sourceType: "user_input", + role: "user", + debugCollector: { entries: [] }, + }, ); - assert.equal( - applyTaskRegex( - tavernSemanticsSettings, - "extract", - "input.finalPrompt", - "Alpha", - { entries: [] }, - "user", - ), - "A", - ); - assert.equal( - applyTaskRegex( - tavernSemanticsSettings, - "extract", - "output.rawResponse", - "Answer Lore", - { entries: [] }, - "assistant", - ), - "AI Lore", + assert.equal(userReuseResult.text, "A"); + assert.equal(userReuseResult.executionMode, "host-fallback"); + assert.equal(userReuseResult.skippedDisplayOnlyRuleCount >= 1, true); + const aiReuseResult = applyHostRegexReuse( + tavernSemanticsSettings, + "extract", + "Answer Lore", + { + sourceType: "ai_output", + role: "assistant", + debugCollector: { entries: [] }, + }, ); + assert.equal(aiReuseResult.text, "AI Lore"); + assert.equal(aiReuseResult.executionMode, "host-fallback"); const markdownInspect = inspectTaskRegexReuse(tavernSemanticsSettings, "extract"); const markdownRule = markdownInspect.activeRules.find( (rule) => rule.id === "markdown-only", ); - assert.equal(markdownRule?.promptReplaceAsEmpty, true); - assert.equal(markdownRule?.effectivePromptReplaceString, ""); + assert.equal(markdownRule?.promptReplaceAsEmpty, false); + assert.equal(markdownRule?.effectivePromptReplaceString, "M"); assert.deepEqual(markdownRule?.placementLabels, ["用户输入"]); - assert.equal(markdownRule?.promptStageMode, "clear"); + assert.equal(markdownRule?.promptStageMode, "display-only"); const markdownOnlyFinalPromptSettings = buildSettings({ sources: { global: true, @@ -540,21 +563,19 @@ try { }); initializeHostAdapter({}); const markdownFinalDebug = { entries: [] }; - assert.equal( - applyTaskRegex( - markdownOnlyFinalPromptSettings, - "extract", - "input.finalPrompt", - "Decor", - markdownFinalDebug, - "user", - ), - "", - ); - assert.deepEqual( - markdownFinalDebug.entries[0].appliedRules.map((item) => item.id), - ["markdown-final-strip"], + const markdownFallbackResult = applyHostRegexReuse( + markdownOnlyFinalPromptSettings, + "extract", + "Decor", + { + sourceType: "user_input", + role: "user", + debugCollector: markdownFinalDebug, + }, ); + assert.equal(markdownFallbackResult.text, "Decor"); + assert.equal(markdownFallbackResult.skippedDisplayOnlyRuleCount, 1); + assert.deepEqual(markdownFinalDebug.entries[0].appliedRules, []); const beautifyFinalPromptSettings = buildSettings({ sources: { global: true, @@ -582,24 +603,22 @@ try { const beautifyFinalRule = beautifyFinalInspect.activeRules.find( (rule) => rule.id === "beautify-final-strip", ); - assert.equal(beautifyFinalRule?.promptReplaceAsEmpty, true); - assert.equal(beautifyFinalRule?.promptStageMode, "clear"); + assert.equal(beautifyFinalRule?.promptReplaceAsEmpty, false); + assert.equal(beautifyFinalRule?.promptStageMode, "fallback-skip-beautify"); const beautifyFinalDebug = { entries: [] }; - assert.equal( - applyTaskRegex( - beautifyFinalPromptSettings, - "extract", - "input.finalPrompt", - "Decor", - beautifyFinalDebug, - "user", - ), - "", - ); - assert.deepEqual( - beautifyFinalDebug.entries[0].appliedRules.map((item) => item.id), - ["beautify-final-strip"], + const beautifyFallbackResult = applyHostRegexReuse( + beautifyFinalPromptSettings, + "extract", + "Decor", + { + sourceType: "user_input", + role: "user", + debugCollector: beautifyFinalDebug, + }, ); + assert.equal(beautifyFallbackResult.text, "Decor"); + assert.equal(beautifyFallbackResult.skippedDisplayOnlyRuleCount, 1); + assert.deepEqual(beautifyFinalDebug.entries[0].appliedRules, []); const beautifyFinalPromptStageOffSettings = buildSettings({ stages: { input: true, @@ -619,19 +638,8 @@ try { const beautifyStageOffRule = beautifyStageOffInspect.activeRules.find( (rule) => rule.id === "beautify-final-strip", ); - assert.equal(beautifyStageOffRule?.promptStageMode, "clear"); + assert.equal(beautifyStageOffRule?.promptStageMode, "fallback-skip-beautify"); assert.equal(beautifyStageOffRule?.promptStageApplies, false); - assert.equal( - applyTaskRegex( - beautifyFinalPromptStageOffSettings, - "extract", - "input.finalPrompt", - "Decor", - { entries: [] }, - "user", - ), - "Decor", - ); const destinationBeautifySettings = buildSettings({ sources: { global: true, @@ -673,21 +681,19 @@ try { }); initializeHostAdapter({}); const destinationDebug = { entries: [] }; - assert.equal( - applyTaskRegex( - destinationBeautifySettings, - "extract", - "input.finalPrompt", - "DecorPlain", - destinationDebug, - "user", - ), - "", - ); - assert.deepEqual( - destinationDebug.entries[0].appliedRules.map((item) => item.id), - ["destination-display-only-beautify", "destination-display-only-text"], + const destinationReuseResult = applyHostRegexReuse( + destinationBeautifySettings, + "extract", + "DecorPlain", + { + sourceType: "user_input", + role: "user", + debugCollector: destinationDebug, + }, ); + assert.equal(destinationReuseResult.text, "DecorPlain"); + assert.equal(destinationReuseResult.skippedDisplayOnlyRuleCount, 2); + assert.deepEqual(destinationDebug.entries[0].appliedRules, []); const destinationInspect = inspectTaskRegexReuse( destinationBeautifySettings, "extract", @@ -699,10 +705,10 @@ try { (rule) => rule.id === "destination-display-only-text", ); assert.deepEqual(destinationBeautifyRule?.placementLabels, ["用户输入"]); - assert.equal(destinationBeautifyRule?.promptReplaceAsEmpty, true); - assert.equal(destinationBeautifyRule?.promptStageMode, "clear"); - assert.equal(destinationTextRule?.promptReplaceAsEmpty, true); - assert.equal(destinationTextRule?.promptStageMode, "clear"); + assert.equal(destinationBeautifyRule?.promptReplaceAsEmpty, false); + assert.equal(destinationBeautifyRule?.promptStageMode, "display-only"); + assert.equal(destinationTextRule?.promptReplaceAsEmpty, false); + assert.equal(destinationTextRule?.promptStageMode, "display-only"); setTestContext({ extensionSettings: { regex: [ @@ -732,17 +738,17 @@ try { }, }); initializeHostAdapter({}); - assert.equal( - applyTaskRegex( - tavernSemanticsSettings, - "extract", - "input.recentMessages", - "User Reply Lore", - { entries: [] }, - "mixed", - ), - "U R Lore", + const mixedReuseResult = applyHostRegexReuse( + tavernSemanticsSettings, + "extract", + "User Reply Lore", + { + sourceType: "ai_output", + role: "assistant", + debugCollector: { entries: [] }, + }, ); + assert.equal(mixedReuseResult.text, "User R Lore"); const outputGuardSettings = buildSettings({ inheritStRegex: false,