import assert from "node:assert/strict"; import { installResolveHooks, toDataModuleUrl, } from "./helpers/register-hooks-compat.mjs"; const extensionsShimSource = [ "export const extension_settings = {};", "export function getContext() {", " return globalThis.__stBmeTestContext || {", " chat: [],", " chatMetadata: {},", " extensionSettings: {},", " powerUserSettings: {},", " characters: {},", " characterId: null,", " name1: '玩家',", " name2: '艾琳',", " chatId: 'test-chat',", " };", "}", ].join("\n"); const scriptShimSource = [ "export function getRequestHeaders() {", " return {};", "}", "export function substituteParamsExtended(value) {", " return String(value ?? '');", "}", ].join("\n"); const openAiShimSource = [ "export const chat_completion_sources = {};", "export async function sendOpenAIRequest() {", " throw new Error('sendOpenAIRequest should not be called in p3 test');", "}", ].join("\n"); installResolveHooks([ { specifiers: [ "../../../extensions.js", "../../../../extensions.js", "../../../../../extensions.js", ], url: toDataModuleUrl(extensionsShimSource), }, { specifiers: [ "../../../../script.js", "../../../../../script.js", ], url: toDataModuleUrl(scriptShimSource), }, { specifiers: [ "../../../../openai.js", "../../../../../openai.js", ], url: toDataModuleUrl(openAiShimSource), }, ]); const { addEdge, addNode, createEdge, createEmptyGraph, createNode } = await import("../graph/graph.js"); const { DEFAULT_NODE_SCHEMA } = await import("../graph/schema.js"); const { extractMemories } = await import("../maintenance/extractor.js"); const { appendSummaryEntry } = await import("../graph/summary-state.js"); const { normalizeGraphSummaryState } = await import("../graph/summary-state.js"); const { applyBatchStoryTime } = await import("../graph/story-timeline.js"); const { defaultSettings } = await import("../runtime/settings-defaults.js"); function setTestOverrides(overrides = {}) { globalThis.__stBmeTestOverrides = overrides; return () => { delete globalThis.__stBmeTestOverrides; }; } globalThis.__stBmeTestContext = { chat: [], chatMetadata: {}, extensionSettings: {}, powerUserSettings: {}, characters: {}, characterId: null, name1: "玩家", name2: "艾琳", chatId: "test-chat", }; const baseMessages = [ { seq: 10, role: "user", content: "第一轮消息", name: "玩家", speaker: "玩家" }, { seq: 11, role: "assistant", content: "第一轮回复", name: "艾琳", speaker: "艾琳" }, { seq: 12, role: "user", content: "第二轮消息", name: "玩家", speaker: "玩家" }, { seq: 13, role: "assistant", content: "第二轮回复", name: "艾琳", speaker: "艾琳" }, { seq: 14, role: "user", content: "第三轮消息", name: "玩家", speaker: "玩家" }, { seq: 15, role: "assistant", content: "第三轮回复", name: "艾琳", speaker: "艾琳" }, ]; function collectAllPromptContent(captured) { return [ String(captured.systemPrompt || ""), String(captured.userPrompt || ""), ...(Array.isArray(captured.promptMessages) ? captured.promptMessages : []).map( (m) => String(m.content || ""), ), ...(Array.isArray(captured.additionalMessages) ? captured.additionalMessages : []).map( (m) => String(m.content || ""), ), ].join("\n"); } // ── Test 1: default settings — activeSummaries and storyTimeContext passed ── { const graph = createEmptyGraph(); normalizeGraphSummaryState(graph); const entry = appendSummaryEntry(graph, { text: "最近的局面总结测试文本", messageRange: [5, 9], level: 1, }); applyBatchStoryTime(graph, { label: "第二天清晨", tense: "ongoing" }, "extract"); let captured = null; const restore = setTestOverrides({ llm: { async callLLMForJSON(payload) { captured = payload; return { operations: [], cognitionUpdates: [], regionUpdates: {} }; }, }, }); try { const result = await extractMemories({ graph, messages: baseMessages.slice(0, 2), startSeq: 10, endSeq: 11, schema: DEFAULT_NODE_SCHEMA, embeddingConfig: null, settings: { ...defaultSettings }, }); assert.equal(result.success, true); assert.ok(captured, "LLM should be called"); const allContent = collectAllPromptContent(captured); // activeSummaries should be somewhere in prompt content assert.match(allContent, /最近的局面总结测试文本/, "active summaries text should appear in prompt"); // storyTimeContext should be somewhere in prompt content assert.match(allContent, /第二天清晨/, "story time label should appear in prompt"); // recentMessages block should contain the dialogue const recentBlock = (Array.isArray(captured.promptMessages) ? captured.promptMessages : []).find( (m) => m.sourceKey === "recentMessages", ); assert.ok(recentBlock, "recentMessages block should exist"); assert.match(String(recentBlock.content || ""), /第一轮/, "recentMessages should contain dialogue content"); } finally { restore(); } } { const graph = createEmptyGraph(); let captured = null; const restore = setTestOverrides({ llm: { async callLLMForJSON(payload) { captured = payload; return { operations: [], cognitionUpdates: [], regionUpdates: {} }; }, }, }); try { const result = await extractMemories({ graph, messages: [ { seq: 10, role: "user", content: "第一轮消息", name: "玩家", speaker: "玩家", isContextOnly: true, }, { seq: 11, role: "assistant", content: "第一轮回复", name: "艾琳", speaker: "艾琳", isContextOnly: true, }, { seq: 12, role: "user", content: "第二轮消息", name: "玩家", speaker: "玩家", isContextOnly: false, }, { seq: 13, role: "assistant", content: "第二轮回复", name: "艾琳", speaker: "艾琳", isContextOnly: false, }, ], startSeq: 12, endSeq: 13, schema: DEFAULT_NODE_SCHEMA, embeddingConfig: null, settings: { ...defaultSettings }, }); assert.equal(result.success, true); assert.ok(captured); const recentMessages = (Array.isArray(captured.promptMessages) ? captured.promptMessages : [] ).filter( (m) => m.sourceKey === "recentMessages", ); assert.equal(recentMessages.length, 2, "recentMessages should split into 2 section system messages"); assert.equal(recentMessages[0]?.role, "system"); assert.equal(recentMessages[0]?.transcriptSection, "context"); assert.match(String(recentMessages[0]?.content || ""), /^--- 以下是上下文回顾(已提取过),仅供理解剧情 ---/); assert.match(String(recentMessages[0]?.content || ""), /#10 \[user\|玩家\]: 第一轮消息/); assert.equal(recentMessages[1]?.role, "system"); assert.equal(recentMessages[1]?.transcriptSection, "target"); assert.match(String(recentMessages[1]?.content || ""), /^--- 以下是本次需要提取记忆的新对话内容 ---/); assert.match(String(recentMessages[1]?.content || ""), /#12 \[user\|玩家\]: 第二轮消息/); assert.ok( recentMessages[0].content.includes("已提取过") && recentMessages[1].content.includes("本次需要提取"), "context and target sections should each be emitted as a single system message", ); } finally { restore(); } } // ── Test 2: extractRecentMessageCap limits messages ── { const graph = createEmptyGraph(); let captured = null; const restore = setTestOverrides({ llm: { async callLLMForJSON(payload) { captured = payload; return { operations: [], cognitionUpdates: [], regionUpdates: {} }; }, }, }); try { const result = await extractMemories({ graph, messages: baseMessages, startSeq: 10, endSeq: 15, schema: DEFAULT_NODE_SCHEMA, embeddingConfig: null, settings: { ...defaultSettings, extractRecentMessageCap: 2, }, }); assert.equal(result.success, true); assert.ok(captured); // With cap=2, only the last 2 messages (seq 14, 15) should be in the recentMessages block const recentBlock = (Array.isArray(captured.promptMessages) ? captured.promptMessages : []).find( (m) => m.sourceKey === "recentMessages", ); assert.ok(recentBlock, "recentMessages block should exist"); const recentContent = String(recentBlock.content || ""); assert.match(recentContent, /第三轮/, "capped messages should contain the last messages"); assert.doesNotMatch(recentContent, /第一轮/, "capped messages should not contain early messages"); } finally { restore(); } } // ── Test 3: extractPromptStructuredMode = "structured" omits dialogueText ── { const graph = createEmptyGraph(); let captured = null; const restore = setTestOverrides({ llm: { async callLLMForJSON(payload) { captured = payload; return { operations: [], cognitionUpdates: [], regionUpdates: {} }; }, }, }); try { const result = await extractMemories({ graph, messages: baseMessages.slice(0, 2), startSeq: 10, endSeq: 11, schema: DEFAULT_NODE_SCHEMA, embeddingConfig: null, settings: { ...defaultSettings, extractPromptStructuredMode: "structured", }, }); assert.equal(result.success, true); assert.ok(captured); // In structured mode, recentMessages block should still have structured content const recentBlock = (Array.isArray(captured.promptMessages) ? captured.promptMessages : []).find( (m) => m.sourceKey === "recentMessages", ); assert.ok(recentBlock, "recentMessages block should exist"); const recentContent = String(recentBlock?.content || ""); assert.ok(recentContent.length > 0, "recentMessages block should have content"); // The full transcript should NOT appear in prompt content // (structured mode excludes dialogueText) const allContent = collectAllPromptContent(captured); // In "structured" mode, the user prompt fallback or blocks may reference structured messages assert.match(recentContent, /第一轮/, "structured messages should contain dialogue"); } finally { restore(); } } // ── Test 4: extractPromptStructuredMode = "transcript" passes string ── { const graph = createEmptyGraph(); let captured = null; const restore = setTestOverrides({ llm: { async callLLMForJSON(payload) { captured = payload; return { operations: [], cognitionUpdates: [], regionUpdates: {} }; }, }, }); try { const result = await extractMemories({ graph, messages: baseMessages.slice(0, 2), startSeq: 10, endSeq: 11, schema: DEFAULT_NODE_SCHEMA, embeddingConfig: null, settings: { ...defaultSettings, extractPromptStructuredMode: "transcript", }, }); assert.equal(result.success, true); assert.ok(captured); // In transcript mode, the content should still be present in some form const allContent = collectAllPromptContent(captured); assert.match(allContent, /第一轮/, "transcript mode should have dialogue content"); // recentMessages block should exist and have transcript content const recentBlock = (Array.isArray(captured.promptMessages) ? captured.promptMessages : []).find( (m) => m.sourceKey === "recentMessages", ); assert.ok(recentBlock, "recentMessages block should exist in transcript mode"); } finally { restore(); } } // ── Test 5: extractIncludeSummaries = false omits summaries ── { const graph = createEmptyGraph(); normalizeGraphSummaryState(graph); appendSummaryEntry(graph, { text: "这条总结不应出现", messageRange: [5, 9], level: 1, }); let captured = null; const restore = setTestOverrides({ llm: { async callLLMForJSON(payload) { captured = payload; return { operations: [], cognitionUpdates: [], regionUpdates: {} }; }, }, }); try { const result = await extractMemories({ graph, messages: baseMessages.slice(0, 2), startSeq: 10, endSeq: 11, schema: DEFAULT_NODE_SCHEMA, embeddingConfig: null, settings: { ...defaultSettings, extractIncludeSummaries: false, }, }); assert.equal(result.success, true); assert.ok(captured); const allContent = collectAllPromptContent(captured); assert.doesNotMatch(allContent, /这条总结不应出现/, "summaries should be excluded when disabled"); } finally { restore(); } } // ── Test 6: extractIncludeStoryTime = false omits story time ── { const graph = createEmptyGraph(); applyBatchStoryTime(graph, { label: "隐藏的时间标签", tense: "ongoing" }, "extract"); let captured = null; const restore = setTestOverrides({ llm: { async callLLMForJSON(payload) { captured = payload; return { operations: [], cognitionUpdates: [], regionUpdates: {} }; }, }, }); try { const result = await extractMemories({ graph, messages: baseMessages.slice(0, 2), startSeq: 10, endSeq: 11, schema: DEFAULT_NODE_SCHEMA, embeddingConfig: null, settings: { ...defaultSettings, extractIncludeStoryTime: false, }, }); assert.equal(result.success, true); assert.ok(captured); const allContent = collectAllPromptContent(captured); assert.doesNotMatch(allContent, /隐藏的时间标签/, "story time should be excluded when disabled"); } finally { restore(); } } // ── Test 7: new settings exist in defaults ── { const graph = createEmptyGraph(); const confessionNode = addNode( graph, createNode({ type: "event", seq: 3, importance: 8, fields: { title: "中文告白", summary: "她认真地要求你再说一遍喜欢她。", }, }), ); const relationshipNode = addNode( graph, createNode({ type: "thread", seq: 4, importance: 7, fields: { title: "感情升温", summary: "两人的关系在这次告白后快速拉近。", }, }), ); addEdge( graph, createEdge({ fromId: confessionNode.id, toId: relationshipNode.id, relation: "supports", strength: 0.9, }), ); let captured = null; const restore = setTestOverrides({ llm: { async callLLMForJSON(payload) { captured = payload; return { operations: [], cognitionUpdates: [], regionUpdates: {} }; }, }, }); try { const result = await extractMemories({ graph, messages: [ { seq: 10, role: "user", content: "中文告白之后,她还是很害羞。", name: "玩家", speaker: "玩家", }, { seq: 11, role: "assistant", content: "这次中文告白让你们的感情升温了。", name: "艾琳", speaker: "艾琳", }, ], startSeq: 10, endSeq: 11, schema: DEFAULT_NODE_SCHEMA, embeddingConfig: null, settings: { ...defaultSettings }, }); assert.equal(result.success, true); assert.ok(captured); const graphStatsBlock = (Array.isArray(captured.promptMessages) ? captured.promptMessages : []).find( (m) => m.sourceKey === "graphStats", ); assert.ok(graphStatsBlock, "graphStats block should exist"); const graphStatsContent = String(graphStatsBlock.content || ""); assert.match(graphStatsContent, /### 图谱节点统计/); assert.match(graphStatsContent, /事件: 1/); assert.match(graphStatsContent, /主线: 1/); assert.match(graphStatsContent, /\[G1\|事件\] 中文告白/); assert.doesNotMatch(graphStatsContent, new RegExp(confessionNode.id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))); } finally { restore(); } } // ── Test 8: new settings exist in defaults ── { assert.equal(defaultSettings.extractRecentMessageCap, 0); assert.equal(defaultSettings.extractPromptStructuredMode, "both"); assert.equal(defaultSettings.extractWorldbookMode, "active"); assert.equal(defaultSettings.extractIncludeStoryTime, true); assert.equal(defaultSettings.extractIncludeSummaries, true); } console.log("extractor-phase3-layered-context tests passed");