// ST-BME: 运行时状态与历史恢复辅助 import { normalizeEdgeMemoryScope, normalizeNodeMemoryScope, } from "../graph/memory-scope.js"; import { createDefaultKnowledgeState, createDefaultRegionState, normalizeGraphCognitiveState, } from "../graph/knowledge-state.js"; import { createDefaultTimelineState, normalizeGraphStoryTimeline, } from "../graph/story-timeline.js"; import { createDefaultSummaryState, importLegacySynopsisToSummaryState, normalizeGraphSummaryState, } from "../graph/summary-state.js"; const BATCH_JOURNAL_LIMIT = 96; const MAINTENANCE_JOURNAL_LIMIT = 20; export const BATCH_JOURNAL_VERSION = 2; export const PROCESSED_MESSAGE_HASH_VERSION = 2; export const MANUAL_BACKUP_BATCH_JOURNAL_COVERAGE_KEY = "manualBackupBatchJournalCoverage"; export function buildVectorCollectionId(chatId) { return `st-bme::${chatId || "unknown-chat"}`; } export function createDefaultHistoryState(chatId = "") { return { chatId, lastProcessedAssistantFloor: -1, processedMessageHashVersion: PROCESSED_MESSAGE_HASH_VERSION, processedMessageHashes: {}, processedMessageHashesNeedRefresh: false, historyDirtyFrom: null, lastMutationReason: "", lastMutationSource: "", extractionCount: 0, lastRecoveryResult: null, lastBatchStatus: null, lastExtractedRegion: "", activeRegion: "", activeRegionSource: "", activeStorySegmentId: "", activeStoryTimeLabel: "", activeStoryTimeSource: "", lastExtractedStorySegmentId: "", activeCharacterPovOwner: "", activeUserPovOwner: "", activeRecallOwnerKey: "", recentRecallOwnerKeys: [], [MANUAL_BACKUP_BATCH_JOURNAL_COVERAGE_KEY]: null, }; } export function createDefaultVectorIndexState(chatId = "") { return { mode: "backend", collectionId: buildVectorCollectionId(chatId), source: "", modelScope: "", hashToNodeId: {}, nodeToHash: {}, dirty: false, replayRequiredNodeIds: [], dirtyReason: "", pendingRepairFromFloor: null, lastSyncAt: 0, lastStats: { total: 0, indexed: 0, stale: 0, pending: 0, }, lastWarning: "", lastIntegrityIssue: null, }; } export function createDefaultBatchJournal() { return []; } export function createDefaultMaintenanceJournal() { return []; } function normalizeManualBackupBatchJournalCoverage(value = null) { if (!value || typeof value !== "object" || Array.isArray(value)) { return null; } const earliestRetainedFloor = Number(value.earliestRetainedFloor); const retainedCount = Number(value.retainedCount); return { truncated: value.truncated === true, earliestRetainedFloor: Number.isFinite(earliestRetainedFloor) ? Math.max(0, Math.floor(earliestRetainedFloor)) : null, retainedCount: Number.isFinite(retainedCount) ? Math.max(0, Math.floor(retainedCount)) : 0, }; } function getEarliestJournalCoverageStartFloor(journals = []) { let earliestFloor = null; for (const journal of Array.isArray(journals) ? journals : []) { const range = Array.isArray(journal?.processedRange) ? journal.processedRange : []; const startFloor = Number(range[0]); if (!Number.isFinite(startFloor)) continue; const normalizedFloor = Math.max(0, Math.floor(startFloor)); earliestFloor = earliestFloor == null ? normalizedFloor : Math.min(earliestFloor, normalizedFloor); } return earliestFloor; } function hasContiguousJournalCoverageThroughFloor(journals = [], targetFloor = null) { const normalizedTargetFloor = Number.isFinite(Number(targetFloor)) ? Math.max(0, Math.floor(Number(targetFloor))) : null; if (!Number.isFinite(normalizedTargetFloor)) { return false; } const ranges = (Array.isArray(journals) ? journals : []) .map((journal) => { const range = Array.isArray(journal?.processedRange) ? journal.processedRange : []; const startFloor = Number(range[0]); const endFloor = Number(range[1]); if (!Number.isFinite(startFloor) || !Number.isFinite(endFloor)) { return null; } return { start: Math.max(0, Math.floor(startFloor)), end: Math.max(0, Math.floor(endFloor)), }; }) .filter(Boolean) .sort((left, right) => left.start - right.start || left.end - right.end); if (ranges.length === 0) { return false; } let coveredUntil = null; for (const range of ranges) { if (coveredUntil == null) { if (range.start > normalizedTargetFloor) { return false; } coveredUntil = range.end; } else if (range.start > coveredUntil + 1) { return coveredUntil >= normalizedTargetFloor; } else { coveredUntil = Math.max(coveredUntil, range.end); } if (coveredUntil >= normalizedTargetFloor) { return true; } } return false; } function reconcileManualBackupBatchJournalCoverage(coverage = null, journals = []) { const normalizedCoverage = normalizeManualBackupBatchJournalCoverage(coverage); if (!normalizedCoverage) { return null; } const manualCoverageFloor = normalizedCoverage.truncated === true && Number.isFinite(normalizedCoverage.earliestRetainedFloor) ? normalizedCoverage.earliestRetainedFloor : null; const actualCoverageFloor = getEarliestJournalCoverageStartFloor(journals); if ( normalizedCoverage.truncated === true && Number.isFinite(actualCoverageFloor) && Number.isFinite(manualCoverageFloor) && actualCoverageFloor < manualCoverageFloor && hasContiguousJournalCoverageThroughFloor(journals, manualCoverageFloor) ) { return null; } return normalizedCoverage; } function getRequiredJournalCoverageStartFloor(graph, journals = []) { const actualCoverageFloor = getEarliestJournalCoverageStartFloor(journals); const manualCoverage = reconcileManualBackupBatchJournalCoverage( graph?.historyState?.[MANUAL_BACKUP_BATCH_JOURNAL_COVERAGE_KEY], journals, ); const manualCoverageFloor = manualCoverage?.truncated === true && Number.isFinite(manualCoverage?.earliestRetainedFloor) ? manualCoverage.earliestRetainedFloor : null; if ( Number.isFinite(actualCoverageFloor) && Number.isFinite(manualCoverageFloor) ) { return Math.max(actualCoverageFloor, manualCoverageFloor); } if (Number.isFinite(actualCoverageFloor)) return actualCoverageFloor; if (Number.isFinite(manualCoverageFloor)) return manualCoverageFloor; return null; } export function normalizeGraphRuntimeState(graph, chatId = "") { if (!graph || typeof graph !== "object") { return graph; } const hadSummaryState = graph.summaryState && typeof graph.summaryState === "object" && !Array.isArray(graph.summaryState); const historyState = { ...createDefaultHistoryState(chatId), ...(graph.historyState || {}), }; historyState[MANUAL_BACKUP_BATCH_JOURNAL_COVERAGE_KEY] = normalizeManualBackupBatchJournalCoverage( historyState[MANUAL_BACKUP_BATCH_JOURNAL_COVERAGE_KEY], ); const vectorIndexState = { ...createDefaultVectorIndexState(chatId), ...(graph.vectorIndexState || {}), }; historyState.chatId = chatId || historyState.chatId || ""; if (!Number.isFinite(historyState.lastProcessedAssistantFloor)) { historyState.lastProcessedAssistantFloor = Number.isFinite( graph.lastProcessedSeq, ) ? graph.lastProcessedSeq : -1; } if (!Number.isFinite(historyState.extractionCount)) { historyState.extractionCount = 0; } if (typeof historyState.lastMutationSource !== "string") { historyState.lastMutationSource = ""; } if ( !historyState.lastBatchStatus || typeof historyState.lastBatchStatus !== "object" || Array.isArray(historyState.lastBatchStatus) ) { historyState.lastBatchStatus = null; } else { historyState.lastBatchStatus = { ...historyState.lastBatchStatus, historyAdvanced: historyState.lastBatchStatus.historyAdvanced === true, historyAdvanceAllowed: historyState.lastBatchStatus.historyAdvanceAllowed === true, persistence: historyState.lastBatchStatus.persistence && typeof historyState.lastBatchStatus.persistence === "object" && !Array.isArray(historyState.lastBatchStatus.persistence) ? { outcome: String( historyState.lastBatchStatus.persistence.outcome || "failed", ), accepted: historyState.lastBatchStatus.persistence.accepted === true, storageTier: String( historyState.lastBatchStatus.persistence.storageTier || "none", ), reason: String( historyState.lastBatchStatus.persistence.reason || "", ), revision: Number.isFinite( Number(historyState.lastBatchStatus.persistence.revision), ) ? Number(historyState.lastBatchStatus.persistence.revision) : 0, saveMode: String( historyState.lastBatchStatus.persistence.saveMode || "", ), recoverable: historyState.lastBatchStatus.persistence.recoverable === true, saved: historyState.lastBatchStatus.persistence.saved === true, queued: historyState.lastBatchStatus.persistence.queued === true, blocked: historyState.lastBatchStatus.persistence.blocked === true, attempted: historyState.lastBatchStatus.persistence.attempted === true || Number(historyState.lastBatchStatus.persistence.revision) > 0 || Boolean( String( historyState.lastBatchStatus.persistence.storageTier || "", ).trim() && String( historyState.lastBatchStatus.persistence.storageTier || "", ) !== "none", ) || Boolean( String( historyState.lastBatchStatus.persistence.saveMode || "", ).trim(), ) || Boolean( String( historyState.lastBatchStatus.persistence.reason || "", ).trim(), ) || historyState.lastBatchStatus.persistence.saved === true || historyState.lastBatchStatus.persistence.queued === true || historyState.lastBatchStatus.persistence.blocked === true, } : null, }; } if (typeof historyState.lastExtractedRegion !== "string") { historyState.lastExtractedRegion = ""; } if (typeof historyState.activeRegion !== "string") { historyState.activeRegion = historyState.lastExtractedRegion || ""; } if (typeof historyState.activeRegionSource !== "string") { historyState.activeRegionSource = historyState.activeRegion ? "history" : ""; } if (typeof historyState.activeStorySegmentId !== "string") { historyState.activeStorySegmentId = ""; } if (typeof historyState.activeStoryTimeLabel !== "string") { historyState.activeStoryTimeLabel = ""; } if (typeof historyState.activeStoryTimeSource !== "string") { historyState.activeStoryTimeSource = historyState.activeStorySegmentId || historyState.activeStoryTimeLabel ? "history" : ""; } if (typeof historyState.lastExtractedStorySegmentId !== "string") { historyState.lastExtractedStorySegmentId = ""; } if (typeof historyState.activeCharacterPovOwner !== "string") { historyState.activeCharacterPovOwner = ""; } if (typeof historyState.activeUserPovOwner !== "string") { historyState.activeUserPovOwner = ""; } if (typeof historyState.activeRecallOwnerKey !== "string") { historyState.activeRecallOwnerKey = ""; } if (!Array.isArray(historyState.recentRecallOwnerKeys)) { historyState.recentRecallOwnerKeys = []; } else { historyState.recentRecallOwnerKeys = [ ...new Set( historyState.recentRecallOwnerKeys .map((value) => String(value || "").trim()) .filter(Boolean), ), ].slice(0, 8); } if ( !historyState.processedMessageHashes || typeof historyState.processedMessageHashes !== "object" || Array.isArray(historyState.processedMessageHashes) ) { historyState.processedMessageHashes = {}; } if (!Number.isFinite(historyState.processedMessageHashVersion)) { historyState.processedMessageHashVersion = 1; } historyState.processedMessageHashVersion = Math.max( 1, Math.floor(historyState.processedMessageHashVersion), ); historyState.processedMessageHashesNeedRefresh = historyState.processedMessageHashesNeedRefresh === true; if ( historyState.processedMessageHashVersion !== PROCESSED_MESSAGE_HASH_VERSION ) { historyState.processedMessageHashes = {}; historyState.processedMessageHashVersion = PROCESSED_MESSAGE_HASH_VERSION; historyState.processedMessageHashesNeedRefresh = true; } const lastProcessedAssistantFloor = Number( historyState.lastProcessedAssistantFloor, ); if ( historyState.processedMessageHashesNeedRefresh !== true && Number.isFinite(lastProcessedAssistantFloor) && lastProcessedAssistantFloor >= 0 && Object.keys(historyState.processedMessageHashes).length === 0 ) { historyState.processedMessageHashesNeedRefresh = true; } if ( !vectorIndexState.hashToNodeId || typeof vectorIndexState.hashToNodeId !== "object" || Array.isArray(vectorIndexState.hashToNodeId) ) { vectorIndexState.hashToNodeId = {}; } if ( !vectorIndexState.nodeToHash || typeof vectorIndexState.nodeToHash !== "object" || Array.isArray(vectorIndexState.nodeToHash) ) { vectorIndexState.nodeToHash = {}; } if ( !vectorIndexState.lastStats || typeof vectorIndexState.lastStats !== "object" ) { vectorIndexState.lastStats = createDefaultVectorIndexState(chatId).lastStats; } if (!Array.isArray(vectorIndexState.replayRequiredNodeIds)) { vectorIndexState.replayRequiredNodeIds = []; } else { vectorIndexState.replayRequiredNodeIds = [ ...new Set(vectorIndexState.replayRequiredNodeIds.filter(Boolean)), ]; } if (typeof vectorIndexState.dirtyReason !== "string") { vectorIndexState.dirtyReason = ""; } if (!Number.isFinite(vectorIndexState.pendingRepairFromFloor)) { vectorIndexState.pendingRepairFromFloor = null; } if ( vectorIndexState.lastIntegrityIssue != null && (typeof vectorIndexState.lastIntegrityIssue !== "object" || Array.isArray(vectorIndexState.lastIntegrityIssue)) ) { vectorIndexState.lastIntegrityIssue = null; } const previousCollectionId = vectorIndexState.collectionId; vectorIndexState.collectionId = buildVectorCollectionId( chatId || historyState.chatId, ); if ( previousCollectionId && previousCollectionId !== vectorIndexState.collectionId ) { vectorIndexState.hashToNodeId = {}; vectorIndexState.nodeToHash = {}; vectorIndexState.replayRequiredNodeIds = []; vectorIndexState.dirty = true; vectorIndexState.dirtyReason = "chat-id-changed"; vectorIndexState.pendingRepairFromFloor = 0; vectorIndexState.lastWarning = "聊天标识变化,向量索引已标记为待重建"; } graph.historyState = historyState; graph.vectorIndexState = vectorIndexState; if (Array.isArray(graph.nodes)) { graph.nodes.forEach((node) => normalizeNodeMemoryScope(node)); } if (Array.isArray(graph.edges)) { graph.edges.forEach((edge) => normalizeEdgeMemoryScope(edge)); } graph.batchJournal = Array.isArray(graph.batchJournal) ? graph.batchJournal.slice(-BATCH_JOURNAL_LIMIT) : createDefaultBatchJournal(); historyState[MANUAL_BACKUP_BATCH_JOURNAL_COVERAGE_KEY] = reconcileManualBackupBatchJournalCoverage( historyState[MANUAL_BACKUP_BATCH_JOURNAL_COVERAGE_KEY], graph.batchJournal, ); graph.maintenanceJournal = Array.isArray(graph.maintenanceJournal) ? graph.maintenanceJournal .filter((entry) => entry && typeof entry === "object") .slice(-MAINTENANCE_JOURNAL_LIMIT) : createDefaultMaintenanceJournal(); graph.knowledgeState = createDefaultKnowledgeState(graph.knowledgeState); graph.regionState = createDefaultRegionState(graph.regionState); graph.timelineState = createDefaultTimelineState(graph.timelineState); graph.summaryState = createDefaultSummaryState(graph.summaryState); normalizeGraphCognitiveState(graph); normalizeGraphStoryTimeline(graph); normalizeGraphSummaryState(graph); if (!hadSummaryState) { importLegacySynopsisToSummaryState(graph); } graph.lastProcessedSeq = historyState.lastProcessedAssistantFloor; return graph; } export function cloneGraphSnapshot(graph) { const snapshot = JSON.parse(JSON.stringify(graph)); if (Array.isArray(snapshot.batchJournal)) { snapshot.batchJournal = snapshot.batchJournal.map((journal) => { if (!journal?.snapshotBefore) return journal; return { ...journal, snapshotBefore: { ...journal.snapshotBefore, batchJournal: [], }, }; }); } return snapshot; } export function stableHashString(text) { let hash = 2166136261; for (const char of String(text || "")) { hash ^= char.charCodeAt(0); hash = Math.imul(hash, 16777619); } return Math.abs(hash >>> 0); } export function buildMessageHash(message) { const swipeId = Number.isFinite(message?.swipe_id) ? message.swipe_id : null; const payload = JSON.stringify({ isUser: Boolean(message?.is_user), text: String(message?.mes || ""), swipeId, }); return String(stableHashString(payload)); } export function snapshotProcessedMessageHashes( chat, lastProcessedAssistantFloor, ) { const result = {}; if (!Array.isArray(chat) || lastProcessedAssistantFloor < 0) { return result; } const upperBound = Math.min(lastProcessedAssistantFloor, chat.length - 1); for (let index = 0; index <= upperBound; index++) { result[index] = buildMessageHash(chat[index]); } return result; } export function applyProcessedHistorySnapshotToGraph( graph, chat, lastProcessedAssistantFloor, ) { if (!graph || typeof graph !== "object") { return graph; } const historyState = graph.historyState && typeof graph.historyState === "object" ? graph.historyState : createDefaultHistoryState(graph?.historyState?.chatId || ""); graph.historyState = historyState; const safeLastProcessedAssistantFloor = Number.isFinite( Number(lastProcessedAssistantFloor), ) ? Math.floor(Number(lastProcessedAssistantFloor)) : -1; historyState.lastProcessedAssistantFloor = safeLastProcessedAssistantFloor; historyState.processedMessageHashVersion = PROCESSED_MESSAGE_HASH_VERSION; historyState.processedMessageHashes = safeLastProcessedAssistantFloor >= 0 ? snapshotProcessedMessageHashes(chat, safeLastProcessedAssistantFloor) : {}; historyState.processedMessageHashesNeedRefresh = false; graph.lastProcessedSeq = safeLastProcessedAssistantFloor; return graph; } export function rebindProcessedHistoryStateToChat( graph, chat, assistantTurns = [], ) { if (!graph || typeof graph !== "object") { return { rebound: false, reason: "missing-graph", lastProcessedAssistantFloor: -1, maxAssistantFloor: -1, clamped: false, }; } const historyState = graph.historyState && typeof graph.historyState === "object" ? graph.historyState : createDefaultHistoryState(); graph.historyState = historyState; const normalizedAssistantTurns = Array.isArray(assistantTurns) ? assistantTurns .map((value) => Number.parseInt(value, 10)) .filter(Number.isFinite) .sort((a, b) => a - b) : []; const maxAssistantFloor = normalizedAssistantTurns.length > 0 ? normalizedAssistantTurns[normalizedAssistantTurns.length - 1] : -1; const rawLastProcessedAssistantFloor = Number.isFinite( historyState.lastProcessedAssistantFloor, ) ? Math.floor(historyState.lastProcessedAssistantFloor) : -1; let safeLastProcessedAssistantFloor = rawLastProcessedAssistantFloor; if (!Array.isArray(chat) || chat.length === 0 || maxAssistantFloor < 0) { safeLastProcessedAssistantFloor = -1; } else if (safeLastProcessedAssistantFloor > maxAssistantFloor) { safeLastProcessedAssistantFloor = maxAssistantFloor; } historyState.lastProcessedAssistantFloor = safeLastProcessedAssistantFloor; historyState.processedMessageHashVersion = PROCESSED_MESSAGE_HASH_VERSION; historyState.processedMessageHashes = safeLastProcessedAssistantFloor >= 0 ? snapshotProcessedMessageHashes(chat, safeLastProcessedAssistantFloor) : {}; historyState.processedMessageHashesNeedRefresh = false; graph.lastProcessedSeq = safeLastProcessedAssistantFloor; return { rebound: true, reason: safeLastProcessedAssistantFloor < 0 ? "no-processed-assistant-floor" : "ok", lastProcessedAssistantFloor: safeLastProcessedAssistantFloor, maxAssistantFloor, clamped: safeLastProcessedAssistantFloor !== rawLastProcessedAssistantFloor, }; } export function detectHistoryMutation(chat, historyState) { const lastProcessedAssistantFloor = historyState?.lastProcessedAssistantFloor ?? -1; const processedMessageHashVersion = Number.isFinite( historyState?.processedMessageHashVersion, ) ? Math.max(1, Math.floor(historyState.processedMessageHashVersion)) : 1; const processedMessageHashesNeedRefresh = historyState?.processedMessageHashesNeedRefresh === true; const processedMessageHashes = historyState?.processedMessageHashes && typeof historyState.processedMessageHashes === "object" && !Array.isArray(historyState.processedMessageHashes) ? historyState.processedMessageHashes : {}; if (!Array.isArray(chat) || lastProcessedAssistantFloor < 0) { return { dirty: false, earliestAffectedFloor: null, reason: "" }; } if ( processedMessageHashesNeedRefresh || processedMessageHashVersion !== PROCESSED_MESSAGE_HASH_VERSION ) { return { dirty: false, earliestAffectedFloor: null, reason: "" }; } if (lastProcessedAssistantFloor >= chat.length) { return { dirty: true, earliestAffectedFloor: chat.length, reason: "已处理楼层超出当前聊天长度,检测到历史截断", }; } const trackedFloors = Object.keys(processedMessageHashes) .map((value) => Number.parseInt(value, 10)) .filter(Number.isFinite) .sort((a, b) => a - b); if (trackedFloors.length === 0 && lastProcessedAssistantFloor >= 0) { return { dirty: true, earliestAffectedFloor: 0, reason: "已处理楼层存在,但 processedMessageHashes 缺失,执行保守重放", }; } for (let floor = 0; floor <= lastProcessedAssistantFloor; floor++) { if ( !Object.prototype.hasOwnProperty.call( processedMessageHashes, String(floor), ) ) { return { dirty: true, earliestAffectedFloor: floor, reason: `楼层 ${floor} 缺少已处理哈希,执行保守重放`, }; } } for (const floor of trackedFloors) { if (floor >= chat.length) { return { dirty: true, earliestAffectedFloor: floor, reason: `楼层 ${floor} 已不存在,检测到历史删除/截断`, }; } const currentHash = buildMessageHash(chat[floor]); if (currentHash !== processedMessageHashes[floor]) { return { dirty: true, earliestAffectedFloor: floor, reason: `楼层 ${floor} 内容或 swipe 已变化`, }; } } return { dirty: false, earliestAffectedFloor: null, reason: "" }; } export function markHistoryDirty(graph, floor, reason = "", source = "") { normalizeGraphRuntimeState(graph, graph?.historyState?.chatId || ""); const currentDirtyFrom = graph.historyState.historyDirtyFrom; if (!Number.isFinite(floor)) { floor = graph.historyState.lastProcessedAssistantFloor; } graph.historyState.historyDirtyFrom = Number.isFinite(currentDirtyFrom) ? Math.min(currentDirtyFrom, floor) : floor; graph.historyState.lastMutationReason = String(reason || "").trim(); graph.historyState.lastMutationSource = String(source || "").trim(); graph.historyState.lastRecoveryResult = { status: "pending", at: Date.now(), fromFloor: graph.historyState.historyDirtyFrom, reason: graph.historyState.lastMutationReason, detectionSource: graph.historyState.lastMutationSource || "", }; } export function clearHistoryDirty(graph, result = null) { normalizeGraphRuntimeState(graph, graph?.historyState?.chatId || ""); graph.historyState.historyDirtyFrom = null; graph.historyState.lastMutationReason = ""; graph.historyState.lastMutationSource = ""; const lastProcessedAssistantFloor = Number( graph.historyState.lastProcessedAssistantFloor, ); graph.historyState.processedMessageHashVersion = PROCESSED_MESSAGE_HASH_VERSION; graph.historyState.processedMessageHashes = {}; graph.historyState.processedMessageHashesNeedRefresh = Number.isFinite(lastProcessedAssistantFloor) && lastProcessedAssistantFloor >= 0; if (result) { graph.historyState.lastRecoveryResult = result; } } function buildNodeMap(nodes = []) { return new Map(nodes.map((node) => [node.id, node])); } function buildEdgeMap(edges = []) { return new Map(edges.map((edge) => [edge.id, edge])); } function hasMeaningfulNodeChange(beforeNode, afterNode) { return JSON.stringify(beforeNode) !== JSON.stringify(afterNode); } function hasMeaningfulEdgeChange(beforeEdge, afterEdge) { return JSON.stringify(beforeEdge) !== JSON.stringify(afterEdge); } function clonePlain(value) { return JSON.parse(JSON.stringify(value)); } function normalizeStringArray(values) { if (!Array.isArray(values)) return []; return [...new Set(values.filter(Boolean).map((value) => String(value)))]; } function normalizeMappingArray(values) { if (!Array.isArray(values)) return []; const seen = new Set(); const mappings = []; for (const entry of values) { if (!entry || typeof entry !== "object") continue; const nodeId = entry.nodeId ? String(entry.nodeId) : ""; const previousHash = entry.previousHash ? String(entry.previousHash) : ""; const nextHash = entry.nextHash ? String(entry.nextHash) : ""; if (!nodeId && !previousHash && !nextHash) continue; const key = JSON.stringify([nodeId, previousHash, nextHash]); if (seen.has(key)) continue; seen.add(key); mappings.push({ nodeId, previousHash, nextHash }); } return mappings; } function buildVectorDelta(snapshotBefore, snapshotAfter, meta = {}) { const beforeState = snapshotBefore?.vectorIndexState || {}; const afterState = snapshotAfter?.vectorIndexState || {}; const beforeNodeToHash = beforeState.nodeToHash || {}; const afterNodeToHash = afterState.nodeToHash || {}; const beforeHashSet = new Set( Object.values(beforeState.hashToNodeId || {}).filter(Boolean), ); const afterHashSet = new Set( Object.values(afterState.hashToNodeId || {}).filter(Boolean), ); const insertedHashes = new Set( normalizeStringArray(meta.vectorHashesInserted), ); const removedHashes = new Set(normalizeStringArray(meta.vectorHashesRemoved)); const touchedNodeIds = new Set( normalizeStringArray(meta.vectorTouchedNodeIds), ); const replayRequiredNodeIds = new Set( normalizeStringArray(meta.vectorReplayRequiredNodeIds), ); const backendDeleteHashes = new Set( normalizeStringArray(meta.vectorBackendDeleteHashes), ); const replacedMappings = normalizeMappingArray(meta.vectorReplacedMappings); const nodeIds = new Set([ ...Object.keys(beforeNodeToHash), ...Object.keys(afterNodeToHash), ]); for (const hash of Object.keys(afterState.hashToNodeId || {})) { if (!beforeHashSet.has(hash)) insertedHashes.add(hash); } for (const hash of Object.keys(beforeState.hashToNodeId || {})) { if (!afterHashSet.has(hash)) removedHashes.add(hash); } for (const nodeId of nodeIds) { const previousHash = beforeNodeToHash[nodeId] ? String(beforeNodeToHash[nodeId]) : ""; const nextHash = afterNodeToHash[nodeId] ? String(afterNodeToHash[nodeId]) : ""; if (previousHash === nextHash) continue; touchedNodeIds.add(String(nodeId)); if (previousHash) { removedHashes.add(previousHash); backendDeleteHashes.add(previousHash); } if (nextHash) { insertedHashes.add(nextHash); } if (previousHash || nextHash) { const key = JSON.stringify([String(nodeId), previousHash, nextHash]); const exists = replacedMappings.some( (entry) => JSON.stringify([entry.nodeId, entry.previousHash, entry.nextHash]) === key, ); if (!exists) { replacedMappings.push({ nodeId: String(nodeId), previousHash, nextHash, }); } } } for (const nodeId of normalizeStringArray(afterState.replayRequiredNodeIds)) { replayRequiredNodeIds.add(nodeId); } return { insertedHashes: [...insertedHashes], removedHashes: [...removedHashes], replacedMappings, touchedNodeIds: [...touchedNodeIds], replayRequiredNodeIds: [...replayRequiredNodeIds], backendDeleteHashes: [...backendDeleteHashes], }; } function buildJournalStateBefore(snapshotBefore, meta = {}) { return { lastProcessedAssistantFloor: snapshotBefore?.historyState?.lastProcessedAssistantFloor ?? snapshotBefore?.lastProcessedSeq ?? -1, processedMessageHashVersion: Number.isFinite( snapshotBefore?.historyState?.processedMessageHashVersion, ) ? Math.max( 1, Math.floor(snapshotBefore.historyState.processedMessageHashVersion), ) : PROCESSED_MESSAGE_HASH_VERSION, processedMessageHashes: clonePlain( snapshotBefore?.historyState?.processedMessageHashes || {}, ), processedMessageHashesNeedRefresh: snapshotBefore?.historyState?.processedMessageHashesNeedRefresh === true, historyDirtyFrom: Number.isFinite( snapshotBefore?.historyState?.historyDirtyFrom, ) ? snapshotBefore.historyState.historyDirtyFrom : null, vectorIndexState: clonePlain(snapshotBefore?.vectorIndexState || {}), knowledgeState: clonePlain( snapshotBefore?.knowledgeState || createDefaultKnowledgeState(), ), regionState: clonePlain( snapshotBefore?.regionState || createDefaultRegionState(), ), timelineState: clonePlain( snapshotBefore?.timelineState || createDefaultTimelineState(), ), summaryState: clonePlain( snapshotBefore?.summaryState || createDefaultSummaryState(), ), lastRecallResult: Array.isArray(snapshotBefore?.lastRecallResult) ? [...snapshotBefore.lastRecallResult] : null, extractionCount: Number.isFinite(meta.extractionCountBefore) ? meta.extractionCountBefore : (snapshotBefore?.historyState?.extractionCount ?? 0), }; } export function createBatchJournalEntry( snapshotBefore, snapshotAfter, meta = {}, ) { const beforeNodes = buildNodeMap(snapshotBefore?.nodes || []); const afterNodes = buildNodeMap(snapshotAfter?.nodes || []); const beforeEdges = buildEdgeMap(snapshotBefore?.edges || []); const afterEdges = buildEdgeMap(snapshotAfter?.edges || []); const createdNodeIds = []; const createdEdgeIds = []; const previousNodeSnapshots = []; const previousEdgeSnapshots = []; for (const [nodeId, afterNode] of afterNodes.entries()) { if (!beforeNodes.has(nodeId)) { createdNodeIds.push(nodeId); continue; } const beforeNode = beforeNodes.get(nodeId); if (!hasMeaningfulNodeChange(beforeNode, afterNode)) continue; previousNodeSnapshots.push(cloneGraphSnapshot(beforeNode)); } for (const [edgeId, afterEdge] of afterEdges.entries()) { if (!beforeEdges.has(edgeId)) { createdEdgeIds.push(edgeId); continue; } const beforeEdge = beforeEdges.get(edgeId); if (!hasMeaningfulEdgeChange(beforeEdge, afterEdge)) continue; previousEdgeSnapshots.push(cloneGraphSnapshot(beforeEdge)); } const entry = { id: `batch-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, journalVersion: BATCH_JOURNAL_VERSION, createdAt: Date.now(), processedRange: meta.processedRange || [-1, -1], processedDialogueRange: Array.isArray(meta.processedDialogueRange) ? meta.processedDialogueRange : [-1, -1], sourceChatIndexRange: Array.isArray(meta.sourceChatIndexRange) ? meta.sourceChatIndexRange : [-1, -1], createdNodeIds, createdEdgeIds, previousNodeSnapshots, previousEdgeSnapshots, touchedNodeIds: normalizeStringArray( meta.touchedNodeIds || [ ...createdNodeIds, ...previousNodeSnapshots.map((node) => node?.id), ], ), stateBefore: buildJournalStateBefore(snapshotBefore, meta), vectorDelta: buildVectorDelta(snapshotBefore, snapshotAfter, meta), postProcessArtifacts: Array.isArray(meta.postProcessArtifacts) ? meta.postProcessArtifacts : [], }; if (meta.includeLegacySnapshotBefore) { entry.snapshotBefore = snapshotBefore; } return entry; } export function appendBatchJournal(graph, entry) { normalizeGraphRuntimeState(graph, graph?.historyState?.chatId || ""); graph.batchJournal.push(entry); if (graph.batchJournal.length > BATCH_JOURNAL_LIMIT) { graph.batchJournal = graph.batchJournal.slice(-BATCH_JOURNAL_LIMIT); } graph.historyState[MANUAL_BACKUP_BATCH_JOURNAL_COVERAGE_KEY] = reconcileManualBackupBatchJournalCoverage( graph.historyState?.[MANUAL_BACKUP_BATCH_JOURNAL_COVERAGE_KEY], graph.batchJournal, ); } export function createMaintenanceJournalEntry( snapshotBefore, snapshotAfter, meta = {}, ) { const normalizedChatId = String( meta.chatId || snapshotAfter?.historyState?.chatId || snapshotBefore?.historyState?.chatId || "", ).trim(); const normalizedBefore = normalizeGraphRuntimeState( cloneGraphSnapshot(snapshotBefore || { nodes: [], edges: [] }), normalizedChatId, ); const normalizedAfter = normalizeGraphRuntimeState( cloneGraphSnapshot(snapshotAfter || { nodes: [], edges: [] }), normalizedChatId, ); const beforeNodes = buildNodeMap(normalizedBefore?.nodes || []); const afterNodes = buildNodeMap(normalizedAfter?.nodes || []); const beforeEdges = buildEdgeMap(normalizedBefore?.edges || []); const afterEdges = buildEdgeMap(normalizedAfter?.edges || []); const restoreNodes = []; const restoreEdges = []; const deleteNodeIds = []; const deleteEdgeIds = []; const postNodes = []; const postEdges = []; for (const [nodeId, beforeNode] of beforeNodes.entries()) { const afterNode = afterNodes.get(nodeId); if (!afterNode) { restoreNodes.push(cloneGraphSnapshot(beforeNode)); continue; } if (!hasMeaningfulNodeChange(beforeNode, afterNode)) continue; restoreNodes.push(cloneGraphSnapshot(beforeNode)); postNodes.push(cloneGraphSnapshot(afterNode)); } for (const [nodeId, afterNode] of afterNodes.entries()) { if (beforeNodes.has(nodeId)) continue; deleteNodeIds.push(nodeId); postNodes.push(cloneGraphSnapshot(afterNode)); } for (const [edgeId, beforeEdge] of beforeEdges.entries()) { const afterEdge = afterEdges.get(edgeId); if (!afterEdge) { restoreEdges.push(cloneGraphSnapshot(beforeEdge)); continue; } if (!hasMeaningfulEdgeChange(beforeEdge, afterEdge)) continue; restoreEdges.push(cloneGraphSnapshot(beforeEdge)); postEdges.push(cloneGraphSnapshot(afterEdge)); } for (const [edgeId, afterEdge] of afterEdges.entries()) { if (beforeEdges.has(edgeId)) continue; deleteEdgeIds.push(edgeId); postEdges.push(cloneGraphSnapshot(afterEdge)); } if ( restoreNodes.length === 0 && restoreEdges.length === 0 && deleteNodeIds.length === 0 && deleteEdgeIds.length === 0 ) { return null; } return { id: `maintenance-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, createdAt: Date.now(), action: String(meta.action || "unknown"), mode: meta.mode === "auto" || meta.mode === "manual" ? meta.mode : "manual", summary: String(meta.summary || ""), inversePatch: { restoreNodes, restoreEdges, deleteNodeIds, deleteEdgeIds, }, postCheck: { nodes: postNodes, edges: postEdges, }, }; } export function appendMaintenanceJournal(graph, entry) { if (!entry || typeof entry !== "object") return; normalizeGraphRuntimeState(graph, graph?.historyState?.chatId || ""); graph.maintenanceJournal.push(entry); if (graph.maintenanceJournal.length > MAINTENANCE_JOURNAL_LIMIT) { graph.maintenanceJournal = graph.maintenanceJournal.slice( -MAINTENANCE_JOURNAL_LIMIT, ); } } export function getLatestMaintenanceJournalEntry(graph) { normalizeGraphRuntimeState(graph, graph?.historyState?.chatId || ""); const journal = Array.isArray(graph?.maintenanceJournal) ? graph.maintenanceJournal : []; return journal.length > 0 ? journal[journal.length - 1] : null; } function validateMaintenanceUndoState(graph, entry) { const currentNodes = buildNodeMap(graph?.nodes || []); const currentEdges = buildEdgeMap(graph?.edges || []); const expectedNodes = entry?.postCheck?.nodes || []; const expectedEdges = entry?.postCheck?.edges || []; for (const snapshot of expectedNodes) { const current = currentNodes.get(snapshot?.id); if (!current) { return { ok: false, reason: `节点 ${snapshot?.id || "unknown"} 已被后续操作改写`, }; } if (JSON.stringify(current) !== JSON.stringify(snapshot)) { return { ok: false, reason: `节点 ${snapshot?.id || "unknown"} 当前状态已变化,无法安全撤销`, }; } } for (const snapshot of expectedEdges) { const current = currentEdges.get(snapshot?.id); if (!current) { return { ok: false, reason: `边 ${snapshot?.id || "unknown"} 已被后续操作改写`, }; } if (JSON.stringify(current) !== JSON.stringify(snapshot)) { return { ok: false, reason: `边 ${snapshot?.id || "unknown"} 当前状态已变化,无法安全撤销`, }; } } return { ok: true, reason: "" }; } export function applyMaintenanceInversePatch(graph, inversePatch = {}) { if (!graph || !inversePatch || typeof inversePatch !== "object") { return graph; } normalizeGraphRuntimeState(graph, graph?.historyState?.chatId || ""); const deleteNodeIds = new Set(inversePatch.deleteNodeIds || []); const deleteEdgeIds = new Set(inversePatch.deleteEdgeIds || []); const restoreNodes = Array.isArray(inversePatch.restoreNodes) ? inversePatch.restoreNodes : []; const restoreEdges = Array.isArray(inversePatch.restoreEdges) ? inversePatch.restoreEdges : []; graph.edges = (graph.edges || []).filter( (edge) => !deleteEdgeIds.has(edge.id) && !deleteNodeIds.has(edge.fromId) && !deleteNodeIds.has(edge.toId), ); graph.nodes = (graph.nodes || []).filter((node) => !deleteNodeIds.has(node.id)); for (const nodeSnapshot of restoreNodes) { upsertById(graph.nodes, cloneGraphSnapshot(nodeSnapshot)); } for (const edgeSnapshot of restoreEdges) { upsertById(graph.edges, cloneGraphSnapshot(edgeSnapshot)); } sanitizeGraphReferences(graph); return graph; } export function undoLatestMaintenance(graph) { normalizeGraphRuntimeState(graph, graph?.historyState?.chatId || ""); const entry = getLatestMaintenanceJournalEntry(graph); if (!entry) { return { ok: false, reason: "当前没有可撤销的维护记录", entry: null, }; } const validation = validateMaintenanceUndoState(graph, entry); if (!validation.ok) { return { ok: false, reason: validation.reason, entry, }; } applyMaintenanceInversePatch(graph, entry.inversePatch || {}); graph.maintenanceJournal = graph.maintenanceJournal.slice(0, -1); return { ok: true, reason: "", entry, remaining: graph.maintenanceJournal.length, }; } function upsertById(list, item) { const index = list.findIndex((entry) => entry.id === item.id); if (index >= 0) { list[index] = item; } else { list.push(item); } } function sanitizeGraphReferences(graph) { const nodeIds = new Set((graph?.nodes || []).map((node) => node.id)); graph.nodes = (graph.nodes || []).map((node) => ({ ...node, parentId: nodeIds.has(node.parentId) ? node.parentId : null, childIds: Array.isArray(node.childIds) ? node.childIds.filter((id) => nodeIds.has(id)) : [], prevId: nodeIds.has(node.prevId) ? node.prevId : null, nextId: nodeIds.has(node.nextId) ? node.nextId : null, })); graph.edges = (graph.edges || []).filter( (edge) => nodeIds.has(edge.fromId) && nodeIds.has(edge.toId), ); } function applyJournalStateBefore(graph, stateBefore = {}) { const historyState = { ...createDefaultHistoryState(graph?.historyState?.chatId || ""), ...(graph.historyState || {}), }; historyState.lastProcessedAssistantFloor = Number.isFinite( stateBefore.lastProcessedAssistantFloor, ) ? stateBefore.lastProcessedAssistantFloor : historyState.lastProcessedAssistantFloor; historyState.processedMessageHashVersion = Number.isFinite( stateBefore.processedMessageHashVersion, ) ? Math.max(1, Math.floor(stateBefore.processedMessageHashVersion)) : historyState.processedMessageHashVersion; historyState.processedMessageHashes = clonePlain( stateBefore.processedMessageHashes || {}, ); historyState.processedMessageHashesNeedRefresh = stateBefore.processedMessageHashesNeedRefresh === true; historyState.historyDirtyFrom = Number.isFinite(stateBefore.historyDirtyFrom) ? stateBefore.historyDirtyFrom : null; historyState.extractionCount = Number.isFinite(stateBefore.extractionCount) ? stateBefore.extractionCount : historyState.extractionCount; graph.historyState = historyState; graph.vectorIndexState = { ...createDefaultVectorIndexState(graph?.historyState?.chatId || ""), ...clonePlain(stateBefore.vectorIndexState || {}), }; graph.knowledgeState = createDefaultKnowledgeState( clonePlain(stateBefore.knowledgeState || {}), ); graph.regionState = createDefaultRegionState( clonePlain(stateBefore.regionState || {}), ); graph.timelineState = createDefaultTimelineState( clonePlain(stateBefore.timelineState || {}), ); graph.summaryState = createDefaultSummaryState( clonePlain(stateBefore.summaryState || {}), ); normalizeGraphCognitiveState(graph); normalizeGraphStoryTimeline(graph); normalizeGraphSummaryState(graph); graph.lastRecallResult = Array.isArray(stateBefore.lastRecallResult) ? [...stateBefore.lastRecallResult] : null; graph.lastProcessedSeq = historyState.lastProcessedAssistantFloor; } export function rollbackBatch(graph, journal) { if (!graph || !journal) return graph; normalizeGraphRuntimeState(graph, graph?.historyState?.chatId || ""); const createdNodeIds = new Set(journal.createdNodeIds || []); const createdEdgeIds = new Set(journal.createdEdgeIds || []); const previousNodeSnapshots = journal.previousNodeSnapshots || journal.updatedNodeSnapshots || journal.archivedNodeSnapshots || []; const previousEdgeSnapshots = journal.previousEdgeSnapshots || journal.invalidatedEdgeSnapshots || []; graph.edges = (graph.edges || []).filter( (edge) => !createdEdgeIds.has(edge.id) && !createdNodeIds.has(edge.fromId) && !createdNodeIds.has(edge.toId), ); graph.nodes = (graph.nodes || []).filter( (node) => !createdNodeIds.has(node.id), ); for (const nodeSnapshot of previousNodeSnapshots) { upsertById(graph.nodes, cloneGraphSnapshot(nodeSnapshot)); } for (const edgeSnapshot of previousEdgeSnapshots) { upsertById(graph.edges, cloneGraphSnapshot(edgeSnapshot)); } applyJournalStateBefore(graph, journal.stateBefore || {}); sanitizeGraphReferences(graph); return graph; } export function findJournalRecoveryPoint(graph, dirtyFromFloor) { const journals = Array.isArray(graph?.batchJournal) ? graph.batchJournal : []; const requiredCoverageFloor = getRequiredJournalCoverageStartFloor( graph, journals, ); if ( Number.isFinite(dirtyFromFloor) && Number.isFinite(requiredCoverageFloor) && dirtyFromFloor < requiredCoverageFloor ) { return null; } const affectedIndex = journals.findIndex((journal) => { const range = Array.isArray(journal?.processedRange) ? journal.processedRange : [-1, -1]; return Number.isFinite(range[1]) && range[1] >= dirtyFromFloor; }); if (affectedIndex < 0) return null; const affectedJournals = journals.slice(affectedIndex); const canReverse = affectedJournals.every( (journal) => Number(journal?.journalVersion || 0) >= BATCH_JOURNAL_VERSION, ); if (canReverse) { return { path: "reverse-journal", affectedIndex, affectedJournals: affectedJournals.map((journal) => cloneGraphSnapshot(journal), ), affectedBatchCount: affectedJournals.length, }; } const journal = journals[affectedIndex]; if (journal?.snapshotBefore) { return { path: "legacy-snapshot", affectedIndex, journal: cloneGraphSnapshot(journal), snapshotBefore: cloneGraphSnapshot(journal.snapshotBefore), affectedBatchCount: affectedJournals.length, }; } return null; } export function buildReverseJournalRecoveryPlan( affectedJournals = [], dirtyFromFloor = null, ) { const backendDeleteHashes = new Set(); const replayRequiredNodeIds = new Set(); const touchedNodeIds = new Set(); let hasLegacyGap = false; let minProcessedFloor = Number.isFinite(dirtyFromFloor) ? dirtyFromFloor : null; let invalidJournalReason = ""; if (!Array.isArray(affectedJournals) || affectedJournals.length === 0) { invalidJournalReason = "affected-journals-empty"; } for (const journal of affectedJournals) { const vectorDelta = journal?.vectorDelta || {}; const insertedHashes = normalizeStringArray( vectorDelta.insertedHashes || journal?.vectorHashesInserted || [], ); const removedHashes = normalizeStringArray(vectorDelta.removedHashes); const backendDeletes = normalizeStringArray( vectorDelta.backendDeleteHashes, ); const touchedNodes = normalizeStringArray(vectorDelta.touchedNodeIds); const replayNodes = normalizeStringArray(vectorDelta.replayRequiredNodeIds); const replacedMappings = normalizeMappingArray( vectorDelta.replacedMappings, ); const range = Array.isArray(journal?.processedRange) ? journal.processedRange : [-1, -1]; if ( !invalidJournalReason && (!Number.isFinite(range[0]) || !Number.isFinite(range[1])) ) { invalidJournalReason = "processed-range-missing"; } if (Number.isFinite(range[0])) { minProcessedFloor = Number.isFinite(minProcessedFloor) ? Math.min(minProcessedFloor, range[0]) : range[0]; } for (const hash of insertedHashes) { backendDeleteHashes.add(hash); } for (const hash of removedHashes) { backendDeleteHashes.add(hash); } for (const hash of backendDeletes) { backendDeleteHashes.add(hash); } for (const nodeId of touchedNodes) { touchedNodeIds.add(nodeId); replayRequiredNodeIds.add(nodeId); } for (const nodeId of replayNodes) { replayRequiredNodeIds.add(nodeId); } for (const entry of replacedMappings) { if (entry.nodeId) { touchedNodeIds.add(entry.nodeId); replayRequiredNodeIds.add(entry.nodeId); } if (entry.previousHash) backendDeleteHashes.add(entry.previousHash); if (entry.nextHash) backendDeleteHashes.add(entry.nextHash); } if ( !Array.isArray(vectorDelta.removedHashes) || !Array.isArray(vectorDelta.replacedMappings) || !Array.isArray(vectorDelta.touchedNodeIds) || !Array.isArray(vectorDelta.replayRequiredNodeIds) || !Array.isArray(vectorDelta.backendDeleteHashes) ) { hasLegacyGap = true; } } const pendingRepairFromFloor = Number.isFinite(minProcessedFloor) ? minProcessedFloor : null; return { backendDeleteHashes: [...backendDeleteHashes], replayRequiredNodeIds: [...replayRequiredNodeIds], touchedNodeIds: [...touchedNodeIds], pendingRepairFromFloor, legacyGapFallback: hasLegacyGap, dirtyReason: hasLegacyGap ? "legacy-gap" : "history-recovery-replay", valid: !invalidJournalReason && Number.isFinite(pendingRepairFromFloor) && pendingRepairFromFloor >= 0, invalidReason: invalidJournalReason || (!Number.isFinite(pendingRepairFromFloor) ? "pending-repair-floor-missing" : pendingRepairFromFloor < 0 ? "pending-repair-floor-negative" : ""), }; } export function buildRecoveryResult(status, extra = {}) { return { status, at: Date.now(), debugReason: typeof extra?.debugReason === "string" && extra.debugReason.trim() ? extra.debugReason.trim() : typeof extra?.reason === "string" ? extra.reason : "", ...extra, }; }