import { debugLog } from "../runtime/debug-logging.js"; import { callLLMForJSON } from "../llm/llm.js"; import { buildTaskExecutionDebugContext, buildTaskLlmPayload, buildTaskPrompt, } from "../prompting/prompt-builder.js"; import { applyTaskRegex } from "../prompting/task-regex.js"; import { getActiveTaskProfile } from "../prompting/prompt-profiles.js"; import { appendSummaryEntry, createDefaultSummaryState, getActiveSummaryEntries, markSummaryEntriesFolded, normalizeGraphSummaryState, } from "../graph/summary-state.js"; import { buildDialogueFloorMap, buildSummarySourceMessages, getDialogueFloorForChatIndex, } from "./chat-history.js"; import { getSTContextForPrompt } from "../host/st-context.js"; import { deriveStoryTimeSpanFromNodes, describeNodeStoryTime, } from "../graph/story-timeline.js"; import { getNode, getActiveNodes } from "../graph/graph.js"; import { getNodeDisplayName } from "../graph/node-labels.js"; import { normalizeMemoryScope } from "../graph/memory-scope.js"; function createAbortError(message = "操作已终止") { const error = new Error(message); error.name = "AbortError"; return error; } function throwIfAborted(signal) { if (signal?.aborted) { throw signal.reason instanceof Error ? signal.reason : createAbortError(); } } function createTaskLlmDebugContext(promptBuild, regexInput) { return typeof buildTaskExecutionDebugContext === "function" ? buildTaskExecutionDebugContext(promptBuild, { regexInput }) : null; } function resolveTaskPromptPayload(promptBuild, fallbackUserPrompt = "") { if (typeof buildTaskLlmPayload === "function") { return buildTaskLlmPayload(promptBuild, fallbackUserPrompt); } return { systemPrompt: String(promptBuild?.systemPrompt || ""), userPrompt: String(fallbackUserPrompt || ""), promptMessages: [], additionalMessages: Array.isArray(promptBuild?.privateTaskMessages) ? promptBuild.privateTaskMessages : [], }; } function resolveTaskLlmSystemPrompt(promptPayload, fallbackSystemPrompt = "") { const hasPromptMessages = Array.isArray(promptPayload?.promptMessages) && promptPayload.promptMessages.length > 0; if (hasPromptMessages) { return String(promptPayload?.systemPrompt || ""); } return String(promptPayload?.systemPrompt || fallbackSystemPrompt || ""); } function clampInt(value, fallback = 0, min = 0, max = 999999) { const parsed = Math.floor(Number(value)); if (!Number.isFinite(parsed)) return fallback; return Math.max(min, Math.min(max, parsed)); } function normalizeRange(range, fallback = [-1, -1]) { if (!Array.isArray(range) || range.length < 2) { return [...fallback]; } const start = Number.isFinite(Number(range[0])) ? Number(range[0]) : fallback[0]; const end = Number.isFinite(Number(range[1])) ? Number(range[1]) : fallback[1]; return [start, end]; } function getSummaryTaskInputConfig(settings = {}, taskType = "synopsis") { const profile = getActiveTaskProfile(settings, taskType); const input = profile?.input && typeof profile.input === "object" && !Array.isArray(profile.input) ? profile.input : {}; return { rawChatContextFloors: clampInt(input.rawChatContextFloors, 0, 0, 200), rawChatSourceMode: String(input.rawChatSourceMode || "ignore_bme_hide").trim() === "ignore_bme_hide" ? "ignore_bme_hide" : "ignore_bme_hide", }; } function buildTranscript(messages = []) { return (Array.isArray(messages) ? messages : []) .map((message) => { const seq = Number.isFinite(Number(message?.seq)) ? Number(message.seq) : "?"; const role = String(message?.role || "assistant").trim() || "assistant"; return `#${seq} [${role}]: ${String(message?.content || "")}`; }) .filter(Boolean) .join("\n\n"); } function uniqueIds(values = []) { return [...new Set( (Array.isArray(values) ? values : []) .map((value) => String(value || "").trim()) .filter(Boolean), )]; } function collectJournalTouchedNodeIds(journal = {}) { return uniqueIds([ ...(Array.isArray(journal?.createdNodeIds) ? journal.createdNodeIds : []), ...((Array.isArray(journal?.previousNodeSnapshots) ? journal.previousNodeSnapshots : [] ).map((node) => node?.id)), ]); } function intersectsRange(leftRange, rightRange) { const [leftStart, leftEnd] = normalizeRange(leftRange); const [rightStart, rightEnd] = normalizeRange(rightRange); if (leftStart < 0 || leftEnd < 0 || rightStart < 0 || rightEnd < 0) { return false; } return leftStart <= rightEnd && rightStart <= leftEnd; } function buildDialogueRangeFromMessageRange(chat = [], messageRange = [-1, -1]) { const [messageStart, messageEnd] = normalizeRange(messageRange); if (messageStart < 0 || messageEnd < 0) { return [-1, -1]; } const startFloor = getDialogueFloorForChatIndex(chat, messageStart); const endFloor = getDialogueFloorForChatIndex(chat, messageEnd); return [ Number.isFinite(Number(startFloor)) ? Number(startFloor) : -1, Number.isFinite(Number(endFloor)) ? Number(endFloor) : -1, ]; } function getSummaryEntryDialogueRange(chat = [], entry = {}) { const directRange = normalizeRange(entry?.dialogueRange); if (directRange[0] >= 0 && directRange[1] >= 0) { return directRange; } return buildDialogueRangeFromMessageRange(chat, entry?.messageRange); } function removeSummaryEntriesByIds(graph, entryIds = []) { normalizeGraphSummaryState(graph); const targetIds = new Set(uniqueIds(entryIds)); if (targetIds.size === 0) return 0; const queue = [...targetIds]; while (queue.length > 0) { const currentId = queue.shift(); for (const entry of graph.summaryState.entries || []) { if (targetIds.has(entry.id)) continue; const sourceSummaryIds = Array.isArray(entry?.sourceSummaryIds) ? entry.sourceSummaryIds : []; if (!sourceSummaryIds.includes(currentId)) continue; targetIds.add(entry.id); queue.push(entry.id); } } graph.summaryState.entries = (graph.summaryState.entries || []).filter( (entry) => !targetIds.has(entry.id), ); graph.summaryState.activeEntryIds = (graph.summaryState.activeEntryIds || []).filter( (entryId) => !targetIds.has(entryId), ); return targetIds.size; } function findJournalForExtractionCount(graph, extractionCountBefore) { const target = Number(extractionCountBefore); const journals = Array.isArray(graph?.batchJournal) ? graph.batchJournal : []; for (let index = journals.length - 1; index >= 0; index -= 1) { const journal = journals[index]; if ( Number(journal?.stateBefore?.extractionCount) === target && Array.isArray(journal?.processedRange) ) { return journal; } } return null; } function buildPseudoCurrentSlice( currentExtractionCount, currentRange, currentNodeIds = [], currentDialogueRange = null, ) { return { id: `summary-pending-${currentExtractionCount}`, extractionCountBefore: Math.max(0, currentExtractionCount - 1), extractionCountAfter: currentExtractionCount, processedRange: normalizeRange(currentRange), processedDialogueRange: normalizeRange(currentDialogueRange), touchedNodeIds: uniqueIds(currentNodeIds), }; } function buildSliceFromJournal(journal = {}) { return { id: String(journal?.id || ""), extractionCountBefore: clampInt(journal?.stateBefore?.extractionCount, 0, 0, 999999), extractionCountAfter: clampInt(journal?.stateBefore?.extractionCount, 0, 0, 999999) + 1, processedRange: normalizeRange(journal?.processedRange), processedDialogueRange: normalizeRange(journal?.processedDialogueRange), touchedNodeIds: uniqueIds([ ...(Array.isArray(journal?.touchedNodeIds) ? journal.touchedNodeIds : []), ...collectJournalTouchedNodeIds(journal), ]), }; } function collectSlicesForSummaryWindow( graph, { lastSummarizedExtractionCount = 0, currentExtractionCount = 0, currentRange = null, currentDialogueRange = null, currentNodeIds = [], includeCurrentPending = false, } = {}, ) { const slices = []; const safeLastCount = clampInt(lastSummarizedExtractionCount, 0, 0, 999999); const safeCurrentCount = clampInt(currentExtractionCount, 0, 0, 999999); const hasCurrentPendingRange = includeCurrentPending && Array.isArray(currentRange) && Number.isFinite(Number(currentRange[0])) && Number.isFinite(Number(currentRange[1])) && Number(currentRange[1]) >= Number(currentRange[0]); for ( let beforeCount = safeLastCount; beforeCount < safeCurrentCount - (hasCurrentPendingRange ? 1 : 0); beforeCount += 1 ) { const journal = findJournalForExtractionCount(graph, beforeCount); if (!journal) continue; slices.push(buildSliceFromJournal(journal)); } if (hasCurrentPendingRange && safeCurrentCount > safeLastCount) { slices.push( buildPseudoCurrentSlice( safeCurrentCount, currentRange, currentNodeIds, currentDialogueRange, ), ); } return slices.sort( (left, right) => left.extractionCountAfter - right.extractionCountAfter, ); } function collectNodeHints(graph, nodeIds = []) { const nodes = uniqueIds(nodeIds) .map((nodeId) => getNode(graph, nodeId)) .filter(Boolean); const regionHints = new Set(); const ownerHints = new Set(); for (const node of nodes) { const scope = normalizeMemoryScope(node?.scope); if (scope.regionPrimary) regionHints.add(scope.regionPrimary); if (scope.ownerName) ownerHints.add(scope.ownerName); } return { nodes, regionHints: [...regionHints], ownerHints: [...ownerHints], }; } function describeNodeForSummary(node) { if (!node) return ""; const storyLabel = describeNodeStoryTime(node); const prefix = storyLabel ? `[${storyLabel}] ` : ""; switch (String(node.type || "")) { case "event": return `${prefix}${node.fields?.title || getNodeDisplayName(node)}: ${node.fields?.summary || "(无摘要)"}`; case "character": return `${prefix}${node.fields?.name || getNodeDisplayName(node)}: ${node.fields?.state || node.fields?.summary || "(无状态)"}`; case "thread": return `${prefix}${node.fields?.title || getNodeDisplayName(node)}: ${node.fields?.status || node.fields?.summary || "(无状态)"}`; case "pov_memory": return `${prefix}${getNodeDisplayName(node)}: ${node.fields?.summary || "(无摘要)"}`; default: return `${prefix}${getNodeDisplayName(node)}: ${node.fields?.summary || node.fields?.title || node.fields?.name || "(无摘要)"}`; } } function buildNodeDigest(graph, nodeIds = []) { return collectNodeHints(graph, nodeIds).nodes .map((node) => describeNodeForSummary(node)) .filter(Boolean) .slice(0, 24) .join("\n"); } function buildFrontierHint(graph) { const activeEntries = getActiveSummaryEntries(graph); if (activeEntries.length === 0) { return "当前还没有活跃总结前沿。"; } return activeEntries .slice(-6) .map((entry) => { const range = normalizeRange(entry.messageRange); return `L${entry.level} · 楼 ${range[0]} ~ ${range[1]} · ${String(entry.text || "").slice(0, 90)}`; }) .join("\n"); } function buildSummaryGraphStats(graph, activeEntries = []) { const historyState = graph?.historyState || {}; const activeRegion = String(historyState.activeRegion || historyState.lastExtractedRegion || "").trim(); const activeStoryTime = String( historyState.activeStoryTimeLabel || historyState.activeStorySegmentId || "", ).trim(); return [ `active_summary_count=${activeEntries.length}`, activeRegion ? `active_region=${activeRegion}` : "", activeStoryTime ? `active_story_time=${activeStoryTime}` : "", ] .filter(Boolean) .join("\n"); } async function callSummaryTask({ settings = {}, taskType = "synopsis", context = {}, fallbackSystemPrompt = "", fallbackUserPrompt = "", signal, }) { const promptBuild = await buildTaskPrompt(settings, taskType, { taskName: taskType, ...context, ...getSTContextForPrompt(), }); const regexInput = { entries: [] }; const systemPrompt = applyTaskRegex( settings, taskType, "finalPrompt", promptBuild.systemPrompt || fallbackSystemPrompt, regexInput, "system", ); const promptPayload = resolveTaskPromptPayload(promptBuild, fallbackUserPrompt); return await callLLMForJSON({ systemPrompt: resolveTaskLlmSystemPrompt(promptPayload, systemPrompt), userPrompt: promptPayload.userPrompt, maxRetries: 1, signal, taskType, debugContext: createTaskLlmDebugContext(promptBuild, regexInput), promptMessages: promptPayload.promptMessages, additionalMessages: promptPayload.additionalMessages, }); } export async function generateSmallSummary({ graph, chat = [], settings = {}, currentExtractionCount = 0, currentAssistantFloor = -1, currentRange = [-1, -1], currentNodeIds = [], signal, force = false, } = {}) { normalizeGraphSummaryState(graph); const summaryState = createDefaultSummaryState(graph?.summaryState || {}); graph.summaryState = summaryState; const threshold = clampInt( settings.smallSummaryEveryNExtractions, 3, 1, 100, ); const deltaCount = Math.max( 0, clampInt(currentExtractionCount, 0, 0, 999999) - clampInt(summaryState.lastSummarizedExtractionCount, 0, 0, 999999), ); if (!force && deltaCount < threshold) { return { created: false, skipped: true, reason: `当前只累计了 ${deltaCount} 次未总结提取,未到小总结门槛 ${threshold}`, }; } const slices = collectSlicesForSummaryWindow(graph, { lastSummarizedExtractionCount: summaryState.lastSummarizedExtractionCount, currentExtractionCount, currentRange, currentDialogueRange: buildDialogueRangeFromMessageRange(chat, currentRange), currentNodeIds, includeCurrentPending: true, }); if (slices.length === 0) { return { created: false, skipped: true, reason: "当前没有可用于生成小总结的提取批次", }; } const firstSlice = slices[0]; const lastSlice = slices[slices.length - 1]; const inputConfig = getSummaryTaskInputConfig(settings, "synopsis"); const messageStart = normalizeRange(firstSlice.processedRange)[0]; const messageEnd = Math.max( normalizeRange(lastSlice.processedRange)[1], clampInt(currentAssistantFloor, -1, -1, 999999), ); const sourceMessages = buildSummarySourceMessages(chat, messageStart, messageEnd, { rawChatContextFloors: inputConfig.rawChatContextFloors, }); if (sourceMessages.length === 0) { return { created: false, skipped: true, reason: "小总结原文窗口为空,已跳过", }; } const messageRange = [ Number.isFinite(Number(sourceMessages[0]?.seq)) ? Number(sourceMessages[0].seq) : messageStart, Number.isFinite(Number(sourceMessages[sourceMessages.length - 1]?.seq)) ? Number(sourceMessages[sourceMessages.length - 1].seq) : messageEnd, ]; const dialogueRange = buildDialogueRangeFromMessageRange(chat, messageRange); const sourceNodeIds = uniqueIds( slices.flatMap((slice) => Array.isArray(slice.touchedNodeIds) ? slice.touchedNodeIds : []), ); const nodeDigest = buildNodeDigest(graph, sourceNodeIds) || "(无关键节点辅助)"; const activeFrontier = getActiveSummaryEntries(graph); const result = await callSummaryTask({ settings, taskType: "synopsis", context: { recentMessages: buildTranscript(sourceMessages), chatMessages: sourceMessages, candidateText: nodeDigest, graphStats: [ buildSummaryGraphStats(graph, activeFrontier), `frontier_hint:\n${buildFrontierHint(graph)}`, ] .filter(Boolean) .join("\n\n"), currentRange: `楼 ${messageRange[0]} ~ ${messageRange[1]}`, }, fallbackSystemPrompt: [ "你是小总结生成器。", "请基于最近原文聊天窗口为主、关键节点为辅,生成一条贴近当前局面的短总结。", '输出 JSON:{"summary":"总结文本(80-220字)"}', "不要写未来预测,不要脱离原文杜撰,不要把多段时间线硬糅在一起。", ].join("\n"), fallbackUserPrompt: [ "## 原文聊天窗口", buildTranscript(sourceMessages), "", "## 关键节点辅助", nodeDigest, "", "## 当前活跃总结前沿", buildFrontierHint(graph), ].join("\n"), signal, }); const summaryText = String(result?.summary || "").trim(); if (!summaryText) { return { created: false, skipped: true, reason: "小总结任务未返回有效 summary", }; } const nodeHints = collectNodeHints(graph, sourceNodeIds); const storyTimeSpan = deriveStoryTimeSpanFromNodes( graph, nodeHints.nodes, "derived", ); const entry = appendSummaryEntry(graph, { level: 0, kind: "small", status: "active", text: summaryText, sourceTask: "synopsis", extractionRange: [firstSlice.extractionCountAfter, lastSlice.extractionCountAfter], messageRange, dialogueRange, sourceBatchIds: uniqueIds(slices.map((slice) => slice.id)), sourceSummaryIds: [], sourceNodeIds, storyTimeSpan, regionHints: nodeHints.regionHints, ownerHints: nodeHints.ownerHints, }); summaryState.lastSummarizedExtractionCount = lastSlice.extractionCountAfter; summaryState.lastSummarizedAssistantFloor = messageRange[1]; debugLog("[ST-BME] 已生成小总结", { entryId: entry.id, extractionRange: entry.extractionRange, messageRange: entry.messageRange, }); return { created: true, entry, sourceMessages, sourceNodeIds, messageRange, dialogueRange, }; } function buildRollupCandidateText(entries = []) { return entries .map((entry, index) => { const range = normalizeRange(entry.messageRange); return [ `#${index + 1}`, `level=L${entry.level}`, `message_range=${range[0]}~${range[1]}`, `text=${String(entry.text || "")}`, ].join(" | "); }) .join("\n"); } function getFoldableSummaryGroup(graph, fanIn = 3, options = {}) { const requireExcess = options?.requireExcess === true; const activeEntries = getActiveSummaryEntries(graph); const byLevel = new Map(); for (const entry of activeEntries) { if (!byLevel.has(entry.level)) { byLevel.set(entry.level, []); } byLevel.get(entry.level).push(entry); } const sortedLevels = [...byLevel.keys()].sort((left, right) => left - right); for (const level of sortedLevels) { const entries = byLevel.get(level) || []; if (requireExcess ? entries.length > fanIn : entries.length >= fanIn) { return entries.slice(0, fanIn); } } return []; } export async function rollupSummaryFrontier({ graph, settings = {}, signal, force = false, } = {}) { normalizeGraphSummaryState(graph); const fanIn = clampInt(settings.summaryRollupFanIn, 3, 2, 10); const requireExcess = force !== true; const createdEntries = []; let foldedCount = 0; while (true) { throwIfAborted(signal); const candidates = getFoldableSummaryGroup(graph, fanIn, { requireExcess, }); if (candidates.length < fanIn) { break; } const sourceNodeIds = uniqueIds( candidates.flatMap((entry) => Array.isArray(entry.sourceNodeIds) ? entry.sourceNodeIds : [], ), ); const nodeHints = collectNodeHints(graph, sourceNodeIds); const result = await callSummaryTask({ settings, taskType: "summary_rollup", context: { candidateText: buildRollupCandidateText(candidates), graphStats: buildSummaryGraphStats(graph, getActiveSummaryEntries(graph)), currentRange: `楼 ${normalizeRange(candidates[0]?.messageRange)[0]} ~ ${ normalizeRange(candidates[candidates.length - 1]?.messageRange)[1] }`, }, fallbackSystemPrompt: [ "你是总结折叠器。", "请把多条同层活跃总结折叠成一条更稳定、更高层的总结。", '输出 JSON:{"summary":"折叠后的总结文本(120-260字)"}', "不要重复原句,不要丢掉当前仍然生效的局面,不要打乱先后顺序。", ].join("\n"), fallbackUserPrompt: [ "## 待折叠总结", buildRollupCandidateText(candidates), "", "## 关键节点辅助", buildNodeDigest(graph, sourceNodeIds) || "(无关键节点辅助)", ].join("\n"), signal, }); const summaryText = String(result?.summary || "").trim(); if (!summaryText) { return { createdCount: createdEntries.length, foldedCount, skipped: createdEntries.length === 0, reason: "总结折叠任务未返回有效 summary", createdEntries, }; } const extractionRange = [ Math.min(...candidates.map((entry) => normalizeRange(entry.extractionRange)[0])), Math.max(...candidates.map((entry) => normalizeRange(entry.extractionRange)[1])), ]; const messageRange = [ Math.min(...candidates.map((entry) => normalizeRange(entry.messageRange)[0])), Math.max(...candidates.map((entry) => normalizeRange(entry.messageRange)[1])), ]; const dialogueRange = [ Math.min( ...candidates.map((entry) => { const range = normalizeRange(entry?.dialogueRange, normalizeRange(entry?.messageRange)); return range[0]; }), ), Math.max( ...candidates.map((entry) => { const range = normalizeRange(entry?.dialogueRange, normalizeRange(entry?.messageRange)); return range[1]; }), ), ]; const storyTimeSpan = deriveStoryTimeSpanFromNodes( graph, nodeHints.nodes, "derived", ); markSummaryEntriesFolded( graph, candidates.map((entry) => entry.id), ); foldedCount += candidates.length; const createdEntry = appendSummaryEntry(graph, { level: Number(candidates[0]?.level || 0) + 1, kind: "rollup", status: "active", text: summaryText, sourceTask: "summary_rollup", extractionRange, messageRange, dialogueRange, sourceBatchIds: uniqueIds( candidates.flatMap((entry) => Array.isArray(entry.sourceBatchIds) ? entry.sourceBatchIds : [], ), ), sourceSummaryIds: candidates.map((entry) => entry.id), sourceNodeIds, storyTimeSpan, regionHints: nodeHints.regionHints, ownerHints: nodeHints.ownerHints, }); createdEntries.push(createdEntry); debugLog("[ST-BME] 已完成总结折叠", { createdEntryId: createdEntry.id, sourceSummaryIds: createdEntry.sourceSummaryIds, }); if (!force) { continue; } } return { createdCount: createdEntries.length, foldedCount, createdEntries, skipped: createdEntries.length === 0, reason: createdEntries.length === 0 ? requireExcess ? `当前没有超过 ${fanIn} 条同层活跃总结的折叠候选` : `当前没有达到 ${fanIn} 条同层活跃总结的折叠候选` : "", }; } export async function runHierarchicalSummaryPostProcess({ graph, chat = [], settings = {}, signal, currentExtractionCount = 0, currentAssistantFloor = -1, currentRange = [-1, -1], currentNodeIds = [], } = {}) { normalizeGraphSummaryState(graph); if (settings.enableHierarchicalSummary === false) { return { smallSummary: null, rollup: null, created: false, reason: "层级总结开关已关闭", }; } const smallSummary = await generateSmallSummary({ graph, chat, settings, currentExtractionCount, currentAssistantFloor, currentRange, currentNodeIds, signal, force: false, }); if (!smallSummary?.created) { return { smallSummary, rollup: null, created: false, reason: smallSummary?.reason || "", }; } const rollup = await rollupSummaryFrontier({ graph, settings, signal, force: false, }); return { smallSummary, rollup, created: true, }; } function clearSummaryState(graph) { graph.summaryState = createDefaultSummaryState(); } function getSliceDialogueRange(chat = [], slice = {}) { const directRange = normalizeRange(slice?.processedDialogueRange); if (directRange[0] >= 0 && directRange[1] >= 0) { return directRange; } return buildDialogueRangeFromMessageRange(chat, slice?.processedRange); } function getSuffixRebuildStartFromDialogueRange( graph, chat = [], targetDialogueRange = [-1, -1], ) { const slices = collectSlicesForSummaryWindow(graph, { lastSummarizedExtractionCount: 0, currentExtractionCount: clampInt( graph?.historyState?.extractionCount, 0, 0, 999999, ), currentRange: null, currentNodeIds: [], includeCurrentPending: false, }); const affectedSlices = slices.filter((slice) => intersectsRange(getSliceDialogueRange(chat, slice), targetDialogueRange), ); if (affectedSlices.length === 0) { return null; } return { rebuildFromExtractionCount: Math.min( ...affectedSlices.map((slice) => clampInt(slice.extractionCountBefore, 0, 0, 999999), ), ), affectedSlices, }; } function resolveCurrentSummaryDialogueRange(graph, chat = []) { const activeEntries = getActiveSummaryEntries(graph); if (activeEntries.length > 0) { return getSummaryEntryDialogueRange( chat, activeEntries[activeEntries.length - 1], ); } const slices = collectSlicesForSummaryWindow(graph, { lastSummarizedExtractionCount: 0, currentExtractionCount: clampInt( graph?.historyState?.extractionCount, 0, 0, 999999, ), currentRange: null, currentNodeIds: [], includeCurrentPending: false, }); if (slices.length === 0) { return [-1, -1]; } return getSliceDialogueRange(chat, slices[slices.length - 1]); } function trimSummaryStateForSuffixRebuild(graph, rebuildFromExtractionCount = 0) { normalizeGraphSummaryState(graph); const entries = Array.isArray(graph.summaryState?.entries) ? graph.summaryState.entries : []; const removeIds = entries .filter((entry) => { const extractionRange = normalizeRange(entry?.extractionRange); return extractionRange[1] >= rebuildFromExtractionCount; }) .map((entry) => entry.id); const removedCount = removeSummaryEntriesByIds(graph, removeIds); const remainingEntries = Array.isArray(graph.summaryState?.entries) ? graph.summaryState.entries : []; graph.summaryState.lastSummarizedExtractionCount = remainingEntries.length > 0 ? Math.max( 0, ...remainingEntries.map((entry) => normalizeRange(entry?.extractionRange)[1], ), ) : Math.max(0, rebuildFromExtractionCount); graph.summaryState.lastSummarizedAssistantFloor = remainingEntries.length > 0 ? Math.max( -1, ...remainingEntries.map((entry) => normalizeRange(entry?.messageRange)[1], ), ) : -1; return { removedCount, }; } export async function rebuildHierarchicalSummaryState({ graph, chat = [], settings = {}, signal, mode = "current", startFloor = null, endFloor = null, } = {}) { normalizeGraphSummaryState(graph); const currentExtractionCount = clampInt( graph?.historyState?.extractionCount, 0, 0, 999999, ); if (currentExtractionCount <= 0) { return { rebuilt: false, smallSummaryCount: 0, rollupCount: 0, reason: "当前还没有成功提取批次", }; } let targetDialogueRange = [-1, -1]; if (String(mode || "current") === "range") { if (!Number.isFinite(Number(startFloor))) { return { rebuilt: false, smallSummaryCount: 0, rollupCount: 0, reason: "范围重建必须填写起始楼层", }; } const latestDialogueFloor = buildDialogueFloorMap(chat).latestDialogueFloor; targetDialogueRange = [ clampInt(startFloor, 0, 0, Math.max(0, latestDialogueFloor)), Number.isFinite(Number(endFloor)) ? clampInt(endFloor, 0, 0, Math.max(0, latestDialogueFloor)) : Math.max(0, latestDialogueFloor), ]; targetDialogueRange[1] = Math.max( targetDialogueRange[0], targetDialogueRange[1], ); } else { targetDialogueRange = resolveCurrentSummaryDialogueRange(graph, chat); } if (targetDialogueRange[0] < 0 || targetDialogueRange[1] < 0) { return { rebuilt: false, smallSummaryCount: 0, rollupCount: 0, reason: "当前没有可重建的总结范围", }; } const rebuildWindow = getSuffixRebuildStartFromDialogueRange( graph, chat, targetDialogueRange, ); if (!rebuildWindow) { return { rebuilt: false, smallSummaryCount: 0, rollupCount: 0, reason: "目标范围内没有命中的总结切片", targetDialogueRange, }; } const trimmed = trimSummaryStateForSuffixRebuild( graph, rebuildWindow.rebuildFromExtractionCount, ); const threshold = clampInt(settings.smallSummaryEveryNExtractions, 3, 1, 100); const slices = collectSlicesForSummaryWindow(graph, { lastSummarizedExtractionCount: rebuildWindow.rebuildFromExtractionCount, currentExtractionCount, currentRange: null, currentNodeIds: [], includeCurrentPending: false, }); let pendingSlices = []; let smallSummaryCount = 0; let rollupCount = 0; for (const slice of slices) { pendingSlices.push(slice); if (pendingSlices.length < threshold) { continue; } const firstSlice = pendingSlices[0]; const lastSlice = pendingSlices[pendingSlices.length - 1]; const sourceNodeIds = uniqueIds( pendingSlices.flatMap((item) => item.touchedNodeIds || []), ); const sourceMessages = buildSummarySourceMessages( chat, normalizeRange(firstSlice.processedRange)[0], normalizeRange(lastSlice.processedRange)[1], { rawChatContextFloors: getSummaryTaskInputConfig(settings, "synopsis") .rawChatContextFloors, }, ); if (sourceMessages.length > 0) { const nodeHints = collectNodeHints(graph, sourceNodeIds); const result = await callSummaryTask({ settings, taskType: "synopsis", context: { recentMessages: buildTranscript(sourceMessages), chatMessages: sourceMessages, candidateText: buildNodeDigest(graph, sourceNodeIds) || "(无关键节点辅助)", graphStats: [ buildSummaryGraphStats(graph, getActiveSummaryEntries(graph)), `frontier_hint:\n${buildFrontierHint(graph)}`, ] .filter(Boolean) .join("\n\n"), currentRange: `楼 ${sourceMessages[0]?.seq ?? "?"} ~ ${ sourceMessages[sourceMessages.length - 1]?.seq ?? "?" }`, }, fallbackSystemPrompt: [ "你是小总结生成器。", '输出 JSON:{"summary":"总结文本(80-220字)"}', ].join("\n"), fallbackUserPrompt: [ "## 原文聊天窗口", buildTranscript(sourceMessages), ].join("\n"), signal, }); const summaryText = String(result?.summary || "").trim(); if (summaryText) { const entryMessageRange = [ Number(sourceMessages[0]?.seq ?? -1), Number(sourceMessages[sourceMessages.length - 1]?.seq ?? -1), ]; const entry = appendSummaryEntry(graph, { level: 0, kind: "small", status: "active", text: summaryText, sourceTask: "synopsis", extractionRange: [firstSlice.extractionCountAfter, lastSlice.extractionCountAfter], messageRange: entryMessageRange, dialogueRange: buildDialogueRangeFromMessageRange(chat, entryMessageRange), sourceBatchIds: pendingSlices.map((item) => item.id), sourceSummaryIds: [], sourceNodeIds, storyTimeSpan: deriveStoryTimeSpanFromNodes( graph, nodeHints.nodes, "derived", ), regionHints: nodeHints.regionHints, ownerHints: nodeHints.ownerHints, }); graph.summaryState.lastSummarizedExtractionCount = lastSlice.extractionCountAfter; graph.summaryState.lastSummarizedAssistantFloor = normalizeRange(lastSlice.processedRange)[1]; if (entry) smallSummaryCount += 1; const rollup = await rollupSummaryFrontier({ graph, settings, signal, force: false, }); rollupCount += Number(rollup?.createdCount || 0); } } pendingSlices = []; } return { rebuilt: smallSummaryCount > 0 || rollupCount > 0, smallSummaryCount, rollupCount, targetDialogueRange, rebuildFromExtractionCount: rebuildWindow.rebuildFromExtractionCount, removedEntryCount: trimmed.removedCount, reason: smallSummaryCount > 0 || rollupCount > 0 ? "" : "根据现有提取批次未能重建出新的总结链", }; } export function resetHierarchicalSummaryState(graph) { clearSummaryState(graph); return graph?.summaryState || null; }