Files
ST-Bionic-Memory-Ecology/plan-custom-worldinfo-filter.md
2026-04-06 23:02:04 +08:00

26 KiB
Raw Blame History

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

worldInfoFilterMode: "default",        // "default" | "custom"
worldInfoFilterCustomKeywords: "",     // 逗号分隔,如 "BME,测试"

2. panel.html — 功能开关页新增卡片

data-config-section="toggles"bme-config-grid增强能力卡片L1015 </div>之后、隐藏旧楼层卡片L1017之前插入

<div class="bme-config-card">
  <div class="bme-config-card-head">
    <div>
      <div class="bme-config-card-title">世界书过滤</div>
      <div class="bme-config-card-subtitle">
        控制 ST-BME 读取世界书条目时的过滤策略。默认自动过滤 MVU 相关条目;自定义模式仅按条目名称关键词过滤。
      </div>
    </div>
  </div>
  <div class="bme-config-row">
    <label for="bme-setting-wi-filter-mode">过滤模式</label>
    <select id="bme-setting-wi-filter-mode" class="bme-config-input">
      <option value="default">默认(自动过滤 MVU 条目)</option>
      <option value="custom">自定义(按名称关键词过滤)</option>
    </select>
  </div>
  <div id="bme-wi-filter-custom-section" style="display:none;">
    <div class="bme-config-row">
      <label for="bme-setting-wi-filter-keywords">过滤关键词</label>
      <input
        id="bme-setting-wi-filter-keywords"
        class="bme-config-input"
        type="text"
        placeholder="用逗号分隔BME,mvu,测试"
      />
    </div>
    <div class="bme-config-help">
      条目名称中包含任一关键词即跳过(不区分大小写)。留空则不过滤任何条目(仅跳过已禁用的条目)。
    </div>
  </div>
</div>

3. panel.js — 绑定 UI 与设置

_refreshConfigTabL1848 中追加:

_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 绑定之后) 追加:

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

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解析设置并透传

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 缺失:

result.debug.customFilter = {
  mode: filterMode,
  keywords: customFilterKeywords,
  filteredEntryCount: 0,
  filteredEntries: [],
  lazyFilteredEntryCount: 0,
};

4c. collectAllWorldbookEntriesL792透传

函数签名增加 { filterMode = "default", customFilterKeywords = [] } = {}

  • 创建 const customFilterCollector = createCustomFilterCollector();
  • loadWorldbookOnce 调用 loadNormalizedWorldbookEntries 时传入 { mvuCollector, filterMode, customFilterKeywords, customFilterCollector }
  • 缓存 key 中加入 filterModecustomFilterKeywords
const cacheKey = JSON.stringify({
  // ... 现有字段 ...
  filterMode,
  customFilterKeywords,
});
  • debug 和缓存对象都带上 customFilter
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. loadNormalizedWorldbookEntriesL741—— 核心改造

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

// 原:
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. __mvuBlockedContentsblockedContents~L1190

自定义模式下 mvuCollector.blockedContents 本来就不会被填入(因为 4d 不走 registerIgnoredWorldInfoEntry),所以 blockedContents 自然为空数组。__mvuBlockedContents 赋值逻辑不需要改动,空数组传过去 prompt-builder 就不会做任何内容删除。

4g. 懒加载世界书透传(~L1269

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

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-L1294lazyMvuCollector 的处理是同构的,语义也最直观:

  • 首轮加载的自定义过滤统计来自 collectAllWorldbookEntries()
  • 懒加载追加过滤统计来自 lazyCustomFilterCollector
  • 两者最终都汇总到同一个 result.debug.customFilter

4h. 调试信息——独立的 debug.customFilter

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

自定义模式下传给 createTaskEjsRenderContextresolveIgnoredEntry 改为查自定义 collector

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. sanitizeWorldInfoContextL682

已有参数 settings,读取 settings.worldInfoFilterMode

const isCustomFilter = String(settings.worldInfoFilterMode || "default").trim() === "custom";

传给 sanitizeWorldInfoEntriessanitizeTaskPromptText

const beforeEntries = sanitizeWorldInfoEntries(
  settings, taskType, worldInfo?.beforeEntries,
  runtimeBlockedContents, debugState, regexCollector,
  { applyMvu: !isCustomFilter },  // 新增
);
// afterEntries, atDepthEntries 同理

additionalMessages 的 sanitizeTaskPromptText 调用也传 applyMvu: !isCustomFilter

5b. sanitizeWorldInfoEntriesL641

签名增加 options = {} 参数:

function sanitizeWorldInfoEntries(
  settings, taskType, entries, blockedContents,
  debugState, regexCollector,
  { applyMvu = true } = {},
) {

内部调用 sanitizeTaskPromptText 时透传 applyMvu

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

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() 里计算:

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 调用时直接按模式传参:
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 条目不再被过滤

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

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 读到

// 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 区分模式和关键词

// 先跑一次默认模式预热缓存
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

// 用 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",而是:

{
  id: "b-interp",
  type: "custom",
  content: "世界书插值:\\n{{worldInfoBefore}}",
  role: "system",
  enabled: true,
  order: 0,
  injectionMode: "append",
}

断言:

assert.match(customInterpolatedPromptBuild.systemPrompt, /status_current_variable/);

这能确保 5c 的 blockUsesWorldInfoContent() 不是只覆盖 sourceKey,而是真的覆盖到了插值路径。

6g. cache hit 时 debug.customFilter 仍正确返回

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. 默认模式回归保护

必须补一条明确的“默认模式没变”回归测试,避免实现时误伤现有链路:

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 影响可忽略。