diff --git a/compressor.js b/compressor.js index 74548e7..1b5bc89 100644 --- a/compressor.js +++ b/compressor.js @@ -58,6 +58,28 @@ function throwIfAborted(signal) { } } +function resolveCompressionWindow(compression = {}, force = false) { + const fanIn = Number.isFinite(Number(compression?.fanIn)) + ? Math.max(2, Number(compression.fanIn)) + : 2; + const threshold = force + ? fanIn + : Number.isFinite(Number(compression?.threshold)) + ? Math.max(2, Number(compression.threshold)) + : fanIn; + const keepRecent = force + ? 0 + : Number.isFinite(Number(compression?.keepRecentLeaves)) + ? Math.max(0, Number(compression.keepRecentLeaves)) + : 0; + + return { + fanIn, + threshold, + keepRecent, + }; +} + /** * 对指定类型执行层级压缩 * @@ -126,9 +148,10 @@ async function compressLevel({ settings = {}, }) { const compression = typeDef.compression; - const fanIn = Number.isFinite(Number(compression.fanIn)) - ? Math.max(2, Number(compression.fanIn)) - : 2; + const { fanIn, threshold, keepRecent } = resolveCompressionWindow( + compression, + force, + ); throwIfAborted(signal); // 获取该层级的活跃叶子节点 @@ -138,17 +161,6 @@ async function compressLevel({ let created = 0; let archived = 0; - const threshold = force - ? fanIn - : Number.isFinite(Number(compression.threshold)) - ? Math.max(2, Number(compression.threshold)) - : fanIn; - const keepRecent = force - ? 0 - : Number.isFinite(Number(compression.keepRecentLeaves)) - ? Math.max(0, Number(compression.keepRecentLeaves)) - : 0; - for (const group of groupCompressionCandidates(levelNodes)) { if (force ? group.length < fanIn : group.length <= threshold) { continue; @@ -237,6 +249,78 @@ function groupCompressionCandidates(nodes = []) { ); } +function inspectCompressibleGroup(group = [], compression = {}, force = false) { + const { fanIn, threshold, keepRecent } = resolveCompressionWindow( + compression, + force, + ); + if (force ? group.length < fanIn : group.length <= threshold) { + return null; + } + + const compressible = group.slice(0, Math.max(0, group.length - keepRecent)); + if (compressible.length < fanIn) { + return null; + } + + return { + candidateCount: compressible.length, + fanIn, + threshold, + keepRecent, + }; +} + +export function inspectAutoCompressionCandidates( + graph, + schema = [], + force = false, +) { + const safeSchema = Array.isArray(schema) ? schema : []; + for (const typeDef of safeSchema) { + if (typeDef?.compression?.mode !== "hierarchical") continue; + const maxDepth = Number.isFinite(Number(typeDef?.compression?.maxDepth)) + ? Math.max(1, Number(typeDef.compression.maxDepth)) + : 1; + + for (let level = 0; level < maxDepth; level++) { + const levelNodes = getActiveNodes(graph, typeDef.id) + .filter((node) => Number(node?.level || 0) === level) + .sort((a, b) => a.seq - b.seq); + + for (const group of groupCompressionCandidates(levelNodes)) { + const summary = inspectCompressibleGroup( + group, + typeDef.compression, + force, + ); + if (!summary) continue; + return { + hasCandidates: true, + typeId: String(typeDef.id || ""), + level, + candidateCount: summary.candidateCount, + threshold: summary.threshold, + fanIn: summary.fanIn, + keepRecent: summary.keepRecent, + reason: "", + }; + } + } + } + + return { + hasCandidates: false, + typeId: "", + level: null, + candidateCount: 0, + threshold: 0, + fanIn: 0, + keepRecent: 0, + reason: "已到自动压缩周期,但当前没有达到内部压缩阈值的候选组", + }; +} + function migrateBatchEdges(graph, batch, compressedNode) { const batchIds = new Set(batch.map((node) => node.id)); diff --git a/consolidator.js b/consolidator.js index 33f86fd..d03a25f 100644 --- a/consolidator.js +++ b/consolidator.js @@ -111,6 +111,153 @@ const CONSOLIDATION_SYSTEM_PROMPT = `你是一个记忆整合分析器。当新 - 不要对无关记忆强行建立联系 - neighbor_updates 中每条必须有实际意义的修改`; +function normalizeLatestOnlyIdentityValue(value) { + return String(value ?? "") + .trim() + .replace(/\s+/g, " ") + .toLowerCase(); +} + +export async function analyzeAutoConsolidationGate({ + graph, + newNodeIds, + embeddingConfig, + schema = [], + conflictThreshold = 0.85, + signal, +} = {}) { + const normalizedThreshold = Number.isFinite(Number(conflictThreshold)) + ? Math.max(0, Math.min(1, Number(conflictThreshold))) + : 0.85; + const safeNewNodeIds = Array.isArray(newNodeIds) ? newNodeIds : []; + + if (!graph || safeNewNodeIds.length === 0) { + return { + triggered: false, + reason: "本批新增少且无明显重复风险,跳过自动整合", + matchedScore: null, + matchedNodeId: "", + detection: "none", + }; + } + + const schemaByType = new Map( + (Array.isArray(schema) ? schema : []) + .filter((typeDef) => typeDef?.id) + .map((typeDef) => [String(typeDef.id), typeDef]), + ); + const activeNodes = getActiveNodes(graph).filter((node) => !node?.archived); + const vectorConfigValid = validateVectorConfig(embeddingConfig).valid; + let bestVectorMatch = null; + + for (const newNodeId of safeNewNodeIds) { + throwIfAborted(signal); + const node = getNode(graph, newNodeId); + if (!node || node.archived) continue; + + const typeDef = schemaByType.get(String(node.type || "")); + const scopedCandidates = activeNodes.filter( + (candidate) => + candidate?.id !== node.id && canMergeScopedMemories(node, candidate), + ); + + if (typeDef?.latestOnly) { + for (const field of ["name", "title"]) { + const normalizedIdentity = normalizeLatestOnlyIdentityValue( + node?.fields?.[field], + ); + if (!normalizedIdentity) continue; + const matchedNode = scopedCandidates.find( + (candidate) => + candidate?.type === node.type && + normalizeLatestOnlyIdentityValue(candidate?.fields?.[field]) === + normalizedIdentity, + ); + if (matchedNode) { + return { + triggered: true, + reason: `本批仅新增 ${safeNewNodeIds.length} 个节点,但 latestOnly 的 ${field} 与旧记忆完全一致,已触发自动整合`, + matchedScore: 1, + matchedNodeId: matchedNode.id, + detection: `latestOnly:${field}`, + }; + } + } + } + + if (!vectorConfigValid) continue; + const text = buildNodeVectorText(node); + if (!text) continue; + + try { + const neighbors = await findSimilarNodesByText( + graph, + text, + embeddingConfig, + 1, + scopedCandidates, + signal, + ); + const topNeighbor = Array.isArray(neighbors) ? neighbors[0] : null; + if (!topNeighbor?.nodeId) continue; + + if ( + !bestVectorMatch || + Number(topNeighbor.score || 0) > Number(bestVectorMatch.score || 0) + ) { + bestVectorMatch = { + score: Number(topNeighbor.score || 0), + nodeId: topNeighbor.nodeId, + }; + } + + if (Number(topNeighbor.score || 0) >= normalizedThreshold) { + return { + triggered: true, + reason: `本批仅新增 ${safeNewNodeIds.length} 个节点,但与旧记忆高度相似(${Number(topNeighbor.score || 0).toFixed(3)} >= ${normalizedThreshold.toFixed(2)}),已触发自动整合`, + matchedScore: Number(topNeighbor.score || 0), + matchedNodeId: topNeighbor.nodeId, + detection: "vector", + }; + } + } catch (error) { + if (isAbortError(error)) throw error; + console.warn( + `[ST-BME] 自动整合门禁近邻查询失败 (${newNodeId}):`, + error?.message || error, + ); + } + } + + if (bestVectorMatch) { + return { + triggered: false, + reason: `本批新增少且最高相似度 ${bestVectorMatch.score.toFixed(3)} 未达到阈值 ${normalizedThreshold.toFixed(2)},跳过自动整合`, + matchedScore: bestVectorMatch.score, + matchedNodeId: bestVectorMatch.nodeId, + detection: "vector", + }; + } + + if (!vectorConfigValid) { + return { + triggered: false, + reason: "本批新增少且当前向量不可用,未检测到明确重复风险,跳过自动整合", + matchedScore: null, + matchedNodeId: "", + detection: "vector-unavailable", + }; + } + + return { + triggered: false, + reason: "本批新增少且无明显重复风险,跳过自动整合", + matchedScore: null, + matchedNodeId: "", + detection: "none", + }; +} + /** * 统一记忆整合主函数(批量化版) * diff --git a/index.js b/index.js index f5b77e8..106b902 100644 --- a/index.js +++ b/index.js @@ -38,8 +38,15 @@ import { resolveDirtyFloorFromMutationMeta, rollbackAffectedJournals, } from "./chat-history.js"; -import { compressAll, sleepCycle } from "./compressor.js"; -import { consolidateMemories } from "./consolidator.js"; +import { + compressAll, + inspectAutoCompressionCandidates, + sleepCycle, +} from "./compressor.js"; +import { + analyzeAutoConsolidationGate, + consolidateMemories, +} from "./consolidator.js"; import { installSendIntentHooksController, onBeforeCombinePromptsController, @@ -465,7 +472,8 @@ const defaultSettings = { // ⑩ 反思条目(P2) enableReflection: true, // 启用反思 reflectEveryN: 10, // 每 N 次提取后反思 - maintenanceAutoMinNewNodes: 3, + consolidationAutoMinNewNodes: 2, + compressionEveryN: 10, // UI 面板 panelTheme: "crimson", // 面板主题 crimson|cyan|amber|violet @@ -2677,10 +2685,37 @@ function installSendIntentHooks() { // ==================== 设置管理 ==================== +function migrateLegacyAutoMaintenanceSettings(loaded = {}) { + if (!loaded || typeof loaded !== "object" || Array.isArray(loaded)) { + return {}; + } + + const migrated = { ...loaded }; + if ( + !Object.prototype.hasOwnProperty.call( + migrated, + "consolidationAutoMinNewNodes", + ) && + Object.prototype.hasOwnProperty.call(migrated, "maintenanceAutoMinNewNodes") + ) { + migrated.consolidationAutoMinNewNodes = clampInt( + migrated.maintenanceAutoMinNewNodes, + defaultSettings.consolidationAutoMinNewNodes, + 1, + 50, + ); + } + delete migrated.maintenanceAutoMinNewNodes; + return migrated; +} + function getSettings() { + const loadedSettings = migrateLegacyAutoMaintenanceSettings( + extension_settings[MODULE_NAME] || {}, + ); const mergedSettings = { ...defaultSettings, - ...(extension_settings[MODULE_NAME] || {}), + ...loadedSettings, }; const migrated = migrateLegacyTaskProfiles(mergedSettings); mergedSettings.taskProfilesVersion = migrated.taskProfilesVersion; @@ -4958,25 +4993,86 @@ function noteMaintenanceGate(status, action, reason) { .join(" | "); } -function evaluateAutoMaintenanceGate(action, newNodeCount, settings = {}) { - const normalizedAction = String(action || "").trim(); - if (!["consolidate", "compress"].includes(normalizedAction)) { - return { blocked: false, reason: "", minNewNodes: 0 }; - } - if (settings?.maintenanceAutoMinNewNodes == null) { - return { blocked: false, reason: "", minNewNodes: 0 }; - } - - const minNewNodes = clampInt(settings.maintenanceAutoMinNewNodes, 3, 1, 50); +function evaluateAutoConsolidationGate( + newNodeCount, + analysis = null, + settings = {}, +) { + const minNewNodes = clampInt( + settings.consolidationAutoMinNewNodes, + 2, + 1, + 50, + ); const safeNewNodeCount = Math.max(0, Number(newNodeCount) || 0); if (safeNewNodeCount >= minNewNodes) { - return { blocked: false, reason: "", minNewNodes }; + return { + shouldRun: true, + minNewNodes, + reason: `本批新增 ${safeNewNodeCount} 个节点,达到自动整合门槛 ${minNewNodes}`, + matchedScore: null, + matchedNodeId: "", + }; + } + + if (analysis?.triggered) { + return { + shouldRun: true, + minNewNodes, + reason: + String(analysis.reason || "").trim() || + "检测到高重复风险,已触发自动整合", + matchedScore: Number.isFinite(Number(analysis?.matchedScore)) + ? Number(analysis.matchedScore) + : null, + matchedNodeId: String(analysis?.matchedNodeId || ""), + }; } return { - blocked: true, + shouldRun: false, minNewNodes, - reason: `本批只新增 ${safeNewNodeCount} 个节点,低于门槛 ${minNewNodes}`, + reason: + String(analysis?.reason || "").trim() || + `本批只新增 ${safeNewNodeCount} 个节点,低于自动整合门槛 ${minNewNodes}`, + matchedScore: Number.isFinite(Number(analysis?.matchedScore)) + ? Number(analysis.matchedScore) + : null, + matchedNodeId: String(analysis?.matchedNodeId || ""), + }; +} + +function evaluateAutoCompressionSchedule( + currentExtractionCount, + settings = {}, +) { + const everyN = clampInt(settings.compressionEveryN, 10, 0, 500); + const safeExtractionCount = Math.max(0, Number(currentExtractionCount) || 0); + + if (everyN <= 0) { + return { + scheduled: false, + everyN, + nextExtractionCount: null, + reason: "自动压缩已关闭", + }; + } + + const remainder = safeExtractionCount % everyN; + if (remainder !== 0) { + return { + scheduled: false, + everyN, + nextExtractionCount: safeExtractionCount + (everyN - remainder), + reason: `当前为第 ${safeExtractionCount} 次提取,未到每 ${everyN} 次自动压缩周期`, + }; + } + + return { + scheduled: true, + everyN, + nextExtractionCount: safeExtractionCount + everyN, + reason: "", }; } @@ -5238,10 +5334,11 @@ function getPersistedSettingsSnapshot(settings = getSettings()) { } function mergePersistedSettings(loaded = {}) { + const compatibleLoaded = migrateLegacyAutoMaintenanceSettings(loaded); const merged = { ...defaultSettings }; for (const key of Object.keys(defaultSettings)) { - if (Object.prototype.hasOwnProperty.call(loaded, key)) { - merged[key] = loaded[key]; + if (Object.prototype.hasOwnProperty.call(compatibleLoaded, key)) { + merged[key] = compatibleLoaded[key]; } } return merged; @@ -7108,33 +7205,106 @@ async function handleExtractionSuccess( const newNodeCount = Array.isArray(result?.newNodeIds) ? result.newNodeIds.length : 0; - const resolveAutoMaintenanceGate = - typeof evaluateAutoMaintenanceGate === "function" - ? evaluateAutoMaintenanceGate - : (action, count, localSettings = {}) => { - const normalizedAction = String(action || "").trim(); - if (!["consolidate", "compress"].includes(normalizedAction)) { - return { blocked: false, reason: "", minNewNodes: 0 }; - } - if (localSettings?.maintenanceAutoMinNewNodes == null) { - return { blocked: false, reason: "", minNewNodes: 0 }; - } - const parsedMinNewNodes = Math.floor( - Number(localSettings.maintenanceAutoMinNewNodes), + const resolveAutoConsolidationGate = + typeof evaluateAutoConsolidationGate === "function" + ? evaluateAutoConsolidationGate + : (count, analysis = null, localSettings = {}) => { + const minNewNodes = Math.max( + 1, + Math.min( + 50, + Math.floor( + Number(localSettings?.consolidationAutoMinNewNodes ?? 2), + ) || 2, + ), ); - const minNewNodes = - Number.isFinite(parsedMinNewNodes) && parsedMinNewNodes >= 1 - ? Math.min(50, parsedMinNewNodes) - : 3; const safeCount = Math.max(0, Number(count) || 0); - return safeCount >= minNewNodes - ? { blocked: false, reason: "", minNewNodes } - : { - blocked: true, - minNewNodes, - reason: `本批只新增 ${safeCount} 个节点,低于门槛 ${minNewNodes}`, - }; + if (safeCount >= minNewNodes) { + return { + shouldRun: true, + minNewNodes, + reason: `本批新增 ${safeCount} 个节点,达到自动整合门槛 ${minNewNodes}`, + matchedScore: null, + matchedNodeId: "", + }; + } + if (analysis?.triggered) { + return { + shouldRun: true, + minNewNodes, + reason: + String(analysis.reason || "").trim() || + "检测到高重复风险,已触发自动整合", + matchedScore: Number.isFinite(Number(analysis?.matchedScore)) + ? Number(analysis.matchedScore) + : null, + matchedNodeId: String(analysis?.matchedNodeId || ""), + }; + } + return { + shouldRun: false, + minNewNodes, + reason: + String(analysis?.reason || "").trim() || + `本批新增少且无明显重复风险,跳过自动整合`, + matchedScore: Number.isFinite(Number(analysis?.matchedScore)) + ? Number(analysis.matchedScore) + : null, + matchedNodeId: String(analysis?.matchedNodeId || ""), + }; }; + const analyzeConsolidationGate = + typeof analyzeAutoConsolidationGate === "function" + ? analyzeAutoConsolidationGate + : async () => ({ + triggered: false, + reason: "本批新增少且无明显重复风险,跳过自动整合", + matchedScore: null, + matchedNodeId: "", + }); + const resolveAutoCompressionSchedule = + typeof evaluateAutoCompressionSchedule === "function" + ? evaluateAutoCompressionSchedule + : (currentCount, localSettings = {}) => { + const parsedEveryN = Math.floor( + Number(localSettings?.compressionEveryN), + ); + const everyN = + Number.isFinite(parsedEveryN) && parsedEveryN >= 0 + ? Math.min(500, parsedEveryN) + : 10; + const safeCount = Math.max(0, Number(currentCount) || 0); + if (everyN <= 0) { + return { + scheduled: false, + everyN, + nextExtractionCount: null, + reason: "自动压缩已关闭", + }; + } + const remainder = safeCount % everyN; + if (remainder !== 0) { + return { + scheduled: false, + everyN, + nextExtractionCount: safeCount + (everyN - remainder), + reason: `当前为第 ${safeCount} 次提取,未到每 ${everyN} 次自动压缩周期`, + }; + } + return { + scheduled: true, + everyN, + nextExtractionCount: safeCount + everyN, + reason: "", + }; + }; + const inspectCompressionCandidates = + typeof inspectAutoCompressionCandidates === "function" + ? inspectAutoCompressionCandidates + : () => ({ + hasCandidates: false, + reason: "已到自动压缩周期,但当前没有达到内部压缩阈值的候选组", + }); const applyMaintenanceGateNote = typeof noteMaintenanceGate === "function" ? noteMaintenanceGate @@ -7185,12 +7355,38 @@ async function handleExtractionSuccess( setBatchStageOutcome(status, "core", "success"); if (settings.enableConsolidation && result.newNodeIds?.length > 0) { - const gate = resolveAutoMaintenanceGate( - "consolidate", + let consolidationAnalysis = null; + const minNewNodes = Math.max( + 1, + Math.min( + 50, + Math.floor(Number(settings?.consolidationAutoMinNewNodes ?? 2)) || 2, + ), + ); + if (newNodeCount < minNewNodes) { + consolidationAnalysis = await analyzeConsolidationGate({ + graph: currentGraph, + newNodeIds: result.newNodeIds, + embeddingConfig: getEmbeddingConfig(), + schema: getSchema(), + conflictThreshold: settings.consolidationThreshold, + signal, + }); + } + const gate = resolveAutoConsolidationGate( newNodeCount, + consolidationAnalysis, settings, ); - if (gate.blocked) { + status.consolidationGateTriggered = Boolean(gate.shouldRun); + status.consolidationGateReason = String(gate.reason || ""); + status.consolidationGateSimilarity = Number.isFinite( + Number(gate.matchedScore), + ) + ? Number(gate.matchedScore) + : null; + status.consolidationGateMatchedNodeId = String(gate.matchedNodeId || ""); + if (!gate.shouldRun) { applyMaintenanceGateNote(status, "consolidate", gate.reason); pushBatchStageArtifact(status, "structural", "consolidation-skipped"); } else { @@ -7315,40 +7511,57 @@ async function handleExtractionSuccess( } } + const compressionSchedule = resolveAutoCompressionSchedule( + extractionCount, + settings, + ); + status.autoCompressionScheduled = Boolean(compressionSchedule.scheduled); + status.nextCompressionAtExtractionCount = + compressionSchedule.nextExtractionCount; + status.autoCompressionSkippedReason = compressionSchedule.reason || ""; + try { throwIfAborted(signal, "提取已终止"); - const gate = resolveAutoMaintenanceGate( - "compress", - newNodeCount, - settings, - ); - if (gate.blocked) { - applyMaintenanceGateNote(status, "compress", gate.reason); - pushBatchStageArtifact(status, "structural", "compression-skipped"); - } else { - const beforeSnapshot = cloneMaintenanceSnapshot(currentGraph); - const compressionResult = await compressAll( + if (compressionSchedule.scheduled) { + const compressionInspection = inspectCompressionCandidates( currentGraph, getSchema(), - getEmbeddingConfig(), false, - undefined, - signal, - settings, ); - if (compressionResult.created > 0 || compressionResult.archived > 0) { - persistMaintenanceAction({ - action: "compress", - beforeSnapshot, - mode: "auto", - summary: summarizeMaintenance( - "compress", - compressionResult, - "auto", - ), - }); - postProcessArtifacts.push("compression"); - pushBatchStageArtifact(status, "structural", "compression"); + if (!compressionInspection?.hasCandidates) { + status.autoCompressionSkippedReason = + String(compressionInspection?.reason || "").trim() || + "已到自动压缩周期,但当前没有达到内部压缩阈值的候选组"; + pushBatchStageArtifact(status, "structural", "compression-skipped"); + } else { + status.autoCompressionSkippedReason = ""; + const beforeSnapshot = cloneMaintenanceSnapshot(currentGraph); + const compressionResult = await compressAll( + currentGraph, + getSchema(), + getEmbeddingConfig(), + false, + undefined, + signal, + settings, + ); + if (compressionResult.created > 0 || compressionResult.archived > 0) { + persistMaintenanceAction({ + action: "compress", + beforeSnapshot, + mode: "auto", + summary: summarizeMaintenance( + "compress", + compressionResult, + "auto", + ), + }); + postProcessArtifacts.push("compression"); + pushBatchStageArtifact(status, "structural", "compression"); + } else { + status.autoCompressionSkippedReason = + "已尝试自动压缩,但本轮未产生可持久化变化"; + } } } } catch (error) { diff --git a/panel.html b/panel.html index 03d2990..5784997 100644 --- a/panel.html +++ b/panel.html @@ -1916,18 +1916,18 @@