/);
+
+ const worldInfoBeforeBlock = (Array.isArray(captured.promptMessages)
+ ? captured.promptMessages
+ : []
+ ).find((message) => message.sourceKey === "worldInfoBefore");
+ assert.ok(worldInfoBeforeBlock, "worldInfoBefore block should exist when worldbook is active");
+ assert.match(String(worldInfoBeforeBlock?.content || ""), /蓝钥匙线索/);
+ } finally {
+ restore();
+ }
+ }
+
+ {
+ const graph = createEmptyGraph();
+ let captured = null;
+ const restore = setTestOverrides({
+ llm: {
+ async callLLMForJSON(payload) {
+ captured = payload;
+ return { operations: [], cognitionUpdates: [], regionUpdates: {} };
+ },
+ },
+ });
+
+ try {
+ const result = await extractMemories({
+ graph,
+ messages: fidelityMessages,
+ startSeq: 30,
+ endSeq: 32,
+ schema: DEFAULT_NODE_SCHEMA,
+ embeddingConfig: null,
+ settings: {
+ ...defaultSettings,
+ extractAssistantExcludeTags: "think,action",
+ extractWorldbookMode: "none",
+ },
+ });
+
+ assert.equal(result.success, true);
+ assert.ok(captured);
+
+ const allContent = collectAllPromptContent(captured);
+ assert.match(allContent, /角色描述:夜巡调查员/);
+ assert.match(allContent, /用户设定:谨慎调查者/);
+ assert.doesNotMatch(allContent, /主世界书:蓝钥匙线索。/);
+ assert.doesNotMatch(allContent, /主世界书命中:调查蓝钥匙时应关注旧城区。/);
+ assert.doesNotMatch(allContent, /人格世界书:保持谨慎,不要忽略路线细节。/);
+ assert.doesNotMatch(allContent, /聊天世界书:当前会话已锁定旧城区雨夜调查。/);
+
+ const recentBlock = (Array.isArray(captured.promptMessages)
+ ? captured.promptMessages
+ : []
+ ).find((message) => message.sourceKey === "recentMessages");
+ assert.ok(recentBlock, "recentMessages block should still exist when worldbook is disabled");
+ assert.match(String(recentBlock?.content || ""), /#30 \[assistant\|艾琳\]: 艾琳说:去调查蓝钥匙。/);
+ } finally {
+ restore();
+ }
+ }
+} finally {
+ if (originalSillyTavern === undefined) {
+ delete globalThis.SillyTavern;
+ } else {
+ globalThis.SillyTavern = originalSillyTavern;
+ }
+ if (originalGetCharWorldbookNames === undefined) {
+ delete globalThis.getCharWorldbookNames;
+ } else {
+ globalThis.getCharWorldbookNames = originalGetCharWorldbookNames;
+ }
+ if (originalGetWorldbook === undefined) {
+ delete globalThis.getWorldbook;
+ } else {
+ globalThis.getWorldbook = originalGetWorldbook;
+ }
+ if (originalGetLorebookEntries === undefined) {
+ delete globalThis.getLorebookEntries;
+ } else {
+ globalThis.getLorebookEntries = originalGetLorebookEntries;
+ }
+ if (originalTestContext === undefined) {
+ delete globalThis.__stBmeTestContext;
+ } else {
+ globalThis.__stBmeTestContext = originalTestContext;
+ }
+}
+
+console.log("extractor-phase5-context-fidelity tests passed");
diff --git a/tests/helpers/generation-recall-harness.mjs b/tests/helpers/generation-recall-harness.mjs
index caa9edf..32d2758 100644
--- a/tests/helpers/generation-recall-harness.mjs
+++ b/tests/helpers/generation-recall-harness.mjs
@@ -248,10 +248,11 @@ export function createGenerationRecallHarness(options = {}) {
};
vm.createContext(context);
vm.runInContext(
- `${snippet}\nresult = { hashRecallInput, buildPreGenerationRecallKey, buildGenerationAfterCommandsRecallInput, buildNormalGenerationRecallInput, cleanupGenerationRecallTransactions, buildGenerationRecallTransactionId, beginGenerationRecallTransaction, markGenerationRecallTransactionHookState, shouldRunRecallForTransaction, createGenerationRecallContext, onGenerationStarted, onGenerationEnded, onGenerationAfterCommands, onBeforeCombinePrompts, applyFinalRecallInjectionForGeneration, ensurePersistedRecallRecordForGeneration, findRecentGenerationRecallTransactionForChat, getGenerationRecallTransactionResult, generationRecallTransactions, freezeHostGenerationInputSnapshot, consumeHostGenerationInputSnapshot, getPendingHostGenerationInputSnapshot, clearPendingHostGenerationInputSnapshot, recordRecallSendIntent, clearPendingRecallSendIntent, recordRecallSentUserMessage, getPendingRecallSendIntent: () => pendingRecallSendIntent, getLastRecallSentUserMessage: () => lastRecallSentUserMessage, getCurrentGenerationTrivialSkip, markCurrentGenerationTrivialSkip, clearCurrentGenerationTrivialSkip, consumeCurrentGenerationTrivialSkip, deferAutoExtraction, maybeResumePendingAutoExtraction, clearPendingAutoExtraction, getPendingAutoExtraction: () => ({ ...pendingAutoExtraction }), getIsHostGenerationRunning: () => isHostGenerationRunning, runPlannerRecallForEna, getGraphPersistenceState: () => graphPersistenceState, setGraphPersistenceState: (value = {}) => { graphPersistenceState = { ...graphPersistenceState, ...(value || {}) }; return graphPersistenceState; } };`,
+ `${snippet}\nresult = { hashRecallInput, buildPreGenerationRecallKey, buildGenerationAfterCommandsRecallInput, buildNormalGenerationRecallInput, cleanupGenerationRecallTransactions, buildGenerationRecallTransactionId, beginGenerationRecallTransaction, markGenerationRecallTransactionHookState, shouldRunRecallForTransaction, createGenerationRecallContext, onGenerationStarted, onGenerationEnded, onGenerationAfterCommands, onBeforeCombinePrompts, applyFinalRecallInjectionForGeneration, ensurePersistedRecallRecordForGeneration, findRecentGenerationRecallTransactionForChat, getGenerationRecallTransactionResult, generationRecallTransactions, freezeHostGenerationInputSnapshot, consumeHostGenerationInputSnapshot, getPendingHostGenerationInputSnapshot, clearPendingHostGenerationInputSnapshot, recordRecallSendIntent, clearPendingRecallSendIntent, recordRecallSentUserMessage, getPendingRecallSendIntent: () => pendingRecallSendIntent, getLastRecallSentUserMessage: () => lastRecallSentUserMessage, getCurrentGenerationTrivialSkip, markCurrentGenerationTrivialSkip, clearCurrentGenerationTrivialSkip, consumeCurrentGenerationTrivialSkip, deferAutoExtraction, maybeResumePendingAutoExtraction, clearPendingAutoExtraction, getPendingAutoExtraction: () => ({ ...pendingAutoExtraction }), getIsHostGenerationRunning: () => isHostGenerationRunning, preparePlannerRecallHandoff, runPlannerRecallForEna, getGraphPersistenceState: () => graphPersistenceState, setGraphPersistenceState: (value = {}) => { graphPersistenceState = { ...graphPersistenceState, ...(value || {}) }; return graphPersistenceState; } };`,
context,
{ filename: indexPath },
);
+
Object.defineProperties(context, {
pendingRecallSendIntent: {
get() {
diff --git a/tests/recall-authoritative-generation-input.mjs b/tests/recall-authoritative-generation-input.mjs
index 912d49f..47d2610 100644
--- a/tests/recall-authoritative-generation-input.mjs
+++ b/tests/recall-authoritative-generation-input.mjs
@@ -41,6 +41,120 @@ async function testSendIntentCanRemainAuthoritativeQueryWhenFlagEnabled() {
assert.equal(transaction.frozenRecallOptions.includeSyntheticUserMessage, true);
}
+async function testPlannerHandoffCanRemainAuthoritativeQueryWhenFlagEnabled() {
+ const harness = await createGenerationRecallHarness();
+ harness.extension_settings[MODULE_NAME] = {
+ recallUseAuthoritativeGenerationInput: true,
+ };
+ harness.chat = [{ is_user: true, mes: "楼层里的稳定用户输入" }];
+
+ const handoff = harness.result.preparePlannerRecallHandoff({
+ rawUserInput: "planner 原始输入",
+ plannerAugmentedMessage: "planner 增强后的输入",
+ plannerRecall: {
+ memoryBlock: "规划记忆块",
+ recentMessages: ["[user]: planner 原始输入", "[assistant]: 记忆命中"],
+ result: {
+ selectedNodeIds: ["node-planner-1"],
+ stats: {
+ coreCount: 1,
+ recallCount: 1,
+ },
+ meta: {
+ retrieval: {
+ vectorHits: 1,
+ vectorMergedHits: 0,
+ diffusionHits: 0,
+ candidatePoolAfterDpp: 1,
+ llm: {
+ status: "disabled",
+ candidatePool: 0,
+ },
+ },
+ },
+ },
+ },
+ chatId: "chat-main",
+ });
+
+ assert.ok(handoff);
+
+ const recallContext = harness.result.createGenerationRecallContext({
+ hookName: "GENERATION_AFTER_COMMANDS",
+ generationType: "normal",
+ recallOptions: {},
+ chatId: "chat-main",
+ });
+
+ assert.equal(recallContext.shouldRun, true);
+ assert.equal(recallContext.recallOptions.overrideUserMessage, "planner 原始输入");
+ assert.equal(recallContext.recallOptions.overrideSource, "planner-handoff");
+ assert.equal(recallContext.recallOptions.authoritativeInputUsed, true);
+ assert.equal(
+ recallContext.recallOptions.boundUserFloorText,
+ "楼层里的稳定用户输入",
+ );
+ assert.equal(recallContext.recallOptions.includeSyntheticUserMessage, true);
+ assert.ok(recallContext.recallOptions.cachedRecallPayload);
+ assert.equal(
+ recallContext.recallOptions.cachedRecallPayload.source,
+ "planner-handoff",
+ );
+
+ await harness.result.onGenerationAfterCommands("normal", {}, false);
+
+ assert.equal(harness.runRecallCalls.length, 1);
+ assert.equal(harness.runRecallCalls[0].overrideUserMessage, "planner 原始输入");
+ assert.equal(harness.runRecallCalls[0].overrideSource, "planner-handoff");
+ assert.equal(harness.runRecallCalls[0].authoritativeInputUsed, true);
+ assert.equal(
+ harness.runRecallCalls[0].boundUserFloorText,
+ "楼层里的稳定用户输入",
+ );
+ assert.equal(harness.runRecallCalls[0].includeSyntheticUserMessage, true);
+ assert.ok(harness.runRecallCalls[0].cachedRecallPayload);
+}
+
+async function testAuthoritativeSendIntentStaysFrozenAcrossHooksWhenFlagEnabled() {
+ const harness = await createGenerationRecallHarness();
+ harness.extension_settings[MODULE_NAME] = {
+ recallUseAuthoritativeGenerationInput: true,
+ };
+ harness.chat = [{ is_user: true, mes: "稳定 chat tail" }];
+ harness.pendingRecallSendIntent = {
+ text: "第一次权威输入",
+ hash: "hash-phase4-frozen-a",
+ at: Date.now(),
+ source: "dom-intent",
+ };
+
+ await harness.result.onGenerationAfterCommands("normal", {}, false);
+
+ harness.pendingRecallSendIntent = {
+ text: "第二次漂移输入",
+ hash: "hash-phase4-frozen-b",
+ at: Date.now(),
+ source: "dom-intent",
+ };
+ await harness.result.onBeforeCombinePrompts();
+
+ assert.equal(harness.runRecallCalls.length, 1);
+ assert.equal(harness.runRecallCalls[0].overrideUserMessage, "第一次权威输入");
+ assert.equal(harness.runRecallCalls[0].overrideSource, "send-intent");
+ assert.equal(harness.runRecallCalls[0].authoritativeInputUsed, true);
+ assert.equal(harness.runRecallCalls[0].boundUserFloorText, "稳定 chat tail");
+
+ const transaction = [...harness.result.generationRecallTransactions.values()][0];
+ assert.ok(transaction);
+ assert.equal(
+ transaction.frozenRecallOptions.overrideUserMessage,
+ "第一次权威输入",
+ );
+ assert.equal(transaction.frozenRecallOptions.authoritativeInputUsed, true);
+ assert.equal(transaction.frozenRecallOptions.boundUserFloorText, "稳定 chat tail");
+ assert.equal(transaction.frozenRecallOptions.includeSyntheticUserMessage, true);
+}
+
async function testHostSnapshotCanRemainAuthoritativeQueryWhenFlagEnabled() {
const harness = await createGenerationRecallHarness();
harness.extension_settings[MODULE_NAME] = {
@@ -123,6 +237,8 @@ function testResolveRecallInputControllerAppendsSyntheticAuthoritativeUserMessag
}
await testSendIntentCanRemainAuthoritativeQueryWhenFlagEnabled();
+await testPlannerHandoffCanRemainAuthoritativeQueryWhenFlagEnabled();
+await testAuthoritativeSendIntentStaysFrozenAcrossHooksWhenFlagEnabled();
await testHostSnapshotCanRemainAuthoritativeQueryWhenFlagEnabled();
testResolveRecallInputControllerAppendsSyntheticAuthoritativeUserMessage();
diff --git a/tests/task-profile-migration.mjs b/tests/task-profile-migration.mjs
index 45af146..4bcf034 100644
--- a/tests/task-profile-migration.mjs
+++ b/tests/task-profile-migration.mjs
@@ -34,7 +34,7 @@ const extractProfile = getActiveTaskProfile(
assert.equal(extractProfile.taskType, "extract");
assert.equal(extractProfile.id, "default");
assert.ok(Array.isArray(extractProfile.blocks));
-assert.equal(extractProfile.blocks.length, 12);
+assert.equal(extractProfile.blocks.length, 14);
assert.deepEqual(
extractProfile.blocks.map((block) => block.name),
[
@@ -48,6 +48,8 @@ assert.deepEqual(
"图统计",
"Schema",
"当前范围",
+ "活跃总结",
+ "故事时间",
"输出格式",
"行为规则",
],
@@ -65,6 +67,8 @@ assert.deepEqual(
"builtin",
"builtin",
"builtin",
+ "builtin",
+ "builtin",
"custom",
"custom",
],
@@ -82,6 +86,8 @@ assert.deepEqual(
"system",
"system",
"system",
+ "system",
+ "system",
"user",
"user",
],
@@ -214,16 +220,16 @@ const upgradedLegacyDefault = getActiveTaskProfile(
},
"extract",
);
-assert.equal(upgradedLegacyDefault.blocks.length, 12);
+assert.equal(upgradedLegacyDefault.blocks.length, 14);
assert.equal(upgradedLegacyDefault.blocks[0].name, "抬头");
assert.match(upgradedLegacyDefault.blocks[0].content, /虚拟的世界/);
assert.equal(upgradedLegacyDefault.blocks[0].role, "system");
assert.equal(upgradedLegacyDefault.blocks[0].injectionMode, "relative");
assert.equal(upgradedLegacyDefault.blocks[1].content, "保留我自己的角色定义");
-assert.equal(upgradedLegacyDefault.blocks[10].content, "保留我自己的输出格式");
-assert.equal(upgradedLegacyDefault.blocks[11].content, "保留我自己的行为规则");
-assert.equal(upgradedLegacyDefault.blocks[10].role, "user");
-assert.equal(upgradedLegacyDefault.blocks[11].role, "user");
+assert.equal(upgradedLegacyDefault.blocks[12].content, "保留我自己的输出格式");
+assert.equal(upgradedLegacyDefault.blocks[13].content, "保留我自己的行为规则");
+assert.equal(upgradedLegacyDefault.blocks[12].role, "user");
+assert.equal(upgradedLegacyDefault.blocks[13].role, "user");
const currentDefaults = createDefaultTaskProfiles();
const currentDefaultExtract = currentDefaults.extract.profiles[0];
@@ -389,7 +395,7 @@ assert.deepEqual(
);
assert.ok(
upgradedLegacyDefault.blocks
- .slice(0, 10)
+ .slice(0, 12)
.every((block) => block.role === "system"),
);
diff --git a/tests/task-profile-storage.mjs b/tests/task-profile-storage.mjs
index b392f3e..1f6c5e8 100644
--- a/tests/task-profile-storage.mjs
+++ b/tests/task-profile-storage.mjs
@@ -53,7 +53,7 @@ const activeProfile = getActiveTaskProfile(
"extract",
);
assert.equal(activeProfile.name, "激进提取");
-assert.equal(activeProfile.blocks.length, 14);
+assert.equal(activeProfile.blocks.length, 16);
const builtinBlock = activeProfile.blocks.find(
(block) => block.type === "builtin" && block.sourceKey === "userMessage",
);
diff --git a/tests/task-worldinfo.mjs b/tests/task-worldinfo.mjs
index 5174fc2..c408a29 100644
--- a/tests/task-worldinfo.mjs
+++ b/tests/task-worldinfo.mjs
@@ -930,7 +930,7 @@ try {
assert.deepEqual(
depthAwarePromptBuild.executionMessages.map((message) => message.content),
[
- "#1 [assistant]: 这是 d4 atDepth 消息。\n\n#2 [assistant]: 这是一条 atDepth 消息。\n\n#11 [user]: 第一句\n\n#4 [assistant]: 这是 d1 atDepth 消息。\n\n#12 [assistant]: 第二句",
+ "#1 [assistant|深度注入 D4]: 这是 d4 atDepth 消息。\n\n#2 [assistant|深度注入]: 这是一条 atDepth 消息。\n\n#11 [user]: 第一句\n\n#4 [assistant|深度注入 D1]: 这是 d1 atDepth 消息。\n\n#12 [assistant]: 第二句",
"用户问题:继续调查 depth 排序",
],
);
diff --git a/ui/panel.html b/ui/panel.html
index 2121291..bbe5777 100644
--- a/ui/panel.html
+++ b/ui/panel.html
@@ -1513,6 +1513,63 @@
开启后,最新 AI 楼先不自动提取,要等下一条 AI 楼出现后,才提取前一批内容。提取未处理和范围重提不受影响。
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -1545,6 +1602,19 @@
max="9999"
/>
+
+
+ 开启后,召回查询将优先使用更接近真实发送入口的文本(如 send-intent、宿主快照、planner handoff),而非回退到 chat tail 或 textarea。
+
diff --git a/ui/panel.js b/ui/panel.js
index 497420f..b2db996 100644
--- a/ui/panel.js
+++ b/ui/panel.js
@@ -4377,6 +4377,26 @@ function _refreshConfigTab() {
"bme-setting-extract-auto-delay-latest-assistant",
settings.extractAutoDelayLatestAssistant === true,
);
+ _setInputValue(
+ "bme-setting-extract-recent-message-cap",
+ settings.extractRecentMessageCap ?? 0,
+ );
+ _setInputValue(
+ "bme-setting-extract-prompt-structured-mode",
+ settings.extractPromptStructuredMode || "both",
+ );
+ _setInputValue(
+ "bme-setting-extract-worldbook-mode",
+ settings.extractWorldbookMode || "active",
+ );
+ _setCheckboxValue(
+ "bme-setting-extract-include-summaries",
+ settings.extractIncludeSummaries !== false,
+ );
+ _setCheckboxValue(
+ "bme-setting-extract-include-story-time",
+ settings.extractIncludeStoryTime !== false,
+ );
_setInputValue("bme-setting-recall-top-k", settings.recallTopK ?? 20);
_setInputValue("bme-setting-recall-max-nodes", settings.recallMaxNodes ?? 8);
_setInputValue(
@@ -4472,6 +4492,10 @@ function _refreshConfigTab() {
settings.recallObjectiveGlobalWeight ?? 0.75,
);
_setInputValue("bme-setting-inject-depth", settings.injectDepth ?? 9999);
+ _setCheckboxValue(
+ "bme-setting-recall-use-authoritative-generation-input",
+ settings.recallUseAuthoritativeGenerationInput === true,
+ );
_setInputValue("bme-setting-graph-weight", settings.graphWeight ?? 0.6);
_setInputValue("bme-setting-vector-weight", settings.vectorWeight ?? 0.3);
_setInputValue(
@@ -4805,6 +4829,35 @@ function _bindConfigControls() {
(checked) =>
_patchSettings({ extractAutoDelayLatestAssistant: checked }),
);
+ bindNumber("bme-setting-extract-recent-message-cap", 0, 0, 200, (value) =>
+ _patchSettings({ extractRecentMessageCap: value }),
+ );
+ const extractStructuredModeEl = document.getElementById(
+ "bme-setting-extract-prompt-structured-mode",
+ );
+ if (extractStructuredModeEl && extractStructuredModeEl.dataset.bmeBound !== "true") {
+ extractStructuredModeEl.addEventListener("change", () => {
+ _patchSettings({ extractPromptStructuredMode: extractStructuredModeEl.value || "both" });
+ });
+ extractStructuredModeEl.dataset.bmeBound = "true";
+ }
+ const extractWorldbookModeEl = document.getElementById(
+ "bme-setting-extract-worldbook-mode",
+ );
+ if (extractWorldbookModeEl && extractWorldbookModeEl.dataset.bmeBound !== "true") {
+ extractWorldbookModeEl.addEventListener("change", () => {
+ _patchSettings({ extractWorldbookMode: extractWorldbookModeEl.value || "active" });
+ });
+ extractWorldbookModeEl.dataset.bmeBound = "true";
+ }
+ bindCheckbox(
+ "bme-setting-extract-include-summaries",
+ (checked) => _patchSettings({ extractIncludeSummaries: checked }),
+ );
+ bindCheckbox(
+ "bme-setting-extract-include-story-time",
+ (checked) => _patchSettings({ extractIncludeStoryTime: checked }),
+ );
bindNumber("bme-setting-recall-top-k", 20, 1, 100, (value) =>
_patchSettings({ recallTopK: value }),
);
@@ -4927,6 +4980,11 @@ function _bindConfigControls() {
bindNumber("bme-setting-inject-depth", 9999, 0, 9999, (value) =>
_patchSettings({ injectDepth: value }),
);
+ bindCheckbox(
+ "bme-setting-recall-use-authoritative-generation-input",
+ (checked) =>
+ _patchSettings({ recallUseAuthoritativeGenerationInput: checked }),
+ );
bindFloat("bme-setting-graph-weight", 0.6, 0, 1, (value) =>
_patchSettings({ graphWeight: value }),
);
From d6fdac07ffe752936065138a12e5c31b4c46688b Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Sat, 11 Apr 2026 08:39:58 +0000
Subject: [PATCH 08/20] chore: bump manifest version [skip ci]
---
manifest.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/manifest.json b/manifest.json
index 9f08c68..514628b 100644
--- a/manifest.json
+++ b/manifest.json
@@ -6,6 +6,6 @@
"js": "index.js",
"css": "style.css",
"author": "Youzini",
- "version": "4.5.3",
+ "version": "4.5.4",
"homePage": "https://github.com/Youzini-afk/ST-Bionic-Memory-Ecology"
}
From 2e57ffce04783d853707c2691bb7c7977f101a4e Mon Sep 17 00:00:00 2001
From: Youzini-afk <13153778771cx@gmail.com>
Date: Sat, 11 Apr 2026 16:47:06 +0800
Subject: [PATCH 09/20] refactor: move extract tuning controls into advanced
section
---
ui/panel.html | 74 ++++++++++++++++++++++++++++++++++-----------------
1 file changed, 49 insertions(+), 25 deletions(-)
diff --git a/ui/panel.html b/ui/panel.html
index bbe5777..3e228f0 100644
--- a/ui/panel.html
+++ b/ui/panel.html
@@ -1513,31 +1513,55 @@
开启后,最新 AI 楼先不自动提取,要等下一条 AI 楼出现后,才提取前一批内容。提取未处理和范围重提不受影响。
-
-
-
-
-
-
-
-
+
Date: Sat, 11 Apr 2026 08:47:24 +0000
Subject: [PATCH 10/20] chore: bump manifest version [skip ci]
---
manifest.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/manifest.json b/manifest.json
index 514628b..da594b5 100644
--- a/manifest.json
+++ b/manifest.json
@@ -6,6 +6,6 @@
"js": "index.js",
"css": "style.css",
"author": "Youzini",
- "version": "4.5.4",
+ "version": "4.5.5",
"homePage": "https://github.com/Youzini-afk/ST-Bionic-Memory-Ecology"
}
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 11/20] 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 hidden text",
);
+ assert.equal(systemOnlyPayload.userPrompt, "fallback text");
+ assert.equal(systemOnlyPayload.fallbackUserPromptSource, "fallback-user-prompt");
+
+ const additionalUserOnlyPayload = buildTaskLlmPayload(
+ {
+ debug: {
+ taskType: "recall",
+ },
+ systemPrompt: "",
+ executionMessages: [],
+ privateTaskMessages: [
+ {
+ role: "user",
+ content: "来自 additionalMessages 的结构化用户块",
+ source: "profile-block",
+ },
+ ],
+ },
+ "unused fallback user prompt",
+ );
+ assert.equal(additionalUserOnlyPayload.userPrompt, "");
assert.equal(
- systemOnlyPayload.userPrompt,
- "fallback hidden text",
+ additionalUserOnlyPayload.fallbackUserPromptSource,
+ "additional-messages",
+ );
+ assert.deepEqual(
+ additionalUserOnlyPayload.additionalMessages.map((message) => ({
+ role: message.role,
+ content: message.content,
+ })),
+ [
+ {
+ role: "user",
+ content: "来自 additionalMessages 的结构化用户块",
+ },
+ ],
);
const rawWorldInfoEntries = [
@@ -465,6 +498,10 @@ try {
assert.equal(payload.systemPrompt, "");
assert.match(JSON.stringify(payload.promptMessages), /FINAL_BAD/);
assert.doesNotMatch(JSON.stringify(payload.promptMessages), /FINAL_GOOD/);
+ assert.equal(
+ payload.promptMessages.some((message) => String(message?.regexSourceType || "").trim()),
+ true,
+ );
const result = await llm.callLLMForJSON({
systemPrompt: payload.systemPrompt,
userPrompt: payload.userPrompt,
@@ -492,6 +529,22 @@ try {
assert.ok(runtimePromptBuild);
assert.ok(runtimeLlmRequest);
assert.match(JSON.stringify(runtimeLlmRequest.messages), /FINAL_GOOD/);
+ assert.equal(
+ runtimeLlmRequest.messages.some((message) =>
+ String(message?.regexSourceType || "").trim(),
+ ),
+ true,
+ );
+ assert.equal(
+ runtimeLlmRequest.transportMessages.some((message) =>
+ Object.prototype.hasOwnProperty.call(message || {}, "regexSourceType"),
+ ),
+ false,
+ );
+ assert.doesNotMatch(
+ JSON.stringify(capturedBodies[0].messages),
+ /regexSourceType|sourceKey|blockId|contentOrigin|speaker/i,
+ );
assert.equal(runtimeLlmRequest.requestCleaning?.applied, true);
assert.equal(
runtimeLlmRequest.requestCleaning?.stages?.length > 0,
@@ -516,7 +569,7 @@ try {
/status_current_variable|updatevariable|StatusPlaceHolderImpl|stat_data|display_data|delta_data|get_message_variable/i,
);
assert.deepEqual(
- runtimeLlmRequest.messages,
+ runtimeLlmRequest.transportMessages,
runtimeLlmRequest.requestBody.messages,
);
assert.equal(
diff --git a/tests/recall-authoritative-generation-input.mjs b/tests/recall-authoritative-generation-input.mjs
index 47d2610..c992f3d 100644
--- a/tests/recall-authoritative-generation-input.mjs
+++ b/tests/recall-authoritative-generation-input.mjs
@@ -199,6 +199,29 @@ async function testHostSnapshotCanRemainAuthoritativeQueryWhenFlagEnabled() {
assert.equal(transaction.frozenRecallOptions.includeSyntheticUserMessage, true);
}
+async function testGenerationAfterCommandsWritesBackAuthoritativePromptWhenPreserved() {
+ const harness = await createGenerationRecallHarness();
+ harness.extension_settings[MODULE_NAME] = {
+ recallUseAuthoritativeGenerationInput: true,
+ };
+ harness.chat = [{ is_user: true, mes: "旧的 chat tail" }];
+ harness.pendingRecallSendIntent = {
+ text: "发送前权威输入",
+ hash: "hash-phase4-writeback",
+ at: Date.now(),
+ source: "dom-intent",
+ };
+ const params = {
+ prompt: "旧 prompt",
+ user_input: "旧 user_input",
+ };
+
+ await harness.result.onGenerationAfterCommands("normal", params, false);
+
+ assert.equal(params.prompt, "发送前权威输入");
+ assert.equal(params.user_input, "发送前权威输入");
+}
+
function testResolveRecallInputControllerAppendsSyntheticAuthoritativeUserMessage() {
const runtime = {
normalizeRecallInputText(value = "") {
@@ -240,6 +263,7 @@ await testSendIntentCanRemainAuthoritativeQueryWhenFlagEnabled();
await testPlannerHandoffCanRemainAuthoritativeQueryWhenFlagEnabled();
await testAuthoritativeSendIntentStaysFrozenAcrossHooksWhenFlagEnabled();
await testHostSnapshotCanRemainAuthoritativeQueryWhenFlagEnabled();
+await testGenerationAfterCommandsWritesBackAuthoritativePromptWhenPreserved();
testResolveRecallInputControllerAppendsSyntheticAuthoritativeUserMessage();
console.log("recall-authoritative-generation-input tests passed");
diff --git a/tests/task-regex.mjs b/tests/task-regex.mjs
index cc00b83..fb662b9 100644
--- a/tests/task-regex.mjs
+++ b/tests/task-regex.mjs
@@ -12,6 +12,16 @@ const extensionsShimSource = [
const extensionsShimUrl = `data:text/javascript,${encodeURIComponent(
extensionsShimSource,
)}`;
+const regexEngineShimSource = [
+ "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");
+const regexEngineShimUrl = `data:text/javascript,${encodeURIComponent(
+ regexEngineShimSource,
+)}`;
installResolveHooks([
{
@@ -22,6 +32,10 @@ installResolveHooks([
],
url: extensionsShimUrl,
},
+ {
+ specifiers: ["../../../../regex/engine.js"],
+ url: regexEngineShimUrl,
+ },
]);
const originalSillyTavern = globalThis.SillyTavern;
@@ -29,6 +43,7 @@ const originalGetTavernRegexes = globalThis.getTavernRegexes;
const originalIsCharacterTavernRegexesEnabled =
globalThis.isCharacterTavernRegexesEnabled;
const originalExtensionSettings = globalThis.__taskRegexTestExtensionSettings;
+const originalCoreGetRegexedString = globalThis.__taskRegexTestCoreGetRegexedString;
const PLACEMENT = Object.freeze({
USER_INPUT: 1,
@@ -146,6 +161,14 @@ function setTestContext({
};
}
+function setCoreRegexedStringHandler(handler = null) {
+ if (typeof handler === "function") {
+ globalThis.__taskRegexTestCoreGetRegexedString = handler;
+ return;
+ }
+ delete globalThis.__taskRegexTestCoreGetRegexedString;
+}
+
try {
const { initializeHostAdapter } = await import("../host/adapter/index.js");
const { applyHostRegexReuse, applyTaskRegex, inspectTaskRegexReuse } = await import(
@@ -157,6 +180,8 @@ try {
normalizeTaskProfile,
normalizeTaskRegexStages,
} = await import("../prompting/prompt-profiles.js");
+ const initializeFallbackHostAdapter = () =>
+ initializeHostAdapter({ disableCoreRegexBridge: true });
const normalizedLegacyStages = normalizeTaskRegexStages({
finalPrompt: true,
@@ -245,6 +270,48 @@ try {
true,
);
+ setTestContext({
+ extensionSettings: {
+ regex: [],
+ preset_allowed_regex: {},
+ character_allowed_regex: [],
+ },
+ });
+ const coreFormatterCalls = [];
+ setCoreRegexedStringHandler((text, placement, options) => {
+ coreFormatterCalls.push({ text, placement, options });
+ return String(text || "").replace(/Alpha/g, "CORE");
+ });
+ initializeHostAdapter({});
+ const coreBridgeDebug = { entries: [] };
+ const coreBridgeOutput = applyHostRegexReuse(
+ buildSettings(),
+ "extract",
+ "Alpha Beta",
+ {
+ sourceType: "user_input",
+ role: "user",
+ debugCollector: coreBridgeDebug,
+ },
+ );
+ assert.equal(coreBridgeOutput.text, "CORE Beta");
+ assert.deepEqual(coreFormatterCalls, [
+ {
+ text: "Alpha Beta",
+ placement: 1,
+ options: {
+ isPrompt: true,
+ isMarkdown: false,
+ },
+ },
+ ]);
+ assert.equal(coreBridgeDebug.entries[0].executionMode, "host-real");
+ assert.equal(
+ inspectTaskRegexReuse(buildSettings(), "extract").host.bridgeTier,
+ "core-real",
+ );
+ setCoreRegexedStringHandler(null);
+
globalThis.getTavernRegexes = () => {
throw new Error("legacy global getter should not be used in regex tests");
};
@@ -333,11 +400,15 @@ try {
},
},
]);
- assert.equal(fullBridgeDebug.entries[0].executionMode, "host-real");
+ assert.equal(fullBridgeDebug.entries[0].executionMode, "host-helper");
assert.deepEqual(
fullBridgeDebug.entries[0].appliedRules.map((item) => item.id),
["__host_formatter__"],
);
+ assert.equal(
+ inspectTaskRegexReuse(fullBridgeSettings, "extract").host.bridgeTier,
+ "helper-bridge",
+ );
assert.equal(
applyTaskRegex(
fullBridgeSettings,
@@ -383,7 +454,7 @@ try {
},
],
});
- initializeHostAdapter({});
+ initializeFallbackHostAdapter();
const fallbackDebug = { entries: [] };
const fallbackOutput = applyHostRegexReuse(
@@ -412,7 +483,7 @@ try {
character_allowed_regex: [],
},
});
- initializeHostAdapter({});
+ initializeFallbackHostAdapter();
const depthMissResult = applyHostRegexReuse(
buildSettings({
sources: {
@@ -476,7 +547,7 @@ try {
},
],
});
- initializeHostAdapter({});
+ initializeFallbackHostAdapter();
const fallbackInspect = inspectTaskRegexReuse(buildSettings(), "extract");
assert.equal(fallbackInspect.activeRuleCount, 3);
assert.deepEqual(
@@ -525,7 +596,7 @@ try {
},
],
});
- initializeHostAdapter({});
+ initializeFallbackHostAdapter();
const disallowedOutput = applyHostRegexReuse(
buildSettings(),
@@ -587,7 +658,7 @@ try {
character_allowed_regex: [],
},
});
- initializeHostAdapter({});
+ initializeFallbackHostAdapter();
const userReuseResult = applyHostRegexReuse(
tavernSemanticsSettings,
@@ -641,7 +712,7 @@ try {
character_allowed_regex: [],
},
});
- initializeHostAdapter({});
+ initializeFallbackHostAdapter();
const markdownFinalDebug = { entries: [] };
const markdownFallbackResult = applyHostRegexReuse(
markdownOnlyFinalPromptSettings,
@@ -675,7 +746,7 @@ try {
character_allowed_regex: [],
},
});
- initializeHostAdapter({});
+ initializeFallbackHostAdapter();
const beautifyFinalInspect = inspectTaskRegexReuse(
beautifyFinalPromptSettings,
"extract",
@@ -759,7 +830,7 @@ try {
character_allowed_regex: [],
},
});
- initializeHostAdapter({});
+ initializeFallbackHostAdapter();
const destinationDebug = { entries: [] };
const destinationReuseResult = applyHostRegexReuse(
destinationBeautifySettings,
@@ -817,7 +888,7 @@ try {
character_allowed_regex: [],
},
});
- initializeHostAdapter({});
+ initializeFallbackHostAdapter();
const mixedReuseResult = applyHostRegexReuse(
tavernSemanticsSettings,
"extract",
@@ -889,6 +960,12 @@ try {
globalThis.__taskRegexTestExtensionSettings = originalExtensionSettings;
}
+ if (originalCoreGetRegexedString === undefined) {
+ delete globalThis.__taskRegexTestCoreGetRegexedString;
+ } else {
+ globalThis.__taskRegexTestCoreGetRegexedString = originalCoreGetRegexedString;
+ }
+
try {
const { initializeHostAdapter } = await import("../host/adapter/index.js");
initializeHostAdapter({});
diff --git a/ui/panel.js b/ui/panel.js
index b2db996..9ab57e4 100644
--- a/ui/panel.js
+++ b/ui/panel.js
@@ -7349,6 +7349,11 @@ function _renderRegexReuseBadges(rule = {}) {
className: "is-transform",
text: "宿主真实执行",
});
+ } else if (rule.promptStageMode === "host-helper") {
+ badges.push({
+ className: "is-prompt",
+ text: "Helper 兼容执行",
+ });
} else if (rule.promptStageMode === "host-fallback") {
badges.push({
className: "is-prompt",
@@ -7514,7 +7519,7 @@ function _buildRegexReusePopupContent(snapshot = {}) {
桥接模式
- ${_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" : ""}
+ ${_escHtml(snapshot.host?.sourceLabel || "unknown")} · ${_escHtml(snapshot.host?.executionMode || snapshot.host?.capabilityStatus?.mode || snapshot.host?.mode || "unknown")}${snapshot.host?.bridgeTier ? ` · ${_escHtml(snapshot.host.bridgeTier)}` : ""}${snapshot.host?.formatterAvailable ? " · formatter" : ""}${snapshot.host?.fallback ? " · fallback" : ""}
diff --git a/ui/recall-message-ui.js b/ui/recall-message-ui.js
index 35f0e3f..4c101e1 100644
--- a/ui/recall-message-ui.js
+++ b/ui/recall-message-ui.js
@@ -82,6 +82,7 @@ function formatTokenHint(tokenEstimate) {
function formatMetaLine(record) {
const parts = [];
if (record.recallSource) parts.push(`来源: ${record.recallSource}`);
+ if (record.authoritativeInputUsed) parts.push("权威输入");
if (record.tokenEstimate > 0) parts.push(`~${record.tokenEstimate} tokens`);
if (Number.isFinite(record.generationCount) && record.generationCount > 0) {
parts.push(`回退 ${record.generationCount} 次`);
@@ -180,6 +181,8 @@ function buildExpandedRenderSignature({
return stableSerialize({
updatedAt: String(record?.updatedAt || ""),
manuallyEdited: Boolean(record?.manuallyEdited),
+ authoritativeInputUsed: Boolean(record?.authoritativeInputUsed),
+ boundUserFloorText: String(record?.boundUserFloorText || ""),
generationCount: Number.isFinite(record?.generationCount)
? record.generationCount
: 0,
From 78a451dfe495ba4c2d298f3da23c408b4cf70edf Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Sat, 11 Apr 2026 10:52:21 +0000
Subject: [PATCH 12/20] chore: bump manifest version [skip ci]
---
manifest.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/manifest.json b/manifest.json
index da594b5..0fb3fbf 100644
--- a/manifest.json
+++ b/manifest.json
@@ -6,6 +6,6 @@
"js": "index.js",
"css": "style.css",
"author": "Youzini",
- "version": "4.5.5",
+ "version": "4.5.6",
"homePage": "https://github.com/Youzini-afk/ST-Bionic-Memory-Ecology"
}
From 30046dd66bb3c18a91b103b2973662d04320829a Mon Sep 17 00:00:00 2001
From: Youzini-afk <13153778771cx@gmail.com>
Date: Sat, 11 Apr 2026 19:38:17 +0800
Subject: [PATCH 13/20] feat(regex): move contamination cleanup to default
global preset
---
prompting/prompt-profiles.js | 122 +++++++++++++++++++++++++++++-
tests/default-settings.mjs | 11 +++
tests/prompt-builder-defaults.mjs | 27 ++++++-
tests/task-profile-migration.mjs | 11 ++-
tests/task-regex.mjs | 88 +++++++++++++++++++++
5 files changed, 254 insertions(+), 5 deletions(-)
diff --git a/prompting/prompt-profiles.js b/prompting/prompt-profiles.js
index a19e2ed..0162ef8 100644
--- a/prompting/prompt-profiles.js
+++ b/prompting/prompt-profiles.js
@@ -896,6 +896,119 @@ const DEFAULT_TASK_REGEX_STAGES = Object.freeze({
output: false,
});
+ const DEFAULT_GLOBAL_TASK_REGEX_RULE_SPECS = Object.freeze([
+ {
+ id: "default-contamination-thinking-blocks",
+ script_name: "默认清理:thinking/analysis/reasoning",
+ enabled: true,
+ find_regex: "/<(think|thinking|analysis|reasoning)\\b[^>]*>[\\s\\S]*?<\\/\\1>/gi",
+ replace_string: "",
+ trim_strings: "",
+ source: {
+ user_input: true,
+ ai_output: true,
+ },
+ destination: {
+ prompt: true,
+ display: false,
+ },
+ min_depth: 0,
+ max_depth: 9999,
+ },
+ {
+ id: "default-contamination-choice-blocks",
+ script_name: "默认清理:choice",
+ enabled: true,
+ find_regex: "/(?:]*>[\\s\\S]*?<\\/choice>|]*\\/?>)/gi",
+ replace_string: "",
+ trim_strings: "",
+ source: {
+ user_input: true,
+ ai_output: true,
+ },
+ destination: {
+ prompt: true,
+ display: false,
+ },
+ min_depth: 0,
+ max_depth: 9999,
+ },
+ {
+ id: "default-contamination-updatevariable-tags",
+ script_name: "默认清理:UpdateVariable",
+ enabled: true,
+ find_regex:
+ "/(?:]*>[\\s\\S]*?<\\/updatevariable>|]*\\/?>)/gi",
+ replace_string: "",
+ trim_strings: "",
+ source: {
+ user_input: true,
+ ai_output: true,
+ },
+ destination: {
+ prompt: true,
+ display: false,
+ },
+ min_depth: 0,
+ max_depth: 9999,
+ },
+ {
+ id: "default-contamination-status-current-variable-tags",
+ script_name: "默认清理:status_current_variable",
+ enabled: true,
+ find_regex:
+ "/(?:]*>[\\s\\S]*?<\\/status_current_variable>|]*\\/?>)/gi",
+ replace_string: "",
+ trim_strings: "",
+ source: {
+ user_input: true,
+ ai_output: true,
+ },
+ destination: {
+ prompt: true,
+ display: false,
+ },
+ min_depth: 0,
+ max_depth: 9999,
+ },
+ {
+ id: "default-contamination-status-placeholder-tags",
+ script_name: "默认清理:StatusPlaceHolderImpl",
+ enabled: true,
+ find_regex: "/]*\\/?>/gi",
+ replace_string: "",
+ trim_strings: "",
+ source: {
+ user_input: true,
+ ai_output: true,
+ },
+ destination: {
+ prompt: true,
+ display: false,
+ },
+ min_depth: 0,
+ max_depth: 9999,
+ },
+ ]);
+
+ function cloneDefaultGlobalTaskRegexRules() {
+ return DEFAULT_GLOBAL_TASK_REGEX_RULE_SPECS.map((rule, index) =>
+ normalizeRegexLocalRule(
+ {
+ ...rule,
+ source: {
+ ...(rule.source || {}),
+ },
+ destination: {
+ ...(rule.destination || {}),
+ },
+ },
+ "global",
+ index,
+ ),
+ );
+ }
+
function normalizeRegexStageKey(stageKey = "") {
const normalized = String(stageKey || "").trim();
return TASK_REGEX_STAGE_ALIAS_MAP[normalized] || normalized;
@@ -939,7 +1052,7 @@ export function createDefaultGlobalTaskRegex() {
character: true,
},
stages: normalizeTaskRegexStages(DEFAULT_TASK_REGEX_STAGES),
- localRules: [],
+ localRules: cloneDefaultGlobalTaskRegexRules(),
};
}
@@ -978,6 +1091,11 @@ export function normalizeGlobalTaskRegex(config = {}, taskType = "global") {
const defaults = createDefaultGlobalTaskRegex();
const source =
config && typeof config === "object" && !Array.isArray(config) ? config : {};
+ const normalizedTaskType = String(taskType || "").trim().toLowerCase();
+ const defaultLocalRules = normalizedTaskType === "global" ? defaults.localRules : [];
+ const rawLocalRules = Array.isArray(source.localRules)
+ ? source.localRules
+ : defaultLocalRules;
return {
enabled: source.enabled !== false,
@@ -990,7 +1108,7 @@ export function normalizeGlobalTaskRegex(config = {}, taskType = "global") {
...normalizeTaskRegexStages(defaults.stages),
...normalizeTaskRegexStages(source.stages || {}),
},
- localRules: dedupeRegexRules(source.localRules, taskType),
+ localRules: dedupeRegexRules(rawLocalRules, taskType),
};
}
diff --git a/tests/default-settings.mjs b/tests/default-settings.mjs
index 4feaec5..f56a161 100644
--- a/tests/default-settings.mjs
+++ b/tests/default-settings.mjs
@@ -70,6 +70,17 @@ assert.equal(defaultSettings.taskProfilesVersion, 3);
assert.ok(defaultSettings.taskProfiles);
assert.ok(defaultSettings.taskProfiles.extract);
assert.ok(defaultSettings.taskProfiles.recall);
+assert.ok(defaultSettings.globalTaskRegex);
+assert.deepEqual(
+ defaultSettings.globalTaskRegex.localRules.map((rule) => rule.id),
+ [
+ "default-contamination-thinking-blocks",
+ "default-contamination-choice-blocks",
+ "default-contamination-updatevariable-tags",
+ "default-contamination-status-current-variable-tags",
+ "default-contamination-status-placeholder-tags",
+ ],
+);
const migratedSettings = mergePersistedSettings({
maintenanceAutoMinNewNodes: 7,
diff --git a/tests/prompt-builder-defaults.mjs b/tests/prompt-builder-defaults.mjs
index 4e6b923..380ce7c 100644
--- a/tests/prompt-builder-defaults.mjs
+++ b/tests/prompt-builder-defaults.mjs
@@ -46,7 +46,10 @@ installResolveHooks([
]);
const { buildTaskLlmPayload, buildTaskPrompt } = await import("../prompting/prompt-builder.js");
-const { createDefaultTaskProfiles } = await import("../prompting/prompt-profiles.js");
+const {
+ createDefaultGlobalTaskRegex,
+ createDefaultTaskProfiles,
+} = await import("../prompting/prompt-profiles.js");
const { initializeHostAdapter } = await import("../host/adapter/index.js");
const settings = {
@@ -145,6 +148,28 @@ assert.match(String(recallFormatBlock?.content || ""), /selected_keys/);
assert.match(String(recallRulesBlock?.content || ""), /剧情时间/);
assert.match(String(recallRulesBlock?.content || ""), /评分召回/);
+const globalRegexPromptBuild = await buildTaskPrompt(
+ {
+ taskProfilesVersion: 3,
+ taskProfiles: createDefaultTaskProfiles(),
+ globalTaskRegex: createDefaultGlobalTaskRegex(),
+ },
+ "recall",
+ {
+ taskName: "recall",
+ recentMessages:
+ "最近消息 隐藏思维 1. 隐藏选项",
+ userMessage:
+ "用户输入 secret hp=3",
+ candidateNodes:
+ "候选节点 隐藏分析",
+ },
+);
+assert.doesNotMatch(
+ JSON.stringify(globalRegexPromptBuild),
+ / rule.script_name),
- ["隐藏规则"],
+ [
+ "默认清理:thinking/analysis/reasoning",
+ "默认清理:choice",
+ "默认清理:UpdateVariable",
+ "默认清理:status_current_variable",
+ "默认清理:StatusPlaceHolderImpl",
+ "隐藏规则",
+ ],
);
assert.deepEqual(
migratedLegacyRegex.settings.taskProfiles.extract.profiles.find(
diff --git a/tests/task-regex.mjs b/tests/task-regex.mjs
index fb662b9..b455316 100644
--- a/tests/task-regex.mjs
+++ b/tests/task-regex.mjs
@@ -175,6 +175,7 @@ try {
"../prompting/task-regex.js"
);
const {
+ createDefaultGlobalTaskRegex,
createDefaultTaskProfiles,
isTaskRegexStageEnabled,
normalizeTaskProfile,
@@ -933,6 +934,93 @@ try {
["prompt-output"],
);
+ const defaultGlobalRegex = createDefaultGlobalTaskRegex();
+ assert.deepEqual(
+ defaultGlobalRegex.localRules.map((rule) => rule.id),
+ [
+ "default-contamination-thinking-blocks",
+ "default-contamination-choice-blocks",
+ "default-contamination-updatevariable-tags",
+ "default-contamination-status-current-variable-tags",
+ "default-contamination-status-placeholder-tags",
+ ],
+ );
+
+ const globalDefaultDebug = { entries: [] };
+ const globalDefaultResult = applyTaskRegex(
+ {
+ taskProfiles: createDefaultTaskProfiles(),
+ globalTaskRegex: createDefaultGlobalTaskRegex(),
+ },
+ "extract",
+ "input.recentMessages",
+ [
+ "前缀",
+ "内部思维",
+ "1. 选项",
+ "hp=1",
+ "hp=1",
+ "",
+ "尾巴",
+ ].join("\n"),
+ globalDefaultDebug,
+ "system",
+ );
+ assert.match(globalDefaultResult, /前缀/);
+ assert.match(globalDefaultResult, /尾巴/);
+ assert.doesNotMatch(
+ globalDefaultResult,
+ / item.id),
+ [
+ "default-contamination-thinking-blocks",
+ "default-contamination-choice-blocks",
+ "default-contamination-updatevariable-tags",
+ "default-contamination-status-current-variable-tags",
+ "default-contamination-status-placeholder-tags",
+ ],
+ );
+ assert.equal(globalDefaultDebug.entries[0].sourceCount.local, 5);
+
+ const explicitEmptyGlobalDebug = { entries: [] };
+ const explicitEmptyGlobalResult = applyTaskRegex(
+ {
+ taskProfiles: createDefaultTaskProfiles(),
+ globalTaskRegex: {
+ enabled: true,
+ inheritStRegex: false,
+ sources: {
+ global: false,
+ preset: false,
+ character: false,
+ },
+ stages: {
+ "input.userMessage": true,
+ "input.recentMessages": true,
+ "input.candidateText": true,
+ "input.finalPrompt": false,
+ "output.rawResponse": false,
+ "output.beforeParse": false,
+ output: false,
+ },
+ localRules: [],
+ },
+ },
+ "extract",
+ "input.recentMessages",
+ "保留保留",
+ explicitEmptyGlobalDebug,
+ "system",
+ );
+ assert.equal(
+ explicitEmptyGlobalResult,
+ "保留保留",
+ );
+ assert.deepEqual(explicitEmptyGlobalDebug.entries[0].appliedRules, []);
+ assert.equal(explicitEmptyGlobalDebug.entries[0].sourceCount.local, 0);
+
console.log("task-regex tests passed");
} finally {
if (originalSillyTavern === undefined) {
From 5eee25d17a5f4d5125764800a5f5e5cecebdc54f Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Sat, 11 Apr 2026 11:38:29 +0000
Subject: [PATCH 14/20] chore: bump manifest version [skip ci]
---
manifest.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/manifest.json b/manifest.json
index 0fb3fbf..37aa88a 100644
--- a/manifest.json
+++ b/manifest.json
@@ -6,6 +6,6 @@
"js": "index.js",
"css": "style.css",
"author": "Youzini",
- "version": "4.5.6",
+ "version": "4.5.7",
"homePage": "https://github.com/Youzini-afk/ST-Bionic-Memory-Ecology"
}
From 041b2221e892dfd504db6d689d660afcf55aae7e Mon Sep 17 00:00:00 2001
From: Youzini-afk <13153778771cx@gmail.com>
Date: Sat, 11 Apr 2026 19:41:40 +0800
Subject: [PATCH 15/20] ci: skip manifest bump on merged PR pushes
---
.github/workflows/bump-manifest-version.yml | 26 +++++++++++++++++++++
1 file changed, 26 insertions(+)
diff --git a/.github/workflows/bump-manifest-version.yml b/.github/workflows/bump-manifest-version.yml
index d5f5ad2..b2c577d 100644
--- a/.github/workflows/bump-manifest-version.yml
+++ b/.github/workflows/bump-manifest-version.yml
@@ -8,6 +8,7 @@ on:
permissions:
contents: write
+ pull-requests: read
jobs:
bump-manifest-version:
@@ -15,20 +16,45 @@ jobs:
runs-on: ubuntu-latest
steps:
+ - name: Detect merged pull request push
+ id: pr-check
+ uses: actions/github-script@v7
+ with:
+ script: |
+ const { owner, repo } = context.repo;
+ const sha = context.sha;
+ const response = await github.rest.repos.listPullRequestsAssociatedWithCommit({
+ owner,
+ repo,
+ commit_sha: sha,
+ });
+ const mergedPr = (response.data || []).find(
+ (pr) => pr?.merged_at && pr?.base?.ref === context.ref.replace('refs/heads/', ''),
+ );
+ const shouldSkip = Boolean(mergedPr);
+ core.setOutput('skip', shouldSkip ? 'true' : 'false');
+ if (mergedPr) {
+ core.notice(`Skip manifest bump for merged PR #${mergedPr.number}: ${mergedPr.title}`);
+ }
+
- name: Checkout
+ if: ${{ steps.pr-check.outputs.skip != 'true' }}
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Node.js
+ if: ${{ steps.pr-check.outputs.skip != 'true' }}
uses: actions/setup-node@v4
with:
node-version: 20
- name: Bump manifest version
+ if: ${{ steps.pr-check.outputs.skip != 'true' }}
run: node scripts/bump-manifest-version.mjs
- name: Commit version bump
+ if: ${{ steps.pr-check.outputs.skip != 'true' }}
run: |
if git diff --quiet -- manifest.json; then
echo "manifest.json version did not change."
From b4ef61009161e15fe58108a722cc7e74caf30bd2 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Sat, 11 Apr 2026 11:42:17 +0000
Subject: [PATCH 16/20] chore: bump manifest version [skip ci]
---
manifest.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/manifest.json b/manifest.json
index 37aa88a..f20f9bc 100644
--- a/manifest.json
+++ b/manifest.json
@@ -6,6 +6,6 @@
"js": "index.js",
"css": "style.css",
"author": "Youzini",
- "version": "4.5.7",
+ "version": "4.5.8",
"homePage": "https://github.com/Youzini-afk/ST-Bionic-Memory-Ecology"
}
From 42bd85b0aa2c58b105d89c14221230deb3db6217 Mon Sep 17 00:00:00 2001
From: Youzini-afk <13153778771cx@gmail.com>
Date: Sat, 11 Apr 2026 20:01:19 +0800
Subject: [PATCH 17/20] fix(recall): reuse persisted recall on history rerolls
---
index.js | 4 +
retrieval/recall-controller.js | 195 ++++++++++++++++++++++-
tests/p0-regressions.mjs | 276 ++++++++++++++++++++++++++++++++-
3 files changed, 468 insertions(+), 7 deletions(-)
diff --git a/index.js b/index.js
index 966bc4c..4fa56c2 100644
--- a/index.js
+++ b/index.js
@@ -12462,6 +12462,7 @@ async function runRecall(options = {}) {
abortRecallStageWithReason,
applyRecallInjection,
beginStageAbortController,
+ bumpPersistedRecallGenerationCount,
buildRecallRetrieveOptions,
clampInt,
console,
@@ -12485,10 +12486,12 @@ async function runRecall(options = {}) {
isGraphReadable,
isGraphReadableForRecall,
nextRecallRunSequence: () => ++recallRunSequence,
+ readPersistedRecallFromUserMessage,
recoverHistoryIfNeeded,
refreshPanelLiveState,
resolveRecallInput,
retrieve,
+ schedulePersistedRecallMessageUiRefresh,
setActiveRecallPromise: (value) => {
activeRecallPromise = value;
},
@@ -12500,6 +12503,7 @@ async function runRecall(options = {}) {
pendingRecallSendIntent = value;
},
toastr,
+ triggerChatMetadataSave,
waitForActiveRecallToSettle,
},
options,
diff --git a/retrieval/recall-controller.js b/retrieval/recall-controller.js
index ebb1a7d..d2b915a 100644
--- a/retrieval/recall-controller.js
+++ b/retrieval/recall-controller.js
@@ -52,6 +52,102 @@ export function getRecallUserMessageSourceLabelController(source) {
}
}
+function buildPersistedRecallReuseResult(record = {}) {
+ const selectedNodeIds = Array.isArray(record?.selectedNodeIds)
+ ? record.selectedNodeIds
+ .map((item) => String(item || "").trim())
+ .filter(Boolean)
+ : [];
+ return {
+ injectionText: String(record?.injectionText || "").trim(),
+ selectedNodeIds,
+ stats: {
+ coreCount: 0,
+ recallCount: selectedNodeIds.length,
+ },
+ meta: {
+ retrieval: {
+ vectorHits: 0,
+ vectorMergedHits: 0,
+ diffusionHits: 0,
+ candidatePoolAfterDpp: 0,
+ persistedReuse: true,
+ llm: {
+ status: "persisted",
+ reason: "复用已持久化召回",
+ selectionProtocol: "persisted-record-reuse",
+ rawSelectedKeys: [],
+ resolvedSelectedKeys: [],
+ resolvedSelectedNodeIds: selectedNodeIds,
+ fallbackReason: "",
+ fallbackType: "",
+ emptySelectionAccepted: false,
+ candidateKeyMapPreview: {},
+ legacySelectionUsed: false,
+ candidatePool: 0,
+ },
+ },
+ },
+ };
+}
+
+function resolveReusablePersistedRecallRecord(chat, recallInput, runtime) {
+ const generationType = String(recallInput?.generationType || "normal").trim() || "normal";
+ if (generationType === "normal") return null;
+
+ const targetUserMessageIndex = Number.isFinite(recallInput?.targetUserMessageIndex)
+ ? Math.floor(Number(recallInput.targetUserMessageIndex))
+ : null;
+ if (!Number.isFinite(targetUserMessageIndex)) return null;
+
+ const targetMessage = Array.isArray(chat) ? chat[targetUserMessageIndex] : null;
+ if (!targetMessage?.is_user) return null;
+
+ const readPersistedRecallFromUserMessage = runtime.readPersistedRecallFromUserMessage;
+ if (typeof readPersistedRecallFromUserMessage !== "function") return null;
+
+ const record = readPersistedRecallFromUserMessage(chat, targetUserMessageIndex);
+ if (!record?.injectionText) return null;
+
+ const normalizeText = (value = "") =>
+ typeof runtime.normalizeRecallInputText === "function"
+ ? runtime.normalizeRecallInputText(value)
+ : String(value ?? "")
+ .replace(/\r\n/g, "\n")
+ .trim();
+ const currentUserFloorText = normalizeText(targetMessage?.mes || "");
+ const currentRecallInputText = normalizeText(recallInput?.userMessage || "");
+ const recordRecallInput = normalizeText(record?.recallInput || "");
+ const boundUserFloorText = normalizeText(record?.boundUserFloorText || "");
+
+ const matchesBoundUserFloor = Boolean(
+ currentUserFloorText &&
+ boundUserFloorText &&
+ currentUserFloorText === boundUserFloorText,
+ );
+ const matchesRecallInput = Boolean(
+ currentRecallInputText &&
+ recordRecallInput &&
+ currentRecallInputText === recordRecallInput,
+ );
+ const matchesCurrentUserFloor = Boolean(
+ currentUserFloorText &&
+ recordRecallInput &&
+ currentUserFloorText === recordRecallInput,
+ );
+
+ if (record.authoritativeInputUsed) {
+ if (!matchesBoundUserFloor) return null;
+ } else if (!matchesRecallInput && !matchesCurrentUserFloor) {
+ return null;
+ }
+
+ return {
+ record,
+ targetUserMessageIndex,
+ };
+}
+
export function resolveRecallInputController(
chat,
recentContextMessageLimit,
@@ -167,12 +263,15 @@ export function applyRecallInjectionController(
result,
runtime,
) {
- const injectionText = runtime
- .formatInjection(result, runtime.getSchema())
- .trim();
+ const injectionText = String(
+ typeof result?.injectionText === "string"
+ ? result.injectionText
+ : runtime.formatInjection(result, runtime.getSchema()),
+ ).trim();
runtime.setLastInjectionContent(injectionText);
const retrievalMeta = result?.meta?.retrieval || {};
+ const isPersistedReuse = Boolean(retrievalMeta.persistedReuse);
const llmMeta = retrievalMeta.llm || {
status: settings.recallEnableLLM ? "unknown" : "disabled",
reason: settings.recallEnableLLM ? "未提供 LLM 状态" : "LLM 精排已关闭",
@@ -190,7 +289,7 @@ export function applyRecallInjectionController(
const deliveryMode =
String(recallInput?.deliveryMode || "immediate").trim() || "immediate";
- if (injectionText) {
+ if (injectionText && !isPersistedReuse) {
const tokens = runtime.estimateTokens(injectionText);
debugLog(
`[ST-BME] 注入 ${tokens} 估算 tokens, Core=${result.stats.coreCount}, Recall=${result.stats.recallCount}`,
@@ -250,7 +349,9 @@ export function applyRecallInjectionController(
runtime.saveGraphToChat({ reason: "recall-result-updated" });
const llmLabel =
- llmMeta.status === "llm"
+ isPersistedReuse
+ ? "复用召回"
+ : llmMeta.status === "llm"
? "LLM 精排完成"
: llmMeta.status === "fallback"
? "LLM 回退评分"
@@ -495,6 +596,90 @@ export async function runRecallController(runtime, options = {}) {
});
}
+ const persistedReuse = resolveReusablePersistedRecallRecord(
+ chat,
+ recallInput,
+ runtime,
+ );
+ if (persistedReuse) {
+ const normalizedBoundUserFloorText =
+ typeof runtime.normalizeRecallInputText === "function"
+ ? runtime.normalizeRecallInputText(
+ persistedReuse.record.boundUserFloorText ||
+ recallInput.boundUserFloorText ||
+ "",
+ )
+ : String(
+ persistedReuse.record.boundUserFloorText ||
+ recallInput.boundUserFloorText ||
+ "",
+ )
+ .replace(/\r\n/g, "\n")
+ .trim();
+ const effectiveRecallInput = {
+ ...recallInput,
+ source: "persisted-user-floor",
+ sourceLabel: "复用用户楼层召回",
+ reason: "persisted-user-floor-reuse",
+ authoritativeInputUsed: Boolean(
+ persistedReuse.record.authoritativeInputUsed ||
+ recallInput.authoritativeInputUsed,
+ ),
+ boundUserFloorText: normalizedBoundUserFloorText,
+ };
+ const reusedResult = buildPersistedRecallReuseResult(persistedReuse.record);
+ const applied = runtime.applyRecallInjection(
+ settings,
+ effectiveRecallInput,
+ recentMessages,
+ reusedResult,
+ );
+ const bumpedRecord =
+ typeof runtime.bumpPersistedRecallGenerationCount === "function"
+ ? runtime.bumpPersistedRecallGenerationCount(
+ chat,
+ persistedReuse.targetUserMessageIndex,
+ )
+ : null;
+ if (bumpedRecord) {
+ runtime.triggerChatMetadataSave?.(context, { immediate: false });
+ runtime.schedulePersistedRecallMessageUiRefresh?.();
+ }
+ return runtime.createRecallRunResult("completed", {
+ reason: "persisted-user-floor-reused",
+ selectedNodeIds: reusedResult.selectedNodeIds || [],
+ injectionText: applied?.injectionText || reusedResult.injectionText || "",
+ retrievalMeta: applied?.retrievalMeta || reusedResult.meta?.retrieval || {},
+ llmMeta:
+ applied?.llmMeta || reusedResult.meta?.retrieval?.llm || {},
+ transport: applied?.transport || {
+ applied: false,
+ source: "none",
+ mode: "none",
+ },
+ deliveryMode:
+ applied?.deliveryMode ||
+ String(effectiveRecallInput?.deliveryMode || "immediate").trim() ||
+ "immediate",
+ source: effectiveRecallInput.source || "",
+ sourceLabel: effectiveRecallInput.sourceLabel || "",
+ authoritativeInputUsed: Boolean(
+ effectiveRecallInput.authoritativeInputUsed,
+ ),
+ boundUserFloorText: String(
+ effectiveRecallInput.boundUserFloorText || "",
+ ),
+ hookName: effectiveRecallInput.hookName || "",
+ sourceCandidates: Array.isArray(effectiveRecallInput.sourceCandidates)
+ ? effectiveRecallInput.sourceCandidates.map((candidate) => ({
+ ...candidate,
+ }))
+ : [],
+ stats: reusedResult?.stats || {},
+ recallInput: String(persistedReuse.record.recallInput || ""),
+ });
+ }
+
const result = await runtime.retrieve({
graph: runtime.getCurrentGraph(),
userMessage,
diff --git a/tests/p0-regressions.mjs b/tests/p0-regressions.mjs
index 36dbd90..33a6712 100644
--- a/tests/p0-regressions.mjs
+++ b/tests/p0-regressions.mjs
@@ -4524,6 +4524,278 @@ async function testBeforeCombineRecallNotSkippedWhenGraphLoadingButRuntimeGraphR
);
}
+async function testHistoryGenerationReusesPersistedRecallForStableUserFloor() {
+ const { runRecallController } = await import("../retrieval/recall-controller.js");
+ const chat = [
+ {
+ is_user: true,
+ mes: "稳定 user 楼层",
+ extra: {
+ bme_recall: buildPersistedRecallRecord({
+ injectionText: "persisted-memory",
+ selectedNodeIds: ["node-persisted-1"],
+ recallInput: "发送前权威输入",
+ recallSource: "send-intent",
+ hookName: "GENERATION_AFTER_COMMANDS",
+ tokenEstimate: 12,
+ manuallyEdited: false,
+ authoritativeInputUsed: true,
+ boundUserFloorText: "稳定 user 楼层",
+ nowIso: "2026-01-01T00:00:00.000Z",
+ }),
+ },
+ },
+ { is_user: false, mes: "assistant-tail" },
+ ];
+ let retrieveCalls = 0;
+ let metadataSaveCalls = 0;
+ let recallUiRefreshCalls = 0;
+ const applyCalls = [];
+
+ const runtime = {
+ getIsRecalling: () => false,
+ abortRecallStageWithReason() {},
+ waitForActiveRecallToSettle: async () => ({ settled: true }),
+ getCurrentGraph: () => ({ nodes: [], edges: [] }),
+ getSettings: () => ({
+ enabled: true,
+ recallEnabled: true,
+ recallLlmContextMessages: 4,
+ }),
+ isGraphReadable: () => true,
+ isGraphReadableForRecall: () => true,
+ getGraphMutationBlockReason: () => "",
+ setLastRecallStatus() {},
+ isGraphMetadataWriteAllowed: () => false,
+ recoverHistoryIfNeeded: async () => true,
+ getContext: () => ({ chat }),
+ nextRecallRunSequence: () => 1,
+ setIsRecalling() {},
+ beginStageAbortController: () => ({
+ signal: { aborted: false, addEventListener() {} },
+ abort() {},
+ }),
+ createAbortError: (message) => new Error(message),
+ ensureVectorReadyIfNeeded: async () => {},
+ clampInt,
+ resolveRecallInput: () => ({
+ userMessage: "稳定 user 楼层",
+ recentMessages: ["[user]: 稳定 user 楼层"],
+ source: "chat-last-user",
+ sourceLabel: "历史最后用户楼层",
+ generationType: "history",
+ targetUserMessageIndex: 0,
+ authoritativeInputUsed: false,
+ boundUserFloorText: "稳定 user 楼层",
+ sourceCandidates: [],
+ }),
+ console,
+ getRecallHookLabel: () => "历史生成",
+ retrieve: async () => {
+ retrieveCalls += 1;
+ return {
+ stats: { recallCount: 1, coreCount: 1 },
+ selectedNodeIds: ["fresh-node"],
+ meta: {
+ retrieval: {
+ vectorHits: 1,
+ diffusionHits: 0,
+ llm: { status: "disabled", candidatePool: 0 },
+ },
+ },
+ };
+ },
+ getEmbeddingConfig: () => null,
+ getSchema: () => schema,
+ buildRecallRetrieveOptions: () => ({}),
+ applyRecallInjection: (_settings, recallInput, _recentMessages, result) => {
+ applyCalls.push({ recallInput: { ...recallInput }, result: { ...result } });
+ return {
+ injectionText: String(result?.injectionText || ""),
+ retrievalMeta: result?.meta?.retrieval || {},
+ llmMeta: result?.meta?.retrieval?.llm || {},
+ transport: {
+ applied: true,
+ source: "module-injection",
+ mode: "module-injection",
+ },
+ deliveryMode: String(recallInput?.deliveryMode || "immediate") || "immediate",
+ };
+ },
+ createRecallInputRecord,
+ createRecallRunResult,
+ isAbortError: () => false,
+ toastr: {
+ warning() {},
+ error() {},
+ },
+ finishStageAbortController() {},
+ getActiveRecallPromise: () => null,
+ setActiveRecallPromise() {},
+ setPendingRecallSendIntent() {},
+ refreshPanelLiveState() {},
+ readPersistedRecallFromUserMessage,
+ bumpPersistedRecallGenerationCount,
+ triggerChatMetadataSave() {
+ metadataSaveCalls += 1;
+ },
+ schedulePersistedRecallMessageUiRefresh() {
+ recallUiRefreshCalls += 1;
+ },
+ };
+
+ const result = await runRecallController(runtime, {
+ hookName: "GENERATION_AFTER_COMMANDS",
+ generationType: "regenerate",
+ deliveryMode: "immediate",
+ });
+
+ assert.equal(retrieveCalls, 0);
+ assert.equal(result.status, "completed");
+ assert.equal(result.reason, "persisted-user-floor-reused");
+ assert.equal(result.injectionText, "persisted-memory");
+ assert.equal(applyCalls.length, 1);
+ assert.equal(applyCalls[0].recallInput.source, "persisted-user-floor");
+ assert.equal(applyCalls[0].recallInput.authoritativeInputUsed, true);
+ assert.equal(applyCalls[0].recallInput.boundUserFloorText, "稳定 user 楼层");
+ assert.equal(
+ readPersistedRecallFromUserMessage(chat, 0)?.generationCount,
+ 1,
+ );
+ assert.equal(metadataSaveCalls, 1);
+ assert.equal(recallUiRefreshCalls, 1);
+}
+
+async function testHistoryGenerationDoesNotReusePersistedRecallAfterUserFloorEdit() {
+ const { runRecallController } = await import("../retrieval/recall-controller.js");
+ const chat = [
+ {
+ is_user: true,
+ mes: "已编辑的新 user 楼层",
+ extra: {
+ bme_recall: buildPersistedRecallRecord({
+ injectionText: "stale-persisted-memory",
+ selectedNodeIds: ["node-stale-1"],
+ recallInput: "旧 user 楼层",
+ recallSource: "chat-last-user",
+ hookName: "GENERATION_AFTER_COMMANDS",
+ tokenEstimate: 12,
+ manuallyEdited: false,
+ authoritativeInputUsed: false,
+ boundUserFloorText: "旧 user 楼层",
+ nowIso: "2026-01-01T00:00:00.000Z",
+ }),
+ },
+ },
+ { is_user: false, mes: "assistant-tail" },
+ ];
+ let retrieveCalls = 0;
+
+ const runtime = {
+ getIsRecalling: () => false,
+ abortRecallStageWithReason() {},
+ waitForActiveRecallToSettle: async () => ({ settled: true }),
+ getCurrentGraph: () => ({ nodes: [], edges: [] }),
+ getSettings: () => ({
+ enabled: true,
+ recallEnabled: true,
+ recallLlmContextMessages: 4,
+ }),
+ isGraphReadable: () => true,
+ isGraphReadableForRecall: () => true,
+ getGraphMutationBlockReason: () => "",
+ setLastRecallStatus() {},
+ isGraphMetadataWriteAllowed: () => false,
+ recoverHistoryIfNeeded: async () => true,
+ getContext: () => ({ chat }),
+ nextRecallRunSequence: () => 1,
+ setIsRecalling() {},
+ beginStageAbortController: () => ({
+ signal: { aborted: false, addEventListener() {} },
+ abort() {},
+ }),
+ createAbortError: (message) => new Error(message),
+ ensureVectorReadyIfNeeded: async () => {},
+ clampInt,
+ resolveRecallInput: () => ({
+ userMessage: "已编辑的新 user 楼层",
+ recentMessages: ["[user]: 已编辑的新 user 楼层"],
+ source: "chat-last-user",
+ sourceLabel: "历史最后用户楼层",
+ generationType: "history",
+ targetUserMessageIndex: 0,
+ authoritativeInputUsed: false,
+ boundUserFloorText: "已编辑的新 user 楼层",
+ sourceCandidates: [],
+ }),
+ console,
+ getRecallHookLabel: () => "历史生成",
+ retrieve: async () => {
+ retrieveCalls += 1;
+ return {
+ stats: { recallCount: 1, coreCount: 1 },
+ selectedNodeIds: ["fresh-node"],
+ meta: {
+ retrieval: {
+ vectorHits: 1,
+ diffusionHits: 0,
+ llm: { status: "disabled", candidatePool: 0 },
+ },
+ },
+ };
+ },
+ getEmbeddingConfig: () => null,
+ getSchema: () => schema,
+ buildRecallRetrieveOptions: () => ({}),
+ applyRecallInjection: (_settings, recallInput) => ({
+ injectionText: `fresh:${recallInput.userMessage}`,
+ retrievalMeta: {
+ vectorHits: 1,
+ diffusionHits: 0,
+ llm: { status: "disabled", candidatePool: 0 },
+ },
+ llmMeta: { status: "disabled", candidatePool: 0 },
+ transport: {
+ applied: true,
+ source: "module-injection",
+ mode: "module-injection",
+ },
+ deliveryMode: String(recallInput?.deliveryMode || "immediate") || "immediate",
+ }),
+ createRecallInputRecord,
+ createRecallRunResult,
+ isAbortError: () => false,
+ toastr: {
+ warning() {},
+ error() {},
+ },
+ finishStageAbortController() {},
+ getActiveRecallPromise: () => null,
+ setActiveRecallPromise() {},
+ setPendingRecallSendIntent() {},
+ refreshPanelLiveState() {},
+ readPersistedRecallFromUserMessage,
+ bumpPersistedRecallGenerationCount,
+ triggerChatMetadataSave() {},
+ schedulePersistedRecallMessageUiRefresh() {},
+ };
+
+ const result = await runRecallController(runtime, {
+ hookName: "GENERATION_AFTER_COMMANDS",
+ generationType: "regenerate",
+ deliveryMode: "immediate",
+ });
+
+ assert.equal(retrieveCalls, 1);
+ assert.equal(result.status, "completed");
+ assert.equal(result.reason, "召回完成");
+ assert.equal(result.injectionText, "fresh:已编辑的新 user 楼层");
+ assert.equal(
+ readPersistedRecallFromUserMessage(chat, 0)?.generationCount,
+ 0,
+ );
+}
+
async function testPersistentRecallDataLayerLifecycleAndCompatibility() {
const chat = [
{ is_user: true, mes: "u0" },
@@ -6181,8 +6453,8 @@ await testAutoExtractionDefersWhenAlreadyExtracting();
await testAutoExtractionDefersWhenHistoryRecoveryBusy();
await testRemoveNodeHandlesCyclicChildGraph();
await testGenerationRecallAppliesFinalInjectionOncePerTransaction();
-await testGenerationRecallDeferredRewriteMutatesFinalMesSendPayload();
-await testGenerationRecallDeferredRewriteMutatesFinalMesSendAuthoritativeUserInput();
+await testHistoryGenerationReusesPersistedRecallForStableUserFloor();
+await testHistoryGenerationDoesNotReusePersistedRecallAfterUserFloorEdit();
await testPersistentRecallDataLayerLifecycleAndCompatibility();
await testPersistentRecallSourceResolutionAndTargetRouting();
await testGenerationRecallFinalInjectionRebindsLatestMatchingUserFloor();
From f3c3256bb36e3984463cd17d147d94fe9478b2e7 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Sat, 11 Apr 2026 12:03:05 +0000
Subject: [PATCH 18/20] chore: bump manifest version [skip ci]
---
manifest.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/manifest.json b/manifest.json
index f20f9bc..f6a679e 100644
--- a/manifest.json
+++ b/manifest.json
@@ -6,6 +6,6 @@
"js": "index.js",
"css": "style.css",
"author": "Youzini",
- "version": "4.5.8",
+ "version": "4.5.9",
"homePage": "https://github.com/Youzini-afk/ST-Bionic-Memory-Ecology"
}
From 9becc235264a66eef40f7c7806b83e41327737b8 Mon Sep 17 00:00:00 2001
From: Youzini-afk <13153778771cx@gmail.com>
Date: Sat, 11 Apr 2026 20:36:24 +0800
Subject: [PATCH 19/20] fix(ux): quiet expected history recovery toasts
---
index.js | 17 +---
tests/p0-regressions.mjs | 166 ++++++++++++++++++++++++++++++++++++++-
2 files changed, 166 insertions(+), 17 deletions(-)
diff --git a/index.js b/index.js
index 4fa56c2..d9cd0ff 100644
--- a/index.js
+++ b/index.js
@@ -715,7 +715,6 @@ let lastRecalledItems = []; // 最近召回的节点(面板展示用)
let extractionCount = 0; // v2: 提取次数计数器(定期触发概要/遗忘/反思)
let serverSettingsSaveTimer = null;
let isRecoveringHistory = false;
-let lastHistoryWarningAt = 0;
let lastRecallFallbackNoticeAt = 0;
let lastExtractionWarningAt = 0;
const LOCAL_VECTOR_TIMEOUT_MS = 300000;
@@ -11249,13 +11248,6 @@ function notifyHistoryDirty(dirtyFrom, reason) {
busy: true,
},
);
- const now = Date.now();
- if (now - lastHistoryWarningAt < 3000) return;
- lastHistoryWarningAt = now;
- toastr.warning(
- `检测到楼层历史变化,将从楼层 ${dirtyFrom} 之后自动恢复图谱`,
- reason || "ST-BME 历史回退保护",
- );
}
function clearPendingHistoryMutationChecks() {
@@ -11979,12 +11971,9 @@ async function recoverHistoryIfNeeded(trigger = "history-recovery") {
persist: false,
},
);
-
- toastr.success(
- usedFullRebuild
- ? "历史变化已触发全量重建"
- : "历史变化已完成受影响后缀恢复",
- );
+ if (usedFullRebuild) {
+ toastr.warning("历史变化已触发全量重建");
+ }
return true;
} catch (error) {
if (isAbortError(error)) {
diff --git a/tests/p0-regressions.mjs b/tests/p0-regressions.mjs
index 33a6712..b68deca 100644
--- a/tests/p0-regressions.mjs
+++ b/tests/p0-regressions.mjs
@@ -349,6 +349,11 @@ function createHistoryRecoveryHarness() {
saveGraphToChatCalls: 0,
refreshPanelCalls: 0,
notices: [],
+ toastCalls: {
+ success: [],
+ warning: [],
+ error: [],
+ },
embeddingConfig: { mode: "backend" },
isRestoreLockActive() {
return false;
@@ -511,9 +516,15 @@ function createHistoryRecoveryHarness() {
context.refreshPanelCalls += 1;
},
toastr: {
- success() {},
- warning() {},
- error() {},
+ success(...args) {
+ context.toastCalls.success.push(args);
+ },
+ warning(...args) {
+ context.toastCalls.warning.push(args);
+ },
+ error(...args) {
+ context.toastCalls.error.push(args);
+ },
},
};
vm.createContext(context);
@@ -526,6 +537,38 @@ function createHistoryRecoveryHarness() {
});
}
+function createHistoryNotificationHarness() {
+ return fs.readFile(indexPath, "utf8").then((source) => {
+ const start = source.indexOf("function notifyHistoryDirty(dirtyFrom, reason) {");
+ const end = source.indexOf("function clearPendingHistoryMutationChecks() {");
+ if (start < 0 || end < 0 || end <= start) {
+ throw new Error("无法从 index.js 提取 history notify 定义");
+ }
+ const snippet = source.slice(start, end).replace(/^export\s+/gm, "");
+ const context = {
+ console,
+ result: null,
+ notices: [],
+ warningToasts: [],
+ updateStageNotice(...args) {
+ context.notices.push(args);
+ },
+ toastr: {
+ warning(...args) {
+ context.warningToasts.push(args);
+ },
+ },
+ };
+ vm.createContext(context);
+ vm.runInContext(
+ `${snippet}\nresult = { notifyHistoryDirty };`,
+ context,
+ { filename: indexPath },
+ );
+ return context;
+ });
+}
+
function createRerollHarness() {
return fs.readFile(indexPath, "utf8").then((source) => {
const rollbackStart = source.indexOf(
@@ -5436,6 +5479,120 @@ async function testHistoryRecoveryAbortClearsVectorRepairState() {
assert.equal(harness.currentGraph.vectorIndexState.dirtyReason, "");
}
+async function testNotifyHistoryDirtyUsesStageNoticeWithoutGenericWarningToast() {
+ const harness = await createHistoryNotificationHarness();
+
+ harness.result.notifyHistoryDirty(
+ 12,
+ "已处理楼层超出当前聊天长度,检测到历史截断",
+ );
+
+ assert.equal(harness.notices.length, 1);
+ assert.equal(harness.warningToasts.length, 0);
+ assert.equal(harness.notices[0][0], "history");
+ assert.equal(harness.notices[0][1], "检测到楼层历史变化");
+}
+
+async function testHistoryRecoveryStandardSuffixReplayDoesNotEmitCompletionToast() {
+ const harness = await createHistoryRecoveryHarness();
+ harness.chat = [
+ { is_user: true, mes: "u1" },
+ { is_user: false, mes: "a1" },
+ ];
+ harness.currentGraph = {
+ historyState: {
+ lastProcessedAssistantFloor: 1,
+ processedMessageHashes: { 1: "hash-1" },
+ historyDirtyFrom: 1,
+ lastMutationSource: "message-deleted",
+ lastMutationReason: "tail-truncated",
+ extractionCount: 1,
+ },
+ vectorIndexState: {
+ collectionId: "col-1",
+ dirty: true,
+ dirtyReason: "history-recovery-replay",
+ pendingRepairFromFloor: 1,
+ replayRequiredNodeIds: ["node-1"],
+ lastWarning: "repair pending",
+ lastIntegrityIssue: null,
+ },
+ batchJournal: [],
+ lastProcessedSeq: 1,
+ };
+ harness.findJournalRecoveryPointImpl = () => ({
+ path: "reverse-journal",
+ affectedBatchCount: 1,
+ affectedJournals: [
+ {
+ processedRange: [1, 1],
+ vectorDelta: {
+ insertedHashes: [],
+ removedHashes: [],
+ backendDeleteHashes: [],
+ touchedNodeIds: [],
+ replayRequiredNodeIds: [],
+ replacedMappings: [],
+ },
+ },
+ ],
+ });
+ harness.replayExtractionFromHistoryImpl = async () => {
+ harness.currentGraph.historyState.lastProcessedAssistantFloor = 1;
+ harness.currentGraph.lastProcessedSeq = 1;
+ return 1;
+ };
+
+ const result = await harness.result.recoverFromHistoryMutation("message-deleted");
+
+ assert.equal(result, true);
+ assert.equal(harness.toastCalls.success.length, 0);
+ assert.equal(harness.toastCalls.warning.length, 0);
+ assert.equal(harness.toastCalls.error.length, 0);
+}
+
+async function testHistoryRecoveryFullRebuildStillWarnsUser() {
+ const harness = await createHistoryRecoveryHarness();
+ harness.chat = [
+ { is_user: true, mes: "u1" },
+ { is_user: false, mes: "a1" },
+ ];
+ harness.currentGraph = {
+ historyState: {
+ lastProcessedAssistantFloor: 1,
+ processedMessageHashes: { 1: "hash-1" },
+ historyDirtyFrom: 1,
+ lastMutationSource: "message-edited",
+ lastMutationReason: "edited",
+ extractionCount: 1,
+ },
+ vectorIndexState: {
+ collectionId: "col-1",
+ dirty: true,
+ dirtyReason: "history-recovery-replay",
+ pendingRepairFromFloor: 1,
+ replayRequiredNodeIds: ["node-1"],
+ lastWarning: "repair pending",
+ lastIntegrityIssue: null,
+ },
+ batchJournal: [],
+ lastProcessedSeq: 1,
+ };
+ harness.findJournalRecoveryPointImpl = () => null;
+ harness.replayExtractionFromHistoryImpl = async () => {
+ harness.currentGraph.historyState.lastProcessedAssistantFloor = 1;
+ harness.currentGraph.lastProcessedSeq = 1;
+ return 1;
+ };
+
+ const result = await harness.result.recoverFromHistoryMutation("message-edited");
+
+ assert.equal(result, true);
+ assert.equal(harness.toastCalls.success.length, 0);
+ assert.equal(harness.toastCalls.warning.length, 1);
+ assert.match(String(harness.toastCalls.warning[0]?.[0] || ""), /全量重建/);
+}
+
async function testHistoryRecoveryFallbackFullRebuildCarriesResultCode() {
const harness = await createHistoryRecoveryHarness();
harness.chat = [
@@ -6475,6 +6632,9 @@ await testRecallCardUserTextRefreshesWithoutCardRecreate();
await testRecallCardDisplayModeToggleRestoresOriginalUserText();
await testRecallSubGraphAndDataLayerEntryPoints();
await testRerollUsesBatchBoundaryRollbackAndPersistsState();
+await testNotifyHistoryDirtyUsesStageNoticeWithoutGenericWarningToast();
+await testHistoryRecoveryStandardSuffixReplayDoesNotEmitCompletionToast();
+await testHistoryRecoveryFullRebuildStillWarnsUser();
await testHistoryRecoveryAbortClearsVectorRepairState();
await testHistoryRecoveryFallbackFullRebuildCarriesResultCode();
await testHistoryRecoverySuccessRestoresProcessedHashesAfterReplay();
From 3cf76a3129f34e4ff086ca5e5df8655d9f513efb Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Sat, 11 Apr 2026 12:36:38 +0000
Subject: [PATCH 20/20] chore: bump manifest version [skip ci]
---
manifest.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/manifest.json b/manifest.json
index f6a679e..20f424d 100644
--- a/manifest.json
+++ b/manifest.json
@@ -6,6 +6,6 @@
"js": "index.js",
"css": "style.css",
"author": "Youzini",
- "version": "4.5.9",
+ "version": "4.6.0",
"homePage": "https://github.com/Youzini-afk/ST-Bionic-Memory-Ecology"
}