diff --git a/index.js b/index.js index c63156c..10019a6 100644 --- a/index.js +++ b/index.js @@ -19486,6 +19486,7 @@ async function handleExtractionSuccess( processedRange: [endIdx, endIdx], extractionCountBefore: extractionCount, }), + postProcessContext = null, ) { const postProcessArtifacts = []; const newNodeCount = Array.isArray(result?.newNodeIds) @@ -19686,85 +19687,113 @@ async function handleExtractionSuccess( `已抽取 ${newNodeCount} 个新节点,正在处理后续阶段`, ); - if (settings.enableConsolidation && result.newNodeIds?.length > 0) { - let consolidationAnalysis = null; - const minNewNodes = Math.max( - 1, - Math.min( - 50, - Math.floor(Number(settings?.consolidationAutoMinNewNodes ?? 2)) || 2, - ), - ); - if (newNodeCount < minNewNodes) { - updateExtractionPostProcessStatus( - "整合判定中", - `本批新增 ${newNodeCount} 个节点,正在检查是否需要自动整合/进化`, - ); - consolidationAnalysis = await analyzeConsolidationGate({ - graph: currentGraph, - newNodeIds: result.newNodeIds, - embeddingConfig: getEmbeddingConfig(), - schema: getSchema(), - conflictThreshold: settings.consolidationThreshold, - signal, - }); - } - const gate = resolveAutoConsolidationGate( - newNodeCount, - consolidationAnalysis, - settings, - ); - 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); + const consolidationCandidateNodeIds = Array.from( + new Set( + [ + ...(Array.isArray(postProcessContext?.pendingAutoConsolidationNodeIds) + ? postProcessContext.pendingAutoConsolidationNodeIds + : []), + ...(Array.isArray(result?.newNodeIds) ? result.newNodeIds : []), + ] + .map((id) => String(id || "").trim()) + .filter(Boolean), + ), + ); + const consolidationCandidateCount = consolidationCandidateNodeIds.length; + + if (settings.enableConsolidation && consolidationCandidateCount > 0) { + const suppressAutoConsolidation = + postProcessContext?.suppressAutoConsolidation === true; + if (suppressAutoConsolidation) { + const reason = + String(postProcessContext?.autoConsolidationSuppressReason || "").trim() || + "批量提取进行中,已跳过本批自动整合"; + status.consolidationGateTriggered = false; + status.consolidationGateReason = reason; + status.consolidationGateSimilarity = null; + status.consolidationGateMatchedNodeId = ""; + applyMaintenanceGateNote(status, "consolidate", reason); pushBatchStageArtifact(status, "structural", "consolidation-skipped"); } else { - try { + let consolidationAnalysis = null; + const minNewNodes = Math.max( + 1, + Math.min( + 50, + Math.floor(Number(settings?.consolidationAutoMinNewNodes ?? 2)) || 2, + ), + ); + if (consolidationCandidateCount < minNewNodes) { updateExtractionPostProcessStatus( - "整合/进化中", - String(gate.reason || "").trim() || "正在自动整合新旧记忆", + "整合判定中", + `本窗口候选 ${consolidationCandidateCount} 个节点,正在检查是否需要自动整合/进化`, ); - const beforeSnapshot = cloneMaintenanceSnapshot(currentGraph); - const consolidationResult = await consolidateMemories({ + consolidationAnalysis = await analyzeConsolidationGate({ graph: currentGraph, - newNodeIds: result.newNodeIds, + newNodeIds: consolidationCandidateNodeIds, embeddingConfig: getEmbeddingConfig(), - options: { - neighborCount: settings.consolidationNeighborCount, - conflictThreshold: settings.consolidationThreshold, - }, - settings, + schema: getSchema(), + conflictThreshold: settings.consolidationThreshold, signal, }); - persistMaintenanceAction({ - action: "consolidate", - beforeSnapshot, - mode: "auto", - summary: summarizeMaintenance( - "consolidate", - consolidationResult, - "auto", - ), - }); - postProcessArtifacts.push("consolidation"); - pushBatchStageArtifact(status, "structural", "consolidation"); - } catch (e) { - if (isAbortError(e)) throw e; - const message = e?.message || String(e) || "记忆整合阶段失败"; - setBatchStageOutcome( - status, - "structural", - "partial", - `记忆整合失败: ${message}`, - ); - console.error("[ST-BME] 记忆整合失败:", e); + } + const gate = resolveAutoConsolidationGate( + consolidationCandidateCount, + consolidationAnalysis, + settings, + ); + 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 { + try { + updateExtractionPostProcessStatus( + "整合/进化中", + String(gate.reason || "").trim() || "正在自动整合新旧记忆", + ); + const beforeSnapshot = cloneMaintenanceSnapshot(currentGraph); + const consolidationResult = await consolidateMemories({ + graph: currentGraph, + newNodeIds: consolidationCandidateNodeIds, + embeddingConfig: getEmbeddingConfig(), + options: { + neighborCount: settings.consolidationNeighborCount, + conflictThreshold: settings.consolidationThreshold, + }, + settings, + signal, + }); + persistMaintenanceAction({ + action: "consolidate", + beforeSnapshot, + mode: "auto", + summary: summarizeMaintenance( + "consolidate", + consolidationResult, + "auto", + ), + }); + postProcessArtifacts.push("consolidation"); + pushBatchStageArtifact(status, "structural", "consolidation"); + } catch (e) { + if (isAbortError(e)) throw e; + const message = e?.message || String(e) || "记忆整合阶段失败"; + setBatchStageOutcome( + status, + "structural", + "partial", + `记忆整合失败: ${message}`, + ); + console.error("[ST-BME] 记忆整合失败:", e); + } } } } @@ -20321,6 +20350,7 @@ async function executeExtractionBatch({ settings, smartTriggerDecision = null, signal = undefined, + postProcessContext = null, } = {}) { return await executeExtractionBatchController( { @@ -20350,7 +20380,15 @@ async function executeExtractionBatch({ throwIfAborted, updateProcessedHistorySnapshot, }, - { chat, startIdx, endIdx, settings, smartTriggerDecision, signal }, + { + chat, + startIdx, + endIdx, + settings, + smartTriggerDecision, + signal, + postProcessContext, + }, ); } diff --git a/maintenance/extraction-controller.js b/maintenance/extraction-controller.js index ba83eb3..81f1fa0 100644 --- a/maintenance/extraction-controller.js +++ b/maintenance/extraction-controller.js @@ -21,6 +21,37 @@ function clampIntValue(value, fallback = 0, min = 0, max = 9999) { return Math.min(max, Math.max(min, Math.trunc(numeric))); } +function uniqueStringList(values = []) { + const seen = new Set(); + const result = []; + for (const value of Array.isArray(values) ? values : []) { + const normalized = String(value || "").trim(); + if (!normalized || seen.has(normalized)) continue; + seen.add(normalized); + result.push(normalized); + } + return result; +} + +function resolveBulkAutoConsolidationWindow(settings = {}, options = {}) { + return { + everyBatches: clampIntValue( + options?.bulkAutoConsolidationEveryBatches ?? + settings?.bulkAutoConsolidationEveryBatches, + 20, + 2, + 200, + ), + minNewNodes: clampIntValue( + options?.bulkAutoConsolidationMinNewNodes ?? + settings?.bulkAutoConsolidationMinNewNodes, + 60, + 4, + 500, + ), + }; +} + function isAssistantFloor(runtime, chat, index) { if (!Array.isArray(chat)) return false; const message = chat[index]; @@ -205,6 +236,66 @@ function resolveRerunDialogueTask(chat = [], options = {}) { }; } +function buildRerunVisibilityWarning(runtime, chat = [], rerunTask = null) { + const settings = + typeof runtime?.getSettings === "function" ? runtime.getSettings() || {} : {}; + const requestedStart = Number.isFinite(Number(rerunTask?.requestedStartFloor)) + ? Number(rerunTask.requestedStartFloor) + : null; + const requestedEnd = Number.isFinite(Number(rerunTask?.requestedEndFloor)) + ? Number(rerunTask.requestedEndFloor) + : null; + const latestDialogueFloor = Number.isFinite(Number(rerunTask?.latestDialogueFloor)) + ? Number(rerunTask.latestDialogueFloor) + : null; + const chatLength = Array.isArray(chat) ? chat.length : 0; + const keepLastN = Math.max( + 0, + Math.trunc(Number(settings?.hideOldMessagesKeepLastN ?? 0) || 0), + ); + const renderLastN = Math.max( + 0, + Math.trunc(Number(settings?.hideOldMessagesRenderLimit ?? 0) || 0), + ); + const hiddenActive = settings?.hideOldMessagesEnabled === true && keepLastN > 0; + const renderLimitActive = + settings?.enabled !== false && + settings?.hideOldMessagesRenderLimitEnabled === true && + renderLastN > 0; + const warnings = []; + + if ( + latestDialogueFloor != null && + ((requestedStart != null && requestedStart > latestDialogueFloor) || + (requestedEnd != null && requestedEnd > latestDialogueFloor)) + ) { + warnings.push( + `当前可见聊天最高楼层只有 ${latestDialogueFloor},请求范围已被夹到可见范围内`, + ); + } + + if (hiddenActive) { + const hiddenBoundary = Math.max(-1, chatLength - keepLastN - 1); + if ( + hiddenBoundary >= 0 && + (requestedStart == null || requestedStart <= hiddenBoundary || requestedEnd == null) + ) { + warnings.push( + `当前启用了旧楼层隐藏(保留最近 ${keepLastN} 层),如需重提隐藏范围,请先在面板中清除/解除消息隐藏`, + ); + } + } + + if (renderLimitActive) { + warnings.push( + `当前启用了聊天区渲染楼层限制(最近 ${renderLastN} 层),如需重提更早楼层,请先关闭该限制并重新加载聊天`, + ); + } + + const uniqueWarnings = Array.from(new Set(warnings.filter(Boolean))); + return uniqueWarnings.length > 0 ? uniqueWarnings.join(";") : ""; +} + function resolveAssistantTargetRange(chat = [], dialogueRange = [-1, -1]) { const map = buildDialogueFloorMap(chat); const assistantDialogueFloors = Array.isArray(map.assistantDialogueFloors) @@ -688,6 +779,7 @@ export async function executeExtractionBatchController( settings, smartTriggerDecision = null, signal = undefined, + postProcessContext = null, } = {}, ) { runtime.ensureCurrentGraphRuntimeState(); @@ -767,6 +859,7 @@ export async function executeExtractionBatchController( settings, signal, batchStatus, + postProcessContext, ); const batchStatusRef = effects?.batchStatus || batchStatus; const committedPersistState = await buildCommittedBatchPersistSnapshot(runtime, { @@ -1073,6 +1166,13 @@ export async function onManualExtractController(runtime, options = {}) { const settings = runtime.getSettings(); const extractEvery = runtime.clampInt(settings.extractEvery, 1, 1, 50); + const bulkConsolidationRequested = + options?.suppressIntermediateAutoConsolidation === true && + options?.drainAll !== false; + const bulkConsolidationWindow = resolveBulkAutoConsolidationWindow( + settings, + options, + ); const totals = { newNodes: 0, updatedNodes: 0, @@ -1080,6 +1180,8 @@ export async function onManualExtractController(runtime, options = {}) { batches: 0, }; let processedAssistantTurns = 0; + let deferredAutoConsolidationBatches = 0; + let deferredAutoConsolidationNodeIds = []; const warnings = []; runtime.setIsExtracting(true); @@ -1112,12 +1214,59 @@ export async function onManualExtractController(runtime, options = {}) { const batchAssistantTurns = pendingTurns.slice(0, extractEvery); const startIdx = batchAssistantTurns[0]; const endIdx = batchAssistantTurns[batchAssistantTurns.length - 1]; + const remainingAfterBatch = Math.max( + 0, + pendingTurns.length - batchAssistantTurns.length, + ); + const finalBulkBatch = + bulkConsolidationRequested && remainingAfterBatch <= 0; + const windowReady = + bulkConsolidationRequested && + remainingAfterBatch > 0 && + (deferredAutoConsolidationBatches + 1 >= + bulkConsolidationWindow.everyBatches || + deferredAutoConsolidationNodeIds.length >= + bulkConsolidationWindow.minNewNodes); + const shouldDelayAutoConsolidation = + bulkConsolidationRequested && + remainingAfterBatch > 0 && + !windowReady; + const pendingAutoConsolidationNodeIds = + windowReady || finalBulkBatch ? deferredAutoConsolidationNodeIds : []; + const bulkPostProcessContext = + !bulkConsolidationRequested + ? null + : shouldDelayAutoConsolidation + ? { + suppressAutoConsolidation: true, + autoConsolidationSuppressReason: `批量重新提取仍有 ${remainingAfterBatch} 条 AI 回复待处理,已延后本批自动整合(累计 ${deferredAutoConsolidationBatches + 1}/${bulkConsolidationWindow.everyBatches} 批,${deferredAutoConsolidationNodeIds.length}/${bulkConsolidationWindow.minNewNodes} 个待整合节点)`, + bulkExtraction: true, + remainingAssistantTurnsAfterBatch: remainingAfterBatch, + deferredAutoConsolidationBatches, + deferredAutoConsolidationNodeCount: + deferredAutoConsolidationNodeIds.length, + } + : pendingAutoConsolidationNodeIds.length > 0 + ? { + suppressAutoConsolidation: false, + bulkExtraction: true, + pendingAutoConsolidationNodeIds, + autoConsolidationWindowReason: finalBulkBatch + ? "批量重新提取已到最后一批,执行窗口整合" + : `批量重新提取累计达到窗口阈值(${deferredAutoConsolidationBatches} 批,${deferredAutoConsolidationNodeIds.length} 个待整合节点),执行一次中间整合`, + remainingAssistantTurnsAfterBatch: remainingAfterBatch, + deferredAutoConsolidationBatches, + deferredAutoConsolidationNodeCount: + deferredAutoConsolidationNodeIds.length, + } + : null; const batchResult = await runtime.executeExtractionBatch({ chat, startIdx, endIdx, settings, signal: extractionSignal, + postProcessContext: bulkPostProcessContext, }); if (!batchResult.success) { @@ -1134,6 +1283,21 @@ export async function onManualExtractController(runtime, options = {}) { totals.batches++; processedAssistantTurns += batchAssistantTurns.length; + if (bulkConsolidationRequested) { + if (shouldDelayAutoConsolidation) { + deferredAutoConsolidationBatches += 1; + deferredAutoConsolidationNodeIds = uniqueStringList([ + ...deferredAutoConsolidationNodeIds, + ...(Array.isArray(batchResult.result?.newNodeIds) + ? batchResult.result.newNodeIds + : []), + ]); + } else { + deferredAutoConsolidationBatches = 0; + deferredAutoConsolidationNodeIds = []; + } + } + if (Array.isArray(batchResult.effects?.warnings)) { warnings.push(...batchResult.effects.warnings); } @@ -1282,6 +1446,7 @@ export async function onExtractionTaskController(runtime, options = {}) { rerunTask.startFloor, rerunTask.endFloor, ]); + const visibilityWarning = buildRerunVisibilityWarning(runtime, chat, rerunTask); if (!fallbackInfo.valid) { runtime.toastr?.info?.(fallbackInfo.reason || "目标范围内没有可重提的 AI 回复"); return { @@ -1291,6 +1456,7 @@ export async function onExtractionTaskController(runtime, options = {}) { requestedRange: [rerunTask.requestedStartFloor, rerunTask.requestedEndFloor], effectiveDialogueRange: [rerunTask.startFloor, rerunTask.endFloor], reason: fallbackInfo.reason || "no-assistant-in-range", + visibilityWarning, }; } @@ -1309,10 +1475,15 @@ export async function onExtractionTaskController(runtime, options = {}) { setExtractionProgressStatus( runtime, "重新提取准备中", - fallbackInfo.fallbackToLatest - ? `范围 ${rerunTask.startFloor} ~ ${rerunTask.endFloor} 命中旧批次,但当前将退化为从 ${effectiveDialogueRange[0]} 到最新重提` - : `准备重提范围 ${rerunTask.startFloor} ~ ${rerunTask.endFloor}`, - fallbackInfo.fallbackToLatest ? "warning" : "running", + [ + fallbackInfo.fallbackToLatest + ? `范围 ${rerunTask.startFloor} ~ ${rerunTask.endFloor} 命中旧批次,但当前将退化为从 ${effectiveDialogueRange[0]} 到最新重提` + : `准备重提范围 ${rerunTask.startFloor} ~ ${rerunTask.endFloor}`, + visibilityWarning, + ] + .filter(Boolean) + .join(";"), + fallbackInfo.fallbackToLatest || visibilityWarning ? "warning" : "running", { syncRuntime: true, toastKind: "info", @@ -1320,6 +1491,12 @@ export async function onExtractionTaskController(runtime, options = {}) { }, ); + if (visibilityWarning) { + runtime.toastr?.warning?.(visibilityWarning, "ST-BME 重新提取", { + timeOut: 7000, + }); + } + let rollbackResult = await runtime.rollbackGraphForReroll( fallbackInfo.startAssistantChatIndex, context, @@ -1360,6 +1537,7 @@ export async function onExtractionTaskController(runtime, options = {}) { ); await runManualExtract({ drainAll: true, + suppressIntermediateAutoConsolidation: true, taskLabel: "重新提取(恢复后)", toastTitle: "ST-BME 重新提取", showStartToast: false, @@ -1374,6 +1552,7 @@ export async function onExtractionTaskController(runtime, options = {}) { rerunTask.requestedEndFloor, ], effectiveDialogueRange, + visibilityWarning, reason: "rollback-unavailable-recovered-pending", }; } @@ -1410,6 +1589,7 @@ export async function onExtractionTaskController(runtime, options = {}) { fallbackToLatest: fallbackInfo.fallbackToLatest, requestedRange: [rerunTask.requestedStartFloor, rerunTask.requestedEndFloor], effectiveDialogueRange, + visibilityWarning, }; } @@ -1438,6 +1618,7 @@ export async function onExtractionTaskController(runtime, options = {}) { await runManualExtract({ drainAll: true, lockedEndFloor: effectiveLockedEndFloor, + suppressIntermediateAutoConsolidation: true, taskLabel: "重新提取", toastTitle: "ST-BME 重新提取", showStartToast: false, @@ -1454,6 +1635,7 @@ export async function onExtractionTaskController(runtime, options = {}) { effectiveLockedEndFloor, ], rollbackResult, + visibilityWarning, reason: fallbackInfo.reason || "", }; } diff --git a/tests/dialogue-floor-range-tasks.mjs b/tests/dialogue-floor-range-tasks.mjs index a399d9d..bc07410 100644 --- a/tests/dialogue-floor-range-tasks.mjs +++ b/tests/dialogue-floor-range-tasks.mjs @@ -111,11 +111,20 @@ const chat = [ rollback: [], manual: [], extractionStatus: [], + warning: [], }; const runtime = { getContext() { return { chat }; }, + getSettings() { + return { + hideOldMessagesEnabled: true, + hideOldMessagesKeepLastN: 2, + hideOldMessagesRenderLimitEnabled: true, + hideOldMessagesRenderLimit: 4, + }; + }, getIsExtracting() { return false; }, @@ -134,7 +143,9 @@ const chat = [ calls.manual.push({ ...options }); }, toastr: { - warning() {}, + warning(message) { + calls.warning.push(String(message || "")); + }, info() {}, }, }; @@ -147,12 +158,18 @@ const chat = [ assert.equal(result.fallbackToLatest, false); assert.deepEqual(calls.rollback, [6]); assert.equal(calls.manual[0].lockedEndFloor, 6); + assert.equal(calls.manual[0].suppressIntermediateAutoConsolidation, true); assert.equal(calls.manual[0].showStartToast, false); assert.equal(calls.extractionStatus[0]?.text, "重新提取准备中"); + assert.match(calls.extractionStatus[0]?.meta || "", /旧楼层隐藏/); + assert.match(calls.extractionStatus[0]?.meta || "", /渲染楼层限制/); + assert.match(calls.warning[0] || "", /解除消息隐藏/); + assert.match(result.visibilityWarning || "", /渲染楼层限制/); } { const statuses = []; + const executeCalls = []; let lastProcessedAssistantFloor = -1; const runtime = { getIsExtracting() { @@ -192,7 +209,9 @@ const chat = [ setLastExtractionStatus(text, meta, level) { statuses.push({ text, meta, level }); }, - async executeExtractionBatch({ endIdx }) { + async executeExtractionBatch(options = {}) { + executeCalls.push(options); + const endIdx = options.endIdx; lastProcessedAssistantFloor = endIdx; return { success: true, @@ -224,6 +243,7 @@ const chat = [ taskLabel: "重新提取", toastTitle: "ST-BME 重新提取", showStartToast: false, + suppressIntermediateAutoConsolidation: true, }); assert.equal(statuses[0]?.text, "重新提取中"); @@ -236,6 +256,117 @@ const chat = [ ), ); assert.equal(statuses[statuses.length - 1]?.text, "重新提取完成"); + assert.equal(executeCalls.length, 2); + assert.equal( + executeCalls[0]?.postProcessContext?.suppressAutoConsolidation, + true, + ); + assert.equal(executeCalls[0]?.postProcessContext?.remainingAssistantTurnsAfterBatch, 1); + assert.equal(executeCalls[1]?.postProcessContext, null); +} + +{ + const executeCalls = []; + let lastProcessedAssistantFloor = -1; + const assistantTurns = [2, 4, 6, 8]; + const runtime = { + getIsExtracting() { + return false; + }, + ensureGraphMutationReady() { + return true; + }, + async recoverHistoryIfNeeded() { + return true; + }, + getCurrentGraph() { + return { historyState: {} }; + }, + getContext() { + return { chat }; + }, + getAssistantTurns() { + return assistantTurns; + }, + getLastProcessedAssistantFloor() { + return lastProcessedAssistantFloor; + }, + getSettings() { + return { extractEvery: 1, bulkAutoConsolidationEveryBatches: 2 }; + }, + clampInt(value, fallback, min, max) { + const numeric = Number(value); + if (!Number.isFinite(numeric)) return fallback; + return Math.min(max, Math.max(min, Math.trunc(numeric))); + }, + setIsExtracting() {}, + beginStageAbortController() { + return { signal: null }; + }, + finishStageAbortController() {}, + setLastExtractionStatus() {}, + async executeExtractionBatch(options = {}) { + executeCalls.push(options); + lastProcessedAssistantFloor = options.endIdx; + return { + success: true, + result: { + newNodes: 1, + newNodeIds: [`node-${options.endIdx}`], + updatedNodes: 0, + newEdges: 1, + }, + effects: { + warnings: [], + }, + historyAdvanceAllowed: true, + }; + }, + isAbortError() { + return false; + }, + refreshPanelLiveState() {}, + retryPendingGraphPersist: async () => ({ accepted: true }), + toastr: { + info() {}, + success() {}, + warning() {}, + error() {}, + }, + }; + + await onManualExtractController(runtime, { + taskLabel: "重新提取", + toastTitle: "ST-BME 重新提取", + showStartToast: false, + suppressIntermediateAutoConsolidation: true, + }); + + assert.equal(executeCalls.length, 4); + assert.equal( + executeCalls[0]?.postProcessContext?.suppressAutoConsolidation, + true, + ); + assert.equal( + executeCalls[1]?.postProcessContext?.suppressAutoConsolidation, + false, + ); + assert.deepEqual( + executeCalls[1]?.postProcessContext?.pendingAutoConsolidationNodeIds, + ["node-2"], + ); + assert.equal( + executeCalls[2]?.postProcessContext?.suppressAutoConsolidation, + true, + ); + assert.equal( + executeCalls[3]?.postProcessContext?.suppressAutoConsolidation, + false, + ); + assert.deepEqual( + executeCalls[3]?.postProcessContext?.pendingAutoConsolidationNodeIds, + ["node-6"], + ); } { diff --git a/tests/p0-regressions.mjs b/tests/p0-regressions.mjs index 8ef39c7..fd11fa9 100644 --- a/tests/p0-regressions.mjs +++ b/tests/p0-regressions.mjs @@ -281,6 +281,12 @@ function createBatchStageHarness() { .replace(/^export\s+/gm, ""); const context = { console, + AbortController, + AbortSignal, + DOMException, + setTimeout, + clearTimeout, + EXTRACTION_VECTOR_SYNC_TIMEOUT_MS: 120000, result: null, extractionCount: 0, currentGraph: null, @@ -3678,6 +3684,148 @@ async function testAutoConsolidationSkipsLowRiskSingleNode() { ); } +async function testAutoConsolidationSuppressedForBulkExtractionBatch() { + 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; + let gateCalls = 0; + harness.analyzeAutoConsolidationGate = async () => { + gateCalls += 1; + return { + triggered: true, + reason: "should-not-run", + matchedScore: 0.99, + matchedNodeId: "old-bulk", + }; + }; + 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: [8, 8], + extractionCountBefore: 0, + }); + const effects = await handleExtractionSuccess( + { newNodeIds: ["node-a", "node-b", "node-c"] }, + 8, + { + enableConsolidation: true, + consolidationAutoMinNewNodes: 2, + consolidationThreshold: 0.85, + enableAutoCompression: false, + compressionEveryN: 10, + enableSynopsis: false, + enableReflection: false, + enableSleepCycle: false, + synopsisEveryN: 1, + reflectEveryN: 1, + sleepEveryN: 1, + }, + undefined, + batchStatus, + { + suppressAutoConsolidation: true, + autoConsolidationSuppressReason: "bulk rerun still draining", + }, + ); + + assert.equal(gateCalls, 0); + assert.equal(consolidateCalls, 0); + assert.equal(effects.batchStatus.consolidationGateTriggered, false); + assert.equal(effects.batchStatus.consolidationGateReason, "bulk rerun still draining"); + assert.equal( + effects.batchStatus.stages.structural.artifacts.includes( + "consolidation-skipped", + ), + true, + ); +} + +async function testAutoConsolidationUsesDeferredBulkCandidates() { + const harness = await createBatchStageHarness(); + const { createBatchStatusSkeleton, handleExtractionSuccess } = harness.result; + harness.currentGraph = { + historyState: { extractionCount: 0 }, + vectorIndexState: {}, + }; + harness.ensureCurrentGraphRuntimeState = () => { + harness.currentGraph.historyState ||= {}; + harness.currentGraph.vectorIndexState ||= {}; + }; + let candidateIds = []; + harness.consolidateMemories = async ({ newNodeIds = [] } = {}) => { + candidateIds = [...newNodeIds]; + return { + merged: 1, + skipped: 0, + kept: 0, + evolved: 0, + connections: 0, + updates: 0, + }; + }; + harness.syncVectorState = async () => ({ + insertedHashes: [], + stats: { pending: 0 }, + }); + + const batchStatus = createBatchStatusSkeleton({ + processedRange: [10, 10], + extractionCountBefore: 0, + }); + const effects = await handleExtractionSuccess( + { newNodeIds: ["node-current", "node-old"] }, + 10, + { + enableConsolidation: true, + consolidationAutoMinNewNodes: 2, + consolidationThreshold: 0.85, + enableAutoCompression: false, + compressionEveryN: 10, + enableSynopsis: false, + enableReflection: false, + enableSleepCycle: false, + synopsisEveryN: 1, + reflectEveryN: 1, + sleepEveryN: 1, + }, + undefined, + batchStatus, + { + suppressAutoConsolidation: false, + pendingAutoConsolidationNodeIds: ["node-old", "node-deferred"], + }, + ); + + assert.deepEqual(candidateIds, ["node-old", "node-deferred", "node-current"]); + assert.equal(effects.batchStatus.consolidationGateTriggered, true); + assert.equal( + effects.batchStatus.stages.structural.artifacts.includes("consolidation"), + true, + ); +} + async function testAutoCompressionRunsOnlyOnConfiguredInterval() { const harness = await createBatchStageHarness(); const { createBatchStatusSkeleton, handleExtractionSuccess } = harness.result; @@ -7510,6 +7658,8 @@ await testBatchStatusSemanticFailureDoesNotHideCoreSuccess(); await testExtractionPostProcessStatusesExposeMaintenancePhases(); await testAutoConsolidationRunsOnHighDuplicateRiskSingleNode(); await testAutoConsolidationSkipsLowRiskSingleNode(); +await testAutoConsolidationSuppressedForBulkExtractionBatch(); +await testAutoConsolidationUsesDeferredBulkCandidates(); await testAutoCompressionRunsOnlyOnConfiguredInterval(); await testAutoCompressionSkipsWhenNotScheduledOrNoCandidates(); await testBatchStatusFinalizeFailureIsNotCompleteSuccess();