From 0cb95c4f2b4ef40e8f474286d5166963779c4641 Mon Sep 17 00:00:00 2001
From: Youzini-afk <13153778771cx@gmail.com>
Date: Sat, 11 Apr 2026 18:51:50 +0800
Subject: [PATCH] phase2-4 recall prompt-flow hardening
---
host/adapter/regex.js | 286 ++++++++++++++----
host/event-binding.js | 17 ++
index.js | 263 +++++++++++++++-
llm/llm.js | 107 +++++--
prompting/prompt-builder.js | 125 ++++++--
prompting/task-regex.js | 86 +++++-
retrieval/recall-persistence.js | 4 +
tests/helpers/register-hooks-compat.mjs | 18 +-
tests/p0-regressions.mjs | 116 ++++++-
tests/prompt-builder-mvu.mjs | 59 +++-
.../recall-authoritative-generation-input.mjs | 24 ++
tests/task-regex.mjs | 97 +++++-
ui/panel.js | 7 +-
ui/recall-message-ui.js | 3 +
14 files changed, 1069 insertions(+), 143 deletions(-)
diff --git a/host/adapter/regex.js b/host/adapter/regex.js
index f730791..d11417c 100644
--- a/host/adapter/regex.js
+++ b/host/adapter/regex.js
@@ -1,3 +1,7 @@
+import {
+ getRegexedString as coreGetRegexedString,
+ regex_placement as coreRegexPlacement,
+} from "../../../../regex/engine.js";
import { buildCapabilityStatus, mergeVersionHints } from "./capabilities.js";
import { createContextHostFacade } from "./context.js";
import { debugDebug } from "../../runtime/debug-logging.js";
@@ -7,6 +11,28 @@ const REGEX_API_NAMES = [
"isCharacterTavernRegexesEnabled",
"formatAsTavernRegexedString",
];
+const CORE_REGEX_SOURCE_TO_PLACEMENT_KEY = Object.freeze({
+ user_input: "USER_INPUT",
+ ai_output: "AI_OUTPUT",
+ slash_command: "SLASH_COMMAND",
+ world_info: "WORLD_INFO",
+ reasoning: "REASONING",
+});
+const REGEX_SOURCE_KIND_PRIORITY = Object.freeze({
+ unknown: 0,
+ unavailable: 0,
+ "global-fallback": 1,
+ context: 2,
+ "core-bridge": 3,
+ "api-map": 4,
+ provider: 5,
+});
+const REGEX_BRIDGE_TIER_PRIORITY = Object.freeze({
+ unavailable: 0,
+ "helper-getter-only": 1,
+ "helper-bridge": 2,
+ "core-real": 3,
+});
function isObjectLike(value) {
return (
@@ -19,11 +45,90 @@ function bindHostFunction(container, name) {
return typeof fn === "function" ? fn.bind(container) : null;
}
+function resolveCorePlacement(regexPlacement, source) {
+ const normalizedSource = String(source || "").trim().toLowerCase();
+ const placementKey = CORE_REGEX_SOURCE_TO_PLACEMENT_KEY[normalizedSource];
+ if (!placementKey || !isObjectLike(regexPlacement)) {
+ return null;
+ }
+ const placement = regexPlacement?.[placementKey];
+ return Number.isFinite(Number(placement)) ? Number(placement) : null;
+}
+
+function hasCoreRegexApi(container) {
+ return (
+ typeof container?.getRegexedString === "function" &&
+ resolveCorePlacement(container?.regex_placement, "user_input") != null
+ );
+}
+
+function normalizeCoreFormatterOptions(destination, options = {}) {
+ const normalizedDestination =
+ typeof destination === "string" ? String(destination || "").trim() : "";
+ const normalizedOptions =
+ destination &&
+ typeof destination === "object" &&
+ !Array.isArray(destination)
+ ? { ...destination }
+ : options && typeof options === "object" && !Array.isArray(options)
+ ? { ...options }
+ : {};
+
+ if (normalizedDestination === "display" && normalizedOptions.isMarkdown == null) {
+ normalizedOptions.isMarkdown = true;
+ }
+ if (normalizedDestination === "prompt" && normalizedOptions.isPrompt == null) {
+ normalizedOptions.isPrompt = true;
+ }
+ if (
+ normalizedOptions.character_name != null &&
+ normalizedOptions.characterOverride == null
+ ) {
+ normalizedOptions.characterOverride = normalizedOptions.character_name;
+ }
+ delete normalizedOptions.character_name;
+ return normalizedOptions;
+}
+
+function createCoreFormatterBridge(container) {
+ if (!hasCoreRegexApi(container)) {
+ return null;
+ }
+ const getRegexedString = bindHostFunction(container, "getRegexedString");
+ const regexPlacement = container?.regex_placement;
+ if (typeof getRegexedString !== "function") {
+ return null;
+ }
+
+ return function formatAsTavernRegexedString(
+ text,
+ source,
+ destination,
+ options = {}
+ ) {
+ const placement = resolveCorePlacement(regexPlacement, source);
+ if (placement == null) {
+ return String(text ?? "");
+ }
+ return getRegexedString(
+ String(text ?? ""),
+ placement,
+ normalizeCoreFormatterOptions(destination, options)
+ );
+ };
+}
+
function buildApiMap(container = null) {
- return REGEX_API_NAMES.reduce((result, name) => {
+ const apiMap = REGEX_API_NAMES.reduce((result, name) => {
result[name] = bindHostFunction(container, name);
return result;
}, {});
+
+ if (typeof apiMap.formatAsTavernRegexedString !== "function") {
+ apiMap.formatAsTavernRegexedString = createCoreFormatterBridge(container);
+ }
+
+ return apiMap;
}
function countResolvedApis(apiMap = {}) {
@@ -31,6 +136,23 @@ function countResolvedApis(apiMap = {}) {
.length;
}
+function detectBridgeTier({ hasCoreApi = false, apiMap = {} } = {}) {
+ const hasGetter = typeof apiMap.getTavernRegexes === "function";
+ const hasFormatter =
+ typeof apiMap.formatAsTavernRegexedString === "function";
+
+ if (hasCoreApi && hasFormatter) {
+ return "core-real";
+ }
+ if (hasFormatter) {
+ return "helper-bridge";
+ }
+ if (hasGetter) {
+ return "helper-getter-only";
+ }
+ return "unavailable";
+}
+
function resolveProviderCandidate(candidate, options = {}) {
if (!candidate) {
return null;
@@ -56,6 +178,8 @@ function buildSourceRecord({
fallback = false,
} = {}) {
const apiMap = buildApiMap(container);
+ const hasCoreApi = hasCoreRegexApi(container);
+ const bridgeTier = detectBridgeTier({ hasCoreApi, apiMap });
return Object.freeze({
label,
@@ -63,6 +187,8 @@ function buildSourceRecord({
fallback,
apiMap,
apiCount: countResolvedApis(apiMap),
+ hasCoreApi,
+ bridgeTier,
});
}
@@ -111,6 +237,27 @@ function collectExplicitRegexSourceRecords(options = {}) {
return records;
}
+function collectCoreBridgeSourceRecords(options = {}) {
+ if (options?.disableCoreRegexBridge === true) {
+ return [];
+ }
+ const coreBridge = {
+ getRegexedString: coreGetRegexedString,
+ regex_placement: coreRegexPlacement,
+ };
+ if (!hasCoreRegexApi(coreBridge)) {
+ return [];
+ }
+
+ return [
+ buildSourceRecord({
+ label: "sillytavern.core.regex",
+ sourceKind: "core-bridge",
+ container: coreBridge,
+ }),
+ ];
+}
+
function collectContextRegexSourceRecords(contextHost, options = {}) {
const context = contextHost?.readContextSnapshot?.();
if (!isObjectLike(context)) {
@@ -177,19 +324,31 @@ function collectGlobalFallbackRecords() {
return records;
}
-function resolveRegexSource(options = {}, contextHost = null) {
- const records = [
- ...collectExplicitRegexSourceRecords(options),
- ...collectContextRegexSourceRecords(contextHost, options),
- ...collectGlobalFallbackRecords(),
- ];
+function scoreSourceRecord(record = {}) {
+ const sourceScore =
+ REGEX_SOURCE_KIND_PRIORITY[String(record?.sourceKind || "unknown")] || 0;
+ const tierScore =
+ REGEX_BRIDGE_TIER_PRIORITY[String(record?.bridgeTier || "unavailable")] || 0;
+ if (tierScore <= 0) {
+ return 0;
+ }
+ return sourceScore * 100 + tierScore * 10 + Number(record?.apiCount || 0);
+}
+
+function selectBestRegexSource(records = []) {
+ let bestRecord = null;
+ let bestScore = -1;
+
+ for (const record of Array.isArray(records) ? records : []) {
+ const score = scoreSourceRecord(record);
+ if (!bestRecord || score > bestScore) {
+ bestRecord = record;
+ bestScore = score;
+ }
+ }
return (
- records.find(
- (record) =>
- typeof record.apiMap.getTavernRegexes === "function" ||
- typeof record.apiMap.formatAsTavernRegexedString === "function",
- ) ||
+ bestRecord ||
buildSourceRecord({
label: "none",
sourceKind: "unavailable",
@@ -198,22 +357,19 @@ function resolveRegexSource(options = {}, contextHost = null) {
);
}
-function detectRegexMode(apiMap = {}) {
- const hasGetter = typeof apiMap.getTavernRegexes === "function";
- const hasFormatter =
- typeof apiMap.formatAsTavernRegexedString === "function";
+function resolveRegexSource(options = {}, contextHost = null) {
+ const records = [
+ ...collectExplicitRegexSourceRecords(options),
+ ...collectCoreBridgeSourceRecords(options),
+ ...collectContextRegexSourceRecords(contextHost, options),
+ ...collectGlobalFallbackRecords(),
+ ];
- if (!hasGetter && !hasFormatter) {
- return "unavailable";
- }
+ return selectBestRegexSource(records);
+}
- if (hasGetter && hasFormatter) {
- return typeof apiMap.isCharacterTavernRegexesEnabled === "function"
- ? "full"
- : "partial";
- }
-
- return hasFormatter ? "formatter-only" : "getter-only";
+function detectRegexMode(sourceRecord = {}) {
+ return String(sourceRecord?.bridgeTier || "").trim() || "unavailable";
}
function buildFallbackReason(sourceRecord, available, mode) {
@@ -221,23 +377,15 @@ function buildFallbackReason(sourceRecord, available, mode) {
return "未检测到 Tavern Regex 宿主接口";
}
- if (sourceRecord?.fallback && mode === "partial") {
- return `当前通过 ${sourceRecord.label} fallback 提供部分 Tavern Regex 能力`;
+ if (mode === "core-real") {
+ return "";
}
- if (sourceRecord?.fallback) {
- return `当前通过 ${sourceRecord.label} fallback 提供 Tavern Regex 能力`;
+ if (mode === "helper-bridge") {
+ return `当前通过 ${sourceRecord?.label || "unknown"} helper bridge 提供 Tavern Regex formatter`;
}
- if (mode === "partial") {
- return `Tavern Regex 桥接仅发现部分接口,来源: ${sourceRecord?.label || "unknown"}`;
- }
-
- if (mode === "formatter-only") {
- return `Tavern Regex 桥接仅发现 formatter 接口,来源: ${sourceRecord?.label || "unknown"}`;
- }
-
- if (mode === "getter-only") {
+ if (mode === "helper-getter-only") {
return `Tavern Regex 桥接仅发现规则读取接口,来源: ${sourceRecord?.label || "unknown"}`;
}
@@ -247,31 +395,45 @@ function buildFallbackReason(sourceRecord, available, mode) {
export function createRegexHostFacade(options = {}) {
const contextHost = options.contextHost || createContextHostFacade(options);
const sourceRecord = resolveRegexSource(options, contextHost);
- const mode = detectRegexMode(sourceRecord.apiMap);
+ const mode = detectRegexMode(sourceRecord);
const available = mode !== "unavailable";
+ const formatterAvailable =
+ typeof sourceRecord.apiMap.formatAsTavernRegexedString === "function";
+ const rulesAvailable =
+ typeof sourceRecord.apiMap.getTavernRegexes === "function";
+ const fallbackReason = buildFallbackReason(sourceRecord, available, mode);
+ const versionHints = mergeVersionHints(
+ {
+ apis: REGEX_API_NAMES.filter(
+ (name) => typeof sourceRecord.apiMap[name] === "function",
+ ),
+ apiCount: String(sourceRecord.apiCount),
+ supportsCharacterToggle:
+ typeof sourceRecord.apiMap.isCharacterTavernRegexesEnabled === "function"
+ ? "yes"
+ : "no",
+ source: sourceRecord.sourceKind,
+ sourceLabel: sourceRecord.label,
+ fallback: sourceRecord.fallback ? "yes" : "no",
+ contextMode: contextHost?.mode || "unknown",
+ bridgeTier: sourceRecord.bridgeTier,
+ hasCoreApi: sourceRecord.hasCoreApi ? "yes" : "no",
+ },
+ options.versionHints,
+ );
+ const capabilityStatus = buildCapabilityStatus({
+ available,
+ mode,
+ fallbackReason,
+ versionHints,
+ });
return Object.freeze({
available,
mode,
- fallbackReason: buildFallbackReason(sourceRecord, available, mode),
- versionHints: mergeVersionHints(
- {
- apis: REGEX_API_NAMES.filter(
- (name) => typeof sourceRecord.apiMap[name] === "function",
- ),
- apiCount: String(sourceRecord.apiCount),
- supportsCharacterToggle:
- typeof sourceRecord.apiMap.isCharacterTavernRegexesEnabled ===
- "function"
- ? "yes"
- : "no",
- source: sourceRecord.sourceKind,
- sourceLabel: sourceRecord.label,
- fallback: sourceRecord.fallback ? "yes" : "no",
- contextMode: contextHost?.mode || "unknown",
- },
- options.versionHints,
- ),
+ fallbackReason,
+ versionHints,
+ capabilityStatus,
getTavernRegexes: sourceRecord.apiMap.getTavernRegexes,
isCharacterTavernRegexesEnabled:
sourceRecord.apiMap.isCharacterTavernRegexesEnabled,
@@ -295,8 +457,10 @@ export function createRegexHostFacade(options = {}) {
source: sourceRecord.sourceKind,
sourceLabel: sourceRecord.label,
fallback: sourceRecord.fallback,
- formatterAvailable:
- typeof sourceRecord.apiMap.formatAsTavernRegexedString === "function",
+ formatterAvailable,
+ rulesAvailable,
+ bridgeTier: sourceRecord.bridgeTier,
+ hasCoreApi: sourceRecord.hasCoreApi,
});
},
});
diff --git a/host/event-binding.js b/host/event-binding.js
index 6bb3a0c..4e64039 100644
--- a/host/event-binding.js
+++ b/host/event-binding.js
@@ -450,6 +450,23 @@ export async function onGenerationAfterCommandsController(
const runtimeRecallOptions =
recallContext.recallOptions || recallOptions || {};
+ if (
+ params &&
+ typeof params === "object" &&
+ runtimeRecallOptions?.authoritativeInputUsed === true
+ ) {
+ const authoritativePrompt = String(
+ runtimeRecallOptions?.overrideUserMessage ||
+ runtimeRecallOptions?.userMessage ||
+ "",
+ ).trim();
+ if (authoritativePrompt) {
+ params.prompt = authoritativePrompt;
+ if (Object.prototype.hasOwnProperty.call(params, "user_input")) {
+ params.user_input = authoritativePrompt;
+ }
+ }
+ }
const deliveryMode =
runtime.resolveGenerationRecallDeliveryMode?.(
recallContext.hookName,
diff --git a/index.js b/index.js
index 62daf7b..966bc4c 100644
--- a/index.js
+++ b/index.js
@@ -2143,6 +2143,17 @@ function ensurePersistedRecallRecordForGeneration({
),
tokenEstimate: estimateTokens(injectionText),
manuallyEdited: false,
+ authoritativeInputUsed: Boolean(
+ recallResult?.authoritativeInputUsed ??
+ frozenRecallOptions?.authoritativeInputUsed ??
+ recallOptions?.authoritativeInputUsed,
+ ),
+ boundUserFloorText: String(
+ recallResult?.boundUserFloorText ||
+ frozenRecallOptions?.boundUserFloorText ||
+ recallOptions?.boundUserFloorText ||
+ "",
+ ),
},
existingRecord,
);
@@ -2314,6 +2325,108 @@ function rewriteRecallPayloadWithInjection(
};
}
+function rewriteRecallPayloadWithAuthoritativeUserInput(
+ promptData = null,
+ authoritativeText = "",
+ boundUserFloorText = "",
+) {
+ const normalizedAuthoritativeText = normalizeRecallInputText(authoritativeText);
+ const normalizedBoundUserFloorText = normalizeRecallInputText(boundUserFloorText);
+ if (!normalizedAuthoritativeText) {
+ return {
+ applied: false,
+ changed: false,
+ path: "",
+ field: "",
+ reason: "empty-authoritative-text",
+ };
+ }
+
+ const finalMesSend = Array.isArray(promptData?.finalMesSend)
+ ? promptData.finalMesSend
+ : null;
+ if (!Array.isArray(finalMesSend) || finalMesSend.length <= 0) {
+ return {
+ applied: false,
+ changed: false,
+ path: "",
+ field: "",
+ reason: "finalMesSend-unavailable",
+ };
+ }
+
+ let fallbackIndex = -1;
+ let matchedIndex = -1;
+ for (let index = finalMesSend.length - 1; index >= 0; index--) {
+ const entry = finalMesSend[index];
+ if (!entry || typeof entry !== "object") continue;
+ if (entry.injected === true) continue;
+
+ const messageText = normalizeRecallInputText(
+ entry.message || entry.mes || entry.content || "",
+ );
+ if (!messageText) continue;
+
+ if (fallbackIndex < 0) {
+ fallbackIndex = index;
+ }
+
+ if (
+ messageText === normalizedAuthoritativeText ||
+ (normalizedBoundUserFloorText &&
+ messageText === normalizedBoundUserFloorText)
+ ) {
+ matchedIndex = index;
+ break;
+ }
+ }
+
+ const targetIndex =
+ matchedIndex >= 0
+ ? matchedIndex
+ : normalizedBoundUserFloorText
+ ? -1
+ : fallbackIndex;
+ if (targetIndex < 0) {
+ return {
+ applied: false,
+ changed: false,
+ path: "finalMesSend",
+ field: "",
+ reason: normalizedBoundUserFloorText
+ ? "bound-user-floor-text-not-found"
+ : "no-rewritable-finalMesSend-entry",
+ };
+ }
+
+ const entry = finalMesSend[targetIndex];
+ const fieldName = Object.prototype.hasOwnProperty.call(entry, "message")
+ ? "message"
+ : Object.prototype.hasOwnProperty.call(entry, "mes")
+ ? "mes"
+ : Object.prototype.hasOwnProperty.call(entry, "content")
+ ? "content"
+ : "message";
+ const previousText = normalizeRecallInputText(
+ entry?.[fieldName] || entry?.message || entry?.mes || entry?.content || "",
+ );
+ const changed = previousText !== normalizedAuthoritativeText;
+ if (changed) {
+ entry[fieldName] = normalizedAuthoritativeText;
+ }
+
+ return {
+ applied: true,
+ changed,
+ path: "finalMesSend",
+ field: `finalMesSend[${targetIndex}].${fieldName}`,
+ reason: changed
+ ? "finalMesSend-authoritative-user-rewritten"
+ : "authoritative-user-already-matched",
+ targetIndex,
+ };
+}
+
function readGenerationRecallTransactionFinalResolution(transaction) {
return transaction?.finalResolution || null;
}
@@ -2339,6 +2452,98 @@ function applyFinalRecallInjectionForGeneration({
const existingFinalResolution =
readGenerationRecallTransactionFinalResolution(transaction);
if (existingFinalResolution) {
+ if (
+ promptData &&
+ transaction?.frozenRecallOptions?.authoritativeInputUsed === true
+ ) {
+ const recallResult =
+ freshRecallResult ||
+ getGenerationRecallTransactionResult(transaction) ||
+ null;
+ const inputRewrite = rewriteRecallPayloadWithAuthoritativeUserInput(
+ promptData,
+ transaction?.frozenRecallOptions?.overrideUserMessage || "",
+ transaction?.frozenRecallOptions?.boundUserFloorText || "",
+ );
+ const rewrite = rewriteRecallPayloadWithInjection(
+ promptData,
+ existingFinalResolution.usedText || recallResult?.injectionText || "",
+ );
+ const nextFinalResolution = {
+ ...existingFinalResolution,
+ deliveryMode: "deferred",
+ applicationMode:
+ rewrite.applied || inputRewrite.applied
+ ? "rewrite"
+ : existingFinalResolution.applicationMode,
+ rewrite,
+ inputRewrite,
+ };
+ recordInjectionSnapshot("recall", {
+ taskType: "recall",
+ source:
+ String(
+ recallResult?.source ||
+ transaction?.frozenRecallOptions?.lockedSource ||
+ transaction?.frozenRecallOptions?.overrideSource ||
+ "",
+ ).trim() || "unknown",
+ sourceLabel:
+ String(
+ recallResult?.sourceLabel ||
+ transaction?.frozenRecallOptions?.lockedSourceLabel ||
+ transaction?.frozenRecallOptions?.overrideSourceLabel ||
+ "",
+ ).trim() || "未知",
+ reason:
+ String(
+ recallResult?.reason ||
+ transaction?.frozenRecallOptions?.lockedReason ||
+ transaction?.frozenRecallOptions?.overrideReason ||
+ "",
+ ).trim() || "final-application-reused",
+ sourceCandidates: Array.isArray(recallResult?.sourceCandidates)
+ ? recallResult.sourceCandidates.map((candidate) => ({ ...candidate }))
+ : Array.isArray(transaction?.frozenRecallOptions?.sourceCandidates)
+ ? transaction.frozenRecallOptions.sourceCandidates.map((candidate) => ({
+ ...candidate,
+ }))
+ : [],
+ hookName: String(hookName || recallResult?.hookName || "").trim(),
+ selectedNodeIds: recallResult?.selectedNodeIds || [],
+ retrievalMeta: recallResult?.retrievalMeta || {},
+ llmMeta: recallResult?.llmMeta || {},
+ stats: recallResult?.stats || {},
+ injectionText: nextFinalResolution.usedText || "",
+ deliveryMode: nextFinalResolution.deliveryMode || "",
+ applicationMode: nextFinalResolution.applicationMode || "none",
+ transport: nextFinalResolution.transport || {
+ applied: false,
+ source: "none",
+ mode: "none",
+ },
+ rewrite: nextFinalResolution.rewrite || {
+ applied: false,
+ path: "",
+ field: "",
+ reason: "final-resolution-reused",
+ },
+ inputRewrite,
+ targetUserMessageIndex: nextFinalResolution.targetUserMessageIndex,
+ sourceKind: nextFinalResolution.source || "none",
+ authoritativeInputUsed: true,
+ boundUserFloorText: String(
+ transaction?.frozenRecallOptions?.boundUserFloorText || "",
+ ),
+ });
+ storeGenerationRecallTransactionFinalResolution(
+ transaction,
+ nextFinalResolution,
+ );
+ refreshPanelLiveState();
+ schedulePersistedRecallMessageUiRefresh();
+ return nextFinalResolution;
+ }
return existingFinalResolution;
}
@@ -2346,15 +2551,21 @@ function applyFinalRecallInjectionForGeneration({
freshRecallResult ||
getGenerationRecallTransactionResult(transaction) ||
null;
+ const hookResolvedDeliveryMode =
+ String(
+ resolveGenerationRecallDeliveryMode(
+ hookName,
+ generationType,
+ transaction?.frozenRecallOptions || {},
+ ),
+ ).trim() || "immediate";
const deliveryMode =
String(
- recallResult?.deliveryMode ||
- transaction?.lastDeliveryMode ||
- resolveGenerationRecallDeliveryMode(
- hookName,
- generationType,
- transaction?.frozenRecallOptions || {},
- ),
+ promptData && hookName === "GENERATE_BEFORE_COMBINE_PROMPTS"
+ ? hookResolvedDeliveryMode
+ : recallResult?.deliveryMode ||
+ transaction?.lastDeliveryMode ||
+ hookResolvedDeliveryMode,
).trim() || "immediate";
const chat = getContext()?.chat;
@@ -2369,6 +2580,24 @@ function applyFinalRecallInjectionForGeneration({
injectionText: "",
record: null,
};
+ const authoritativeInputRewrite =
+ deliveryMode === "deferred" &&
+ transaction?.frozenRecallOptions?.authoritativeInputUsed === true
+ ? rewriteRecallPayloadWithAuthoritativeUserInput(
+ promptData,
+ transaction?.frozenRecallOptions?.overrideUserMessage || "",
+ transaction?.frozenRecallOptions?.boundUserFloorText || "",
+ )
+ : {
+ applied: false,
+ changed: false,
+ path: "",
+ field: "",
+ reason:
+ deliveryMode === "deferred"
+ ? "authoritative-input-unused"
+ : "non-deferred-delivery",
+ };
const rewrite = {
applied: false,
path: "",
@@ -2539,8 +2768,18 @@ function applyFinalRecallInjectionForGeneration({
applicationMode,
transport,
rewrite,
+ inputRewrite: authoritativeInputRewrite,
targetUserMessageIndex,
sourceKind: resolved.source,
+ authoritativeInputUsed: Boolean(
+ recallResult?.authoritativeInputUsed ??
+ transaction?.frozenRecallOptions?.authoritativeInputUsed,
+ ),
+ boundUserFloorText: String(
+ recallResult?.boundUserFloorText ||
+ transaction?.frozenRecallOptions?.boundUserFloorText ||
+ "",
+ ),
});
refreshPanelLiveState();
@@ -2557,6 +2796,16 @@ function applyFinalRecallInjectionForGeneration({
applicationMode,
rewrite,
transport,
+ inputRewrite: authoritativeInputRewrite,
+ authoritativeInputUsed: Boolean(
+ recallResult?.authoritativeInputUsed ??
+ transaction?.frozenRecallOptions?.authoritativeInputUsed,
+ ),
+ boundUserFloorText: String(
+ recallResult?.boundUserFloorText ||
+ transaction?.frozenRecallOptions?.boundUserFloorText ||
+ "",
+ ),
};
storeGenerationRecallTransactionFinalResolution(transaction, finalResolution);
return finalResolution;
diff --git a/llm/llm.js b/llm/llm.js
index 0383eeb..3ea73ba 100644
--- a/llm/llm.js
+++ b/llm/llm.js
@@ -129,6 +129,7 @@ function summarizeTaskTimelineEntry(taskType, snapshot = {}) {
responseCleaning: cloneRuntimeDebugValue(snapshot?.responseCleaning, null),
jsonFailure: cloneRuntimeDebugValue(snapshot?.jsonFailure, null),
messages: cloneRuntimeDebugValue(snapshot?.messages, []),
+ transportMessages: cloneRuntimeDebugValue(snapshot?.transportMessages, []),
requestBody: cloneRuntimeDebugValue(snapshot?.requestBody, null),
};
}
@@ -930,6 +931,76 @@ function looksLikeTruncatedJson(text) {
return false;
}
+function cloneLlmDebugMessageMetadata(message = {}) {
+ const metadata = {};
+
+ for (const key of [
+ "source",
+ "sourceKey",
+ "blockId",
+ "blockName",
+ "blockType",
+ "injectionMode",
+ "contentOrigin",
+ "regexSourceType",
+ "speaker",
+ "name",
+ ]) {
+ const value = String(message?.[key] || "").trim();
+ if (value) {
+ metadata[key] = value;
+ }
+ }
+
+ if (message?.derivedFromWorldInfo === true) {
+ metadata.derivedFromWorldInfo = true;
+ }
+ if (message?.sanitizationEligible === true) {
+ metadata.sanitizationEligible = true;
+ }
+ if (Number.isFinite(Number(message?.depth))) {
+ metadata.depth = Number(message.depth);
+ }
+ if (Number.isFinite(Number(message?.order))) {
+ metadata.order = Number(message.order);
+ }
+
+ return metadata;
+}
+
+function normalizeLlmDebugMessage(message = {}) {
+ if (!message || typeof message !== "object") return null;
+ const role = String(message.role || "").trim().toLowerCase();
+ const content = String(message.content || "").trim();
+ if (!content || !["system", "user", "assistant"].includes(role)) {
+ return null;
+ }
+ return {
+ role,
+ content,
+ ...cloneLlmDebugMessageMetadata(message),
+ };
+}
+
+function buildTransportMessages(messages = []) {
+ return (Array.isArray(messages) ? messages : [])
+ .map((message) => {
+ if (!message || typeof message !== "object") {
+ return null;
+ }
+ const role = String(message.role || "").trim().toLowerCase();
+ const content = String(message.content || "").trim();
+ if (!content || !["system", "user", "assistant"].includes(role)) {
+ return null;
+ }
+ return {
+ role,
+ content,
+ };
+ })
+ .filter(Boolean);
+}
+
function buildJsonAttemptMessages(
systemPrompt,
userPrompt,
@@ -961,15 +1032,7 @@ function buildJsonAttemptMessages(
const normalizedPromptMessages = Array.isArray(promptMessages)
? promptMessages
- .map((message) => {
- if (!message || typeof message !== "object") return null;
- const role = String(message.role || "").trim().toLowerCase();
- const content = String(message.content || "").trim();
- if (!["system", "user", "assistant"].includes(role) || !content) {
- return null;
- }
- return { role, content };
- })
+ .map((message) => normalizeLlmDebugMessage(message))
.filter(Boolean)
: [];
@@ -1037,12 +1100,9 @@ function buildJsonAttemptMessages(
}
for (const message of additionalMessages || []) {
- if (!message || typeof message !== "object") continue;
- const role = String(message.role || "").trim().toLowerCase();
- const content = String(message.content || "").trim();
- if (!content) continue;
- if (!["system", "user", "assistant"].includes(role)) continue;
- messages.push({ role, content });
+ const normalizedMessage = normalizeLlmDebugMessage(message);
+ if (!normalizedMessage) continue;
+ messages.push(normalizedMessage);
}
messages.push({ role: "user", content: userParts.join("\n\n") });
@@ -1054,16 +1114,16 @@ function resolvePrivateRequestSource(
requestSource = "",
{ allowAnonymous = false } = {},
) {
- const normalizedRequestSource = String(requestSource || "").trim();
- if (normalizedRequestSource) {
- return normalizedRequestSource;
- }
-
const normalizedTaskType = String(taskType || "").trim();
if (normalizedTaskType) {
return `task:${normalizedTaskType}`;
}
+ const normalizedRequestSource = String(requestSource || "").trim();
+ if (normalizedRequestSource) {
+ return normalizedRequestSource;
+ }
+
if (allowAnonymous) {
return "adhoc";
}
@@ -1399,6 +1459,7 @@ async function callDedicatedOpenAICompatible(
taskType,
requestSource,
);
+ const transportMessages = buildTransportMessages(messages);
const config = getMemoryLLMConfig(taskType);
const settings = extension_settings[MODULE_NAME] || {};
const hasDedicatedConfig = hasDedicatedLLMConfig(config);
@@ -1448,6 +1509,7 @@ async function callDedicatedOpenAICompatible(
requestedLlmPresetName: config.requestedLlmPresetName || "",
llmPresetFallbackReason: config.llmPresetFallbackReason || "",
messages,
+ transportMessages,
generation: generationResolved.generation || {},
filteredGeneration: generationResolved.filtered || {},
removedGeneration: generationResolved.removed || [],
@@ -1463,7 +1525,7 @@ async function callDedicatedOpenAICompatible(
if (!hasDedicatedConfig) {
const payload = await sendOpenAIRequest(
"quiet",
- messages,
+ transportMessages,
signal,
jsonMode ? { jsonSchema: createGenericJsonSchema() } : {},
);
@@ -1500,7 +1562,7 @@ async function callDedicatedOpenAICompatible(
})
: "",
model: config.model,
- messages,
+ messages: transportMessages,
temperature: filteredGeneration.temperature ?? 1,
max_tokens: resolvedCompletionTokens,
stream: filteredGeneration.stream ?? false,
@@ -1556,6 +1618,7 @@ async function callDedicatedOpenAICompatible(
requestedLlmPresetName: config.requestedLlmPresetName || "",
llmPresetFallbackReason: config.llmPresetFallbackReason || "",
messages,
+ transportMessages,
generation: generationResolved.generation || {},
filteredGeneration,
removedGeneration: generationResolved.removed || [],
diff --git a/prompting/prompt-builder.js b/prompting/prompt-builder.js
index 0dc5fb1..7b84777 100644
--- a/prompting/prompt-builder.js
+++ b/prompting/prompt-builder.js
@@ -1865,6 +1865,86 @@ export async function buildTaskPrompt(settings = {}, taskType, context = {}) {
return result;
}
+function clonePayloadMessage(message = {}) {
+ return 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 || ""),
+ });
+}
+
+function collectPayloadUserMessageTexts(messages = []) {
+ return (Array.isArray(messages) ? messages : [])
+ .filter((message) => String(message?.role || "").trim().toLowerCase() === "user")
+ .map((message) => String(message?.content || "").trim())
+ .filter(Boolean);
+}
+
+function buildSafeFallbackUserPrompt(
+ settings = {},
+ taskType,
+ {
+ fallbackUserPrompt = "",
+ blockedContents = [],
+ rawExecutionMessages = [],
+ rawPrivateTaskMessages = [],
+ } = {},
+) {
+ const structuredUserPrompt = [
+ ...collectPayloadUserMessageTexts(rawExecutionMessages),
+ ...collectPayloadUserMessageTexts(rawPrivateTaskMessages),
+ ]
+ .join("\n\n")
+ .trim();
+ const candidates = [
+ {
+ source: "structured-user-blocks",
+ text: structuredUserPrompt,
+ },
+ {
+ source: "fallback-user-prompt",
+ text: String(fallbackUserPrompt || "").trim(),
+ },
+ ].filter((candidate) => candidate.text);
+
+ for (const candidate of candidates) {
+ const sanitized = sanitizeInjectionText(settings, taskType, candidate.text, {
+ mode: "final-injection-safe",
+ blockedContents,
+ contentOrigin: PROMPT_CONTENT_ORIGIN.HOST_INJECTED,
+ sanitizationEligible: true,
+ role: "user",
+ applySanitizer: true,
+ applyHostRegex: false,
+ path: "payload.fallbackUserPrompt",
+ stage: "payload-fallback-user-prompt",
+ });
+ const text = String(sanitized.text || "").trim();
+ if (text) {
+ return {
+ text,
+ source: candidate.source,
+ changed: Boolean(sanitized.changed),
+ dropped: Boolean(sanitized.dropped),
+ };
+ }
+ }
+
+ return {
+ text: "",
+ source: candidates[0]?.source || "",
+ changed: false,
+ dropped: candidates.length > 0,
+ };
+}
+
export function buildTaskLlmPayload(promptBuild = null, fallbackUserPrompt = "") {
const runtimeMvu = promptBuild?.__mvuRuntime || {};
const taskType = String(promptBuild?.debug?.taskType || "");
@@ -1880,20 +1960,12 @@ export function buildTaskLlmPayload(promptBuild = null, fallbackUserPrompt = "")
: [];
const rawExecutionMessages = Array.isArray(promptBuild?.executionMessages)
? promptBuild.executionMessages
- .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 || ""),
- }),
- )
+ .map((message) => clonePayloadMessage(message))
+ .filter(Boolean)
+ : [];
+ const rawPrivateTaskMessages = Array.isArray(promptBuild?.privateTaskMessages)
+ ? promptBuild.privateTaskMessages
+ .map((message) => clonePayloadMessage(message))
.filter(Boolean)
: [];
const executionMessages = sanitizePromptMessages(
@@ -1949,22 +2021,39 @@ export function buildTaskLlmPayload(promptBuild = null, fallbackUserPrompt = "")
: sanitizePromptMessages(
settings,
taskType,
- Array.isArray(promptBuild?.privateTaskMessages)
- ? promptBuild.privateTaskMessages
- : [],
+ rawPrivateTaskMessages,
{
blockedContents,
applySanitizer: (message) =>
!(isCustomFilter && messageUsesWorldInfoContent(message)),
},
);
+ const hasAdditionalUserMessage = additionalMessages.some(
+ (message) => message.role === "user",
+ );
+ const fallbackUserPromptResult =
+ hasUserMessage || hasAdditionalUserMessage
+ ? {
+ text: "",
+ source: hasUserMessage ? "execution-messages" : "additional-messages",
+ changed: false,
+ dropped: false,
+ }
+ : buildSafeFallbackUserPrompt(settings, taskType, {
+ fallbackUserPrompt,
+ blockedContents,
+ rawExecutionMessages,
+ rawPrivateTaskMessages,
+ });
return {
systemPrompt:
executionMessages.length > 0 ? "" : String(promptBuild?.systemPrompt || ""),
- userPrompt: hasUserMessage ? "" : String(fallbackUserPrompt || ""),
+ userPrompt: fallbackUserPromptResult.text,
promptMessages: executionMessages,
additionalMessages,
+ fallbackUserPromptSource: fallbackUserPromptResult.source,
+ fallbackUserPromptApplied: Boolean(fallbackUserPromptResult.text),
};
}
diff --git a/prompting/task-regex.js b/prompting/task-regex.js
index 16749a7..0805c0d 100644
--- a/prompting/task-regex.js
+++ b/prompting/task-regex.js
@@ -256,6 +256,10 @@ function getRegexHost() {
const capabilitySupport = regexHost.readCapabilitySupport?.() || {};
const supplementedCapabilities = [];
const missingCapabilities = [];
+ const resolvedGetter =
+ typeof regexHost.getTavernRegexes === "function"
+ ? regexHost.getTavernRegexes
+ : legacyGetTavernRegexes;
const resolvedCharacterToggle =
typeof regexHost.isCharacterTavernRegexesEnabled === "function"
? regexHost.isCharacterTavernRegexesEnabled
@@ -265,6 +269,14 @@ function getRegexHost() {
? regexHost.formatAsTavernRegexedString
: legacyFormatAsTavernRegexedString;
+ if (typeof regexHost.getTavernRegexes !== "function") {
+ if (resolvedGetter) {
+ supplementedCapabilities.push("getTavernRegexes");
+ } else {
+ missingCapabilities.push("getTavernRegexes");
+ }
+ }
+
if (typeof regexHost.isCharacterTavernRegexesEnabled !== "function") {
if (resolvedCharacterToggle) {
supplementedCapabilities.push("isCharacterTavernRegexesEnabled");
@@ -282,16 +294,24 @@ function getRegexHost() {
}
return {
- getTavernRegexes: regexHost.getTavernRegexes,
+ getTavernRegexes: resolvedGetter,
isCharacterTavernRegexesEnabled: resolvedCharacterToggle,
formatAsTavernRegexedString: resolvedFormatter,
- sourceLabel: capabilitySupport.sourceLabel || "host-adapter.regex",
+ sourceLabel:
+ capabilitySupport.sourceLabel || regexHost?.sourceLabel || "host-adapter.regex",
fallback:
Boolean(capabilitySupport.fallback) ||
+ typeof regexHost.getTavernRegexes !== "function" ||
+ typeof regexHost.isCharacterTavernRegexesEnabled !== "function" ||
+ typeof regexHost.formatAsTavernRegexedString !== "function" ||
supplementedCapabilities.length > 0,
- fallbackReason: String(capabilitySupport.fallbackReason || "").trim(),
+ fallbackReason: String(
+ regexHost?.fallbackReason || capabilitySupport.fallbackReason || "",
+ ).trim(),
capabilityStatus: Object.freeze({
- mode: capabilitySupport.mode || "unknown",
+ mode: capabilitySupport.mode || regexHost?.mode || "unknown",
+ bridgeTier:
+ capabilitySupport.bridgeTier || capabilitySupport.mode || regexHost?.mode || "unknown",
supplementedCapabilities: Object.freeze(supplementedCapabilities),
missingCapabilities: Object.freeze(missingCapabilities),
}),
@@ -500,10 +520,15 @@ function summarizeRuleForPromptPreview(rule, stageConfig = {}, reason = "") {
promptStageMode = promptSemanticApplies ? "replace" : "skip";
} else if (rule?.destinationFlags?.prompt === false || summary.markdownOnly) {
promptStageMode = "display-only";
- } else if (summary.beautificationReplace && executionState.mode !== "host-real") {
+ } else if (
+ summary.beautificationReplace &&
+ !["host-real", "host-helper"].includes(executionState.mode)
+ ) {
promptStageMode = "fallback-skip-beautify";
} else if (executionState.mode === "host-real") {
promptStageMode = "host-real";
+ } else if (executionState.mode === "host-helper") {
+ promptStageMode = "host-helper";
} else if (executionState.mode === "host-fallback") {
promptStageMode = "host-fallback";
}
@@ -748,6 +773,10 @@ function collectTavernRulesDetailed(regexConfig = {}) {
formatterAvailable:
typeof regexHost.formatAsTavernRegexedString === "function",
executionMode: buildHostRegexExecutionState(regexHost).mode,
+ bridgeTier:
+ regexHost?.capabilityStatus?.bridgeTier ||
+ regexHost?.capabilityStatus?.mode ||
+ "unknown",
capabilityStatus: regexHost.capabilityStatus || null,
},
sources,
@@ -822,21 +851,40 @@ function ruleMatchesFormatterDepth(rule, formatterOptions = null) {
}
function buildHostRegexExecutionState(regexHost = null) {
+ const bridgeTier =
+ String(
+ regexHost?.capabilityStatus?.bridgeTier ||
+ regexHost?.capabilityStatus?.mode ||
+ "",
+ ).trim() || "unknown";
const formatterAvailable =
typeof regexHost?.formatAsTavernRegexedString === "function";
const rulesAvailable = typeof regexHost?.getTavernRegexes === "function";
- if (formatterAvailable) {
+ if (formatterAvailable && bridgeTier === "core-real") {
return {
mode: "host-real",
+ bridgeTier,
formatterAvailable: true,
fallbackReason: "",
};
}
+ if (formatterAvailable) {
+ return {
+ mode: "host-helper",
+ bridgeTier,
+ formatterAvailable: true,
+ fallbackReason:
+ String(regexHost?.fallbackReason || "").trim() ||
+ "当前通过 helper bridge 提供 Tavern Regex formatter",
+ };
+ }
+
if (rulesAvailable) {
return {
mode: "host-fallback",
+ bridgeTier,
formatterAvailable: false,
fallbackReason:
String(regexHost?.fallbackReason || "").trim() ||
@@ -846,6 +894,7 @@ function buildHostRegexExecutionState(regexHost = null) {
return {
mode: "host-unavailable",
+ bridgeTier,
formatterAvailable: false,
fallbackReason:
String(regexHost?.fallbackReason || "").trim() ||
@@ -864,7 +913,7 @@ function shouldReuseTavernRuleForPrompt(rule, executionMode = "host-fallback") {
return false;
}
if (
- executionMode !== "host-real" &&
+ !["host-real", "host-helper"].includes(executionMode) &&
Boolean(rule?.beautificationReplace)
) {
return false;
@@ -1106,7 +1155,10 @@ export function applyHostRegexReuse(
if (
!normalizedSourceType ||
- (tavernRules.length === 0 && executionState.mode !== "host-real")
+ (
+ tavernRules.length === 0 &&
+ !["host-real", "host-helper"].includes(executionState.mode)
+ )
) {
pushDebug(debugCollector, {
kind: "host-reuse",
@@ -1133,7 +1185,7 @@ export function applyHostRegexReuse(
}
if (
- executionState.mode === "host-real" &&
+ ["host-real", "host-helper"].includes(executionState.mode) &&
typeof regexHost?.formatAsTavernRegexedString === "function"
) {
try {
@@ -1150,23 +1202,29 @@ export function applyHostRegexReuse(
taskType: normalizedTaskType,
stage: `host:${normalizedSourceType}`,
enabled: true,
- executionMode: "host-real",
+ executionMode: executionState.mode,
formatterAvailable: true,
appliedRules: output !== input
- ? [{ id: "__host_formatter__", source: "host-real" }]
+ ? [{ id: "__host_formatter__", source: executionState.mode }]
: [],
sourceCount: { tavern: tavernRules.length, local: 0 },
- fallbackReason: "",
+ fallbackReason:
+ executionState.mode === "host-real"
+ ? ""
+ : executionState.fallbackReason,
hostFormatterSource: String(regexHost?.sourceLabel || ""),
skippedDisplayOnlyRuleCount,
});
return {
text: output,
changed: output !== input,
- executionMode: "host-real",
+ executionMode: executionState.mode,
formatterAvailable: true,
formatterSource: String(regexHost?.sourceLabel || ""),
- fallbackReason: "",
+ fallbackReason:
+ executionState.mode === "host-real"
+ ? ""
+ : executionState.fallbackReason,
skippedDisplayOnlyRuleCount,
};
} catch (error) {
diff --git a/retrieval/recall-persistence.js b/retrieval/recall-persistence.js
index b418474..69b5cfe 100644
--- a/retrieval/recall-persistence.js
+++ b/retrieval/recall-persistence.js
@@ -47,6 +47,8 @@ export function readPersistedRecallFromUserMessage(chat, userMessageIndex) {
updatedAt: toIsoString(record.updatedAt),
generationCount: Math.max(0, Number.parseInt(record.generationCount, 10) || 0),
manuallyEdited: Boolean(record.manuallyEdited),
+ authoritativeInputUsed: Boolean(record.authoritativeInputUsed),
+ boundUserFloorText: String(record.boundUserFloorText || ""),
};
}
@@ -69,6 +71,8 @@ export function buildPersistedRecallRecord(payload = {}, existingRecord = null)
updatedAt: nowIso,
generationCount: 0,
manuallyEdited: Boolean(payload.manuallyEdited),
+ authoritativeInputUsed: Boolean(payload.authoritativeInputUsed),
+ boundUserFloorText: String(payload.boundUserFloorText || ""),
};
}
diff --git a/tests/helpers/register-hooks-compat.mjs b/tests/helpers/register-hooks-compat.mjs
index da1b471..1d4d3a2 100644
--- a/tests/helpers/register-hooks-compat.mjs
+++ b/tests/helpers/register-hooks-compat.mjs
@@ -1,11 +1,27 @@
import { register, registerHooks } from "node:module";
+const DEFAULT_REGEX_ENGINE_HOOK_ENTRIES = Object.freeze([
+ {
+ specifiers: ["../../../../regex/engine.js"],
+ url: toDataModuleUrl([
+ "export const regex_placement = { USER_INPUT: 1, AI_OUTPUT: 2, SLASH_COMMAND: 3, WORLD_INFO: 5, REASONING: 6 };",
+ "export function getRegexedString(...args) {",
+ " const fn = globalThis.__taskRegexTestCoreGetRegexedString;",
+ " return typeof fn === 'function' ? fn(...args) : String(args?.[0] ?? '');",
+ "}",
+ ].join("\n")),
+ },
+]);
+
export function toDataModuleUrl(source = "") {
return `data:text/javascript,${encodeURIComponent(String(source || ""))}`;
}
export function installResolveHooks(entries = []) {
- const normalizedEntries = (Array.isArray(entries) ? entries : [])
+ const normalizedEntries = [
+ ...(Array.isArray(entries) ? entries : []),
+ ...DEFAULT_REGEX_ENGINE_HOOK_ENTRIES,
+ ]
.map((entry) => ({
specifiers: Array.isArray(entry?.specifiers)
? entry.specifiers.map((value) => String(value || "")).filter(Boolean)
diff --git a/tests/p0-regressions.mjs b/tests/p0-regressions.mjs
index b222863..36dbd90 100644
--- a/tests/p0-regressions.mjs
+++ b/tests/p0-regressions.mjs
@@ -4244,6 +4244,65 @@ async function testGenerationRecallDeferredRewriteMutatesFinalMesSendPayload() {
);
}
+async function testGenerationRecallDeferredRewriteMutatesFinalMesSendAuthoritativeUserInput() {
+ const harness = await createGenerationRecallHarness({ realApplyFinal: true });
+ harness.extension_settings[MODULE_NAME] = {
+ recallUseAuthoritativeGenerationInput: true,
+ };
+ harness.chat = [{ is_user: true, mes: "楼层稳定输入" }];
+ harness.pendingRecallSendIntent = {
+ text: "发送前真实输入",
+ hash: "hash-deferred-authoritative-rewrite",
+ at: Date.now(),
+ source: "dom-intent",
+ };
+ harness.result.pendingRecallSendIntent = harness.pendingRecallSendIntent;
+
+ await harness.result.onGenerationAfterCommands("normal", {}, false);
+
+ const promptData = {
+ finalMesSend: [
+ {
+ injected: false,
+ message: "楼层稳定输入",
+ extensionPrompts: [],
+ },
+ ],
+ };
+
+ const resolution = await harness.result.onBeforeCombinePrompts(promptData);
+
+ assert.equal(harness.runRecallCalls.length, 1);
+ assert.equal(
+ harness.runRecallCalls[0].hookName,
+ "GENERATION_AFTER_COMMANDS",
+ );
+ const transaction = [...harness.result.generationRecallTransactions.values()][0];
+ assert.ok(transaction);
+ assert.equal(transaction.frozenRecallOptions.authoritativeInputUsed, true);
+ assert.equal(transaction.frozenRecallOptions.boundUserFloorText, "楼层稳定输入");
+ assert.equal(
+ harness.runRecallCalls[0].authoritativeInputUsed,
+ true,
+ );
+ assert.equal(harness.runRecallCalls[0].boundUserFloorText, "楼层稳定输入");
+ assert.equal(promptData.finalMesSend[0].message, "发送前真实输入");
+ assert.equal(resolution.applicationMode, "rewrite");
+ assert.equal(resolution.authoritativeInputUsed, true);
+ assert.equal(resolution.boundUserFloorText, "楼层稳定输入");
+ assert.equal(resolution.inputRewrite.applied, true);
+ assert.equal(resolution.inputRewrite.changed, true);
+ assert.equal(resolution.inputRewrite.field, "finalMesSend[0].message");
+ assert.match(
+ promptData.finalMesSend[0].extensionPrompts.join("\n"),
+ /注入:发送前真实输入/,
+ );
+ assert.equal(
+ harness.recordedInjectionSnapshots.at(-1)?.inputRewrite?.applied,
+ true,
+ );
+}
+
async function testGenerationRecallSendIntentBeatsChatTailAndStaysObservable() {
const harness = await createGenerationRecallHarness();
harness.chat = [{ is_user: true, mes: "旧的 chat tail" }];
@@ -4480,8 +4539,11 @@ async function testPersistentRecallDataLayerLifecycleAndCompatibility() {
hookName: "GENERATION_AFTER_COMMANDS",
tokenEstimate: 24,
manuallyEdited: false,
+ authoritativeInputUsed: true,
+ boundUserFloorText: "稳定楼层输入",
nowIso: "2026-01-01T00:00:00.000Z",
});
+
assert.equal(writePersistedRecallToUserMessage(chat, 2, record), true);
const loaded = readPersistedRecallFromUserMessage(chat, 2);
@@ -4489,6 +4551,8 @@ async function testPersistentRecallDataLayerLifecycleAndCompatibility() {
assert.equal(loaded.injectionText, "fresh-memory");
assert.equal(loaded.generationCount, 0);
assert.equal(loaded.manuallyEdited, false);
+ assert.equal(loaded.authoritativeInputUsed, true);
+ assert.equal(loaded.boundUserFloorText, "稳定楼层输入");
chat[2].mes = "u2 edited";
assert.equal(
@@ -4517,14 +4581,19 @@ async function testPersistentRecallDataLayerLifecycleAndCompatibility() {
hookName: "MESSAGE_RECALL_BADGE_RERUN",
tokenEstimate: 30,
manuallyEdited: false,
+ authoritativeInputUsed: false,
+ boundUserFloorText: "",
nowIso: "2026-01-01T00:00:02.000Z",
},
readPersistedRecallFromUserMessage(chat, 2),
);
+
assert.equal(writePersistedRecallToUserMessage(chat, 2, overwrite), true);
const overwritten = readPersistedRecallFromUserMessage(chat, 2);
assert.equal(overwritten?.manuallyEdited, false);
assert.equal(overwritten?.injectionText, "system-rerecall");
+ assert.equal(overwritten?.authoritativeInputUsed, false);
+ assert.equal(overwritten?.boundUserFloorText, "");
assert.equal(removePersistedRecallFromUserMessage(chat, 2), true);
assert.equal(readPersistedRecallFromUserMessage(chat, 2), null);
@@ -4601,17 +4670,39 @@ async function testGenerationRecallFinalInjectionRebindsLatestMatchingUserFloor(
status: "completed",
didRecall: true,
injectionText: "fresh-memory",
+ authoritativeInputUsed: true,
+ boundUserFloorText: "稳定楼层输入",
},
transaction: {
frozenRecallOptions: {
generationType: "normal",
targetUserMessageIndex: null,
overrideUserMessage: "当前输入",
+ lockedSource: "send-intent",
+ hookName: "GENERATION_AFTER_COMMANDS",
},
},
});
+ assert.equal(resolution.source, "fresh");
assert.equal(resolution.targetUserMessageIndex, 0);
+ assert.equal(resolution.authoritativeInputUsed, true);
+ assert.equal(resolution.boundUserFloorText, "稳定楼层输入");
+ assert.equal(
+ harness.chat[0]?.extra?.bme_recall?.injectionText,
+ "fresh-memory",
+ );
+
+ assert.equal(
+ JSON.stringify(harness.chat[0]?.extra?.bme_recall?.selectedNodeIds || []),
+ JSON.stringify([]),
+ );
+ assert.equal(harness.chat[0]?.extra?.bme_recall?.authoritativeInputUsed, true);
+ assert.equal(
+ harness.chat[0]?.extra?.bme_recall?.boundUserFloorText,
+ "稳定楼层输入",
+ );
+ assert.equal(harness.metadataSaveCalls > 0, true);
}
{
@@ -4640,6 +4731,8 @@ async function testGenerationRecallFinalInjectionRebindsLatestMatchingUserFloor(
generationType: "normal",
targetUserMessageIndex: null,
overrideUserMessage: "尾部 user 仍可匹配",
+ lockedSource: "send-intent",
+ hookName: "GENERATION_AFTER_COMMANDS",
},
},
});
@@ -4674,6 +4767,8 @@ async function testGenerationRecallFinalInjectionRebindsLatestMatchingUserFloor(
generationType: "normal",
targetUserMessageIndex: null,
overrideUserMessage: "发送前捕获的原始文本",
+ lockedSource: "send-intent",
+ hookName: "GENERATION_AFTER_COMMANDS",
},
},
});
@@ -4703,6 +4798,8 @@ async function testGenerationRecallFinalInjectionBackfillsPersistedRecord() {
didRecall: true,
injectionText: "fresh-memory",
selectedNodeIds: ["node-a", "node-b"],
+ authoritativeInputUsed: true,
+ boundUserFloorText: "稳定楼层输入",
},
transaction: {
frozenRecallOptions: {
@@ -4721,9 +4818,15 @@ async function testGenerationRecallFinalInjectionBackfillsPersistedRecord() {
harness.chat[0]?.extra?.bme_recall?.injectionText,
"fresh-memory",
);
- assert.deepEqual(
- harness.chat[0]?.extra?.bme_recall?.selectedNodeIds,
- ["node-a", "node-b"],
+
+ assert.equal(
+ JSON.stringify(harness.chat[0]?.extra?.bme_recall?.selectedNodeIds || []),
+ JSON.stringify(["node-a", "node-b"]),
+ );
+ assert.equal(harness.chat[0]?.extra?.bme_recall?.authoritativeInputUsed, true);
+ assert.equal(
+ harness.chat[0]?.extra?.bme_recall?.boundUserFloorText,
+ "稳定楼层输入",
);
assert.equal(harness.metadataSaveCalls > 0, true);
}
@@ -4744,9 +4847,9 @@ async function testGenerationRecallImmediateAfterCommandsBackfillsPersistedRecor
harness.chat[0]?.extra?.bme_recall?.injectionText,
"注入:即时模式补写目标",
);
- assert.deepEqual(
- harness.chat[0]?.extra?.bme_recall?.selectedNodeIds,
- ["node-test-1"],
+ assert.equal(
+ JSON.stringify(harness.chat[0]?.extra?.bme_recall?.selectedNodeIds || []),
+ JSON.stringify(["node-test-1"]),
);
assert.equal(harness.metadataSaveCalls > 0, true);
}
@@ -6079,6 +6182,7 @@ await testAutoExtractionDefersWhenHistoryRecoveryBusy();
await testRemoveNodeHandlesCyclicChildGraph();
await testGenerationRecallAppliesFinalInjectionOncePerTransaction();
await testGenerationRecallDeferredRewriteMutatesFinalMesSendPayload();
+await testGenerationRecallDeferredRewriteMutatesFinalMesSendAuthoritativeUserInput();
await testPersistentRecallDataLayerLifecycleAndCompatibility();
await testPersistentRecallSourceResolutionAndTargetRouting();
await testGenerationRecallFinalInjectionRebindsLatestMatchingUserFloor();
diff --git a/tests/prompt-builder-mvu.mjs b/tests/prompt-builder-mvu.mjs
index 4bfc577..bd808bd 100644
--- a/tests/prompt-builder-mvu.mjs
+++ b/tests/prompt-builder-mvu.mjs
@@ -327,9 +327,42 @@ try {
systemOnlyPromptBuild,
"fallback