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 @@
-
自动维护门槛
+
自动整合触发
- 新增节点太少时,自动整合和自动压缩直接跳过,避免小批次也跑重维护。 + 本批新增节点不足时,仍会检查是否与旧记忆高度重复;命中后照样自动整合。
-
+
+
+
+
自动压缩周期
+
+ 按提取次数计数;到周期点时才尝试自动压缩。填 0 表示关闭。 +
+
+
+
+ + +
+
+
_patchSettings({ maintenanceAutoMinNewNodes: value }), + (value) => _patchSettings({ consolidationAutoMinNewNodes: value }), + ); + bindNumber( + "bme-setting-compression-every", + 10, + 0, + 500, + (value) => _patchSettings({ compressionEveryN: value }), ); bindNumber("bme-setting-sleep-every", 10, 1, 200, (value) => _patchSettings({ sleepEveryN: value }), diff --git a/tests/default-settings.mjs b/tests/default-settings.mjs index 1bdf48b..f02f371 100644 --- a/tests/default-settings.mjs +++ b/tests/default-settings.mjs @@ -34,7 +34,53 @@ this.defaultSettings = defaultSettings; return context.defaultSettings; } +async function loadSettingsCompatHelpers() { + const __dirname = path.dirname(fileURLToPath(import.meta.url)); + const indexPath = path.resolve(__dirname, "../index.js"); + const source = await fs.readFile(indexPath, "utf8"); + const settingsMatch = source.match(/const defaultSettings = \{[\s\S]*?^\};/m); + const compatMatch = source.match( + /function migrateLegacyAutoMaintenanceSettings\(loaded = \{\}\) \{[\s\S]*?^}\n/m, + ); + const mergeMatch = source.match( + /function mergePersistedSettings\(loaded = \{\}\) \{[\s\S]*?^}\n/m, + ); + + if (!settingsMatch || !compatMatch || !mergeMatch) { + throw new Error("无法从 index.js 提取设置兼容辅助函数"); + } + + const context = vm.createContext({ + clampInt: (value, fallback = 0, min = 0, max = 9999) => { + const numeric = Number(value); + if (!Number.isFinite(numeric)) return fallback; + return Math.min(max, Math.max(min, Math.trunc(numeric))); + }, + createDefaultTaskProfiles() { + return { + extract: { activeProfileId: "default", profiles: [] }, + recall: { activeProfileId: "default", profiles: [] }, + compress: { activeProfileId: "default", profiles: [] }, + synopsis: { activeProfileId: "default", profiles: [] }, + reflection: { activeProfileId: "default", profiles: [] }, + consolidation: { activeProfileId: "default", profiles: [] }, + }; + }, + }); + const script = new vm.Script(` +${settingsMatch[0]} +${compatMatch[0]} +${mergeMatch[0]} +this.mergePersistedSettings = mergePersistedSettings; +`); + script.runInContext(context); + return { + mergePersistedSettings: context.mergePersistedSettings, + }; +} + const defaultSettings = await loadDefaultSettings(); +const { mergePersistedSettings } = await loadSettingsCompatHelpers(); assert.equal(defaultSettings.extractContextTurns, 2); assert.equal(defaultSettings.recallTopK, 20); @@ -79,11 +125,20 @@ assert.equal(defaultSettings.injectObjectiveGlobalMemory, true); assert.equal(defaultSettings.injectDepth, 9999); assert.equal(defaultSettings.enabled, true); assert.equal(defaultSettings.enableReflection, true); -assert.equal(defaultSettings.maintenanceAutoMinNewNodes, 3); +assert.equal(defaultSettings.consolidationAutoMinNewNodes, 2); +assert.equal(defaultSettings.compressionEveryN, 10); +assert.equal("maintenanceAutoMinNewNodes" in defaultSettings, false); assert.equal(defaultSettings.embeddingTransportMode, "direct"); assert.equal(defaultSettings.taskProfilesVersion, 3); assert.ok(defaultSettings.taskProfiles); assert.ok(defaultSettings.taskProfiles.extract); assert.ok(defaultSettings.taskProfiles.recall); +const migratedSettings = mergePersistedSettings({ + maintenanceAutoMinNewNodes: 7, +}); +assert.equal(migratedSettings.consolidationAutoMinNewNodes, 7); +assert.equal(migratedSettings.compressionEveryN, 10); +assert.equal("maintenanceAutoMinNewNodes" in migratedSettings, false); + console.log("default-settings tests passed"); diff --git a/tests/p0-regressions.mjs b/tests/p0-regressions.mjs index 750607d..6fec55d 100644 --- a/tests/p0-regressions.mjs +++ b/tests/p0-regressions.mjs @@ -231,6 +231,16 @@ function createBatchStageHarness() { getSchema: () => schema, getEmbeddingConfig: () => null, getVectorIndexStats: () => ({ pending: 0 }), + analyzeAutoConsolidationGate: async () => ({ + triggered: false, + reason: "本批新增少且无明显重复风险,跳过自动整合", + matchedScore: null, + matchedNodeId: "", + }), + inspectAutoCompressionCandidates: () => ({ + hasCandidates: false, + reason: "已到自动压缩周期,但当前没有达到内部压缩阈值的候选组", + }), updateLastExtractedItems: () => {}, ensureCurrentGraphRuntimeState: () => {}, throwIfAborted: () => {}, @@ -2530,6 +2540,10 @@ async function testBatchStatusStructuralPartialRemainsRecoverable() { harness.currentGraph.historyState ||= {}; harness.currentGraph.vectorIndexState ||= {}; }; + harness.inspectAutoCompressionCandidates = () => ({ + hasCandidates: true, + reason: "", + }); harness.compressAll = async () => { throw new Error("compression down"); }; @@ -2550,6 +2564,7 @@ async function testBatchStatusStructuralPartialRemainsRecoverable() { enableSynopsis: false, enableReflection: false, enableSleepCycle: false, + compressionEveryN: 1, synopsisEveryN: 1, reflectEveryN: 1, sleepEveryN: 1, @@ -2614,6 +2629,333 @@ async function testBatchStatusSemanticFailureDoesNotHideCoreSuccess() { assert.match(effects.batchStatus.errors[0], /概要生成失败/); } +async function testAutoConsolidationRunsOnHighDuplicateRiskSingleNode() { + const harness = await createBatchStageHarness(); + const { createBatchStatusSkeleton, handleExtractionSuccess } = harness.result; + harness.currentGraph = { + historyState: { extractionCount: 0 }, + vectorIndexState: {}, + }; + harness.ensureCurrentGraphRuntimeState = () => { + harness.currentGraph.historyState ||= {}; + harness.currentGraph.vectorIndexState ||= {}; + }; + let gateCalls = 0; + let consolidateCalls = 0; + harness.analyzeAutoConsolidationGate = async () => { + gateCalls += 1; + return { + triggered: true, + reason: + "本批仅新增 1 个节点,但与旧记忆高度相似(0.930 >= 0.85),已触发自动整合", + matchedScore: 0.93, + matchedNodeId: "old-1", + }; + }; + harness.consolidateMemories = async () => { + consolidateCalls += 1; + return { + merged: 1, + skipped: 0, + kept: 0, + evolved: 0, + connections: 0, + updates: 0, + }; + }; + harness.syncVectorState = async () => ({ + insertedHashes: [], + stats: { pending: 0 }, + }); + + const batchStatus = createBatchStatusSkeleton({ + processedRange: [6, 6], + extractionCountBefore: 0, + }); + const effects = await handleExtractionSuccess( + { newNodeIds: ["node-dup"] }, + 6, + { + enableConsolidation: true, + consolidationAutoMinNewNodes: 2, + consolidationThreshold: 0.85, + compressionEveryN: 0, + enableSynopsis: false, + enableReflection: false, + enableSleepCycle: false, + synopsisEveryN: 1, + reflectEveryN: 1, + sleepEveryN: 1, + }, + undefined, + batchStatus, + ); + + assert.equal(gateCalls, 1); + assert.equal(consolidateCalls, 1); + assert.equal(effects.batchStatus.consolidationGateTriggered, true); + assert.equal( + effects.batchStatus.consolidationGateMatchedNodeId, + "old-1", + ); + assert.equal(effects.batchStatus.consolidationGateSimilarity, 0.93); + assert.match( + effects.batchStatus.consolidationGateReason, + /高度相似/, + ); + assert.equal(effects.batchStatus.autoCompressionScheduled, false); + assert.match( + effects.batchStatus.autoCompressionSkippedReason, + /自动压缩已关闭/, + ); +} + +async function testAutoConsolidationSkipsLowRiskSingleNode() { + const harness = await createBatchStageHarness(); + const { createBatchStatusSkeleton, handleExtractionSuccess } = harness.result; + harness.currentGraph = { + historyState: { extractionCount: 0 }, + vectorIndexState: {}, + }; + harness.ensureCurrentGraphRuntimeState = () => { + harness.currentGraph.historyState ||= {}; + harness.currentGraph.vectorIndexState ||= {}; + }; + let consolidateCalls = 0; + harness.analyzeAutoConsolidationGate = async () => ({ + triggered: false, + reason: + "本批新增少且最高相似度 0.420 未达到阈值 0.85,跳过自动整合", + matchedScore: 0.42, + matchedNodeId: "old-2", + }); + harness.consolidateMemories = async () => { + consolidateCalls += 1; + return { + merged: 0, + skipped: 0, + kept: 1, + evolved: 0, + connections: 0, + updates: 0, + }; + }; + harness.syncVectorState = async () => ({ + insertedHashes: [], + stats: { pending: 0 }, + }); + + const batchStatus = createBatchStatusSkeleton({ + processedRange: [7, 7], + extractionCountBefore: 0, + }); + const effects = await handleExtractionSuccess( + { newNodeIds: ["node-low-risk"] }, + 7, + { + enableConsolidation: true, + consolidationAutoMinNewNodes: 2, + consolidationThreshold: 0.85, + compressionEveryN: 0, + enableSynopsis: false, + enableReflection: false, + enableSleepCycle: false, + synopsisEveryN: 1, + reflectEveryN: 1, + sleepEveryN: 1, + }, + undefined, + batchStatus, + ); + + assert.equal(consolidateCalls, 0); + assert.equal(effects.batchStatus.consolidationGateTriggered, false); + assert.equal( + effects.batchStatus.consolidationGateMatchedNodeId, + "old-2", + ); + assert.equal(effects.batchStatus.consolidationGateSimilarity, 0.42); + assert.match( + effects.batchStatus.consolidationGateReason, + /跳过自动整合/, + ); + assert.equal( + effects.batchStatus.stages.structural.artifacts.includes( + "consolidation-skipped", + ), + true, + ); +} + +async function testAutoCompressionRunsOnlyOnConfiguredInterval() { + const harness = await createBatchStageHarness(); + const { createBatchStatusSkeleton, handleExtractionSuccess } = harness.result; + harness.currentGraph = { + historyState: { extractionCount: 9 }, + vectorIndexState: {}, + }; + harness.ensureCurrentGraphRuntimeState = () => { + harness.currentGraph.historyState ||= {}; + harness.currentGraph.vectorIndexState ||= {}; + }; + harness.extractionCount = 9; + let compressionCalls = 0; + harness.inspectAutoCompressionCandidates = () => ({ + hasCandidates: true, + reason: "", + }); + harness.compressAll = async () => { + compressionCalls += 1; + return { created: 1, archived: 2 }; + }; + harness.syncVectorState = async () => ({ + insertedHashes: [], + stats: { pending: 0 }, + }); + + const batchStatus = createBatchStatusSkeleton({ + processedRange: [8, 8], + extractionCountBefore: 9, + }); + const effects = await handleExtractionSuccess( + { newNodeIds: ["node-for-compress"] }, + 8, + { + enableConsolidation: false, + compressionEveryN: 10, + enableSynopsis: false, + enableReflection: false, + enableSleepCycle: false, + synopsisEveryN: 1, + reflectEveryN: 1, + sleepEveryN: 1, + }, + undefined, + batchStatus, + ); + + assert.equal(compressionCalls, 1); + assert.equal(effects.batchStatus.autoCompressionScheduled, true); + assert.equal(effects.batchStatus.nextCompressionAtExtractionCount, 20); + assert.equal(effects.batchStatus.autoCompressionSkippedReason, ""); +} + +async function testAutoCompressionSkipsWhenNotScheduledOrNoCandidates() { + const offCycleHarness = await createBatchStageHarness(); + const { + createBatchStatusSkeleton: createOffCycleBatchStatus, + handleExtractionSuccess: handleOffCycleExtractionSuccess, + } = offCycleHarness.result; + offCycleHarness.currentGraph = { + historyState: { extractionCount: 0 }, + vectorIndexState: {}, + }; + offCycleHarness.ensureCurrentGraphRuntimeState = () => { + offCycleHarness.currentGraph.historyState ||= {}; + offCycleHarness.currentGraph.vectorIndexState ||= {}; + }; + let offCycleCompressionCalls = 0; + offCycleHarness.compressAll = async () => { + offCycleCompressionCalls += 1; + return { created: 1, archived: 1 }; + }; + offCycleHarness.syncVectorState = async () => ({ + insertedHashes: [], + stats: { pending: 0 }, + }); + + const offCycleStatus = createOffCycleBatchStatus({ + processedRange: [9, 9], + extractionCountBefore: 0, + }); + const offCycleEffects = await handleOffCycleExtractionSuccess( + { newNodeIds: ["node-off-cycle"] }, + 9, + { + enableConsolidation: false, + compressionEveryN: 10, + enableSynopsis: false, + enableReflection: false, + enableSleepCycle: false, + synopsisEveryN: 1, + reflectEveryN: 1, + sleepEveryN: 1, + }, + undefined, + offCycleStatus, + ); + + assert.equal(offCycleCompressionCalls, 0); + assert.equal(offCycleEffects.batchStatus.autoCompressionScheduled, false); + assert.match( + offCycleEffects.batchStatus.autoCompressionSkippedReason, + /未到每 10 次自动压缩周期/, + ); + assert.equal(offCycleEffects.batchStatus.nextCompressionAtExtractionCount, 10); + + const scheduledHarness = await createBatchStageHarness(); + const { + createBatchStatusSkeleton: createScheduledBatchStatus, + handleExtractionSuccess: handleScheduledExtractionSuccess, + } = scheduledHarness.result; + scheduledHarness.currentGraph = { + historyState: { extractionCount: 9 }, + vectorIndexState: {}, + }; + scheduledHarness.ensureCurrentGraphRuntimeState = () => { + scheduledHarness.currentGraph.historyState ||= {}; + scheduledHarness.currentGraph.vectorIndexState ||= {}; + }; + scheduledHarness.extractionCount = 9; + let scheduledCompressionCalls = 0; + scheduledHarness.inspectAutoCompressionCandidates = () => ({ + hasCandidates: false, + reason: "已到自动压缩周期,但当前没有达到内部压缩阈值的候选组", + }); + scheduledHarness.compressAll = async () => { + scheduledCompressionCalls += 1; + return { created: 1, archived: 1 }; + }; + scheduledHarness.syncVectorState = async () => ({ + insertedHashes: [], + stats: { pending: 0 }, + }); + + const scheduledStatus = createScheduledBatchStatus({ + processedRange: [10, 10], + extractionCountBefore: 9, + }); + const scheduledEffects = await handleScheduledExtractionSuccess( + { newNodeIds: ["node-scheduled"] }, + 10, + { + enableConsolidation: false, + compressionEveryN: 10, + enableSynopsis: false, + enableReflection: false, + enableSleepCycle: false, + synopsisEveryN: 1, + reflectEveryN: 1, + sleepEveryN: 1, + }, + undefined, + scheduledStatus, + ); + + assert.equal(scheduledCompressionCalls, 0); + assert.equal(scheduledEffects.batchStatus.autoCompressionScheduled, true); + assert.match( + scheduledEffects.batchStatus.autoCompressionSkippedReason, + /没有达到内部压缩阈值的候选组/, + ); + assert.equal( + scheduledEffects.batchStatus.stages.structural.artifacts.includes( + "compression-skipped", + ), + true, + ); +} + async function testBatchStatusFinalizeFailureIsNotCompleteSuccess() { const harness = await createBatchStageHarness(); const { createBatchStatusSkeleton, handleExtractionSuccess } = harness.result; @@ -4685,6 +5027,10 @@ await testReverseJournalRollbackStateFormsReplayClosure(); await testReverseJournalRecoveryPlanMixedLegacyAndCurrentRetainsRepairSet(); await testBatchStatusStructuralPartialRemainsRecoverable(); await testBatchStatusSemanticFailureDoesNotHideCoreSuccess(); +await testAutoConsolidationRunsOnHighDuplicateRiskSingleNode(); +await testAutoConsolidationSkipsLowRiskSingleNode(); +await testAutoCompressionRunsOnlyOnConfiguredInterval(); +await testAutoCompressionSkipsWhenNotScheduledOrNoCandidates(); await testBatchStatusFinalizeFailureIsNotCompleteSuccess(); await testProcessedHistoryAdvanceTracksCoreExtractionSuccess(); await testGenerationRecallTransactionDedupesDoubleHookBySameKey();