diff --git a/panel.js b/panel.js
index 65132a1..06c68ae 100644
--- a/panel.js
+++ b/panel.js
@@ -1983,6 +1983,21 @@ function _refreshConfigTab() {
"bme-setting-notice-display-mode",
settings.noticeDisplayMode ?? "normal",
);
+ _setInputValue(
+ "bme-setting-wi-filter-mode",
+ settings.worldInfoFilterMode || "default",
+ );
+ _setInputValue(
+ "bme-setting-wi-filter-keywords",
+ settings.worldInfoFilterCustomKeywords || "",
+ );
+ const wiFilterCustomSection = panelEl?.querySelector(
+ "#bme-wi-filter-custom-section",
+ );
+ if (wiFilterCustomSection) {
+ wiFilterCustomSection.style.display =
+ (settings.worldInfoFilterMode || "default") === "custom" ? "" : "none";
+ }
_setInputValue("bme-setting-extract-every", settings.extractEvery ?? 1);
_setInputValue(
@@ -2337,6 +2352,29 @@ function _bindConfigControls() {
});
noticeDisplayModeEl.dataset.bmeBound = "true";
}
+ const wiFilterModeEl = document.getElementById("bme-setting-wi-filter-mode");
+ if (wiFilterModeEl && wiFilterModeEl.dataset.bmeBound !== "true") {
+ wiFilterModeEl.addEventListener("change", () => {
+ const nextValue = wiFilterModeEl.value || "default";
+ _patchSettings({ worldInfoFilterMode: nextValue });
+ const section = panelEl?.querySelector("#bme-wi-filter-custom-section");
+ if (section) {
+ section.style.display = nextValue === "custom" ? "" : "none";
+ }
+ });
+ wiFilterModeEl.dataset.bmeBound = "true";
+ }
+ const wiFilterKeywordsEl = document.getElementById(
+ "bme-setting-wi-filter-keywords",
+ );
+ if (wiFilterKeywordsEl && wiFilterKeywordsEl.dataset.bmeBound !== "true") {
+ wiFilterKeywordsEl.addEventListener("change", () => {
+ _patchSettings({
+ worldInfoFilterCustomKeywords: wiFilterKeywordsEl.value || "",
+ });
+ });
+ wiFilterKeywordsEl.dataset.bmeBound = "true";
+ }
bindNumber("bme-setting-extract-every", 1, 1, 50, (value) =>
_patchSettings({ extractEvery: value }),
diff --git a/plan-custom-worldinfo-filter.md b/plan-custom-worldinfo-filter.md
new file mode 100644
index 0000000..1323c2a
--- /dev/null
+++ b/plan-custom-worldinfo-filter.md
@@ -0,0 +1,702 @@
+# Plan: 世界书自定义过滤模式(v4)
+
+## 需求
+
+- **默认模式**:保持现有全部行为不变
+- **自定义模式**:完全替代 MVU 过滤链路:
+ 1. disabled 条目不进入读取链路
+ 2. 条目 `name`(仅 name)包含用户指定关键词的条目被跳过
+ 3. 不做任何 MVU heuristic / sanitize / blockedContents 处理
+ - 关键词留空 → 除 disabled 外全部可读
+
+## 行为护栏
+
+- 这次改动的目标只是给世界书读取新增一条 **custom 跑道**,不是重写默认逻辑
+- `worldInfoFilterMode !== "custom"` 时,行为必须与当前版本逐字节等价:
+ - 仍走 `getMvuIgnoreReason()`
+ - 仍写入 `blockedContents`
+ - 仍保留默认模式下 disabled 条目可被 EJS `getwi` 读取的现有语义
+ - 仍保持现有 `debug.mvu`、缓存、warning 文案和 prompt-builder 清洗行为
+- 只有在 `worldInfoFilterMode === "custom"` 时,才切到自定义分支
+- 任何实现如果让默认模式下“本来能读到的条目读不到”,都视为回归
+
+---
+
+## 需要切断的 MVU 链路(自定义模式下全部跳过)
+
+| # | 位置 | 作用 | 自定义模式处理 |
+|---|------|------|---------------|
+| A | `task-worldinfo.js` L779 `getMvuIgnoreReason()` | 按 name/comment 标签 + 内容特征过滤条目 | 替换为自定义关键词按 `entry.name` 过滤 |
+| B | `task-worldinfo.js` L781 `registerIgnoredWorldInfoEntry()` | 被过滤条目 content 推入 `blockedContents` | 自定义过滤条目不推入 `blockedContents` |
+| C | `task-worldinfo.js` L1387 `sanitizeMvuContent()` | 渲染后条目再过一轮 MVU 内容检测 | 跳过,直接使用渲染结果 |
+| D | `task-worldinfo.js` L1190 `__mvuBlockedContents` | 传递给 prompt-builder | 自定义模式下为空数组 |
+| E | `prompt-builder.js` L700 `runtimeBlockedContents` | 读取 `__mvuBlockedContents` 对条目做内容删除 | 空数组 → 不删除 |
+| F | `prompt-builder.js` L651,733 `sanitizeTaskPromptText()` | 默认 `applyMvu: true`,对世界书条目和 additionalMessages 跑 `sanitizeMvuContent()` | 传 `applyMvu: false` |
+| G | `prompt-builder.js` L1137 `sanitizeTaskPromptText()` | 最终 block 组装阶段还会再跑一次 MVU 清洗 | 自定义模式下,对世界书来源 block 传 `applyMvu: false` |
+| H | `prompt-builder.js` L1445 `sanitizePromptMessages()` | fallback 路径会再次清洗 `privateTaskMessages` | 自定义模式下,对世界书来源 message 传 `applyMvu: false` |
+| I | `task-ejs.js` L564-568 `resolveIgnoredEntry` 回调 | 被过滤条目 warning 文案硬编码为 `"mvu filtered world info blocked"` | 自定义过滤条目不走这条 warning |
+| J | `task-worldinfo.js` L159 `buildMvuDebugSummary()` | 把 mvuCollector 数据归入 `debug.mvu` | 自定义过滤条目归入独立的 `debug.customFilter`,不混入 `debug.mvu` |
+
+---
+
+## 涉及文件与改动
+
+### 1. `index.js` — 新增默认设置字段(~L378 `defaultSettings`)
+
+```js
+worldInfoFilterMode: "default", // "default" | "custom"
+worldInfoFilterCustomKeywords: "", // 逗号分隔,如 "BME,测试"
+```
+
+### 2. `panel.html` — 功能开关页新增卡片
+
+在 `data-config-section="toggles"` 的 `bme-config-grid` 中,增强能力卡片(L1015 `
`)之后、隐藏旧楼层卡片(L1017)之前,插入:
+
+```html
+
+
+
+
世界书过滤
+
+ 控制 ST-BME 读取世界书条目时的过滤策略。默认自动过滤 MVU 相关条目;自定义模式仅按条目名称关键词过滤。
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 条目名称中包含任一关键词即跳过(不区分大小写)。留空则不过滤任何条目(仅跳过已禁用的条目)。
+
+
+
+```
+
+### 3. `panel.js` — 绑定 UI 与设置
+
+**`_refreshConfigTab`(L1848)** 中追加:
+
+```js
+_setInputValue("bme-setting-wi-filter-mode", settings.worldInfoFilterMode || "default");
+_setInputValue("bme-setting-wi-filter-keywords", settings.worldInfoFilterCustomKeywords || "");
+const wiFilterCustomSection = panelEl?.querySelector("#bme-wi-filter-custom-section");
+if (wiFilterCustomSection) {
+ wiFilterCustomSection.style.display =
+ (settings.worldInfoFilterMode || "default") === "custom" ? "" : "none";
+}
+```
+
+**事件绑定区域(~L2318 附近,`noticeDisplayModeEl` 绑定之后)** 追加:
+
+```js
+const wiFilterModeEl = document.getElementById("bme-setting-wi-filter-mode");
+if (wiFilterModeEl && wiFilterModeEl.dataset.bmeBound !== "true") {
+ wiFilterModeEl.addEventListener("change", () => {
+ _patchSettings({ worldInfoFilterMode: wiFilterModeEl.value || "default" });
+ const section = panelEl?.querySelector("#bme-wi-filter-custom-section");
+ if (section) {
+ section.style.display = wiFilterModeEl.value === "custom" ? "" : "none";
+ }
+ });
+ wiFilterModeEl.dataset.bmeBound = "true";
+}
+const wiFilterKeywordsEl = document.getElementById("bme-setting-wi-filter-keywords");
+if (wiFilterKeywordsEl && wiFilterKeywordsEl.dataset.bmeBound !== "true") {
+ wiFilterKeywordsEl.addEventListener("change", () => {
+ _patchSettings({ worldInfoFilterCustomKeywords: wiFilterKeywordsEl.value || "" });
+ });
+ wiFilterKeywordsEl.dataset.bmeBound = "true";
+}
+```
+
+### 4. `task-worldinfo.js` — 核心过滤逻辑
+
+#### 4a. 新增独立的自定义过滤 collector(不复用 mvuCollector)
+
+```js
+function createCustomFilterCollector() {
+ return {
+ filteredEntries: [],
+ lazyFilteredEntries: [],
+ };
+}
+
+function registerCustomFilteredEntry(collector, entry, matchedKeyword, { lazy = false } = {}) {
+ if (!collector || !entry) return;
+ const meta = {
+ worldbook: normalizeKey(entry.worldbook),
+ name: entry.name,
+ matchedKeyword,
+ reason: "custom_keyword",
+ };
+ if (lazy) {
+ collector.lazyFilteredEntries.push(meta);
+ } else {
+ collector.filteredEntries.push(meta);
+ }
+}
+
+function buildCustomFilterDebugSummary(collector, {
+ filterMode = "default",
+ customFilterKeywords = [],
+} = {}) {
+ const filteredEntries = Array.isArray(collector?.filteredEntries)
+ ? collector.filteredEntries
+ : [];
+ const lazyFilteredEntries = Array.isArray(collector?.lazyFilteredEntries)
+ ? collector.lazyFilteredEntries
+ : [];
+
+ return {
+ mode: filterMode,
+ keywords: [...customFilterKeywords],
+ filteredEntryCount: filteredEntries.length + lazyFilteredEntries.length,
+ filteredEntries: [...filteredEntries, ...lazyFilteredEntries],
+ lazyFilteredEntryCount: lazyFilteredEntries.length,
+ };
+}
+```
+
+不推入 `blockedContents`,不注册进 `ignoredLookup`。
+
+#### 4b. `resolveTaskWorldInfo` 入口(L1128)解析设置并透传
+
+```js
+const filterMode = String(settings.worldInfoFilterMode || "default").trim();
+const isCustomFilter = filterMode === "custom";
+const customFilterKeywords = isCustomFilter
+ ? String(settings.worldInfoFilterCustomKeywords || "")
+ .split(",")
+ .map(kw => kw.trim().toLowerCase())
+ .filter(Boolean)
+ : [];
+```
+
+传给 `collectAllWorldbookEntries`:`{ filterMode, customFilterKeywords }`
+
+同时给 `result.debug` 预留默认结构,避免无世界书命中时 `debug.customFilter` 缺失:
+
+```js
+result.debug.customFilter = {
+ mode: filterMode,
+ keywords: customFilterKeywords,
+ filteredEntryCount: 0,
+ filteredEntries: [],
+ lazyFilteredEntryCount: 0,
+};
+```
+
+#### 4c. `collectAllWorldbookEntries`(L792)透传
+
+函数签名增加 `{ filterMode = "default", customFilterKeywords = [] } = {}`。
+
+- 创建 `const customFilterCollector = createCustomFilterCollector();`
+- `loadWorldbookOnce` 调用 `loadNormalizedWorldbookEntries` 时传入 `{ mvuCollector, filterMode, customFilterKeywords, customFilterCollector }`
+- 缓存 key 中加入 `filterMode` 和 `customFilterKeywords`
+
+```js
+const cacheKey = JSON.stringify({
+ // ... 现有字段 ...
+ filterMode,
+ customFilterKeywords,
+});
+```
+
+- `debug` 和缓存对象都带上 `customFilter`
+
+```js
+const customFilterDebug = buildCustomFilterDebugSummary(customFilterCollector, {
+ filterMode,
+ customFilterKeywords,
+});
+
+worldbookEntriesCache = {
+ // ...现有字段...
+ debug: {
+ ...debug,
+ mvu: buildMvuDebugSummary(mvuCollector),
+ customFilter: customFilterDebug,
+ },
+};
+
+return {
+ entries: allEntries,
+ blockedContents: [...mvuCollector.blockedContents],
+ ignoredEntries: [...debug.mvu.filteredEntries],
+ ignoredLookup: new Map(mvuCollector.ignoredLookup),
+ debug: {
+ ...debug,
+ mvu: buildMvuDebugSummary(mvuCollector),
+ customFilter: customFilterDebug,
+ },
+};
+```
+
+缓存命中分支也要把 `worldbookEntriesCache.debug?.customFilter` 原样回传。
+
+#### 4d. `loadNormalizedWorldbookEntries`(L741)—— 核心改造
+
+```js
+async function loadNormalizedWorldbookEntries(
+ worldbookHost, worldbookName,
+ { mvuCollector = null, lazy = false, filterMode = "default",
+ customFilterKeywords = [], customFilterCollector = null } = {},
+) {
+ // ... 现有逻辑读取 entries 和 commentByUid ...
+
+ const normalizedEntries = [];
+ for (const entry of Array.isArray(entries) ? entries : []) {
+ const normalizedEntry = normalizeEntry(/* 不变 */);
+
+ if (filterMode === "custom") {
+ // 自定义模式:disabled 条目直接跳过,不进入 allEntries
+ if (!normalizedEntry.enabled) continue;
+
+ // 按 entry.name 关键词过滤
+ if (customFilterKeywords.length > 0) {
+ const nameLower = normalizedEntry.name.toLowerCase();
+ const matched = customFilterKeywords.find(kw => nameLower.includes(kw));
+ if (matched) {
+ registerCustomFilteredEntry(customFilterCollector, normalizedEntry, matched, { lazy });
+ continue;
+ }
+ }
+ // 通过 → 加入结果,不做 MVU 检测
+ } else {
+ // 默认模式:现有 MVU 逻辑,完全不动
+ const ignoreReason = getMvuIgnoreReason(normalizedEntry);
+ if (ignoreReason) {
+ registerIgnoredWorldInfoEntry(mvuCollector, normalizedEntry, ignoreReason, { lazy });
+ continue;
+ }
+ }
+
+ normalizedEntries.push(normalizedEntry);
+ }
+ return normalizedEntries;
+}
+```
+
+**关于 disabled 条目**:仅自定义模式下在此处跳过。默认模式保持原样(disabled 条目进入 `allEntries`,在 L588 激活阶段跳过,但 EJS `getwi` 仍可按需拉取——这是现有有意设计)。
+
+#### 4e. `resolveTaskWorldInfo` 中渲染后清洗(~L1387)
+
+```js
+// 原:
+const mvuSanitized = sanitizeMvuContent(renderedContent, {
+ mode: "aggressive",
+ blockedContents,
+});
+
+// 改为:
+const mvuSanitized = isCustomFilter
+ ? { text: renderedContent, changed: false, dropped: false,
+ reasons: [], blockedHitCount: 0, artifactRemovedCount: 0 }
+ : sanitizeMvuContent(renderedContent, { mode: "aggressive", blockedContents });
+```
+
+#### 4f. `__mvuBlockedContents` 与 `blockedContents`(~L1190)
+
+自定义模式下 `mvuCollector.blockedContents` 本来就不会被填入(因为 4d 不走 `registerIgnoredWorldInfoEntry`),所以 `blockedContents` 自然为空数组。`__mvuBlockedContents` 赋值逻辑不需要改动,空数组传过去 prompt-builder 就不会做任何内容删除。
+
+#### 4g. 懒加载世界书透传(~L1269)
+
+```js
+const lazyCustomFilterCollector = createCustomFilterCollector();
+
+const lazyEntries = await loadNormalizedWorldbookEntries(
+ worldbookHost, normalizedWorldbook,
+ { mvuCollector: lazyMvuCollector, lazy: true,
+ filterMode, customFilterKeywords, customFilterCollector: lazyCustomFilterCollector },
+);
+```
+
+这里改为**仿照现有 `lazyMvuCollector` 模式**,在 `resolveTaskWorldInfo()` 内新建一个局部 `lazyCustomFilterCollector`,原因是:
+
+- `collectAllWorldbookEntries()` 里的 `customFilterCollector` 是其内部局部变量,`resolveTaskWorldInfo()` 闭包里拿不到
+- 懒加载发生在 `resolveTaskWorldInfo()` 内部,不在 `collectAllWorldbookEntries()` 的作用域里
+- 直接照着现有 `lazyMvuCollector` 的合并写法做,最贴近现有代码结构,风险最低
+
+回调后立刻合并到 `result.debug.customFilter`:
+
+```js
+const newLazyEntries = [...lazyCustomFilterCollector.lazyFilteredEntries];
+if (newLazyEntries.length > 0) {
+ result.debug.customFilter = {
+ ...result.debug.customFilter,
+ filteredEntries: [
+ ...(Array.isArray(result.debug.customFilter?.filteredEntries)
+ ? result.debug.customFilter.filteredEntries
+ : []),
+ ...newLazyEntries,
+ ],
+ filteredEntryCount:
+ Number(result.debug.customFilter?.filteredEntryCount || 0) +
+ newLazyEntries.length,
+ lazyFilteredEntryCount:
+ Number(result.debug.customFilter?.lazyFilteredEntryCount || 0) +
+ newLazyEntries.length,
+ };
+}
+lazyCustomFilterCollector.lazyFilteredEntries = [];
+```
+
+这和当前 `L1278-L1294` 对 `lazyMvuCollector` 的处理是同构的,语义也最直观:
+
+- 首轮加载的自定义过滤统计来自 `collectAllWorldbookEntries()`
+- 懒加载追加过滤统计来自 `lazyCustomFilterCollector`
+- 两者最终都汇总到同一个 `result.debug.customFilter`
+
+#### 4h. 调试信息——独立的 `debug.customFilter`
+
+```js
+result.debug = {
+ ...result.debug,
+ ...(collected?.debug || {}),
+ customFilter:
+ collected?.debug?.customFilter && typeof collected.debug.customFilter === "object"
+ ? { ...collected.debug.customFilter }
+ : buildCustomFilterDebugSummary(null, { filterMode, customFilterKeywords }),
+};
+```
+
+不混入 `debug.mvu`。自定义模式下 `debug.mvu` 保持初始空值(`buildMvuDebugSummary(null)` 返回的全零结构),`debug.customFilter` 始终由 `collectAllWorldbookEntries()` 汇总后统一返回。
+
+#### 4i. `resolveIgnoredEntry` 回调与 EJS warning(~L1315)
+
+自定义模式下传给 `createTaskEjsRenderContext` 的 `resolveIgnoredEntry` 改为查自定义 collector:
+
+```js
+resolveIgnoredEntry: isCustomFilter
+ ? (worldbookName, identifier) => {
+ // 自定义过滤条目不注册进 ignoredLookup,所以 EJS getwi 找不到时不会报 MVU warning
+ return null;
+ }
+ : (worldbookName, identifier) =>
+ findIgnoredWorldInfoEntry({ ignoredLookup }, worldbookName, identifier),
+```
+
+自定义模式下 `getwi` 找不到条目只会走正常的 "target not found" 逻辑(L639),不会出现 "mvu filtered world info blocked" 字样。
+
+### 5. `prompt-builder.js` — 自定义模式下跳过世界书条目的 MVU 清洗
+
+#### 5a. `sanitizeWorldInfoContext`(L682)
+
+已有参数 `settings`,读取 `settings.worldInfoFilterMode`:
+
+```js
+const isCustomFilter = String(settings.worldInfoFilterMode || "default").trim() === "custom";
+```
+
+传给 `sanitizeWorldInfoEntries` 和 `sanitizeTaskPromptText`:
+
+```js
+const beforeEntries = sanitizeWorldInfoEntries(
+ settings, taskType, worldInfo?.beforeEntries,
+ runtimeBlockedContents, debugState, regexCollector,
+ { applyMvu: !isCustomFilter }, // 新增
+);
+// afterEntries, atDepthEntries 同理
+```
+
+additionalMessages 的 `sanitizeTaskPromptText` 调用也传 `applyMvu: !isCustomFilter`。
+
+#### 5b. `sanitizeWorldInfoEntries`(L641)
+
+签名增加 `options = {}` 参数:
+
+```js
+function sanitizeWorldInfoEntries(
+ settings, taskType, entries, blockedContents,
+ debugState, regexCollector,
+ { applyMvu = true } = {},
+) {
+```
+
+内部调用 `sanitizeTaskPromptText` 时透传 `applyMvu`:
+
+```js
+const sanitized = sanitizeTaskPromptText(settings, taskType, content, {
+ mode: "aggressive",
+ blockedContents,
+ regexStage: "",
+ role: entry?.role || "system",
+ regexCollector,
+ applyMvu, // 新增
+});
+```
+
+#### 5c. `buildTaskPrompt` 最终 block 清洗(L1137)也要跳过世界书来源内容
+
+这是 v3 漏掉的关键点。即使 `resolveTaskWorldInfo()` 和 `sanitizeWorldInfoContext()` 都不做 MVU 清洗,`buildTaskPrompt()` 在把 block 内容组装成最终 `systemPrompt` / `executionMessages` 时,还会再调用一次 `sanitizeTaskPromptText()`。如果不改,这一层仍会把自定义模式下的世界书内容删掉。
+
+新增 helper:
+
+```js
+function blockUsesWorldInfoContent(block = {}) {
+ const sourceKey = String(block?.sourceKey || "");
+ if (
+ sourceKey === "worldInfoBefore" ||
+ sourceKey === "worldInfoAfter" ||
+ sourceKey === "worldInfoBeforeEntries" ||
+ sourceKey === "worldInfoAfterEntries" ||
+ sourceKey === "worldInfoAtDepthEntries" ||
+ sourceKey === "activatedWorldInfoNames" ||
+ sourceKey === "taskAdditionalMessages"
+ ) {
+ return true;
+ }
+
+ const content = String(block?.content || "");
+ return /\{\{\s*(worldInfoBefore|worldInfoAfter|worldInfoBeforeEntries|worldInfoAfterEntries|worldInfoAtDepthEntries|activatedWorldInfoNames|taskAdditionalMessages)\s*\}\}/.test(content);
+}
+```
+
+在 `buildTaskPrompt()` 里计算:
+
+```js
+const isCustomFilter = String(settings.worldInfoFilterMode || "default").trim() === "custom";
+const blockApplyMvu = !(isCustomFilter && blockUsesWorldInfoContent(block));
+
+const sanitizedBlockContent = sanitizeTaskPromptText(settings, taskType, content, {
+ mode: "final-safe",
+ blockedContents: worldInfoRuntimeBlockedContents,
+ regexStage: "",
+ role,
+ regexCollector: promptRegexInput,
+ applyMvu: blockApplyMvu,
+});
+```
+
+这里**只**对“世界书来源 block”关闭 MVU;其他普通 block 仍保持现有清洗行为,不扩大改动面。
+
+#### 5d. `buildTaskLlmPayload` fallback 路径(L1445)保持同样规则
+
+当 `executionMessages.length === 0` 时,代码会回退到 `privateTaskMessages` 并再次调用 `sanitizePromptMessages()`。这条 fallback 也必须遵守和 5c 一样的规则,否则仍然可能在极端路径里把自定义模式的世界书内容清掉。
+
+这里收敛成**单一路径**,不要再给 message 打额外标记:
+
+1. 给 `sanitizePromptMessages()` 增加 `applyMvu = true` 选项,并向下透传到 `sanitizeStructuredPromptValue()`
+2. fallback 调用时直接按模式传参:
+
+```js
+const additionalMessages =
+ executionMessages.length > 0
+ ? []
+ : sanitizePromptMessages(
+ {},
+ taskType,
+ Array.isArray(promptBuild?.privateTaskMessages)
+ ? promptBuild.privateTaskMessages
+ : [],
+ {
+ blockedContents,
+ regexStage: "",
+ applyMvu: !isCustomFilter,
+ },
+ );
+```
+
+这样做的理由:
+
+- fallback 本身就是极端兜底路径,没必要再做“按 message 来源细分”的复杂逻辑
+- 自定义模式要求“世界书链路不做 MVU sanitize”,fallback 时直接整体关闭 MVU 更符合预期
+- 默认模式不受影响,仍保持 `applyMvu: true`
+
+这样可保证:
+
+- 正常路径不会二次清洗世界书内容
+- fallback 路径也不会偷偷恢复 MVU 清洗
+- 默认模式仍保持原行为
+
+### 6. 测试(`tests/task-worldinfo.mjs`)
+
+#### 6a. 自定义模式替代 MVU——MVU 条目不再被过滤
+
+```js
+const customResult = await resolveTaskWorldInfo({
+ settings: { worldInfoFilterMode: "custom", worldInfoFilterCustomKeywords: "" },
+ userMessage: "继续调查",
+ templateContext: { recentMessages: "...", charName: "Alice" },
+});
+// MVU tagged/heuristic 条目应出现在激活结果中
+assert.equal(
+ customResult.beforeEntries.some(e => e.sourceName === "[mvu_update] 状态同步"),
+ true,
+ "custom filter mode should not filter MVU tagged entries",
+);
+assert.equal(
+ customResult.beforeEntries.some(e => e.sourceName === "MVU 启发式条目"),
+ true,
+ "custom filter mode should not filter MVU heuristic entries",
+);
+// debug.mvu 应为空
+assert.equal(customResult.debug.mvu.filteredEntryCount, 0);
+// debug.customFilter 存在且 mode 正确
+assert.equal(customResult.debug.customFilter.mode, "custom");
+assert.equal(customResult.debug.customFilter.filteredEntryCount, 0);
+```
+
+#### 6b. 自定义关键词仅匹配 name
+
+```js
+const keywordResult = await resolveTaskWorldInfo({
+ settings: { worldInfoFilterMode: "custom", worldInfoFilterCustomKeywords: "常驻" },
+ userMessage: "继续调查",
+ templateContext: { recentMessages: "...", charName: "Alice" },
+});
+// name 为 "常驻设定" → 被过滤
+assert.equal(
+ keywordResult.beforeEntries.some(e => e.sourceName === "常驻设定"),
+ false,
+);
+// comment 包含某关键词但 name 不包含 → 不被过滤(用现有 fixture 验证)
+assert.equal(keywordResult.debug.customFilter.filteredEntryCount, 1);
+assert.equal(keywordResult.debug.customFilter.filteredEntries[0].name, "常驻设定");
+assert.equal(keywordResult.debug.customFilter.filteredEntries[0].matchedKeyword, "常驻");
+```
+
+#### 6c. disabled 条目在自定义模式下不可被 getwi 读到
+
+```js
+// dynEntry (enabled=false, name="EW/Dyn/线索") 在自定义模式下不应进入 allEntries
+// inlineSummaryEntry 调用 getwi("EW/Dyn/线索") 应该返回空
+assert.equal(
+ customResult.allEntries.some(e => e.name === "EW/Dyn/线索"),
+ false,
+ "disabled entries should not enter allEntries in custom filter mode",
+);
+```
+
+#### 6d. 缓存 key 区分模式和关键词
+
+```js
+// 先跑一次默认模式预热缓存
+const defaultResult = await resolveTaskWorldInfo({
+ settings: {},
+ userMessage: "继续调查",
+ templateContext: { recentMessages: "...", charName: "Alice" },
+});
+assert.equal(defaultResult.debug.cache.hit, false);
+
+// 再跑自定义模式,不应命中缓存
+const customResult2 = await resolveTaskWorldInfo({
+ settings: { worldInfoFilterMode: "custom", worldInfoFilterCustomKeywords: "" },
+ userMessage: "继续调查",
+ templateContext: { recentMessages: "...", charName: "Alice" },
+});
+assert.equal(customResult2.debug.cache.hit, false,
+ "switching filter mode should not hit default mode cache");
+```
+
+#### 6e. prompt-builder 层不做 MVU 清洗(直接 worldInfo block)
+
+```js
+// 用 buildTaskPrompt 验证自定义模式下 MVU 内容条目不被清洗
+const customPromptBuild = await buildTaskPrompt(
+ { ...settings, worldInfoFilterMode: "custom", worldInfoFilterCustomKeywords: "" },
+ "recall",
+ { taskName: "recall", userMessage: "继续调查", recentMessages: "...", charName: "Alice" },
+);
+// MVU heuristic 条目(content 含 status_current_variable)应出现在 systemPrompt 中
+assert.match(customPromptBuild.systemPrompt, /status_current_variable/,
+ "custom filter mode should not strip MVU content in prompt-builder");
+```
+
+#### 6f. 最终 block 清洗对 `{{worldInfoBefore}}` 插值块也不应删内容
+
+补一个 profile fixture,不用 `sourceKey: "worldInfoBefore"`,而是:
+
+```js
+{
+ id: "b-interp",
+ type: "custom",
+ content: "世界书插值:\\n{{worldInfoBefore}}",
+ role: "system",
+ enabled: true,
+ order: 0,
+ injectionMode: "append",
+}
+```
+
+断言:
+
+```js
+assert.match(customInterpolatedPromptBuild.systemPrompt, /status_current_variable/);
+```
+
+这能确保 5c 的 `blockUsesWorldInfoContent()` 不是只覆盖 `sourceKey`,而是真的覆盖到了插值路径。
+
+#### 6g. cache hit 时 `debug.customFilter` 仍正确返回
+
+```js
+const keywordResult1 = await resolveTaskWorldInfo({
+ settings: { worldInfoFilterMode: "custom", worldInfoFilterCustomKeywords: "常驻" },
+ userMessage: "继续调查",
+ templateContext: { recentMessages: "...", charName: "Alice" },
+});
+assert.equal(keywordResult1.debug.cache.hit, false);
+assert.equal(keywordResult1.debug.customFilter.filteredEntryCount, 1);
+
+const keywordResult2 = await resolveTaskWorldInfo({
+ settings: { worldInfoFilterMode: "custom", worldInfoFilterCustomKeywords: "常驻" },
+ userMessage: "继续调查",
+ templateContext: { recentMessages: "...", charName: "Alice" },
+});
+assert.equal(keywordResult2.debug.cache.hit, true);
+assert.equal(keywordResult2.debug.customFilter.filteredEntryCount, 1);
+assert.equal(keywordResult2.debug.customFilter.filteredEntries[0].name, "常驻设定");
+```
+
+#### 6h. 默认模式回归保护
+
+必须补一条明确的“默认模式没变”回归测试,避免实现时误伤现有链路:
+
+```js
+const defaultModeResult = await resolveTaskWorldInfo({
+ settings: { worldInfoFilterMode: "default", worldInfoFilterCustomKeywords: "常驻" },
+ userMessage: "继续调查",
+ templateContext: { recentMessages: "...", charName: "Alice" },
+});
+
+// 默认模式仍忽略自定义关键词设置
+assert.equal(
+ defaultModeResult.beforeEntries.some(e => e.sourceName === "常驻设定"),
+ true,
+);
+
+// 默认模式仍保留原有 MVU 过滤
+assert.equal(defaultModeResult.debug.mvu.filteredEntryCount > 0, true);
+assert.equal(defaultModeResult.debug.customFilter.filteredEntryCount, 0);
+```
+
+---
+
+## 不做的事
+
+- 不修改 `mvu-compat.js`
+- 不支持正则匹配(只做子串包含)
+- 不按 `comment` 或 content 过滤(只按 `name`)
+- 不改变默认模式下的任何现有行为(包括 disabled 条目可被 EJS getwi 读取)
+
+---
+
+## 风险点
+
+1. **自定义模式下 disabled 条目不可被 EJS getwi 读取** — 与默认模式行为不同(默认允许)。这是有意设计,符合用户 "未激活状态也不要读取" 的需求。
+2. **自定义模式完全跳过 MVU 清洗** — 如果用户世界书有 MVU 变量标签但没用关键词过滤掉,这些内容会原样进入 prompt。这是自定义模式的预期行为。
+3. **缓存 key 包含关键词** — 每次修改关键词后第一次请求会重新加载。3 秒 TTL 影响可忽略。
diff --git a/prompt-builder.js b/prompt-builder.js
index ac2bddc..ef011fa 100644
--- a/prompt-builder.js
+++ b/prompt-builder.js
@@ -211,6 +211,39 @@ function createExecutionMessage(
};
}
+function isCustomWorldInfoFilterEnabled(settings = {}) {
+ return String(settings?.worldInfoFilterMode || "default").trim() === "custom";
+}
+
+function usesWorldInfoSourceKey(sourceKey = "") {
+ return [
+ "worldInfoBefore",
+ "worldInfoAfter",
+ "worldInfoBeforeEntries",
+ "worldInfoAfterEntries",
+ "worldInfoAtDepthEntries",
+ "activatedWorldInfoNames",
+ "taskAdditionalMessages",
+ ].includes(String(sourceKey || ""));
+}
+
+function blockUsesWorldInfoContent(block = {}) {
+ if (usesWorldInfoSourceKey(block?.sourceKey)) {
+ return true;
+ }
+ const content = String(block?.content || "");
+ return /\{\{\s*(worldInfoBefore|worldInfoAfter|worldInfoBeforeEntries|worldInfoAfterEntries|worldInfoAtDepthEntries|activatedWorldInfoNames|taskAdditionalMessages)\s*\}\}/.test(
+ content,
+ );
+}
+
+function messageUsesWorldInfoContent(message = {}) {
+ if (usesWorldInfoSourceKey(message?.sourceKey)) {
+ return true;
+ }
+ return String(message?.source || "") === "worldInfo-atDepth";
+}
+
function stringifyInterpolatedValue(value) {
if (value == null) return "";
if (typeof value === "string") return value;
@@ -537,10 +570,13 @@ function sanitizePromptMessages(
regexStage = "",
debugState = null,
regexCollector = null,
+ applyMvu = true,
} = {},
) {
return (Array.isArray(messages) ? messages : [])
.map((message, index) => {
+ const messageApplyMvu =
+ typeof applyMvu === "function" ? applyMvu(message, index) : applyMvu;
const sanitized = sanitizeStructuredPromptValue(
settings,
taskType,
@@ -554,6 +590,8 @@ function sanitizePromptMessages(
role: message?.role || "system",
debugState,
regexCollector,
+ applyMvu: messageApplyMvu,
+ stripMvuContainers: messageApplyMvu,
},
);
if (debugState && (sanitized.changed || sanitized.omit)) {
@@ -645,6 +683,7 @@ function sanitizeWorldInfoEntries(
blockedContents = [],
debugState = null,
regexCollector = null,
+ { applyMvu = true } = {},
) {
return (Array.isArray(entries) ? entries : [])
.map((entry, index) => {
@@ -658,6 +697,7 @@ function sanitizeWorldInfoEntries(
regexStage: "",
role: entry?.role || "system",
regexCollector,
+ applyMvu,
},
);
debugState.worldInfoBlockedContentHits += sanitized.blockedHitCount;
@@ -686,6 +726,7 @@ function sanitizeWorldInfoContext(
debugState = null,
regexCollector = null,
) {
+ const isCustomFilter = isCustomWorldInfoFilterEnabled(settings);
const rawDebug =
worldInfo?.debug && typeof worldInfo.debug === "object"
? worldInfo.debug
@@ -708,6 +749,7 @@ function sanitizeWorldInfoContext(
runtimeBlockedContents,
debugState,
regexCollector,
+ { applyMvu: !isCustomFilter },
);
const afterEntries = sanitizeWorldInfoEntries(
settings,
@@ -716,6 +758,7 @@ function sanitizeWorldInfoContext(
runtimeBlockedContents,
debugState,
regexCollector,
+ { applyMvu: !isCustomFilter },
);
const atDepthEntries = sanitizeWorldInfoEntries(
settings,
@@ -724,6 +767,7 @@ function sanitizeWorldInfoContext(
runtimeBlockedContents,
debugState,
regexCollector,
+ { applyMvu: !isCustomFilter },
);
const additionalMessages = (Array.isArray(worldInfo?.additionalMessages)
? worldInfo.additionalMessages
@@ -740,6 +784,7 @@ function sanitizeWorldInfoContext(
regexStage: "",
role: message?.role || "system",
regexCollector,
+ applyMvu: !isCustomFilter,
},
);
debugState.worldInfoBlockedContentHits += sanitized.blockedHitCount;
@@ -752,6 +797,8 @@ function sanitizeWorldInfoContext(
return {
...message,
content: sanitized.text,
+ source: String(message?.source || "worldInfo-atDepth"),
+ sourceKey: String(message?.sourceKey || "taskAdditionalMessages"),
};
})
.filter(Boolean);
@@ -1012,6 +1059,7 @@ function extractWorldInfoChatMessages(context = {}) {
}
export async function buildTaskPrompt(settings = {}, taskType, context = {}) {
+ const isCustomFilter = isCustomWorldInfoFilterEnabled(settings);
const profile = getActiveTaskProfile(settings, taskType);
const legacyPrompt = getLegacyPromptForTask(settings, taskType);
const promptRegexInput = { entries: [] };
@@ -1134,6 +1182,7 @@ export async function buildTaskPrompt(settings = {}, taskType, context = {}) {
);
}
+ const blockApplyMvu = !(isCustomFilter && blockUsesWorldInfoContent(block));
const sanitizedBlockContent = sanitizeTaskPromptText(
settings,
taskType,
@@ -1144,6 +1193,7 @@ export async function buildTaskPrompt(settings = {}, taskType, context = {}) {
regexStage: "",
role,
regexCollector: promptRegexInput,
+ applyMvu: blockApplyMvu,
},
);
mvuPromptDebug.worldInfoBlockedContentHits +=
@@ -1234,6 +1284,7 @@ export async function buildTaskPrompt(settings = {}, taskType, context = {}) {
message.content,
{
source: "worldInfo-atDepth",
+ sourceKey: "taskAdditionalMessages",
},
);
if (executionMessage) {
@@ -1366,6 +1417,12 @@ export async function buildTaskPrompt(settings = {}, taskType, context = {}) {
export function buildTaskLlmPayload(promptBuild = null, fallbackUserPrompt = "") {
const runtimeMvu = promptBuild?.__mvuRuntime || {};
const taskType = String(promptBuild?.debug?.taskType || "");
+ const isCustomFilter =
+ String(
+ promptBuild?.worldInfo?.debug?.customFilter?.mode ||
+ promptBuild?.worldInfoResolution?.debug?.customFilter?.mode ||
+ "default",
+ ).trim() === "custom";
const blockedContents = Array.isArray(runtimeMvu?.blockedContents)
? runtimeMvu.blockedContents
: [];
@@ -1390,6 +1447,8 @@ export function buildTaskLlmPayload(promptBuild = null, fallbackUserPrompt = "")
{
blockedContents,
regexStage: "",
+ applyMvu: (message) =>
+ !(isCustomFilter && messageUsesWorldInfoContent(message)),
},
);
@@ -1451,6 +1510,8 @@ export function buildTaskLlmPayload(promptBuild = null, fallbackUserPrompt = "")
{
blockedContents,
regexStage: "",
+ applyMvu: (message) =>
+ !(isCustomFilter && messageUsesWorldInfoContent(message)),
},
);
diff --git a/task-worldinfo.js b/task-worldinfo.js
index 1d9bca9..46432c5 100644
--- a/task-worldinfo.js
+++ b/task-worldinfo.js
@@ -71,6 +71,14 @@ function createMvuCollector() {
};
}
+function createCustomFilterCollector() {
+ return {
+ filteredEntries: [],
+ lazyFilteredEntries: [],
+ seenEntries: new Set(),
+ };
+}
+
function registerIgnoredEntryLookup(collector, worldbookName, identifier, meta) {
const normalizedIdentifier = normalizeKey(identifier);
if (!collector || !normalizedIdentifier) return;
@@ -80,6 +88,36 @@ function registerIgnoredEntryLookup(collector, worldbookName, identifier, meta)
);
}
+function registerCustomFilteredEntry(
+ collector,
+ entry = {},
+ matchedKeyword = "",
+ { lazy = false } = {},
+) {
+ if (!collector || !entry) return;
+
+ const worldbook = normalizeKey(entry.worldbook);
+ const name = normalizeKey(entry.name);
+ const identity = `${worldbook}:${entry.uid || 0}:${name}:${String(matchedKeyword || "")}`;
+ if (collector.seenEntries.has(identity)) {
+ return;
+ }
+ collector.seenEntries.add(identity);
+
+ const meta = {
+ worldbook,
+ name,
+ matchedKeyword: String(matchedKeyword || ""),
+ reason: "custom_keyword",
+ };
+
+ if (lazy) {
+ collector.lazyFilteredEntries.push(meta);
+ } else {
+ collector.filteredEntries.push(meta);
+ }
+}
+
function registerIgnoredWorldInfoEntry(
collector,
entry = {},
@@ -175,6 +213,26 @@ function buildMvuDebugSummary(collector) {
};
}
+function buildCustomFilterDebugSummary(
+ collector,
+ { filterMode = "default", customFilterKeywords = [] } = {},
+) {
+ const filteredEntries = Array.isArray(collector?.filteredEntries)
+ ? collector.filteredEntries
+ : [];
+ const lazyFilteredEntries = Array.isArray(collector?.lazyFilteredEntries)
+ ? collector.lazyFilteredEntries
+ : [];
+
+ return {
+ mode: String(filterMode || "default"),
+ keywords: [...(Array.isArray(customFilterKeywords) ? customFilterKeywords : [])],
+ filteredEntryCount: filteredEntries.length + lazyFilteredEntries.length,
+ filteredEntries: [...filteredEntries, ...lazyFilteredEntries],
+ lazyFilteredEntryCount: lazyFilteredEntries.length,
+ };
+}
+
function getStContext() {
try {
return globalThis.SillyTavern?.getContext?.() || {};
@@ -741,7 +799,13 @@ function selectActivatedEntries(
async function loadNormalizedWorldbookEntries(
worldbookHost,
worldbookName,
- { mvuCollector = null, lazy = false } = {},
+ {
+ mvuCollector = null,
+ lazy = false,
+ filterMode = "default",
+ customFilterKeywords = [],
+ customFilterCollector = null,
+ } = {},
) {
const normalizedName = normalizeKey(worldbookName);
if (!normalizedName || typeof worldbookHost?.getWorldbook !== "function") {
@@ -776,12 +840,33 @@ async function loadNormalizedWorldbookEntries(
},
normalizedName,
);
- const ignoreReason = getMvuIgnoreReason(normalizedEntry);
- if (ignoreReason) {
- registerIgnoredWorldInfoEntry(mvuCollector, normalizedEntry, ignoreReason, {
- lazy,
- });
- continue;
+ if (String(filterMode || "default") === "custom") {
+ if (!normalizedEntry.enabled) {
+ continue;
+ }
+ if (Array.isArray(customFilterKeywords) && customFilterKeywords.length > 0) {
+ const nameLower = normalizedEntry.name.toLowerCase();
+ const matchedKeyword = customFilterKeywords.find((keyword) =>
+ nameLower.includes(String(keyword || "")),
+ );
+ if (matchedKeyword) {
+ registerCustomFilteredEntry(
+ customFilterCollector,
+ normalizedEntry,
+ matchedKeyword,
+ { lazy },
+ );
+ continue;
+ }
+ }
+ } else {
+ const ignoreReason = getMvuIgnoreReason(normalizedEntry);
+ if (ignoreReason) {
+ registerIgnoredWorldInfoEntry(mvuCollector, normalizedEntry, ignoreReason, {
+ lazy,
+ });
+ continue;
+ }
}
normalizedEntries.push(normalizedEntry);
}
@@ -789,7 +874,10 @@ async function loadNormalizedWorldbookEntries(
return normalizedEntries;
}
-async function collectAllWorldbookEntries(worldbookHost = null) {
+async function collectAllWorldbookEntries(
+ worldbookHost = null,
+ { filterMode = "default", customFilterKeywords = [] } = {},
+) {
const resolvedWorldbookHost = worldbookHost || (await getWorldbookHost());
const {
getWorldbook,
@@ -883,6 +971,10 @@ async function collectAllWorldbookEntries(worldbookHost = null) {
sourceLabel,
fallback,
snapshotRevision: Number(snapshotRevision || 0),
+ filterMode: String(filterMode || "default"),
+ customFilterKeywords: Array.isArray(customFilterKeywords)
+ ? customFilterKeywords
+ : [],
});
debug.cache.key = cacheKey;
@@ -902,6 +994,12 @@ async function collectAllWorldbookEntries(worldbookHost = null) {
worldbookCount: worldbookEntriesCache.entries.length,
loadMs: worldbookEntriesCache.debug?.loadMs || 0,
mvu: worldbookEntriesCache.debug?.mvu || buildMvuDebugSummary(null),
+ customFilter:
+ worldbookEntriesCache.debug?.customFilter ||
+ buildCustomFilterDebugSummary(null, {
+ filterMode,
+ customFilterKeywords,
+ }),
cache: {
...debug.cache,
hit: true,
@@ -915,6 +1013,7 @@ async function collectAllWorldbookEntries(worldbookHost = null) {
const loadedNames = new Set();
const startedAt = Date.now();
const mvuCollector = createMvuCollector();
+ const customFilterCollector = createCustomFilterCollector();
async function loadWorldbookOnce(worldbookName) {
const normalizedName = normalizeKey(worldbookName);
@@ -925,7 +1024,12 @@ async function collectAllWorldbookEntries(worldbookHost = null) {
const entries = await loadNormalizedWorldbookEntries(
resolvedWorldbookHost,
normalizedName,
- { mvuCollector },
+ {
+ mvuCollector,
+ filterMode,
+ customFilterKeywords,
+ customFilterCollector,
+ },
);
allEntries.push(...entries);
} catch (error) {
@@ -944,6 +1048,10 @@ async function collectAllWorldbookEntries(worldbookHost = null) {
debug.worldbookCount = allEntries.length;
debug.loadMs = Date.now() - startedAt;
debug.mvu = buildMvuDebugSummary(mvuCollector);
+ debug.customFilter = buildCustomFilterDebugSummary(customFilterCollector, {
+ filterMode,
+ customFilterKeywords,
+ });
worldbookEntriesCache = {
key: cacheKey,
createdAt: Date.now(),
@@ -1024,6 +1132,8 @@ function buildAdditionalMessages(entries = []) {
.map((entry) => ({
role: entry.role,
content: String(entry.content || "").trim(),
+ source: "worldInfo-atDepth",
+ sourceKey: "taskAdditionalMessages",
}))
.filter((entry) => entry.content);
}
@@ -1131,6 +1241,14 @@ export async function resolveTaskWorldInfo({
userMessage = "",
templateContext = {},
} = {}) {
+ const filterMode = String(settings.worldInfoFilterMode || "default").trim();
+ const isCustomFilter = filterMode === "custom";
+ const customFilterKeywords = isCustomFilter
+ ? String(settings.worldInfoFilterCustomKeywords || "")
+ .split(",")
+ .map((keyword) => keyword.trim().toLowerCase())
+ .filter(Boolean)
+ : [];
const result = {
beforeEntries: [],
afterEntries: [],
@@ -1172,12 +1290,19 @@ export async function resolveTaskWorldInfo({
warnings: [],
resolvedEntries: [],
mvu: buildMvuDebugSummary(null),
+ customFilter: buildCustomFilterDebugSummary(null, {
+ filterMode,
+ customFilterKeywords,
+ }),
},
};
try {
const worldbookHost = await getWorldbookHost();
- const collected = await collectAllWorldbookEntries(worldbookHost);
+ const collected = await collectAllWorldbookEntries(worldbookHost, {
+ filterMode,
+ customFilterKeywords,
+ });
const allEntries = Array.isArray(collected?.entries) ? collected.entries : [];
const blockedContents = Array.isArray(collected?.blockedContents)
? collected.blockedContents
@@ -1210,6 +1335,14 @@ export async function resolveTaskWorldInfo({
collected?.debug?.mvu && typeof collected.debug.mvu === "object"
? { ...collected.debug.mvu }
: buildMvuDebugSummary(null),
+ customFilter:
+ collected?.debug?.customFilter &&
+ typeof collected.debug.customFilter === "object"
+ ? { ...collected.debug.customFilter }
+ : buildCustomFilterDebugSummary(null, {
+ filterMode,
+ customFilterKeywords,
+ }),
};
if (allEntries.length === 0) {
return result;
@@ -1258,6 +1391,7 @@ export async function resolveTaskWorldInfo({
ignoredLookup,
seenEntries: new Set(),
};
+ const lazyCustomFilterCollector = createCustomFilterCollector();
const knownWorldbooks = new Set(
allEntries.map((entry) => entry.worldbook).filter(Boolean),
);
@@ -1272,6 +1406,9 @@ export async function resolveTaskWorldInfo({
{
mvuCollector: lazyMvuCollector,
lazy: true,
+ filterMode,
+ customFilterKeywords,
+ customFilterCollector: lazyCustomFilterCollector,
},
);
knownWorldbooks.add(normalizedWorldbook);
@@ -1292,6 +1429,29 @@ export async function resolveTaskWorldInfo({
newLazyIgnoredEntries.length,
};
lazyMvuCollector.lazyFilteredEntries = [];
+ if (isCustomFilter) {
+ const newLazyCustomEntries = [
+ ...lazyCustomFilterCollector.lazyFilteredEntries,
+ ];
+ if (newLazyCustomEntries.length > 0) {
+ result.debug.customFilter = {
+ ...result.debug.customFilter,
+ filteredEntries: [
+ ...(Array.isArray(result.debug.customFilter?.filteredEntries)
+ ? result.debug.customFilter.filteredEntries
+ : []),
+ ...newLazyCustomEntries,
+ ],
+ filteredEntryCount:
+ Number(result.debug.customFilter?.filteredEntryCount || 0) +
+ newLazyCustomEntries.length,
+ lazyFilteredEntryCount:
+ Number(result.debug.customFilter?.lazyFilteredEntryCount || 0) +
+ newLazyCustomEntries.length,
+ };
+ }
+ lazyCustomFilterCollector.lazyFilteredEntries = [];
+ }
return lazyEntries;
};
@@ -1312,12 +1472,14 @@ export async function resolveTaskWorldInfo({
templateContext: normalizedTemplateContext,
currentActivatedEntries: [...allActivated.values()],
loadWorldbookEntries: lazyLoadWorldbookEntries,
- resolveIgnoredEntry: (worldbookName, identifier) =>
- findIgnoredWorldInfoEntry(
- { ignoredLookup },
- worldbookName,
- identifier,
- ),
+ resolveIgnoredEntry: isCustomFilter
+ ? () => null
+ : (worldbookName, identifier) =>
+ findIgnoredWorldInfoEntry(
+ { ignoredLookup },
+ worldbookName,
+ identifier,
+ ),
},
);
@@ -1384,10 +1546,19 @@ export async function resolveTaskWorldInfo({
recursionWarnings.add(String(warning || ""));
}
- const mvuSanitized = sanitizeMvuContent(renderedContent, {
- mode: "aggressive",
- blockedContents,
- });
+ const mvuSanitized = isCustomFilter
+ ? {
+ text: renderedContent,
+ changed: false,
+ dropped: false,
+ reasons: [],
+ blockedHitCount: 0,
+ artifactRemovedCount: 0,
+ }
+ : sanitizeMvuContent(renderedContent, {
+ mode: "aggressive",
+ blockedContents,
+ });
if (mvuSanitized.dropped) {
const warning = `世界书条目 ${entry.name} 渲染结果命中 MVU 规则,已跳过`;
if (!result.debug.warnings.includes(warning)) {
diff --git a/tests/default-settings.mjs b/tests/default-settings.mjs
index 4a40dc6..d744a33 100644
--- a/tests/default-settings.mjs
+++ b/tests/default-settings.mjs
@@ -129,6 +129,8 @@ assert.equal(defaultSettings.enableReflection, true);
assert.equal(defaultSettings.consolidationAutoMinNewNodes, 2);
assert.equal(defaultSettings.enableAutoCompression, true);
assert.equal(defaultSettings.compressionEveryN, 10);
+assert.equal(defaultSettings.worldInfoFilterMode, "default");
+assert.equal(defaultSettings.worldInfoFilterCustomKeywords, "");
assert.equal("maintenanceAutoMinNewNodes" in defaultSettings, false);
assert.equal(defaultSettings.embeddingTransportMode, "direct");
assert.equal(defaultSettings.taskProfilesVersion, 3);
diff --git a/tests/task-worldinfo.mjs b/tests/task-worldinfo.mjs
index a8786fe..ed115dc 100644
--- a/tests/task-worldinfo.mjs
+++ b/tests/task-worldinfo.mjs
@@ -122,6 +122,16 @@ const inlineDataTemplateEntry = createWorldbookEntry({
order: 22,
});
+const commentKeywordProbeEntry = createWorldbookEntry({
+ uid: 14,
+ name: "备注命中测试",
+ comment: "常驻备注",
+ content: "这条只用于验证 comment 不参与自定义过滤。",
+ strategyType: "selective",
+ keys: ["绝不会匹配到这里"],
+ order: 23,
+});
+
const extensionLiteralEntry = createWorldbookEntry({
uid: 4,
name: "扩展语义正文",
@@ -214,6 +224,7 @@ const worldbooksByName = {
inlineSummaryEntry,
inlineDataSummaryEntry,
inlineDataTemplateEntry,
+ commentKeywordProbeEntry,
extensionLiteralEntry,
externalInlineEntry,
mvuLazyProbeEntry,
@@ -251,7 +262,9 @@ try {
}));
const { resolveTaskWorldInfo } = await import("../task-worldinfo.js");
- const { buildTaskPrompt } = await import("../prompt-builder.js");
+ const { buildTaskPrompt, buildTaskLlmPayload } = await import(
+ "../prompt-builder.js"
+ );
const emptyTriggerWorldInfo = await resolveTaskWorldInfo({
chatMessages: [],
@@ -335,6 +348,132 @@ try {
),
true,
);
+ assert.equal(worldInfo.debug.customFilter.mode, "default");
+ assert.equal(worldInfo.debug.customFilter.filteredEntryCount, 0);
+
+ const customWorldInfo = await resolveTaskWorldInfo({
+ settings: {
+ worldInfoFilterMode: "custom",
+ worldInfoFilterCustomKeywords: "",
+ },
+ templateContext: {
+ recentMessages: "我们继续调查那条线索",
+ charName: "Alice",
+ },
+ userMessage: "继续调查",
+ });
+
+ assert.equal(
+ customWorldInfo.beforeEntries.some(
+ (entry) => entry.sourceName === "[mvu_update] 状态同步",
+ ),
+ true,
+ );
+ assert.equal(
+ customWorldInfo.beforeEntries.some(
+ (entry) => entry.sourceName === "MVU 启发式条目",
+ ),
+ true,
+ );
+ assert.match(
+ customWorldInfo.beforeText,
+ /
secret=true<\/status_current_variable>/,
+ );
+ assert.doesNotMatch(
+ customWorldInfo.beforeText,
+ /控制摘要:隐藏线索:Alice 正在调查/,
+ );
+ assert.equal(
+ customWorldInfo.allEntries.some((entry) => entry.name === "EW/Dyn/线索"),
+ false,
+ );
+ assert.equal(customWorldInfo.debug.mvu.filteredEntryCount, 0);
+ assert.equal(customWorldInfo.debug.customFilter.mode, "custom");
+ assert.equal(customWorldInfo.debug.customFilter.filteredEntryCount, 0);
+
+ const keywordWorldInfo = await resolveTaskWorldInfo({
+ settings: {
+ worldInfoFilterMode: "custom",
+ worldInfoFilterCustomKeywords: "常驻",
+ },
+ templateContext: {
+ recentMessages: "我们继续调查那条线索",
+ charName: "Alice",
+ },
+ userMessage: "继续调查",
+ });
+
+ assert.equal(
+ keywordWorldInfo.beforeEntries.some(
+ (entry) => entry.sourceName === "常驻设定",
+ ),
+ false,
+ );
+ assert.equal(
+ keywordWorldInfo.allEntries.some((entry) => entry.name === "备注命中测试"),
+ true,
+ );
+ assert.equal(keywordWorldInfo.debug.customFilter.filteredEntryCount, 1);
+ assert.equal(
+ keywordWorldInfo.debug.customFilter.filteredEntries[0].name,
+ "常驻设定",
+ );
+ assert.equal(
+ keywordWorldInfo.debug.customFilter.filteredEntries[0].matchedKeyword,
+ "常驻",
+ );
+
+ const keywordCachePrime = await resolveTaskWorldInfo({
+ settings: {
+ worldInfoFilterMode: "custom",
+ worldInfoFilterCustomKeywords: "常驻,缓存探针",
+ },
+ templateContext: {
+ recentMessages: "我们继续调查那条线索",
+ charName: "Alice",
+ },
+ userMessage: "继续调查",
+ });
+ assert.equal(keywordCachePrime.debug.cache.hit, false);
+ assert.equal(keywordCachePrime.debug.customFilter.filteredEntryCount, 1);
+
+ const keywordCacheHit = await resolveTaskWorldInfo({
+ settings: {
+ worldInfoFilterMode: "custom",
+ worldInfoFilterCustomKeywords: "常驻,缓存探针",
+ },
+ templateContext: {
+ recentMessages: "我们继续调查那条线索",
+ charName: "Alice",
+ },
+ userMessage: "继续调查",
+ });
+ assert.equal(keywordCacheHit.debug.cache.hit, true);
+ assert.equal(keywordCacheHit.debug.customFilter.filteredEntryCount, 1);
+ assert.equal(
+ keywordCacheHit.debug.customFilter.filteredEntries[0].name,
+ "常驻设定",
+ );
+
+ const defaultModeWithKeywords = await resolveTaskWorldInfo({
+ settings: {
+ worldInfoFilterMode: "default",
+ worldInfoFilterCustomKeywords: "常驻",
+ },
+ templateContext: {
+ recentMessages: "我们继续调查那条线索",
+ charName: "Alice",
+ },
+ userMessage: "继续调查",
+ });
+ assert.equal(
+ defaultModeWithKeywords.beforeEntries.some(
+ (entry) => entry.sourceName === "常驻设定",
+ ),
+ true,
+ );
+ assert.equal(defaultModeWithKeywords.debug.mvu.filteredEntryCount > 0, true);
+ assert.equal(defaultModeWithKeywords.debug.customFilter.filteredEntryCount, 0);
const settings = {
taskProfiles: {
@@ -456,6 +595,82 @@ try {
assert.equal(promptBuild.additionalMessages[0].content, "这是一条 atDepth 消息。");
assert.equal(promptBuild.debug.mvu.sanitizedFieldCount >= 0, true);
+ const customPromptBuild = await buildTaskPrompt(
+ {
+ ...settings,
+ worldInfoFilterMode: "custom",
+ worldInfoFilterCustomKeywords: "",
+ },
+ "recall",
+ {
+ taskName: "recall",
+ userMessage: "继续调查",
+ recentMessages: "我们继续调查那条线索",
+ charName: "Alice",
+ },
+ );
+ assert.match(
+ customPromptBuild.systemPrompt,
+ /secret=true<\/status_current_variable>/,
+ );
+ assert.match(customPromptBuild.systemPrompt, /这一条不应该进入结果/);
+ assert.doesNotMatch(
+ customPromptBuild.systemPrompt,
+ /控制摘要:隐藏线索:Alice 正在调查/,
+ );
+ const customPayload = buildTaskLlmPayload(customPromptBuild, "unused fallback");
+ assert.equal(
+ customPayload.promptMessages.some((message) =>
+ /secret=true<\/status_current_variable>/.test(
+ message.content,
+ ),
+ ),
+ true,
+ );
+
+ const interpolatedSettings = {
+ taskProfiles: {
+ recall: {
+ activeProfileId: "interpolated",
+ profiles: [
+ {
+ id: "interpolated",
+ name: "插值预设",
+ taskType: "recall",
+ builtin: false,
+ blocks: [
+ {
+ id: "interp-system",
+ type: "custom",
+ content: "世界书插值:\\n{{worldInfoBefore}}",
+ role: "system",
+ enabled: true,
+ order: 0,
+ injectionMode: "append",
+ },
+ ],
+ },
+ ],
+ },
+ },
+ worldInfoFilterMode: "custom",
+ worldInfoFilterCustomKeywords: "",
+ };
+ const customInterpolatedPromptBuild = await buildTaskPrompt(
+ interpolatedSettings,
+ "recall",
+ {
+ taskName: "recall",
+ userMessage: "继续调查",
+ recentMessages: "我们继续调查那条线索",
+ charName: "Alice",
+ },
+ );
+ assert.match(
+ customInterpolatedPromptBuild.systemPrompt,
+ /secret=true<\/status_current_variable>/,
+ );
+
const noWorldInfoBlockSettings = {
taskProfiles: {
recall: {