Refine automatic consolidation and compression triggers

This commit is contained in:
Youzini-afk
2026-04-04 15:54:39 +08:00
parent 4bbbd4d09d
commit f367b8989c
7 changed files with 977 additions and 98 deletions

View File

@@ -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 = {}, settings = {},
}) { }) {
const compression = typeDef.compression; const compression = typeDef.compression;
const fanIn = Number.isFinite(Number(compression.fanIn)) const { fanIn, threshold, keepRecent } = resolveCompressionWindow(
? Math.max(2, Number(compression.fanIn)) compression,
: 2; force,
);
throwIfAborted(signal); throwIfAborted(signal);
// 获取该层级的活跃叶子节点 // 获取该层级的活跃叶子节点
@@ -138,17 +161,6 @@ async function compressLevel({
let created = 0; let created = 0;
let archived = 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)) { for (const group of groupCompressionCandidates(levelNodes)) {
if (force ? group.length < fanIn : group.length <= threshold) { if (force ? group.length < fanIn : group.length <= threshold) {
continue; 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) { function migrateBatchEdges(graph, batch, compressedNode) {
const batchIds = new Set(batch.map((node) => node.id)); const batchIds = new Set(batch.map((node) => node.id));

View File

@@ -111,6 +111,153 @@ const CONSOLIDATION_SYSTEM_PROMPT = `你是一个记忆整合分析器。当新
- 不要对无关记忆强行建立联系 - 不要对无关记忆强行建立联系
- neighbor_updates 中每条必须有实际意义的修改`; - 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",
};
}
/** /**
* 统一记忆整合主函数(批量化版) * 统一记忆整合主函数(批量化版)
* *

361
index.js
View File

@@ -38,8 +38,15 @@ import {
resolveDirtyFloorFromMutationMeta, resolveDirtyFloorFromMutationMeta,
rollbackAffectedJournals, rollbackAffectedJournals,
} from "./chat-history.js"; } from "./chat-history.js";
import { compressAll, sleepCycle } from "./compressor.js"; import {
import { consolidateMemories } from "./consolidator.js"; compressAll,
inspectAutoCompressionCandidates,
sleepCycle,
} from "./compressor.js";
import {
analyzeAutoConsolidationGate,
consolidateMemories,
} from "./consolidator.js";
import { import {
installSendIntentHooksController, installSendIntentHooksController,
onBeforeCombinePromptsController, onBeforeCombinePromptsController,
@@ -465,7 +472,8 @@ const defaultSettings = {
// ⑩ 反思条目P2 // ⑩ 反思条目P2
enableReflection: true, // 启用反思 enableReflection: true, // 启用反思
reflectEveryN: 10, // 每 N 次提取后反思 reflectEveryN: 10, // 每 N 次提取后反思
maintenanceAutoMinNewNodes: 3, consolidationAutoMinNewNodes: 2,
compressionEveryN: 10,
// UI 面板 // UI 面板
panelTheme: "crimson", // 面板主题 crimson|cyan|amber|violet 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() { function getSettings() {
const loadedSettings = migrateLegacyAutoMaintenanceSettings(
extension_settings[MODULE_NAME] || {},
);
const mergedSettings = { const mergedSettings = {
...defaultSettings, ...defaultSettings,
...(extension_settings[MODULE_NAME] || {}), ...loadedSettings,
}; };
const migrated = migrateLegacyTaskProfiles(mergedSettings); const migrated = migrateLegacyTaskProfiles(mergedSettings);
mergedSettings.taskProfilesVersion = migrated.taskProfilesVersion; mergedSettings.taskProfilesVersion = migrated.taskProfilesVersion;
@@ -4958,25 +4993,86 @@ function noteMaintenanceGate(status, action, reason) {
.join(" | "); .join(" | ");
} }
function evaluateAutoMaintenanceGate(action, newNodeCount, settings = {}) { function evaluateAutoConsolidationGate(
const normalizedAction = String(action || "").trim(); newNodeCount,
if (!["consolidate", "compress"].includes(normalizedAction)) { analysis = null,
return { blocked: false, reason: "", minNewNodes: 0 }; settings = {},
} ) {
if (settings?.maintenanceAutoMinNewNodes == null) { const minNewNodes = clampInt(
return { blocked: false, reason: "", minNewNodes: 0 }; settings.consolidationAutoMinNewNodes,
} 2,
1,
const minNewNodes = clampInt(settings.maintenanceAutoMinNewNodes, 3, 1, 50); 50,
);
const safeNewNodeCount = Math.max(0, Number(newNodeCount) || 0); const safeNewNodeCount = Math.max(0, Number(newNodeCount) || 0);
if (safeNewNodeCount >= minNewNodes) { 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 { return {
blocked: true, shouldRun: false,
minNewNodes, 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 = {}) { function mergePersistedSettings(loaded = {}) {
const compatibleLoaded = migrateLegacyAutoMaintenanceSettings(loaded);
const merged = { ...defaultSettings }; const merged = { ...defaultSettings };
for (const key of Object.keys(defaultSettings)) { for (const key of Object.keys(defaultSettings)) {
if (Object.prototype.hasOwnProperty.call(loaded, key)) { if (Object.prototype.hasOwnProperty.call(compatibleLoaded, key)) {
merged[key] = loaded[key]; merged[key] = compatibleLoaded[key];
} }
} }
return merged; return merged;
@@ -7108,33 +7205,106 @@ async function handleExtractionSuccess(
const newNodeCount = Array.isArray(result?.newNodeIds) const newNodeCount = Array.isArray(result?.newNodeIds)
? result.newNodeIds.length ? result.newNodeIds.length
: 0; : 0;
const resolveAutoMaintenanceGate = const resolveAutoConsolidationGate =
typeof evaluateAutoMaintenanceGate === "function" typeof evaluateAutoConsolidationGate === "function"
? evaluateAutoMaintenanceGate ? evaluateAutoConsolidationGate
: (action, count, localSettings = {}) => { : (count, analysis = null, localSettings = {}) => {
const normalizedAction = String(action || "").trim(); const minNewNodes = Math.max(
if (!["consolidate", "compress"].includes(normalizedAction)) { 1,
return { blocked: false, reason: "", minNewNodes: 0 }; Math.min(
} 50,
if (localSettings?.maintenanceAutoMinNewNodes == null) { Math.floor(
return { blocked: false, reason: "", minNewNodes: 0 }; Number(localSettings?.consolidationAutoMinNewNodes ?? 2),
} ) || 2,
const parsedMinNewNodes = Math.floor( ),
Number(localSettings.maintenanceAutoMinNewNodes),
); );
const minNewNodes =
Number.isFinite(parsedMinNewNodes) && parsedMinNewNodes >= 1
? Math.min(50, parsedMinNewNodes)
: 3;
const safeCount = Math.max(0, Number(count) || 0); const safeCount = Math.max(0, Number(count) || 0);
return safeCount >= minNewNodes if (safeCount >= minNewNodes) {
? { blocked: false, reason: "", minNewNodes } return {
: { shouldRun: true,
blocked: true, minNewNodes,
minNewNodes, reason: `本批新增 ${safeCount} 个节点,达到自动整合门槛 ${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 = const applyMaintenanceGateNote =
typeof noteMaintenanceGate === "function" typeof noteMaintenanceGate === "function"
? noteMaintenanceGate ? noteMaintenanceGate
@@ -7185,12 +7355,38 @@ async function handleExtractionSuccess(
setBatchStageOutcome(status, "core", "success"); setBatchStageOutcome(status, "core", "success");
if (settings.enableConsolidation && result.newNodeIds?.length > 0) { if (settings.enableConsolidation && result.newNodeIds?.length > 0) {
const gate = resolveAutoMaintenanceGate( let consolidationAnalysis = null;
"consolidate", 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, newNodeCount,
consolidationAnalysis,
settings, 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); applyMaintenanceGateNote(status, "consolidate", gate.reason);
pushBatchStageArtifact(status, "structural", "consolidation-skipped"); pushBatchStageArtifact(status, "structural", "consolidation-skipped");
} else { } 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 { try {
throwIfAborted(signal, "提取已终止"); throwIfAborted(signal, "提取已终止");
const gate = resolveAutoMaintenanceGate( if (compressionSchedule.scheduled) {
"compress", const compressionInspection = inspectCompressionCandidates(
newNodeCount,
settings,
);
if (gate.blocked) {
applyMaintenanceGateNote(status, "compress", gate.reason);
pushBatchStageArtifact(status, "structural", "compression-skipped");
} else {
const beforeSnapshot = cloneMaintenanceSnapshot(currentGraph);
const compressionResult = await compressAll(
currentGraph, currentGraph,
getSchema(), getSchema(),
getEmbeddingConfig(),
false, false,
undefined,
signal,
settings,
); );
if (compressionResult.created > 0 || compressionResult.archived > 0) { if (!compressionInspection?.hasCandidates) {
persistMaintenanceAction({ status.autoCompressionSkippedReason =
action: "compress", String(compressionInspection?.reason || "").trim() ||
beforeSnapshot, "已到自动压缩周期,但当前没有达到内部压缩阈值的候选组";
mode: "auto", pushBatchStageArtifact(status, "structural", "compression-skipped");
summary: summarizeMaintenance( } else {
"compress", status.autoCompressionSkippedReason = "";
compressionResult, const beforeSnapshot = cloneMaintenanceSnapshot(currentGraph);
"auto", const compressionResult = await compressAll(
), currentGraph,
}); getSchema(),
postProcessArtifacts.push("compression"); getEmbeddingConfig(),
pushBatchStageArtifact(status, "structural", "compression"); 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) { } catch (error) {

View File

@@ -1916,18 +1916,18 @@
<div class="bme-config-card"> <div class="bme-config-card">
<div class="bme-config-card-head"> <div class="bme-config-card-head">
<div> <div>
<div class="bme-config-card-title">自动维护门槛</div> <div class="bme-config-card-title">自动整合触发</div>
<div class="bme-config-card-subtitle"> <div class="bme-config-card-subtitle">
新增节点太少时,自动整合和自动压缩直接跳过,避免小批次也跑重维护 本批新增节点不足时,仍会检查是否与旧记忆高度重复;命中后照样自动整合
</div> </div>
</div> </div>
</div> </div>
<div class="bme-config-row"> <div class="bme-config-row">
<label for="bme-setting-maintenance-auto-min-new-nodes" <label for="bme-setting-consolidation-auto-min-new-nodes"
>最少新增节点数</label >最少新增节点数</label
> >
<input <input
id="bme-setting-maintenance-auto-min-new-nodes" id="bme-setting-consolidation-auto-min-new-nodes"
class="bme-config-input" class="bme-config-input"
type="number" type="number"
min="1" min="1"
@@ -1936,6 +1936,29 @@
</div> </div>
</div> </div>
<div class="bme-config-card">
<div class="bme-config-card-head">
<div>
<div class="bme-config-card-title">自动压缩周期</div>
<div class="bme-config-card-subtitle">
按提取次数计数;到周期点时才尝试自动压缩。填 0 表示关闭。
</div>
</div>
</div>
<div class="bme-config-row">
<label for="bme-setting-compression-every"
>每 N 次提取尝试一次</label
>
<input
id="bme-setting-compression-every"
class="bme-config-input"
type="number"
min="0"
max="500"
/>
</div>
</div>
<div <div
class="bme-config-card bme-guarded-card" class="bme-config-card bme-guarded-card"
data-guard-settings="enableProbRecall" data-guard-settings="enableProbRecall"

View File

@@ -1765,8 +1765,12 @@ function _refreshConfigTab() {
settings.forgetThreshold ?? 0.5, settings.forgetThreshold ?? 0.5,
); );
_setInputValue( _setInputValue(
"bme-setting-maintenance-auto-min-new-nodes", "bme-setting-consolidation-auto-min-new-nodes",
settings.maintenanceAutoMinNewNodes ?? 3, settings.consolidationAutoMinNewNodes ?? 2,
);
_setInputValue(
"bme-setting-compression-every",
settings.compressionEveryN ?? 10,
); );
_setInputValue("bme-setting-sleep-every", settings.sleepEveryN ?? 10); _setInputValue("bme-setting-sleep-every", settings.sleepEveryN ?? 10);
_setInputValue( _setInputValue(
@@ -2127,11 +2131,18 @@ function _bindConfigControls() {
_patchSettings({ forgetThreshold: value }), _patchSettings({ forgetThreshold: value }),
); );
bindNumber( bindNumber(
"bme-setting-maintenance-auto-min-new-nodes", "bme-setting-consolidation-auto-min-new-nodes",
3, 2,
1, 1,
50, 50,
(value) => _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) => bindNumber("bme-setting-sleep-every", 10, 1, 200, (value) =>
_patchSettings({ sleepEveryN: value }), _patchSettings({ sleepEveryN: value }),

View File

@@ -34,7 +34,53 @@ this.defaultSettings = defaultSettings;
return context.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 defaultSettings = await loadDefaultSettings();
const { mergePersistedSettings } = await loadSettingsCompatHelpers();
assert.equal(defaultSettings.extractContextTurns, 2); assert.equal(defaultSettings.extractContextTurns, 2);
assert.equal(defaultSettings.recallTopK, 20); assert.equal(defaultSettings.recallTopK, 20);
@@ -79,11 +125,20 @@ assert.equal(defaultSettings.injectObjectiveGlobalMemory, true);
assert.equal(defaultSettings.injectDepth, 9999); assert.equal(defaultSettings.injectDepth, 9999);
assert.equal(defaultSettings.enabled, true); assert.equal(defaultSettings.enabled, true);
assert.equal(defaultSettings.enableReflection, 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.embeddingTransportMode, "direct");
assert.equal(defaultSettings.taskProfilesVersion, 3); assert.equal(defaultSettings.taskProfilesVersion, 3);
assert.ok(defaultSettings.taskProfiles); assert.ok(defaultSettings.taskProfiles);
assert.ok(defaultSettings.taskProfiles.extract); assert.ok(defaultSettings.taskProfiles.extract);
assert.ok(defaultSettings.taskProfiles.recall); 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"); console.log("default-settings tests passed");

View File

@@ -231,6 +231,16 @@ function createBatchStageHarness() {
getSchema: () => schema, getSchema: () => schema,
getEmbeddingConfig: () => null, getEmbeddingConfig: () => null,
getVectorIndexStats: () => ({ pending: 0 }), getVectorIndexStats: () => ({ pending: 0 }),
analyzeAutoConsolidationGate: async () => ({
triggered: false,
reason: "本批新增少且无明显重复风险,跳过自动整合",
matchedScore: null,
matchedNodeId: "",
}),
inspectAutoCompressionCandidates: () => ({
hasCandidates: false,
reason: "已到自动压缩周期,但当前没有达到内部压缩阈值的候选组",
}),
updateLastExtractedItems: () => {}, updateLastExtractedItems: () => {},
ensureCurrentGraphRuntimeState: () => {}, ensureCurrentGraphRuntimeState: () => {},
throwIfAborted: () => {}, throwIfAborted: () => {},
@@ -2530,6 +2540,10 @@ async function testBatchStatusStructuralPartialRemainsRecoverable() {
harness.currentGraph.historyState ||= {}; harness.currentGraph.historyState ||= {};
harness.currentGraph.vectorIndexState ||= {}; harness.currentGraph.vectorIndexState ||= {};
}; };
harness.inspectAutoCompressionCandidates = () => ({
hasCandidates: true,
reason: "",
});
harness.compressAll = async () => { harness.compressAll = async () => {
throw new Error("compression down"); throw new Error("compression down");
}; };
@@ -2550,6 +2564,7 @@ async function testBatchStatusStructuralPartialRemainsRecoverable() {
enableSynopsis: false, enableSynopsis: false,
enableReflection: false, enableReflection: false,
enableSleepCycle: false, enableSleepCycle: false,
compressionEveryN: 1,
synopsisEveryN: 1, synopsisEveryN: 1,
reflectEveryN: 1, reflectEveryN: 1,
sleepEveryN: 1, sleepEveryN: 1,
@@ -2614,6 +2629,333 @@ async function testBatchStatusSemanticFailureDoesNotHideCoreSuccess() {
assert.match(effects.batchStatus.errors[0], /概要生成失败/); 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() { async function testBatchStatusFinalizeFailureIsNotCompleteSuccess() {
const harness = await createBatchStageHarness(); const harness = await createBatchStageHarness();
const { createBatchStatusSkeleton, handleExtractionSuccess } = harness.result; const { createBatchStatusSkeleton, handleExtractionSuccess } = harness.result;
@@ -4685,6 +5027,10 @@ await testReverseJournalRollbackStateFormsReplayClosure();
await testReverseJournalRecoveryPlanMixedLegacyAndCurrentRetainsRepairSet(); await testReverseJournalRecoveryPlanMixedLegacyAndCurrentRetainsRepairSet();
await testBatchStatusStructuralPartialRemainsRecoverable(); await testBatchStatusStructuralPartialRemainsRecoverable();
await testBatchStatusSemanticFailureDoesNotHideCoreSuccess(); await testBatchStatusSemanticFailureDoesNotHideCoreSuccess();
await testAutoConsolidationRunsOnHighDuplicateRiskSingleNode();
await testAutoConsolidationSkipsLowRiskSingleNode();
await testAutoCompressionRunsOnlyOnConfiguredInterval();
await testAutoCompressionSkipsWhenNotScheduledOrNoCandidates();
await testBatchStatusFinalizeFailureIsNotCompleteSuccess(); await testBatchStatusFinalizeFailureIsNotCompleteSuccess();
await testProcessedHistoryAdvanceTracksCoreExtractionSuccess(); await testProcessedHistoryAdvanceTracksCoreExtractionSuccess();
await testGenerationRecallTransactionDedupesDoubleHookBySameKey(); await testGenerationRecallTransactionDedupesDoubleHookBySameKey();