From 1834bc1d24f329312213fc497171370eebc68b12 Mon Sep 17 00:00:00 2001 From: Youzini-afk <13153778771cx@gmail.com> Date: Sat, 11 Apr 2026 13:57:03 +0800 Subject: [PATCH 01/20] refactor: stabilize persistence delta commit flow --- index.js | 1349 +++++++++++-------- maintenance/extraction-controller.js | 8 +- runtime/runtime-state.js | 2 + sync/bme-db.js | 257 ++++ tests/graph-persistence.mjs | 115 +- tests/helpers/generation-recall-harness.mjs | 1 + tests/mobile-status-regressions.mjs | 3 + tests/p0-regressions.mjs | 6 + ui/panel.js | 21 +- ui/ui-status.js | 9 + 10 files changed, 1182 insertions(+), 589 deletions(-) diff --git a/index.js b/index.js index a0de6ea..58609ea 100644 --- a/index.js +++ b/index.js @@ -1,5 +1,5 @@ -// ST-BME: 主入口 -// 事件钩子、设置管理、流程调度 +// ST-BME: 主入? +// 事件钩子、设置管理、流程调? import { eventSource, @@ -20,6 +20,7 @@ import { BmeChatManager } from "./sync/bme-chat-manager.js"; import { BmeDatabase, buildBmeDbName, + buildPersistDelta, buildGraphFromSnapshot, buildSnapshotFromGraph, ensureDexieLoaded, @@ -296,7 +297,7 @@ import { export { DEFAULT_TRIGGER_KEYWORDS, getSmartTriggerDecision }; -// 操控面板模块(动态加载,防止加载失败崩溃整个扩展) +// 操控面板模块(动态加载,防止加载失败崩溃整个扩展? let _panelModule = null; let _themesModule = null; @@ -315,14 +316,143 @@ function syncCommitMarkerToPersistenceState(context = getContext()) { const marker = getChatCommitMarker(context); updateGraphPersistenceState({ commitMarker: cloneRuntimeDebugValue(marker, null), - lastAcceptedRevision: Math.max( - Number(graphPersistenceState.lastAcceptedRevision || 0), - getAcceptedCommitMarkerRevision(marker), - ), }); return marker; } +function isAcceptedPersistTier(storageTier = "none") { + const normalizedTier = String(storageTier || "none").trim().toLowerCase(); + return normalizedTier === "indexeddb" || normalizedTier === "chat-state"; +} + +function isRecoveryOnlyPersistTier(storageTier = "none") { + const normalizedTier = String(storageTier || "none").trim().toLowerCase(); + return normalizedTier === "shadow" || normalizedTier === "metadata-full"; +} + +function resolvePersistRevisionFloor( + requestedRevision = 0, + graph = currentGraph, +) { + return Math.max( + normalizeIndexedDbRevision(requestedRevision), + normalizeIndexedDbRevision(graphPersistenceState.revision), + normalizeIndexedDbRevision(graphPersistenceState.lastPersistedRevision), + normalizeIndexedDbRevision(graphPersistenceState.queuedPersistRevision), + normalizeIndexedDbRevision(graph ? getGraphPersistedRevision(graph) : 0), + ); +} + +function allocateRequestedPersistRevision( + requestedRevision = 0, + graph = currentGraph, +) { + return Math.max(1, resolvePersistRevisionFloor(requestedRevision, graph) + 1); +} + +function normalizeRestoreLockState(lock = null) { + const source = String(lock?.source || "").trim(); + const reason = String(lock?.reason || "").trim(); + const startedAt = Number(lock?.startedAt); + const depth = Math.max(0, Math.floor(Number(lock?.depth) || 0)); + const active = lock?.active === true || depth > 0; + return { + active, + depth: active ? Math.max(1, depth || 1) : 0, + source, + reason, + startedAt: Number.isFinite(startedAt) && startedAt > 0 ? startedAt : 0, + }; +} + +function isRestoreLockActive() { + return normalizeRestoreLockState(graphPersistenceState.restoreLock).active; +} + +function getRestoreLockMessage(operationLabel = "当前操作") { + const lock = normalizeRestoreLockState(graphPersistenceState.restoreLock); + if (!lock.active) return ""; + const details = [lock.reason, lock.source].filter(Boolean).join(" / "); + return `${operationLabel}已暂停:当前处于恢复锁${details ? `(${details})` : ""}`; +} + +function enterRestoreLock(source = "runtime", reason = "") { + const currentLock = normalizeRestoreLockState(graphPersistenceState.restoreLock); + const nextLock = { + active: true, + depth: currentLock.depth + 1, + source: String(source || currentLock.source || "runtime"), + reason: String(reason || currentLock.reason || ""), + startedAt: currentLock.startedAt || Date.now(), + }; + updateGraphPersistenceState({ + restoreLock: nextLock, + }); + return cloneRuntimeDebugValue(nextLock, nextLock); +} + +function leaveRestoreLock(source = "runtime") { + const currentLock = normalizeRestoreLockState(graphPersistenceState.restoreLock); + if (!currentLock.active) { + return currentLock; + } + const nextDepth = Math.max(0, currentLock.depth - 1); + const nextLock = + nextDepth > 0 + ? { + ...currentLock, + depth: nextDepth, + source: String(source || currentLock.source || ""), + } + : { + active: false, + depth: 0, + source: "", + reason: "", + startedAt: 0, + }; + updateGraphPersistenceState({ + restoreLock: nextLock, + }); + return cloneRuntimeDebugValue(nextLock, nextLock); +} + +async function runWithRestoreLock(source, reason, task) { + enterRestoreLock(source, reason); + try { + return await task(); + } finally { + leaveRestoreLock(source); + } +} + +function recordPersistMismatchDiagnostic( + mismatch = null, + { source = "persist-mismatch", resolvedBy = "" } = {}, +) { + const normalizedReason = String(mismatch?.reason || "").trim(); + const marker = cloneRuntimeDebugValue(mismatch?.marker, null) || getChatCommitMarker(); + updateGraphPersistenceState({ + persistMismatchReason: normalizedReason, + commitMarker: marker, + dualWriteLastResult: { + action: "load", + source: String(source || "persist-mismatch"), + success: false, + diagnostic: true, + reason: normalizedReason, + markerRevision: Number(mismatch?.markerRevision || 0), + snapshotRevision: Number(mismatch?.snapshotRevision || 0), + resolvedBy: String(resolvedBy || ""), + at: Date.now(), + }, + }); + return { + reason: normalizedReason, + marker, + }; +} + function persistGraphCommitMarker( context = getContext(), { @@ -385,12 +515,6 @@ function persistGraphCommitMarker( const saveMode = triggerChatMetadataSave(context, { immediate }); updateGraphPersistenceState({ commitMarker: cloneRuntimeDebugValue(marker, null), - lastAcceptedRevision: accepted - ? Math.max( - Number(graphPersistenceState.lastAcceptedRevision || 0), - Number(marker.revision || 0), - ) - : Number(graphPersistenceState.lastAcceptedRevision || 0), lastPersistReason: String(reason || ""), lastPersistMode: `commit-marker:${saveMode}`, }); @@ -408,53 +532,37 @@ function persistGraphCommitMarker( function applyPersistMismatchBlockedState( chatId, mismatch = null, - { source = "persist-mismatch", attemptIndex = 0 } = {}, + { source = "persist-mismatch", attemptIndex = 0, resolvedBy = "" } = {}, ) { const marker = cloneRuntimeDebugValue(mismatch?.marker, null) || getChatCommitMarker(); const markerRevision = Number(mismatch?.markerRevision || 0); const snapshotRevision = Number(mismatch?.snapshotRevision || 0); - const reason = String(mismatch?.reason || "persist-mismatch:indexeddb-behind-commit-marker"); - applyGraphLoadState(GRAPH_LOAD_STATES.BLOCKED, { - chatId, - reason: `${source}:${reason}`, - attemptIndex, - revision: Math.max(Number(graphPersistenceState.revision || 0), markerRevision), - lastPersistedRevision: Math.max( - Number(graphPersistenceState.lastPersistedRevision || 0), - snapshotRevision, - ), - pendingPersist: false, - dbReady: true, - writesBlocked: true, - }); - updateGraphPersistenceState({ - persistMismatchReason: reason, - commitMarker: cloneRuntimeDebugValue(marker, null), - lastAcceptedRevision: Math.max( - Number(graphPersistenceState.lastAcceptedRevision || 0), - markerRevision, - ), - dualWriteLastResult: { - action: "load", - source: String(source || "persist-mismatch"), - success: false, - rejected: true, - reason, - markerRevision, - snapshotRevision, - at: Date.now(), + const diagnostic = recordPersistMismatchDiagnostic( + { + ...(mismatch || {}), + marker, }, - }); + { + source, + resolvedBy, + }, + ); refreshPanelLiveState(); return { success: false, loaded: false, - loadState: GRAPH_LOAD_STATES.BLOCKED, - reason, + loadState: graphPersistenceState.loadState, + reason: + diagnostic.reason || + String( + mismatch?.reason || + "persist-mismatch:indexeddb-behind-commit-marker", + ), chatId, attemptIndex, markerRevision, snapshotRevision, + diagnosticOnly: true, }; } @@ -594,7 +702,7 @@ function readRuntimeDebugSnapshot() { ); } -// ==================== 状态 ==================== +// ==================== 状?==================== let currentGraph = null; let isExtracting = false; @@ -602,8 +710,8 @@ let isRecalling = false; let activeRecallPromise = null; let recallRunSequence = 0; let lastInjectionContent = ""; -let lastExtractedItems = []; // 最近提取的节点(面板展示用) -let lastRecalledItems = []; // 最近召回的节点(面板展示用) +let lastExtractedItems = []; // 最近提取的节点(面板展示用? +let lastRecalledItems = []; // 最近召回的节点(面板展示用? let extractionCount = 0; // v2: 提取次数计数器(定期触发概要/遗忘/反思) let serverSettingsSaveTimer = null; let isRecoveringHistory = false; @@ -732,10 +840,7 @@ function getGraphPersistenceLiveState() { const liveCommitMarker = cloneRuntimeDebugValue(graphPersistenceState.commitMarker, null) || readGraphCommitMarker(getContext()); - const lastAcceptedRevision = Math.max( - Number(graphPersistenceState.lastAcceptedRevision || 0), - getAcceptedCommitMarkerRevision(liveCommitMarker), - ); + const restoreLock = normalizeRestoreLockState(graphPersistenceState.restoreLock); const snapshot = { loadState: graphPersistenceState.loadState, chatId: graphPersistenceState.chatId, @@ -754,9 +859,14 @@ function getGraphPersistenceLiveState() { metadataIntegrity: graphPersistenceState.metadataIntegrity, writesBlocked: graphPersistenceState.writesBlocked, pendingPersist: graphPersistenceState.pendingPersist, - lastAcceptedRevision, + lastAcceptedRevision: Number(graphPersistenceState.lastAcceptedRevision || 0), + acceptedStorageTier: String(graphPersistenceState.acceptedStorageTier || "none"), + lastRecoverableStorageTier: String( + graphPersistenceState.lastRecoverableStorageTier || "none", + ), persistMismatchReason: String(graphPersistenceState.persistMismatchReason || ""), commitMarker: cloneRuntimeDebugValue(liveCommitMarker, null), + restoreLock, queuedPersistMode: graphPersistenceState.queuedPersistMode, queuedPersistRotateIntegrity: graphPersistenceState.queuedPersistRotateIntegrity, @@ -863,14 +973,14 @@ function hasReadableRuntimeGraphForRecall(chatId = getCurrentChatId()) { currentGraph.historyState.chatId, ); - // chatId 匹配验证:如果两者都有,必须一致 + // chatId 匹配验证:如果两者都有,必须一? if (activeChatId && runtimeChatId) { return runtimeChatId === activeChatId; } // 兜底:chatId 不可用(ST 插件环境可能无法获取 chatId), - // 但 currentGraph 结构完整且有节点数据 → 允许召回。 - // 这对应用户能在 UI 看到图谱但 getCurrentChatId() 返回空的场景。 + // ?currentGraph 结构完整且有节点数据 ?允许召回? + // 这对应用户能?UI 看到图谱?getCurrentChatId() 返回空的场景? return currentGraph.nodes.length > 0 || currentGraph.edges.length > 0; } @@ -882,10 +992,10 @@ function isGraphReadableForRecall( return true; } - // 当 loadState 不在正常可读状态时(如 NO_CHAT、LOADING), - // 仍检查运行时图谱的实际结构。持久化状态机可能失同步 - // (如 getCurrentChatId 在某些 ST 环境下返回空导致 loadState 卡在 NO_CHAT), - // 但 currentGraph 已经通过其他路径(IndexedDB probe / metadata fallback)加载了数据。 + // ?loadState 不在正常可读状态时(如 NO_CHAT、LOADING), + // 仍检查运行时图谱的实际结构。持久化状态机可能失同? + // (如 getCurrentChatId 在某?ST 环境下返回空导致 loadState 卡在 NO_CHAT), + // ?currentGraph 已经通过其他路径(IndexedDB probe / metadata fallback)加载了数据? return hasReadableRuntimeGraphForRecall(chatId); } @@ -918,7 +1028,7 @@ function createGraphLoadUiStatus() { case GRAPH_LOAD_STATES.BLOCKED: return createUiStatus( "图谱加载受阻", - "当前图谱尚未完成 IndexedDB 初始化", + "当前图谱尚未完成 IndexedDB 初始加载", "warning", ); case GRAPH_LOAD_STATES.LOADED: @@ -942,6 +1052,9 @@ function getPanelRuntimeStatus() { } function getGraphMutationBlockReason(operationLabel = "当前操作") { + if (isRestoreLockActive()) { + return getRestoreLockMessage(operationLabel); + } const loadState = graphPersistenceState.loadState; if (!getCurrentChatId()) { return `${operationLabel}已暂停:当前尚未进入聊天。`; @@ -967,8 +1080,14 @@ function getGraphMutationBlockReason(operationLabel = "当前操作") { function ensureGraphMutationReady( operationLabel = "当前操作", - { notify = true } = {}, + { notify = true, ignoreRestoreLock = false } = {}, ) { + if (!ignoreRestoreLock && isRestoreLockActive()) { + if (notify) { + toastr.info(getRestoreLockMessage(operationLabel), "ST-BME"); + } + return false; + } if (graphPersistenceState.dbReady || isGraphLoadStateDbReady()) return true; if (notify) { toastr.info(getGraphMutationBlockReason(operationLabel), "ST-BME"); @@ -1603,10 +1722,10 @@ function recordRecallSentUserMessage(messageId, text, source = "message-sent") { } // 注意:不再在 MESSAGE_SENT 阶段清空 pendingRecallSendIntent / - // pendingHostGenerationInputSnapshot / transactions。 - // 这些数据在 GENERATION_AFTER_COMMANDS 中被消费;MESSAGE_SENT 先于 - // GENERATION_AFTER_COMMANDS 触发,提前清空会导致召回拿不到用户输入。 - // 真正的消费发生在 recall 执行后(runRecallController 内部)。 + // pendingHostGenerationInputSnapshot / transactions? + // 这些数据?GENERATION_AFTER_COMMANDS 中被消费;MESSAGE_SENT 先于 + // GENERATION_AFTER_COMMANDS 触发,提前清空会导致召回拿不到用户输入? + // 真正的消费发生在 recall 执行后(runRecallController 内部)? return lastRecallSentUserMessage; } @@ -1781,9 +1900,9 @@ function resolveRecallPersistenceTargetUserMessageIndex( } // 正常生成阶段里,ST 可能会在真正发送前改写用户文本 - // (命令展开、包装显示、助手 UI 处理等),导致 hash 已无法精确匹配。 - // 这时仍应优先回绑到“当前最新 user 楼层”,否则召回记录虽然生成了, - // 但 Recall Card 会因为找不到目标楼层而消失。 + // (命令展开、包装显示、助?UI 处理等),导?hash 已无法精确匹配? + // 这时仍应优先回绑到“当前最?user 楼层”,否则召回记录虽然生成了, + // ?Recall Card 会因为找不到目标楼层而消失? if ( normalizedGenerationType === "normal" && Number.isFinite(latestUserIndex) && @@ -2945,8 +3064,8 @@ function schedulePersistedRecallMessageUiRefresh(delayMs = 0) { summary.status === "missing_message_anchor") && attemptIndex < retryDelays.length - 1; - // 勿在「已成功渲染」时长期挂 MutationObserver:#chat 上 class/流式更新会疯狂触发 - // runAttempt,造成满屏刷新与日志;显式事件(USER_MESSAGE_RENDERED 等)仍会 schedule 刷新。 + // 勿在「已成功渲染」时长期?MutationObserver?chat ?class/流式更新会疯狂触? + // runAttempt,造成满屏刷新与日志;显式事件(USER_MESSAGE_RENDERED 等)仍会 schedule 刷新? const shouldWatchForRepaint = false; if (!shouldRetryForPending && !shouldWatchForRepaint) { @@ -3182,7 +3301,7 @@ async function runIncrementalMessageHide(reason = "incremental") { function clearMessageHideState(reason = "reset") { try { resetHideState(getHideRuntimeAdapters()); - debugLog("[ST-BME] 已重置旧楼层隐藏状态:", reason); + debugLog("[ST-BME] 已重置旧楼层隐藏状态", reason); } catch (error) { console.warn("[ST-BME] 重置旧楼层隐藏状态失败:", reason, error); } @@ -3396,7 +3515,7 @@ function isHostChatMetadataReady(context = getContext()) { } const metadata = context.chatMetadata; - // 仅接受宿主“强信号”,避免把中间态/占位 metadata 误判为 ready。 + // 仅接受宿主“强信号”,避免把中间?占位 metadata 误判?ready? if (hasHostMetadataReadySignal(metadata)) return true; return false; @@ -3991,11 +4110,8 @@ function applyShadowSnapshotToRuntime( storageMode: "indexeddb", dbReady: true, indexedDbLastError: "", - persistMismatchReason: "", - lastAcceptedRevision: Math.max( - Number(graphPersistenceState.lastAcceptedRevision || 0), - shadowRevision, - ), + pendingPersist: Boolean(promoteToIndexedDb), + lastRecoverableStorageTier: "shadow", metadataIntegrity: getChatMetadataIntegrity(getContext()) || graphPersistenceState.metadataIntegrity, @@ -4668,28 +4784,33 @@ async function loadGraphFromChatState( snapshot, effectiveCommitMarker, ); + let commitMarkerDiagnostic = null; if (commitMarkerMismatch.mismatched) { - if ( - shadowSnapshot && - Number(shadowSnapshot.revision || 0) >= - Number(commitMarkerMismatch.markerRevision || 0) - ) { - return applyShadowSnapshotToRuntime(normalizedChatId, shadowSnapshot, { - source: `${source}:shadow-beats-chat-state-marker`, - attemptIndex, - }); - } - return applyPersistMismatchBlockedState( - normalizedChatId, + commitMarkerDiagnostic = recordPersistMismatchDiagnostic( { ...commitMarkerMismatch, marker: commitMarkerMismatch.marker || effectiveCommitMarker, }, { source: `${source}:chat-state-marker`, - attemptIndex, }, ); + if ( + shadowSnapshot && + Number(shadowSnapshot.revision || 0) >= + Number(commitMarkerMismatch.markerRevision || 0) + ) { + const shadowResult = applyShadowSnapshotToRuntime(normalizedChatId, shadowSnapshot, { + source: `${source}:shadow-beats-chat-state-marker`, + attemptIndex, + }); + if (shadowResult?.loaded && commitMarkerDiagnostic?.reason) { + updateGraphPersistenceState({ + persistMismatchReason: commitMarkerDiagnostic.reason, + }); + } + return shadowResult; + } } const shouldAllowOverride = @@ -4719,7 +4840,7 @@ async function loadGraphFromChatState( }; } - return applyIndexedDbSnapshotToRuntime(normalizedChatId, snapshot, { + const loadResult = applyIndexedDbSnapshotToRuntime(normalizedChatId, snapshot, { source, attemptIndex, storagePrimary: "chat-state", @@ -4727,6 +4848,12 @@ async function loadGraphFromChatState( statusLabel: "聊天侧车", reasonPrefix: "chat-state", }); + if (commitMarkerDiagnostic?.reason && loadResult?.loaded) { + updateGraphPersistenceState({ + persistMismatchReason: commitMarkerDiagnostic.reason, + }); + } + return loadResult; } function scheduleGraphChatStateProbe(chatId, options = {}) { @@ -5610,8 +5737,15 @@ async function loadGraphFromIndexedDb( snapshot, commitMarker, ); + let commitMarkerDiagnostic = null; if (!isIndexedDbSnapshotMeaningful(snapshot)) { if (commitMarkerMismatch.mismatched) { + commitMarkerDiagnostic = recordPersistMismatchDiagnostic( + commitMarkerMismatch, + { + source: `${source}:indexeddb-empty`, + }, + ); if ( shadowSnapshot && Number(shadowSnapshot.revision || 0) >= @@ -5626,17 +5760,12 @@ async function loadGraphFromIndexedDb( }, ); if (shadowRestoreResult?.loaded) { + updateGraphPersistenceState({ + persistMismatchReason: commitMarkerDiagnostic.reason, + }); return shadowRestoreResult; } } - return applyPersistMismatchBlockedState( - normalizedChatId, - commitMarkerMismatch, - { - source: `${source}:indexeddb-empty`, - attemptIndex, - }, - ); } if (shadowSnapshot) { const shadowRestoreResult = applyShadowSnapshotToRuntime( @@ -5651,7 +5780,11 @@ async function loadGraphFromIndexedDb( return shadowRestoreResult; } } - if (applyEmptyState && getCurrentChatId() === normalizedChatId) { + if ( + applyEmptyState && + !commitMarkerDiagnostic?.reason && + getCurrentChatId() === normalizedChatId + ) { return applyIndexedDbEmptyToRuntime(normalizedChatId, { source, attemptIndex, @@ -5660,7 +5793,7 @@ async function loadGraphFromIndexedDb( return { success: false, loaded: false, - reason: "indexeddb-empty", + reason: commitMarkerDiagnostic?.reason || "indexeddb-empty", chatId: normalizedChatId, attemptIndex, }; @@ -5703,12 +5836,21 @@ async function loadGraphFromIndexedDb( ); } if (commitMarkerMismatch.mismatched) { + commitMarkerDiagnostic = recordPersistMismatchDiagnostic( + { + ...commitMarkerMismatch, + marker: commitMarkerMismatch.marker || commitMarker, + }, + { + source: `${source}:indexeddb-commit-marker`, + }, + ); if ( shadowSnapshot && Number(shadowSnapshot.revision || 0) >= Number(commitMarkerMismatch.markerRevision || 0) ) { - return applyShadowSnapshotToRuntime( + const shadowResult = applyShadowSnapshotToRuntime( normalizedChatId, shadowSnapshot, { @@ -5716,18 +5858,13 @@ async function loadGraphFromIndexedDb( attemptIndex, }, ); + if (shadowResult?.loaded && commitMarkerDiagnostic?.reason) { + updateGraphPersistenceState({ + persistMismatchReason: commitMarkerDiagnostic.reason, + }); + } + return shadowResult; } - return applyPersistMismatchBlockedState( - normalizedChatId, - { - ...commitMarkerMismatch, - marker: commitMarkerMismatch.marker || commitMarker, - }, - { - source: `${source}:indexeddb-commit-marker`, - attemptIndex, - }, - ); } const shouldAllowOverride = allowOverride || @@ -5760,10 +5897,16 @@ async function loadGraphFromIndexedDb( }; } - return applyIndexedDbSnapshotToRuntime(normalizedChatId, snapshot, { + const loadResult = applyIndexedDbSnapshotToRuntime(normalizedChatId, snapshot, { source, attemptIndex, }); + if (commitMarkerDiagnostic?.reason && loadResult?.loaded) { + updateGraphPersistenceState({ + persistMismatchReason: commitMarkerDiagnostic.reason, + }); + } + return loadResult; } catch (error) { console.warn("[ST-BME] IndexedDB 读取失败,回退 metadata:", error); updateGraphPersistenceState({ @@ -6091,6 +6234,17 @@ function maybeResumePendingAutoExtraction(source = "auto-extraction-resume") { }; } + if (isRestoreLockActive()) { + return { + resumed: false, + reason: "restore-lock-active", + restoreLock: cloneRuntimeDebugValue( + normalizeRestoreLockState(graphPersistenceState.restoreLock), + null, + ), + }; + } + const currentChatId = normalizeChatIdCandidate(getCurrentChatId()); if (!currentChatId || currentChatId !== pendingChatId) { clearPendingAutoExtraction(); @@ -6325,6 +6479,7 @@ function buildGraphPersistResult({ queued = false, blocked = false, accepted = false, + recoverable = false, storageTier = "none", reason = "", loadState = graphPersistenceState.loadState, @@ -6336,6 +6491,7 @@ function buildGraphPersistResult({ queued, blocked, accepted, + recoverable, storageTier: String(storageTier || "none"), reason: String(reason || ""), loadState, @@ -6431,6 +6587,7 @@ function buildBatchPersistenceRecordFromPersistResult(persistResult = null) { const accepted = persistResult?.accepted === true; const queued = persistResult?.queued === true; const blocked = persistResult?.blocked === true; + const recoverable = persistResult?.recoverable === true; let outcome = "failed"; if (accepted && String(persistResult?.storageTier || "") === "indexeddb") { @@ -6439,6 +6596,8 @@ function buildBatchPersistenceRecordFromPersistResult(persistResult = null) { outcome = "fallback"; } else if (queued) { outcome = "queued"; + } else if (recoverable) { + outcome = "recoverable"; } else if (blocked) { outcome = "blocked"; } @@ -6446,6 +6605,7 @@ function buildBatchPersistenceRecordFromPersistResult(persistResult = null) { return { outcome, accepted, + recoverable, storageTier: String(persistResult?.storageTier || "none"), reason: String(persistResult?.reason || ""), revision: Number.isFinite(Number(persistResult?.revision)) @@ -6589,6 +6749,11 @@ function applyAcceptedPendingPersistState( } if (persistenceRecord.accepted === true) { + updateGraphPersistenceState({ + acceptedStorageTier: String(persistenceRecord.storageTier || "none"), + lastRecoverableStorageTier: "none", + pendingPersist: false, + }); const safeFloor = Number.isFinite(Number(lastProcessedAssistantFloor)) ? Math.floor(Number(lastProcessedAssistantFloor)) : null; @@ -6616,6 +6781,9 @@ function schedulePendingGraphPersistRetry( reason = "pending-graph-persist-retry", attempt = 0, ) { + if (isRestoreLockActive()) { + return false; + } if (!graphPersistenceState.pendingPersist) { clearPendingGraphPersistRetry(); return false; @@ -6671,6 +6839,8 @@ function persistGraphToChatMetadata( return buildGraphPersistResult({ saved: false, blocked: true, + accepted: false, + recoverable: false, reason: "missing-context-or-graph", revision, }); @@ -6681,6 +6851,8 @@ function persistGraphToChatMetadata( return buildGraphPersistResult({ saved: false, blocked: true, + accepted: false, + recoverable: false, reason: "missing-chat-id", revision, }); @@ -6694,54 +6866,35 @@ function persistGraphToChatMetadata( chatId, integrity: nextIntegrity, }); - if (graph === currentGraph) { - stampGraphPersistenceMeta(currentGraph, { - revision, - reason, - chatId, - integrity: nextIntegrity, - }); - } + writeChatMetadataPatch(context, { [GRAPH_METADATA_KEY]: persistedGraph, }); const saveMode = triggerChatMetadataSave(context, { immediate }); - applyGraphLoadState(graphPersistenceState.loadState, { - chatId, - reason: graphPersistenceState.reason, - attemptIndex: graphPersistenceState.attemptIndex, - shadowSnapshotUsed: false, - shadowSnapshotRevision: 0, - shadowSnapshotUpdatedAt: "", - shadowSnapshotReason: "", - revision, - lastPersistedRevision: revision, - queuedPersistRevision: 0, - queuedPersistChatId: "", - pendingPersist: false, - writesBlocked: false, - }); - clearPendingGraphPersistRetry(); - removeGraphShadowSnapshot(chatId); updateGraphPersistenceState({ lastPersistReason: String(reason || ""), - lastPersistMode: saveMode, - metadataIntegrity: String(nextIntegrity || ""), - persistMismatchReason: "", - storagePrimary: "metadata", - storageMode: "metadata", - indexedDbLastError: "", - queuedPersistChatId: "", - queuedPersistMode: "", - queuedPersistRotateIntegrity: false, - queuedPersistReason: "", + lastPersistMode: `metadata-full:${saveMode}`, + metadataIntegrity: String(nextIntegrity || graphPersistenceState.metadataIntegrity || ""), + indexedDbLastError: graphPersistenceState.indexedDbLastError || "", + lastRecoverableStorageTier: "metadata-full", + dualWriteLastResult: { + action: "save", + target: "metadata", + success: true, + recoverable: true, + chatId, + revision: normalizeIndexedDbRevision(revision), + reason: String(reason || "graph-persist"), + at: Date.now(), + }, }); rememberResolvedGraphIdentityAlias(context, chatId); return buildGraphPersistResult({ saved: true, - accepted: true, + accepted: false, + recoverable: true, reason, loadState: graphPersistenceState.loadState, revision, @@ -6753,19 +6906,39 @@ function persistGraphToChatMetadata( function queueGraphPersist( reason = "graph-persist-blocked", revision = graphPersistenceState.revision, - { immediate = true, graph = currentGraph, chatId = undefined } = {}, + { + immediate = true, + graph = currentGraph, + chatId = undefined, + captureShadow = true, + recoverableTier = "none", + } = {}, ) { const queuedChatId = String(chatId || graphPersistenceState.chatId || getCurrentChatId()) || ""; - const shadowCaptured = maybeCaptureGraphShadowSnapshot(reason, { - graph, - chatId: queuedChatId, - revision, - }); + const normalizedRevision = Math.max( + 1, + allocateRequestedPersistRevision(revision, graph), + ); + let effectiveRecoverableTier = isRecoveryOnlyPersistTier(recoverableTier) + ? String(recoverableTier) + : "none"; + + if (captureShadow) { + const shadowCaptured = maybeCaptureGraphShadowSnapshot(reason, { + graph, + chatId: queuedChatId, + revision: normalizedRevision, + }); + if (shadowCaptured && effectiveRecoverableTier === "none") { + effectiveRecoverableTier = "shadow"; + } + } + updateGraphPersistenceState({ queuedPersistRevision: Math.max( - graphPersistenceState.queuedPersistRevision || 0, - revision || 0, + normalizeIndexedDbRevision(graphPersistenceState.queuedPersistRevision), + normalizedRevision, ), queuedPersistChatId: String(queuedChatId || ""), queuedPersistMode: immediate ? "immediate" : "debounced", @@ -6774,18 +6947,23 @@ function queueGraphPersist( pendingPersist: true, writesBlocked: true, lastPersistReason: String(reason || ""), + lastPersistMode: immediate ? "pending-immediate" : "pending-debounced", + lastRecoverableStorageTier: isRecoveryOnlyPersistTier(effectiveRecoverableTier) + ? effectiveRecoverableTier + : graphPersistenceState.lastRecoverableStorageTier, }); schedulePendingGraphPersistRetry(String(reason || "graph-persist-blocked"), 0); return buildGraphPersistResult({ queued: true, blocked: true, - accepted: shadowCaptured, + accepted: false, + recoverable: isRecoveryOnlyPersistTier(effectiveRecoverableTier), reason, loadState: graphPersistenceState.loadState, - revision, + revision: normalizedRevision, saveMode: immediate ? "immediate" : "debounced", - storageTier: shadowCaptured ? "shadow" : "none", + storageTier: effectiveRecoverableTier !== "none" ? effectiveRecoverableTier : "none", }); } @@ -6846,9 +7024,22 @@ async function retryPendingGraphPersist({ reason = "pending-graph-persist-retry", retryAttempt = 0, scheduleRetryOnFailure = false, + ignoreRestoreLock = false, } = {}) { ensureCurrentGraphRuntimeState(); + if (!ignoreRestoreLock && isRestoreLockActive()) { + return buildGraphPersistResult({ + saved: false, + blocked: true, + accepted: false, + reason: "restore-lock-active", + revision: graphPersistenceState.revision, + saveMode: graphPersistenceState.lastPersistMode, + storageTier: "none", + }); + } + if (!graphPersistenceState.pendingPersist) { clearPendingGraphPersistRetry(); return buildGraphPersistResult({ @@ -6937,49 +7128,34 @@ async function retryPendingGraphPersist({ reason, }); if (indexedDbResult?.saved) { - const chatStateMirrorResult = canUseHostGraphChatStatePersistence(context) - ? await persistGraphToHostChatState(context, { - graph: pendingPersistGraph, - revision: targetRevision, - reason: `${reason}:chat-state-mirror`, - storageTier: "chat-state", - accepted: true, - lastProcessedAssistantFloor, - extractionCount, - mode: "mirror", - }) - : null; + if (canUseHostGraphChatStatePersistence(context)) { + await persistGraphToHostChatState(context, { + graph: pendingPersistGraph, + revision: indexedDbResult.revision || targetRevision, + reason: `${reason}:chat-state-mirror`, + storageTier: "chat-state", + accepted: true, + lastProcessedAssistantFloor, + extractionCount, + mode: "mirror", + }); + } clearPendingGraphPersistRetry(); persistGraphCommitMarker(context, { reason, - revision: targetRevision, + revision: indexedDbResult.revision || targetRevision, storageTier: "indexeddb", accepted: true, lastProcessedAssistantFloor, extractionCount, immediate: true, }); - updateGraphPersistenceState({ - pendingPersist: false, - persistMismatchReason: "", - lastAcceptedRevision: Math.max( - Number(graphPersistenceState.lastAcceptedRevision || 0), - targetRevision, - ), - lastPersistReason: String(reason || ""), - lastPersistMode: "indexeddb", - queuedPersistRevision: 0, - queuedPersistChatId: "", - queuedPersistMode: "", - queuedPersistRotateIntegrity: false, - queuedPersistReason: "", - }); const persistResult = buildGraphPersistResult({ saved: true, accepted: true, reason, - revision: targetRevision, - saveMode: "indexeddb", + revision: indexedDbResult.revision || targetRevision, + saveMode: String(indexedDbResult.saveMode || "indexeddb-delta"), storageTier: "indexeddb", }); applyAcceptedPendingPersistState(persistResult, { @@ -7005,7 +7181,7 @@ async function retryPendingGraphPersist({ clearPendingGraphPersistRetry(); persistGraphCommitMarker(context, { reason: `${reason}:chat-state-fallback`, - revision: targetRevision, + revision: chatStateResult.revision || targetRevision, storageTier: "chat-state", accepted: true, lastProcessedAssistantFloor, @@ -7013,14 +7189,20 @@ async function retryPendingGraphPersist({ immediate: true, }); updateGraphPersistenceState({ + revision: Math.max( + Number(graphPersistenceState.revision || 0), + Number(chatStateResult.revision || targetRevision), + ), pendingPersist: false, persistMismatchReason: "", lastAcceptedRevision: Math.max( Number(graphPersistenceState.lastAcceptedRevision || 0), - targetRevision, + Number(chatStateResult.revision || targetRevision), ), + acceptedStorageTier: "chat-state", + lastRecoverableStorageTier: "none", lastPersistReason: `${reason}:chat-state-fallback`, - lastPersistMode: "chat-state", + lastPersistMode: String(chatStateResult.saveMode || "chat-state"), queuedPersistRevision: 0, queuedPersistChatId: "", queuedPersistMode: "", @@ -7033,8 +7215,8 @@ async function retryPendingGraphPersist({ saved: true, accepted: true, reason: `${reason}:chat-state-fallback`, - revision: targetRevision, - saveMode: "chat-state", + revision: Number(chatStateResult.revision || targetRevision), + saveMode: String(chatStateResult.saveMode || "chat-state"), storageTier: "chat-state", }); applyAcceptedPendingPersistState(persistResult, { @@ -7042,7 +7224,7 @@ async function retryPendingGraphPersist({ persistedGraph: pendingPersistGraph, }); queueGraphPersistToIndexedDb(activeChatId, pendingPersistGraph, { - revision: targetRevision, + revision: Number(chatStateResult.revision || targetRevision), reason: `${reason}:chat-state-fallback:promote-indexeddb`, }); void maybeResumePendingAutoExtraction("pending-persist-resolved:chat-state"); @@ -7050,6 +7232,7 @@ async function retryPendingGraphPersist({ } } + let recoverableTier = "none"; if (canPersistGraphToMetadataFallback(context, pendingPersistGraph)) { const metadataReason = `${reason}:metadata-full-fallback`; const metadataResult = persistGraphToChatMetadata(context, { @@ -7059,49 +7242,36 @@ async function retryPendingGraphPersist({ graph: pendingPersistGraph, }); if (metadataResult?.saved) { - clearPendingGraphPersistRetry(); - persistGraphCommitMarker(context, { - reason: metadataReason, - revision: targetRevision, - storageTier: "metadata-full", - accepted: true, - lastProcessedAssistantFloor, - extractionCount, - immediate: true, - }); - updateGraphPersistenceState({ - pendingPersist: false, - persistMismatchReason: "", - lastAcceptedRevision: Math.max( - Number(graphPersistenceState.lastAcceptedRevision || 0), - targetRevision, - ), - lastPersistReason: metadataReason, - lastPersistMode: String(metadataResult.saveMode || "metadata"), - queuedPersistRevision: 0, - queuedPersistChatId: "", - queuedPersistMode: "", - queuedPersistRotateIntegrity: false, - queuedPersistReason: "", - }); - const persistResult = buildGraphPersistResult({ - saved: true, - accepted: true, - reason: metadataReason, - revision: targetRevision, - saveMode: metadataResult.saveMode, - storageTier: "metadata-full", - }); - applyAcceptedPendingPersistState(persistResult, { - lastProcessedAssistantFloor, - persistedGraph: pendingPersistGraph, - }); - void maybeResumePendingAutoExtraction("pending-persist-resolved:metadata"); - return persistResult; + recoverableTier = "metadata-full"; } } - if (scheduleRetryOnFailure) { + if ( + recoverableTier === "none" && + maybeCaptureGraphShadowSnapshot(`${reason}:shadow-fallback`, { + graph: pendingPersistGraph, + chatId: activeChatId, + revision: targetRevision, + }) + ) { + recoverableTier = "shadow"; + } + + const queuedReason = `${reason}:still-pending`; + const queuedResult = queueGraphPersist(queuedReason, targetRevision, { + immediate: graphPersistenceState.queuedPersistMode !== "debounced", + graph: pendingPersistGraph, + chatId: activeChatId, + captureShadow: recoverableTier === "none", + recoverableTier, + }); + if (recoverableTier !== "none") { + updateGraphPersistenceState({ + lastPersistReason: queuedReason, + lastRecoverableStorageTier: recoverableTier, + }); + } + if (scheduleRetryOnFailure && recoverableTier === "none") { schedulePendingGraphPersistRetry(reason, Number(retryAttempt) + 1); } return buildGraphPersistResult({ @@ -7109,10 +7279,17 @@ async function retryPendingGraphPersist({ queued: true, blocked: true, accepted: false, - reason: `${reason}:still-pending`, - revision: targetRevision, - saveMode: graphPersistenceState.queuedPersistMode || "immediate", - storageTier: "none", + recoverable: + recoverableTier !== "none" || queuedResult?.recoverable === true, + reason: queuedReason, + revision: Number(queuedResult?.revision || targetRevision), + saveMode: String( + queuedResult?.saveMode || graphPersistenceState.queuedPersistMode || "immediate", + ), + storageTier: + recoverableTier !== "none" + ? recoverableTier + : String(queuedResult?.storageTier || "none"), }); } @@ -7148,55 +7325,40 @@ async function persistExtractionBatchResult({ }); } - const revision = bumpGraphRevision(reason); + const revision = allocateRequestedPersistRevision(0, persistGraph); const indexedDbResult = await saveGraphToIndexedDb(chatId, persistGraph, { revision, reason, }); if (indexedDbResult?.saved) { - const chatStateMirrorResult = canUseHostGraphChatStatePersistence(context) - ? await persistGraphToHostChatState(context, { - graph: persistGraph, - revision, - reason: `${reason}:chat-state-mirror`, - storageTier: "chat-state", - accepted: true, - lastProcessedAssistantFloor, - extractionCount, - mode: "mirror", - }) - : null; + if (canUseHostGraphChatStatePersistence(context)) { + await persistGraphToHostChatState(context, { + graph: persistGraph, + revision: indexedDbResult.revision || revision, + reason: `${reason}:chat-state-mirror`, + storageTier: "chat-state", + accepted: true, + lastProcessedAssistantFloor, + extractionCount, + mode: "mirror", + }); + } persistGraphCommitMarker(context, { reason, - revision, + revision: indexedDbResult.revision || revision, storageTier: "indexeddb", accepted: true, lastProcessedAssistantFloor, extractionCount, immediate: true, }); - updateGraphPersistenceState({ - pendingPersist: false, - persistMismatchReason: "", - lastAcceptedRevision: Math.max( - Number(graphPersistenceState.lastAcceptedRevision || 0), - revision, - ), - lastPersistReason: String(reason || ""), - lastPersistMode: "indexeddb", - queuedPersistRevision: 0, - queuedPersistChatId: "", - queuedPersistMode: "", - queuedPersistRotateIntegrity: false, - queuedPersistReason: "", - }); clearPendingGraphPersistRetry(); return buildGraphPersistResult({ saved: true, accepted: true, reason, - revision, - saveMode: "indexeddb", + revision: indexedDbResult.revision || revision, + saveMode: String(indexedDbResult.saveMode || "indexeddb-delta"), storageTier: "indexeddb", }); } @@ -7215,7 +7377,7 @@ async function persistExtractionBatchResult({ if (chatStateResult?.saved) { persistGraphCommitMarker(context, { reason: `${reason}:chat-state-fallback`, - revision, + revision: chatStateResult.revision || revision, storageTier: "chat-state", accepted: true, lastProcessedAssistantFloor, @@ -7223,14 +7385,20 @@ async function persistExtractionBatchResult({ immediate: true, }); updateGraphPersistenceState({ + revision: Math.max( + Number(graphPersistenceState.revision || 0), + Number(chatStateResult.revision || revision), + ), pendingPersist: false, persistMismatchReason: "", lastAcceptedRevision: Math.max( Number(graphPersistenceState.lastAcceptedRevision || 0), - revision, + Number(chatStateResult.revision || revision), ), + acceptedStorageTier: "chat-state", + lastRecoverableStorageTier: "none", lastPersistReason: `${reason}:chat-state-fallback`, - lastPersistMode: "chat-state", + lastPersistMode: String(chatStateResult.saveMode || "chat-state"), queuedPersistRevision: 0, queuedPersistChatId: "", queuedPersistMode: "", @@ -7241,66 +7409,29 @@ async function persistExtractionBatchResult({ }); clearPendingGraphPersistRetry(); queueGraphPersistToIndexedDb(chatId, persistGraph, { - revision, + revision: Number(chatStateResult.revision || revision), reason: `${reason}:chat-state-fallback:promote-indexeddb`, }); return buildGraphPersistResult({ saved: true, accepted: true, reason: `${reason}:chat-state-fallback`, - revision, - saveMode: "chat-state", + revision: Number(chatStateResult.revision || revision), + saveMode: String(chatStateResult.saveMode || "chat-state"), storageTier: "chat-state", }); } } - const shadowReason = `${reason}:shadow-fallback`; - const shadowCaptured = maybeCaptureGraphShadowSnapshot(shadowReason, { - graph: persistGraph, - chatId, - revision, - }); - if (shadowCaptured) { - if (isGraphMetadataWriteAllowed()) { - persistGraphCommitMarker(context, { - reason: shadowReason, - revision, - storageTier: "shadow", - accepted: true, - lastProcessedAssistantFloor, - extractionCount, - immediate: true, - }); - } - updateGraphPersistenceState({ - pendingPersist: false, - persistMismatchReason: "", - lastAcceptedRevision: Math.max( - Number(graphPersistenceState.lastAcceptedRevision || 0), - revision, - ), - lastPersistReason: shadowReason, - lastPersistMode: "shadow", - queuedPersistRevision: 0, - queuedPersistChatId: "", - queuedPersistMode: "", - queuedPersistRotateIntegrity: false, - queuedPersistReason: "", - }); - clearPendingGraphPersistRetry(); - queueGraphPersistToIndexedDb(chatId, persistGraph, { + let recoverableTier = "none"; + if ( + maybeCaptureGraphShadowSnapshot(`${reason}:shadow-fallback`, { + graph: persistGraph, + chatId, revision, - reason: `${shadowReason}:promote-indexeddb`, - }); - return buildGraphPersistResult({ - saved: false, - accepted: true, - reason: shadowReason, - revision, - saveMode: "shadow", - storageTier: "shadow", - }); + }) + ) { + recoverableTier = "shadow"; } if (canPersistGraphToMetadataFallback(context, persistGraph)) { @@ -7312,41 +7443,7 @@ async function persistExtractionBatchResult({ graph: persistGraph, }); if (metadataResult?.saved) { - persistGraphCommitMarker(context, { - reason: metadataReason, - revision, - storageTier: "metadata-full", - accepted: true, - lastProcessedAssistantFloor, - extractionCount, - immediate: true, - }); - updateGraphPersistenceState({ - pendingPersist: false, - persistMismatchReason: "", - lastAcceptedRevision: Math.max( - Number(graphPersistenceState.lastAcceptedRevision || 0), - revision, - ), - queuedPersistRevision: 0, - queuedPersistChatId: "", - queuedPersistMode: "", - queuedPersistRotateIntegrity: false, - queuedPersistReason: "", - }); - clearPendingGraphPersistRetry(); - queueGraphPersistToIndexedDb(chatId, persistGraph, { - revision, - reason: `${metadataReason}:promote-indexeddb`, - }); - return buildGraphPersistResult({ - saved: true, - accepted: true, - reason: metadataReason, - revision, - saveMode: metadataResult.saveMode, - storageTier: "metadata-full", - }); + recoverableTier = "metadata-full"; } } @@ -7354,21 +7451,32 @@ async function persistExtractionBatchResult({ immediate: true, graph: persistGraph, chatId, + captureShadow: recoverableTier === "none", + recoverableTier, }); updateGraphPersistenceState({ pendingPersist: true, lastPersistReason: String(queuedResult.reason || `${reason}:pending`), lastPersistMode: String(queuedResult.saveMode || ""), + lastRecoverableStorageTier: + recoverableTier !== "none" + ? recoverableTier + : String(queuedResult.storageTier || graphPersistenceState.lastRecoverableStorageTier || "none"), }); return buildGraphPersistResult({ saved: false, queued: Boolean(queuedResult?.queued), blocked: Boolean(queuedResult?.blocked), accepted: false, + recoverable: + recoverableTier !== "none" || queuedResult?.recoverable === true, reason: String(queuedResult?.reason || `${reason}:pending`), - revision, + revision: Number(queuedResult?.revision || revision), saveMode: String(queuedResult?.saveMode || ""), - storageTier: String(queuedResult?.storageTier || "none"), + storageTier: + recoverableTier !== "none" + ? recoverableTier + : String(queuedResult?.storageTier || "none"), }); } @@ -7583,7 +7691,7 @@ function clearInjectionState(options = {}) { try { applyModuleInjectionPrompt("", getSettings()); } catch (error) { - console.warn("[ST-BME] 清理旧注入失败:", error); + console.warn("[ST-BME] 清理旧注入失?", error); } refreshPanelLiveState(); @@ -7607,7 +7715,7 @@ function notifyStatusToast(key, kind, message, title = "ST-BME") { function setRuntimeStatus(text, meta, level = "info") { runtimeStatus = createUiStatus(text, meta, level); refreshPanelLiveState(); - // 同步悬浮球状态 + // 同步悬浮球状? const fabStatus = level === "info" ? "idle" : level; _panelModule?.updateFloatingBallStatus?.(fabStatus, text || "BME 记忆图谱"); } @@ -7968,7 +8076,7 @@ function buildMaintenanceSummary(action, result, mode = "manual") { const prefix = mode === "auto" ? "自动" : "手动"; switch (String(action || "")) { case "compress": - return `${prefix}压缩:新建 ${result?.created || 0},归档 ${result?.archived || 0}`; + return `${prefix}压缩:新增 ${result?.created || 0},归档 ${result?.archived || 0}`; case "consolidate": return `${prefix}整合:合并 ${result?.merged || 0},跳过 ${result?.skipped || 0},保留 ${result?.kept || 0},进化 ${result?.evolved || 0},新链接 ${result?.connections || 0},回溯更新 ${result?.updates || 0}`; case "sleep": @@ -8648,26 +8756,31 @@ function loadGraphFromChat(options = {}) { }; } + let metadataMismatchDiagnostic = null; if (metadataCommitMismatch.mismatched) { clearPendingGraphLoadRetry(); + metadataMismatchDiagnostic = recordPersistMismatchDiagnostic( + metadataCommitMismatch, + { + source: `${source}:metadata-compat`, + }, + ); if ( shadowSnapshot && Number(shadowSnapshot.revision || 0) >= Number(metadataCommitMismatch.markerRevision || 0) ) { - return applyShadowSnapshotToRuntime(chatId, shadowSnapshot, { + const shadowResult = applyShadowSnapshotToRuntime(chatId, shadowSnapshot, { source: `${source}:metadata-shadow`, attemptIndex, }); + if (shadowResult?.loaded && metadataMismatchDiagnostic?.reason) { + updateGraphPersistenceState({ + persistMismatchReason: metadataMismatchDiagnostic.reason, + }); + } + return shadowResult; } - return applyPersistMismatchBlockedState( - chatId, - metadataCommitMismatch, - { - source: `${source}:metadata-compat`, - attemptIndex, - }, - ); } if (shadowSnapshot && shadowDecision?.reason) { @@ -8757,6 +8870,10 @@ function loadGraphFromChat(options = {}) { storageMode: "indexeddb", dbReady: false, indexedDbLastError: "", + persistMismatchReason: + metadataMismatchDiagnostic?.reason || + graphPersistenceState.persistMismatchReason || + "", dualWriteLastResult: { action: "load", source: `${source}:metadata-compat`, @@ -8797,13 +8914,13 @@ function loadGraphFromChat(options = {}) { if (shadowSnapshot) { const acceptedCommitRevision = getAcceptedCommitMarkerRevision(commitMarker); + let shadowOnlyMismatch = null; if ( acceptedCommitRevision > 0 && Number(shadowSnapshot.revision || 0) < acceptedCommitRevision ) { clearPendingGraphLoadRetry(); - return applyPersistMismatchBlockedState( - chatId, + shadowOnlyMismatch = recordPersistMismatchDiagnostic( { mismatched: true, reason: "persist-mismatch:indexeddb-behind-commit-marker", @@ -8813,15 +8930,21 @@ function loadGraphFromChat(options = {}) { }, { source: `${source}:shadow-no-official`, - attemptIndex, + resolvedBy: "shadow", }, ); } clearPendingGraphLoadRetry(); - return applyShadowSnapshotToRuntime(chatId, shadowSnapshot, { + const shadowResult = applyShadowSnapshotToRuntime(chatId, shadowSnapshot, { source: `${source}:shadow-no-official`, attemptIndex, }); + if (shadowOnlyMismatch?.reason && shadowResult?.loaded) { + updateGraphPersistenceState({ + persistMismatchReason: shadowOnlyMismatch.reason, + }); + } + return shadowResult; } applyGraphLoadState(GRAPH_LOAD_STATES.LOADING, { @@ -8885,9 +9008,10 @@ async function saveGraphToIndexedDb( const baseSnapshot = readCachedIndexedDbSnapshot(normalizedChatId) || (await db.exportSnapshot()); + const requestedRevision = resolvePersistRevisionFloor(revision, graph); const snapshot = buildSnapshotFromGraph(graph, { chatId: normalizedChatId, - revision, + revision: requestedRevision, baseSnapshot, lastModified: Date.now(), meta: { @@ -8898,27 +9022,35 @@ async function saveGraphToIndexedDb( hostChatId: currentIdentity.hostChatId || "", }, }); - const importResult = await db.importSnapshot(snapshot, { - mode: "replace", - preserveRevision: true, - revision, + const delta = buildPersistDelta(baseSnapshot, snapshot); + const commitResult = await db.commitDelta(delta, { + reason, + requestedRevision, markSyncDirty: true, }); - let syncDirtyWarning = ""; + let scheduleUploadWarning = ""; - try { - await db.markSyncDirty(reason); - } catch (error) { - syncDirtyWarning = - error?.message || String(error) || "mark-sync-dirty-failed"; - console.warn("[ST-BME] IndexedDB 已写入,但补记 syncDirty 失败:", error); + snapshot.meta.revision = normalizeIndexedDbRevision( + commitResult?.revision, + requestedRevision, + ); + snapshot.meta.lastModified = Number(commitResult?.lastModified || Date.now()); + snapshot.meta.lastMutationReason = String(reason || "graph-save"); + snapshot.meta.storagePrimary = "indexeddb"; + cacheIndexedDbSnapshot(normalizedChatId, snapshot); + + if (graph === currentGraph) { + stampGraphPersistenceMeta(currentGraph, { + revision: snapshot.meta.revision, + reason: String(reason || "graph-save"), + chatId: normalizedChatId, + integrity: + currentIdentity.integrity || + getChatMetadataIntegrity(getContext()) || + graphPersistenceState.metadataIntegrity, + }); } - snapshot.meta.revision = normalizeIndexedDbRevision( - importResult?.revision, - revision, - ); - cacheIndexedDbSnapshot(normalizedChatId, snapshot); try { scheduleUpload( normalizedChatId, @@ -8933,6 +9065,7 @@ async function saveGraphToIndexedDb( } updateGraphPersistenceState({ + revision: snapshot.meta.revision, storagePrimary: "indexeddb", storageMode: "indexeddb", dbReady: true, @@ -8946,9 +9079,20 @@ async function saveGraphToIndexedDb( indexedDbRevision: snapshot.meta.revision, metadataIntegrity: getChatMetadataIntegrity(getContext()) || + currentIdentity.integrity || graphPersistenceState.metadataIntegrity, indexedDbLastError: "", lastSyncError: scheduleUploadWarning, + syncDirty: true, + syncDirtyReason: String(reason || "graph-save"), + lastPersistReason: String(reason || "graph-save"), + lastPersistMode: "indexeddb-delta", + lastAcceptedRevision: Math.max( + Number(graphPersistenceState.lastAcceptedRevision || 0), + snapshot.meta.revision, + ), + acceptedStorageTier: "indexeddb", + lastRecoverableStorageTier: "none", dualWriteLastResult: { action: "save", target: "indexeddb", @@ -8956,9 +9100,8 @@ async function saveGraphToIndexedDb( chatId: normalizedChatId, revision: snapshot.meta.revision, reason: String(reason || "graph-save"), - warning: - [syncDirtyWarning, scheduleUploadWarning].filter(Boolean).join(" | ") || - "", + warning: scheduleUploadWarning || "", + delta: cloneRuntimeDebugValue(commitResult?.delta, null), at: Date.now(), }, }); @@ -9001,11 +9144,13 @@ async function saveGraphToIndexedDb( chatId: normalizedChatId, revision: snapshot.meta.revision, reason: String(reason || "graph-save"), - warning: - [syncDirtyWarning, scheduleUploadWarning].filter(Boolean).join(" | ") || "", + saveMode: "indexeddb-delta", + warning: scheduleUploadWarning || "", + delta: cloneRuntimeDebugValue(commitResult?.delta, null), + snapshot, }; } catch (error) { - console.warn("[ST-BME] IndexedDB 写入失败,保留 metadata 兜底:", error); + console.warn("[ST-BME] IndexedDB 写入失败,保鐣?metadata 兜底:", error); updateGraphPersistenceState({ indexedDbLastError: error?.message || String(error), dualWriteLastResult: { @@ -9113,8 +9258,8 @@ function saveGraphToChat(options = {}) { } const revision = markMutation - ? bumpGraphRevision(reason) - : graphPersistenceState.revision || 0; + ? allocateRequestedPersistRevision(0, currentGraph) + : resolvePersistRevisionFloor(0, currentGraph); if (captureShadow) { maybeCaptureGraphShadowSnapshot(reason); @@ -9204,8 +9349,7 @@ function saveGraphToChat(options = {}) { immediate, }); updateGraphPersistenceState({ - storagePrimary: "metadata", - storageMode: "metadata", + storageMode: "metadata-full", dualWriteLastResult: { action: "save", target: "metadata", @@ -9319,7 +9463,7 @@ function buildGenerationAfterCommandsRecallInput(type, params = {}, chat) { generationType, }); - // 对于 history 类型(continue/regenerate/swipe),必须有 chat 中的用户消息 + // 对于 history 类型(continue/regenerate/swipe),必须?chat 中的用户消息 if (generationType !== "normal") { if (!Number.isFinite(targetUserMessageIndex)) { return { @@ -9341,10 +9485,10 @@ function buildGenerationAfterCommandsRecallInput(type, params = {}, chat) { }; } - // 对于 normal 类型:GENERATION_AFTER_COMMANDS 触发时用户消息可能不在 chat 末尾 - // (ST 可能已追加空 assistant 消息)。如果 chat 中存在任何用户消息, - // 继续走 buildNormalGenerationRecallInput,它会通过 latestUserText 兜底找到。 - // 如果 chat 中完全没有用户消息,则延迟到 BEFORE_COMBINE_PROMPTS 处理。 + // 对于 normal 类型:GENERATION_AFTER_COMMANDS 触发时用户消息可能不?chat 末尾 + // (ST 可能已追加空 assistant 消息)。如?chat 中存在任何用户消息, + // 继续?buildNormalGenerationRecallInput,它会通过 latestUserText 兜底找到? + // 如果 chat 中完全没有用户消息,则延迟到 BEFORE_COMBINE_PROMPTS 处理? if (!Number.isFinite(targetUserMessageIndex) && !getLatestUserChatMessage(chat)) { return { generationType, @@ -9370,9 +9514,9 @@ function buildNormalGenerationRecallInput(chat, options = {}) { const tailUserText = lastNonSystemMessage?.is_user ? normalizeRecallInputText(lastNonSystemMessage?.mes || "") : ""; - // 当 GENERATION_AFTER_COMMANDS 触发时,ST 可能已追加了空 assistant 消息, + // ?GENERATION_AFTER_COMMANDS 触发时,ST 可能已追加了?assistant 消息? // 导致 lastNonSystemMessage 不是 user。用 getLatestUserChatMessage 反向扫描 - // 定位真正的用户消息(与 shujuku 参考实现一致)。 + // 定位真正的用户消息(?shujuku 参考实现一致)? const latestUserMessage = !tailUserText ? getLatestUserChatMessage(chat) : null; const latestUserText = latestUserMessage ? normalizeRecallInputText(latestUserMessage?.mes || "") @@ -9707,10 +9851,10 @@ function resolveGenerationRecallDeliveryMode( return "immediate"; } - // GENERATION_AFTER_COMMANDS: immediate —— await 完召回后直接通过 - // setExtensionPrompt 注入记忆,与 shujuku 参考实现一致。 - // GENERATE_BEFORE_COMBINE_PROMPTS: deferred —— 作为兜底,通过 promptData - // rewrite 补救注入。 + // GENERATION_AFTER_COMMANDS: immediate —?await 完召回后直接通过 + // setExtensionPrompt 注入记忆,与 shujuku 参考实现一致? + // GENERATE_BEFORE_COMBINE_PROMPTS: deferred —?作为兜底,通过 promptData + // rewrite 补救注入? if (hookName === "GENERATE_BEFORE_COMBINE_PROMPTS") { return "deferred"; } @@ -10024,6 +10168,10 @@ function clearGenerationRecallTransactionsForChat( } function invalidateRecallAfterHistoryMutation(reason = "聊天记录已变更") { + if (isRestoreLockActive()) { + return false; + } + const hadActiveRecall = Boolean( isRecalling || (stageAbortControllers.recall && @@ -10400,7 +10548,7 @@ async function handleExtractionSuccess( const prefix = mode === "auto" ? "自动" : "手动"; switch (String(action || "")) { case "compress": - return `${prefix}压缩:新建 ${maintenanceResult?.created || 0},归档 ${maintenanceResult?.archived || 0}`; + return `${prefix}压缩:新增 ${maintenanceResult?.created || 0},归档 ${maintenanceResult?.archived || 0}`; case "consolidate": return `${prefix}整合:合并 ${maintenanceResult?.merged || 0},跳过 ${maintenanceResult?.skipped || 0},保留 ${maintenanceResult?.kept || 0},进化 ${maintenanceResult?.evolved || 0},新链接 ${maintenanceResult?.connections || 0},回溯更新 ${maintenanceResult?.updates || 0}`; case "sleep": @@ -10563,8 +10711,8 @@ async function handleExtractionSuccess( updateExtractionPostProcessStatus( summaryStageLabel === "概要生成" ? "概要更新中" : "层级总结处理中", summaryStageLabel === "概要生成" - ? `第 ${extractionCount} 次提取,正在生成全局概要` - : `第 ${extractionCount} 次提取,正在检查小总结与折叠总结`, + ? `${extractionCount} 次提取,正在生成全局概要` + : `${extractionCount} 次提取,正在检查小总结与折叠总结`, ); const summaryResult = await runSummaryPostProcess({ graph: currentGraph, @@ -10606,7 +10754,7 @@ async function handleExtractionSuccess( try { updateExtractionPostProcessStatus( "反思生成中", - `第 ${extractionCount} 次提取,正在生成长期反思`, + `${extractionCount} 次提取,正在生成长期反思`, ); await generateReflection({ graph: currentGraph, @@ -10636,7 +10784,7 @@ async function handleExtractionSuccess( try { updateExtractionPostProcessStatus( "主动遗忘中", - `第 ${extractionCount} 次提取,正在归档低价值记忆`, + `${extractionCount} 次提取,正在归档低价值记忆`, ); const beforeSnapshot = cloneMaintenanceSnapshot(currentGraph); const sleepResult = sleepCycle(currentGraph, settings); @@ -11389,7 +11537,11 @@ async function recoverHistoryIfNeeded(trigger = "history-recovery") { if (!detection.dirty && !Number.isFinite(dirtyFrom)) { return true; } + if (isRestoreLockActive()) { + return false; + } + enterRestoreLock("history-recovery", trigger); isRecoveringHistory = true; clearInjectionState(); @@ -11677,6 +11829,7 @@ async function recoverHistoryIfNeeded(trigger = "history-recovery") { } } finally { finishStageAbortController("history", historyController); + leaveRestoreLock("history-recovery"); isRecoveringHistory = false; const enqueueMicrotask = typeof globalThis.queueMicrotask === "function" @@ -11994,6 +12147,19 @@ async function runPlannerRecallForEna({ * 召回管线:检索并注入记忆 */ async function runRecall(options = {}) { + if (!options?.ignoreRestoreLock && isRestoreLockActive()) { + const message = getRestoreLockMessage("召回"); + setLastRecallStatus("召回已暂停", message, "warning", { + syncRuntime: true, + }); + return createRecallRunResult("skipped", { + reason: "restore-lock-active", + restoreLock: cloneRuntimeDebugValue( + normalizeRestoreLockState(graphPersistenceState.restoreLock), + null, + ), + }); + } return await runRecallController( { abortRecallStageWithReason, @@ -12375,38 +12541,47 @@ async function onViewGraph() { } async function onRebuild() { - return await onRebuildController({ - buildRecoveryResult, - clearHistoryDirty, - clearInjectionState, - cloneGraphSnapshot, - confirm: (message) => { - if (typeof globalThis.confirm === "function") { - return globalThis.confirm(message); - } - return false; - }, - createEmptyGraph, - ensureGraphMutationReady, - getContext, - getCurrentChatId, - getCurrentGraph: () => currentGraph, - getSettings, - normalizeGraphRuntimeState, - prepareVectorStateForReplay, - refreshPanelLiveState, - replayExtractionFromHistory, - restoreRuntimeUiState, - saveGraphToChat, - updateProcessedHistorySnapshot, - setCurrentGraph: (graph) => { - currentGraph = graph; - }, - setLastExtractionStatus, - setRuntimeStatus, - snapshotRuntimeUiState, - toastr, - }); + return await runWithRestoreLock( + "manual-rebuild", + "manual-rebuild", + async () => + await onRebuildController({ + buildRecoveryResult, + clearHistoryDirty, + clearInjectionState, + cloneGraphSnapshot, + confirm: (message) => { + if (typeof globalThis.confirm === "function") { + return globalThis.confirm(message); + } + return false; + }, + createEmptyGraph, + ensureGraphMutationReady: (operationLabel, options = {}) => + ensureGraphMutationReady(operationLabel, { + ...(options || {}), + ignoreRestoreLock: true, + }), + getContext, + getCurrentChatId, + getCurrentGraph: () => currentGraph, + getSettings, + normalizeGraphRuntimeState, + prepareVectorStateForReplay, + refreshPanelLiveState, + replayExtractionFromHistory, + restoreRuntimeUiState, + saveGraphToChat, + updateProcessedHistorySnapshot, + setCurrentGraph: (graph) => { + currentGraph = graph; + }, + setLastExtractionStatus, + setRuntimeStatus, + snapshotRuntimeUiState, + toastr, + }), + ); } async function onManualCompress() { @@ -12664,32 +12839,41 @@ async function onExportGraph() { } async function onImportGraph() { - return await onImportGraphController({ - clearInjectionState, - clearTimeout, - document, - ensureGraphMutationReady, - getAssistantTurns, - getContext, - getCurrentChatId, - importGraph, - markVectorStateDirty, - normalizeGraphRuntimeState, - rebindProcessedHistoryStateToChat, - saveGraphToChat, - setCurrentGraph: (graph) => { - currentGraph = graph; - }, - setExtractionCount: (value) => { - extractionCount = value; - }, - setLastExtractedItems: (items) => { - lastExtractedItems = items; - }, - toastr, - updateLastRecalledItems, - window, - }); + return await runWithRestoreLock( + "graph-import", + "graph-import", + async () => + await onImportGraphController({ + clearInjectionState, + clearTimeout, + document, + ensureGraphMutationReady: (operationLabel, options = {}) => + ensureGraphMutationReady(operationLabel, { + ...(options || {}), + ignoreRestoreLock: true, + }), + getAssistantTurns, + getContext, + getCurrentChatId, + importGraph, + markVectorStateDirty, + normalizeGraphRuntimeState, + rebindProcessedHistoryStateToChat, + saveGraphToChat, + setCurrentGraph: (graph) => { + currentGraph = graph; + }, + setExtractionCount: (value) => { + extractionCount = value; + }, + setLastExtractedItems: (items) => { + lastExtractedItems = items; + }, + toastr, + updateLastRecalledItems, + window, + }), + ); } async function onViewLastInjection() { @@ -12882,17 +13066,29 @@ async function onManualSummaryRollup() { } async function onRebuildSummaryState(options = {}) { - return await onRebuildSummaryStateController({ - ensureGraphMutationReady, - getContext, - getCurrentGraph: () => currentGraph, - getSettings, - rebuildHierarchicalSummaryState, - refreshPanelLiveState, - saveGraphToChat, - setRuntimeStatus, - toastr, - }, options); + return await runWithRestoreLock( + "summary-rebuild", + "summary-rebuild", + async () => + await onRebuildSummaryStateController( + { + ensureGraphMutationReady: (operationLabel, nextOptions = {}) => + ensureGraphMutationReady(operationLabel, { + ...(nextOptions || {}), + ignoreRestoreLock: true, + }), + getContext, + getCurrentGraph: () => currentGraph, + getSettings, + rebuildHierarchicalSummaryState, + refreshPanelLiveState, + saveGraphToChat, + setRuntimeStatus, + toastr, + }, + options, + ), + ); } async function onClearSummaryState() { @@ -13069,48 +13265,54 @@ async function onBackupCurrentChatToCloud() { } async function onRestoreCurrentChatFromCloud() { - const chatId = getCurrentChatId(); - if (!chatId) { - toastr.warning("当前没有聊天上下文"); - return { handledToast: true }; - } + return await runWithRestoreLock( + "cloud-restore", + "manual-restore", + async () => { + const chatId = getCurrentChatId(); + if (!chatId) { + toastr.warning("当前没有聊天上下鏂?"); + return { handledToast: true }; + } - const confirmed = globalThis.confirm?.( - "这会用云端备份完整覆盖当前聊天的本地记忆,并先保留一份本地安全快照。确定继续吗?", + const confirmed = globalThis.confirm?.( + "这会用云端备份完整覆盖当前聊天的本地记忆,并先保留一份本地安全快照。确定继续吗锛?, + ); + if (!confirmed) { + return { cancelled: true }; + } + + const result = await restoreFromServer( + chatId, + buildBmeSyncRuntimeOptions({ + reason: "manual-restore", + trigger: "panel:manual-restore", + }), + ); + + if (!result?.restored) { + const reasonMap = { + "not-found": "服务器上没有找到当前聊天的备浠?, + "backup-missing": "服务器上没有找到当前聊天的备浠?, + "backup-version-mismatch": "备份版本与当前运行时不兼瀹?, + "backup-chat-id-mismatch": "备份聊天 ID 与当前聊天不匹配", + "snapshot-chat-id-mismatch": "备份内部快照与当前聊天不匹配", + }; + toastr.error( + reasonMap[result?.reason] || + `恢复失败: ${result?.error?.message || result?.reason || "未知原因"}`, + ); + return { handledToast: true, result }; + } + + toastr.success("已从云端恢复当前聊天备份"); + await syncIndexedDbMetaToPersistenceState(chatId, { + syncState: "idle", + lastSyncError: "", + }); + return { handledToast: true, result }; + }, ); - if (!confirmed) { - return { cancelled: true }; - } - - const result = await restoreFromServer( - chatId, - buildBmeSyncRuntimeOptions({ - reason: "manual-restore", - trigger: "panel:manual-restore", - }), - ); - - if (!result?.restored) { - const reasonMap = { - "not-found": "服务器上没有找到当前聊天的备份", - "backup-missing": "服务器上没有找到当前聊天的备份", - "backup-version-mismatch": "备份版本与当前运行时不兼容", - "backup-chat-id-mismatch": "备份聊天 ID 与当前聊天不匹配", - "snapshot-chat-id-mismatch": "备份内部快照与当前聊天不匹配", - }; - toastr.error( - reasonMap[result?.reason] || - `恢复失败: ${result?.error?.message || result?.reason || "未知原因"}`, - ); - return { handledToast: true, result }; - } - - toastr.success("已从云端恢复当前聊天备份"); - await syncIndexedDbMetaToPersistenceState(chatId, { - syncState: "idle", - lastSyncError: "", - }); - return { handledToast: true, result }; } async function onManageServerBackups() { @@ -13174,7 +13376,7 @@ async function onDeleteServerBackupEntry(payload = {}) { }; } -// ==================== 初始化 ==================== +// ==================== 初始?==================== async function onGetRestoreSafetySnapshotStatus() { const chatId = getCurrentChatId(); @@ -13197,46 +13399,52 @@ async function onGetRestoreSafetySnapshotStatus() { } async function onRollbackLastRestore() { - const chatId = getCurrentChatId(); - if (!chatId) { - toastr.warning("当前没有聊天上下文"); - return { handledToast: true }; - } + return await runWithRestoreLock( + "restore-rollback", + "manual-restore-rollback", + async () => { + const chatId = getCurrentChatId(); + if (!chatId) { + toastr.warning("当前没有聊天上下鏂?"); + return { handledToast: true }; + } - const safetyStatus = await onGetRestoreSafetySnapshotStatus(); - if (!safetyStatus?.exists) { - toastr.info("当前聊天还没有可用的上次恢复回滚点"); - return { handledToast: true, result: safetyStatus }; - } + const safetyStatus = await onGetRestoreSafetySnapshotStatus(); + if (!safetyStatus?.exists) { + toastr.info("当前聊天还没有可用的上次恢复回滚鐐?"); + return { handledToast: true, result: safetyStatus }; + } - const confirmed = globalThis.confirm?.( - "这会回滚到上次从云端恢复之前的本地状态。确定继续吗?", + const confirmed = globalThis.confirm?.( + "这会回滚到上次从云端恢复之前的本地状态。确定继续吗锛?, + ); + if (!confirmed) { + return { cancelled: true }; + } + + const result = await rollbackFromRestoreSafetySnapshot( + chatId, + buildBmeSyncRuntimeOptions({ + reason: "manual-restore-safety-rollback", + trigger: "panel:rollback-last-restore", + }), + ); + + if (!result?.restored) { + toastr.error( + `回滚失败: ${result?.error?.message || result?.reason || "未知原因"}`, + ); + return { handledToast: true, result }; + } + + toastr.success("已回滚到上次恢复前的本地状鎬?"); + await syncIndexedDbMetaToPersistenceState(chatId, { + syncState: "idle", + lastSyncError: "", + }); + return { handledToast: true, result }; + }, ); - if (!confirmed) { - return { cancelled: true }; - } - - const result = await rollbackFromRestoreSafetySnapshot( - chatId, - buildBmeSyncRuntimeOptions({ - reason: "manual-restore-safety-rollback", - trigger: "panel:rollback-last-restore", - }), - ); - - if (!result?.restored) { - toastr.error( - `回滚失败: ${result?.error?.message || result?.reason || "未知原因"}`, - ); - return { handledToast: true, result }; - } - - toastr.success("已回滚到上次恢复前的本地状态"); - await syncIndexedDbMetaToPersistenceState(chatId, { - syncState: "idle", - lastSyncError: "", - }); - return { handledToast: true, result }; } async function onRetryPendingPersist() { @@ -13244,6 +13452,7 @@ async function onRetryPendingPersist() { const result = await retryPendingGraphPersist({ reason: "panel-manual-persist-retry", scheduleRetryOnFailure: false, + ignoreRestoreLock: true, }); refreshPanelLiveState(); @@ -13408,7 +13617,7 @@ async function onProbeGraphLoad() { setCoreEventBindingState, }); - // 加载当前聊天的图谱 + // 加载当前聊天的图? scheduleBmeIndexedDbTask(async () => { const syncResult = await syncBmeChatManagerWithCurrentChat("initial-load"); if (!syncResult?.chatId) { diff --git a/maintenance/extraction-controller.js b/maintenance/extraction-controller.js index 03d8f59..b769749 100644 --- a/maintenance/extraction-controller.js +++ b/maintenance/extraction-controller.js @@ -82,6 +82,7 @@ function normalizePersistenceStateRecord(persistResult = null) { ? Number(persistResult.revision) : 0, saveMode: String(persistResult?.saveMode || ""), + recoverable: persistResult?.recoverable === true, saved: persistResult?.saved === true, queued, blocked, @@ -349,12 +350,7 @@ function isPersistenceRevisionAccepted(runtime, persistence = null) { if (!Number.isFinite(persistenceRevision) || persistenceRevision <= 0) { return false; } - const lastAcceptedRevision = Math.max( - Number(graphPersistenceState?.lastAcceptedRevision || 0), - Number(graphPersistenceState?.commitMarker?.accepted === true - ? graphPersistenceState?.commitMarker?.revision - : 0), - ); + const lastAcceptedRevision = Number(graphPersistenceState?.lastAcceptedRevision || 0); return Number.isFinite(lastAcceptedRevision) && lastAcceptedRevision >= persistenceRevision; } diff --git a/runtime/runtime-state.js b/runtime/runtime-state.js index f291121..3550c19 100644 --- a/runtime/runtime-state.js +++ b/runtime/runtime-state.js @@ -155,6 +155,8 @@ export function normalizeGraphRuntimeState(graph, chatId = "") { saveMode: String( historyState.lastBatchStatus.persistence.saveMode || "", ), + recoverable: + historyState.lastBatchStatus.persistence.recoverable === true, saved: historyState.lastBatchStatus.persistence.saved === true, queued: diff --git a/sync/bme-db.js b/sync/bme-db.js index 37d7576..9cd13cb 100644 --- a/sync/bme-db.js +++ b/sync/bme-db.js @@ -127,6 +127,17 @@ function normalizeMode(mode = "replace") { return String(mode || "").toLowerCase() === "merge" ? "merge" : "replace"; } +const BME_PERSIST_META_RESERVED_KEYS = new Set([ + "revision", + "lastModified", + "nodeCount", + "edgeCount", + "tombstoneCount", + "syncDirty", + "syncDirtyReason", + "lastMutationReason", +]); + function sanitizeSnapshot(snapshot = {}) { if (!snapshot || typeof snapshot !== "object" || Array.isArray(snapshot)) { return { @@ -430,6 +441,153 @@ export function buildSnapshotFromGraph(graph, options = {}) { }; } +function buildSnapshotRecordIndex(records = []) { + const map = new Map(); + for (const record of toArray(records)) { + const id = normalizeRecordId(record?.id); + if (!id) continue; + map.set(id, JSON.stringify(record)); + } + return map; +} + +function buildSnapshotRecordArrayIndex(records = []) { + const map = new Map(); + for (const record of toArray(records)) { + const id = normalizeRecordId(record?.id); + if (!id) continue; + map.set(id, toPlainData(record, record)); + } + return map; +} + +function buildRuntimeMetaPatch(snapshot = {}) { + const normalizedSnapshot = sanitizeSnapshot(snapshot); + const patch = {}; + for (const [rawKey, value] of Object.entries(normalizedSnapshot.meta || {})) { + const key = normalizeRecordId(rawKey); + if (!key || BME_PERSIST_META_RESERVED_KEYS.has(key)) continue; + patch[key] = toPlainData(value, value); + } + const state = normalizeStateSnapshot(normalizedSnapshot); + patch.lastProcessedFloor = state.lastProcessedFloor; + patch.extractionCount = state.extractionCount; + patch.schemaVersion = BME_DB_SCHEMA_VERSION; + patch.chatId = normalizeChatId( + normalizedSnapshot.meta?.chatId || patch.chatId || "", + ); + return patch; +} + +function ensureDeleteTombstone( + tombstoneMap, + kind, + targetId, + deletedAt, + sourceDeviceId = "", +) { + const normalizedKind = normalizeRecordId(kind); + const normalizedTargetId = normalizeRecordId(targetId); + if (!normalizedKind || !normalizedTargetId) return; + const targetKey = `${normalizedKind}:${normalizedTargetId}`; + if (tombstoneMap.has(targetKey)) return; + tombstoneMap.set(targetKey, { + id: `${normalizedKind}:${normalizedTargetId}`, + kind: normalizedKind, + targetId: normalizedTargetId, + sourceDeviceId: normalizeRecordId(sourceDeviceId), + deletedAt: normalizeTimestamp(deletedAt), + }); +} + +export function buildPersistDelta(beforeSnapshot, afterSnapshot, options = {}) { + const normalizedBefore = sanitizeSnapshot(beforeSnapshot); + const normalizedAfter = sanitizeSnapshot(afterSnapshot); + const nowMs = normalizeTimestamp(options.nowMs, Date.now()); + const beforeNodeJsonById = buildSnapshotRecordIndex(normalizedBefore.nodes); + const afterNodeJsonById = buildSnapshotRecordIndex(normalizedAfter.nodes); + const beforeEdgeJsonById = buildSnapshotRecordIndex(normalizedBefore.edges); + const afterEdgeJsonById = buildSnapshotRecordIndex(normalizedAfter.edges); + const beforeTombstoneJsonById = buildSnapshotRecordIndex( + normalizedBefore.tombstones, + ); + const afterNodeById = buildSnapshotRecordArrayIndex(normalizedAfter.nodes); + const afterEdgeById = buildSnapshotRecordArrayIndex(normalizedAfter.edges); + const afterTombstoneById = buildSnapshotRecordArrayIndex( + normalizedAfter.tombstones, + ); + + const upsertNodes = []; + for (const [id, record] of afterNodeById.entries()) { + if (beforeNodeJsonById.get(id) !== JSON.stringify(record)) { + upsertNodes.push(record); + } + } + + const upsertEdges = []; + for (const [id, record] of afterEdgeById.entries()) { + if (beforeEdgeJsonById.get(id) !== JSON.stringify(record)) { + upsertEdges.push(record); + } + } + + const deleteNodeIds = []; + for (const id of beforeNodeJsonById.keys()) { + if (!afterNodeJsonById.has(id)) { + deleteNodeIds.push(id); + } + } + + const deleteEdgeIds = []; + for (const id of beforeEdgeJsonById.keys()) { + if (!afterEdgeJsonById.has(id)) { + deleteEdgeIds.push(id); + } + } + + const tombstoneMap = new Map(); + for (const [id, record] of afterTombstoneById.entries()) { + if (beforeTombstoneJsonById.get(id) !== JSON.stringify(record)) { + tombstoneMap.set(`${record.kind}:${record.targetId}`, record); + } + } + + for (const nodeId of deleteNodeIds) { + ensureDeleteTombstone( + tombstoneMap, + "node", + nodeId, + nowMs, + normalizedAfter.meta?.deviceId || normalizedBefore.meta?.deviceId || "", + ); + } + for (const edgeId of deleteEdgeIds) { + ensureDeleteTombstone( + tombstoneMap, + "edge", + edgeId, + nowMs, + normalizedAfter.meta?.deviceId || normalizedBefore.meta?.deviceId || "", + ); + } + + return { + upsertNodes, + upsertEdges, + deleteNodeIds, + deleteEdgeIds, + tombstones: Array.from(tombstoneMap.values()), + runtimeMetaPatch: { + ...buildRuntimeMetaPatch(normalizedAfter), + ...(options.runtimeMetaPatch && + typeof options.runtimeMetaPatch === "object" && + !Array.isArray(options.runtimeMetaPatch) + ? toPlainData(options.runtimeMetaPatch, {}) + : {}), + }, + }; +} + export function buildGraphFromSnapshot(snapshot, options = {}) { const normalizedSnapshot = sanitizeSnapshot(snapshot); const chatId = @@ -915,6 +1073,105 @@ export class BmeDatabase { return true; } + async commitDelta(delta = {}, options = {}) { + const db = await this.open(); + const nowMs = Date.now(); + const normalizedDelta = + delta && typeof delta === "object" && !Array.isArray(delta) ? delta : {}; + const upsertNodes = this._normalizeNodeRecords(normalizedDelta.upsertNodes, nowMs); + const upsertEdges = this._normalizeEdgeRecords(normalizedDelta.upsertEdges, nowMs); + const tombstones = this._normalizeTombstoneRecords( + normalizedDelta.tombstones, + nowMs, + ); + const deleteNodeIds = toArray(normalizedDelta.deleteNodeIds) + .map((value) => normalizeRecordId(value)) + .filter(Boolean); + const deleteEdgeIds = toArray(normalizedDelta.deleteEdgeIds) + .map((value) => normalizeRecordId(value)) + .filter(Boolean); + const runtimeMetaPatch = + normalizedDelta.runtimeMetaPatch && + typeof normalizedDelta.runtimeMetaPatch === "object" && + !Array.isArray(normalizedDelta.runtimeMetaPatch) + ? normalizedDelta.runtimeMetaPatch + : {}; + const reason = String(options.reason || "commitDelta"); + const requestedRevision = normalizeRevision(options.requestedRevision); + const shouldMarkSyncDirty = options.markSyncDirty !== false; + + let nextRevision = 0; + let counts = { + nodes: 0, + edges: 0, + tombstones: 0, + }; + + await db.transaction( + "rw", + db.table("nodes"), + db.table("edges"), + db.table("tombstones"), + db.table("meta"), + async () => { + if (deleteEdgeIds.length) { + await db.table("edges").bulkDelete(deleteEdgeIds); + } + if (deleteNodeIds.length) { + await db.table("nodes").bulkDelete(deleteNodeIds); + } + if (upsertNodes.length) { + await db.table("nodes").bulkPut(upsertNodes); + } + if (upsertEdges.length) { + await db.table("edges").bulkPut(upsertEdges); + } + if (tombstones.length) { + await db.table("tombstones").bulkPut(tombstones); + } + + for (const [rawKey, value] of Object.entries(runtimeMetaPatch)) { + const key = normalizeRecordId(rawKey); + if (!key || BME_PERSIST_META_RESERVED_KEYS.has(key)) continue; + await this._setMetaInTx(db, key, value, nowMs); + } + + counts = await this._updateCountMetaInTx(db, nowMs); + const currentRevision = normalizeRevision( + (await db.table("meta").get("revision"))?.value, + ); + nextRevision = Math.max(currentRevision + 1, requestedRevision); + await this._setMetaInTx(db, "revision", nextRevision, nowMs); + await this._setMetaInTx(db, "lastModified", nowMs, nowMs); + await this._setMetaInTx(db, "lastMutationReason", reason, nowMs); + await this._setMetaInTx(db, "syncDirty", shouldMarkSyncDirty, nowMs); + await this._setMetaInTx( + db, + "syncDirtyReason", + shouldMarkSyncDirty ? reason : "", + nowMs, + ); + }, + ); + + return { + revision: nextRevision, + lastModified: nowMs, + imported: { + nodes: counts.nodes, + edges: counts.edges, + tombstones: counts.tombstones, + }, + delta: { + upsertNodes: upsertNodes.length, + upsertEdges: upsertEdges.length, + deleteNodeIds: deleteNodeIds.length, + deleteEdgeIds: deleteEdgeIds.length, + tombstones: tombstones.length, + }, + }; + } + async bulkUpsertNodes(nodes = []) { const records = this._normalizeNodeRecords(nodes); if (!records.length) { diff --git a/tests/graph-persistence.mjs b/tests/graph-persistence.mjs index a38878f..66b5933 100644 --- a/tests/graph-persistence.mjs +++ b/tests/graph-persistence.mjs @@ -7,6 +7,7 @@ import vm from "node:vm"; import { buildBmeDbName, buildGraphFromSnapshot, + buildPersistDelta, buildSnapshotFromGraph, } from "../sync/bme-db.js"; import { onMessageReceivedController } from "../host/event-binding.js"; @@ -104,7 +105,7 @@ const persistenceCore = extractSnippet( ); const messageSnippet = extractSnippet( 'function onMessageReceived(messageId = null, type = "") {', - "// ==================== UI 操作 ====================", + "async function onViewGraph() {", ); function createSessionStorage(seed = null) { @@ -271,6 +272,109 @@ async function createGraphPersistenceHarness({ indexedDbSnapshotMap.set(normalizedChatId, structuredClone(snapshot)); } + function commitIndexedDbDelta(targetChatId = "", delta = {}, options = {}) { + const normalizedChatId = String(targetChatId || ""); + const currentSnapshot = getIndexedDbSnapshotForChat(normalizedChatId); + const now = Date.now(); + + const nodeMap = new Map( + (Array.isArray(currentSnapshot?.nodes) ? currentSnapshot.nodes : []) + .filter((record) => record?.id) + .map((record) => [String(record.id), structuredClone(record)]), + ); + const edgeMap = new Map( + (Array.isArray(currentSnapshot?.edges) ? currentSnapshot.edges : []) + .filter((record) => record?.id) + .map((record) => [String(record.id), structuredClone(record)]), + ); + const tombstoneMap = new Map( + (Array.isArray(currentSnapshot?.tombstones) ? currentSnapshot.tombstones : []) + .filter((record) => record?.id) + .map((record) => [String(record.id), structuredClone(record)]), + ); + + for (const edgeId of Array.isArray(delta?.deleteEdgeIds) ? delta.deleteEdgeIds : []) { + edgeMap.delete(String(edgeId)); + } + for (const nodeId of Array.isArray(delta?.deleteNodeIds) ? delta.deleteNodeIds : []) { + nodeMap.delete(String(nodeId)); + } + for (const record of Array.isArray(delta?.upsertNodes) ? delta.upsertNodes : []) { + if (!record?.id) continue; + nodeMap.set(String(record.id), structuredClone(record)); + } + for (const record of Array.isArray(delta?.upsertEdges) ? delta.upsertEdges : []) { + if (!record?.id) continue; + edgeMap.set(String(record.id), structuredClone(record)); + } + for (const record of Array.isArray(delta?.tombstones) ? delta.tombstones : []) { + if (!record?.id) continue; + tombstoneMap.set(String(record.id), structuredClone(record)); + } + + const runtimeMetaPatch = + delta?.runtimeMetaPatch && + typeof delta.runtimeMetaPatch === "object" && + !Array.isArray(delta.runtimeMetaPatch) + ? structuredClone(delta.runtimeMetaPatch) + : {}; + const shouldMarkSyncDirty = options?.markSyncDirty !== false; + const nextRevision = Math.max( + Number(currentSnapshot?.meta?.revision || 0) + 1, + Number(options?.requestedRevision || 0), + ); + const nextState = { + lastProcessedFloor: Number.isFinite(Number(runtimeMetaPatch.lastProcessedFloor)) + ? Number(runtimeMetaPatch.lastProcessedFloor) + : Number(currentSnapshot?.state?.lastProcessedFloor ?? -1), + extractionCount: Number.isFinite(Number(runtimeMetaPatch.extractionCount)) + ? Number(runtimeMetaPatch.extractionCount) + : Number(currentSnapshot?.state?.extractionCount ?? 0), + }; + const nextSnapshot = { + meta: { + ...(currentSnapshot?.meta || {}), + ...runtimeMetaPatch, + chatId: normalizedChatId, + revision: nextRevision, + lastModified: now, + lastMutationReason: String(options?.reason || "commitDelta"), + syncDirty: shouldMarkSyncDirty, + syncDirtyReason: shouldMarkSyncDirty + ? String(options?.reason || "commitDelta") + : "", + nodeCount: nodeMap.size, + edgeCount: edgeMap.size, + tombstoneCount: tombstoneMap.size, + }, + nodes: Array.from(nodeMap.values()), + edges: Array.from(edgeMap.values()), + tombstones: Array.from(tombstoneMap.values()), + state: nextState, + }; + + setIndexedDbSnapshotForChat(normalizedChatId, nextSnapshot); + runtimeContext.__indexedDbSnapshot = + getIndexedDbSnapshotForChat(normalizedChatId); + + return { + revision: nextRevision, + lastModified: now, + imported: { + nodes: nodeMap.size, + edges: edgeMap.size, + tombstones: tombstoneMap.size, + }, + delta: { + upsertNodes: Array.isArray(delta?.upsertNodes) ? delta.upsertNodes.length : 0, + upsertEdges: Array.isArray(delta?.upsertEdges) ? delta.upsertEdges.length : 0, + deleteNodeIds: Array.isArray(delta?.deleteNodeIds) ? delta.deleteNodeIds.length : 0, + deleteEdgeIds: Array.isArray(delta?.deleteEdgeIds) ? delta.deleteEdgeIds.length : 0, + tombstones: Array.isArray(delta?.tombstones) ? delta.tombstones.length : 0, + }, + }; + } + const runtimeContext = { console, Date, @@ -774,6 +878,7 @@ async function createGraphPersistenceHarness({ __contextSaveCalls: 0, __contextImmediateSaveCalls: 0, buildGraphFromSnapshot, + buildPersistDelta, buildSnapshotFromGraph, buildBmeDbName, scheduleUpload() { @@ -790,6 +895,9 @@ async function createGraphPersistenceHarness({ async exportSnapshot() { return getIndexedDbSnapshotForChat(this.chatId); } + async commitDelta(delta, options = {}) { + return commitIndexedDbDelta(this.chatId, delta, options); + } async importSnapshot(snapshot) { setIndexedDbSnapshotForChat(this.chatId, snapshot); return { @@ -806,6 +914,9 @@ async function createGraphPersistenceHarness({ async exportSnapshot() { return getIndexedDbSnapshotForChat(dbChatId); }, + async commitDelta(delta, options = {}) { + return commitIndexedDbDelta(dbChatId, delta, options); + }, async importSnapshot(snapshot) { setIndexedDbSnapshotForChat(dbChatId, snapshot); runtimeContext.__indexedDbSnapshot = @@ -2312,7 +2423,6 @@ result = { lastPersistedRevision: 0, writesBlocked: false, }); - harness.runtimeContext.__markSyncDirtyShouldThrow = true; harness.runtimeContext.__scheduleUploadShouldThrow = true; const result = await harness.api.saveGraphToIndexedDb( @@ -2325,7 +2435,6 @@ result = { ); assert.equal(result.saved, true); - assert.match(String(result.warning || ""), /mark-sync-dirty-failed/); assert.match(String(result.warning || ""), /schedule-upload-failed/); assert.equal( harness.api.getIndexedDbSnapshot().meta.revision, diff --git a/tests/helpers/generation-recall-harness.mjs b/tests/helpers/generation-recall-harness.mjs index 1a8483e..caa9edf 100644 --- a/tests/helpers/generation-recall-harness.mjs +++ b/tests/helpers/generation-recall-harness.mjs @@ -169,6 +169,7 @@ export function createGenerationRecallHarness(options = {}) { hideScheduleCalls: [], isExtracting: false, isRecoveringHistory: false, + isRestoreLockActive: () => false, isAssistantChatMessage: (message) => Boolean(message) && !message.is_user && !message.is_system, createRecallInputRecord, diff --git a/tests/mobile-status-regressions.mjs b/tests/mobile-status-regressions.mjs index 8fb6a9e..76ffd57 100644 --- a/tests/mobile-status-regressions.mjs +++ b/tests/mobile-status-regressions.mjs @@ -539,6 +539,9 @@ async function testManualRebuildSetsTerminalRuntimeStatus() { context.currentGraph.historyState.processedMessageHashesNeedRefresh; }, restoreRuntimeUiState() {}, + async runWithRestoreLock(_source, _reason, task) { + return await task(); + }, onRebuildController, result: null, }; diff --git a/tests/p0-regressions.mjs b/tests/p0-regressions.mjs index c82d678..b222863 100644 --- a/tests/p0-regressions.mjs +++ b/tests/p0-regressions.mjs @@ -350,6 +350,12 @@ function createHistoryRecoveryHarness() { refreshPanelCalls: 0, notices: [], embeddingConfig: { mode: "backend" }, + isRestoreLockActive() { + return false; + }, + enterRestoreLock() {}, + leaveRestoreLock() {}, + async maybeResumePendingAutoExtraction() {}, ensureCurrentGraphRuntimeState() { return context.currentGraph; }, diff --git a/ui/panel.js b/ui/panel.js index 5ab4d72..497420f 100644 --- a/ui/panel.js +++ b/ui/panel.js @@ -9518,6 +9518,8 @@ function _formatPersistenceOutcomeLabel(outcome = "") { return "已阻塞"; case "failed": return "失败"; + case "recoverable": + return "已捕获恢复锚点"; default: return "未知"; } @@ -9548,14 +9550,7 @@ function _isPersistenceRevisionAccepted(persistence = null, loadInfo = {}) { if (!Number.isFinite(persistenceRevision) || persistenceRevision <= 0) { return false; } - const commitMarkerRevision = - loadInfo?.commitMarker?.accepted === true - ? Number(loadInfo.commitMarker.revision || 0) - : 0; - const lastAcceptedRevision = Math.max( - Number(loadInfo?.lastAcceptedRevision || 0), - commitMarkerRevision, - ); + const lastAcceptedRevision = Number(loadInfo?.lastAcceptedRevision || 0); return Number.isFinite(lastAcceptedRevision) && lastAcceptedRevision >= persistenceRevision; } @@ -9564,7 +9559,11 @@ function _formatDashboardPersistMeta(loadInfo = {}, batchStatus = null) { if (_hasMeaningfulPersistenceRecord(persistence)) { const accepted = _isPersistenceRevisionAccepted(persistence, loadInfo); const parts = [ - accepted ? "已确认" : _formatPersistenceOutcomeLabel(persistence.outcome), + accepted + ? "已确认" + : persistence.recoverable === true + ? "已捕获恢复锚点" + : _formatPersistenceOutcomeLabel(persistence.outcome), persistence.storageTier ? `tier ${persistence.storageTier}` : "", Number.isFinite(Number(persistence.revision)) && Number(persistence.revision) > 0 ? `rev ${Number(persistence.revision)}` @@ -9685,7 +9684,9 @@ function _refreshPersistenceRepairUi( } help.textContent = - "最近一批持久化没有被接受。可以先重试持久化;如果宿主延迟加载了本地存储,再重新探测图谱。"; + persistence?.recoverable === true + ? "最近一批已经捕获了恢复锚点,但还没有进入正式 accepted 存储。可以先重试持久化;如果仍未确认,再重新探测图谱。" + : "最近一批持久化没有被接受。可以先重试持久化;如果宿主延迟加载了本地存储,再重新探测图谱。"; } function _canRenderGraphData(loadInfo = _getGraphPersistenceSnapshot()) { diff --git a/ui/ui-status.js b/ui/ui-status.js index 9e7412f..53e8df0 100644 --- a/ui/ui-status.js +++ b/ui/ui-status.js @@ -50,8 +50,17 @@ export function createGraphPersistenceState() { writesBlocked: false, pendingPersist: false, lastAcceptedRevision: 0, + acceptedStorageTier: "none", + lastRecoverableStorageTier: "none", persistMismatchReason: "", commitMarker: null, + restoreLock: { + active: false, + depth: 0, + source: "", + reason: "", + startedAt: 0, + }, storagePrimary: "indexeddb", storageMode: "indexeddb", dbReady: false, From 6ff6f2d9b0e57c0596e667d246e84a31730bf9ce Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 11 Apr 2026 05:57:34 +0000 Subject: [PATCH 02/20] chore: bump manifest version [skip ci] --- manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/manifest.json b/manifest.json index fe8c66f..db2a70a 100644 --- a/manifest.json +++ b/manifest.json @@ -6,6 +6,6 @@ "js": "index.js", "css": "style.css", "author": "Youzini", - "version": "4.5.0", + "version": "4.5.1", "homePage": "https://github.com/Youzini-afk/ST-Bionic-Memory-Ecology" } From b42157c72d5f6e24372fea0cdfea20bf547e62e5 Mon Sep 17 00:00:00 2001 From: Youzini-afk <13153778771cx@gmail.com> Date: Sat, 11 Apr 2026 14:21:34 +0800 Subject: [PATCH 03/20] fix: clean up residual mojibake strings in index.js --- index.js | 94 ++++++++++++++++++++++++++++---------------------------- 1 file changed, 47 insertions(+), 47 deletions(-) diff --git a/index.js b/index.js index 58609ea..758c535 100644 --- a/index.js +++ b/index.js @@ -1,5 +1,5 @@ -// ST-BME: 主入? -// 事件钩子、设置管理、流程调? +// ST-BME: 主入口 +// 事件钩子、设置管理、流程调度 import { eventSource, @@ -297,7 +297,7 @@ import { export { DEFAULT_TRIGGER_KEYWORDS, getSmartTriggerDecision }; -// 操控面板模块(动态加载,防止加载失败崩溃整个扩展? +// 操控面板模块(动态加载,防止加载失败崩溃整个扩展) let _panelModule = null; let _themesModule = null; @@ -702,7 +702,7 @@ function readRuntimeDebugSnapshot() { ); } -// ==================== 状?==================== +// ==================== 状态 ==================== let currentGraph = null; let isExtracting = false; @@ -710,8 +710,8 @@ let isRecalling = false; let activeRecallPromise = null; let recallRunSequence = 0; let lastInjectionContent = ""; -let lastExtractedItems = []; // 最近提取的节点(面板展示用? -let lastRecalledItems = []; // 最近召回的节点(面板展示用? +let lastExtractedItems = []; // 最近提取的节点(面板展示用) +let lastRecalledItems = []; // 最近召回的节点(面板展示用) let extractionCount = 0; // v2: 提取次数计数器(定期触发概要/遗忘/反思) let serverSettingsSaveTimer = null; let isRecoveringHistory = false; @@ -973,14 +973,14 @@ function hasReadableRuntimeGraphForRecall(chatId = getCurrentChatId()) { currentGraph.historyState.chatId, ); - // chatId 匹配验证:如果两者都有,必须一? + // chatId 匹配验证:如果两者都有,必须一致 if (activeChatId && runtimeChatId) { return runtimeChatId === activeChatId; } // 兜底:chatId 不可用(ST 插件环境可能无法获取 chatId), - // ?currentGraph 结构完整且有节点数据 ?允许召回? - // 这对应用户能?UI 看到图谱?getCurrentChatId() 返回空的场景? + // 只要 currentGraph 结构完整且有节点数据,就允许召回。 + // 这对应用户能在 UI 看到图谱,但 getCurrentChatId() 返回空的场景。 return currentGraph.nodes.length > 0 || currentGraph.edges.length > 0; } @@ -992,10 +992,10 @@ function isGraphReadableForRecall( return true; } - // ?loadState 不在正常可读状态时(如 NO_CHAT、LOADING), - // 仍检查运行时图谱的实际结构。持久化状态机可能失同? - // (如 getCurrentChatId 在某?ST 环境下返回空导致 loadState 卡在 NO_CHAT), - // ?currentGraph 已经通过其他路径(IndexedDB probe / metadata fallback)加载了数据? + // 当 loadState 不在正常可读状态时(如 NO_CHAT、LOADING), + // 仍检查运行时图谱的实际结构。持久化状态机可能失同步 + // (如 getCurrentChatId 在某些 ST 环境下返回空导致 loadState 卡在 NO_CHAT), + // 但 currentGraph 已经通过其他路径(IndexedDB probe / metadata fallback)加载了数据。 return hasReadableRuntimeGraphForRecall(chatId); } @@ -1722,10 +1722,10 @@ function recordRecallSentUserMessage(messageId, text, source = "message-sent") { } // 注意:不再在 MESSAGE_SENT 阶段清空 pendingRecallSendIntent / - // pendingHostGenerationInputSnapshot / transactions? - // 这些数据?GENERATION_AFTER_COMMANDS 中被消费;MESSAGE_SENT 先于 - // GENERATION_AFTER_COMMANDS 触发,提前清空会导致召回拿不到用户输入? - // 真正的消费发生在 recall 执行后(runRecallController 内部)? + // pendingHostGenerationInputSnapshot / transactions。 + // 这些数据在 GENERATION_AFTER_COMMANDS 中被消费;MESSAGE_SENT 先于 + // GENERATION_AFTER_COMMANDS 触发,提前清空会导致召回拿不到用户输入。 + // 真正的消费发生在 recall 执行后(runRecallController 内部)。 return lastRecallSentUserMessage; } @@ -1900,9 +1900,9 @@ function resolveRecallPersistenceTargetUserMessageIndex( } // 正常生成阶段里,ST 可能会在真正发送前改写用户文本 - // (命令展开、包装显示、助?UI 处理等),导?hash 已无法精确匹配? - // 这时仍应优先回绑到“当前最?user 楼层”,否则召回记录虽然生成了, - // ?Recall Card 会因为找不到目标楼层而消失? + // (命令展开、包装显示、辅助 UI 处理等),导致 hash 已无法精确匹配。 + // 这时仍应优先回绑到“当前最新 user 楼层”,否则召回记录虽然生成了, + // Recall Card 会因为找不到目标楼层而消失。 if ( normalizedGenerationType === "normal" && Number.isFinite(latestUserIndex) && @@ -3064,8 +3064,8 @@ function schedulePersistedRecallMessageUiRefresh(delayMs = 0) { summary.status === "missing_message_anchor") && attemptIndex < retryDelays.length - 1; - // 勿在「已成功渲染」时长期?MutationObserver?chat ?class/流式更新会疯狂触? - // runAttempt,造成满屏刷新与日志;显式事件(USER_MESSAGE_RENDERED 等)仍会 schedule 刷新? + // 勿在「已成功渲染」时长期监听 MutationObserver:chat 的 class/流式更新会疯狂触发 + // runAttempt,造成满屏刷新与日志;显式事件(USER_MESSAGE_RENDERED 等)仍会 schedule 刷新。 const shouldWatchForRepaint = false; if (!shouldRetryForPending && !shouldWatchForRepaint) { @@ -3515,7 +3515,7 @@ function isHostChatMetadataReady(context = getContext()) { } const metadata = context.chatMetadata; - // 仅接受宿主“强信号”,避免把中间?占位 metadata 误判?ready? + // 仅接受宿主“强信号”,避免把中间态占位 metadata 误判为 ready。 if (hasHostMetadataReadySignal(metadata)) return true; return false; @@ -7691,7 +7691,7 @@ function clearInjectionState(options = {}) { try { applyModuleInjectionPrompt("", getSettings()); } catch (error) { - console.warn("[ST-BME] 清理旧注入失?", error); + console.warn("[ST-BME] 清理旧注入失败:", error); } refreshPanelLiveState(); @@ -7715,7 +7715,7 @@ function notifyStatusToast(key, kind, message, title = "ST-BME") { function setRuntimeStatus(text, meta, level = "info") { runtimeStatus = createUiStatus(text, meta, level); refreshPanelLiveState(); - // 同步悬浮球状? + // 同步悬浮球状态 const fabStatus = level === "info" ? "idle" : level; _panelModule?.updateFloatingBallStatus?.(fabStatus, text || "BME 记忆图谱"); } @@ -9463,7 +9463,7 @@ function buildGenerationAfterCommandsRecallInput(type, params = {}, chat) { generationType, }); - // 对于 history 类型(continue/regenerate/swipe),必须?chat 中的用户消息 + // 对于 history 类型(continue/regenerate/swipe),必须依赖 chat 中的用户消息 if (generationType !== "normal") { if (!Number.isFinite(targetUserMessageIndex)) { return { @@ -9485,10 +9485,10 @@ function buildGenerationAfterCommandsRecallInput(type, params = {}, chat) { }; } - // 对于 normal 类型:GENERATION_AFTER_COMMANDS 触发时用户消息可能不?chat 末尾 - // (ST 可能已追加空 assistant 消息)。如?chat 中存在任何用户消息, - // 继续?buildNormalGenerationRecallInput,它会通过 latestUserText 兜底找到? - // 如果 chat 中完全没有用户消息,则延迟到 BEFORE_COMBINE_PROMPTS 处理? + // 对于 normal 类型:GENERATION_AFTER_COMMANDS 触发时用户消息可能不在 chat 末尾 + // (ST 可能已追加空 assistant 消息)。如果 chat 中存在任何用户消息, + // 继续走 buildNormalGenerationRecallInput,它会通过 latestUserText 兜底找到。 + // 如果 chat 中完全没有用户消息,则延迟到 BEFORE_COMBINE_PROMPTS 处理。 if (!Number.isFinite(targetUserMessageIndex) && !getLatestUserChatMessage(chat)) { return { generationType, @@ -9514,9 +9514,9 @@ function buildNormalGenerationRecallInput(chat, options = {}) { const tailUserText = lastNonSystemMessage?.is_user ? normalizeRecallInputText(lastNonSystemMessage?.mes || "") : ""; - // ?GENERATION_AFTER_COMMANDS 触发时,ST 可能已追加了?assistant 消息? + // 当 GENERATION_AFTER_COMMANDS 触发时,ST 可能已追加了空 assistant 消息。 // 导致 lastNonSystemMessage 不是 user。用 getLatestUserChatMessage 反向扫描 - // 定位真正的用户消息(?shujuku 参考实现一致)? + // 定位真正的用户消息(与 shujuku 参考实现一致)。 const latestUserMessage = !tailUserText ? getLatestUserChatMessage(chat) : null; const latestUserText = latestUserMessage ? normalizeRecallInputText(latestUserMessage?.mes || "") @@ -9851,10 +9851,10 @@ function resolveGenerationRecallDeliveryMode( return "immediate"; } - // GENERATION_AFTER_COMMANDS: immediate —?await 完召回后直接通过 - // setExtensionPrompt 注入记忆,与 shujuku 参考实现一致? - // GENERATE_BEFORE_COMBINE_PROMPTS: deferred —?作为兜底,通过 promptData - // rewrite 补救注入? + // GENERATION_AFTER_COMMANDS: immediate —— await 完召回后直接通过 + // setExtensionPrompt 注入记忆,与 shujuku 参考实现一致。 + // GENERATE_BEFORE_COMBINE_PROMPTS: deferred —— 作为兜底,通过 promptData + // rewrite 补救注入。 if (hookName === "GENERATE_BEFORE_COMBINE_PROMPTS") { return "deferred"; } @@ -13292,9 +13292,9 @@ async function onRestoreCurrentChatFromCloud() { if (!result?.restored) { const reasonMap = { - "not-found": "服务器上没有找到当前聊天的备浠?, - "backup-missing": "服务器上没有找到当前聊天的备浠?, - "backup-version-mismatch": "备份版本与当前运行时不兼瀹?, + "not-found": "服务器上没有找到当前聊天的备份", + "backup-missing": "服务器上没有找到当前聊天的备份", + "backup-version-mismatch": "备份版本与当前运行时不兼容", "backup-chat-id-mismatch": "备份聊天 ID 与当前聊天不匹配", "snapshot-chat-id-mismatch": "备份内部快照与当前聊天不匹配", }; @@ -13376,7 +13376,7 @@ async function onDeleteServerBackupEntry(payload = {}) { }; } -// ==================== 初始?==================== +// ==================== 恢复快照 ==================== async function onGetRestoreSafetySnapshotStatus() { const chatId = getCurrentChatId(); @@ -13405,18 +13405,18 @@ async function onRollbackLastRestore() { async () => { const chatId = getCurrentChatId(); if (!chatId) { - toastr.warning("当前没有聊天上下鏂?"); + toastr.warning("当前没有聊天上下文"); return { handledToast: true }; } const safetyStatus = await onGetRestoreSafetySnapshotStatus(); if (!safetyStatus?.exists) { - toastr.info("当前聊天还没有可用的上次恢复回滚鐐?"); + toastr.info("当前聊天还没有可用的上次恢复回滚点"); return { handledToast: true, result: safetyStatus }; } const confirmed = globalThis.confirm?.( - "这会回滚到上次从云端恢复之前的本地状态。确定继续吗锛?, + "这会回滚到上次从云端恢复之前的本地状态。确定继续吗?", ); if (!confirmed) { return { cancelled: true }; @@ -13437,7 +13437,7 @@ async function onRollbackLastRestore() { return { handledToast: true, result }; } - toastr.success("已回滚到上次恢复前的本地状鎬?"); + toastr.success("已回滚到上次恢复前的本地状态"); await syncIndexedDbMetaToPersistenceState(chatId, { syncState: "idle", lastSyncError: "", @@ -13462,12 +13462,12 @@ async function onRetryPendingPersist() { } if (!hadPending && String(result?.reason || "") === "no-pending-persist") { - toastr.info("当前没有待确认的持久化批次"); + toastr.info("当前没有待重试的持久化批次"); return { handledToast: true, result }; } toastr.warning( - `持久化仍未确认: ${result?.reason || result?.loadState || "未知原因"}`, + `重试持久化仍未成功: ${result?.reason || result?.loadState || "未知原因"}`, ); return { handledToast: true, result }; } @@ -13617,7 +13617,7 @@ async function onProbeGraphLoad() { setCoreEventBindingState, }); - // 加载当前聊天的图? + // 加载当前聊天的图谱 scheduleBmeIndexedDbTask(async () => { const syncResult = await syncBmeChatManagerWithCurrentChat("initial-load"); if (!syncResult?.chatId) { From 154e5535894340ca1d7014136c0dc964e4f92769 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 11 Apr 2026 06:21:45 +0000 Subject: [PATCH 04/20] chore: bump manifest version [skip ci] --- manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/manifest.json b/manifest.json index db2a70a..8f2a24e 100644 --- a/manifest.json +++ b/manifest.json @@ -6,6 +6,6 @@ "js": "index.js", "css": "style.css", "author": "Youzini", - "version": "4.5.1", + "version": "4.5.2", "homePage": "https://github.com/Youzini-afk/ST-Bionic-Memory-Ecology" } From 322752bb43f388623d0bda2adf9234552c62e107 Mon Sep 17 00:00:00 2001 From: Youzini-afk <13153778771cx@gmail.com> Date: Sat, 11 Apr 2026 16:05:06 +0800 Subject: [PATCH 05/20] feat: improve shujuku-compatible extraction and recall input --- index.js | 64 ++- llm/llm.js | 4 + maintenance/chat-history.js | 3 + maintenance/extraction-context.js | 453 ++++++++++++++++++ maintenance/extractor.js | 196 +++++++- prompting/default-task-profile-templates.js | 28 +- prompting/prompt-builder.js | 59 ++- prompting/prompt-profiles.js | 26 + retrieval/recall-controller.js | 12 + runtime/settings-defaults.js | 10 + tests/default-settings.mjs | 1 + tests/extractor-input-context.mjs | 151 ++++++ tests/extractor-phase3-layered-context.mjs | 393 +++++++++++++++ tests/prompt-builder-mixed-transcript.mjs | 148 ++++++ .../recall-authoritative-generation-input.mjs | 129 +++++ 15 files changed, 1642 insertions(+), 35 deletions(-) create mode 100644 maintenance/extraction-context.js create mode 100644 tests/extractor-input-context.mjs create mode 100644 tests/extractor-phase3-layered-context.mjs create mode 100644 tests/prompt-builder-mixed-transcript.mjs create mode 100644 tests/recall-authoritative-generation-input.mjs diff --git a/index.js b/index.js index 758c535..62daf7b 100644 --- a/index.js +++ b/index.js @@ -9861,6 +9861,41 @@ function resolveGenerationRecallDeliveryMode( return "immediate"; } +function shouldUseAuthoritativeGenerationRecallInput(recallOptions = {}) { + const normalizedGenerationType = normalizeGenerationRecallTransactionType( + recallOptions?.generationType || "normal", + ); + if (normalizedGenerationType !== "normal") { + return false; + } + return Boolean(getSettings()?.recallUseAuthoritativeGenerationInput); +} + +function shouldPreserveAuthoritativeGenerationRecallText( + source, + overrideUserMessage, + targetUserMessageText, + recallOptions = {}, +) { + if (!shouldUseAuthoritativeGenerationRecallInput(recallOptions)) { + return false; + } + const normalizedOverride = normalizeRecallInputText(overrideUserMessage); + const normalizedTarget = normalizeRecallInputText(targetUserMessageText); + if (!normalizedOverride || !normalizedTarget || normalizedOverride === normalizedTarget) { + return false; + } + const normalizedSource = String(source || "").trim(); + return [ + "send-intent", + "generation-started-send-intent", + "generation-started-textarea", + "host-generation-lifecycle", + "textarea-live", + "planner-handoff", + ].includes(normalizedSource); +} + function freezeGenerationRecallOptionsForTransaction( chat, generationType = "normal", @@ -9935,6 +9970,8 @@ function freezeGenerationRecallOptionsForTransaction( lockedSource: source, lockedSourceLabel: sourceLabel, lockedReason: sourceReason, + authoritativeInputUsed: false, + boundUserFloorText: "", includeSyntheticUserMessage: Boolean( recallOptions?.includeSyntheticUserMessage, ), @@ -9949,12 +9986,21 @@ function freezeGenerationRecallOptionsForTransaction( return null; } - const frozenUserMessage = normalizeRecallInputText( - targetUserMessage?.mes || - recallOptions?.overrideUserMessage || - recallOptions?.userMessage || - "", + const targetUserMessageText = normalizeRecallInputText(targetUserMessage?.mes || ""); + const preserveAuthoritativeText = shouldPreserveAuthoritativeGenerationRecallText( + source, + overrideUserMessage, + targetUserMessageText, + recallOptions, ); + const frozenUserMessage = preserveAuthoritativeText + ? normalizeRecallInputText(overrideUserMessage) + : normalizeRecallInputText( + targetUserMessage?.mes || + recallOptions?.overrideUserMessage || + recallOptions?.userMessage || + "", + ); if (!frozenUserMessage) { return null; } @@ -9978,7 +10024,9 @@ function freezeGenerationRecallOptionsForTransaction( (frozenUserMessage === overrideUserMessage ? "transaction-source-frozen" : "transaction-bound-to-chat-user-floor"), - includeSyntheticUserMessage: false, + authoritativeInputUsed: preserveAuthoritativeText, + boundUserFloorText: targetUserMessageText, + includeSyntheticUserMessage: preserveAuthoritativeText, }; } @@ -13271,12 +13319,12 @@ async function onRestoreCurrentChatFromCloud() { async () => { const chatId = getCurrentChatId(); if (!chatId) { - toastr.warning("当前没有聊天上下鏂?"); + toastr.warning("当前没有聊天上下文"); return { handledToast: true }; } const confirmed = globalThis.confirm?.( - "这会用云端备份完整覆盖当前聊天的本地记忆,并先保留一份本地安全快照。确定继续吗锛?, + "这会用云端备份完整覆盖当前聊天的本地记忆,并先保留一份本地安全快照。确定继续吗?", ); if (!confirmed) { return { cancelled: true }; diff --git a/llm/llm.js b/llm/llm.js index e54c844..0383eeb 100644 --- a/llm/llm.js +++ b/llm/llm.js @@ -457,6 +457,10 @@ function buildPromptExecutionSummary(debugContext = null) { debugContext.mvu && typeof debugContext.mvu === "object" ? cloneRuntimeDebugValue(debugContext.mvu, {}) : null, + inputContext: + debugContext.inputContext && typeof debugContext.inputContext === "object" + ? cloneRuntimeDebugValue(debugContext.inputContext, {}) + : null, regexInput: normalizeRegexDebugEntries(debugContext.regexInput), }; } diff --git a/maintenance/chat-history.js b/maintenance/chat-history.js index 22a96b4..4595e9c 100644 --- a/maintenance/chat-history.js +++ b/maintenance/chat-history.js @@ -283,6 +283,9 @@ export function buildExtractionMessages(chat, startIdx, endIdx, settings) { seq: index, role: msg.is_user ? "user" : "assistant", content, + rawContent: String(msg?.mes ?? ""), + name: String(msg?.name ?? "").trim(), + speaker: String(msg?.name ?? "").trim(), }); } diff --git a/maintenance/extraction-context.js b/maintenance/extraction-context.js new file mode 100644 index 0000000..201f05d --- /dev/null +++ b/maintenance/extraction-context.js @@ -0,0 +1,453 @@ +function splitConfigText(value = "") { + return String(value || "") + .split(/[\r\n,]+/) + .map((item) => String(item || "").trim()) + .filter(Boolean); +} + +function escapeRegex(value = "") { + return String(value || "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function normalizeBoundaryRule(rawRule, mode = "exclude", index = 0) { + if (typeof rawRule === "string") { + const tag = String(rawRule || "").trim(); + if (!tag) return null; + return { + id: `${mode}:tag:${index}:${tag}`, + mode, + kind: "tag", + label: tag, + tag, + }; + } + + if (!rawRule || typeof rawRule !== "object" || Array.isArray(rawRule)) { + return null; + } + + const tag = String(rawRule.tag || rawRule.name || "").trim(); + if (tag) { + return { + id: `${mode}:tag:${index}:${tag}`, + mode, + kind: "tag", + label: String(rawRule.label || tag).trim() || tag, + tag, + }; + } + + const start = String(rawRule.start ?? rawRule.open ?? rawRule.begin ?? "").trim(); + const end = String(rawRule.end ?? rawRule.close ?? rawRule.finish ?? "").trim(); + if (!start || !end) { + return null; + } + + return { + id: `${mode}:boundary:${index}`, + mode, + kind: "boundary", + label: String(rawRule.label || `${start} … ${end}`).trim() || `${start} … ${end}`, + start, + end, + caseSensitive: rawRule.caseSensitive === true, + }; +} + +function normalizeBoundaryRules(rawRules = null, rawTags = "", mode = "exclude") { + const values = []; + if (Array.isArray(rawRules)) { + values.push(...rawRules); + } else if (rawRules !== null && rawRules !== undefined && rawRules !== "") { + values.push(rawRules); + } + values.push(...splitConfigText(rawTags)); + + return values + .map((item, index) => normalizeBoundaryRule(item, mode, index)) + .filter(Boolean); +} + +function applyTagBoundaryRule(text, rule) { + const input = String(text || ""); + const escapedTag = escapeRegex(rule?.tag || ""); + if (!escapedTag) { + return { + changed: false, + output: input, + ruleLabel: String(rule?.label || ""), + matchedText: "", + }; + } + + const regex = new RegExp( + `<${escapedTag}\\b[^>]*>([\\s\\S]*?)<\\/${escapedTag}>`, + "gi", + ); + let match = null; + for (const candidate of input.matchAll(regex)) { + match = candidate; + } + if (!match) { + return { + changed: false, + output: input, + ruleLabel: String(rule?.label || ""), + matchedText: "", + }; + } + + const matchedText = String(match[0] || ""); + if (rule?.mode === "extract") { + return { + changed: true, + output: String(match[1] || "").trim(), + ruleLabel: String(rule?.label || rule?.tag || ""), + matchedText, + }; + } + + const matchIndex = Number(match.index); + if (!Number.isFinite(matchIndex) || matchIndex < 0) { + return { + changed: false, + output: input, + ruleLabel: String(rule?.label || rule?.tag || ""), + matchedText: "", + }; + } + + return { + changed: true, + output: `${input.slice(0, matchIndex)}${input.slice(matchIndex + matchedText.length)}`.trim(), + ruleLabel: String(rule?.label || rule?.tag || ""), + matchedText, + }; +} + +function applyLiteralBoundaryRule(text, rule) { + const input = String(text || ""); + const start = String(rule?.start || ""); + const end = String(rule?.end || ""); + if (!start || !end) { + return { + changed: false, + output: input, + ruleLabel: String(rule?.label || ""), + matchedText: "", + }; + } + + const sourceText = rule?.caseSensitive === true ? input : input.toLowerCase(); + const startNeedle = rule?.caseSensitive === true ? start : start.toLowerCase(); + const endNeedle = rule?.caseSensitive === true ? end : end.toLowerCase(); + const startIndex = sourceText.lastIndexOf(startNeedle); + if (startIndex < 0) { + return { + changed: false, + output: input, + ruleLabel: String(rule?.label || ""), + matchedText: "", + }; + } + + const endIndex = sourceText.indexOf(endNeedle, startIndex + startNeedle.length); + if (endIndex < 0) { + return { + changed: false, + output: input, + ruleLabel: String(rule?.label || ""), + matchedText: "", + }; + } + + const matchedText = input.slice(startIndex, endIndex + end.length); + if (rule?.mode === "extract") { + return { + changed: true, + output: input.slice(startIndex + start.length, endIndex).trim(), + ruleLabel: String(rule?.label || ""), + matchedText, + }; + } + + return { + changed: true, + output: `${input.slice(0, startIndex)}${input.slice(endIndex + end.length)}`.trim(), + ruleLabel: String(rule?.label || ""), + matchedText, + }; +} + +function applyBoundaryRule(text, rule) { + if (rule?.kind === "tag") { + return applyTagBoundaryRule(text, rule); + } + if (rule?.kind === "boundary") { + return applyLiteralBoundaryRule(text, rule); + } + return { + changed: false, + output: String(text || ""), + ruleLabel: String(rule?.label || ""), + matchedText: "", + }; +} + +function applyFirstExtractRule(text, rules = []) { + const input = String(text || ""); + for (const rule of Array.isArray(rules) ? rules : []) { + const result = applyBoundaryRule(input, rule); + if (result.changed) { + return { + changed: true, + output: result.output, + operation: { + mode: "extract", + rule: result.ruleLabel, + matchedLength: String(result.matchedText || "").length, + }, + }; + } + } + return { + changed: false, + output: input, + operation: null, + }; +} + +function applyExcludeRules(text, rules = []) { + const input = String(text || ""); + let output = input; + const operations = []; + + for (const rule of Array.isArray(rules) ? rules : []) { + const result = applyBoundaryRule(output, rule); + if (!result.changed) { + continue; + } + output = result.output; + operations.push({ + mode: "exclude", + rule: result.ruleLabel, + matchedLength: String(result.matchedText || "").length, + }); + } + + return { + changed: output !== input, + output, + operations, + }; +} + +function normalizeRole(value = "") { + const role = String(value || "assistant").trim().toLowerCase(); + if (["user", "assistant", "system"].includes(role)) { + return role; + } + return role === "ai" ? "assistant" : "assistant"; +} + +function resolveMessageContent(message = {}) { + if (typeof message?.content === "string") { + return message.content; + } + if (typeof message?.mes === "string") { + return message.mes; + } + return ""; +} + +function resolveMessageRawContent(message = {}) { + if (typeof message?.rawContent === "string") { + return message.rawContent; + } + if (typeof message?.mes === "string") { + return message.mes; + } + if (typeof message?.content === "string") { + return message.content; + } + return ""; +} + +function resolveSpeakerName(message = {}, role = "assistant", names = {}) { + const explicitSpeaker = String( + message?.speaker ?? message?.name ?? message?.displayName ?? "", + ).trim(); + if (explicitSpeaker) { + return explicitSpeaker; + } + if (role === "user") { + return String(names?.userName || "用户").trim() || "用户"; + } + if (role === "assistant") { + return String(names?.charName || "角色").trim() || "角色"; + } + return role || "assistant"; +} + +function normalizeExtractionMessage(message = {}, index = 0, names = {}) { + const role = normalizeRole( + message?.role ?? (message?.is_user === true ? "user" : "assistant"), + ); + const content = String(resolveMessageContent(message) || "").trim(); + const rawContent = String(resolveMessageRawContent(message) || content).trim(); + const speaker = resolveSpeakerName(message, role, names); + const seq = Number.isFinite(Number(message?.seq)) ? Number(message.seq) : null; + + return { + index, + seq, + role, + speaker, + name: speaker, + content, + rawContent, + sourceType: role === "user" ? "user_input" : "ai_output", + }; +} + +function countRoles(messages = []) { + return (Array.isArray(messages) ? messages : []).reduce( + (acc, message) => { + const role = normalizeRole(message?.role || "assistant"); + acc[role] = Number(acc[role] || 0) + 1; + return acc; + }, + { user: 0, assistant: 0, system: 0 }, + ); +} + +export function formatExtractionTranscript(messages = []) { + return (Array.isArray(messages) ? messages : []) + .map((message, index) => { + const seqLabel = Number.isFinite(Number(message?.seq)) + ? `#${Number(message.seq)}` + : `#${index + 1}`; + const role = normalizeRole(message?.role || "assistant"); + const speaker = String(message?.speaker || message?.name || "").trim(); + const speakerLabel = speaker ? `|${speaker}` : ""; + return `${seqLabel} [${role}${speakerLabel}]: ${String(message?.content || "")}`; + }) + .filter((item) => String(item || "").trim()) + .join("\n\n"); +} + +export function buildExtractionInputContext( + messages = [], + { settings = {}, userName = "", charName = "" } = {}, +) { + const normalizedMessages = (Array.isArray(messages) ? messages : []) + .map((message, index) => normalizeExtractionMessage(message, index, { + userName, + charName, + })) + .filter( + (message) => + String(message?.content || "").trim().length > 0 || + String(message?.rawContent || "").trim().length > 0, + ); + + const extractRules = normalizeBoundaryRules( + settings?.extractAssistantExtractRules, + settings?.extractAssistantExtractTags, + "extract", + ); + const excludeRules = normalizeBoundaryRules( + settings?.extractAssistantExcludeRules, + settings?.extractAssistantExcludeTags, + "exclude", + ); + + const filteredMessages = []; + const messageOperations = []; + let changedAssistantMessageCount = 0; + let droppedAssistantMessageCount = 0; + let extractedAssistantMessageCount = 0; + let excludedAssistantMessageCount = 0; + + for (const message of normalizedMessages) { + const operations = []; + let nextContent = String(message.content || ""); + + if (message.role === "assistant") { + const extractResult = applyFirstExtractRule(nextContent, extractRules); + if (extractResult.changed) { + nextContent = extractResult.output; + extractedAssistantMessageCount += 1; + operations.push(extractResult.operation); + } + + const excludeResult = applyExcludeRules(nextContent, excludeRules); + if (excludeResult.changed) { + nextContent = excludeResult.output; + excludedAssistantMessageCount += 1; + operations.push(...excludeResult.operations); + } + } + + const normalizedContent = String(nextContent || "").trim(); + if (operations.length > 0 || normalizedContent !== String(message.content || "").trim()) { + if (message.role === "assistant") { + changedAssistantMessageCount += 1; + } + messageOperations.push({ + seq: message.seq, + role: message.role, + speaker: message.speaker, + beforeLength: String(message.content || "").length, + afterLength: normalizedContent.length, + operations, + }); + } + + if (!normalizedContent) { + if (message.role === "assistant" && String(message.content || "").trim()) { + droppedAssistantMessageCount += 1; + } + continue; + } + + filteredMessages.push({ + ...message, + content: normalizedContent, + extractionFilterOperations: operations, + }); + } + + const rawTranscript = formatExtractionTranscript( + normalizedMessages.filter((message) => String(message.content || "").trim()), + ); + const filteredTranscript = formatExtractionTranscript(filteredMessages); + + return { + rawMessages: normalizedMessages, + filteredMessages, + rawTranscript, + filteredTranscript, + debug: { + rawMessageCount: normalizedMessages.length, + filteredMessageCount: filteredMessages.length, + rawRoleCounts: countRoles(normalizedMessages), + filteredRoleCounts: countRoles(filteredMessages), + rawTranscriptLength: rawTranscript.length, + filteredTranscriptLength: filteredTranscript.length, + changedAssistantMessageCount, + droppedAssistantMessageCount, + extractedAssistantMessageCount, + excludedAssistantMessageCount, + assistantBoundaryConfig: { + extractRuleCount: extractRules.length, + excludeRuleCount: excludeRules.length, + extractRules: extractRules.map((rule) => rule.label), + excludeRules: excludeRules.map((rule) => rule.label), + }, + rawMessages: normalizedMessages, + filteredMessages, + messageOperations, + }, + }; +} diff --git a/maintenance/extractor.js b/maintenance/extractor.js index 40ad690..efafd7b 100644 --- a/maintenance/extractor.js +++ b/maintenance/extractor.js @@ -32,8 +32,10 @@ import { deriveStoryTimeSpanFromNodes, describeNodeStoryTime, normalizeStoryTime, + resolveActiveStoryContext, upsertTimelineSegment, } from "../graph/story-timeline.js"; +import { getActiveSummaryEntries } from "../graph/summary-state.js"; import { buildTaskExecutionDebugContext, buildTaskLlmPayload, @@ -42,6 +44,7 @@ import { import { RELATION_TYPES } from "../graph/schema.js"; import { applyTaskRegex } from "../prompting/task-regex.js"; import { getSTContextForPrompt, getSTContextSnapshot } from "../host/st-context.js"; +import { buildExtractionInputContext } from "./extraction-context.js"; import { aliasSetMatchesValue, buildUserPovAliasNormalizedSet, @@ -61,6 +64,17 @@ function createTaskLlmDebugContext(promptBuild, regexInput) { : null; } +function createExtractTaskLlmDebugContext(promptBuild, regexInput, inputContext = null) { + const debugContext = createTaskLlmDebugContext(promptBuild, regexInput); + if (!inputContext || typeof inputContext !== "object") { + return debugContext; + } + return { + ...debugContext, + inputContext, + }; +} + function resolveTaskPromptPayload(promptBuild, fallbackUserPrompt = "") { if (typeof buildTaskLlmPayload === "function") { return buildTaskLlmPayload(promptBuild, fallbackUserPrompt); @@ -86,6 +100,54 @@ function resolveTaskLlmSystemPrompt(promptPayload, fallbackSystemPrompt = "") { return String(promptPayload?.systemPrompt || fallbackSystemPrompt || ""); } +function buildActiveSummariesText(graph) { + const entries = getActiveSummaryEntries(graph); + if (!Array.isArray(entries) || entries.length === 0) return ""; + return entries + .map((entry, index) => { + const rangeLabel = Array.isArray(entry.messageRange) && entry.messageRange.length >= 2 + && entry.messageRange[0] >= 0 && entry.messageRange[1] >= 0 + ? `楼${entry.messageRange[0]}~${entry.messageRange[1]}` + : ""; + const levelLabel = entry.level ? `L${entry.level}` : ""; + const prefix = [rangeLabel, levelLabel].filter(Boolean).join(" "); + return `[${index + 1}]${prefix ? ` (${prefix})` : ""} ${String(entry.text || entry.summary || "").trim()}`; + }) + .filter((line) => line.trim()) + .join("\n"); +} + +function buildStoryTimeContextText(graph) { + const storyCtx = resolveActiveStoryContext(graph); + if (!storyCtx?.resolved) return ""; + const parts = []; + if (storyCtx.activeStoryTimeLabel) { + parts.push(`当前活跃剧情时间:${storyCtx.activeStoryTimeLabel}`); + } + if (storyCtx.source) { + parts.push(`来源:${storyCtx.source}`); + } + const seg = storyCtx.segment; + if (seg?.tense && seg.tense !== "unknown") { + parts.push(`时态:${seg.tense}`); + } + return parts.join(" | "); +} + +function applyRecentMessageCap(messages, cap = 0) { + if (!Array.isArray(messages) || messages.length === 0) return messages; + const numericCap = Number(cap); + if (!Number.isFinite(numericCap) || numericCap <= 0) return messages; + if (messages.length <= numericCap) return messages; + return messages.slice(-numericCap); +} + +function resolveExtractPromptStructuredMode(settings) { + const mode = String(settings?.extractPromptStructuredMode || "both").trim().toLowerCase(); + if (["transcript", "structured", "both"].includes(mode)) return mode; + return "both"; +} + function isAbortError(error) { return error?.name === "AbortError"; } @@ -799,13 +861,42 @@ export async function extractMemories({ `[ST-BME] 提取开始: chat[${effectiveStartSeq}..${effectiveEndSeq}], ${messages.length} 条消息`, ); - // 构建对话文本 - const dialogueText = messages - .map((m) => { - const seqLabel = Number.isFinite(m.seq) ? `#${m.seq}` : "#?"; - return `${seqLabel} [${m.role}]: ${m.content}`; - }) - .join("\n\n"); + const extractionInput = buildExtractionInputContext(messages, { + settings, + userName: stContext?.prompt?.userName || "", + charName: stContext?.prompt?.charName || "", + }); + const allStructuredMessages = Array.isArray(extractionInput?.filteredMessages) + ? extractionInput.filteredMessages.map((message) => ({ + seq: message?.seq, + role: message?.role, + content: message?.content, + speaker: message?.speaker, + name: message?.name, + })) + : []; + + // Phase 3: apply recent message cap + const structuredMessages = applyRecentMessageCap( + allStructuredMessages, + settings?.extractRecentMessageCap, + ); + const cappedMessageCount = allStructuredMessages.length - structuredMessages.length; + if (cappedMessageCount > 0) { + debugLog( + `[ST-BME][extract-p3] extractRecentMessageCap=${settings?.extractRecentMessageCap}, ` + + `capped ${cappedMessageCount} messages (${allStructuredMessages.length} -> ${structuredMessages.length})`, + ); + } + + // Phase 3: structured mode determines what goes into recentMessages/dialogueText + const structuredMode = resolveExtractPromptStructuredMode(settings); + const dialogueText = structuredMode === "structured" + ? "" + : String(extractionInput?.filteredTranscript || ""); + const promptRecentMessages = structuredMode === "transcript" + ? dialogueText + : structuredMessages; // 构建当前图概览(让 LLM 知道已有哪些节点,避免重复) const graphOverview = buildGraphOverview(graph, schema); @@ -817,16 +908,36 @@ export async function extractMemories({ ? `${messages[0]?.seq ?? "?"} ~ ${messages[messages.length - 1]?.seq ?? "?"}` : ""; + // Phase 3: layered context — active summaries and story time + const activeSummaries = settings?.extractIncludeSummaries !== false + ? buildActiveSummariesText(graph) + : ""; + const storyTimeContext = settings?.extractIncludeStoryTime !== false + ? buildStoryTimeContextText(graph) + : ""; + + debugLog( + `[ST-BME][extract-p3] structuredMode=${structuredMode}, ` + + `activeSummaries=${activeSummaries ? activeSummaries.split("\n").length + " entries" : "none"}, ` + + `storyTimeContext=${storyTimeContext ? "present" : "none"}, ` + + `worldbookMode=${String(settings?.extractWorldbookMode || "active")}`, + ); + + const extractWorldbookMode = String(settings?.extractWorldbookMode || "active").trim().toLowerCase(); const promptBuild = await buildTaskPrompt(settings, "extract", { taskName: "extract", schema: schemaDescription, schemaDescription, - recentMessages: dialogueText, - chatMessages: messages, + recentMessages: promptRecentMessages, + chatMessages: structuredMessages, dialogueText, graphStats: graphOverview, graphOverview, currentRange, + activeSummaries, + storyTimeContext, + taskInputDebug: extractionInput?.debug || null, + __skipWorldInfo: extractWorldbookMode === "none", ...getSTContextForPrompt(), }); @@ -843,19 +954,50 @@ export async function extractMemories({ "system", ); - // 用户提示词 - const userPrompt = [ - "## 当前对话内容(需提取记忆)", - dialogueText, - "", + // 用户提示词 — Phase 3 分层信息结构 + const userPromptSections = []; + + // Layer 1: 当前对话切片 + if (dialogueText) { + userPromptSections.push("## 当前对话内容(需提取记忆)", dialogueText, ""); + } else if (structuredMode === "structured" && structuredMessages.length > 0) { + userPromptSections.push( + "## 当前对话内容(结构化消息,需提取记忆)", + "(结构化消息已通过 profile blocks 注入,请参考上方 recentMessages 块。)", + "", + ); + } + + // Layer 2: 当前图谱状态 + userPromptSections.push( "## 当前图谱状态", graphOverview || "(空图谱,尚无节点)", "", - "## 节点类型定义", - schemaDescription, - "", - "请分析对话,按 JSON 格式输出操作列表。", - ].join("\n"); + ); + + // Layer 3: 已有总结快照(帮助避免重复提取) + if (activeSummaries) { + userPromptSections.push( + "## 近期局面总结(已有覆盖,避免重复)", + activeSummaries, + "", + ); + } + + // Layer 4: 故事时间线位置 + if (storyTimeContext) { + userPromptSections.push( + "## 当前故事时间", + storyTimeContext, + "", + ); + } + + // Layer 5: 节点类型定义 + userPromptSections.push("## 节点类型定义", schemaDescription, ""); + + userPromptSections.push("请分析对话,按 JSON 格式输出操作列表。"); + const userPrompt = userPromptSections.join("\n"); const promptPayload = resolveTaskPromptPayload(promptBuild, userPrompt); const extractionAugmentPrompt = buildCognitiveExtractAugmentPrompt(); const promptPayloadAdditionalMessages = Array.isArray( @@ -904,6 +1046,16 @@ export async function extractMemories({ `[ST-BME][prompt-diag] NO user messages in promptMessages! Fallback userPrompt will be used.`, ); } + if (extractionInput?.debug) { + debugLog( + `[ST-BME][extract-input] raw=${Number(extractionInput.debug.rawMessageCount || 0)}, ` + + `filtered=${Number(extractionInput.debug.filteredMessageCount || 0)}, ` + + `assistantChanged=${Number(extractionInput.debug.changedAssistantMessageCount || 0)}, ` + + `assistantDropped=${Number(extractionInput.debug.droppedAssistantMessageCount || 0)}, ` + + `extractRules=${Number(extractionInput.debug.assistantBoundaryConfig?.extractRuleCount || 0)}, ` + + `excludeRules=${Number(extractionInput.debug.assistantBoundaryConfig?.excludeRuleCount || 0)}`, + ); + } } // 调用 LLM @@ -913,7 +1065,11 @@ export async function extractMemories({ maxRetries: 2, signal, taskType: "extract", - debugContext: createTaskLlmDebugContext(promptBuild, extractRegexInput), + debugContext: createExtractTaskLlmDebugContext( + promptBuild, + extractRegexInput, + extractionInput?.debug || null, + ), promptMessages: promptPayload.promptMessages, additionalMessages: promptPayloadAdditionalMessages, onStreamProgress, diff --git a/prompting/default-task-profile-templates.js b/prompting/default-task-profile-templates.js index 1758366..03c0927 100644 --- a/prompting/default-task-profile-templates.js +++ b/prompting/default-task-profile-templates.js @@ -133,6 +133,30 @@ export const DEFAULT_TASK_PROFILE_TEMPLATES = { "injectionMode": "relative", "order": 9 }, + { + "id": "default-active-summaries", + "name": "活跃总结", + "type": "builtin", + "enabled": true, + "role": "system", + "sourceKey": "activeSummaries", + "sourceField": "", + "content": "", + "injectionMode": "relative", + "order": 10 + }, + { + "id": "default-story-time-context", + "name": "故事时间", + "type": "builtin", + "enabled": true, + "role": "system", + "sourceKey": "storyTimeContext", + "sourceField": "", + "content": "", + "injectionMode": "relative", + "order": 11 + }, { "id": "default-format", "name": "输出格式", @@ -143,7 +167,7 @@ export const DEFAULT_TASK_PROFILE_TEMPLATES = { "sourceField": "", "content": "请只输出一个合法 JSON 对象:\n{\n \"thought\": \"简要分析这批对话里真正值得入图的变化\",\n \"batchStoryTime\": {\n \"label\": \"第二天清晨\",\n \"tense\": \"ongoing\",\n \"relation\": \"after\",\n \"anchorLabel\": \"昨夜冲突之后\",\n \"confidence\": \"high\",\n \"advancesActiveTimeline\": true\n },\n \"operations\": [\n {\n \"action\": \"create\",\n \"type\": \"event\",\n \"fields\": {\"title\": \"简短事件名\", \"summary\": \"...\", \"participants\": \"...\", \"status\": \"ongoing\"},\n \"scope\": {\"layer\": \"objective\", \"regionPrimary\": \"主地区\", \"regionPath\": [\"上级地区\", \"主地区\"], \"regionSecondary\": [\"次级地区\"]},\n \"storyTime\": {\"label\": \"第二天清晨\", \"tense\": \"ongoing\", \"relation\": \"same\", \"confidence\": \"high\"},\n \"importance\": 6,\n \"ref\": \"evt1\"\n },\n {\n \"action\": \"create\",\n \"type\": \"pov_memory\",\n \"fields\": {\"summary\": \"这个角色会怎么记住这件事\", \"belief\": \"她认为发生了什么\", \"emotion\": \"情绪\", \"attitude\": \"态度\", \"certainty\": \"unsure\", \"about\": \"evt1\"},\n \"scope\": {\"layer\": \"pov\", \"ownerType\": \"character\", \"ownerId\": \"角色名\", \"ownerName\": \"角色名\", \"regionPrimary\": \"主地区\", \"regionPath\": [\"上级地区\", \"主地区\"]},\n \"storyTime\": {\"label\": \"第二天清晨\", \"tense\": \"ongoing\", \"relation\": \"same\", \"confidence\": \"high\"}\n }\n ],\n \"cognitionUpdates\": [\n {\n \"ownerType\": \"character\",\n \"ownerName\": \"艾琳\",\n \"ownerNodeId\": \"char-1\",\n \"knownRefs\": [\"evt1\", \"char2\"],\n \"mistakenRefs\": [\"evt2\"],\n \"visibility\": [\n {\"ref\": \"evt1\", \"score\": 1.0, \"reason\": \"direct witness\"},\n {\"ref\": \"thread-1\", \"score\": 0.55, \"reason\": \"heard nearby\"}\n ]\n }\n ],\n \"regionUpdates\": {\n \"activeRegionHint\": \"钟楼\",\n \"adjacency\": [\n {\"region\": \"钟楼\", \"adjacent\": [\"旧城区\", \"内廷\"]}\n ]\n }\n}\n如果要更新已有节点,可使用 {\"action\":\"update\",\"nodeId\":\"existing-node-id\",\"fields\":{...},\"scope\":{...}}。\nknownRefs / mistakenRefs / visibility.ref 优先引用同批 ref,没有 ref 再引用已有 nodeId。\n如果这一批主叙事时间能判断,尽量填写 batchStoryTime;operations[].storyTime 可以单独覆盖,不写时视为继承本批主时间。\n如果这批对话没有值得入图的新信息,返回 {\"thought\":\"...\", \"operations\": [], \"cognitionUpdates\": [], \"regionUpdates\": {}}。", "injectionMode": "relative", - "order": 10 + "order": 12 }, { "id": "default-rules", @@ -155,7 +179,7 @@ export const DEFAULT_TASK_PROFILE_TEMPLATES = { "sourceField": "", "content": "我对你的执行标准是这样的——\n- 先帮我做事件分级,再决定要不要建节点:\n · A级(转折点):关系质变、告白、背叛、决裂、不可逆改变、重大选择 -> importance 8-10,必记\n · B级(推进点):新信息、新联系、阶段性完成、有意义的位置移动 -> importance 5-7,按信息量建节点\n · C级(填充):日常对话、重复行为、无后续影响的闲聊 -> 通常不单独建节点\n- 每批帮我收敛成少量高价值操作就好;通常 1 个 event,加上必要的 update、必要的 POV 和记忆认知更新就够了。\n- 客观事实帮我优先用 event / character / location / thread / rule / synopsis / reflection。\n- 主观记忆统一使用 type = pov_memory,不要拿 character / location / event 去伪装第一视角记忆。\n- 客观节点 scope.layer 必须是 objective;POV 节点 scope.layer 必须是 pov,并且必须写 ownerType / ownerId / ownerName。\n- 涉及到的角色都尽量尝试补 cognitionUpdates,不只限当前角色和用户。\n- cognitionUpdates 只表达谁明确知道、谁误解、谁低置信可见;不要帮我写成第二份事实节点。\n- 多角色场景里,pov_memory 和 cognitionUpdates 必须写清具体人物;不要把角色卡名当作 POV owner。\n- 用户 POV 不等于角色已知事实;它是我作为用户/玩家侧的感受、承诺、偏见和长期互动背景。\n- batchStoryTime 表示这批主叙事所处的剧情时间;只有明确推进主叙事时才把 advancesActiveTimeline 设为 true。\n- operations[].storyTime 写节点自己的剧情时间;帮我区分\"故事里什么时候发生\"和\"聊天里什么时候被提到\"。\n- flashback / future / hypothetical 可以写时间,但通常不要推进当前活动时间轴。\n- 地区能判断才写 scope.regionPrimary / regionPath / regionSecondary;判断不出来就帮我留空。\n- 角色、地点等 latestOnly 节点如果图里已有同名同作用域节点,优先帮我 update,不要重复 create。\n\n客观层字段方面我的要求是——\n- event.title 只写简短事件名,6-18 字。\n- event.summary 用白描复述事实,150 字以内,不抒情不评价。\n- participants 用逗号分隔参与者。\n- character / location 的字段也用白描,不写主观评价。\n\nPOV 记忆字段方面我的要求是——\npov_memory 要像角色真的会留下的记忆痕迹,不是客观事件的换个说法。\n\n- **summary**:帮我写\"这个角色会怎么记住这件事\"\n · 不是客观事件摘要,是主观记忆痕迹\n · 用角色的人格语气(温柔?冷淡?戏谑?怯懦?警觉?)\n · 可以是碎念、独白、关系定位、感官片段——看角色性格\n · 只包含角色真实看到、听到、感受到的内容(非全知)\n · 示例:\n × \"角色A和用户在咖啡馆聊天,谈到了工作\"(客观复述,我不要这种)\n √ \"他今天一直在揉太阳穴。我问他要不要换个话题,他说没事。他说没事的时候眼睛没看我。\"(主观记忆,我要这种)\n\n- **belief**:角色认为发生了什么\n · 可能与客观事实不同——这正是 POV 价值所在\n · 如果角色误解了真相,belief 要帮我反映这个误解\n\n- **emotion**:当时最强烈的情感\n · 帮我写具体感受,不写\"开心\"\"难过\"这种标签\n · 示例:\n × \"开心\"\n √ \"胸口像被什么顶着,想说点什么又说不出来\"\n\n- **attitude**:角色对这件事或相关人的态度(可能发生了变化)\n\n- **certainty**:\n · certain = 亲历确认,非常肯定\n · unsure = 间接得知或只看到片段\n · mistaken = 明确误解了事实\n\n- **about**:关联的事件或实体,优先引用同批 ref,没有 ref 再用简短标签\n\nvisibility.score 取 0..1;1 表示亲历或明确得知,0.5 左右表示间接听闻,0.2 左右表示远远瞥见。\n时间推不出来就留空,不允许为了补全格式硬编剧情时间标签。\n\n以下是我特别不想看到的——\n- 编造对话里没有的事件、地区、想法、认知状态或邻接关系。\n- 把角色 POV、用户 POV、客观事实混成同一个节点。\n- 让 POV 记忆拥有该视角不可能知道的信息(全知错误)。\n- 所有角色的 POV 都用同一种语气写(应该各有各的人格印记)。\n- POV summary 写成客观事件的换皮复述。\n- emotion 只写标签词,不写具体感受。\n- 只为显得全面就给所有角色都硬写 POV 或 cognitionUpdates。\n- 把 cognitionUpdates 当硬白名单或第二份世界事实表。\n- 把后面才说到的事情误判成后面才发生,或把未来计划当成已经发生的当前事实。\n- 把角色卡名、群像统称或旁白身份当成具体 POV owner。\n- 地区不确定却硬写一个像地区的词。\n- 为了显得全面而生成很多低价值碎节点。\n- 直接复制原文,或写成文学化修辞。", "injectionMode": "relative", - "order": 11 + "order": 13 } ], "generation": { diff --git a/prompting/prompt-builder.js b/prompting/prompt-builder.js index 1b9c3cd..0dc5fb1 100644 --- a/prompting/prompt-builder.js +++ b/prompting/prompt-builder.js @@ -181,6 +181,10 @@ export function buildTaskExecutionDebugContext( promptDebug.mvu && typeof promptDebug.mvu === "object" ? cloneRuntimeDebugValue(promptDebug.mvu, {}) : null, + inputContext: + promptDebug.inputContext && typeof promptDebug.inputContext === "object" + ? cloneRuntimeDebugValue(promptDebug.inputContext, {}) + : null, regexInput: (() => { const merged = mergeRegexCollectors( @@ -284,18 +288,26 @@ function getPromptMessageLikeDescriptor(value) { if (typeof value.content === "string") { const role = String(value.role || "assistant").trim().toLowerCase(); + const speaker = String( + value.speaker || value.name || value.displayName || "", + ).trim(); return { content: String(value.content || ""), role: role === "user" ? "user" : "assistant", seq: getOptionalFiniteNumber(value.seq), + speaker, }; } if (typeof value.mes === "string") { + const speaker = String( + value.speaker || value.name || value.displayName || "", + ).trim(); return { content: String(value.mes || ""), role: value.is_user === true ? "user" : "assistant", seq: getOptionalFiniteNumber(value.seq), + speaker, }; } @@ -320,7 +332,10 @@ function formatPromptMessageTranscript(value) { } const seqLabel = descriptor.seq != null ? `#${descriptor.seq}` : `#${index + 1}`; - return `${seqLabel} [${descriptor.role}]: ${descriptor.content}`; + const speakerLabel = descriptor.speaker + ? `|${descriptor.speaker}` + : ""; + return `${seqLabel} [${descriptor.role}${speakerLabel}]: ${descriptor.content}`; }) .filter(Boolean) .join("\n\n"); @@ -766,6 +781,32 @@ function sanitizePromptContextInputs( return value; } seen.add(value); + const messageDescriptor = getPromptMessageLikeDescriptor(value); + if (messageDescriptor) { + const contentKey = typeof value.content === "string" + ? "content" + : typeof value.mes === "string" + ? "mes" + : ""; + const messageRole = messageDescriptor.role === "user" + ? "user" + : messageDescriptor.role === "assistant" + ? "assistant" + : regexRole; + return Object.fromEntries( + Object.entries(value).map(([key, entryValue]) => [ + key, + key === contentKey + ? applyLocalRegexToStructuredValue( + entryValue, + regexStage, + messageRole, + seen, + ) + : entryValue, + ]), + ); + } return Object.fromEntries( Object.entries(value).map(([key, entryValue]) => [ key, @@ -821,14 +862,14 @@ function sanitizePromptContextInputs( ? "" : null : sanitized.value; - if (structuredSanitizerInput.renderAsTranscript) { - sanitizedValue = stringifyInterpolatedValue(sanitizedValue); - } sanitizedValue = applyLocalRegexToStructuredValue( sanitizedValue, regexStage, regexRole, ); + if (structuredSanitizerInput.renderAsTranscript) { + sanitizedValue = stringifyInterpolatedValue(sanitizedValue); + } sanitizedContext[fieldName] = sanitizedValue; } @@ -1407,6 +1448,10 @@ export async function buildTaskPrompt(settings = {}, taskType, context = {}) { const legacyPrompt = getLegacyPromptForTask(settings, taskType); const promptRegexInput = { entries: [] }; const mvuPromptDebug = createEmptyMvuPromptDebug(); + const taskInputDebug = + context?.taskInputDebug && typeof context.taskInputDebug === "object" + ? cloneRuntimeDebugValue(context.taskInputDebug, {}) + : null; const worldInfoInputContext = { ...context, }; @@ -1430,7 +1475,9 @@ export async function buildTaskPrompt(settings = {}, taskType, context = {}) { return orderA - orderB; }); - const worldInfoRequested = profileRequiresWorldInfo(profile); + const worldInfoRequested = context?.__skipWorldInfo === true + ? false + : profileRequiresWorldInfo(profile); const emptyWorldInfo = buildEmptyWorldInfoContext(); let resolvedWorldInfo = emptyWorldInfo; let worldInfoRuntimeBlockedContents = []; @@ -1771,6 +1818,7 @@ export async function buildTaskPrompt(settings = {}, taskType, context = {}) { ), fallbackReason: String(mvuPromptDebug.fallbackReason || ""), }, + inputContext: taskInputDebug, effectivePath: { promptAssembly: "ordered-private-messages", hostInjectionPlan: "diagnostic-plan-only", @@ -1809,6 +1857,7 @@ export async function buildTaskPrompt(settings = {}, taskType, context = {}) { hostInjectionPlan, worldInfoResolution, mvu: result.debug.mvu, + inputContext: taskInputDebug, regexInput: result.regexInput, debug: result.debug, }); diff --git a/prompting/prompt-profiles.js b/prompting/prompt-profiles.js index ddd7ba4..a19e2ed 100644 --- a/prompting/prompt-profiles.js +++ b/prompting/prompt-profiles.js @@ -158,6 +158,18 @@ const BUILTIN_BLOCK_DEFINITIONS = [ role: "system", description: "注入近期检测到的记忆矛盾或冲突信息。reflection 任务专用,触发基于矛盾的深度反思。", }, + { + sourceKey: "activeSummaries", + name: "活跃总结", + role: "system", + description: "注入当前活跃的分层总结快照。extract 任务使用,帮助 LLM 了解近期已总结的局面,避免重复提取已覆盖内容。", + }, + { + sourceKey: "storyTimeContext", + name: "故事时间", + role: "system", + description: "注入当前活跃的故事时间线标签与来源。extract 任务使用,帮助 LLM 定位本批对话在剧情时间轴上的位置。", + }, ]; const DEFAULT_TASK_PROFILE_VERSION = 3; @@ -489,6 +501,20 @@ const TASK_CONTEXT_BLOCK_BLUEPRINTS = { role: "system", sourceKey: "currentRange", }, + { + id: "default-active-summaries", + name: "活跃总结", + type: "builtin", + role: "system", + sourceKey: "activeSummaries", + }, + { + id: "default-story-time-context", + name: "故事时间", + type: "builtin", + role: "system", + sourceKey: "storyTimeContext", + }, ], recall: [ { diff --git a/retrieval/recall-controller.js b/retrieval/recall-controller.js index 03c41d4..ebb1a7d 100644 --- a/retrieval/recall-controller.js +++ b/retrieval/recall-controller.js @@ -86,6 +86,10 @@ export function resolveRecallInputController( override?.overrideReason || "override-bound", ), + authoritativeInputUsed: Boolean(override?.authoritativeInputUsed), + boundUserFloorText: runtime.normalizeRecallInputText( + override?.boundUserFloorText || "", + ), sourceCandidates: Array.isArray(override?.sourceCandidates) ? override.sourceCandidates.map((candidate) => ({ ...candidate })) : [], @@ -145,6 +149,8 @@ export function resolveRecallInputController( source, sourceLabel: runtime.getRecallUserMessageSourceLabel(source), reason: userMessage ? `${source || "unknown"}-selected` : "no-recall-input", + authoritativeInputUsed: false, + boundUserFloorText: tailUserText || latestUserText || "", sourceCandidates: [], recentMessages: runtime.buildRecallRecentMessages( chat, @@ -212,6 +218,8 @@ export function applyRecallInjectionController( source: recallInput.source, sourceLabel: recallInput.sourceLabel, reason: recallInput.reason || "", + authoritativeInputUsed: Boolean(recallInput.authoritativeInputUsed), + boundUserFloorText: String(recallInput.boundUserFloorText || ""), sourceCandidates: Array.isArray(recallInput.sourceCandidates) ? recallInput.sourceCandidates.map((candidate) => ({ ...candidate })) : [], @@ -475,6 +483,8 @@ export async function runRecallController(runtime, options = {}) { source: recallInput?.source || cachedRecallPayload.source || "", sourceLabel: recallInput?.sourceLabel || cachedRecallPayload.sourceLabel || "", + authoritativeInputUsed: Boolean(recallInput?.authoritativeInputUsed), + boundUserFloorText: String(recallInput?.boundUserFloorText || ""), hookName: recallInput?.hookName || "", sourceCandidates: Array.isArray(recallInput?.sourceCandidates) ? recallInput.sourceCandidates.map((candidate) => ({ @@ -531,6 +541,8 @@ export async function runRecallController(runtime, options = {}) { "immediate", source: recallInput?.source || "", sourceLabel: recallInput?.sourceLabel || "", + authoritativeInputUsed: Boolean(recallInput?.authoritativeInputUsed), + boundUserFloorText: String(recallInput?.boundUserFloorText || ""), hookName: recallInput?.hookName || "", sourceCandidates: Array.isArray(recallInput?.sourceCandidates) ? recallInput.sourceCandidates.map((candidate) => ({ ...candidate })) diff --git a/runtime/settings-defaults.js b/runtime/settings-defaults.js index e5d2c62..2ba320a 100644 --- a/runtime/settings-defaults.js +++ b/runtime/settings-defaults.js @@ -20,6 +20,15 @@ export const defaultSettings = { extractEvery: 1, extractContextTurns: 2, extractAutoDelayLatestAssistant: false, + extractAssistantExtractTags: "", + extractAssistantExcludeTags: "think,analysis,reasoning", + extractAssistantExtractRules: [], + extractAssistantExcludeRules: [], + extractRecentMessageCap: 0, + extractPromptStructuredMode: "both", + extractWorldbookMode: "active", + extractIncludeStoryTime: true, + extractIncludeSummaries: true, // 召回设置 recallEnabled: true, @@ -34,6 +43,7 @@ export const defaultSettings = { recallDiffusionTopK: 100, recallLlmCandidatePool: 30, recallLlmContextMessages: 4, + recallUseAuthoritativeGenerationInput: false, recallEnableMultiIntent: true, recallMultiIntentMaxSegments: 4, recallEnableContextQueryBlend: true, diff --git a/tests/default-settings.mjs b/tests/default-settings.mjs index efc451f..4feaec5 100644 --- a/tests/default-settings.mjs +++ b/tests/default-settings.mjs @@ -14,6 +14,7 @@ assert.equal(defaultSettings.recallEnableGraphDiffusion, true); assert.equal(defaultSettings.recallDiffusionTopK, 100); assert.equal(defaultSettings.recallLlmCandidatePool, 30); assert.equal(defaultSettings.recallLlmContextMessages, 4); +assert.equal(defaultSettings.recallUseAuthoritativeGenerationInput, false); assert.equal(defaultSettings.recallEnableMultiIntent, true); assert.equal(defaultSettings.recallMultiIntentMaxSegments, 4); assert.equal(defaultSettings.recallEnableContextQueryBlend, true); diff --git a/tests/extractor-input-context.mjs b/tests/extractor-input-context.mjs new file mode 100644 index 0000000..683e3ee --- /dev/null +++ b/tests/extractor-input-context.mjs @@ -0,0 +1,151 @@ +import assert from "node:assert/strict"; +import { + installResolveHooks, + toDataModuleUrl, +} from "./helpers/register-hooks-compat.mjs"; + +const extensionsShimSource = [ + "export const extension_settings = {};", + "export function getContext() {", + " return globalThis.__stBmeTestContext || {", + " chat: [],", + " chatMetadata: {},", + " extensionSettings: {},", + " powerUserSettings: {},", + " characters: {},", + " characterId: null,", + " name1: '玩家',", + " name2: '艾琳',", + " chatId: 'test-chat',", + " };", + "}", +].join("\n"); + +const scriptShimSource = [ + "export function getRequestHeaders() {", + " return {};", + "}", + "export function substituteParamsExtended(value) {", + " return String(value ?? '');", + "}", +].join("\n"); + +const openAiShimSource = [ + "export const chat_completion_sources = {};", + "export async function sendOpenAIRequest() {", + " throw new Error('sendOpenAIRequest should not be called in extractor-input-context test');", + "}", +].join("\n"); + +installResolveHooks([ + { + specifiers: [ + "../../../extensions.js", + "../../../../extensions.js", + "../../../../../extensions.js", + ], + url: toDataModuleUrl(extensionsShimSource), + }, + { + specifiers: [ + "../../../../script.js", + "../../../../../script.js", + ], + url: toDataModuleUrl(scriptShimSource), + }, + { + specifiers: [ + "../../../../openai.js", + "../../../../../openai.js", + ], + url: toDataModuleUrl(openAiShimSource), + }, +]); + +const { createEmptyGraph } = await import("../graph/graph.js"); +const { DEFAULT_NODE_SCHEMA } = await import("../graph/schema.js"); +const { extractMemories } = await import("../maintenance/extractor.js"); + +function setTestOverrides(overrides = {}) { + globalThis.__stBmeTestOverrides = overrides; + return () => { + delete globalThis.__stBmeTestOverrides; + }; +} + +globalThis.__stBmeTestContext = { + chat: [], + chatMetadata: {}, + extensionSettings: {}, + powerUserSettings: {}, + characters: {}, + characterId: null, + name1: "玩家", + name2: "艾琳", + chatId: "test-chat", +}; + +const graph = createEmptyGraph(); +let captured = null; +const restore = setTestOverrides({ + llm: { + async callLLMForJSON(payload) { + captured = payload; + return { + operations: [], + cognitionUpdates: [], + regionUpdates: {}, + }; + }, + }, +}); + +try { + const result = await extractMemories({ + graph, + messages: [ + { + seq: 10, + role: "assistant", + content: "隐式思维继续说明", + name: "艾琳", + speaker: "艾琳", + }, + { + seq: 11, + role: "user", + content: "用户输入", + name: "玩家", + speaker: "玩家", + }, + ], + startSeq: 10, + endSeq: 11, + schema: DEFAULT_NODE_SCHEMA, + embeddingConfig: null, + settings: { + extractAssistantExcludeTags: "think", + }, + }); + + assert.equal(result.success, true); + assert.ok(captured); + assert.ok(captured.debugContext); + assert.ok(captured.debugContext.inputContext); + assert.equal(captured.debugContext.inputContext.rawMessageCount, 2); + assert.equal(captured.debugContext.inputContext.filteredMessageCount, 2); + assert.equal(captured.debugContext.inputContext.changedAssistantMessageCount, 1); + assert.equal(captured.debugContext.inputContext.excludedAssistantMessageCount, 1); + + const recentBlock = (Array.isArray(captured.promptMessages) ? captured.promptMessages : []).find( + (message) => message.sourceKey === "recentMessages", + ); + assert.ok(recentBlock); + assert.match(String(recentBlock?.content || ""), /#10 \[assistant\|艾琳\]: 继续说明/); + assert.match(String(recentBlock?.content || ""), /#11 \[user\|玩家\]: 用户输入/); + assert.doesNotMatch(String(recentBlock?.content || ""), /隐式思维|/); +} finally { + restore(); +} + +console.log("extractor-input-context tests passed"); diff --git a/tests/extractor-phase3-layered-context.mjs b/tests/extractor-phase3-layered-context.mjs new file mode 100644 index 0000000..f83a305 --- /dev/null +++ b/tests/extractor-phase3-layered-context.mjs @@ -0,0 +1,393 @@ +import assert from "node:assert/strict"; +import { + installResolveHooks, + toDataModuleUrl, +} from "./helpers/register-hooks-compat.mjs"; + +const extensionsShimSource = [ + "export const extension_settings = {};", + "export function getContext() {", + " return globalThis.__stBmeTestContext || {", + " chat: [],", + " chatMetadata: {},", + " extensionSettings: {},", + " powerUserSettings: {},", + " characters: {},", + " characterId: null,", + " name1: '玩家',", + " name2: '艾琳',", + " chatId: 'test-chat',", + " };", + "}", +].join("\n"); + +const scriptShimSource = [ + "export function getRequestHeaders() {", + " return {};", + "}", + "export function substituteParamsExtended(value) {", + " return String(value ?? '');", + "}", +].join("\n"); + +const openAiShimSource = [ + "export const chat_completion_sources = {};", + "export async function sendOpenAIRequest() {", + " throw new Error('sendOpenAIRequest should not be called in p3 test');", + "}", +].join("\n"); + +installResolveHooks([ + { + specifiers: [ + "../../../extensions.js", + "../../../../extensions.js", + "../../../../../extensions.js", + ], + url: toDataModuleUrl(extensionsShimSource), + }, + { + specifiers: [ + "../../../../script.js", + "../../../../../script.js", + ], + url: toDataModuleUrl(scriptShimSource), + }, + { + specifiers: [ + "../../../../openai.js", + "../../../../../openai.js", + ], + url: toDataModuleUrl(openAiShimSource), + }, +]); + +const { createEmptyGraph, addNode, createNode } = await import("../graph/graph.js"); +const { DEFAULT_NODE_SCHEMA } = await import("../graph/schema.js"); +const { extractMemories } = await import("../maintenance/extractor.js"); +const { appendSummaryEntry } = await import("../graph/summary-state.js"); +const { normalizeGraphSummaryState } = await import("../graph/summary-state.js"); +const { applyBatchStoryTime } = await import("../graph/story-timeline.js"); +const { defaultSettings } = await import("../runtime/settings-defaults.js"); + +function setTestOverrides(overrides = {}) { + globalThis.__stBmeTestOverrides = overrides; + return () => { + delete globalThis.__stBmeTestOverrides; + }; +} + +globalThis.__stBmeTestContext = { + chat: [], + chatMetadata: {}, + extensionSettings: {}, + powerUserSettings: {}, + characters: {}, + characterId: null, + name1: "玩家", + name2: "艾琳", + chatId: "test-chat", +}; + +const baseMessages = [ + { seq: 10, role: "user", content: "第一轮消息", name: "玩家", speaker: "玩家" }, + { seq: 11, role: "assistant", content: "第一轮回复", name: "艾琳", speaker: "艾琳" }, + { seq: 12, role: "user", content: "第二轮消息", name: "玩家", speaker: "玩家" }, + { seq: 13, role: "assistant", content: "第二轮回复", name: "艾琳", speaker: "艾琳" }, + { seq: 14, role: "user", content: "第三轮消息", name: "玩家", speaker: "玩家" }, + { seq: 15, role: "assistant", content: "第三轮回复", name: "艾琳", speaker: "艾琳" }, +]; + +function collectAllPromptContent(captured) { + return [ + String(captured.systemPrompt || ""), + String(captured.userPrompt || ""), + ...(Array.isArray(captured.promptMessages) ? captured.promptMessages : []).map( + (m) => String(m.content || ""), + ), + ...(Array.isArray(captured.additionalMessages) ? captured.additionalMessages : []).map( + (m) => String(m.content || ""), + ), + ].join("\n"); +} + +// ── Test 1: default settings — activeSummaries and storyTimeContext passed ── +{ + const graph = createEmptyGraph(); + normalizeGraphSummaryState(graph); + const entry = appendSummaryEntry(graph, { + text: "最近的局面总结测试文本", + messageRange: [5, 9], + level: 1, + }); + applyBatchStoryTime(graph, { label: "第二天清晨", tense: "ongoing" }, "extract"); + + let captured = null; + const restore = setTestOverrides({ + llm: { + async callLLMForJSON(payload) { + captured = payload; + return { operations: [], cognitionUpdates: [], regionUpdates: {} }; + }, + }, + }); + + try { + const result = await extractMemories({ + graph, + messages: baseMessages.slice(0, 2), + startSeq: 10, + endSeq: 11, + schema: DEFAULT_NODE_SCHEMA, + embeddingConfig: null, + settings: { ...defaultSettings }, + }); + + assert.equal(result.success, true); + assert.ok(captured, "LLM should be called"); + + const allContent = collectAllPromptContent(captured); + + // activeSummaries should be somewhere in prompt content + assert.match(allContent, /最近的局面总结测试文本/, "active summaries text should appear in prompt"); + + // storyTimeContext should be somewhere in prompt content + assert.match(allContent, /第二天清晨/, "story time label should appear in prompt"); + + // recentMessages block should contain the dialogue + const recentBlock = (Array.isArray(captured.promptMessages) ? captured.promptMessages : []).find( + (m) => m.sourceKey === "recentMessages", + ); + assert.ok(recentBlock, "recentMessages block should exist"); + assert.match(String(recentBlock.content || ""), /第一轮/, "recentMessages should contain dialogue content"); + } finally { + restore(); + } +} + +// ── Test 2: extractRecentMessageCap limits messages ── +{ + const graph = createEmptyGraph(); + let captured = null; + const restore = setTestOverrides({ + llm: { + async callLLMForJSON(payload) { + captured = payload; + return { operations: [], cognitionUpdates: [], regionUpdates: {} }; + }, + }, + }); + + try { + const result = await extractMemories({ + graph, + messages: baseMessages, + startSeq: 10, + endSeq: 15, + schema: DEFAULT_NODE_SCHEMA, + embeddingConfig: null, + settings: { + ...defaultSettings, + extractRecentMessageCap: 2, + }, + }); + + assert.equal(result.success, true); + assert.ok(captured); + + // With cap=2, only the last 2 messages (seq 14, 15) should be in the recentMessages block + const recentBlock = (Array.isArray(captured.promptMessages) ? captured.promptMessages : []).find( + (m) => m.sourceKey === "recentMessages", + ); + assert.ok(recentBlock, "recentMessages block should exist"); + const recentContent = String(recentBlock.content || ""); + assert.match(recentContent, /第三轮/, "capped messages should contain the last messages"); + assert.doesNotMatch(recentContent, /第一轮/, "capped messages should not contain early messages"); + } finally { + restore(); + } +} + +// ── Test 3: extractPromptStructuredMode = "structured" omits dialogueText ── +{ + const graph = createEmptyGraph(); + let captured = null; + const restore = setTestOverrides({ + llm: { + async callLLMForJSON(payload) { + captured = payload; + return { operations: [], cognitionUpdates: [], regionUpdates: {} }; + }, + }, + }); + + try { + const result = await extractMemories({ + graph, + messages: baseMessages.slice(0, 2), + startSeq: 10, + endSeq: 11, + schema: DEFAULT_NODE_SCHEMA, + embeddingConfig: null, + settings: { + ...defaultSettings, + extractPromptStructuredMode: "structured", + }, + }); + + assert.equal(result.success, true); + assert.ok(captured); + + // In structured mode, recentMessages block should still have structured content + const recentBlock = (Array.isArray(captured.promptMessages) ? captured.promptMessages : []).find( + (m) => m.sourceKey === "recentMessages", + ); + assert.ok(recentBlock, "recentMessages block should exist"); + const recentContent = String(recentBlock?.content || ""); + assert.ok(recentContent.length > 0, "recentMessages block should have content"); + // The full transcript should NOT appear in prompt content + // (structured mode excludes dialogueText) + const allContent = collectAllPromptContent(captured); + // In "structured" mode, the user prompt fallback or blocks may reference structured messages + assert.match(recentContent, /第一轮/, "structured messages should contain dialogue"); + } finally { + restore(); + } +} + +// ── Test 4: extractPromptStructuredMode = "transcript" passes string ── +{ + const graph = createEmptyGraph(); + let captured = null; + const restore = setTestOverrides({ + llm: { + async callLLMForJSON(payload) { + captured = payload; + return { operations: [], cognitionUpdates: [], regionUpdates: {} }; + }, + }, + }); + + try { + const result = await extractMemories({ + graph, + messages: baseMessages.slice(0, 2), + startSeq: 10, + endSeq: 11, + schema: DEFAULT_NODE_SCHEMA, + embeddingConfig: null, + settings: { + ...defaultSettings, + extractPromptStructuredMode: "transcript", + }, + }); + + assert.equal(result.success, true); + assert.ok(captured); + + // In transcript mode, the content should still be present in some form + const allContent = collectAllPromptContent(captured); + assert.match(allContent, /第一轮/, "transcript mode should have dialogue content"); + // recentMessages block should exist and have transcript content + const recentBlock = (Array.isArray(captured.promptMessages) ? captured.promptMessages : []).find( + (m) => m.sourceKey === "recentMessages", + ); + assert.ok(recentBlock, "recentMessages block should exist in transcript mode"); + } finally { + restore(); + } +} + +// ── Test 5: extractIncludeSummaries = false omits summaries ── +{ + const graph = createEmptyGraph(); + normalizeGraphSummaryState(graph); + appendSummaryEntry(graph, { + text: "这条总结不应出现", + messageRange: [5, 9], + level: 1, + }); + + let captured = null; + const restore = setTestOverrides({ + llm: { + async callLLMForJSON(payload) { + captured = payload; + return { operations: [], cognitionUpdates: [], regionUpdates: {} }; + }, + }, + }); + + try { + const result = await extractMemories({ + graph, + messages: baseMessages.slice(0, 2), + startSeq: 10, + endSeq: 11, + schema: DEFAULT_NODE_SCHEMA, + embeddingConfig: null, + settings: { + ...defaultSettings, + extractIncludeSummaries: false, + }, + }); + + assert.equal(result.success, true); + assert.ok(captured); + + const allContent = collectAllPromptContent(captured); + assert.doesNotMatch(allContent, /这条总结不应出现/, "summaries should be excluded when disabled"); + } finally { + restore(); + } +} + +// ── Test 6: extractIncludeStoryTime = false omits story time ── +{ + const graph = createEmptyGraph(); + applyBatchStoryTime(graph, { label: "隐藏的时间标签", tense: "ongoing" }, "extract"); + + let captured = null; + const restore = setTestOverrides({ + llm: { + async callLLMForJSON(payload) { + captured = payload; + return { operations: [], cognitionUpdates: [], regionUpdates: {} }; + }, + }, + }); + + try { + const result = await extractMemories({ + graph, + messages: baseMessages.slice(0, 2), + startSeq: 10, + endSeq: 11, + schema: DEFAULT_NODE_SCHEMA, + embeddingConfig: null, + settings: { + ...defaultSettings, + extractIncludeStoryTime: false, + }, + }); + + assert.equal(result.success, true); + assert.ok(captured); + + const allContent = collectAllPromptContent(captured); + assert.doesNotMatch(allContent, /隐藏的时间标签/, "story time should be excluded when disabled"); + } finally { + restore(); + } +} + +// ── Test 7: new settings exist in defaults ── +{ + assert.equal(defaultSettings.extractRecentMessageCap, 0); + assert.equal(defaultSettings.extractPromptStructuredMode, "both"); + assert.equal(defaultSettings.extractWorldbookMode, "active"); + assert.equal(defaultSettings.extractIncludeStoryTime, true); + assert.equal(defaultSettings.extractIncludeSummaries, true); +} + +console.log("extractor-phase3-layered-context tests passed"); diff --git a/tests/prompt-builder-mixed-transcript.mjs b/tests/prompt-builder-mixed-transcript.mjs new file mode 100644 index 0000000..fa2bd83 --- /dev/null +++ b/tests/prompt-builder-mixed-transcript.mjs @@ -0,0 +1,148 @@ +import assert from "node:assert/strict"; +import { + installResolveHooks, + toDataModuleUrl, +} from "./helpers/register-hooks-compat.mjs"; + +const extensionsShimSource = [ + "export const extension_settings = {};", + "export function getContext() {", + " return {", + " chat: [],", + " chatMetadata: {},", + " extensionSettings: {},", + " powerUserSettings: {},", + " characters: {},", + " characterId: null,", + " name1: '',", + " name2: '',", + " chatId: 'test-chat',", + " };", + "}", +].join("\n"); + +const scriptShimSource = [ + "export function substituteParamsExtended(value) {", + " return String(value ?? '');", + "}", +].join("\n"); + +installResolveHooks([ + { + specifiers: [ + "../../../extensions.js", + "../../../../extensions.js", + "../../../../../extensions.js", + ], + url: toDataModuleUrl(extensionsShimSource), + }, + { + specifiers: [ + "../../../../script.js", + "../../../../../script.js", + ], + url: toDataModuleUrl(scriptShimSource), + }, +]); + +const { buildTaskLlmPayload, buildTaskPrompt } = await import("../prompting/prompt-builder.js"); +const { createDefaultTaskProfiles } = await import("../prompting/prompt-profiles.js"); +const { initializeHostAdapter } = await import("../host/adapter/index.js"); + +initializeHostAdapter({}); + +const settings = { + taskProfilesVersion: 3, + taskProfiles: createDefaultTaskProfiles(), +}; +const extractProfile = settings.taskProfiles.extract.profiles[0]; +extractProfile.regex = { + ...(extractProfile.regex || {}), + enabled: true, + inheritStRegex: false, + sources: { + global: false, + preset: false, + character: false, + }, + stages: { + ...(extractProfile.regex?.stages || {}), + input: true, + "input.recentMessages": true, + "input.finalPrompt": false, + }, + localRules: [ + { + id: "assistant-local-role-aware", + script_name: "assistant-local-role-aware", + enabled: true, + find_regex: "/继续说明/g", + replace_string: "助手已净化", + source: { + user_input: false, + ai_output: true, + }, + destination: { + prompt: true, + display: false, + }, + }, + { + id: "user-local-role-aware", + script_name: "user-local-role-aware", + enabled: true, + find_regex: "/用户输入/g", + replace_string: "用户已净化", + source: { + user_input: true, + ai_output: false, + }, + destination: { + prompt: true, + display: false, + }, + }, + ], +}; + +const promptBuild = await buildTaskPrompt(settings, "extract", { + taskName: "extract", + charDescription: "", + userPersona: "", + recentMessages: "这里会被 chatMessages 回填", + chatMessages: [ + { + seq: 41, + role: "assistant", + content: "继续说明", + name: "艾琳", + speaker: "艾琳", + }, + { + seq: 42, + role: "user", + content: "用户输入", + name: "玩家", + speaker: "玩家", + }, + ], + graphStats: "node_count=1", + schema: "event(title, summary)", + currentRange: "41 ~ 42", +}); +const payload = buildTaskLlmPayload(promptBuild, "fallback-user"); +const recentBlock = payload.promptMessages.find( + (message) => message.sourceKey === "recentMessages", +); +assert.match(String(recentBlock?.content || ""), /#41 \[assistant\|艾琳\]: 助手已净化/); +assert.match(String(recentBlock?.content || ""), /#42 \[user\|玩家\]: 用户已净化/); +assert.doesNotMatch( + String(recentBlock?.content || ""), + /#41 \[assistant\|艾琳\]: 用户已净化/, +); +assert.doesNotMatch( + String(recentBlock?.content || ""), + /#42 \[user\|玩家\]: 助手已净化/, +); + +console.log("prompt-builder-mixed-transcript tests passed"); diff --git a/tests/recall-authoritative-generation-input.mjs b/tests/recall-authoritative-generation-input.mjs new file mode 100644 index 0000000..912d49f --- /dev/null +++ b/tests/recall-authoritative-generation-input.mjs @@ -0,0 +1,129 @@ +import assert from "node:assert/strict"; + +import { MODULE_NAME } from "../graph/graph-persistence.js"; +import { + buildRecallRecentMessagesController, + resolveRecallInputController, +} from "../retrieval/recall-controller.js"; +import { createGenerationRecallHarness } from "./helpers/generation-recall-harness.mjs"; + +async function testSendIntentCanRemainAuthoritativeQueryWhenFlagEnabled() { + const harness = await createGenerationRecallHarness(); + harness.extension_settings[MODULE_NAME] = { + recallUseAuthoritativeGenerationInput: true, + }; + harness.chat = [{ is_user: true, mes: "旧的 chat tail" }]; + harness.pendingRecallSendIntent = { + text: "刚触发发送的新输入", + hash: "hash-phase4-send-intent", + at: Date.now(), + source: "dom-intent", + }; + + await harness.result.onGenerationAfterCommands("normal", {}, false); + + assert.equal(harness.runRecallCalls.length, 1); + assert.equal(harness.runRecallCalls[0].overrideUserMessage, "刚触发发送的新输入"); + assert.equal(harness.runRecallCalls[0].overrideSource, "send-intent"); + assert.equal(harness.runRecallCalls[0].targetUserMessageIndex, 0); + assert.equal(harness.runRecallCalls[0].includeSyntheticUserMessage, true); + + const transaction = [...harness.result.generationRecallTransactions.values()][0]; + assert.ok(transaction); + assert.equal( + transaction.frozenRecallOptions.overrideUserMessage, + "刚触发发送的新输入", + ); + assert.equal(transaction.frozenRecallOptions.lockedSource, "send-intent"); + assert.equal(transaction.frozenRecallOptions.targetUserMessageIndex, 0); + assert.equal(transaction.frozenRecallOptions.authoritativeInputUsed, true); + assert.equal(transaction.frozenRecallOptions.boundUserFloorText, "旧的 chat tail"); + assert.equal(transaction.frozenRecallOptions.includeSyntheticUserMessage, true); +} + +async function testHostSnapshotCanRemainAuthoritativeQueryWhenFlagEnabled() { + const harness = await createGenerationRecallHarness(); + harness.extension_settings[MODULE_NAME] = { + recallUseAuthoritativeGenerationInput: true, + }; + harness.chat = [{ is_user: true, mes: "旧的 chat tail" }]; + const frozenSnapshot = harness.result.freezeHostGenerationInputSnapshot( + "宿主快照输入", + ); + + await harness.result.onGenerationAfterCommands( + "normal", + { frozenInputSnapshot: frozenSnapshot }, + false, + ); + + assert.equal(harness.runRecallCalls.length, 1); + assert.equal(harness.runRecallCalls[0].overrideUserMessage, "宿主快照输入"); + assert.equal( + harness.runRecallCalls[0].overrideSource, + "host-generation-lifecycle", + ); + assert.equal(harness.runRecallCalls[0].targetUserMessageIndex, 0); + assert.equal(harness.runRecallCalls[0].includeSyntheticUserMessage, true); + assert.equal( + JSON.stringify( + harness.runRecallCalls[0].sourceCandidates.map((candidate) => candidate.source), + ), + JSON.stringify(["host-generation-lifecycle", "chat-tail-user"]), + ); + + const transaction = [...harness.result.generationRecallTransactions.values()][0]; + assert.ok(transaction); + assert.equal(transaction.frozenRecallOptions.overrideUserMessage, "宿主快照输入"); + assert.equal( + transaction.frozenRecallOptions.lockedSource, + "host-generation-lifecycle", + ); + assert.equal(transaction.frozenRecallOptions.targetUserMessageIndex, 0); + assert.equal(transaction.frozenRecallOptions.authoritativeInputUsed, true); + assert.equal(transaction.frozenRecallOptions.boundUserFloorText, "旧的 chat tail"); + assert.equal(transaction.frozenRecallOptions.includeSyntheticUserMessage, true); +} + +function testResolveRecallInputControllerAppendsSyntheticAuthoritativeUserMessage() { + const runtime = { + normalizeRecallInputText(value = "") { + return String(value || "").trim(); + }, + buildRecallRecentMessages(chat, limit, syntheticUserMessage = "") { + return buildRecallRecentMessagesController(chat, limit, syntheticUserMessage, { + formatRecallContextLine(message) { + return `[${message?.is_user ? "user" : "assistant"}]: ${String(message?.mes || "")}`; + }, + normalizeRecallInputText(value = "") { + return String(value || "").trim(); + }, + }); + }, + }; + const result = resolveRecallInputController( + [{ is_user: true, mes: "旧的 chat tail" }], + 4, + { + overrideUserMessage: "权威输入", + overrideSource: "send-intent", + includeSyntheticUserMessage: true, + }, + runtime, + ); + + assert.equal(result.userMessage, "权威输入"); + assert.equal(result.source, "send-intent"); + assert.equal(result.authoritativeInputUsed, false); + assert.equal(result.boundUserFloorText, ""); + assert.deepEqual(result.recentMessages, [ + "[user]: 旧的 chat tail", + "[user]: 权威输入", + ]); +} + +await testSendIntentCanRemainAuthoritativeQueryWhenFlagEnabled(); +await testHostSnapshotCanRemainAuthoritativeQueryWhenFlagEnabled(); +testResolveRecallInputControllerAppendsSyntheticAuthoritativeUserMessage(); + +console.log("recall-authoritative-generation-input tests passed"); From 2803066c1b4dcf8ba54c50e4eff1487292581713 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 11 Apr 2026 08:05:46 +0000 Subject: [PATCH 06/20] chore: bump manifest version [skip ci] --- manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/manifest.json b/manifest.json index 8f2a24e..9f08c68 100644 --- a/manifest.json +++ b/manifest.json @@ -6,6 +6,6 @@ "js": "index.js", "css": "style.css", "author": "Youzini", - "version": "4.5.2", + "version": "4.5.3", "homePage": "https://github.com/Youzini-afk/ST-Bionic-Memory-Ecology" } From 3e376003992873cc47158cc8f8b35c71b9687308 Mon Sep 17 00:00:00 2001 From: Youzini-afk <13153778771cx@gmail.com> Date: Sat, 11 Apr 2026 16:39:33 +0800 Subject: [PATCH 07/20] feat: integrate phase3/4 settings UI and add phase4/5 regressions --- tests/extractor-phase5-context-fidelity.mjs | 387 ++++++++++++++++++ tests/helpers/generation-recall-harness.mjs | 3 +- .../recall-authoritative-generation-input.mjs | 116 ++++++ tests/task-profile-migration.mjs | 20 +- tests/task-profile-storage.mjs | 2 +- tests/task-worldinfo.mjs | 2 +- ui/panel.html | 70 ++++ ui/panel.js | 58 +++ 8 files changed, 648 insertions(+), 10 deletions(-) create mode 100644 tests/extractor-phase5-context-fidelity.mjs diff --git a/tests/extractor-phase5-context-fidelity.mjs b/tests/extractor-phase5-context-fidelity.mjs new file mode 100644 index 0000000..ebb15bc --- /dev/null +++ b/tests/extractor-phase5-context-fidelity.mjs @@ -0,0 +1,387 @@ +import assert from "node:assert/strict"; +import { + installResolveHooks, + toDataModuleUrl, +} from "./helpers/register-hooks-compat.mjs"; + +const extensionsShimSource = [ + "export const extension_settings = {};", + "export function getContext() {", + " return globalThis.__stBmeTestContext || {", + " chat: [],", + " chatMetadata: {},", + " extensionSettings: {},", + " powerUserSettings: {},", + " characters: {},", + " characterId: null,", + " name1: '玩家',", + " name2: '艾琳',", + " chatId: 'test-chat',", + " };", + "}", +].join("\n"); + +const scriptShimSource = [ + "export function getRequestHeaders() {", + " return {};", + "}", + "export function substituteParamsExtended(value) {", + " return String(value ?? '');", + "}", +].join("\n"); + +const openAiShimSource = [ + "export const chat_completion_sources = {};", + "export async function sendOpenAIRequest() {", + " throw new Error('sendOpenAIRequest should not be called in phase5 fidelity test');", + "}", +].join("\n"); + +installResolveHooks([ + { + specifiers: [ + "../../../extensions.js", + "../../../../extensions.js", + "../../../../../extensions.js", + ], + url: toDataModuleUrl(extensionsShimSource), + }, + { + specifiers: [ + "../../../../script.js", + "../../../../../script.js", + ], + url: toDataModuleUrl(scriptShimSource), + }, + { + specifiers: [ + "../../../../openai.js", + "../../../../../openai.js", + ], + url: toDataModuleUrl(openAiShimSource), + }, +]); + +const { createEmptyGraph } = await import("../graph/graph.js"); +const { DEFAULT_NODE_SCHEMA } = await import("../graph/schema.js"); +const { extractMemories } = await import("../maintenance/extractor.js"); +const { defaultSettings } = await import("../runtime/settings-defaults.js"); + +function setTestOverrides(overrides = {}) { + globalThis.__stBmeTestOverrides = overrides; + return () => { + delete globalThis.__stBmeTestOverrides; + }; +} + +function collectAllPromptContent(captured) { + return [ + String(captured.systemPrompt || ""), + String(captured.userPrompt || ""), + ...(Array.isArray(captured.promptMessages) ? captured.promptMessages : []).map( + (message) => String(message.content || ""), + ), + ...(Array.isArray(captured.additionalMessages) + ? captured.additionalMessages + : [] + ).map((message) => String(message.content || "")), + ].join("\n"); +} + +function createWorldbookEntry({ + uid, + name, + comment = name, + content, + enabled = true, + keys = [], + positionType = "before_character_definition", + role = "system", + depth = 0, + order = 10, + strategyType = keys.length > 0 ? "selective" : "constant", +}) { + return { + uid, + name, + comment, + content, + enabled, + position: { + type: positionType, + role, + depth, + order, + }, + strategy: { + type: strategyType, + keys, + keys_secondary: { logic: "and_any", keys: [] }, + }, + probability: 100, + extra: {}, + }; +} + +const originalSillyTavern = globalThis.SillyTavern; +const originalGetCharWorldbookNames = globalThis.getCharWorldbookNames; +const originalGetWorldbook = globalThis.getWorldbook; +const originalGetLorebookEntries = globalThis.getLorebookEntries; +const originalTestContext = globalThis.__stBmeTestContext; + +const worldbooksByName = { + "main-book": [ + createWorldbookEntry({ + uid: 1, + name: "主书常驻设定", + content: "主世界书:蓝钥匙线索。", + order: 10, + }), + createWorldbookEntry({ + uid: 2, + name: "蓝钥匙触发条目", + content: "主世界书命中:调查蓝钥匙时应关注旧城区。", + keys: ["蓝钥匙"], + order: 20, + }), + ], + "persona-book": [ + createWorldbookEntry({ + uid: 3, + name: "人格设定", + content: "人格世界书:保持谨慎,不要忽略路线细节。", + order: 10, + }), + ], + "chat-book": [ + createWorldbookEntry({ + uid: 4, + name: "聊天绑定设定", + content: "聊天世界书:当前会话已锁定旧城区雨夜调查。", + order: 10, + }), + ], +}; + +const fidelityMessages = [ + { + seq: 30, + role: "assistant", + content: "先推断举灯艾琳说:去调查蓝钥匙。", + name: "艾琳", + speaker: "艾琳", + }, + { + seq: 31, + role: "assistant", + content: "旁白补充:雨夜巷子很安静。", + name: "旁白", + speaker: "旁白", + }, + { + seq: 32, + role: "user", + content: "先记路线我会继续调查蓝钥匙。", + name: "玩家", + speaker: "玩家", + }, +]; + +globalThis.__stBmeTestContext = { + chat: [ + { is_user: false, mes: "艾琳说:去调查蓝钥匙。", name: "艾琳" }, + { is_user: false, mes: "旁白补充:雨夜巷子很安静。", name: "旁白" }, + { is_user: true, mes: "我会继续调查蓝钥匙。", name: "玩家" }, + ], + chatMetadata: { + world: "chat-book", + }, + extensionSettings: { + persona_description_lorebook: "persona-book", + }, + powerUserSettings: { + persona_description: "用户设定:谨慎调查者", + }, + characters: { + 1: { + name: "艾琳", + description: "角色描述:夜巡调查员", + data: { + description: "角色描述:夜巡调查员", + extensions: { + world: "main-book", + }, + }, + extensions: { + world: "main-book", + }, + }, + }, + characterId: 1, + name1: "玩家", + name2: "艾琳", + chatId: "phase5-context-fidelity", +}; + +globalThis.SillyTavern = { + getContext() { + return globalThis.__stBmeTestContext; + }, +}; + +globalThis.getCharWorldbookNames = () => ({ + primary: "main-book", + additional: [], +}); +globalThis.getWorldbook = async (worldbookName) => + worldbooksByName[String(worldbookName || "").trim()] || []; +globalThis.getLorebookEntries = async (worldbookName) => + (worldbooksByName[String(worldbookName || "").trim()] || []).map((entry) => ({ + uid: entry.uid, + comment: entry.comment, + })); + +try { + { + const graph = createEmptyGraph(); + let captured = null; + const restore = setTestOverrides({ + llm: { + async callLLMForJSON(payload) { + captured = payload; + return { operations: [], cognitionUpdates: [], regionUpdates: {} }; + }, + }, + }); + + try { + const result = await extractMemories({ + graph, + messages: fidelityMessages, + startSeq: 30, + endSeq: 32, + schema: DEFAULT_NODE_SCHEMA, + embeddingConfig: null, + settings: { + ...defaultSettings, + extractAssistantExcludeTags: "think,action", + extractWorldbookMode: "active", + }, + }); + + assert.equal(result.success, true); + assert.ok(captured); + + const allContent = collectAllPromptContent(captured); + assert.match(allContent, /角色描述:夜巡调查员/); + assert.match(allContent, /用户设定:谨慎调查者/); + assert.match(allContent, /主世界书:蓝钥匙线索。/); + assert.match(allContent, /主世界书命中:调查蓝钥匙时应关注旧城区。/); + assert.match(allContent, /人格世界书:保持谨慎,不要忽略路线细节。/); + assert.match(allContent, /聊天世界书:当前会话已锁定旧城区雨夜调查。/); + + const recentBlock = (Array.isArray(captured.promptMessages) + ? captured.promptMessages + : [] + ).find((message) => message.sourceKey === "recentMessages"); + assert.ok(recentBlock, "recentMessages block should exist"); + const recentContent = String(recentBlock?.content || ""); + assert.match(recentContent, /#30 \[assistant\|艾琳\]: 艾琳说:去调查蓝钥匙。/); + assert.match( + recentContent, + /#31 \[assistant\|旁白\]: 旁白补充:雨夜<\/status>巷子很安静。/, + ); + assert.match( + recentContent, + /#32 \[user\|玩家\]: 先记路线<\/plan>我会继续调查蓝钥匙。/, + ); + assert.doesNotMatch(recentContent, /|/); + + const worldInfoBeforeBlock = (Array.isArray(captured.promptMessages) + ? captured.promptMessages + : [] + ).find((message) => message.sourceKey === "worldInfoBefore"); + assert.ok(worldInfoBeforeBlock, "worldInfoBefore block should exist when worldbook is active"); + assert.match(String(worldInfoBeforeBlock?.content || ""), /蓝钥匙线索/); + } finally { + restore(); + } + } + + { + const graph = createEmptyGraph(); + let captured = null; + const restore = setTestOverrides({ + llm: { + async callLLMForJSON(payload) { + captured = payload; + return { operations: [], cognitionUpdates: [], regionUpdates: {} }; + }, + }, + }); + + try { + const result = await extractMemories({ + graph, + messages: fidelityMessages, + startSeq: 30, + endSeq: 32, + schema: DEFAULT_NODE_SCHEMA, + embeddingConfig: null, + settings: { + ...defaultSettings, + extractAssistantExcludeTags: "think,action", + extractWorldbookMode: "none", + }, + }); + + assert.equal(result.success, true); + assert.ok(captured); + + const allContent = collectAllPromptContent(captured); + assert.match(allContent, /角色描述:夜巡调查员/); + assert.match(allContent, /用户设定:谨慎调查者/); + assert.doesNotMatch(allContent, /主世界书:蓝钥匙线索。/); + assert.doesNotMatch(allContent, /主世界书命中:调查蓝钥匙时应关注旧城区。/); + assert.doesNotMatch(allContent, /人格世界书:保持谨慎,不要忽略路线细节。/); + assert.doesNotMatch(allContent, /聊天世界书:当前会话已锁定旧城区雨夜调查。/); + + const recentBlock = (Array.isArray(captured.promptMessages) + ? captured.promptMessages + : [] + ).find((message) => message.sourceKey === "recentMessages"); + assert.ok(recentBlock, "recentMessages block should still exist when worldbook is disabled"); + assert.match(String(recentBlock?.content || ""), /#30 \[assistant\|艾琳\]: 艾琳说:去调查蓝钥匙。/); + } finally { + restore(); + } + } +} finally { + if (originalSillyTavern === undefined) { + delete globalThis.SillyTavern; + } else { + globalThis.SillyTavern = originalSillyTavern; + } + if (originalGetCharWorldbookNames === undefined) { + delete globalThis.getCharWorldbookNames; + } else { + globalThis.getCharWorldbookNames = originalGetCharWorldbookNames; + } + if (originalGetWorldbook === undefined) { + delete globalThis.getWorldbook; + } else { + globalThis.getWorldbook = originalGetWorldbook; + } + if (originalGetLorebookEntries === undefined) { + delete globalThis.getLorebookEntries; + } else { + globalThis.getLorebookEntries = originalGetLorebookEntries; + } + if (originalTestContext === undefined) { + delete globalThis.__stBmeTestContext; + } else { + globalThis.__stBmeTestContext = originalTestContext; + } +} + +console.log("extractor-phase5-context-fidelity tests passed"); diff --git a/tests/helpers/generation-recall-harness.mjs b/tests/helpers/generation-recall-harness.mjs index caa9edf..32d2758 100644 --- a/tests/helpers/generation-recall-harness.mjs +++ b/tests/helpers/generation-recall-harness.mjs @@ -248,10 +248,11 @@ export function createGenerationRecallHarness(options = {}) { }; vm.createContext(context); vm.runInContext( - `${snippet}\nresult = { hashRecallInput, buildPreGenerationRecallKey, buildGenerationAfterCommandsRecallInput, buildNormalGenerationRecallInput, cleanupGenerationRecallTransactions, buildGenerationRecallTransactionId, beginGenerationRecallTransaction, markGenerationRecallTransactionHookState, shouldRunRecallForTransaction, createGenerationRecallContext, onGenerationStarted, onGenerationEnded, onGenerationAfterCommands, onBeforeCombinePrompts, applyFinalRecallInjectionForGeneration, ensurePersistedRecallRecordForGeneration, findRecentGenerationRecallTransactionForChat, getGenerationRecallTransactionResult, generationRecallTransactions, freezeHostGenerationInputSnapshot, consumeHostGenerationInputSnapshot, getPendingHostGenerationInputSnapshot, clearPendingHostGenerationInputSnapshot, recordRecallSendIntent, clearPendingRecallSendIntent, recordRecallSentUserMessage, getPendingRecallSendIntent: () => pendingRecallSendIntent, getLastRecallSentUserMessage: () => lastRecallSentUserMessage, getCurrentGenerationTrivialSkip, markCurrentGenerationTrivialSkip, clearCurrentGenerationTrivialSkip, consumeCurrentGenerationTrivialSkip, deferAutoExtraction, maybeResumePendingAutoExtraction, clearPendingAutoExtraction, getPendingAutoExtraction: () => ({ ...pendingAutoExtraction }), getIsHostGenerationRunning: () => isHostGenerationRunning, runPlannerRecallForEna, getGraphPersistenceState: () => graphPersistenceState, setGraphPersistenceState: (value = {}) => { graphPersistenceState = { ...graphPersistenceState, ...(value || {}) }; return graphPersistenceState; } };`, + `${snippet}\nresult = { hashRecallInput, buildPreGenerationRecallKey, buildGenerationAfterCommandsRecallInput, buildNormalGenerationRecallInput, cleanupGenerationRecallTransactions, buildGenerationRecallTransactionId, beginGenerationRecallTransaction, markGenerationRecallTransactionHookState, shouldRunRecallForTransaction, createGenerationRecallContext, onGenerationStarted, onGenerationEnded, onGenerationAfterCommands, onBeforeCombinePrompts, applyFinalRecallInjectionForGeneration, ensurePersistedRecallRecordForGeneration, findRecentGenerationRecallTransactionForChat, getGenerationRecallTransactionResult, generationRecallTransactions, freezeHostGenerationInputSnapshot, consumeHostGenerationInputSnapshot, getPendingHostGenerationInputSnapshot, clearPendingHostGenerationInputSnapshot, recordRecallSendIntent, clearPendingRecallSendIntent, recordRecallSentUserMessage, getPendingRecallSendIntent: () => pendingRecallSendIntent, getLastRecallSentUserMessage: () => lastRecallSentUserMessage, getCurrentGenerationTrivialSkip, markCurrentGenerationTrivialSkip, clearCurrentGenerationTrivialSkip, consumeCurrentGenerationTrivialSkip, deferAutoExtraction, maybeResumePendingAutoExtraction, clearPendingAutoExtraction, getPendingAutoExtraction: () => ({ ...pendingAutoExtraction }), getIsHostGenerationRunning: () => isHostGenerationRunning, preparePlannerRecallHandoff, runPlannerRecallForEna, getGraphPersistenceState: () => graphPersistenceState, setGraphPersistenceState: (value = {}) => { graphPersistenceState = { ...graphPersistenceState, ...(value || {}) }; return graphPersistenceState; } };`, context, { filename: indexPath }, ); + Object.defineProperties(context, { pendingRecallSendIntent: { get() { diff --git a/tests/recall-authoritative-generation-input.mjs b/tests/recall-authoritative-generation-input.mjs index 912d49f..47d2610 100644 --- a/tests/recall-authoritative-generation-input.mjs +++ b/tests/recall-authoritative-generation-input.mjs @@ -41,6 +41,120 @@ async function testSendIntentCanRemainAuthoritativeQueryWhenFlagEnabled() { assert.equal(transaction.frozenRecallOptions.includeSyntheticUserMessage, true); } +async function testPlannerHandoffCanRemainAuthoritativeQueryWhenFlagEnabled() { + const harness = await createGenerationRecallHarness(); + harness.extension_settings[MODULE_NAME] = { + recallUseAuthoritativeGenerationInput: true, + }; + harness.chat = [{ is_user: true, mes: "楼层里的稳定用户输入" }]; + + const handoff = harness.result.preparePlannerRecallHandoff({ + rawUserInput: "planner 原始输入", + plannerAugmentedMessage: "planner 增强后的输入", + plannerRecall: { + memoryBlock: "规划记忆块", + recentMessages: ["[user]: planner 原始输入", "[assistant]: 记忆命中"], + result: { + selectedNodeIds: ["node-planner-1"], + stats: { + coreCount: 1, + recallCount: 1, + }, + meta: { + retrieval: { + vectorHits: 1, + vectorMergedHits: 0, + diffusionHits: 0, + candidatePoolAfterDpp: 1, + llm: { + status: "disabled", + candidatePool: 0, + }, + }, + }, + }, + }, + chatId: "chat-main", + }); + + assert.ok(handoff); + + const recallContext = harness.result.createGenerationRecallContext({ + hookName: "GENERATION_AFTER_COMMANDS", + generationType: "normal", + recallOptions: {}, + chatId: "chat-main", + }); + + assert.equal(recallContext.shouldRun, true); + assert.equal(recallContext.recallOptions.overrideUserMessage, "planner 原始输入"); + assert.equal(recallContext.recallOptions.overrideSource, "planner-handoff"); + assert.equal(recallContext.recallOptions.authoritativeInputUsed, true); + assert.equal( + recallContext.recallOptions.boundUserFloorText, + "楼层里的稳定用户输入", + ); + assert.equal(recallContext.recallOptions.includeSyntheticUserMessage, true); + assert.ok(recallContext.recallOptions.cachedRecallPayload); + assert.equal( + recallContext.recallOptions.cachedRecallPayload.source, + "planner-handoff", + ); + + await harness.result.onGenerationAfterCommands("normal", {}, false); + + assert.equal(harness.runRecallCalls.length, 1); + assert.equal(harness.runRecallCalls[0].overrideUserMessage, "planner 原始输入"); + assert.equal(harness.runRecallCalls[0].overrideSource, "planner-handoff"); + assert.equal(harness.runRecallCalls[0].authoritativeInputUsed, true); + assert.equal( + harness.runRecallCalls[0].boundUserFloorText, + "楼层里的稳定用户输入", + ); + assert.equal(harness.runRecallCalls[0].includeSyntheticUserMessage, true); + assert.ok(harness.runRecallCalls[0].cachedRecallPayload); +} + +async function testAuthoritativeSendIntentStaysFrozenAcrossHooksWhenFlagEnabled() { + const harness = await createGenerationRecallHarness(); + harness.extension_settings[MODULE_NAME] = { + recallUseAuthoritativeGenerationInput: true, + }; + harness.chat = [{ is_user: true, mes: "稳定 chat tail" }]; + harness.pendingRecallSendIntent = { + text: "第一次权威输入", + hash: "hash-phase4-frozen-a", + at: Date.now(), + source: "dom-intent", + }; + + await harness.result.onGenerationAfterCommands("normal", {}, false); + + harness.pendingRecallSendIntent = { + text: "第二次漂移输入", + hash: "hash-phase4-frozen-b", + at: Date.now(), + source: "dom-intent", + }; + await harness.result.onBeforeCombinePrompts(); + + assert.equal(harness.runRecallCalls.length, 1); + assert.equal(harness.runRecallCalls[0].overrideUserMessage, "第一次权威输入"); + assert.equal(harness.runRecallCalls[0].overrideSource, "send-intent"); + assert.equal(harness.runRecallCalls[0].authoritativeInputUsed, true); + assert.equal(harness.runRecallCalls[0].boundUserFloorText, "稳定 chat tail"); + + const transaction = [...harness.result.generationRecallTransactions.values()][0]; + assert.ok(transaction); + assert.equal( + transaction.frozenRecallOptions.overrideUserMessage, + "第一次权威输入", + ); + assert.equal(transaction.frozenRecallOptions.authoritativeInputUsed, true); + assert.equal(transaction.frozenRecallOptions.boundUserFloorText, "稳定 chat tail"); + assert.equal(transaction.frozenRecallOptions.includeSyntheticUserMessage, true); +} + async function testHostSnapshotCanRemainAuthoritativeQueryWhenFlagEnabled() { const harness = await createGenerationRecallHarness(); harness.extension_settings[MODULE_NAME] = { @@ -123,6 +237,8 @@ function testResolveRecallInputControllerAppendsSyntheticAuthoritativeUserMessag } await testSendIntentCanRemainAuthoritativeQueryWhenFlagEnabled(); +await testPlannerHandoffCanRemainAuthoritativeQueryWhenFlagEnabled(); +await testAuthoritativeSendIntentStaysFrozenAcrossHooksWhenFlagEnabled(); await testHostSnapshotCanRemainAuthoritativeQueryWhenFlagEnabled(); testResolveRecallInputControllerAppendsSyntheticAuthoritativeUserMessage(); diff --git a/tests/task-profile-migration.mjs b/tests/task-profile-migration.mjs index 45af146..4bcf034 100644 --- a/tests/task-profile-migration.mjs +++ b/tests/task-profile-migration.mjs @@ -34,7 +34,7 @@ const extractProfile = getActiveTaskProfile( assert.equal(extractProfile.taskType, "extract"); assert.equal(extractProfile.id, "default"); assert.ok(Array.isArray(extractProfile.blocks)); -assert.equal(extractProfile.blocks.length, 12); +assert.equal(extractProfile.blocks.length, 14); assert.deepEqual( extractProfile.blocks.map((block) => block.name), [ @@ -48,6 +48,8 @@ assert.deepEqual( "图统计", "Schema", "当前范围", + "活跃总结", + "故事时间", "输出格式", "行为规则", ], @@ -65,6 +67,8 @@ assert.deepEqual( "builtin", "builtin", "builtin", + "builtin", + "builtin", "custom", "custom", ], @@ -82,6 +86,8 @@ assert.deepEqual( "system", "system", "system", + "system", + "system", "user", "user", ], @@ -214,16 +220,16 @@ const upgradedLegacyDefault = getActiveTaskProfile( }, "extract", ); -assert.equal(upgradedLegacyDefault.blocks.length, 12); +assert.equal(upgradedLegacyDefault.blocks.length, 14); assert.equal(upgradedLegacyDefault.blocks[0].name, "抬头"); assert.match(upgradedLegacyDefault.blocks[0].content, /虚拟的世界/); assert.equal(upgradedLegacyDefault.blocks[0].role, "system"); assert.equal(upgradedLegacyDefault.blocks[0].injectionMode, "relative"); assert.equal(upgradedLegacyDefault.blocks[1].content, "保留我自己的角色定义"); -assert.equal(upgradedLegacyDefault.blocks[10].content, "保留我自己的输出格式"); -assert.equal(upgradedLegacyDefault.blocks[11].content, "保留我自己的行为规则"); -assert.equal(upgradedLegacyDefault.blocks[10].role, "user"); -assert.equal(upgradedLegacyDefault.blocks[11].role, "user"); +assert.equal(upgradedLegacyDefault.blocks[12].content, "保留我自己的输出格式"); +assert.equal(upgradedLegacyDefault.blocks[13].content, "保留我自己的行为规则"); +assert.equal(upgradedLegacyDefault.blocks[12].role, "user"); +assert.equal(upgradedLegacyDefault.blocks[13].role, "user"); const currentDefaults = createDefaultTaskProfiles(); const currentDefaultExtract = currentDefaults.extract.profiles[0]; @@ -389,7 +395,7 @@ assert.deepEqual( ); assert.ok( upgradedLegacyDefault.blocks - .slice(0, 10) + .slice(0, 12) .every((block) => block.role === "system"), ); diff --git a/tests/task-profile-storage.mjs b/tests/task-profile-storage.mjs index b392f3e..1f6c5e8 100644 --- a/tests/task-profile-storage.mjs +++ b/tests/task-profile-storage.mjs @@ -53,7 +53,7 @@ const activeProfile = getActiveTaskProfile( "extract", ); assert.equal(activeProfile.name, "激进提取"); -assert.equal(activeProfile.blocks.length, 14); +assert.equal(activeProfile.blocks.length, 16); const builtinBlock = activeProfile.blocks.find( (block) => block.type === "builtin" && block.sourceKey === "userMessage", ); diff --git a/tests/task-worldinfo.mjs b/tests/task-worldinfo.mjs index 5174fc2..c408a29 100644 --- a/tests/task-worldinfo.mjs +++ b/tests/task-worldinfo.mjs @@ -930,7 +930,7 @@ try { assert.deepEqual( depthAwarePromptBuild.executionMessages.map((message) => message.content), [ - "#1 [assistant]: 这是 d4 atDepth 消息。\n\n#2 [assistant]: 这是一条 atDepth 消息。\n\n#11 [user]: 第一句\n\n#4 [assistant]: 这是 d1 atDepth 消息。\n\n#12 [assistant]: 第二句", + "#1 [assistant|深度注入 D4]: 这是 d4 atDepth 消息。\n\n#2 [assistant|深度注入]: 这是一条 atDepth 消息。\n\n#11 [user]: 第一句\n\n#4 [assistant|深度注入 D1]: 这是 d1 atDepth 消息。\n\n#12 [assistant]: 第二句", "用户问题:继续调查 depth 排序", ], ); diff --git a/ui/panel.html b/ui/panel.html index 2121291..bbe5777 100644 --- a/ui/panel.html +++ b/ui/panel.html @@ -1513,6 +1513,63 @@
开启后,最新 AI 楼先不自动提取,要等下一条 AI 楼出现后,才提取前一批内容。提取未处理和范围重提不受影响。
+
+ + +
+
+ + +
+
+ + +
+ + @@ -1545,6 +1602,19 @@ max="9999" /> + +
+ 开启后,召回查询将优先使用更接近真实发送入口的文本(如 send-intent、宿主快照、planner handoff),而非回退到 chat tail 或 textarea。 +
diff --git a/ui/panel.js b/ui/panel.js index 497420f..b2db996 100644 --- a/ui/panel.js +++ b/ui/panel.js @@ -4377,6 +4377,26 @@ function _refreshConfigTab() { "bme-setting-extract-auto-delay-latest-assistant", settings.extractAutoDelayLatestAssistant === true, ); + _setInputValue( + "bme-setting-extract-recent-message-cap", + settings.extractRecentMessageCap ?? 0, + ); + _setInputValue( + "bme-setting-extract-prompt-structured-mode", + settings.extractPromptStructuredMode || "both", + ); + _setInputValue( + "bme-setting-extract-worldbook-mode", + settings.extractWorldbookMode || "active", + ); + _setCheckboxValue( + "bme-setting-extract-include-summaries", + settings.extractIncludeSummaries !== false, + ); + _setCheckboxValue( + "bme-setting-extract-include-story-time", + settings.extractIncludeStoryTime !== false, + ); _setInputValue("bme-setting-recall-top-k", settings.recallTopK ?? 20); _setInputValue("bme-setting-recall-max-nodes", settings.recallMaxNodes ?? 8); _setInputValue( @@ -4472,6 +4492,10 @@ function _refreshConfigTab() { settings.recallObjectiveGlobalWeight ?? 0.75, ); _setInputValue("bme-setting-inject-depth", settings.injectDepth ?? 9999); + _setCheckboxValue( + "bme-setting-recall-use-authoritative-generation-input", + settings.recallUseAuthoritativeGenerationInput === true, + ); _setInputValue("bme-setting-graph-weight", settings.graphWeight ?? 0.6); _setInputValue("bme-setting-vector-weight", settings.vectorWeight ?? 0.3); _setInputValue( @@ -4805,6 +4829,35 @@ function _bindConfigControls() { (checked) => _patchSettings({ extractAutoDelayLatestAssistant: checked }), ); + bindNumber("bme-setting-extract-recent-message-cap", 0, 0, 200, (value) => + _patchSettings({ extractRecentMessageCap: value }), + ); + const extractStructuredModeEl = document.getElementById( + "bme-setting-extract-prompt-structured-mode", + ); + if (extractStructuredModeEl && extractStructuredModeEl.dataset.bmeBound !== "true") { + extractStructuredModeEl.addEventListener("change", () => { + _patchSettings({ extractPromptStructuredMode: extractStructuredModeEl.value || "both" }); + }); + extractStructuredModeEl.dataset.bmeBound = "true"; + } + const extractWorldbookModeEl = document.getElementById( + "bme-setting-extract-worldbook-mode", + ); + if (extractWorldbookModeEl && extractWorldbookModeEl.dataset.bmeBound !== "true") { + extractWorldbookModeEl.addEventListener("change", () => { + _patchSettings({ extractWorldbookMode: extractWorldbookModeEl.value || "active" }); + }); + extractWorldbookModeEl.dataset.bmeBound = "true"; + } + bindCheckbox( + "bme-setting-extract-include-summaries", + (checked) => _patchSettings({ extractIncludeSummaries: checked }), + ); + bindCheckbox( + "bme-setting-extract-include-story-time", + (checked) => _patchSettings({ extractIncludeStoryTime: checked }), + ); bindNumber("bme-setting-recall-top-k", 20, 1, 100, (value) => _patchSettings({ recallTopK: value }), ); @@ -4927,6 +4980,11 @@ function _bindConfigControls() { bindNumber("bme-setting-inject-depth", 9999, 0, 9999, (value) => _patchSettings({ injectDepth: value }), ); + bindCheckbox( + "bme-setting-recall-use-authoritative-generation-input", + (checked) => + _patchSettings({ recallUseAuthoritativeGenerationInput: checked }), + ); bindFloat("bme-setting-graph-weight", 0.6, 0, 1, (value) => _patchSettings({ graphWeight: value }), ); From d6fdac07ffe752936065138a12e5c31b4c46688b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 11 Apr 2026 08:39:58 +0000 Subject: [PATCH 08/20] chore: bump manifest version [skip ci] --- manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/manifest.json b/manifest.json index 9f08c68..514628b 100644 --- a/manifest.json +++ b/manifest.json @@ -6,6 +6,6 @@ "js": "index.js", "css": "style.css", "author": "Youzini", - "version": "4.5.3", + "version": "4.5.4", "homePage": "https://github.com/Youzini-afk/ST-Bionic-Memory-Ecology" } From 2e57ffce04783d853707c2691bb7c7977f101a4e Mon Sep 17 00:00:00 2001 From: Youzini-afk <13153778771cx@gmail.com> Date: Sat, 11 Apr 2026 16:47:06 +0800 Subject: [PATCH 09/20] refactor: move extract tuning controls into advanced section --- ui/panel.html | 74 ++++++++++++++++++++++++++++++++++----------------- 1 file changed, 49 insertions(+), 25 deletions(-) diff --git a/ui/panel.html b/ui/panel.html index bbe5777..3e228f0 100644 --- a/ui/panel.html +++ b/ui/panel.html @@ -1513,31 +1513,55 @@
开启后,最新 AI 楼先不自动提取,要等下一条 AI 楼出现后,才提取前一批内容。提取未处理和范围重提不受影响。
-
- - -
-
- - -
+
+ +
+
提取高级项
+
+ 控制提取 prompt 的表达方式,以及结构化 recentMessages 的裁剪。通常保持默认即可。 +
+
+ +
+
+
+ + +
+
+ 只限制提取 prompt 里的 structured recentMessages 条数,不影响实际提取切片范围。 +
+
+ + +
+
+ 控制 LLM 在提取时看到的是纯 transcript、结构化 recentMessages,还是两者同时提供。通常保持“混合”即可。 +
+
+
Date: Sat, 11 Apr 2026 08:47:24 +0000 Subject: [PATCH 10/20] chore: bump manifest version [skip ci] --- manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/manifest.json b/manifest.json index 514628b..da594b5 100644 --- a/manifest.json +++ b/manifest.json @@ -6,6 +6,6 @@ "js": "index.js", "css": "style.css", "author": "Youzini", - "version": "4.5.4", + "version": "4.5.5", "homePage": "https://github.com/Youzini-afk/ST-Bionic-Memory-Ecology" } From 0cb95c4f2b4ef40e8f474286d5166963779c4641 Mon Sep 17 00:00:00 2001 From: Youzini-afk <13153778771cx@gmail.com> Date: Sat, 11 Apr 2026 18:51:50 +0800 Subject: [PATCH 11/20] phase2-4 recall prompt-flow hardening --- host/adapter/regex.js | 286 ++++++++++++++---- host/event-binding.js | 17 ++ index.js | 263 +++++++++++++++- llm/llm.js | 107 +++++-- prompting/prompt-builder.js | 125 ++++++-- prompting/task-regex.js | 86 +++++- retrieval/recall-persistence.js | 4 + tests/helpers/register-hooks-compat.mjs | 18 +- tests/p0-regressions.mjs | 116 ++++++- tests/prompt-builder-mvu.mjs | 59 +++- .../recall-authoritative-generation-input.mjs | 24 ++ tests/task-regex.mjs | 97 +++++- ui/panel.js | 7 +- ui/recall-message-ui.js | 3 + 14 files changed, 1069 insertions(+), 143 deletions(-) diff --git a/host/adapter/regex.js b/host/adapter/regex.js index f730791..d11417c 100644 --- a/host/adapter/regex.js +++ b/host/adapter/regex.js @@ -1,3 +1,7 @@ +import { + getRegexedString as coreGetRegexedString, + regex_placement as coreRegexPlacement, +} from "../../../../regex/engine.js"; import { buildCapabilityStatus, mergeVersionHints } from "./capabilities.js"; import { createContextHostFacade } from "./context.js"; import { debugDebug } from "../../runtime/debug-logging.js"; @@ -7,6 +11,28 @@ const REGEX_API_NAMES = [ "isCharacterTavernRegexesEnabled", "formatAsTavernRegexedString", ]; +const CORE_REGEX_SOURCE_TO_PLACEMENT_KEY = Object.freeze({ + user_input: "USER_INPUT", + ai_output: "AI_OUTPUT", + slash_command: "SLASH_COMMAND", + world_info: "WORLD_INFO", + reasoning: "REASONING", +}); +const REGEX_SOURCE_KIND_PRIORITY = Object.freeze({ + unknown: 0, + unavailable: 0, + "global-fallback": 1, + context: 2, + "core-bridge": 3, + "api-map": 4, + provider: 5, +}); +const REGEX_BRIDGE_TIER_PRIORITY = Object.freeze({ + unavailable: 0, + "helper-getter-only": 1, + "helper-bridge": 2, + "core-real": 3, +}); function isObjectLike(value) { return ( @@ -19,11 +45,90 @@ function bindHostFunction(container, name) { return typeof fn === "function" ? fn.bind(container) : null; } +function resolveCorePlacement(regexPlacement, source) { + const normalizedSource = String(source || "").trim().toLowerCase(); + const placementKey = CORE_REGEX_SOURCE_TO_PLACEMENT_KEY[normalizedSource]; + if (!placementKey || !isObjectLike(regexPlacement)) { + return null; + } + const placement = regexPlacement?.[placementKey]; + return Number.isFinite(Number(placement)) ? Number(placement) : null; +} + +function hasCoreRegexApi(container) { + return ( + typeof container?.getRegexedString === "function" && + resolveCorePlacement(container?.regex_placement, "user_input") != null + ); +} + +function normalizeCoreFormatterOptions(destination, options = {}) { + const normalizedDestination = + typeof destination === "string" ? String(destination || "").trim() : ""; + const normalizedOptions = + destination && + typeof destination === "object" && + !Array.isArray(destination) + ? { ...destination } + : options && typeof options === "object" && !Array.isArray(options) + ? { ...options } + : {}; + + if (normalizedDestination === "display" && normalizedOptions.isMarkdown == null) { + normalizedOptions.isMarkdown = true; + } + if (normalizedDestination === "prompt" && normalizedOptions.isPrompt == null) { + normalizedOptions.isPrompt = true; + } + if ( + normalizedOptions.character_name != null && + normalizedOptions.characterOverride == null + ) { + normalizedOptions.characterOverride = normalizedOptions.character_name; + } + delete normalizedOptions.character_name; + return normalizedOptions; +} + +function createCoreFormatterBridge(container) { + if (!hasCoreRegexApi(container)) { + return null; + } + const getRegexedString = bindHostFunction(container, "getRegexedString"); + const regexPlacement = container?.regex_placement; + if (typeof getRegexedString !== "function") { + return null; + } + + return function formatAsTavernRegexedString( + text, + source, + destination, + options = {} + ) { + const placement = resolveCorePlacement(regexPlacement, source); + if (placement == null) { + return String(text ?? ""); + } + return getRegexedString( + String(text ?? ""), + placement, + normalizeCoreFormatterOptions(destination, options) + ); + }; +} + function buildApiMap(container = null) { - return REGEX_API_NAMES.reduce((result, name) => { + const apiMap = REGEX_API_NAMES.reduce((result, name) => { result[name] = bindHostFunction(container, name); return result; }, {}); + + if (typeof apiMap.formatAsTavernRegexedString !== "function") { + apiMap.formatAsTavernRegexedString = createCoreFormatterBridge(container); + } + + return apiMap; } function countResolvedApis(apiMap = {}) { @@ -31,6 +136,23 @@ function countResolvedApis(apiMap = {}) { .length; } +function detectBridgeTier({ hasCoreApi = false, apiMap = {} } = {}) { + const hasGetter = typeof apiMap.getTavernRegexes === "function"; + const hasFormatter = + typeof apiMap.formatAsTavernRegexedString === "function"; + + if (hasCoreApi && hasFormatter) { + return "core-real"; + } + if (hasFormatter) { + return "helper-bridge"; + } + if (hasGetter) { + return "helper-getter-only"; + } + return "unavailable"; +} + function resolveProviderCandidate(candidate, options = {}) { if (!candidate) { return null; @@ -56,6 +178,8 @@ function buildSourceRecord({ fallback = false, } = {}) { const apiMap = buildApiMap(container); + const hasCoreApi = hasCoreRegexApi(container); + const bridgeTier = detectBridgeTier({ hasCoreApi, apiMap }); return Object.freeze({ label, @@ -63,6 +187,8 @@ function buildSourceRecord({ fallback, apiMap, apiCount: countResolvedApis(apiMap), + hasCoreApi, + bridgeTier, }); } @@ -111,6 +237,27 @@ function collectExplicitRegexSourceRecords(options = {}) { return records; } +function collectCoreBridgeSourceRecords(options = {}) { + if (options?.disableCoreRegexBridge === true) { + return []; + } + const coreBridge = { + getRegexedString: coreGetRegexedString, + regex_placement: coreRegexPlacement, + }; + if (!hasCoreRegexApi(coreBridge)) { + return []; + } + + return [ + buildSourceRecord({ + label: "sillytavern.core.regex", + sourceKind: "core-bridge", + container: coreBridge, + }), + ]; +} + function collectContextRegexSourceRecords(contextHost, options = {}) { const context = contextHost?.readContextSnapshot?.(); if (!isObjectLike(context)) { @@ -177,19 +324,31 @@ function collectGlobalFallbackRecords() { return records; } -function resolveRegexSource(options = {}, contextHost = null) { - const records = [ - ...collectExplicitRegexSourceRecords(options), - ...collectContextRegexSourceRecords(contextHost, options), - ...collectGlobalFallbackRecords(), - ]; +function scoreSourceRecord(record = {}) { + const sourceScore = + REGEX_SOURCE_KIND_PRIORITY[String(record?.sourceKind || "unknown")] || 0; + const tierScore = + REGEX_BRIDGE_TIER_PRIORITY[String(record?.bridgeTier || "unavailable")] || 0; + if (tierScore <= 0) { + return 0; + } + return sourceScore * 100 + tierScore * 10 + Number(record?.apiCount || 0); +} + +function selectBestRegexSource(records = []) { + let bestRecord = null; + let bestScore = -1; + + for (const record of Array.isArray(records) ? records : []) { + const score = scoreSourceRecord(record); + if (!bestRecord || score > bestScore) { + bestRecord = record; + bestScore = score; + } + } return ( - records.find( - (record) => - typeof record.apiMap.getTavernRegexes === "function" || - typeof record.apiMap.formatAsTavernRegexedString === "function", - ) || + bestRecord || buildSourceRecord({ label: "none", sourceKind: "unavailable", @@ -198,22 +357,19 @@ function resolveRegexSource(options = {}, contextHost = null) { ); } -function detectRegexMode(apiMap = {}) { - const hasGetter = typeof apiMap.getTavernRegexes === "function"; - const hasFormatter = - typeof apiMap.formatAsTavernRegexedString === "function"; +function resolveRegexSource(options = {}, contextHost = null) { + const records = [ + ...collectExplicitRegexSourceRecords(options), + ...collectCoreBridgeSourceRecords(options), + ...collectContextRegexSourceRecords(contextHost, options), + ...collectGlobalFallbackRecords(), + ]; - if (!hasGetter && !hasFormatter) { - return "unavailable"; - } + return selectBestRegexSource(records); +} - if (hasGetter && hasFormatter) { - return typeof apiMap.isCharacterTavernRegexesEnabled === "function" - ? "full" - : "partial"; - } - - return hasFormatter ? "formatter-only" : "getter-only"; +function detectRegexMode(sourceRecord = {}) { + return String(sourceRecord?.bridgeTier || "").trim() || "unavailable"; } function buildFallbackReason(sourceRecord, available, mode) { @@ -221,23 +377,15 @@ function buildFallbackReason(sourceRecord, available, mode) { return "未检测到 Tavern Regex 宿主接口"; } - if (sourceRecord?.fallback && mode === "partial") { - return `当前通过 ${sourceRecord.label} fallback 提供部分 Tavern Regex 能力`; + if (mode === "core-real") { + return ""; } - if (sourceRecord?.fallback) { - return `当前通过 ${sourceRecord.label} fallback 提供 Tavern Regex 能力`; + if (mode === "helper-bridge") { + return `当前通过 ${sourceRecord?.label || "unknown"} helper bridge 提供 Tavern Regex formatter`; } - if (mode === "partial") { - return `Tavern Regex 桥接仅发现部分接口,来源: ${sourceRecord?.label || "unknown"}`; - } - - if (mode === "formatter-only") { - return `Tavern Regex 桥接仅发现 formatter 接口,来源: ${sourceRecord?.label || "unknown"}`; - } - - if (mode === "getter-only") { + if (mode === "helper-getter-only") { return `Tavern Regex 桥接仅发现规则读取接口,来源: ${sourceRecord?.label || "unknown"}`; } @@ -247,31 +395,45 @@ function buildFallbackReason(sourceRecord, available, mode) { export function createRegexHostFacade(options = {}) { const contextHost = options.contextHost || createContextHostFacade(options); const sourceRecord = resolveRegexSource(options, contextHost); - const mode = detectRegexMode(sourceRecord.apiMap); + const mode = detectRegexMode(sourceRecord); const available = mode !== "unavailable"; + const formatterAvailable = + typeof sourceRecord.apiMap.formatAsTavernRegexedString === "function"; + const rulesAvailable = + typeof sourceRecord.apiMap.getTavernRegexes === "function"; + const fallbackReason = buildFallbackReason(sourceRecord, available, mode); + const versionHints = mergeVersionHints( + { + apis: REGEX_API_NAMES.filter( + (name) => typeof sourceRecord.apiMap[name] === "function", + ), + apiCount: String(sourceRecord.apiCount), + supportsCharacterToggle: + typeof sourceRecord.apiMap.isCharacterTavernRegexesEnabled === "function" + ? "yes" + : "no", + source: sourceRecord.sourceKind, + sourceLabel: sourceRecord.label, + fallback: sourceRecord.fallback ? "yes" : "no", + contextMode: contextHost?.mode || "unknown", + bridgeTier: sourceRecord.bridgeTier, + hasCoreApi: sourceRecord.hasCoreApi ? "yes" : "no", + }, + options.versionHints, + ); + const capabilityStatus = buildCapabilityStatus({ + available, + mode, + fallbackReason, + versionHints, + }); return Object.freeze({ available, mode, - fallbackReason: buildFallbackReason(sourceRecord, available, mode), - versionHints: mergeVersionHints( - { - apis: REGEX_API_NAMES.filter( - (name) => typeof sourceRecord.apiMap[name] === "function", - ), - apiCount: String(sourceRecord.apiCount), - supportsCharacterToggle: - typeof sourceRecord.apiMap.isCharacterTavernRegexesEnabled === - "function" - ? "yes" - : "no", - source: sourceRecord.sourceKind, - sourceLabel: sourceRecord.label, - fallback: sourceRecord.fallback ? "yes" : "no", - contextMode: contextHost?.mode || "unknown", - }, - options.versionHints, - ), + fallbackReason, + versionHints, + capabilityStatus, getTavernRegexes: sourceRecord.apiMap.getTavernRegexes, isCharacterTavernRegexesEnabled: sourceRecord.apiMap.isCharacterTavernRegexesEnabled, @@ -295,8 +457,10 @@ export function createRegexHostFacade(options = {}) { source: sourceRecord.sourceKind, sourceLabel: sourceRecord.label, fallback: sourceRecord.fallback, - formatterAvailable: - typeof sourceRecord.apiMap.formatAsTavernRegexedString === "function", + formatterAvailable, + rulesAvailable, + bridgeTier: sourceRecord.bridgeTier, + hasCoreApi: sourceRecord.hasCoreApi, }); }, }); diff --git a/host/event-binding.js b/host/event-binding.js index 6bb3a0c..4e64039 100644 --- a/host/event-binding.js +++ b/host/event-binding.js @@ -450,6 +450,23 @@ export async function onGenerationAfterCommandsController( const runtimeRecallOptions = recallContext.recallOptions || recallOptions || {}; + if ( + params && + typeof params === "object" && + runtimeRecallOptions?.authoritativeInputUsed === true + ) { + const authoritativePrompt = String( + runtimeRecallOptions?.overrideUserMessage || + runtimeRecallOptions?.userMessage || + "", + ).trim(); + if (authoritativePrompt) { + params.prompt = authoritativePrompt; + if (Object.prototype.hasOwnProperty.call(params, "user_input")) { + params.user_input = authoritativePrompt; + } + } + } const deliveryMode = runtime.resolveGenerationRecallDeliveryMode?.( recallContext.hookName, diff --git a/index.js b/index.js index 62daf7b..966bc4c 100644 --- a/index.js +++ b/index.js @@ -2143,6 +2143,17 @@ function ensurePersistedRecallRecordForGeneration({ ), tokenEstimate: estimateTokens(injectionText), manuallyEdited: false, + authoritativeInputUsed: Boolean( + recallResult?.authoritativeInputUsed ?? + frozenRecallOptions?.authoritativeInputUsed ?? + recallOptions?.authoritativeInputUsed, + ), + boundUserFloorText: String( + recallResult?.boundUserFloorText || + frozenRecallOptions?.boundUserFloorText || + recallOptions?.boundUserFloorText || + "", + ), }, existingRecord, ); @@ -2314,6 +2325,108 @@ function rewriteRecallPayloadWithInjection( }; } +function rewriteRecallPayloadWithAuthoritativeUserInput( + promptData = null, + authoritativeText = "", + boundUserFloorText = "", +) { + const normalizedAuthoritativeText = normalizeRecallInputText(authoritativeText); + const normalizedBoundUserFloorText = normalizeRecallInputText(boundUserFloorText); + if (!normalizedAuthoritativeText) { + return { + applied: false, + changed: false, + path: "", + field: "", + reason: "empty-authoritative-text", + }; + } + + const finalMesSend = Array.isArray(promptData?.finalMesSend) + ? promptData.finalMesSend + : null; + if (!Array.isArray(finalMesSend) || finalMesSend.length <= 0) { + return { + applied: false, + changed: false, + path: "", + field: "", + reason: "finalMesSend-unavailable", + }; + } + + let fallbackIndex = -1; + let matchedIndex = -1; + for (let index = finalMesSend.length - 1; index >= 0; index--) { + const entry = finalMesSend[index]; + if (!entry || typeof entry !== "object") continue; + if (entry.injected === true) continue; + + const messageText = normalizeRecallInputText( + entry.message || entry.mes || entry.content || "", + ); + if (!messageText) continue; + + if (fallbackIndex < 0) { + fallbackIndex = index; + } + + if ( + messageText === normalizedAuthoritativeText || + (normalizedBoundUserFloorText && + messageText === normalizedBoundUserFloorText) + ) { + matchedIndex = index; + break; + } + } + + const targetIndex = + matchedIndex >= 0 + ? matchedIndex + : normalizedBoundUserFloorText + ? -1 + : fallbackIndex; + if (targetIndex < 0) { + return { + applied: false, + changed: false, + path: "finalMesSend", + field: "", + reason: normalizedBoundUserFloorText + ? "bound-user-floor-text-not-found" + : "no-rewritable-finalMesSend-entry", + }; + } + + const entry = finalMesSend[targetIndex]; + const fieldName = Object.prototype.hasOwnProperty.call(entry, "message") + ? "message" + : Object.prototype.hasOwnProperty.call(entry, "mes") + ? "mes" + : Object.prototype.hasOwnProperty.call(entry, "content") + ? "content" + : "message"; + const previousText = normalizeRecallInputText( + entry?.[fieldName] || entry?.message || entry?.mes || entry?.content || "", + ); + const changed = previousText !== normalizedAuthoritativeText; + if (changed) { + entry[fieldName] = normalizedAuthoritativeText; + } + + return { + applied: true, + changed, + path: "finalMesSend", + field: `finalMesSend[${targetIndex}].${fieldName}`, + reason: changed + ? "finalMesSend-authoritative-user-rewritten" + : "authoritative-user-already-matched", + targetIndex, + }; +} + function readGenerationRecallTransactionFinalResolution(transaction) { return transaction?.finalResolution || null; } @@ -2339,6 +2452,98 @@ function applyFinalRecallInjectionForGeneration({ const existingFinalResolution = readGenerationRecallTransactionFinalResolution(transaction); if (existingFinalResolution) { + if ( + promptData && + transaction?.frozenRecallOptions?.authoritativeInputUsed === true + ) { + const recallResult = + freshRecallResult || + getGenerationRecallTransactionResult(transaction) || + null; + const inputRewrite = rewriteRecallPayloadWithAuthoritativeUserInput( + promptData, + transaction?.frozenRecallOptions?.overrideUserMessage || "", + transaction?.frozenRecallOptions?.boundUserFloorText || "", + ); + const rewrite = rewriteRecallPayloadWithInjection( + promptData, + existingFinalResolution.usedText || recallResult?.injectionText || "", + ); + const nextFinalResolution = { + ...existingFinalResolution, + deliveryMode: "deferred", + applicationMode: + rewrite.applied || inputRewrite.applied + ? "rewrite" + : existingFinalResolution.applicationMode, + rewrite, + inputRewrite, + }; + recordInjectionSnapshot("recall", { + taskType: "recall", + source: + String( + recallResult?.source || + transaction?.frozenRecallOptions?.lockedSource || + transaction?.frozenRecallOptions?.overrideSource || + "", + ).trim() || "unknown", + sourceLabel: + String( + recallResult?.sourceLabel || + transaction?.frozenRecallOptions?.lockedSourceLabel || + transaction?.frozenRecallOptions?.overrideSourceLabel || + "", + ).trim() || "未知", + reason: + String( + recallResult?.reason || + transaction?.frozenRecallOptions?.lockedReason || + transaction?.frozenRecallOptions?.overrideReason || + "", + ).trim() || "final-application-reused", + sourceCandidates: Array.isArray(recallResult?.sourceCandidates) + ? recallResult.sourceCandidates.map((candidate) => ({ ...candidate })) + : Array.isArray(transaction?.frozenRecallOptions?.sourceCandidates) + ? transaction.frozenRecallOptions.sourceCandidates.map((candidate) => ({ + ...candidate, + })) + : [], + hookName: String(hookName || recallResult?.hookName || "").trim(), + selectedNodeIds: recallResult?.selectedNodeIds || [], + retrievalMeta: recallResult?.retrievalMeta || {}, + llmMeta: recallResult?.llmMeta || {}, + stats: recallResult?.stats || {}, + injectionText: nextFinalResolution.usedText || "", + deliveryMode: nextFinalResolution.deliveryMode || "", + applicationMode: nextFinalResolution.applicationMode || "none", + transport: nextFinalResolution.transport || { + applied: false, + source: "none", + mode: "none", + }, + rewrite: nextFinalResolution.rewrite || { + applied: false, + path: "", + field: "", + reason: "final-resolution-reused", + }, + inputRewrite, + targetUserMessageIndex: nextFinalResolution.targetUserMessageIndex, + sourceKind: nextFinalResolution.source || "none", + authoritativeInputUsed: true, + boundUserFloorText: String( + transaction?.frozenRecallOptions?.boundUserFloorText || "", + ), + }); + storeGenerationRecallTransactionFinalResolution( + transaction, + nextFinalResolution, + ); + refreshPanelLiveState(); + schedulePersistedRecallMessageUiRefresh(); + return nextFinalResolution; + } return existingFinalResolution; } @@ -2346,15 +2551,21 @@ function applyFinalRecallInjectionForGeneration({ freshRecallResult || getGenerationRecallTransactionResult(transaction) || null; + const hookResolvedDeliveryMode = + String( + resolveGenerationRecallDeliveryMode( + hookName, + generationType, + transaction?.frozenRecallOptions || {}, + ), + ).trim() || "immediate"; const deliveryMode = String( - recallResult?.deliveryMode || - transaction?.lastDeliveryMode || - resolveGenerationRecallDeliveryMode( - hookName, - generationType, - transaction?.frozenRecallOptions || {}, - ), + promptData && hookName === "GENERATE_BEFORE_COMBINE_PROMPTS" + ? hookResolvedDeliveryMode + : recallResult?.deliveryMode || + transaction?.lastDeliveryMode || + hookResolvedDeliveryMode, ).trim() || "immediate"; const chat = getContext()?.chat; @@ -2369,6 +2580,24 @@ function applyFinalRecallInjectionForGeneration({ injectionText: "", record: null, }; + const authoritativeInputRewrite = + deliveryMode === "deferred" && + transaction?.frozenRecallOptions?.authoritativeInputUsed === true + ? rewriteRecallPayloadWithAuthoritativeUserInput( + promptData, + transaction?.frozenRecallOptions?.overrideUserMessage || "", + transaction?.frozenRecallOptions?.boundUserFloorText || "", + ) + : { + applied: false, + changed: false, + path: "", + field: "", + reason: + deliveryMode === "deferred" + ? "authoritative-input-unused" + : "non-deferred-delivery", + }; const rewrite = { applied: false, path: "", @@ -2539,8 +2768,18 @@ function applyFinalRecallInjectionForGeneration({ applicationMode, transport, rewrite, + inputRewrite: authoritativeInputRewrite, targetUserMessageIndex, sourceKind: resolved.source, + authoritativeInputUsed: Boolean( + recallResult?.authoritativeInputUsed ?? + transaction?.frozenRecallOptions?.authoritativeInputUsed, + ), + boundUserFloorText: String( + recallResult?.boundUserFloorText || + transaction?.frozenRecallOptions?.boundUserFloorText || + "", + ), }); refreshPanelLiveState(); @@ -2557,6 +2796,16 @@ function applyFinalRecallInjectionForGeneration({ applicationMode, rewrite, transport, + inputRewrite: authoritativeInputRewrite, + authoritativeInputUsed: Boolean( + recallResult?.authoritativeInputUsed ?? + transaction?.frozenRecallOptions?.authoritativeInputUsed, + ), + boundUserFloorText: String( + recallResult?.boundUserFloorText || + transaction?.frozenRecallOptions?.boundUserFloorText || + "", + ), }; storeGenerationRecallTransactionFinalResolution(transaction, finalResolution); return finalResolution; diff --git a/llm/llm.js b/llm/llm.js index 0383eeb..3ea73ba 100644 --- a/llm/llm.js +++ b/llm/llm.js @@ -129,6 +129,7 @@ function summarizeTaskTimelineEntry(taskType, snapshot = {}) { responseCleaning: cloneRuntimeDebugValue(snapshot?.responseCleaning, null), jsonFailure: cloneRuntimeDebugValue(snapshot?.jsonFailure, null), messages: cloneRuntimeDebugValue(snapshot?.messages, []), + transportMessages: cloneRuntimeDebugValue(snapshot?.transportMessages, []), requestBody: cloneRuntimeDebugValue(snapshot?.requestBody, null), }; } @@ -930,6 +931,76 @@ function looksLikeTruncatedJson(text) { return false; } +function cloneLlmDebugMessageMetadata(message = {}) { + const metadata = {}; + + for (const key of [ + "source", + "sourceKey", + "blockId", + "blockName", + "blockType", + "injectionMode", + "contentOrigin", + "regexSourceType", + "speaker", + "name", + ]) { + const value = String(message?.[key] || "").trim(); + if (value) { + metadata[key] = value; + } + } + + if (message?.derivedFromWorldInfo === true) { + metadata.derivedFromWorldInfo = true; + } + if (message?.sanitizationEligible === true) { + metadata.sanitizationEligible = true; + } + if (Number.isFinite(Number(message?.depth))) { + metadata.depth = Number(message.depth); + } + if (Number.isFinite(Number(message?.order))) { + metadata.order = Number(message.order); + } + + return metadata; +} + +function normalizeLlmDebugMessage(message = {}) { + if (!message || typeof message !== "object") return null; + const role = String(message.role || "").trim().toLowerCase(); + const content = String(message.content || "").trim(); + if (!content || !["system", "user", "assistant"].includes(role)) { + return null; + } + return { + role, + content, + ...cloneLlmDebugMessageMetadata(message), + }; +} + +function buildTransportMessages(messages = []) { + return (Array.isArray(messages) ? messages : []) + .map((message) => { + if (!message || typeof message !== "object") { + return null; + } + const role = String(message.role || "").trim().toLowerCase(); + const content = String(message.content || "").trim(); + if (!content || !["system", "user", "assistant"].includes(role)) { + return null; + } + return { + role, + content, + }; + }) + .filter(Boolean); +} + function buildJsonAttemptMessages( systemPrompt, userPrompt, @@ -961,15 +1032,7 @@ function buildJsonAttemptMessages( const normalizedPromptMessages = Array.isArray(promptMessages) ? promptMessages - .map((message) => { - if (!message || typeof message !== "object") return null; - const role = String(message.role || "").trim().toLowerCase(); - const content = String(message.content || "").trim(); - if (!["system", "user", "assistant"].includes(role) || !content) { - return null; - } - return { role, content }; - }) + .map((message) => normalizeLlmDebugMessage(message)) .filter(Boolean) : []; @@ -1037,12 +1100,9 @@ function buildJsonAttemptMessages( } for (const message of additionalMessages || []) { - if (!message || typeof message !== "object") continue; - const role = String(message.role || "").trim().toLowerCase(); - const content = String(message.content || "").trim(); - if (!content) continue; - if (!["system", "user", "assistant"].includes(role)) continue; - messages.push({ role, content }); + const normalizedMessage = normalizeLlmDebugMessage(message); + if (!normalizedMessage) continue; + messages.push(normalizedMessage); } messages.push({ role: "user", content: userParts.join("\n\n") }); @@ -1054,16 +1114,16 @@ function resolvePrivateRequestSource( requestSource = "", { allowAnonymous = false } = {}, ) { - const normalizedRequestSource = String(requestSource || "").trim(); - if (normalizedRequestSource) { - return normalizedRequestSource; - } - const normalizedTaskType = String(taskType || "").trim(); if (normalizedTaskType) { return `task:${normalizedTaskType}`; } + const normalizedRequestSource = String(requestSource || "").trim(); + if (normalizedRequestSource) { + return normalizedRequestSource; + } + if (allowAnonymous) { return "adhoc"; } @@ -1399,6 +1459,7 @@ async function callDedicatedOpenAICompatible( taskType, requestSource, ); + const transportMessages = buildTransportMessages(messages); const config = getMemoryLLMConfig(taskType); const settings = extension_settings[MODULE_NAME] || {}; const hasDedicatedConfig = hasDedicatedLLMConfig(config); @@ -1448,6 +1509,7 @@ async function callDedicatedOpenAICompatible( requestedLlmPresetName: config.requestedLlmPresetName || "", llmPresetFallbackReason: config.llmPresetFallbackReason || "", messages, + transportMessages, generation: generationResolved.generation || {}, filteredGeneration: generationResolved.filtered || {}, removedGeneration: generationResolved.removed || [], @@ -1463,7 +1525,7 @@ async function callDedicatedOpenAICompatible( if (!hasDedicatedConfig) { const payload = await sendOpenAIRequest( "quiet", - messages, + transportMessages, signal, jsonMode ? { jsonSchema: createGenericJsonSchema() } : {}, ); @@ -1500,7 +1562,7 @@ async function callDedicatedOpenAICompatible( }) : "", model: config.model, - messages, + messages: transportMessages, temperature: filteredGeneration.temperature ?? 1, max_tokens: resolvedCompletionTokens, stream: filteredGeneration.stream ?? false, @@ -1556,6 +1618,7 @@ async function callDedicatedOpenAICompatible( requestedLlmPresetName: config.requestedLlmPresetName || "", llmPresetFallbackReason: config.llmPresetFallbackReason || "", messages, + transportMessages, generation: generationResolved.generation || {}, filteredGeneration, removedGeneration: generationResolved.removed || [], diff --git a/prompting/prompt-builder.js b/prompting/prompt-builder.js index 0dc5fb1..7b84777 100644 --- a/prompting/prompt-builder.js +++ b/prompting/prompt-builder.js @@ -1865,6 +1865,86 @@ export async function buildTaskPrompt(settings = {}, taskType, context = {}) { return result; } +function clonePayloadMessage(message = {}) { + return createExecutionMessage(message.role, message.content, { + source: String(message.source || ""), + blockId: String(message.blockId || ""), + blockName: String(message.blockName || ""), + blockType: String(message.blockType || ""), + sourceKey: String(message.sourceKey || ""), + injectionMode: String(message.injectionMode || ""), + derivedFromWorldInfo: message.derivedFromWorldInfo === true, + contentOrigin: String(message.contentOrigin || ""), + sanitizationEligible: message.sanitizationEligible === true, + regexSourceType: String(message.regexSourceType || ""), + }); +} + +function collectPayloadUserMessageTexts(messages = []) { + return (Array.isArray(messages) ? messages : []) + .filter((message) => String(message?.role || "").trim().toLowerCase() === "user") + .map((message) => String(message?.content || "").trim()) + .filter(Boolean); +} + +function buildSafeFallbackUserPrompt( + settings = {}, + taskType, + { + fallbackUserPrompt = "", + blockedContents = [], + rawExecutionMessages = [], + rawPrivateTaskMessages = [], + } = {}, +) { + const structuredUserPrompt = [ + ...collectPayloadUserMessageTexts(rawExecutionMessages), + ...collectPayloadUserMessageTexts(rawPrivateTaskMessages), + ] + .join("\n\n") + .trim(); + const candidates = [ + { + source: "structured-user-blocks", + text: structuredUserPrompt, + }, + { + source: "fallback-user-prompt", + text: String(fallbackUserPrompt || "").trim(), + }, + ].filter((candidate) => candidate.text); + + for (const candidate of candidates) { + const sanitized = sanitizeInjectionText(settings, taskType, candidate.text, { + mode: "final-injection-safe", + blockedContents, + contentOrigin: PROMPT_CONTENT_ORIGIN.HOST_INJECTED, + sanitizationEligible: true, + role: "user", + applySanitizer: true, + applyHostRegex: false, + path: "payload.fallbackUserPrompt", + stage: "payload-fallback-user-prompt", + }); + const text = String(sanitized.text || "").trim(); + if (text) { + return { + text, + source: candidate.source, + changed: Boolean(sanitized.changed), + dropped: Boolean(sanitized.dropped), + }; + } + } + + return { + text: "", + source: candidates[0]?.source || "", + changed: false, + dropped: candidates.length > 0, + }; +} + export function buildTaskLlmPayload(promptBuild = null, fallbackUserPrompt = "") { const runtimeMvu = promptBuild?.__mvuRuntime || {}; const taskType = String(promptBuild?.debug?.taskType || ""); @@ -1880,20 +1960,12 @@ export function buildTaskLlmPayload(promptBuild = null, fallbackUserPrompt = "") : []; const rawExecutionMessages = Array.isArray(promptBuild?.executionMessages) ? promptBuild.executionMessages - .map((message) => - createExecutionMessage(message.role, message.content, { - source: String(message.source || ""), - blockId: String(message.blockId || ""), - blockName: String(message.blockName || ""), - blockType: String(message.blockType || ""), - sourceKey: String(message.sourceKey || ""), - injectionMode: String(message.injectionMode || ""), - derivedFromWorldInfo: message.derivedFromWorldInfo === true, - contentOrigin: String(message.contentOrigin || ""), - sanitizationEligible: message.sanitizationEligible === true, - regexSourceType: String(message.regexSourceType || ""), - }), - ) + .map((message) => clonePayloadMessage(message)) + .filter(Boolean) + : []; + const rawPrivateTaskMessages = Array.isArray(promptBuild?.privateTaskMessages) + ? promptBuild.privateTaskMessages + .map((message) => clonePayloadMessage(message)) .filter(Boolean) : []; const executionMessages = sanitizePromptMessages( @@ -1949,22 +2021,39 @@ export function buildTaskLlmPayload(promptBuild = null, fallbackUserPrompt = "") : sanitizePromptMessages( settings, taskType, - Array.isArray(promptBuild?.privateTaskMessages) - ? promptBuild.privateTaskMessages - : [], + rawPrivateTaskMessages, { blockedContents, applySanitizer: (message) => !(isCustomFilter && messageUsesWorldInfoContent(message)), }, ); + const hasAdditionalUserMessage = additionalMessages.some( + (message) => message.role === "user", + ); + const fallbackUserPromptResult = + hasUserMessage || hasAdditionalUserMessage + ? { + text: "", + source: hasUserMessage ? "execution-messages" : "additional-messages", + changed: false, + dropped: false, + } + : buildSafeFallbackUserPrompt(settings, taskType, { + fallbackUserPrompt, + blockedContents, + rawExecutionMessages, + rawPrivateTaskMessages, + }); return { systemPrompt: executionMessages.length > 0 ? "" : String(promptBuild?.systemPrompt || ""), - userPrompt: hasUserMessage ? "" : String(fallbackUserPrompt || ""), + userPrompt: fallbackUserPromptResult.text, promptMessages: executionMessages, additionalMessages, + fallbackUserPromptSource: fallbackUserPromptResult.source, + fallbackUserPromptApplied: Boolean(fallbackUserPromptResult.text), }; } diff --git a/prompting/task-regex.js b/prompting/task-regex.js index 16749a7..0805c0d 100644 --- a/prompting/task-regex.js +++ b/prompting/task-regex.js @@ -256,6 +256,10 @@ function getRegexHost() { const capabilitySupport = regexHost.readCapabilitySupport?.() || {}; const supplementedCapabilities = []; const missingCapabilities = []; + const resolvedGetter = + typeof regexHost.getTavernRegexes === "function" + ? regexHost.getTavernRegexes + : legacyGetTavernRegexes; const resolvedCharacterToggle = typeof regexHost.isCharacterTavernRegexesEnabled === "function" ? regexHost.isCharacterTavernRegexesEnabled @@ -265,6 +269,14 @@ function getRegexHost() { ? regexHost.formatAsTavernRegexedString : legacyFormatAsTavernRegexedString; + if (typeof regexHost.getTavernRegexes !== "function") { + if (resolvedGetter) { + supplementedCapabilities.push("getTavernRegexes"); + } else { + missingCapabilities.push("getTavernRegexes"); + } + } + if (typeof regexHost.isCharacterTavernRegexesEnabled !== "function") { if (resolvedCharacterToggle) { supplementedCapabilities.push("isCharacterTavernRegexesEnabled"); @@ -282,16 +294,24 @@ function getRegexHost() { } return { - getTavernRegexes: regexHost.getTavernRegexes, + getTavernRegexes: resolvedGetter, isCharacterTavernRegexesEnabled: resolvedCharacterToggle, formatAsTavernRegexedString: resolvedFormatter, - sourceLabel: capabilitySupport.sourceLabel || "host-adapter.regex", + sourceLabel: + capabilitySupport.sourceLabel || regexHost?.sourceLabel || "host-adapter.regex", fallback: Boolean(capabilitySupport.fallback) || + typeof regexHost.getTavernRegexes !== "function" || + typeof regexHost.isCharacterTavernRegexesEnabled !== "function" || + typeof regexHost.formatAsTavernRegexedString !== "function" || supplementedCapabilities.length > 0, - fallbackReason: String(capabilitySupport.fallbackReason || "").trim(), + fallbackReason: String( + regexHost?.fallbackReason || capabilitySupport.fallbackReason || "", + ).trim(), capabilityStatus: Object.freeze({ - mode: capabilitySupport.mode || "unknown", + mode: capabilitySupport.mode || regexHost?.mode || "unknown", + bridgeTier: + capabilitySupport.bridgeTier || capabilitySupport.mode || regexHost?.mode || "unknown", supplementedCapabilities: Object.freeze(supplementedCapabilities), missingCapabilities: Object.freeze(missingCapabilities), }), @@ -500,10 +520,15 @@ function summarizeRuleForPromptPreview(rule, stageConfig = {}, reason = "") { promptStageMode = promptSemanticApplies ? "replace" : "skip"; } else if (rule?.destinationFlags?.prompt === false || summary.markdownOnly) { promptStageMode = "display-only"; - } else if (summary.beautificationReplace && executionState.mode !== "host-real") { + } else if ( + summary.beautificationReplace && + !["host-real", "host-helper"].includes(executionState.mode) + ) { promptStageMode = "fallback-skip-beautify"; } else if (executionState.mode === "host-real") { promptStageMode = "host-real"; + } else if (executionState.mode === "host-helper") { + promptStageMode = "host-helper"; } else if (executionState.mode === "host-fallback") { promptStageMode = "host-fallback"; } @@ -748,6 +773,10 @@ function collectTavernRulesDetailed(regexConfig = {}) { formatterAvailable: typeof regexHost.formatAsTavernRegexedString === "function", executionMode: buildHostRegexExecutionState(regexHost).mode, + bridgeTier: + regexHost?.capabilityStatus?.bridgeTier || + regexHost?.capabilityStatus?.mode || + "unknown", capabilityStatus: regexHost.capabilityStatus || null, }, sources, @@ -822,21 +851,40 @@ function ruleMatchesFormatterDepth(rule, formatterOptions = null) { } function buildHostRegexExecutionState(regexHost = null) { + const bridgeTier = + String( + regexHost?.capabilityStatus?.bridgeTier || + regexHost?.capabilityStatus?.mode || + "", + ).trim() || "unknown"; const formatterAvailable = typeof regexHost?.formatAsTavernRegexedString === "function"; const rulesAvailable = typeof regexHost?.getTavernRegexes === "function"; - if (formatterAvailable) { + if (formatterAvailable && bridgeTier === "core-real") { return { mode: "host-real", + bridgeTier, formatterAvailable: true, fallbackReason: "", }; } + if (formatterAvailable) { + return { + mode: "host-helper", + bridgeTier, + formatterAvailable: true, + fallbackReason: + String(regexHost?.fallbackReason || "").trim() || + "当前通过 helper bridge 提供 Tavern Regex formatter", + }; + } + if (rulesAvailable) { return { mode: "host-fallback", + bridgeTier, formatterAvailable: false, fallbackReason: String(regexHost?.fallbackReason || "").trim() || @@ -846,6 +894,7 @@ function buildHostRegexExecutionState(regexHost = null) { return { mode: "host-unavailable", + bridgeTier, formatterAvailable: false, fallbackReason: String(regexHost?.fallbackReason || "").trim() || @@ -864,7 +913,7 @@ function shouldReuseTavernRuleForPrompt(rule, executionMode = "host-fallback") { return false; } if ( - executionMode !== "host-real" && + !["host-real", "host-helper"].includes(executionMode) && Boolean(rule?.beautificationReplace) ) { return false; @@ -1106,7 +1155,10 @@ export function applyHostRegexReuse( if ( !normalizedSourceType || - (tavernRules.length === 0 && executionState.mode !== "host-real") + ( + tavernRules.length === 0 && + !["host-real", "host-helper"].includes(executionState.mode) + ) ) { pushDebug(debugCollector, { kind: "host-reuse", @@ -1133,7 +1185,7 @@ export function applyHostRegexReuse( } if ( - executionState.mode === "host-real" && + ["host-real", "host-helper"].includes(executionState.mode) && typeof regexHost?.formatAsTavernRegexedString === "function" ) { try { @@ -1150,23 +1202,29 @@ export function applyHostRegexReuse( taskType: normalizedTaskType, stage: `host:${normalizedSourceType}`, enabled: true, - executionMode: "host-real", + executionMode: executionState.mode, formatterAvailable: true, appliedRules: output !== input - ? [{ id: "__host_formatter__", source: "host-real" }] + ? [{ id: "__host_formatter__", source: executionState.mode }] : [], sourceCount: { tavern: tavernRules.length, local: 0 }, - fallbackReason: "", + fallbackReason: + executionState.mode === "host-real" + ? "" + : executionState.fallbackReason, hostFormatterSource: String(regexHost?.sourceLabel || ""), skippedDisplayOnlyRuleCount, }); return { text: output, changed: output !== input, - executionMode: "host-real", + executionMode: executionState.mode, formatterAvailable: true, formatterSource: String(regexHost?.sourceLabel || ""), - fallbackReason: "", + fallbackReason: + executionState.mode === "host-real" + ? "" + : executionState.fallbackReason, skippedDisplayOnlyRuleCount, }; } catch (error) { diff --git a/retrieval/recall-persistence.js b/retrieval/recall-persistence.js index b418474..69b5cfe 100644 --- a/retrieval/recall-persistence.js +++ b/retrieval/recall-persistence.js @@ -47,6 +47,8 @@ export function readPersistedRecallFromUserMessage(chat, userMessageIndex) { updatedAt: toIsoString(record.updatedAt), generationCount: Math.max(0, Number.parseInt(record.generationCount, 10) || 0), manuallyEdited: Boolean(record.manuallyEdited), + authoritativeInputUsed: Boolean(record.authoritativeInputUsed), + boundUserFloorText: String(record.boundUserFloorText || ""), }; } @@ -69,6 +71,8 @@ export function buildPersistedRecallRecord(payload = {}, existingRecord = null) updatedAt: nowIso, generationCount: 0, manuallyEdited: Boolean(payload.manuallyEdited), + authoritativeInputUsed: Boolean(payload.authoritativeInputUsed), + boundUserFloorText: String(payload.boundUserFloorText || ""), }; } diff --git a/tests/helpers/register-hooks-compat.mjs b/tests/helpers/register-hooks-compat.mjs index da1b471..1d4d3a2 100644 --- a/tests/helpers/register-hooks-compat.mjs +++ b/tests/helpers/register-hooks-compat.mjs @@ -1,11 +1,27 @@ import { register, registerHooks } from "node:module"; +const DEFAULT_REGEX_ENGINE_HOOK_ENTRIES = Object.freeze([ + { + specifiers: ["../../../../regex/engine.js"], + url: toDataModuleUrl([ + "export const regex_placement = { USER_INPUT: 1, AI_OUTPUT: 2, SLASH_COMMAND: 3, WORLD_INFO: 5, REASONING: 6 };", + "export function getRegexedString(...args) {", + " const fn = globalThis.__taskRegexTestCoreGetRegexedString;", + " return typeof fn === 'function' ? fn(...args) : String(args?.[0] ?? '');", + "}", + ].join("\n")), + }, +]); + export function toDataModuleUrl(source = "") { return `data:text/javascript,${encodeURIComponent(String(source || ""))}`; } export function installResolveHooks(entries = []) { - const normalizedEntries = (Array.isArray(entries) ? entries : []) + const normalizedEntries = [ + ...(Array.isArray(entries) ? entries : []), + ...DEFAULT_REGEX_ENGINE_HOOK_ENTRIES, + ] .map((entry) => ({ specifiers: Array.isArray(entry?.specifiers) ? entry.specifiers.map((value) => String(value || "")).filter(Boolean) diff --git a/tests/p0-regressions.mjs b/tests/p0-regressions.mjs index b222863..36dbd90 100644 --- a/tests/p0-regressions.mjs +++ b/tests/p0-regressions.mjs @@ -4244,6 +4244,65 @@ async function testGenerationRecallDeferredRewriteMutatesFinalMesSendPayload() { ); } +async function testGenerationRecallDeferredRewriteMutatesFinalMesSendAuthoritativeUserInput() { + const harness = await createGenerationRecallHarness({ realApplyFinal: true }); + harness.extension_settings[MODULE_NAME] = { + recallUseAuthoritativeGenerationInput: true, + }; + harness.chat = [{ is_user: true, mes: "楼层稳定输入" }]; + harness.pendingRecallSendIntent = { + text: "发送前真实输入", + hash: "hash-deferred-authoritative-rewrite", + at: Date.now(), + source: "dom-intent", + }; + harness.result.pendingRecallSendIntent = harness.pendingRecallSendIntent; + + await harness.result.onGenerationAfterCommands("normal", {}, false); + + const promptData = { + finalMesSend: [ + { + injected: false, + message: "楼层稳定输入", + extensionPrompts: [], + }, + ], + }; + + const resolution = await harness.result.onBeforeCombinePrompts(promptData); + + assert.equal(harness.runRecallCalls.length, 1); + assert.equal( + harness.runRecallCalls[0].hookName, + "GENERATION_AFTER_COMMANDS", + ); + const transaction = [...harness.result.generationRecallTransactions.values()][0]; + assert.ok(transaction); + assert.equal(transaction.frozenRecallOptions.authoritativeInputUsed, true); + assert.equal(transaction.frozenRecallOptions.boundUserFloorText, "楼层稳定输入"); + assert.equal( + harness.runRecallCalls[0].authoritativeInputUsed, + true, + ); + assert.equal(harness.runRecallCalls[0].boundUserFloorText, "楼层稳定输入"); + assert.equal(promptData.finalMesSend[0].message, "发送前真实输入"); + assert.equal(resolution.applicationMode, "rewrite"); + assert.equal(resolution.authoritativeInputUsed, true); + assert.equal(resolution.boundUserFloorText, "楼层稳定输入"); + assert.equal(resolution.inputRewrite.applied, true); + assert.equal(resolution.inputRewrite.changed, true); + assert.equal(resolution.inputRewrite.field, "finalMesSend[0].message"); + assert.match( + promptData.finalMesSend[0].extensionPrompts.join("\n"), + /注入:发送前真实输入/, + ); + assert.equal( + harness.recordedInjectionSnapshots.at(-1)?.inputRewrite?.applied, + true, + ); +} + async function testGenerationRecallSendIntentBeatsChatTailAndStaysObservable() { const harness = await createGenerationRecallHarness(); harness.chat = [{ is_user: true, mes: "旧的 chat tail" }]; @@ -4480,8 +4539,11 @@ async function testPersistentRecallDataLayerLifecycleAndCompatibility() { hookName: "GENERATION_AFTER_COMMANDS", tokenEstimate: 24, manuallyEdited: false, + authoritativeInputUsed: true, + boundUserFloorText: "稳定楼层输入", nowIso: "2026-01-01T00:00:00.000Z", }); + assert.equal(writePersistedRecallToUserMessage(chat, 2, record), true); const loaded = readPersistedRecallFromUserMessage(chat, 2); @@ -4489,6 +4551,8 @@ async function testPersistentRecallDataLayerLifecycleAndCompatibility() { assert.equal(loaded.injectionText, "fresh-memory"); assert.equal(loaded.generationCount, 0); assert.equal(loaded.manuallyEdited, false); + assert.equal(loaded.authoritativeInputUsed, true); + assert.equal(loaded.boundUserFloorText, "稳定楼层输入"); chat[2].mes = "u2 edited"; assert.equal( @@ -4517,14 +4581,19 @@ async function testPersistentRecallDataLayerLifecycleAndCompatibility() { hookName: "MESSAGE_RECALL_BADGE_RERUN", tokenEstimate: 30, manuallyEdited: false, + authoritativeInputUsed: false, + boundUserFloorText: "", nowIso: "2026-01-01T00:00:02.000Z", }, readPersistedRecallFromUserMessage(chat, 2), ); + assert.equal(writePersistedRecallToUserMessage(chat, 2, overwrite), true); const overwritten = readPersistedRecallFromUserMessage(chat, 2); assert.equal(overwritten?.manuallyEdited, false); assert.equal(overwritten?.injectionText, "system-rerecall"); + assert.equal(overwritten?.authoritativeInputUsed, false); + assert.equal(overwritten?.boundUserFloorText, ""); assert.equal(removePersistedRecallFromUserMessage(chat, 2), true); assert.equal(readPersistedRecallFromUserMessage(chat, 2), null); @@ -4601,17 +4670,39 @@ async function testGenerationRecallFinalInjectionRebindsLatestMatchingUserFloor( status: "completed", didRecall: true, injectionText: "fresh-memory", + authoritativeInputUsed: true, + boundUserFloorText: "稳定楼层输入", }, transaction: { frozenRecallOptions: { generationType: "normal", targetUserMessageIndex: null, overrideUserMessage: "当前输入", + lockedSource: "send-intent", + hookName: "GENERATION_AFTER_COMMANDS", }, }, }); + assert.equal(resolution.source, "fresh"); assert.equal(resolution.targetUserMessageIndex, 0); + assert.equal(resolution.authoritativeInputUsed, true); + assert.equal(resolution.boundUserFloorText, "稳定楼层输入"); + assert.equal( + harness.chat[0]?.extra?.bme_recall?.injectionText, + "fresh-memory", + ); + + assert.equal( + JSON.stringify(harness.chat[0]?.extra?.bme_recall?.selectedNodeIds || []), + JSON.stringify([]), + ); + assert.equal(harness.chat[0]?.extra?.bme_recall?.authoritativeInputUsed, true); + assert.equal( + harness.chat[0]?.extra?.bme_recall?.boundUserFloorText, + "稳定楼层输入", + ); + assert.equal(harness.metadataSaveCalls > 0, true); } { @@ -4640,6 +4731,8 @@ async function testGenerationRecallFinalInjectionRebindsLatestMatchingUserFloor( generationType: "normal", targetUserMessageIndex: null, overrideUserMessage: "尾部 user 仍可匹配", + lockedSource: "send-intent", + hookName: "GENERATION_AFTER_COMMANDS", }, }, }); @@ -4674,6 +4767,8 @@ async function testGenerationRecallFinalInjectionRebindsLatestMatchingUserFloor( generationType: "normal", targetUserMessageIndex: null, overrideUserMessage: "发送前捕获的原始文本", + lockedSource: "send-intent", + hookName: "GENERATION_AFTER_COMMANDS", }, }, }); @@ -4703,6 +4798,8 @@ async function testGenerationRecallFinalInjectionBackfillsPersistedRecord() { didRecall: true, injectionText: "fresh-memory", selectedNodeIds: ["node-a", "node-b"], + authoritativeInputUsed: true, + boundUserFloorText: "稳定楼层输入", }, transaction: { frozenRecallOptions: { @@ -4721,9 +4818,15 @@ async function testGenerationRecallFinalInjectionBackfillsPersistedRecord() { harness.chat[0]?.extra?.bme_recall?.injectionText, "fresh-memory", ); - assert.deepEqual( - harness.chat[0]?.extra?.bme_recall?.selectedNodeIds, - ["node-a", "node-b"], + + assert.equal( + JSON.stringify(harness.chat[0]?.extra?.bme_recall?.selectedNodeIds || []), + JSON.stringify(["node-a", "node-b"]), + ); + assert.equal(harness.chat[0]?.extra?.bme_recall?.authoritativeInputUsed, true); + assert.equal( + harness.chat[0]?.extra?.bme_recall?.boundUserFloorText, + "稳定楼层输入", ); assert.equal(harness.metadataSaveCalls > 0, true); } @@ -4744,9 +4847,9 @@ async function testGenerationRecallImmediateAfterCommandsBackfillsPersistedRecor harness.chat[0]?.extra?.bme_recall?.injectionText, "注入:即时模式补写目标", ); - assert.deepEqual( - harness.chat[0]?.extra?.bme_recall?.selectedNodeIds, - ["node-test-1"], + assert.equal( + JSON.stringify(harness.chat[0]?.extra?.bme_recall?.selectedNodeIds || []), + JSON.stringify(["node-test-1"]), ); assert.equal(harness.metadataSaveCalls > 0, true); } @@ -6079,6 +6182,7 @@ await testAutoExtractionDefersWhenHistoryRecoveryBusy(); await testRemoveNodeHandlesCyclicChildGraph(); await testGenerationRecallAppliesFinalInjectionOncePerTransaction(); await testGenerationRecallDeferredRewriteMutatesFinalMesSendPayload(); +await testGenerationRecallDeferredRewriteMutatesFinalMesSendAuthoritativeUserInput(); await testPersistentRecallDataLayerLifecycleAndCompatibility(); await testPersistentRecallSourceResolutionAndTargetRouting(); await testGenerationRecallFinalInjectionRebindsLatestMatchingUserFloor(); diff --git a/tests/prompt-builder-mvu.mjs b/tests/prompt-builder-mvu.mjs index 4bfc577..bd808bd 100644 --- a/tests/prompt-builder-mvu.mjs +++ b/tests/prompt-builder-mvu.mjs @@ -327,9 +327,42 @@ try { systemOnlyPromptBuild, "fallback hidden text", ); + assert.equal(systemOnlyPayload.userPrompt, "fallback text"); + assert.equal(systemOnlyPayload.fallbackUserPromptSource, "fallback-user-prompt"); + + const additionalUserOnlyPayload = buildTaskLlmPayload( + { + debug: { + taskType: "recall", + }, + systemPrompt: "", + executionMessages: [], + privateTaskMessages: [ + { + role: "user", + content: "来自 additionalMessages 的结构化用户块", + source: "profile-block", + }, + ], + }, + "unused fallback user prompt", + ); + assert.equal(additionalUserOnlyPayload.userPrompt, ""); assert.equal( - systemOnlyPayload.userPrompt, - "fallback hidden text", + additionalUserOnlyPayload.fallbackUserPromptSource, + "additional-messages", + ); + assert.deepEqual( + additionalUserOnlyPayload.additionalMessages.map((message) => ({ + role: message.role, + content: message.content, + })), + [ + { + role: "user", + content: "来自 additionalMessages 的结构化用户块", + }, + ], ); const rawWorldInfoEntries = [ @@ -465,6 +498,10 @@ try { assert.equal(payload.systemPrompt, ""); assert.match(JSON.stringify(payload.promptMessages), /FINAL_BAD/); assert.doesNotMatch(JSON.stringify(payload.promptMessages), /FINAL_GOOD/); + assert.equal( + payload.promptMessages.some((message) => String(message?.regexSourceType || "").trim()), + true, + ); const result = await llm.callLLMForJSON({ systemPrompt: payload.systemPrompt, userPrompt: payload.userPrompt, @@ -492,6 +529,22 @@ try { assert.ok(runtimePromptBuild); assert.ok(runtimeLlmRequest); assert.match(JSON.stringify(runtimeLlmRequest.messages), /FINAL_GOOD/); + assert.equal( + runtimeLlmRequest.messages.some((message) => + String(message?.regexSourceType || "").trim(), + ), + true, + ); + assert.equal( + runtimeLlmRequest.transportMessages.some((message) => + Object.prototype.hasOwnProperty.call(message || {}, "regexSourceType"), + ), + false, + ); + assert.doesNotMatch( + JSON.stringify(capturedBodies[0].messages), + /regexSourceType|sourceKey|blockId|contentOrigin|speaker/i, + ); assert.equal(runtimeLlmRequest.requestCleaning?.applied, true); assert.equal( runtimeLlmRequest.requestCleaning?.stages?.length > 0, @@ -516,7 +569,7 @@ try { /status_current_variable|updatevariable|StatusPlaceHolderImpl|stat_data|display_data|delta_data|get_message_variable/i, ); assert.deepEqual( - runtimeLlmRequest.messages, + runtimeLlmRequest.transportMessages, runtimeLlmRequest.requestBody.messages, ); assert.equal( diff --git a/tests/recall-authoritative-generation-input.mjs b/tests/recall-authoritative-generation-input.mjs index 47d2610..c992f3d 100644 --- a/tests/recall-authoritative-generation-input.mjs +++ b/tests/recall-authoritative-generation-input.mjs @@ -199,6 +199,29 @@ async function testHostSnapshotCanRemainAuthoritativeQueryWhenFlagEnabled() { assert.equal(transaction.frozenRecallOptions.includeSyntheticUserMessage, true); } +async function testGenerationAfterCommandsWritesBackAuthoritativePromptWhenPreserved() { + const harness = await createGenerationRecallHarness(); + harness.extension_settings[MODULE_NAME] = { + recallUseAuthoritativeGenerationInput: true, + }; + harness.chat = [{ is_user: true, mes: "旧的 chat tail" }]; + harness.pendingRecallSendIntent = { + text: "发送前权威输入", + hash: "hash-phase4-writeback", + at: Date.now(), + source: "dom-intent", + }; + const params = { + prompt: "旧 prompt", + user_input: "旧 user_input", + }; + + await harness.result.onGenerationAfterCommands("normal", params, false); + + assert.equal(params.prompt, "发送前权威输入"); + assert.equal(params.user_input, "发送前权威输入"); +} + function testResolveRecallInputControllerAppendsSyntheticAuthoritativeUserMessage() { const runtime = { normalizeRecallInputText(value = "") { @@ -240,6 +263,7 @@ await testSendIntentCanRemainAuthoritativeQueryWhenFlagEnabled(); await testPlannerHandoffCanRemainAuthoritativeQueryWhenFlagEnabled(); await testAuthoritativeSendIntentStaysFrozenAcrossHooksWhenFlagEnabled(); await testHostSnapshotCanRemainAuthoritativeQueryWhenFlagEnabled(); +await testGenerationAfterCommandsWritesBackAuthoritativePromptWhenPreserved(); testResolveRecallInputControllerAppendsSyntheticAuthoritativeUserMessage(); console.log("recall-authoritative-generation-input tests passed"); diff --git a/tests/task-regex.mjs b/tests/task-regex.mjs index cc00b83..fb662b9 100644 --- a/tests/task-regex.mjs +++ b/tests/task-regex.mjs @@ -12,6 +12,16 @@ const extensionsShimSource = [ const extensionsShimUrl = `data:text/javascript,${encodeURIComponent( extensionsShimSource, )}`; +const regexEngineShimSource = [ + "export const regex_placement = { USER_INPUT: 1, AI_OUTPUT: 2, SLASH_COMMAND: 3, WORLD_INFO: 5, REASONING: 6 };", + "export function getRegexedString(...args) {", + " const fn = globalThis.__taskRegexTestCoreGetRegexedString;", + " return typeof fn === 'function' ? fn(...args) : String(args?.[0] ?? '');", + "}", +].join("\n"); +const regexEngineShimUrl = `data:text/javascript,${encodeURIComponent( + regexEngineShimSource, +)}`; installResolveHooks([ { @@ -22,6 +32,10 @@ installResolveHooks([ ], url: extensionsShimUrl, }, + { + specifiers: ["../../../../regex/engine.js"], + url: regexEngineShimUrl, + }, ]); const originalSillyTavern = globalThis.SillyTavern; @@ -29,6 +43,7 @@ const originalGetTavernRegexes = globalThis.getTavernRegexes; const originalIsCharacterTavernRegexesEnabled = globalThis.isCharacterTavernRegexesEnabled; const originalExtensionSettings = globalThis.__taskRegexTestExtensionSettings; +const originalCoreGetRegexedString = globalThis.__taskRegexTestCoreGetRegexedString; const PLACEMENT = Object.freeze({ USER_INPUT: 1, @@ -146,6 +161,14 @@ function setTestContext({ }; } +function setCoreRegexedStringHandler(handler = null) { + if (typeof handler === "function") { + globalThis.__taskRegexTestCoreGetRegexedString = handler; + return; + } + delete globalThis.__taskRegexTestCoreGetRegexedString; +} + try { const { initializeHostAdapter } = await import("../host/adapter/index.js"); const { applyHostRegexReuse, applyTaskRegex, inspectTaskRegexReuse } = await import( @@ -157,6 +180,8 @@ try { normalizeTaskProfile, normalizeTaskRegexStages, } = await import("../prompting/prompt-profiles.js"); + const initializeFallbackHostAdapter = () => + initializeHostAdapter({ disableCoreRegexBridge: true }); const normalizedLegacyStages = normalizeTaskRegexStages({ finalPrompt: true, @@ -245,6 +270,48 @@ try { true, ); + setTestContext({ + extensionSettings: { + regex: [], + preset_allowed_regex: {}, + character_allowed_regex: [], + }, + }); + const coreFormatterCalls = []; + setCoreRegexedStringHandler((text, placement, options) => { + coreFormatterCalls.push({ text, placement, options }); + return String(text || "").replace(/Alpha/g, "CORE"); + }); + initializeHostAdapter({}); + const coreBridgeDebug = { entries: [] }; + const coreBridgeOutput = applyHostRegexReuse( + buildSettings(), + "extract", + "Alpha Beta", + { + sourceType: "user_input", + role: "user", + debugCollector: coreBridgeDebug, + }, + ); + assert.equal(coreBridgeOutput.text, "CORE Beta"); + assert.deepEqual(coreFormatterCalls, [ + { + text: "Alpha Beta", + placement: 1, + options: { + isPrompt: true, + isMarkdown: false, + }, + }, + ]); + assert.equal(coreBridgeDebug.entries[0].executionMode, "host-real"); + assert.equal( + inspectTaskRegexReuse(buildSettings(), "extract").host.bridgeTier, + "core-real", + ); + setCoreRegexedStringHandler(null); + globalThis.getTavernRegexes = () => { throw new Error("legacy global getter should not be used in regex tests"); }; @@ -333,11 +400,15 @@ try { }, }, ]); - assert.equal(fullBridgeDebug.entries[0].executionMode, "host-real"); + assert.equal(fullBridgeDebug.entries[0].executionMode, "host-helper"); assert.deepEqual( fullBridgeDebug.entries[0].appliedRules.map((item) => item.id), ["__host_formatter__"], ); + assert.equal( + inspectTaskRegexReuse(fullBridgeSettings, "extract").host.bridgeTier, + "helper-bridge", + ); assert.equal( applyTaskRegex( fullBridgeSettings, @@ -383,7 +454,7 @@ try { }, ], }); - initializeHostAdapter({}); + initializeFallbackHostAdapter(); const fallbackDebug = { entries: [] }; const fallbackOutput = applyHostRegexReuse( @@ -412,7 +483,7 @@ try { character_allowed_regex: [], }, }); - initializeHostAdapter({}); + initializeFallbackHostAdapter(); const depthMissResult = applyHostRegexReuse( buildSettings({ sources: { @@ -476,7 +547,7 @@ try { }, ], }); - initializeHostAdapter({}); + initializeFallbackHostAdapter(); const fallbackInspect = inspectTaskRegexReuse(buildSettings(), "extract"); assert.equal(fallbackInspect.activeRuleCount, 3); assert.deepEqual( @@ -525,7 +596,7 @@ try { }, ], }); - initializeHostAdapter({}); + initializeFallbackHostAdapter(); const disallowedOutput = applyHostRegexReuse( buildSettings(), @@ -587,7 +658,7 @@ try { character_allowed_regex: [], }, }); - initializeHostAdapter({}); + initializeFallbackHostAdapter(); const userReuseResult = applyHostRegexReuse( tavernSemanticsSettings, @@ -641,7 +712,7 @@ try { character_allowed_regex: [], }, }); - initializeHostAdapter({}); + initializeFallbackHostAdapter(); const markdownFinalDebug = { entries: [] }; const markdownFallbackResult = applyHostRegexReuse( markdownOnlyFinalPromptSettings, @@ -675,7 +746,7 @@ try { character_allowed_regex: [], }, }); - initializeHostAdapter({}); + initializeFallbackHostAdapter(); const beautifyFinalInspect = inspectTaskRegexReuse( beautifyFinalPromptSettings, "extract", @@ -759,7 +830,7 @@ try { character_allowed_regex: [], }, }); - initializeHostAdapter({}); + initializeFallbackHostAdapter(); const destinationDebug = { entries: [] }; const destinationReuseResult = applyHostRegexReuse( destinationBeautifySettings, @@ -817,7 +888,7 @@ try { character_allowed_regex: [], }, }); - initializeHostAdapter({}); + initializeFallbackHostAdapter(); const mixedReuseResult = applyHostRegexReuse( tavernSemanticsSettings, "extract", @@ -889,6 +960,12 @@ try { globalThis.__taskRegexTestExtensionSettings = originalExtensionSettings; } + if (originalCoreGetRegexedString === undefined) { + delete globalThis.__taskRegexTestCoreGetRegexedString; + } else { + globalThis.__taskRegexTestCoreGetRegexedString = originalCoreGetRegexedString; + } + try { const { initializeHostAdapter } = await import("../host/adapter/index.js"); initializeHostAdapter({}); diff --git a/ui/panel.js b/ui/panel.js index b2db996..9ab57e4 100644 --- a/ui/panel.js +++ b/ui/panel.js @@ -7349,6 +7349,11 @@ function _renderRegexReuseBadges(rule = {}) { className: "is-transform", text: "宿主真实执行", }); + } else if (rule.promptStageMode === "host-helper") { + badges.push({ + className: "is-prompt", + text: "Helper 兼容执行", + }); } else if (rule.promptStageMode === "host-fallback") { badges.push({ className: "is-prompt", @@ -7514,7 +7519,7 @@ function _buildRegexReusePopupContent(snapshot = {}) {
桥接模式 - ${_escHtml(snapshot.host?.sourceLabel || "unknown")} · ${_escHtml(snapshot.host?.executionMode || snapshot.host?.capabilityStatus?.mode || snapshot.host?.mode || "unknown")}${snapshot.host?.formatterAvailable ? " · formatter" : ""}${snapshot.host?.fallback ? " · fallback" : ""} + ${_escHtml(snapshot.host?.sourceLabel || "unknown")} · ${_escHtml(snapshot.host?.executionMode || snapshot.host?.capabilityStatus?.mode || snapshot.host?.mode || "unknown")}${snapshot.host?.bridgeTier ? ` · ${_escHtml(snapshot.host.bridgeTier)}` : ""}${snapshot.host?.formatterAvailable ? " · formatter" : ""}${snapshot.host?.fallback ? " · fallback" : ""}
diff --git a/ui/recall-message-ui.js b/ui/recall-message-ui.js index 35f0e3f..4c101e1 100644 --- a/ui/recall-message-ui.js +++ b/ui/recall-message-ui.js @@ -82,6 +82,7 @@ function formatTokenHint(tokenEstimate) { function formatMetaLine(record) { const parts = []; if (record.recallSource) parts.push(`来源: ${record.recallSource}`); + if (record.authoritativeInputUsed) parts.push("权威输入"); if (record.tokenEstimate > 0) parts.push(`~${record.tokenEstimate} tokens`); if (Number.isFinite(record.generationCount) && record.generationCount > 0) { parts.push(`回退 ${record.generationCount} 次`); @@ -180,6 +181,8 @@ function buildExpandedRenderSignature({ return stableSerialize({ updatedAt: String(record?.updatedAt || ""), manuallyEdited: Boolean(record?.manuallyEdited), + authoritativeInputUsed: Boolean(record?.authoritativeInputUsed), + boundUserFloorText: String(record?.boundUserFloorText || ""), generationCount: Number.isFinite(record?.generationCount) ? record.generationCount : 0, From 78a451dfe495ba4c2d298f3da23c408b4cf70edf Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 11 Apr 2026 10:52:21 +0000 Subject: [PATCH 12/20] chore: bump manifest version [skip ci] --- manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/manifest.json b/manifest.json index da594b5..0fb3fbf 100644 --- a/manifest.json +++ b/manifest.json @@ -6,6 +6,6 @@ "js": "index.js", "css": "style.css", "author": "Youzini", - "version": "4.5.5", + "version": "4.5.6", "homePage": "https://github.com/Youzini-afk/ST-Bionic-Memory-Ecology" } From 30046dd66bb3c18a91b103b2973662d04320829a Mon Sep 17 00:00:00 2001 From: Youzini-afk <13153778771cx@gmail.com> Date: Sat, 11 Apr 2026 19:38:17 +0800 Subject: [PATCH 13/20] feat(regex): move contamination cleanup to default global preset --- prompting/prompt-profiles.js | 122 +++++++++++++++++++++++++++++- tests/default-settings.mjs | 11 +++ tests/prompt-builder-defaults.mjs | 27 ++++++- tests/task-profile-migration.mjs | 11 ++- tests/task-regex.mjs | 88 +++++++++++++++++++++ 5 files changed, 254 insertions(+), 5 deletions(-) diff --git a/prompting/prompt-profiles.js b/prompting/prompt-profiles.js index a19e2ed..0162ef8 100644 --- a/prompting/prompt-profiles.js +++ b/prompting/prompt-profiles.js @@ -896,6 +896,119 @@ const DEFAULT_TASK_REGEX_STAGES = Object.freeze({ output: false, }); + const DEFAULT_GLOBAL_TASK_REGEX_RULE_SPECS = Object.freeze([ + { + id: "default-contamination-thinking-blocks", + script_name: "默认清理:thinking/analysis/reasoning", + enabled: true, + find_regex: "/<(think|thinking|analysis|reasoning)\\b[^>]*>[\\s\\S]*?<\\/\\1>/gi", + replace_string: "", + trim_strings: "", + source: { + user_input: true, + ai_output: true, + }, + destination: { + prompt: true, + display: false, + }, + min_depth: 0, + max_depth: 9999, + }, + { + id: "default-contamination-choice-blocks", + script_name: "默认清理:choice", + enabled: true, + find_regex: "/(?:]*>[\\s\\S]*?<\\/choice>|]*\\/?>)/gi", + replace_string: "", + trim_strings: "", + source: { + user_input: true, + ai_output: true, + }, + destination: { + prompt: true, + display: false, + }, + min_depth: 0, + max_depth: 9999, + }, + { + id: "default-contamination-updatevariable-tags", + script_name: "默认清理:UpdateVariable", + enabled: true, + find_regex: + "/(?:]*>[\\s\\S]*?<\\/updatevariable>|]*\\/?>)/gi", + replace_string: "", + trim_strings: "", + source: { + user_input: true, + ai_output: true, + }, + destination: { + prompt: true, + display: false, + }, + min_depth: 0, + max_depth: 9999, + }, + { + id: "default-contamination-status-current-variable-tags", + script_name: "默认清理:status_current_variable", + enabled: true, + find_regex: + "/(?:]*>[\\s\\S]*?<\\/status_current_variable>|]*\\/?>)/gi", + replace_string: "", + trim_strings: "", + source: { + user_input: true, + ai_output: true, + }, + destination: { + prompt: true, + display: false, + }, + min_depth: 0, + max_depth: 9999, + }, + { + id: "default-contamination-status-placeholder-tags", + script_name: "默认清理:StatusPlaceHolderImpl", + enabled: true, + find_regex: "/]*\\/?>/gi", + replace_string: "", + trim_strings: "", + source: { + user_input: true, + ai_output: true, + }, + destination: { + prompt: true, + display: false, + }, + min_depth: 0, + max_depth: 9999, + }, + ]); + + function cloneDefaultGlobalTaskRegexRules() { + return DEFAULT_GLOBAL_TASK_REGEX_RULE_SPECS.map((rule, index) => + normalizeRegexLocalRule( + { + ...rule, + source: { + ...(rule.source || {}), + }, + destination: { + ...(rule.destination || {}), + }, + }, + "global", + index, + ), + ); + } + function normalizeRegexStageKey(stageKey = "") { const normalized = String(stageKey || "").trim(); return TASK_REGEX_STAGE_ALIAS_MAP[normalized] || normalized; @@ -939,7 +1052,7 @@ export function createDefaultGlobalTaskRegex() { character: true, }, stages: normalizeTaskRegexStages(DEFAULT_TASK_REGEX_STAGES), - localRules: [], + localRules: cloneDefaultGlobalTaskRegexRules(), }; } @@ -978,6 +1091,11 @@ export function normalizeGlobalTaskRegex(config = {}, taskType = "global") { const defaults = createDefaultGlobalTaskRegex(); const source = config && typeof config === "object" && !Array.isArray(config) ? config : {}; + const normalizedTaskType = String(taskType || "").trim().toLowerCase(); + const defaultLocalRules = normalizedTaskType === "global" ? defaults.localRules : []; + const rawLocalRules = Array.isArray(source.localRules) + ? source.localRules + : defaultLocalRules; return { enabled: source.enabled !== false, @@ -990,7 +1108,7 @@ export function normalizeGlobalTaskRegex(config = {}, taskType = "global") { ...normalizeTaskRegexStages(defaults.stages), ...normalizeTaskRegexStages(source.stages || {}), }, - localRules: dedupeRegexRules(source.localRules, taskType), + localRules: dedupeRegexRules(rawLocalRules, taskType), }; } diff --git a/tests/default-settings.mjs b/tests/default-settings.mjs index 4feaec5..f56a161 100644 --- a/tests/default-settings.mjs +++ b/tests/default-settings.mjs @@ -70,6 +70,17 @@ assert.equal(defaultSettings.taskProfilesVersion, 3); assert.ok(defaultSettings.taskProfiles); assert.ok(defaultSettings.taskProfiles.extract); assert.ok(defaultSettings.taskProfiles.recall); +assert.ok(defaultSettings.globalTaskRegex); +assert.deepEqual( + defaultSettings.globalTaskRegex.localRules.map((rule) => rule.id), + [ + "default-contamination-thinking-blocks", + "default-contamination-choice-blocks", + "default-contamination-updatevariable-tags", + "default-contamination-status-current-variable-tags", + "default-contamination-status-placeholder-tags", + ], +); const migratedSettings = mergePersistedSettings({ maintenanceAutoMinNewNodes: 7, diff --git a/tests/prompt-builder-defaults.mjs b/tests/prompt-builder-defaults.mjs index 4e6b923..380ce7c 100644 --- a/tests/prompt-builder-defaults.mjs +++ b/tests/prompt-builder-defaults.mjs @@ -46,7 +46,10 @@ installResolveHooks([ ]); const { buildTaskLlmPayload, buildTaskPrompt } = await import("../prompting/prompt-builder.js"); -const { createDefaultTaskProfiles } = await import("../prompting/prompt-profiles.js"); +const { + createDefaultGlobalTaskRegex, + createDefaultTaskProfiles, +} = await import("../prompting/prompt-profiles.js"); const { initializeHostAdapter } = await import("../host/adapter/index.js"); const settings = { @@ -145,6 +148,28 @@ assert.match(String(recallFormatBlock?.content || ""), /selected_keys/); assert.match(String(recallRulesBlock?.content || ""), /剧情时间/); assert.match(String(recallRulesBlock?.content || ""), /评分召回/); +const globalRegexPromptBuild = await buildTaskPrompt( + { + taskProfilesVersion: 3, + taskProfiles: createDefaultTaskProfiles(), + globalTaskRegex: createDefaultGlobalTaskRegex(), + }, + "recall", + { + taskName: "recall", + recentMessages: + "最近消息 隐藏思维 1. 隐藏选项", + userMessage: + "用户输入 secret hp=3", + candidateNodes: + "候选节点 隐藏分析", + }, +); +assert.doesNotMatch( + JSON.stringify(globalRegexPromptBuild), + / rule.script_name), - ["隐藏规则"], + [ + "默认清理:thinking/analysis/reasoning", + "默认清理:choice", + "默认清理:UpdateVariable", + "默认清理:status_current_variable", + "默认清理:StatusPlaceHolderImpl", + "隐藏规则", + ], ); assert.deepEqual( migratedLegacyRegex.settings.taskProfiles.extract.profiles.find( diff --git a/tests/task-regex.mjs b/tests/task-regex.mjs index fb662b9..b455316 100644 --- a/tests/task-regex.mjs +++ b/tests/task-regex.mjs @@ -175,6 +175,7 @@ try { "../prompting/task-regex.js" ); const { + createDefaultGlobalTaskRegex, createDefaultTaskProfiles, isTaskRegexStageEnabled, normalizeTaskProfile, @@ -933,6 +934,93 @@ try { ["prompt-output"], ); + const defaultGlobalRegex = createDefaultGlobalTaskRegex(); + assert.deepEqual( + defaultGlobalRegex.localRules.map((rule) => rule.id), + [ + "default-contamination-thinking-blocks", + "default-contamination-choice-blocks", + "default-contamination-updatevariable-tags", + "default-contamination-status-current-variable-tags", + "default-contamination-status-placeholder-tags", + ], + ); + + const globalDefaultDebug = { entries: [] }; + const globalDefaultResult = applyTaskRegex( + { + taskProfiles: createDefaultTaskProfiles(), + globalTaskRegex: createDefaultGlobalTaskRegex(), + }, + "extract", + "input.recentMessages", + [ + "前缀", + "内部思维", + "1. 选项", + "hp=1", + "hp=1", + "", + "尾巴", + ].join("\n"), + globalDefaultDebug, + "system", + ); + assert.match(globalDefaultResult, /前缀/); + assert.match(globalDefaultResult, /尾巴/); + assert.doesNotMatch( + globalDefaultResult, + / item.id), + [ + "default-contamination-thinking-blocks", + "default-contamination-choice-blocks", + "default-contamination-updatevariable-tags", + "default-contamination-status-current-variable-tags", + "default-contamination-status-placeholder-tags", + ], + ); + assert.equal(globalDefaultDebug.entries[0].sourceCount.local, 5); + + const explicitEmptyGlobalDebug = { entries: [] }; + const explicitEmptyGlobalResult = applyTaskRegex( + { + taskProfiles: createDefaultTaskProfiles(), + globalTaskRegex: { + enabled: true, + inheritStRegex: false, + sources: { + global: false, + preset: false, + character: false, + }, + stages: { + "input.userMessage": true, + "input.recentMessages": true, + "input.candidateText": true, + "input.finalPrompt": false, + "output.rawResponse": false, + "output.beforeParse": false, + output: false, + }, + localRules: [], + }, + }, + "extract", + "input.recentMessages", + "保留保留", + explicitEmptyGlobalDebug, + "system", + ); + assert.equal( + explicitEmptyGlobalResult, + "保留保留", + ); + assert.deepEqual(explicitEmptyGlobalDebug.entries[0].appliedRules, []); + assert.equal(explicitEmptyGlobalDebug.entries[0].sourceCount.local, 0); + console.log("task-regex tests passed"); } finally { if (originalSillyTavern === undefined) { From 5eee25d17a5f4d5125764800a5f5e5cecebdc54f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 11 Apr 2026 11:38:29 +0000 Subject: [PATCH 14/20] chore: bump manifest version [skip ci] --- manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/manifest.json b/manifest.json index 0fb3fbf..37aa88a 100644 --- a/manifest.json +++ b/manifest.json @@ -6,6 +6,6 @@ "js": "index.js", "css": "style.css", "author": "Youzini", - "version": "4.5.6", + "version": "4.5.7", "homePage": "https://github.com/Youzini-afk/ST-Bionic-Memory-Ecology" } From 041b2221e892dfd504db6d689d660afcf55aae7e Mon Sep 17 00:00:00 2001 From: Youzini-afk <13153778771cx@gmail.com> Date: Sat, 11 Apr 2026 19:41:40 +0800 Subject: [PATCH 15/20] ci: skip manifest bump on merged PR pushes --- .github/workflows/bump-manifest-version.yml | 26 +++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/.github/workflows/bump-manifest-version.yml b/.github/workflows/bump-manifest-version.yml index d5f5ad2..b2c577d 100644 --- a/.github/workflows/bump-manifest-version.yml +++ b/.github/workflows/bump-manifest-version.yml @@ -8,6 +8,7 @@ on: permissions: contents: write + pull-requests: read jobs: bump-manifest-version: @@ -15,20 +16,45 @@ jobs: runs-on: ubuntu-latest steps: + - name: Detect merged pull request push + id: pr-check + uses: actions/github-script@v7 + with: + script: | + const { owner, repo } = context.repo; + const sha = context.sha; + const response = await github.rest.repos.listPullRequestsAssociatedWithCommit({ + owner, + repo, + commit_sha: sha, + }); + const mergedPr = (response.data || []).find( + (pr) => pr?.merged_at && pr?.base?.ref === context.ref.replace('refs/heads/', ''), + ); + const shouldSkip = Boolean(mergedPr); + core.setOutput('skip', shouldSkip ? 'true' : 'false'); + if (mergedPr) { + core.notice(`Skip manifest bump for merged PR #${mergedPr.number}: ${mergedPr.title}`); + } + - name: Checkout + if: ${{ steps.pr-check.outputs.skip != 'true' }} uses: actions/checkout@v4 with: fetch-depth: 0 - name: Setup Node.js + if: ${{ steps.pr-check.outputs.skip != 'true' }} uses: actions/setup-node@v4 with: node-version: 20 - name: Bump manifest version + if: ${{ steps.pr-check.outputs.skip != 'true' }} run: node scripts/bump-manifest-version.mjs - name: Commit version bump + if: ${{ steps.pr-check.outputs.skip != 'true' }} run: | if git diff --quiet -- manifest.json; then echo "manifest.json version did not change." From b4ef61009161e15fe58108a722cc7e74caf30bd2 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 11 Apr 2026 11:42:17 +0000 Subject: [PATCH 16/20] chore: bump manifest version [skip ci] --- manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/manifest.json b/manifest.json index 37aa88a..f20f9bc 100644 --- a/manifest.json +++ b/manifest.json @@ -6,6 +6,6 @@ "js": "index.js", "css": "style.css", "author": "Youzini", - "version": "4.5.7", + "version": "4.5.8", "homePage": "https://github.com/Youzini-afk/ST-Bionic-Memory-Ecology" } From 42bd85b0aa2c58b105d89c14221230deb3db6217 Mon Sep 17 00:00:00 2001 From: Youzini-afk <13153778771cx@gmail.com> Date: Sat, 11 Apr 2026 20:01:19 +0800 Subject: [PATCH 17/20] fix(recall): reuse persisted recall on history rerolls --- index.js | 4 + retrieval/recall-controller.js | 195 ++++++++++++++++++++++- tests/p0-regressions.mjs | 276 ++++++++++++++++++++++++++++++++- 3 files changed, 468 insertions(+), 7 deletions(-) diff --git a/index.js b/index.js index 966bc4c..4fa56c2 100644 --- a/index.js +++ b/index.js @@ -12462,6 +12462,7 @@ async function runRecall(options = {}) { abortRecallStageWithReason, applyRecallInjection, beginStageAbortController, + bumpPersistedRecallGenerationCount, buildRecallRetrieveOptions, clampInt, console, @@ -12485,10 +12486,12 @@ async function runRecall(options = {}) { isGraphReadable, isGraphReadableForRecall, nextRecallRunSequence: () => ++recallRunSequence, + readPersistedRecallFromUserMessage, recoverHistoryIfNeeded, refreshPanelLiveState, resolveRecallInput, retrieve, + schedulePersistedRecallMessageUiRefresh, setActiveRecallPromise: (value) => { activeRecallPromise = value; }, @@ -12500,6 +12503,7 @@ async function runRecall(options = {}) { pendingRecallSendIntent = value; }, toastr, + triggerChatMetadataSave, waitForActiveRecallToSettle, }, options, diff --git a/retrieval/recall-controller.js b/retrieval/recall-controller.js index ebb1a7d..d2b915a 100644 --- a/retrieval/recall-controller.js +++ b/retrieval/recall-controller.js @@ -52,6 +52,102 @@ export function getRecallUserMessageSourceLabelController(source) { } } +function buildPersistedRecallReuseResult(record = {}) { + const selectedNodeIds = Array.isArray(record?.selectedNodeIds) + ? record.selectedNodeIds + .map((item) => String(item || "").trim()) + .filter(Boolean) + : []; + return { + injectionText: String(record?.injectionText || "").trim(), + selectedNodeIds, + stats: { + coreCount: 0, + recallCount: selectedNodeIds.length, + }, + meta: { + retrieval: { + vectorHits: 0, + vectorMergedHits: 0, + diffusionHits: 0, + candidatePoolAfterDpp: 0, + persistedReuse: true, + llm: { + status: "persisted", + reason: "复用已持久化召回", + selectionProtocol: "persisted-record-reuse", + rawSelectedKeys: [], + resolvedSelectedKeys: [], + resolvedSelectedNodeIds: selectedNodeIds, + fallbackReason: "", + fallbackType: "", + emptySelectionAccepted: false, + candidateKeyMapPreview: {}, + legacySelectionUsed: false, + candidatePool: 0, + }, + }, + }, + }; +} + +function resolveReusablePersistedRecallRecord(chat, recallInput, runtime) { + const generationType = String(recallInput?.generationType || "normal").trim() || "normal"; + if (generationType === "normal") return null; + + const targetUserMessageIndex = Number.isFinite(recallInput?.targetUserMessageIndex) + ? Math.floor(Number(recallInput.targetUserMessageIndex)) + : null; + if (!Number.isFinite(targetUserMessageIndex)) return null; + + const targetMessage = Array.isArray(chat) ? chat[targetUserMessageIndex] : null; + if (!targetMessage?.is_user) return null; + + const readPersistedRecallFromUserMessage = runtime.readPersistedRecallFromUserMessage; + if (typeof readPersistedRecallFromUserMessage !== "function") return null; + + const record = readPersistedRecallFromUserMessage(chat, targetUserMessageIndex); + if (!record?.injectionText) return null; + + const normalizeText = (value = "") => + typeof runtime.normalizeRecallInputText === "function" + ? runtime.normalizeRecallInputText(value) + : String(value ?? "") + .replace(/\r\n/g, "\n") + .trim(); + const currentUserFloorText = normalizeText(targetMessage?.mes || ""); + const currentRecallInputText = normalizeText(recallInput?.userMessage || ""); + const recordRecallInput = normalizeText(record?.recallInput || ""); + const boundUserFloorText = normalizeText(record?.boundUserFloorText || ""); + + const matchesBoundUserFloor = Boolean( + currentUserFloorText && + boundUserFloorText && + currentUserFloorText === boundUserFloorText, + ); + const matchesRecallInput = Boolean( + currentRecallInputText && + recordRecallInput && + currentRecallInputText === recordRecallInput, + ); + const matchesCurrentUserFloor = Boolean( + currentUserFloorText && + recordRecallInput && + currentUserFloorText === recordRecallInput, + ); + + if (record.authoritativeInputUsed) { + if (!matchesBoundUserFloor) return null; + } else if (!matchesRecallInput && !matchesCurrentUserFloor) { + return null; + } + + return { + record, + targetUserMessageIndex, + }; +} + export function resolveRecallInputController( chat, recentContextMessageLimit, @@ -167,12 +263,15 @@ export function applyRecallInjectionController( result, runtime, ) { - const injectionText = runtime - .formatInjection(result, runtime.getSchema()) - .trim(); + const injectionText = String( + typeof result?.injectionText === "string" + ? result.injectionText + : runtime.formatInjection(result, runtime.getSchema()), + ).trim(); runtime.setLastInjectionContent(injectionText); const retrievalMeta = result?.meta?.retrieval || {}; + const isPersistedReuse = Boolean(retrievalMeta.persistedReuse); const llmMeta = retrievalMeta.llm || { status: settings.recallEnableLLM ? "unknown" : "disabled", reason: settings.recallEnableLLM ? "未提供 LLM 状态" : "LLM 精排已关闭", @@ -190,7 +289,7 @@ export function applyRecallInjectionController( const deliveryMode = String(recallInput?.deliveryMode || "immediate").trim() || "immediate"; - if (injectionText) { + if (injectionText && !isPersistedReuse) { const tokens = runtime.estimateTokens(injectionText); debugLog( `[ST-BME] 注入 ${tokens} 估算 tokens, Core=${result.stats.coreCount}, Recall=${result.stats.recallCount}`, @@ -250,7 +349,9 @@ export function applyRecallInjectionController( runtime.saveGraphToChat({ reason: "recall-result-updated" }); const llmLabel = - llmMeta.status === "llm" + isPersistedReuse + ? "复用召回" + : llmMeta.status === "llm" ? "LLM 精排完成" : llmMeta.status === "fallback" ? "LLM 回退评分" @@ -495,6 +596,90 @@ export async function runRecallController(runtime, options = {}) { }); } + const persistedReuse = resolveReusablePersistedRecallRecord( + chat, + recallInput, + runtime, + ); + if (persistedReuse) { + const normalizedBoundUserFloorText = + typeof runtime.normalizeRecallInputText === "function" + ? runtime.normalizeRecallInputText( + persistedReuse.record.boundUserFloorText || + recallInput.boundUserFloorText || + "", + ) + : String( + persistedReuse.record.boundUserFloorText || + recallInput.boundUserFloorText || + "", + ) + .replace(/\r\n/g, "\n") + .trim(); + const effectiveRecallInput = { + ...recallInput, + source: "persisted-user-floor", + sourceLabel: "复用用户楼层召回", + reason: "persisted-user-floor-reuse", + authoritativeInputUsed: Boolean( + persistedReuse.record.authoritativeInputUsed || + recallInput.authoritativeInputUsed, + ), + boundUserFloorText: normalizedBoundUserFloorText, + }; + const reusedResult = buildPersistedRecallReuseResult(persistedReuse.record); + const applied = runtime.applyRecallInjection( + settings, + effectiveRecallInput, + recentMessages, + reusedResult, + ); + const bumpedRecord = + typeof runtime.bumpPersistedRecallGenerationCount === "function" + ? runtime.bumpPersistedRecallGenerationCount( + chat, + persistedReuse.targetUserMessageIndex, + ) + : null; + if (bumpedRecord) { + runtime.triggerChatMetadataSave?.(context, { immediate: false }); + runtime.schedulePersistedRecallMessageUiRefresh?.(); + } + return runtime.createRecallRunResult("completed", { + reason: "persisted-user-floor-reused", + selectedNodeIds: reusedResult.selectedNodeIds || [], + injectionText: applied?.injectionText || reusedResult.injectionText || "", + retrievalMeta: applied?.retrievalMeta || reusedResult.meta?.retrieval || {}, + llmMeta: + applied?.llmMeta || reusedResult.meta?.retrieval?.llm || {}, + transport: applied?.transport || { + applied: false, + source: "none", + mode: "none", + }, + deliveryMode: + applied?.deliveryMode || + String(effectiveRecallInput?.deliveryMode || "immediate").trim() || + "immediate", + source: effectiveRecallInput.source || "", + sourceLabel: effectiveRecallInput.sourceLabel || "", + authoritativeInputUsed: Boolean( + effectiveRecallInput.authoritativeInputUsed, + ), + boundUserFloorText: String( + effectiveRecallInput.boundUserFloorText || "", + ), + hookName: effectiveRecallInput.hookName || "", + sourceCandidates: Array.isArray(effectiveRecallInput.sourceCandidates) + ? effectiveRecallInput.sourceCandidates.map((candidate) => ({ + ...candidate, + })) + : [], + stats: reusedResult?.stats || {}, + recallInput: String(persistedReuse.record.recallInput || ""), + }); + } + const result = await runtime.retrieve({ graph: runtime.getCurrentGraph(), userMessage, diff --git a/tests/p0-regressions.mjs b/tests/p0-regressions.mjs index 36dbd90..33a6712 100644 --- a/tests/p0-regressions.mjs +++ b/tests/p0-regressions.mjs @@ -4524,6 +4524,278 @@ async function testBeforeCombineRecallNotSkippedWhenGraphLoadingButRuntimeGraphR ); } +async function testHistoryGenerationReusesPersistedRecallForStableUserFloor() { + const { runRecallController } = await import("../retrieval/recall-controller.js"); + const chat = [ + { + is_user: true, + mes: "稳定 user 楼层", + extra: { + bme_recall: buildPersistedRecallRecord({ + injectionText: "persisted-memory", + selectedNodeIds: ["node-persisted-1"], + recallInput: "发送前权威输入", + recallSource: "send-intent", + hookName: "GENERATION_AFTER_COMMANDS", + tokenEstimate: 12, + manuallyEdited: false, + authoritativeInputUsed: true, + boundUserFloorText: "稳定 user 楼层", + nowIso: "2026-01-01T00:00:00.000Z", + }), + }, + }, + { is_user: false, mes: "assistant-tail" }, + ]; + let retrieveCalls = 0; + let metadataSaveCalls = 0; + let recallUiRefreshCalls = 0; + const applyCalls = []; + + const runtime = { + getIsRecalling: () => false, + abortRecallStageWithReason() {}, + waitForActiveRecallToSettle: async () => ({ settled: true }), + getCurrentGraph: () => ({ nodes: [], edges: [] }), + getSettings: () => ({ + enabled: true, + recallEnabled: true, + recallLlmContextMessages: 4, + }), + isGraphReadable: () => true, + isGraphReadableForRecall: () => true, + getGraphMutationBlockReason: () => "", + setLastRecallStatus() {}, + isGraphMetadataWriteAllowed: () => false, + recoverHistoryIfNeeded: async () => true, + getContext: () => ({ chat }), + nextRecallRunSequence: () => 1, + setIsRecalling() {}, + beginStageAbortController: () => ({ + signal: { aborted: false, addEventListener() {} }, + abort() {}, + }), + createAbortError: (message) => new Error(message), + ensureVectorReadyIfNeeded: async () => {}, + clampInt, + resolveRecallInput: () => ({ + userMessage: "稳定 user 楼层", + recentMessages: ["[user]: 稳定 user 楼层"], + source: "chat-last-user", + sourceLabel: "历史最后用户楼层", + generationType: "history", + targetUserMessageIndex: 0, + authoritativeInputUsed: false, + boundUserFloorText: "稳定 user 楼层", + sourceCandidates: [], + }), + console, + getRecallHookLabel: () => "历史生成", + retrieve: async () => { + retrieveCalls += 1; + return { + stats: { recallCount: 1, coreCount: 1 }, + selectedNodeIds: ["fresh-node"], + meta: { + retrieval: { + vectorHits: 1, + diffusionHits: 0, + llm: { status: "disabled", candidatePool: 0 }, + }, + }, + }; + }, + getEmbeddingConfig: () => null, + getSchema: () => schema, + buildRecallRetrieveOptions: () => ({}), + applyRecallInjection: (_settings, recallInput, _recentMessages, result) => { + applyCalls.push({ recallInput: { ...recallInput }, result: { ...result } }); + return { + injectionText: String(result?.injectionText || ""), + retrievalMeta: result?.meta?.retrieval || {}, + llmMeta: result?.meta?.retrieval?.llm || {}, + transport: { + applied: true, + source: "module-injection", + mode: "module-injection", + }, + deliveryMode: String(recallInput?.deliveryMode || "immediate") || "immediate", + }; + }, + createRecallInputRecord, + createRecallRunResult, + isAbortError: () => false, + toastr: { + warning() {}, + error() {}, + }, + finishStageAbortController() {}, + getActiveRecallPromise: () => null, + setActiveRecallPromise() {}, + setPendingRecallSendIntent() {}, + refreshPanelLiveState() {}, + readPersistedRecallFromUserMessage, + bumpPersistedRecallGenerationCount, + triggerChatMetadataSave() { + metadataSaveCalls += 1; + }, + schedulePersistedRecallMessageUiRefresh() { + recallUiRefreshCalls += 1; + }, + }; + + const result = await runRecallController(runtime, { + hookName: "GENERATION_AFTER_COMMANDS", + generationType: "regenerate", + deliveryMode: "immediate", + }); + + assert.equal(retrieveCalls, 0); + assert.equal(result.status, "completed"); + assert.equal(result.reason, "persisted-user-floor-reused"); + assert.equal(result.injectionText, "persisted-memory"); + assert.equal(applyCalls.length, 1); + assert.equal(applyCalls[0].recallInput.source, "persisted-user-floor"); + assert.equal(applyCalls[0].recallInput.authoritativeInputUsed, true); + assert.equal(applyCalls[0].recallInput.boundUserFloorText, "稳定 user 楼层"); + assert.equal( + readPersistedRecallFromUserMessage(chat, 0)?.generationCount, + 1, + ); + assert.equal(metadataSaveCalls, 1); + assert.equal(recallUiRefreshCalls, 1); +} + +async function testHistoryGenerationDoesNotReusePersistedRecallAfterUserFloorEdit() { + const { runRecallController } = await import("../retrieval/recall-controller.js"); + const chat = [ + { + is_user: true, + mes: "已编辑的新 user 楼层", + extra: { + bme_recall: buildPersistedRecallRecord({ + injectionText: "stale-persisted-memory", + selectedNodeIds: ["node-stale-1"], + recallInput: "旧 user 楼层", + recallSource: "chat-last-user", + hookName: "GENERATION_AFTER_COMMANDS", + tokenEstimate: 12, + manuallyEdited: false, + authoritativeInputUsed: false, + boundUserFloorText: "旧 user 楼层", + nowIso: "2026-01-01T00:00:00.000Z", + }), + }, + }, + { is_user: false, mes: "assistant-tail" }, + ]; + let retrieveCalls = 0; + + const runtime = { + getIsRecalling: () => false, + abortRecallStageWithReason() {}, + waitForActiveRecallToSettle: async () => ({ settled: true }), + getCurrentGraph: () => ({ nodes: [], edges: [] }), + getSettings: () => ({ + enabled: true, + recallEnabled: true, + recallLlmContextMessages: 4, + }), + isGraphReadable: () => true, + isGraphReadableForRecall: () => true, + getGraphMutationBlockReason: () => "", + setLastRecallStatus() {}, + isGraphMetadataWriteAllowed: () => false, + recoverHistoryIfNeeded: async () => true, + getContext: () => ({ chat }), + nextRecallRunSequence: () => 1, + setIsRecalling() {}, + beginStageAbortController: () => ({ + signal: { aborted: false, addEventListener() {} }, + abort() {}, + }), + createAbortError: (message) => new Error(message), + ensureVectorReadyIfNeeded: async () => {}, + clampInt, + resolveRecallInput: () => ({ + userMessage: "已编辑的新 user 楼层", + recentMessages: ["[user]: 已编辑的新 user 楼层"], + source: "chat-last-user", + sourceLabel: "历史最后用户楼层", + generationType: "history", + targetUserMessageIndex: 0, + authoritativeInputUsed: false, + boundUserFloorText: "已编辑的新 user 楼层", + sourceCandidates: [], + }), + console, + getRecallHookLabel: () => "历史生成", + retrieve: async () => { + retrieveCalls += 1; + return { + stats: { recallCount: 1, coreCount: 1 }, + selectedNodeIds: ["fresh-node"], + meta: { + retrieval: { + vectorHits: 1, + diffusionHits: 0, + llm: { status: "disabled", candidatePool: 0 }, + }, + }, + }; + }, + getEmbeddingConfig: () => null, + getSchema: () => schema, + buildRecallRetrieveOptions: () => ({}), + applyRecallInjection: (_settings, recallInput) => ({ + injectionText: `fresh:${recallInput.userMessage}`, + retrievalMeta: { + vectorHits: 1, + diffusionHits: 0, + llm: { status: "disabled", candidatePool: 0 }, + }, + llmMeta: { status: "disabled", candidatePool: 0 }, + transport: { + applied: true, + source: "module-injection", + mode: "module-injection", + }, + deliveryMode: String(recallInput?.deliveryMode || "immediate") || "immediate", + }), + createRecallInputRecord, + createRecallRunResult, + isAbortError: () => false, + toastr: { + warning() {}, + error() {}, + }, + finishStageAbortController() {}, + getActiveRecallPromise: () => null, + setActiveRecallPromise() {}, + setPendingRecallSendIntent() {}, + refreshPanelLiveState() {}, + readPersistedRecallFromUserMessage, + bumpPersistedRecallGenerationCount, + triggerChatMetadataSave() {}, + schedulePersistedRecallMessageUiRefresh() {}, + }; + + const result = await runRecallController(runtime, { + hookName: "GENERATION_AFTER_COMMANDS", + generationType: "regenerate", + deliveryMode: "immediate", + }); + + assert.equal(retrieveCalls, 1); + assert.equal(result.status, "completed"); + assert.equal(result.reason, "召回完成"); + assert.equal(result.injectionText, "fresh:已编辑的新 user 楼层"); + assert.equal( + readPersistedRecallFromUserMessage(chat, 0)?.generationCount, + 0, + ); +} + async function testPersistentRecallDataLayerLifecycleAndCompatibility() { const chat = [ { is_user: true, mes: "u0" }, @@ -6181,8 +6453,8 @@ await testAutoExtractionDefersWhenAlreadyExtracting(); await testAutoExtractionDefersWhenHistoryRecoveryBusy(); await testRemoveNodeHandlesCyclicChildGraph(); await testGenerationRecallAppliesFinalInjectionOncePerTransaction(); -await testGenerationRecallDeferredRewriteMutatesFinalMesSendPayload(); -await testGenerationRecallDeferredRewriteMutatesFinalMesSendAuthoritativeUserInput(); +await testHistoryGenerationReusesPersistedRecallForStableUserFloor(); +await testHistoryGenerationDoesNotReusePersistedRecallAfterUserFloorEdit(); await testPersistentRecallDataLayerLifecycleAndCompatibility(); await testPersistentRecallSourceResolutionAndTargetRouting(); await testGenerationRecallFinalInjectionRebindsLatestMatchingUserFloor(); From f3c3256bb36e3984463cd17d147d94fe9478b2e7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 11 Apr 2026 12:03:05 +0000 Subject: [PATCH 18/20] chore: bump manifest version [skip ci] --- manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/manifest.json b/manifest.json index f20f9bc..f6a679e 100644 --- a/manifest.json +++ b/manifest.json @@ -6,6 +6,6 @@ "js": "index.js", "css": "style.css", "author": "Youzini", - "version": "4.5.8", + "version": "4.5.9", "homePage": "https://github.com/Youzini-afk/ST-Bionic-Memory-Ecology" } From 9becc235264a66eef40f7c7806b83e41327737b8 Mon Sep 17 00:00:00 2001 From: Youzini-afk <13153778771cx@gmail.com> Date: Sat, 11 Apr 2026 20:36:24 +0800 Subject: [PATCH 19/20] fix(ux): quiet expected history recovery toasts --- index.js | 17 +--- tests/p0-regressions.mjs | 166 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 166 insertions(+), 17 deletions(-) diff --git a/index.js b/index.js index 4fa56c2..d9cd0ff 100644 --- a/index.js +++ b/index.js @@ -715,7 +715,6 @@ let lastRecalledItems = []; // 最近召回的节点(面板展示用) let extractionCount = 0; // v2: 提取次数计数器(定期触发概要/遗忘/反思) let serverSettingsSaveTimer = null; let isRecoveringHistory = false; -let lastHistoryWarningAt = 0; let lastRecallFallbackNoticeAt = 0; let lastExtractionWarningAt = 0; const LOCAL_VECTOR_TIMEOUT_MS = 300000; @@ -11249,13 +11248,6 @@ function notifyHistoryDirty(dirtyFrom, reason) { busy: true, }, ); - const now = Date.now(); - if (now - lastHistoryWarningAt < 3000) return; - lastHistoryWarningAt = now; - toastr.warning( - `检测到楼层历史变化,将从楼层 ${dirtyFrom} 之后自动恢复图谱`, - reason || "ST-BME 历史回退保护", - ); } function clearPendingHistoryMutationChecks() { @@ -11979,12 +11971,9 @@ async function recoverHistoryIfNeeded(trigger = "history-recovery") { persist: false, }, ); - - toastr.success( - usedFullRebuild - ? "历史变化已触发全量重建" - : "历史变化已完成受影响后缀恢复", - ); + if (usedFullRebuild) { + toastr.warning("历史变化已触发全量重建"); + } return true; } catch (error) { if (isAbortError(error)) { diff --git a/tests/p0-regressions.mjs b/tests/p0-regressions.mjs index 33a6712..b68deca 100644 --- a/tests/p0-regressions.mjs +++ b/tests/p0-regressions.mjs @@ -349,6 +349,11 @@ function createHistoryRecoveryHarness() { saveGraphToChatCalls: 0, refreshPanelCalls: 0, notices: [], + toastCalls: { + success: [], + warning: [], + error: [], + }, embeddingConfig: { mode: "backend" }, isRestoreLockActive() { return false; @@ -511,9 +516,15 @@ function createHistoryRecoveryHarness() { context.refreshPanelCalls += 1; }, toastr: { - success() {}, - warning() {}, - error() {}, + success(...args) { + context.toastCalls.success.push(args); + }, + warning(...args) { + context.toastCalls.warning.push(args); + }, + error(...args) { + context.toastCalls.error.push(args); + }, }, }; vm.createContext(context); @@ -526,6 +537,38 @@ function createHistoryRecoveryHarness() { }); } +function createHistoryNotificationHarness() { + return fs.readFile(indexPath, "utf8").then((source) => { + const start = source.indexOf("function notifyHistoryDirty(dirtyFrom, reason) {"); + const end = source.indexOf("function clearPendingHistoryMutationChecks() {"); + if (start < 0 || end < 0 || end <= start) { + throw new Error("无法从 index.js 提取 history notify 定义"); + } + const snippet = source.slice(start, end).replace(/^export\s+/gm, ""); + const context = { + console, + result: null, + notices: [], + warningToasts: [], + updateStageNotice(...args) { + context.notices.push(args); + }, + toastr: { + warning(...args) { + context.warningToasts.push(args); + }, + }, + }; + vm.createContext(context); + vm.runInContext( + `${snippet}\nresult = { notifyHistoryDirty };`, + context, + { filename: indexPath }, + ); + return context; + }); +} + function createRerollHarness() { return fs.readFile(indexPath, "utf8").then((source) => { const rollbackStart = source.indexOf( @@ -5436,6 +5479,120 @@ async function testHistoryRecoveryAbortClearsVectorRepairState() { assert.equal(harness.currentGraph.vectorIndexState.dirtyReason, ""); } +async function testNotifyHistoryDirtyUsesStageNoticeWithoutGenericWarningToast() { + const harness = await createHistoryNotificationHarness(); + + harness.result.notifyHistoryDirty( + 12, + "已处理楼层超出当前聊天长度,检测到历史截断", + ); + + assert.equal(harness.notices.length, 1); + assert.equal(harness.warningToasts.length, 0); + assert.equal(harness.notices[0][0], "history"); + assert.equal(harness.notices[0][1], "检测到楼层历史变化"); +} + +async function testHistoryRecoveryStandardSuffixReplayDoesNotEmitCompletionToast() { + const harness = await createHistoryRecoveryHarness(); + harness.chat = [ + { is_user: true, mes: "u1" }, + { is_user: false, mes: "a1" }, + ]; + harness.currentGraph = { + historyState: { + lastProcessedAssistantFloor: 1, + processedMessageHashes: { 1: "hash-1" }, + historyDirtyFrom: 1, + lastMutationSource: "message-deleted", + lastMutationReason: "tail-truncated", + extractionCount: 1, + }, + vectorIndexState: { + collectionId: "col-1", + dirty: true, + dirtyReason: "history-recovery-replay", + pendingRepairFromFloor: 1, + replayRequiredNodeIds: ["node-1"], + lastWarning: "repair pending", + lastIntegrityIssue: null, + }, + batchJournal: [], + lastProcessedSeq: 1, + }; + harness.findJournalRecoveryPointImpl = () => ({ + path: "reverse-journal", + affectedBatchCount: 1, + affectedJournals: [ + { + processedRange: [1, 1], + vectorDelta: { + insertedHashes: [], + removedHashes: [], + backendDeleteHashes: [], + touchedNodeIds: [], + replayRequiredNodeIds: [], + replacedMappings: [], + }, + }, + ], + }); + harness.replayExtractionFromHistoryImpl = async () => { + harness.currentGraph.historyState.lastProcessedAssistantFloor = 1; + harness.currentGraph.lastProcessedSeq = 1; + return 1; + }; + + const result = await harness.result.recoverFromHistoryMutation("message-deleted"); + + assert.equal(result, true); + assert.equal(harness.toastCalls.success.length, 0); + assert.equal(harness.toastCalls.warning.length, 0); + assert.equal(harness.toastCalls.error.length, 0); +} + +async function testHistoryRecoveryFullRebuildStillWarnsUser() { + const harness = await createHistoryRecoveryHarness(); + harness.chat = [ + { is_user: true, mes: "u1" }, + { is_user: false, mes: "a1" }, + ]; + harness.currentGraph = { + historyState: { + lastProcessedAssistantFloor: 1, + processedMessageHashes: { 1: "hash-1" }, + historyDirtyFrom: 1, + lastMutationSource: "message-edited", + lastMutationReason: "edited", + extractionCount: 1, + }, + vectorIndexState: { + collectionId: "col-1", + dirty: true, + dirtyReason: "history-recovery-replay", + pendingRepairFromFloor: 1, + replayRequiredNodeIds: ["node-1"], + lastWarning: "repair pending", + lastIntegrityIssue: null, + }, + batchJournal: [], + lastProcessedSeq: 1, + }; + harness.findJournalRecoveryPointImpl = () => null; + harness.replayExtractionFromHistoryImpl = async () => { + harness.currentGraph.historyState.lastProcessedAssistantFloor = 1; + harness.currentGraph.lastProcessedSeq = 1; + return 1; + }; + + const result = await harness.result.recoverFromHistoryMutation("message-edited"); + + assert.equal(result, true); + assert.equal(harness.toastCalls.success.length, 0); + assert.equal(harness.toastCalls.warning.length, 1); + assert.match(String(harness.toastCalls.warning[0]?.[0] || ""), /全量重建/); +} + async function testHistoryRecoveryFallbackFullRebuildCarriesResultCode() { const harness = await createHistoryRecoveryHarness(); harness.chat = [ @@ -6475,6 +6632,9 @@ await testRecallCardUserTextRefreshesWithoutCardRecreate(); await testRecallCardDisplayModeToggleRestoresOriginalUserText(); await testRecallSubGraphAndDataLayerEntryPoints(); await testRerollUsesBatchBoundaryRollbackAndPersistsState(); +await testNotifyHistoryDirtyUsesStageNoticeWithoutGenericWarningToast(); +await testHistoryRecoveryStandardSuffixReplayDoesNotEmitCompletionToast(); +await testHistoryRecoveryFullRebuildStillWarnsUser(); await testHistoryRecoveryAbortClearsVectorRepairState(); await testHistoryRecoveryFallbackFullRebuildCarriesResultCode(); await testHistoryRecoverySuccessRestoresProcessedHashesAfterReplay(); From 3cf76a3129f34e4ff086ca5e5df8655d9f513efb Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 11 Apr 2026 12:36:38 +0000 Subject: [PATCH 20/20] chore: bump manifest version [skip ci] --- manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/manifest.json b/manifest.json index f6a679e..20f424d 100644 --- a/manifest.json +++ b/manifest.json @@ -6,6 +6,6 @@ "js": "index.js", "css": "style.css", "author": "Youzini", - "version": "4.5.9", + "version": "4.6.0", "homePage": "https://github.com/Youzini-afk/ST-Bionic-Memory-Ecology" }