Files
ST-Bionic-Memory-Ecology/task-worldinfo.js
2026-04-06 11:26:37 +08:00

1546 lines
45 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ST-BME: 任务级世界书激活引擎
// 对标 SillyTavern 原生世界书扫描逻辑,并在私有 prompt 组装阶段
// 提供最小 EJS 配合能力,用于 getwi / activewi。
import {
createTaskEjsRenderContext,
evalTaskEjsTemplate,
inspectTaskEjsRuntimeBackend,
substituteTaskEjsParams,
} from "./task-ejs.js";
import {
isLikelyMvuWorldInfoContent,
isMvuTaggedWorldInfoNameOrComment,
sanitizeMvuContent,
} from "./mvu-compat.js";
import { debugDebug } from "./debug-logging.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) {
debugDebug(
"[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) {
debugDebug(
`[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) {
debugDebug(
`[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) {
debugDebug(
`[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) {
debugDebug(
`[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;
}