From 42011201b9063a0fbb8851c28cdccd343e8631df Mon Sep 17 00:00:00 2001 From: youzini Date: Sun, 31 May 2026 11:40:46 +0000 Subject: [PATCH] refactor(runtime): extract recall input/intent state factory (Phase 4a) --- index.js | 197 ++++----------- runtime/recall-input-state.js | 259 ++++++++++++++++++++ tests/graph-persistence.mjs | 2 + tests/helpers/generation-recall-harness.mjs | 6 + 4 files changed, 313 insertions(+), 151 deletions(-) create mode 100644 runtime/recall-input-state.js diff --git a/index.js b/index.js index af1f091..ce64224 100644 --- a/index.js +++ b/index.js @@ -160,6 +160,7 @@ import { consumeRerollRecallReuseMarker, createRerollRecallReuseMarker, } from "./runtime/reroll-transaction-boundary.js"; +import { createRecallInputState } from "./runtime/recall-input-state.js"; import { extractMemories, generateReflection, @@ -1349,7 +1350,31 @@ let pendingRecallSendIntent = createRecallInputRecord(); let lastRecallSentUserMessage = createRecallInputRecord(); let pendingHostGenerationInputSnapshot = createRecallInputRecord(); let pendingRerollRecallReuse = null; -let currentGenerationTrivialSkip = null; +const recallInputState = createRecallInputState({ + createRecallInputRecord, + getCurrentChatId, + getLastRecallSentUserMessage: () => lastRecallSentUserMessage, + getPendingHostGenerationInputSnapshot: () => pendingHostGenerationInputSnapshot, + getPendingRecallSendIntent: () => pendingRecallSendIntent, + hashRecallInput, + isFreshRecallInputRecord, + normalizeChatIdCandidate, + normalizeRecallInputText, + recordMessageTraceSnapshot: (patch) => recordMessageTraceSnapshot(patch), + setLastRecallSentUserMessage: (record) => { + lastRecallSentUserMessage = record; + }, + setPendingHostGenerationInputSnapshot: (record) => { + pendingHostGenerationInputSnapshot = record; + }, + setPendingRecallSendIntent: (record) => { + pendingRecallSendIntent = record; + }, + clearPendingRerollRecallReuse: (...args) => clearPendingRerollRecallReuse(...args), + clearPlannerRecallHandoffsForChat: (...args) => + clearPlannerRecallHandoffsForChat(...args), + TRIVIAL_GENERATION_SKIP_TTL_MS, +}); let coreEventBindingState = { registered: false, cleanups: [], @@ -5138,18 +5163,8 @@ function restoreRecallUiStateFromPersistence(chat = getContext()?.chat) { } function clearRecallInputTracking() { - clearPendingRecallSendIntent(); - lastRecallSentUserMessage = createRecallInputRecord(); - clearPendingHostGenerationInputSnapshot(); - clearPendingRerollRecallReuse("recall-input-tracking-cleared"); - if (typeof recordMessageTraceSnapshot === "function") { - recordMessageTraceSnapshot({ - lastSentUserMessage: null, - }); - } - clearPlannerRecallHandoffsForChat("", { clearAll: true }); + return recallInputState.clearRecallInputTracking(); } - function getCoreEventBindingState() { return coreEventBindingState; } @@ -5189,74 +5204,30 @@ function freezeHostGenerationInputSnapshot( text, source = "host-generation-lifecycle", ) { - const normalized = normalizeRecallInputText(text); - if (!normalized) return null; - - pendingHostGenerationInputSnapshot = createRecallInputRecord({ - text: normalized, - hash: hashRecallInput(normalized), - source, - at: Date.now(), - }); - return pendingHostGenerationInputSnapshot; + return recallInputState.freezeHostGenerationInputSnapshot(text, source); } function consumeHostGenerationInputSnapshot(options = {}) { - const { preserve = false } = options; - if (!isFreshRecallInputRecord(pendingHostGenerationInputSnapshot)) { - if (!preserve) { - pendingHostGenerationInputSnapshot = createRecallInputRecord(); - } - return createRecallInputRecord(); - } - - const snapshot = createRecallInputRecord({ - ...pendingHostGenerationInputSnapshot, - }); - if (!preserve) { - pendingHostGenerationInputSnapshot = createRecallInputRecord(); - } - return snapshot; + return recallInputState.consumeHostGenerationInputSnapshot(options); } function getPendingHostGenerationInputSnapshot() { - return pendingHostGenerationInputSnapshot; + return recallInputState.getPendingHostGenerationInputSnapshot(); } function clearPendingRecallSendIntent() { - pendingRecallSendIntent = createRecallInputRecord(); - return pendingRecallSendIntent; + return recallInputState.clearPendingRecallSendIntent(); } function clearPendingHostGenerationInputSnapshot() { - pendingHostGenerationInputSnapshot = createRecallInputRecord(); - return pendingHostGenerationInputSnapshot; + return recallInputState.clearPendingHostGenerationInputSnapshot(); } function getCurrentGenerationTrivialSkip( chatId = getCurrentChatId(), now = Date.now(), ) { - if (!currentGenerationTrivialSkip) return null; - - const setAtMs = Number(currentGenerationTrivialSkip.setAtMs) || 0; - if ( - !setAtMs || - now - setAtMs > TRIVIAL_GENERATION_SKIP_TTL_MS - ) { - currentGenerationTrivialSkip = null; - return null; - } - - const normalizedChatId = normalizeChatIdCandidate(chatId); - const activeChatId = normalizeChatIdCandidate( - currentGenerationTrivialSkip.chatId, - ); - if (normalizedChatId && activeChatId && normalizedChatId !== activeChatId) { - return null; - } - - return currentGenerationTrivialSkip; + return recallInputState.getCurrentGenerationTrivialSkip(chatId, now); } function markCurrentGenerationTrivialSkip({ @@ -5264,22 +5235,15 @@ function markCurrentGenerationTrivialSkip({ chatId = getCurrentChatId(), chatLength = 0, } = {}) { - currentGenerationTrivialSkip = { - chatId: normalizeChatIdCandidate(chatId), - setAtMs: Date.now(), - reason: String(reason || ""), - generationStartMinChatIndex: Math.max( - 0, - Math.floor(Number(chatLength) || 0), - ), - }; - return currentGenerationTrivialSkip; + return recallInputState.markCurrentGenerationTrivialSkip({ + reason, + chatId, + chatLength, + }); } function clearCurrentGenerationTrivialSkip(_reason = "") { - const previous = currentGenerationTrivialSkip; - currentGenerationTrivialSkip = null; - return previous; + return recallInputState.clearCurrentGenerationTrivialSkip(_reason); } function consumeCurrentGenerationTrivialSkip( @@ -5287,89 +5251,20 @@ function consumeCurrentGenerationTrivialSkip( chatId = getCurrentChatId(), now = Date.now(), ) { - const activeSkip = getCurrentGenerationTrivialSkip(chatId, now); - if (!activeSkip) return false; - - const normalizedTargetIndex = Number.isFinite(Number(targetMessageIndex)) - ? Math.floor(Number(targetMessageIndex)) - : null; - if (!Number.isFinite(normalizedTargetIndex)) { - return false; - } - - if ( - normalizedTargetIndex < - Math.max(0, Math.floor(Number(activeSkip.generationStartMinChatIndex) || 0)) - ) { - return false; - } - - currentGenerationTrivialSkip = null; - return true; + return recallInputState.consumeCurrentGenerationTrivialSkip( + targetMessageIndex, + chatId, + now, + ); } function recordRecallSendIntent(text, source = "dom-intent") { - const normalized = normalizeRecallInputText(text); - if (!normalized) return createRecallInputRecord(); - - const hash = hashRecallInput(normalized); - const previousRecord = isFreshRecallInputRecord(pendingRecallSendIntent) - ? pendingRecallSendIntent - : null; - const previousHash = String(previousRecord?.hash || ""); - const previousText = String(previousRecord?.text || ""); - - if (previousHash && previousHash === hash && previousText === normalized) { - pendingRecallSendIntent = createRecallInputRecord({ - ...previousRecord, - at: Date.now(), - source: String(source || previousRecord.source || "dom-intent"), - }); - return pendingRecallSendIntent; - } - - pendingRecallSendIntent = createRecallInputRecord({ - text: normalized, - hash, - source, - at: Date.now(), - }); - return pendingRecallSendIntent; + return recallInputState.recordRecallSendIntent(text, source); } function recordRecallSentUserMessage(messageId, text, source = "message-sent") { - const normalized = normalizeRecallInputText(text); - if (!normalized) return createRecallInputRecord(); - - const hash = hashRecallInput(normalized); - lastRecallSentUserMessage = createRecallInputRecord({ - text: normalized, - hash, - messageId: Number.isFinite(messageId) ? messageId : null, - source, - at: Date.now(), - }); - if (typeof recordMessageTraceSnapshot === "function") { - recordMessageTraceSnapshot({ - lastSentUserMessage: { - text: normalized, - hash, - messageId: Number.isFinite(messageId) ? messageId : null, - source, - updatedAt: new Date().toISOString(), - }, - }); - } - - // 注意:不再在 MESSAGE_SENT 阶段清空 pendingRecallSendIntent / - // pendingHostGenerationInputSnapshot / transactions。 - // 这些数据在 GENERATION_AFTER_COMMANDS 中被消费;MESSAGE_SENT 先于 - // GENERATION_AFTER_COMMANDS 触发,提前清空会导致召回拿不到用户输入。 - // 真正的消费发生在 recall 执行后(runRecallController 内部)。 - - return lastRecallSentUserMessage; + return recallInputState.recordRecallSentUserMessage(messageId, text, source); } - function getMessageRecallRecord(messageIndex) { const chat = getContext()?.chat; return readPersistedRecallFromUserMessage(chat, messageIndex); diff --git a/runtime/recall-input-state.js b/runtime/recall-input-state.js new file mode 100644 index 0000000..1844525 --- /dev/null +++ b/runtime/recall-input-state.js @@ -0,0 +1,259 @@ +export function createRecallInputState(deps = {}) { + let currentGenerationTrivialSkip = null; + + const getPendingRecallSendIntent = () => + deps.getPendingRecallSendIntent?.() ?? deps.createRecallInputRecord?.(); + const setPendingRecallSendIntent = (record) => { + deps.setPendingRecallSendIntent?.(record); + return record; + }; + const getPendingHostGenerationInputSnapshot = () => + deps.getPendingHostGenerationInputSnapshot?.() ?? deps.createRecallInputRecord?.(); + const setPendingHostGenerationInputSnapshot = (record) => { + deps.setPendingHostGenerationInputSnapshot?.(record); + return record; + }; + const getLastRecallSentUserMessage = () => + deps.getLastRecallSentUserMessage?.() ?? deps.createRecallInputRecord?.(); + const setLastRecallSentUserMessage = (record) => { + deps.setLastRecallSentUserMessage?.(record); + return record; + }; + const getCurrentChatId = (...args) => deps.getCurrentChatId?.(...args); + const normalizeChatIdCandidate = (value = "") => + deps.normalizeChatIdCandidate?.(value) ?? String(value ?? "").trim(); + const normalizeRecallInputText = (value = "") => + deps.normalizeRecallInputText?.(value) ?? String(value || "").trim(); + const createRecallInputRecord = (record = {}) => + deps.createRecallInputRecord?.(record) ?? { ...(record || {}) }; + const hashRecallInput = (value = "") => deps.hashRecallInput?.(value) ?? ""; + const isFreshRecallInputRecord = (record) => + deps.isFreshRecallInputRecord?.(record) ?? Boolean(record?.text); + const getTrivialGenerationSkipTtlMs = () => + Number.isFinite(Number(deps.TRIVIAL_GENERATION_SKIP_TTL_MS)) + ? Number(deps.TRIVIAL_GENERATION_SKIP_TTL_MS) + : 60000; + + function freezeHostGenerationInputSnapshot( + text, + source = "host-generation-lifecycle", + ) { + const normalized = normalizeRecallInputText(text); + if (!normalized) return null; + + const nextSnapshot = createRecallInputRecord({ + text: normalized, + hash: hashRecallInput(normalized), + source, + at: Date.now(), + }); + setPendingHostGenerationInputSnapshot(nextSnapshot); + return nextSnapshot; + } + + function consumeHostGenerationInputSnapshot(options = {}) { + const { preserve = false } = options; + const pendingHostGenerationInputSnapshot = getPendingHostGenerationInputSnapshot(); + if (!isFreshRecallInputRecord(pendingHostGenerationInputSnapshot)) { + if (!preserve) { + setPendingHostGenerationInputSnapshot(createRecallInputRecord()); + } + return createRecallInputRecord(); + } + + const snapshot = createRecallInputRecord({ + ...pendingHostGenerationInputSnapshot, + }); + if (!preserve) { + setPendingHostGenerationInputSnapshot(createRecallInputRecord()); + } + return snapshot; + } + + function readPendingHostGenerationInputSnapshot() { + return getPendingHostGenerationInputSnapshot(); + } + + function clearPendingRecallSendIntent() { + const nextRecord = createRecallInputRecord(); + setPendingRecallSendIntent(nextRecord); + return nextRecord; + } + + function clearPendingHostGenerationInputSnapshot() { + const nextSnapshot = createRecallInputRecord(); + setPendingHostGenerationInputSnapshot(nextSnapshot); + return nextSnapshot; + } + + function getCurrentGenerationTrivialSkip( + chatId = getCurrentChatId(), + now = Date.now(), + ) { + if (!currentGenerationTrivialSkip) return null; + + const setAtMs = Number(currentGenerationTrivialSkip.setAtMs) || 0; + if ( + !setAtMs || + now - setAtMs > getTrivialGenerationSkipTtlMs() + ) { + currentGenerationTrivialSkip = null; + return null; + } + + const normalizedChatId = normalizeChatIdCandidate(chatId); + const activeChatId = normalizeChatIdCandidate( + currentGenerationTrivialSkip.chatId, + ); + if (normalizedChatId && activeChatId && normalizedChatId !== activeChatId) { + return null; + } + + return currentGenerationTrivialSkip; + } + + function markCurrentGenerationTrivialSkip({ + reason = "", + chatId = getCurrentChatId(), + chatLength = 0, + } = {}) { + currentGenerationTrivialSkip = { + chatId: normalizeChatIdCandidate(chatId), + setAtMs: Date.now(), + reason: String(reason || ""), + generationStartMinChatIndex: Math.max( + 0, + Math.floor(Number(chatLength) || 0), + ), + }; + return currentGenerationTrivialSkip; + } + + function clearCurrentGenerationTrivialSkip(_reason = "") { + const previous = currentGenerationTrivialSkip; + currentGenerationTrivialSkip = null; + return previous; + } + + function consumeCurrentGenerationTrivialSkip( + targetMessageIndex, + chatId = getCurrentChatId(), + now = Date.now(), + ) { + const activeSkip = getCurrentGenerationTrivialSkip(chatId, now); + if (!activeSkip) return false; + + const normalizedTargetIndex = Number.isFinite(Number(targetMessageIndex)) + ? Math.floor(Number(targetMessageIndex)) + : null; + if (!Number.isFinite(normalizedTargetIndex)) { + return false; + } + + if ( + normalizedTargetIndex < + Math.max(0, Math.floor(Number(activeSkip.generationStartMinChatIndex) || 0)) + ) { + return false; + } + + currentGenerationTrivialSkip = null; + return true; + } + + function recordRecallSendIntent(text, source = "dom-intent") { + const normalized = normalizeRecallInputText(text); + if (!normalized) return createRecallInputRecord(); + + const hash = hashRecallInput(normalized); + const pendingRecallSendIntent = getPendingRecallSendIntent(); + const previousRecord = isFreshRecallInputRecord(pendingRecallSendIntent) + ? pendingRecallSendIntent + : null; + const previousHash = String(previousRecord?.hash || ""); + const previousText = String(previousRecord?.text || ""); + + if (previousHash && previousHash === hash && previousText === normalized) { + const nextRecord = createRecallInputRecord({ + ...previousRecord, + at: Date.now(), + source: String(source || previousRecord.source || "dom-intent"), + }); + setPendingRecallSendIntent(nextRecord); + return nextRecord; + } + + const nextRecord = createRecallInputRecord({ + text: normalized, + hash, + source, + at: Date.now(), + }); + setPendingRecallSendIntent(nextRecord); + return nextRecord; + } + + function recordRecallSentUserMessage(messageId, text, source = "message-sent") { + const normalized = normalizeRecallInputText(text); + if (!normalized) return createRecallInputRecord(); + + const hash = hashRecallInput(normalized); + const nextRecord = createRecallInputRecord({ + text: normalized, + hash, + messageId: Number.isFinite(messageId) ? messageId : null, + source, + at: Date.now(), + }); + setLastRecallSentUserMessage(nextRecord); + if (typeof deps.recordMessageTraceSnapshot === "function") { + deps.recordMessageTraceSnapshot({ + lastSentUserMessage: { + text: normalized, + hash, + messageId: Number.isFinite(messageId) ? messageId : null, + source, + updatedAt: new Date().toISOString(), + }, + }); + } + + // 注意:不再在 MESSAGE_SENT 阶段清空 pendingRecallSendIntent / + // pendingHostGenerationInputSnapshot / transactions。 + // 这些数据在 GENERATION_AFTER_COMMANDS 中被消费;MESSAGE_SENT 先于 + // GENERATION_AFTER_COMMANDS 触发,提前清空会导致召回拿不到用户输入。 + // 真正的消费发生在 recall 执行后(runRecallController 内部)。 + + return nextRecord; + } + + function clearRecallInputTracking() { + clearPendingRecallSendIntent(); + setLastRecallSentUserMessage(createRecallInputRecord()); + clearPendingHostGenerationInputSnapshot(); + deps.clearPendingRerollRecallReuse?.("recall-input-tracking-cleared"); + if (typeof deps.recordMessageTraceSnapshot === "function") { + deps.recordMessageTraceSnapshot({ + lastSentUserMessage: null, + }); + } + deps.clearPlannerRecallHandoffsForChat?.("", { clearAll: true }); + } + + return { + freezeHostGenerationInputSnapshot, + consumeHostGenerationInputSnapshot, + getPendingHostGenerationInputSnapshot: readPendingHostGenerationInputSnapshot, + clearPendingHostGenerationInputSnapshot, + recordRecallSendIntent, + clearPendingRecallSendIntent, + recordRecallSentUserMessage, + getCurrentGenerationTrivialSkip, + markCurrentGenerationTrivialSkip, + clearCurrentGenerationTrivialSkip, + consumeCurrentGenerationTrivialSkip, + clearRecallInputTracking, + getLastRecallSentUserMessage, + getPendingRecallSendIntent, + }; +} diff --git a/tests/graph-persistence.mjs b/tests/graph-persistence.mjs index 7f25583..f3ccd63 100644 --- a/tests/graph-persistence.mjs +++ b/tests/graph-persistence.mjs @@ -160,6 +160,7 @@ import { normalizeRecallInputText, normalizeStageNoticeLevel, } from "../ui/ui-status.js"; +import { createRecallInputState } from "../runtime/recall-input-state.js"; const moduleDir = path.dirname(fileURLToPath(import.meta.url)); const indexPath = path.resolve(moduleDir, "../index.js"); @@ -781,6 +782,7 @@ async function createGraphPersistenceHarness({ return null; }, }, + createRecallInputState, createRecallMessageUiController() { return { refreshPersistedRecallMessageUi: () => ({ diff --git a/tests/helpers/generation-recall-harness.mjs b/tests/helpers/generation-recall-harness.mjs index b985bbb..fab9fa6 100644 --- a/tests/helpers/generation-recall-harness.mjs +++ b/tests/helpers/generation-recall-harness.mjs @@ -62,6 +62,7 @@ import { consumeRerollRecallReuseMarker, createRerollRecallReuseMarker, } from "../../runtime/reroll-transaction-boundary.js"; +import { createRecallInputState } from "../../runtime/recall-input-state.js"; const moduleDir = path.dirname(fileURLToPath(import.meta.url)); const indexPath = path.resolve(moduleDir, "../../index.js"); @@ -102,6 +103,9 @@ export function createGenerationRecallHarness(options = {}) { }, result: null, currentGraph: {}, + pendingRecallSendIntent: createRecallInputRecord(), + lastRecallSentUserMessage: createRecallInputRecord(), + pendingHostGenerationInputSnapshot: createRecallInputRecord(), _panelModule: null, defaultSettings, mergePersistedSettings, @@ -115,6 +119,7 @@ export function createGenerationRecallHarness(options = {}) { recordAuthorityAcceptedRevision, consumeRerollRecallReuseMarker, createRerollRecallReuseMarker, + createRecallInputState, settings: {}, graphPersistenceState: createGraphPersistenceState(), extension_settings: { [MODULE_NAME]: {} }, @@ -273,6 +278,7 @@ export function createGenerationRecallHarness(options = {}) { recordInjectionSnapshot: (_kind, snapshot = {}) => { context.recordedInjectionSnapshots.push({ ...snapshot }); }, + recordMessageTraceSnapshot() {}, schedulePersistedRecallMessageUiRefresh: () => { context.recallUiRefreshCalls += 1; },