// ST-BME: 任务级世界书激活引擎 // 对标 SillyTavern 原生世界书扫描逻辑,并在私有 prompt 组装阶段 // 提供最小 EJS 配合能力,用于 getwi / activewi。 import { createTaskEjsRenderContext, evalTaskEjsTemplate, inspectTaskEjsRuntimeBackend, substituteTaskEjsParams, } from "./task-ejs.js"; import { isLikelyMvuWorldInfoContent, isMvuTaggedWorldInfoNameOrComment, sanitizeMvuContent, } from "./mvu-compat.js"; const WI_POSITION = { before: 0, after: 1, EMTop: 2, EMBottom: 3, ANTop: 4, ANBottom: 5, atDepth: 6, }; const WI_LOGIC = { AND_ANY: 0, NOT_ALL: 1, NOT_ANY: 2, AND_ALL: 3, }; const DEPTH_MAPPING = { [WI_POSITION.before]: 4, [WI_POSITION.after]: 3, [WI_POSITION.EMTop]: 2, [WI_POSITION.EMBottom]: 1, [WI_POSITION.ANTop]: 1, [WI_POSITION.ANBottom]: -1, }; const DEFAULT_DEPTH = 4; const DEFAULT_MAX_RESOLVE_PASSES = 10; const WORLDINFO_CACHE_TTL_MS = 3000; const KNOWN_DECORATORS = ["@@activate", "@@dont_activate"]; let worldbookEntriesCache = { key: "", createdAt: 0, expiresAt: 0, entries: [], blockedContents: [], ignoredEntries: [], ignoredLookup: new Map(), debug: null, }; function buildIgnoredEntryLookupKey(worldbookName, identifier) { return `${normalizeKey(worldbookName)}::${normalizeKey(identifier)}`; } function createMvuCollector() { return { blockedContents: [], filteredEntries: [], lazyFilteredEntries: [], ignoredLookup: new Map(), seenEntries: new Set(), }; } function registerIgnoredEntryLookup(collector, worldbookName, identifier, meta) { const normalizedIdentifier = normalizeKey(identifier); if (!collector || !normalizedIdentifier) return; collector.ignoredLookup.set( buildIgnoredEntryLookupKey(worldbookName, normalizedIdentifier), meta, ); } function registerIgnoredWorldInfoEntry( collector, entry = {}, reason = "", { lazy = false } = {}, ) { if (!collector || !entry) return; const worldbook = normalizeKey(entry.worldbook); const name = normalizeKey(entry.name); const comment = normalizeKey(entry.comment); const content = String(entry.cleanContent || entry.content || "").trim(); const identity = `${worldbook}:${entry.uid || 0}:${name}:${reason}`; const meta = { worldbook, name: comment || name, sourceName: name, reason: String(reason || ""), }; registerIgnoredEntryLookup(collector, worldbook, name, meta); registerIgnoredEntryLookup(collector, worldbook, comment, meta); if (collector.seenEntries.has(identity)) { return; } collector.seenEntries.add(identity); if (content) { collector.blockedContents.push(content); } if (lazy) { collector.lazyFilteredEntries.push(meta); } else { collector.filteredEntries.push(meta); } } function findIgnoredWorldInfoEntry(collector, worldbookName, identifier) { if (!collector || !normalizeKey(identifier)) { return null; } const normalizedWorldbook = normalizeKey(worldbookName); const normalizedIdentifier = normalizeKey(identifier); const exact = collector.ignoredLookup.get( buildIgnoredEntryLookupKey(normalizedWorldbook, normalizedIdentifier), ); if (exact) { return exact; } if (normalizedWorldbook) { return null; } for (const [lookupKey, value] of collector.ignoredLookup.entries()) { if (lookupKey.endsWith(`::${normalizedIdentifier}`)) { return value; } } return null; } function getMvuIgnoreReason(entry = {}) { if (isMvuTaggedWorldInfoNameOrComment(entry.name, entry.comment)) { return "mvu_tagged"; } if (isLikelyMvuWorldInfoContent(entry.cleanContent || entry.content)) { return "mvu_content"; } return ""; } function buildMvuDebugSummary(collector) { const filteredEntries = Array.isArray(collector?.filteredEntries) ? collector.filteredEntries : []; const lazyFilteredEntries = Array.isArray(collector?.lazyFilteredEntries) ? collector.lazyFilteredEntries : []; const blockedContents = Array.isArray(collector?.blockedContents) ? collector.blockedContents : []; return { filteredEntryCount: filteredEntries.length, filteredEntries: [...filteredEntries, ...lazyFilteredEntries], blockedContentsCount: uniq(blockedContents.map((item) => String(item || "").trim()).filter(Boolean)).length, lazyFilteredEntryCount: lazyFilteredEntries.length, }; } function getStContext() { try { return globalThis.SillyTavern?.getContext?.() || {}; } catch { return {}; } } function getLegacyWorldbookApi(name) { const fn = globalThis[name]; return typeof fn === "function" ? fn : null; } async function getWorldbookHost() { const legacyGetWorldbook = getLegacyWorldbookApi("getWorldbook"); const legacyGetLorebookEntries = getLegacyWorldbookApi("getLorebookEntries"); const legacyGetCharWorldbookNames = getLegacyWorldbookApi( "getCharWorldbookNames", ); try { const { getHostAdapter } = await import("./host-adapter/index.js"); const adapter = getHostAdapter?.() || null; const adapterSnapshot = adapter?.getSnapshot?.() || null; const worldbookHost = adapter?.worldbook || null; if (typeof worldbookHost?.getWorldbook === "function") { const capabilitySupport = worldbookHost.readCapabilitySupport?.() || {}; const bridgeGetLorebookEntries = typeof worldbookHost.getLorebookEntries === "function" ? worldbookHost.getLorebookEntries : null; const bridgeGetCharWorldbookNames = typeof worldbookHost.getCharWorldbookNames === "function" ? worldbookHost.getCharWorldbookNames : null; const supplementedCapabilities = []; const missingCapabilities = []; const resolvedGetLorebookEntries = bridgeGetLorebookEntries || legacyGetLorebookEntries; if (!bridgeGetLorebookEntries) { if (resolvedGetLorebookEntries) { supplementedCapabilities.push("getLorebookEntries"); } else { missingCapabilities.push("getLorebookEntries"); } } const resolvedGetCharWorldbookNames = bridgeGetCharWorldbookNames || legacyGetCharWorldbookNames; if (!bridgeGetCharWorldbookNames) { if (resolvedGetCharWorldbookNames) { supplementedCapabilities.push("getCharWorldbookNames"); } else { missingCapabilities.push("getCharWorldbookNames"); } } return { getWorldbook: worldbookHost.getWorldbook, getLorebookEntries: resolvedGetLorebookEntries, getCharWorldbookNames: resolvedGetCharWorldbookNames, sourceLabel: capabilitySupport.sourceLabel || "host-adapter.worldbook", fallback: Boolean(capabilitySupport.fallback) || supplementedCapabilities.length > 0, capabilityStatus: Object.freeze({ mode: capabilitySupport.mode || "unknown", supplementedCapabilities: Object.freeze(supplementedCapabilities), missingCapabilities: Object.freeze(missingCapabilities), }), snapshotRevision: Number(adapterSnapshot?.snapshotRevision || 0), }; } } catch (error) { console.debug( "[ST-BME] task-worldinfo 读取 worldbook bridge 失败,回退到 legacy 宿主接口", error, ); } const missingCapabilities = []; if (typeof legacyGetLorebookEntries !== "function") { missingCapabilities.push("getLorebookEntries"); } if (typeof legacyGetCharWorldbookNames !== "function") { missingCapabilities.push("getCharWorldbookNames"); } return { getWorldbook: legacyGetWorldbook, getLorebookEntries: legacyGetLorebookEntries, getCharWorldbookNames: legacyGetCharWorldbookNames, sourceLabel: "legacy.globalThis", fallback: true, capabilityStatus: Object.freeze({ mode: "legacy", supplementedCapabilities: Object.freeze([]), missingCapabilities: Object.freeze(missingCapabilities), }), snapshotRevision: 0, }; } function normalizeKey(value) { return String(value ?? "").trim(); } function escapeRegExp(value) { return String(value || "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } function uniq(values = []) { return [...new Set((Array.isArray(values) ? values : []).filter(Boolean))]; } function groupBy(items = [], getKey) { const grouped = {}; for (const item of items) { const key = String(getKey(item) ?? ""); if (!grouped[key]) { grouped[key] = []; } grouped[key].push(item); } return grouped; } function sum(values = []) { return (Array.isArray(values) ? values : []).reduce( (total, value) => total + (Number(value) || 0), 0, ); } function simpleHash(input = "") { let hash = 2166136261; const text = String(input || ""); for (let index = 0; index < text.length; index += 1) { hash ^= text.charCodeAt(index); hash = Math.imul(hash, 16777619); } return `h${(hash >>> 0).toString(16).padStart(8, "0")}`; } function parseDecorators(content = "") { const rawContent = String(content || ""); if (!rawContent.startsWith("@@")) { return { decorators: [], cleanContent: rawContent, }; } const lines = rawContent.split("\n"); const decorators = []; let index = 0; while (index < lines.length) { const line = String(lines[index] || ""); if (!line.startsWith("@@")) { break; } if (line.startsWith("@@@")) { break; } const matched = KNOWN_DECORATORS.find((decorator) => line.startsWith(decorator), ); if (!matched) { break; } decorators.push(line); index += 1; } return { decorators, cleanContent: index > 0 ? lines.slice(index).join("\n") : rawContent, }; } function normalizeEntry(raw = {}, worldbookName = "") { const { decorators, cleanContent } = parseDecorators(raw.content || ""); const positionType = raw.position?.type ?? "at_depth"; let position = WI_POSITION.atDepth; let role = raw.position?.role ?? "system"; if ( positionType === "before_char" || positionType === "before" || positionType === "before_character_definition" ) { position = WI_POSITION.before; } else if ( positionType === "after_char" || positionType === "after" || positionType === "after_character_definition" ) { position = WI_POSITION.after; } else if ( positionType === "em_top" || positionType === "before_example_messages" ) { position = WI_POSITION.EMTop; } else if ( positionType === "em_bottom" || positionType === "after_example_messages" ) { position = WI_POSITION.EMBottom; } else if ( positionType === "an_top" || positionType === "before_author_note" ) { position = WI_POSITION.ANTop; } else if ( positionType === "an_bottom" || positionType === "after_author_note" ) { position = WI_POSITION.ANBottom; } else if (positionType === "at_depth_as_assistant") { position = WI_POSITION.atDepth; role = "assistant"; } else if (positionType === "at_depth_as_user") { position = WI_POSITION.atDepth; role = "user"; } else if (typeof raw.extensions?.position === "number") { position = raw.extensions.position; } let enabled; if (typeof raw.disable === "boolean") { enabled = !raw.disable; } else if (typeof raw.enabled === "boolean") { enabled = raw.enabled; } else { enabled = true; } let selectiveLogic = WI_LOGIC.AND_ANY; const logic = raw.strategy?.keys_secondary?.logic; if (logic === "not_all") selectiveLogic = WI_LOGIC.NOT_ALL; if (logic === "not_any") selectiveLogic = WI_LOGIC.NOT_ANY; if (logic === "and_all") selectiveLogic = WI_LOGIC.AND_ALL; return { uid: Number(raw.uid) || 0, name: normalizeKey(raw.name), comment: normalizeKey(raw.comment), content: String(raw.content || ""), cleanContent, decorators, enabled, worldbook: normalizeKey(worldbookName), constant: raw.strategy?.type === "constant", selective: raw.strategy?.type === "selective", keys: Array.isArray(raw.strategy?.keys) ? raw.strategy.keys : [], keysSecondary: Array.isArray(raw.strategy?.keys_secondary?.keys) ? raw.strategy.keys_secondary.keys : [], selectiveLogic, useProbability: (raw.extensions?.useProbability === true || raw.probability !== undefined) && Number(raw.probability ?? 100) < 100, probability: Number(raw.probability ?? 100), caseSensitive: Boolean(raw.extra?.caseSensitive), matchWholeWords: Boolean(raw.extra?.matchWholeWords), group: normalizeKey(raw.extra?.group), groupOverride: Boolean(raw.extra?.groupOverride), groupWeight: Number(raw.extra?.groupWeight ?? 100), useGroupScoring: Boolean(raw.extra?.useGroupScoring), position, depth: Number(raw.position?.depth ?? 0), order: Number(raw.position?.order ?? 100), role, }; } function parseRegexFromString(input = "") { const match = /^\/(.*?)\/([gimsuy]*)$/.exec(String(input || "").trim()); if (!match) return null; try { return new RegExp(match[1], match[2]); } catch { return null; } } function deterministicPercent(seed) { const hashed = simpleHash(seed).replace(/^h/, ""); const parsed = Number.parseInt(hashed.slice(0, 8), 16); if (!Number.isFinite(parsed)) return 100; return (parsed % 100) + 1; } function deterministicWeightedIndex(weights = [], seed = "") { const normalized = weights.map((weight) => Math.max(0, Math.trunc(Number(weight) || 0)), ); const totalWeight = sum(normalized); if (totalWeight <= 0) return -1; const hashed = simpleHash(seed).replace(/^h/, ""); let roll = (Number.parseInt(hashed.slice(0, 8), 16) % totalWeight) + 1; for (let index = 0; index < normalized.length; index += 1) { roll -= normalized[index]; if (roll <= 0) { return index; } } return normalized.length - 1; } function matchKeys(haystack = "", needle = "", entry) { const regex = parseRegexFromString(String(needle || "").trim()); if (regex) { return regex.test(haystack); } const source = entry.caseSensitive ? haystack : haystack.toLowerCase(); const target = entry.caseSensitive ? String(needle || "").trim() : String(needle || "") .trim() .toLowerCase(); if (!target) return false; if (entry.matchWholeWords) { const words = target.split(/\s+/); if (words.length > 1) { return source.includes(target); } return new RegExp(`(?:^|\\W)(${escapeRegExp(target)})(?:$|\\W)`).test( source, ); } return source.includes(target); } function getScore(trigger = "", entry) { let primaryScore = 0; let secondaryScore = 0; for (const key of entry.keys) { if (matchKeys(trigger, key, entry)) primaryScore += 1; } for (const key of entry.keysSecondary) { if (matchKeys(trigger, key, entry)) secondaryScore += 1; } if (entry.keys.length === 0) return 0; if (entry.keysSecondary.length > 0) { if (entry.selectiveLogic === WI_LOGIC.AND_ANY) { return primaryScore + secondaryScore; } if (entry.selectiveLogic === WI_LOGIC.AND_ALL) { return secondaryScore === entry.keysSecondary.length ? primaryScore + secondaryScore : primaryScore; } } return primaryScore; } function calcDepth(entry, maxDepth) { const offset = DEPTH_MAPPING[entry.position]; if (offset == null) { return entry.depth ?? DEFAULT_DEPTH; } return offset + maxDepth; } function sortEntries(a, b) { const maxDepth = Math.max(a.depth ?? 0, b.depth ?? 0, DEFAULT_DEPTH); return ( calcDepth(b, maxDepth) - calcDepth(a, maxDepth) || (a.order ?? 100) - (b.order ?? 100) || (b.uid ?? 0) - (a.uid ?? 0) ); } function selectActivatedEntries( entries = [], trigger = "", templateContext = {}, ) { const activationSeedBase = simpleHash(String(trigger || "")); const activated = new Map(); const addActivated = (entry, activationDebug = {}) => { const key = `${entry.worldbook}:${entry.uid}:${entry.name}`; activated.set(key, { ...entry, activationDebug: { mode: activationDebug.mode || "", matchedPrimaryKey: activationDebug.matchedPrimaryKey || "", matchedSecondaryKeys: Array.isArray(activationDebug.matchedSecondaryKeys) ? activationDebug.matchedSecondaryKeys : [], }, }); }; for (const entry of entries) { if (!entry.enabled) continue; if (entry.useProbability) { const probabilityRoll = deterministicPercent( `${activationSeedBase}:prob:${entry.worldbook}:${entry.uid}:${entry.name}`, ); if (entry.probability < probabilityRoll) continue; } if (entry.constant) { addActivated(entry, { mode: "constant" }); continue; } if (entry.decorators.includes("@@activate")) { addActivated(entry, { mode: "forced" }); continue; } if (entry.decorators.includes("@@dont_activate")) continue; if (entry.keys.length === 0) continue; const matchedPrimary = entry.keys .map((key) => substituteTaskEjsParams(key, templateContext)) .find((key) => matchKeys(trigger, key, entry)); if (!matchedPrimary) continue; const hasSecondaryKeys = entry.selective && entry.keysSecondary.length > 0; if (!hasSecondaryKeys) { addActivated(entry, { mode: "selective", matchedPrimaryKey: matchedPrimary, }); continue; } let hasAnyMatch = false; let hasAllMatch = true; const matchedSecondaryKeys = []; for (const secondaryKey of entry.keysSecondary) { const substituted = substituteTaskEjsParams( secondaryKey, templateContext, ); const hasMatch = substituted.trim() !== "" && matchKeys(trigger, substituted.trim(), entry); if (hasMatch) hasAnyMatch = true; if (!hasMatch) hasAllMatch = false; if (hasMatch) matchedSecondaryKeys.push(substituted.trim()); if (entry.selectiveLogic === WI_LOGIC.AND_ANY && hasMatch) { addActivated(entry, { mode: "selective", matchedPrimaryKey: matchedPrimary, matchedSecondaryKeys, }); break; } if (entry.selectiveLogic === WI_LOGIC.NOT_ALL && !hasMatch) { addActivated(entry, { mode: "selective", matchedPrimaryKey: matchedPrimary, matchedSecondaryKeys, }); break; } } if (entry.selectiveLogic === WI_LOGIC.NOT_ANY && !hasAnyMatch) { addActivated(entry, { mode: "selective", matchedPrimaryKey: matchedPrimary, matchedSecondaryKeys, }); continue; } if (entry.selectiveLogic === WI_LOGIC.AND_ALL && hasAllMatch) { addActivated(entry, { mode: "selective", matchedPrimaryKey: matchedPrimary, matchedSecondaryKeys, }); } } if (activated.size === 0) { return []; } const grouped = groupBy([...activated.values()], (entry) => entry.group || ""); const ungrouped = grouped[""] || []; if (ungrouped.length > 0 && Object.keys(grouped).length <= 1) { return ungrouped.sort(sortEntries); } const matched = []; for (const [groupName, members] of Object.entries(grouped)) { if (groupName === "") continue; if (members.length === 1) { matched.push(members[0]); continue; } const prioritized = members.filter((entry) => entry.groupOverride); if (prioritized.length > 0) { const topOrder = Math.min( ...prioritized.map((entry) => entry.order ?? 100), ); matched.push( prioritized.find((entry) => (entry.order ?? 100) <= topOrder) || prioritized[0], ); continue; } const scored = members.filter((entry) => entry.useGroupScoring); if (scored.length > 0) { const scores = members.map((entry) => getScore(trigger, entry)); const topScore = Math.max(...scores); if (topScore > 0) { const winnerIndex = Math.max( scores.findIndex((score) => score >= topScore), 0, ); matched.push(members[winnerIndex]); continue; } } const weighted = members.filter( (entry) => !entry.groupOverride && !entry.useGroupScoring, ); if (weighted.length > 0) { const weights = weighted.map((entry) => entry.groupWeight); const winner = deterministicWeightedIndex( weights, `${activationSeedBase}:group:${groupName}:${weighted .map((entry) => `${entry.worldbook}:${entry.uid}`) .join("|")}`, ); if (winner >= 0) { matched.push(weighted[winner]); } } } return ungrouped.concat(matched).sort(sortEntries); } async function loadNormalizedWorldbookEntries( worldbookHost, worldbookName, { mvuCollector = null, lazy = false } = {}, ) { const normalizedName = normalizeKey(worldbookName); if (!normalizedName || typeof worldbookHost?.getWorldbook !== "function") { return []; } const entries = await worldbookHost.getWorldbook(normalizedName); let commentByUid = new Map(); if (typeof worldbookHost?.getLorebookEntries === "function") { try { const loreEntries = await worldbookHost.getLorebookEntries(normalizedName); commentByUid = new Map( (Array.isArray(loreEntries) ? loreEntries : []).map((entry) => [ entry.uid, String(entry.comment ?? ""), ]), ); } catch (error) { console.debug( `[ST-BME] task-worldinfo 读取 lorebook comment 失败: ${normalizedName}`, error, ); } } const normalizedEntries = []; for (const entry of Array.isArray(entries) ? entries : []) { const normalizedEntry = normalizeEntry( { ...entry, comment: commentByUid.get(entry.uid) ?? entry.comment ?? "", }, normalizedName, ); const ignoreReason = getMvuIgnoreReason(normalizedEntry); if (ignoreReason) { registerIgnoredWorldInfoEntry(mvuCollector, normalizedEntry, ignoreReason, { lazy, }); continue; } normalizedEntries.push(normalizedEntry); } return normalizedEntries; } async function collectAllWorldbookEntries(worldbookHost = null) { const resolvedWorldbookHost = worldbookHost || (await getWorldbookHost()); const { getWorldbook, getCharWorldbookNames, sourceLabel, fallback, capabilityStatus, snapshotRevision, } = resolvedWorldbookHost; const ctx = getStContext(); const debug = { sourceLabel, fallback, capabilityStatus, snapshotRevision: Number(snapshotRevision || 0), requestedWorldbooks: [], loadedWorldbooks: [], worldbookCount: 0, cache: { hit: false, key: "", ageMs: 0, ttlMs: WORLDINFO_CACHE_TTL_MS, }, loadMs: 0, }; if (!getWorldbook) { return { entries: [], debug, }; } const sourceTag = `${sourceLabel}${fallback ? ", fallback" : ""}`; const supplementedCapabilities = capabilityStatus?.supplementedCapabilities || []; const missingCapabilities = capabilityStatus?.missingCapabilities || []; if (supplementedCapabilities.length > 0) { console.debug( `[ST-BME] task-worldinfo worldbook bridge 已通过 legacy 补齐关键能力: ${supplementedCapabilities.join(", ")} [${sourceTag}]`, ); } if (missingCapabilities.length > 0) { console.warn( `[ST-BME] task-worldinfo worldbook host 缺失关键能力,将显式降级相关旧语义: ${missingCapabilities.join(", ")} [${sourceTag}]`, ); } const charWorldbooks = { primary: "", additional: [], }; if (getCharWorldbookNames) { try { const resolved = getCharWorldbookNames("current") || {}; charWorldbooks.primary = normalizeKey(resolved.primary); charWorldbooks.additional = Array.isArray(resolved.additional) ? resolved.additional.map((name) => normalizeKey(name)).filter(Boolean) : []; } catch (error) { console.debug( `[ST-BME] task-worldinfo 读取角色世界书失败 [${sourceTag}]`, error, ); } } const personaLorebook = ctx.extensionSettings?.persona_description_lorebook || ctx.powerUserSettings?.persona_description_lorebook || ctx.power_user?.persona_description_lorebook || ""; const chatLorebook = ctx.chatMetadata?.world || ""; const requestedWorldbooks = uniq( [ charWorldbooks.primary, ...(charWorldbooks.additional || []), personaLorebook, chatLorebook, ] .map((name) => normalizeKey(name)) .filter(Boolean), ); debug.requestedWorldbooks = requestedWorldbooks; const cacheKey = JSON.stringify({ chatId: ctx.chatId || globalThis.getCurrentChatId?.() || "", characterId: ctx.characterId ?? "", requestedWorldbooks, sourceLabel, fallback, snapshotRevision: Number(snapshotRevision || 0), }); debug.cache.key = cacheKey; if ( worldbookEntriesCache.key === cacheKey && worldbookEntriesCache.expiresAt > Date.now() ) { return { entries: worldbookEntriesCache.entries, blockedContents: worldbookEntriesCache.blockedContents, ignoredEntries: worldbookEntriesCache.ignoredEntries, ignoredLookup: worldbookEntriesCache.ignoredLookup, debug: { ...debug, loadedWorldbooks: worldbookEntriesCache.debug?.loadedWorldbooks || requestedWorldbooks, worldbookCount: worldbookEntriesCache.entries.length, loadMs: worldbookEntriesCache.debug?.loadMs || 0, mvu: worldbookEntriesCache.debug?.mvu || buildMvuDebugSummary(null), cache: { ...debug.cache, hit: true, ageMs: Math.max(0, Date.now() - worldbookEntriesCache.createdAt), }, }, }; } const allEntries = []; const loadedNames = new Set(); const startedAt = Date.now(); const mvuCollector = createMvuCollector(); async function loadWorldbookOnce(worldbookName) { const normalizedName = normalizeKey(worldbookName); if (!normalizedName || loadedNames.has(normalizedName)) return; loadedNames.add(normalizedName); try { const entries = await loadNormalizedWorldbookEntries( resolvedWorldbookHost, normalizedName, { mvuCollector }, ); allEntries.push(...entries); } catch (error) { console.debug( `[ST-BME] task-worldinfo 读取世界书失败: ${normalizedName} [${sourceTag}]`, error, ); } } for (const worldbookName of requestedWorldbooks) { await loadWorldbookOnce(worldbookName); } debug.loadedWorldbooks = [...loadedNames]; debug.worldbookCount = allEntries.length; debug.loadMs = Date.now() - startedAt; debug.mvu = buildMvuDebugSummary(mvuCollector); worldbookEntriesCache = { key: cacheKey, createdAt: Date.now(), expiresAt: Date.now() + WORLDINFO_CACHE_TTL_MS, entries: allEntries, blockedContents: [...mvuCollector.blockedContents], ignoredEntries: [...debug.mvu.filteredEntries], ignoredLookup: new Map(mvuCollector.ignoredLookup), debug: { ...debug, }, }; return { entries: allEntries, blockedContents: [...mvuCollector.blockedContents], ignoredEntries: [...debug.mvu.filteredEntries], ignoredLookup: new Map(mvuCollector.ignoredLookup), debug, }; } function classifyPosition(entry) { switch (entry.position) { case WI_POSITION.before: case WI_POSITION.EMTop: case WI_POSITION.ANTop: return "before"; case WI_POSITION.atDepth: return "atDepth"; case WI_POSITION.after: case WI_POSITION.EMBottom: case WI_POSITION.ANBottom: default: return "after"; } } function normalizeResolvedEntry(entry = {}, fallbackIndex = 0) { const role = ["system", "user", "assistant"].includes(entry.role) ? entry.role : "system"; return { name: normalizeKey(entry.name), sourceName: normalizeKey( entry.sourceName || entry.source_name || entry.name, ), worldbook: normalizeKey(entry.worldbook), content: String(entry.content || ""), role, position: Number(entry.position ?? WI_POSITION.after), depth: Number(entry.depth ?? 0), order: Number(entry.order ?? 100), index: fallbackIndex, activationDebug: entry.activationDebug && typeof entry.activationDebug === "object" ? { ...entry.activationDebug, } : null, }; } function sortAtDepthEntries(entries = []) { return [...entries].sort((a, b) => { const depthA = Number(a.depth ?? 0); const depthB = Number(b.depth ?? 0); return ( depthB - depthA || (a.order ?? 100) - (b.order ?? 100) || a.index - b.index ); }); } function buildAdditionalMessages(entries = []) { return sortAtDepthEntries(entries) .map((entry) => ({ role: entry.role, content: String(entry.content || "").trim(), })) .filter((entry) => entry.content); } function buildWorldInfoText(entries = []) { return entries .map((entry) => String(entry.content || "").trim()) .filter(Boolean) .join("\n\n"); } function buildActivationSourceTexts({ chatMessages = [], userMessage = "", templateContext = {}, } = {}) { const texts = []; if (Array.isArray(chatMessages)) { for (const message of chatMessages) { const text = typeof message === "string" ? message : typeof message?.content === "string" ? message.content : typeof message?.mes === "string" ? message.mes : ""; if (text) texts.push(text); } } if (typeof userMessage === "string" && userMessage.trim()) { texts.push(userMessage); } const fallbackContextFields = [ "recentMessages", "dialogueText", "userMessage", "candidateNodes", "candidateText", "nodeContent", "eventSummary", "characterSummary", "threadSummary", "contradictionSummary", ]; for (const key of fallbackContextFields) { const value = templateContext?.[key]; if (typeof value === "string" && value.trim()) { texts.push(value); } } return uniq(texts.map((text) => String(text).trim()).filter(Boolean)); } function getEntryIdentity(entry = {}) { return `${entry.worldbook}:${entry.uid}:${entry.name}`; } function toActivationMap(entries = []) { const map = new Map(); for (const entry of Array.isArray(entries) ? entries : []) { map.set(getEntryIdentity(entry), entry); } return map; } function warnLegacyEntryNames(entries = [], warnings = []) { const legacyNames = uniq( (Array.isArray(entries) ? entries : []) .map((entry) => String(entry?.name || "").trim()) .filter( (name) => name.startsWith("EW/Controller/") || name.startsWith("EW/Dyn/"), ), ); if (legacyNames.length === 0) { return; } const warning = `检测到旧 EW 命名条目 (${legacyNames.join(", ")});这些条目现在只按普通世界书条目处理,不再有专用魔法行为`; if (!warnings.includes(warning)) { warnings.push(warning); } console.warn(`[ST-BME] task-worldinfo ${warning}`); } function mergeActivationDebug(entry = {}, overrides = {}) { return { ...(entry.activationDebug && typeof entry.activationDebug === "object" ? entry.activationDebug : {}), ...overrides, }; } export async function resolveTaskWorldInfo({ settings = {}, chatMessages = [], userMessage = "", templateContext = {}, } = {}) { const result = { beforeEntries: [], afterEntries: [], atDepthEntries: [], beforeText: "", afterText: "", additionalMessages: [], activatedEntryNames: [], allEntries: [], debug: { sourceLabel: "", fallback: false, capabilityStatus: null, snapshotRevision: 0, requestedWorldbooks: [], loadedWorldbooks: [], worldbookCount: 0, triggerLength: 0, activatedEntryCount: 0, constantActivatedCount: 0, selectiveActivatedCount: 0, ejsForcedActivationCount: 0, ejsInlinePullCount: 0, resolvePassCount: 0, forcedActivatedEntries: [], inlinePulledEntries: [], lazyLoadedWorldbooks: [], recursionWarnings: [], cache: { hit: false, key: "", ageMs: 0, ttlMs: WORLDINFO_CACHE_TTL_MS, }, loadMs: 0, ejsRuntimeStatus: "", ejsRuntimeFallback: false, ejsLastError: "", warnings: [], resolvedEntries: [], mvu: buildMvuDebugSummary(null), }, }; try { const worldbookHost = await getWorldbookHost(); const collected = await collectAllWorldbookEntries(worldbookHost); const allEntries = Array.isArray(collected?.entries) ? collected.entries : []; const blockedContents = Array.isArray(collected?.blockedContents) ? collected.blockedContents : []; const ignoredLookup = collected?.ignoredLookup instanceof Map ? collected.ignoredLookup : new Map(); result.allEntries = allEntries; Object.defineProperty(result, "__mvuBlockedContents", { value: blockedContents, configurable: true, enumerable: false, writable: true, }); result.debug = { ...result.debug, ...(collected?.debug || {}), cache: { ...result.debug.cache, ...(collected?.debug?.cache || {}), }, warnings: Array.isArray(result.debug.warnings) ? result.debug.warnings : [], resolvedEntries: Array.isArray(result.debug.resolvedEntries) ? result.debug.resolvedEntries : [], mvu: collected?.debug?.mvu && typeof collected.debug.mvu === "object" ? { ...collected.debug.mvu } : buildMvuDebugSummary(null), }; if (allEntries.length === 0) { return result; } warnLegacyEntryNames(allEntries, result.debug.warnings); const triggerTexts = buildActivationSourceTexts({ chatMessages, userMessage, templateContext, }); const trigger = triggerTexts.join("\n\n"); result.debug.triggerLength = trigger.length; const ejsBackend = await inspectTaskEjsRuntimeBackend(); result.debug.ejsRuntimeStatus = ejsBackend.status || ""; result.debug.ejsRuntimeFallback = Boolean(ejsBackend.isFallback); result.debug.ejsLastError = ejsBackend.error ? ejsBackend.error instanceof Error ? ejsBackend.error.message : String(ejsBackend.error) : ""; const normalizedTemplateContext = { ...templateContext, user_input: userMessage || templateContext?.user_input || "", }; const initialActivated = selectActivatedEntries( allEntries, trigger, normalizedTemplateContext, ); if (initialActivated.length === 0) { return result; } const allActivated = toActivationMap(initialActivated); const aggregatedForcedEntries = new Map(); const aggregatedInlineEntries = new Map(); const recursionWarnings = new Set(); const lazyMvuCollector = { blockedContents, filteredEntries: Array.isArray(result.debug.mvu.filteredEntries) ? result.debug.mvu.filteredEntries : [], lazyFilteredEntries: [], ignoredLookup, seenEntries: new Set(), }; const knownWorldbooks = new Set( allEntries.map((entry) => entry.worldbook).filter(Boolean), ); const lazyLoadWorldbookEntries = async (worldbookName) => { const normalizedWorldbook = normalizeKey(worldbookName); if (!normalizedWorldbook || knownWorldbooks.has(normalizedWorldbook)) { return []; } const lazyEntries = await loadNormalizedWorldbookEntries( worldbookHost, normalizedWorldbook, { mvuCollector: lazyMvuCollector, lazy: true, }, ); knownWorldbooks.add(normalizedWorldbook); const newLazyIgnoredEntries = [...lazyMvuCollector.lazyFilteredEntries]; result.debug.mvu = { ...result.debug.mvu, blockedContentsCount: uniq( blockedContents.map((item) => String(item || "").trim()).filter(Boolean), ).length, filteredEntries: [ ...(Array.isArray(result.debug.mvu.filteredEntries) ? result.debug.mvu.filteredEntries : []), ...newLazyIgnoredEntries, ], lazyFilteredEntryCount: Number(result.debug.mvu.lazyFilteredEntryCount || 0) + newLazyIgnoredEntries.length, }; lazyMvuCollector.lazyFilteredEntries = []; return lazyEntries; }; const renderCtx = createTaskEjsRenderContext( allEntries.map((entry) => ({ uid: entry.uid, name: entry.name, comment: entry.comment, content: entry.cleanContent || entry.content, worldbook: entry.worldbook, role: entry.role, position: entry.position, depth: entry.depth, order: entry.order, activationDebug: entry.activationDebug, })), { templateContext: normalizedTemplateContext, currentActivatedEntries: [...allActivated.values()], loadWorldbookEntries: lazyLoadWorldbookEntries, resolveIgnoredEntry: (worldbookName, identifier) => findIgnoredWorldInfoEntry( { ignoredLookup }, worldbookName, identifier, ), }, ); const maxResolvePasses = Number.isFinite(Number(settings.worldInfoMaxResolvePasses)) && Number(settings.worldInfoMaxResolvePasses) > 0 ? Number(settings.worldInfoMaxResolvePasses) : DEFAULT_MAX_RESOLVE_PASSES; const beforeEntries = []; const afterEntries = []; const atDepthEntries = []; let resolvedIndex = 0; let finalResolvedEntries = []; let hitResolveCap = false; for (let pass = 0; pass < maxResolvePasses; pass += 1) { result.debug.resolvePassCount = pass + 1; renderCtx.currentActivatedEntries = [...allActivated.values()]; renderCtx.forcedActivatedEntries.clear(); renderCtx.inlinePulledEntries.clear(); renderCtx.warnings = []; finalResolvedEntries = []; resolvedIndex = 0; const activatedEntries = [...allActivated.values()].sort(sortEntries); for (const entry of activatedEntries) { const sourceContent = entry.cleanContent || entry.content; let renderedContent = sourceContent; try { renderedContent = await evalTaskEjsTemplate(sourceContent, renderCtx, { world_info: { comment: entry.comment || entry.name, name: entry.name, world: entry.worldbook, }, }); } catch (error) { const warning = error?.code === "st_bme_task_ejs_unsupported_helper" ? `世界书条目 ${entry.name} 调用了不支持的 helper: ${error.helperName}` : error?.code === "st_bme_task_ejs_runtime_unavailable" ? `世界书条目 ${entry.name} 依赖 EJS runtime,当前已跳过` : `世界书条目 ${entry.name} 渲染失败,已跳过`; if (!result.debug.warnings.includes(warning)) { result.debug.warnings.push(warning); } console.warn( `[ST-BME] task-worldinfo 渲染世界书条目失败: ${entry.name}`, error, ); if ( error?.code === "st_bme_task_ejs_runtime_unavailable" && !result.debug.ejsLastError ) { result.debug.ejsLastError = error instanceof Error ? error.message : String(error); } renderedContent = ""; } for (const warning of renderCtx.warnings || []) { recursionWarnings.add(String(warning || "")); } const mvuSanitized = sanitizeMvuContent(renderedContent, { mode: "aggressive", blockedContents, }); if (mvuSanitized.dropped) { const warning = `世界书条目 ${entry.name} 渲染结果命中 MVU 规则,已跳过`; if (!result.debug.warnings.includes(warning)) { result.debug.warnings.push(warning); } } const trimmedContent = String(mvuSanitized.text || "").trim(); if (!trimmedContent) { continue; } finalResolvedEntries.push( normalizeResolvedEntry( { name: entry.comment || entry.name, sourceName: entry.name, worldbook: entry.worldbook, content: trimmedContent, role: entry.role, position: entry.position, depth: entry.depth, order: entry.order, activationDebug: entry.activationDebug, }, resolvedIndex++, ), ); } for (const pulledEntry of renderCtx.inlinePulledEntries.values()) { const key = `${pulledEntry.worldbook}:${pulledEntry.name}`; if (!aggregatedInlineEntries.has(key)) { aggregatedInlineEntries.set(key, { name: pulledEntry.comment || pulledEntry.name, sourceName: pulledEntry.name, worldbook: pulledEntry.worldbook, }); } } let discoveredNewActivation = false; for (const forcedEntry of renderCtx.forcedActivatedEntries.values()) { const key = getEntryIdentity(forcedEntry); if (!aggregatedForcedEntries.has(key)) { aggregatedForcedEntries.set(key, { name: forcedEntry.comment || forcedEntry.name, sourceName: forcedEntry.name, worldbook: forcedEntry.worldbook, }); } if (!allActivated.has(key)) { allActivated.set(key, { ...forcedEntry, activationDebug: mergeActivationDebug(forcedEntry, { mode: "ejs-forced", }), }); discoveredNewActivation = true; } } if (!discoveredNewActivation) { break; } if (pass + 1 >= maxResolvePasses) { hitResolveCap = true; } } if (hitResolveCap) { const warning = `世界书 EJS 激活达到递归上限 ${maxResolvePasses},已停止继续展开`; if (!result.debug.warnings.includes(warning)) { result.debug.warnings.push(warning); } recursionWarnings.add(warning); } for (const entry of finalResolvedEntries) { const bucketName = classifyPosition(entry); const bucket = bucketName === "before" ? beforeEntries : bucketName === "after" ? afterEntries : atDepthEntries; bucket.push(entry); } result.beforeEntries = beforeEntries; result.afterEntries = afterEntries; result.atDepthEntries = sortAtDepthEntries(atDepthEntries); result.beforeText = buildWorldInfoText(result.beforeEntries); result.afterText = buildWorldInfoText(result.afterEntries); result.additionalMessages = buildAdditionalMessages(result.atDepthEntries); result.debug.activatedEntryCount = allActivated.size; result.debug.constantActivatedCount = [...allActivated.values()].filter( (entry) => entry.activationDebug?.mode === "constant", ).length; result.debug.selectiveActivatedCount = [...allActivated.values()].filter( (entry) => entry.activationDebug?.mode === "selective" || entry.activationDebug?.mode === "forced", ).length; result.debug.ejsForcedActivationCount = aggregatedForcedEntries.size; result.debug.ejsInlinePullCount = aggregatedInlineEntries.size; result.debug.forcedActivatedEntries = [...aggregatedForcedEntries.values()]; result.debug.inlinePulledEntries = [...aggregatedInlineEntries.values()]; result.debug.lazyLoadedWorldbooks = [...renderCtx.lazyLoadedWorldbooks]; result.debug.recursionWarnings = [...recursionWarnings]; result.debug.resolvedEntries = [ ...result.beforeEntries.map((entry) => ({ name: entry.name, bucket: "before", sourceName: entry.sourceName, worldbook: entry.worldbook, activationMode: entry.activationDebug?.mode || "", matchedPrimaryKey: entry.activationDebug?.matchedPrimaryKey || "", matchedSecondaryKeys: entry.activationDebug?.matchedSecondaryKeys || [], })), ...result.afterEntries.map((entry) => ({ name: entry.name, bucket: "after", sourceName: entry.sourceName, worldbook: entry.worldbook, activationMode: entry.activationDebug?.mode || "", matchedPrimaryKey: entry.activationDebug?.matchedPrimaryKey || "", matchedSecondaryKeys: entry.activationDebug?.matchedSecondaryKeys || [], })), ...result.atDepthEntries.map((entry) => ({ name: entry.name, bucket: "atDepth", sourceName: entry.sourceName, worldbook: entry.worldbook, activationMode: entry.activationDebug?.mode || "", matchedPrimaryKey: entry.activationDebug?.matchedPrimaryKey || "", matchedSecondaryKeys: entry.activationDebug?.matchedSecondaryKeys || [], })), ]; result.activatedEntryNames = uniq( [ ...result.beforeEntries.map((entry) => entry.name), ...result.afterEntries.map((entry) => entry.name), ...result.atDepthEntries.map((entry) => entry.name), ...[...aggregatedForcedEntries.values()].map( (entry) => entry.name || entry.sourceName, ), ].filter(Boolean), ); } catch (error) { console.error("[ST-BME] task-worldinfo 解析失败:", error); } return result; }