From bbe3a505e870d69db47833420f832eae0baa8e43 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 9 Jun 2026 14:42:21 +0000 Subject: [PATCH 01/10] 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 60f8ec2..0f61a9e 100644 --- a/manifest.json +++ b/manifest.json @@ -6,6 +6,6 @@ "js": "index.js", "css": "style.css", "author": "Youzini", - "version": "7.6.3", + "version": "7.6.4", "homePage": "https://github.com/Youzini-afk/ST-Bionic-Memory-Ecology" } From 43936b962bb87cd3f581df64f4c8cc7a1fe2f42c Mon Sep 17 00:00:00 2001 From: youzini Date: Tue, 9 Jun 2026 15:48:36 +0000 Subject: [PATCH 02/10] test(planner): characterize plot fallback and handoff order --- ena-planner/ena-planner-runtime-utils.js | 64 +++++++++++++++++++ ena-planner/ena-planner.js | 65 ++++++-------------- tests/ena-planner-plots.mjs | 78 ++++++++++++++++++++++++ 3 files changed, 159 insertions(+), 48 deletions(-) create mode 100644 ena-planner/ena-planner-runtime-utils.js create mode 100644 tests/ena-planner-plots.mjs diff --git a/ena-planner/ena-planner-runtime-utils.js b/ena-planner/ena-planner-runtime-utils.js new file mode 100644 index 0000000..fb7b854 --- /dev/null +++ b/ena-planner/ena-planner-runtime-utils.js @@ -0,0 +1,64 @@ +export function extractLastNPlots(chat, n) { + if (!Array.isArray(chat) || chat.length === 0) return []; + const want = Math.max(0, Number(n) || 0); + if (!want) return []; + + const plots = []; + const plotRe = /]*>[\s\S]*?<\/plot>/gi; + + for (let i = chat.length - 1; i >= 0; i--) { + const text = chat[i]?.mes ?? ''; + if (!text) continue; + const matches = [...text.matchAll(plotRe)]; + for (let j = matches.length - 1; j >= 0; j--) { + plots.push(matches[j][0]); + if (plots.length >= want) return plots; + } + } + return plots; +} + +export function formatPlotsBlock(plotList) { + if (!Array.isArray(plotList) || plotList.length === 0) return ''; + const chrono = [...plotList].reverse(); + const lines = []; + chrono.forEach((p, idx) => { + lines.push(`【plot -${chrono.length - idx}】\n${p}`); + }); + return `\n${lines.join('\n\n')}\n`; +} + +export function applyPlannerResultAndSend({ + textarea, + button, + rawUserInput = '', + filtered = '', + plannerRecall = null, + runtime = null, + plannerState = null, +} = {}) { + if (!textarea || !button) return { applied: false, reason: 'missing-target' }; + + const raw = String(rawUserInput ?? '').trim(); + const merged = `${raw}\n\n${String(filtered ?? '')}`.trim(); + textarea.value = merged; + if (plannerState && typeof plannerState === 'object') { + plannerState.lastInjectedText = merged; + } + + let handoffPrepared = false; + if (runtime?.preparePlannerRecallHandoff && plannerRecall?.result) { + runtime.preparePlannerRecallHandoff({ + rawUserInput: raw, + plannerAugmentedMessage: merged, + plannerRecall, + }); + handoffPrepared = true; + } + + if (plannerState && typeof plannerState === 'object') { + plannerState.bypassNextSend = true; + } + button.click(); + return { applied: true, merged, handoffPrepared }; +} diff --git a/ena-planner/ena-planner.js b/ena-planner/ena-planner.js index 92ef842..dbff20f 100644 --- a/ena-planner/ena-planner.js +++ b/ena-planner/ena-planner.js @@ -1,6 +1,11 @@ import { extension_settings } from '../../../../extensions.js'; import { getRequestHeaders, saveSettingsDebounced, substituteParamsExtended } from '../../../../../script.js'; import { EnaPlannerStorage, migrateFromLWBIfNeeded } from './ena-planner-storage.js'; +import { + applyPlannerResultAndSend, + extractLastNPlots, + formatPlotsBlock, +} from './ena-planner-runtime-utils.js'; import { DEFAULT_PROMPT_BLOCKS, BUILTIN_TEMPLATES } from './ena-planner-presets.js'; import { createBuiltinPromptBlock, @@ -794,38 +799,6 @@ function collectRecentChatSnippet(chat, maxMessages) { * Plot extraction * -------------------------- */ -function extractLastNPlots(chat, n) { - if (!Array.isArray(chat) || chat.length === 0) return []; - const want = Math.max(0, Number(n) || 0); - if (!want) return []; - - const plots = []; - const plotRe = /]*>[\s\S]*?<\/plot>/gi; - - for (let i = chat.length - 1; i >= 0; i--) { - const text = chat[i]?.mes ?? ''; - if (!text) continue; - const matches = [...text.matchAll(plotRe)]; - for (let j = matches.length - 1; j >= 0; j--) { - plots.push(matches[j][0]); - if (plots.length >= want) return plots; - } - } - return plots; -} - -function formatPlotsBlock(plotList) { - if (!Array.isArray(plotList) || plotList.length === 0) return ''; - // plotList is [newest, ..., oldest] from extractLastNPlots - // Reverse to chronological: oldest first, newest last - const chrono = [...plotList].reverse(); - const lines = []; - chrono.forEach((p, idx) => { - lines.push(`【plot -${chrono.length - idx}】\n${p}`); - }); - return `\n${lines.join('\n\n')}\n`; -} - /** * ------------------------- * Worldbook — read via ST API (like idle-watcher) @@ -1941,22 +1914,18 @@ async function doInterceptAndPlanThenSend() { ta.value = `${raw}\n\n${preview}`.trim(); } }); - const merged = `${raw}\n\n${filtered}`.trim(); - ta.value = merged; - state.lastInjectedText = merged; - - // Ordering requirement: register the one-shot planner recall handoff - // synchronously before btn.click(), with no await/timer hop in between. - if (_bmeRuntime?.preparePlannerRecallHandoff && plannerRecall?.result) { - _bmeRuntime.preparePlannerRecallHandoff({ - rawUserInput: raw, - plannerAugmentedMessage: merged, - plannerRecall, - }); - } - - state.bypassNextSend = true; - btn.click(); + // Ordering requirement: write the merged textarea, register the + // one-shot planner recall handoff synchronously, then click send with + // no await/timer hop in between. + applyPlannerResultAndSend({ + textarea: ta, + button: btn, + rawUserInput: raw, + filtered, + plannerRecall, + runtime: _bmeRuntime, + plannerState: state, + }); } catch (err) { ta.value = raw; state.lastInjectedText = ''; diff --git a/tests/ena-planner-plots.mjs b/tests/ena-planner-plots.mjs new file mode 100644 index 0000000..67abe4c --- /dev/null +++ b/tests/ena-planner-plots.mjs @@ -0,0 +1,78 @@ +import assert from 'node:assert/strict'; + +import { + applyPlannerResultAndSend, + extractLastNPlots, + formatPlotsBlock, +} from '../ena-planner/ena-planner-runtime-utils.js'; + +{ + const chat = [ + { mes: 'no plot here' }, + { mes: 'old one\nold two' }, + { mes: 'assistant says new one' }, + ]; + assert.deepEqual(extractLastNPlots(chat, 2), [ + 'new one', + 'old two', + ]); + assert.deepEqual(extractLastNPlots(chat, 0), []); + assert.deepEqual(extractLastNPlots(null, 3), []); +} + +{ + const block = formatPlotsBlock([ + 'newest', + 'older', + ]); + assert.equal( + block, + '\n【plot -2】\nolder\n\n【plot -1】\nnewest\n', + ); + assert.equal(formatPlotsBlock([]), ''); +} + +{ + const order = []; + const textarea = { value: 'raw' }; + const button = { click: () => order.push('click') }; + const plannerState = { bypassNextSend: false, lastInjectedText: '' }; + const plannerRecall = { result: { selected: ['memory-a'] } }; + const runtime = { + preparePlannerRecallHandoff(payload) { + order.push('handoff'); + assert.equal(payload.rawUserInput, 'raw input'); + assert.equal(payload.plannerAugmentedMessage, 'raw input\n\nnext'); + assert.equal(payload.plannerRecall, plannerRecall); + }, + }; + + const result = applyPlannerResultAndSend({ + textarea, + button, + rawUserInput: 'raw input', + filtered: 'next', + plannerRecall, + runtime, + plannerState, + }); + + assert.deepEqual(order, ['handoff', 'click']); + assert.equal(result.applied, true); + assert.equal(result.handoffPrepared, true); + assert.equal(textarea.value, 'raw input\n\nnext'); + assert.equal(plannerState.lastInjectedText, textarea.value); + assert.equal(plannerState.bypassNextSend, true); +} + +{ + const order = []; + const result = applyPlannerResultAndSend({ + textarea: null, + button: { click: () => order.push('click') }, + }); + assert.deepEqual(result, { applied: false, reason: 'missing-target' }); + assert.deepEqual(order, []); +} + +console.log('ena-planner-plots tests passed'); From 9f4787a035a5521f1cc83a10e6aa2b43fff538e5 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 9 Jun 2026 15:49:11 +0000 Subject: [PATCH 03/10] 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 0f61a9e..7d3c0ea 100644 --- a/manifest.json +++ b/manifest.json @@ -6,6 +6,6 @@ "js": "index.js", "css": "style.css", "author": "Youzini", - "version": "7.6.4", + "version": "7.6.5", "homePage": "https://github.com/Youzini-afk/ST-Bionic-Memory-Ecology" } From 60b29c3f57bf720249c92f1adbf959885cb06585 Mon Sep 17 00:00:00 2001 From: youzini Date: Tue, 9 Jun 2026 15:53:46 +0000 Subject: [PATCH 04/10] feat(planner): persist structured plot history --- ena-planner/ena-planner-runtime-utils.js | 9 ++ ena-planner/ena-planner.js | 9 +- ena-planner/planner-plot-history.js | 132 +++++++++++++++++++++++ host/event-binding.js | 1 + index.js | 29 +++++ runtime/reroll-recall-input.js | 5 + tests/ena-planner-plots.mjs | 57 ++++++++++ 7 files changed, 239 insertions(+), 3 deletions(-) create mode 100644 ena-planner/planner-plot-history.js diff --git a/ena-planner/ena-planner-runtime-utils.js b/ena-planner/ena-planner-runtime-utils.js index fb7b854..69690a7 100644 --- a/ena-planner/ena-planner-runtime-utils.js +++ b/ena-planner/ena-planner-runtime-utils.js @@ -34,6 +34,7 @@ export function applyPlannerResultAndSend({ rawUserInput = '', filtered = '', plannerRecall = null, + plannerPlotRecord = null, runtime = null, plannerState = null, } = {}) { @@ -48,10 +49,18 @@ export function applyPlannerResultAndSend({ let handoffPrepared = false; if (runtime?.preparePlannerRecallHandoff && plannerRecall?.result) { + const plotRecordPayload = plannerPlotRecord && typeof plannerPlotRecord === 'object' + ? { + ...plannerPlotRecord, + rawUserInput: raw, + plannerAugmentedMessage: merged, + } + : null; runtime.preparePlannerRecallHandoff({ rawUserInput: raw, plannerAugmentedMessage: merged, plannerRecall, + plannerPlotRecord: plotRecordPayload, }); handoffPrepared = true; } diff --git a/ena-planner/ena-planner.js b/ena-planner/ena-planner.js index dbff20f..4eb2a37 100644 --- a/ena-planner/ena-planner.js +++ b/ena-planner/ena-planner.js @@ -3,9 +3,8 @@ import { getRequestHeaders, saveSettingsDebounced, substituteParamsExtended } fr import { EnaPlannerStorage, migrateFromLWBIfNeeded } from './ena-planner-storage.js'; import { applyPlannerResultAndSend, - extractLastNPlots, - formatPlotsBlock, } from './ena-planner-runtime-utils.js'; +import { readPlannerPlotHistory } from './planner-plot-history.js'; import { DEFAULT_PROMPT_BLOCKS, BUILTIN_TEMPLATES } from './ena-planner-presets.js'; import { createBuiltinPromptBlock, @@ -1743,7 +1742,7 @@ async function buildPlannerMessages(rawUserInput) { // a little continuity even when memory recall returns empty. const recentChatRaw = collectRecentChatSnippet(chat, 2); - const plotsRaw = formatPlotsBlock(extractLastNPlots(chat, s.plotCount)); + const plotsRaw = readPlannerPlotHistory(chat, { count: s.plotCount }).block; // Build scanText for worldbook keyword activation const scanText = [charBlockRaw, recentChatRaw, plotsRaw, rawUserInput].join('\n\n'); @@ -1923,6 +1922,10 @@ async function doInterceptAndPlanThenSend() { rawUserInput: raw, filtered, plannerRecall, + plannerPlotRecord: { + rawUserInput: raw, + plotText: filtered, + }, runtime: _bmeRuntime, plannerState: state, }); diff --git a/ena-planner/planner-plot-history.js b/ena-planner/planner-plot-history.js new file mode 100644 index 0000000..358abcc --- /dev/null +++ b/ena-planner/planner-plot-history.js @@ -0,0 +1,132 @@ +import { + extractLastNPlots, + formatPlotsBlock, +} from './ena-planner-runtime-utils.js'; + +export const ST_BME_PLOT_HISTORY_KEY = 'st_bme_plot'; +export const ST_BME_PLOT_HISTORY_VERSION = 1; + +export function hashPlannerPlotInput(text = '') { + let hash = 2166136261; + for (const char of String(text || '')) { + hash ^= char.charCodeAt(0); + hash = Math.imul(hash, 16777619); + } + return String(Math.abs(hash >>> 0)); +} + +export function createStructuredPlotRecord({ + rawUserInput = '', + plannerAugmentedMessage = '', + plotText = '', + plotBlocks = null, + promptProfileId = '', + recallHandoffId = '', + taskResults = [], + createdAt = Date.now(), + inputHash = '', +} = {}) { + const normalizedRaw = String(rawUserInput || '').trim(); + const normalizedPlot = String(plotText || '').trim(); + const blocks = Array.isArray(plotBlocks) + ? plotBlocks.map((item) => String(item || '').trim()).filter(Boolean) + : extractLastNPlots([{ mes: normalizedPlot }], 99); + return { + version: ST_BME_PLOT_HISTORY_VERSION, + inputHash: String(inputHash || hashPlannerPlotInput(normalizedRaw)), + rawUserInput: normalizedRaw, + plannerAugmentedMessage: String(plannerAugmentedMessage || '').trim(), + plotText: normalizedPlot, + plotBlocks: blocks, + promptProfileId: String(promptProfileId || ''), + recallHandoffId: String(recallHandoffId || ''), + taskResults: Array.isArray(taskResults) ? taskResults : [], + createdAt: Number.isFinite(Number(createdAt)) ? Number(createdAt) : Date.now(), + }; +} + +export function normalizeStructuredPlotRecord(value) { + if (!value || typeof value !== 'object') return null; + if (Number(value.version) !== ST_BME_PLOT_HISTORY_VERSION) return null; + const plotText = String(value.plotText || '').trim(); + const plotBlocks = Array.isArray(value.plotBlocks) + ? value.plotBlocks.map((item) => String(item || '').trim()).filter(Boolean) + : []; + if (!plotText && plotBlocks.length === 0) return null; + return createStructuredPlotRecord({ + ...value, + plotText, + plotBlocks, + createdAt: value.createdAt, + }); +} + +export function readStructuredPlotRecordFromMessage(message) { + return normalizeStructuredPlotRecord(message?.extra?.[ST_BME_PLOT_HISTORY_KEY]); +} + +export function collectStructuredPlotRecords(chat, count = 2) { + if (!Array.isArray(chat) || chat.length === 0) return []; + const want = Math.max(0, Number(count) || 0); + if (!want) return []; + const records = []; + for (let index = chat.length - 1; index >= 0; index--) { + const record = readStructuredPlotRecordFromMessage(chat[index]); + if (!record) continue; + records.push(record); + if (records.length >= want) break; + } + return records; +} + +export function readPlannerPlotHistory(chat, { count = 2 } = {}) { + const structuredRecords = collectStructuredPlotRecords(chat, count); + if (structuredRecords.length > 0) { + const plots = structuredRecords.map((record) => record.plotText || record.plotBlocks.join('\n')).filter(Boolean); + return { + source: 'structured', + records: structuredRecords, + plots, + block: formatPlotsBlock(plots), + }; + } + + const plots = extractLastNPlots(chat, count); + return { + source: plots.length > 0 ? 'legacy' : 'empty', + records: [], + plots, + block: formatPlotsBlock(plots), + }; +} + +export function writeStructuredPlotRecordToMessage(message, recordInput) { + if (!message || typeof message !== 'object' || !message.is_user) return false; + const record = normalizeStructuredPlotRecord( + recordInput?.version ? recordInput : createStructuredPlotRecord(recordInput), + ); + if (!record) return false; + message.extra = message.extra && typeof message.extra === 'object' + ? message.extra + : {}; + message.extra[ST_BME_PLOT_HISTORY_KEY] = record; + return true; +} + +export function writeStructuredPlotRecordToMatchingUserMessage(chat, recordInput) { + if (!Array.isArray(chat)) return null; + const record = normalizeStructuredPlotRecord( + recordInput?.version ? recordInput : createStructuredPlotRecord(recordInput), + ); + if (!record) return null; + const inputHash = String(record.inputHash || hashPlannerPlotInput(record.rawUserInput)); + for (let index = chat.length - 1; index >= 0; index--) { + const message = chat[index]; + if (!message?.is_user) continue; + if (hashPlannerPlotInput(message.mes || '') !== inputHash) continue; + if (writeStructuredPlotRecordToMessage(message, record)) { + return { index, record }; + } + } + return null; +} diff --git a/host/event-binding.js b/host/event-binding.js index 37a35f2..df0d3d9 100644 --- a/host/event-binding.js +++ b/host/event-binding.js @@ -327,6 +327,7 @@ export function onMessageSentController(runtime, messageId) { resolvedMessageId, message.mes || "", ); + runtime.persistPlannerPlotRecordToUserMessage?.(resolvedMessageId); // GENERATION_AFTER_COMMANDS 在 sendMessageAsUser 之前触发,此时新用户消息 // 尚未进入 chat,recall 记录会被写到上一条 user 上。这里用户消息刚入场, // transaction 仍在桥接窗口内,立即把记录重新绑定到正确的楼层。 diff --git a/index.js b/index.js index 92f7d75..5b8151d 100644 --- a/index.js +++ b/index.js @@ -111,6 +111,7 @@ import { registerGenerationAfterCommandsController, scheduleSendIntentHookRetryController, } from "./host/event-binding.js"; +import { writeStructuredPlotRecordToMessage } from "./ena-planner/planner-plot-history.js"; import { BME_HOST_PROFILE_LUKER, getBmeHostAdapter, @@ -14734,16 +14735,43 @@ function preparePlannerRecallHandoff({ rawUserInput = "", plannerAugmentedMessage = "", plannerRecall = null, + plannerPlotRecord = null, chatId = getCurrentChatId(), } = {}) { return rerollRecallInput.preparePlannerRecallHandoff({ rawUserInput, plannerAugmentedMessage, plannerRecall, + plannerPlotRecord, chatId, }); } +function persistPlannerPlotRecordToUserMessage(newUserMessageIndex) { + const context = getContext(); + const chat = context?.chat; + if ( + !Array.isArray(chat) || + !Number.isFinite(newUserMessageIndex) || + !chat[newUserMessageIndex]?.is_user + ) { + return false; + } + const handoff = peekPlannerRecallHandoff(context?.chatId || getCurrentChatId()); + const plannerPlotRecord = handoff?.plannerPlotRecord; + if (!plannerPlotRecord || typeof plannerPlotRecord !== "object") { + return false; + } + const wrote = writeStructuredPlotRecordToMessage(chat[newUserMessageIndex], { + ...plannerPlotRecord, + recallHandoffId: handoff.id || plannerPlotRecord.recallHandoffId || "", + }); + if (wrote) { + triggerChatMetadataSave(context, { immediate: false }); + } + return wrote; +} + function buildPreGenerationRecallKey(type, options = {}) { return generationRecallTransactionRuntime.buildPreGenerationRecallKey( type, @@ -16051,6 +16079,7 @@ function onMessageSent(messageId) { getContext, isTrivialUserInput, markCurrentGenerationTrivialSkip, + persistPlannerPlotRecordToUserMessage, recordRecallSentUserMessage, rebindRecallRecordToNewUserMessage, refreshPersistedRecallMessageUi: schedulePersistedRecallMessageUiRefresh, diff --git a/runtime/reroll-recall-input.js b/runtime/reroll-recall-input.js index df7cde7..3f04986 100644 --- a/runtime/reroll-recall-input.js +++ b/runtime/reroll-recall-input.js @@ -309,6 +309,7 @@ export function createRerollRecallInput(deps = {}) { rawUserInput = "", plannerAugmentedMessage = "", plannerRecall = null, + plannerPlotRecord = null, chatId = getCurrentChatId(), } = {}) { const normalizedChatId = normalizeChatIdCandidate(chatId); @@ -340,6 +341,10 @@ export function createRerollRecallInput(deps = {}) { ? plannerRecall.recentMessages.map((item) => String(item || "")) : [], injectionText, + plannerPlotRecord: + plannerPlotRecord && typeof plannerPlotRecord === "object" + ? { ...plannerPlotRecord } + : null, source: "planner-handoff", sourceLabel: "Planner handoff", createdAt, diff --git a/tests/ena-planner-plots.mjs b/tests/ena-planner-plots.mjs index 67abe4c..befdb82 100644 --- a/tests/ena-planner-plots.mjs +++ b/tests/ena-planner-plots.mjs @@ -5,6 +5,12 @@ import { extractLastNPlots, formatPlotsBlock, } from '../ena-planner/ena-planner-runtime-utils.js'; +import { + createStructuredPlotRecord, + readPlannerPlotHistory, + writeStructuredPlotRecordToMatchingUserMessage, + writeStructuredPlotRecordToMessage, +} from '../ena-planner/planner-plot-history.js'; { const chat = [ @@ -44,6 +50,11 @@ import { assert.equal(payload.rawUserInput, 'raw input'); assert.equal(payload.plannerAugmentedMessage, 'raw input\n\nnext'); assert.equal(payload.plannerRecall, plannerRecall); + assert.deepEqual(payload.plannerPlotRecord, { + rawUserInput: 'raw input', + plannerAugmentedMessage: 'raw input\n\nnext', + plotText: 'next', + }); }, }; @@ -53,6 +64,7 @@ import { rawUserInput: 'raw input', filtered: 'next', plannerRecall, + plannerPlotRecord: { plotText: 'next' }, runtime, plannerState, }); @@ -65,6 +77,51 @@ import { assert.equal(plannerState.bypassNextSend, true); } +{ + const chat = [ + { is_user: true, mes: 'raw old', extra: {} }, + { is_user: false, mes: 'legacy stale' }, + { is_user: true, mes: 'raw latest', extra: {} }, + ]; + writeStructuredPlotRecordToMessage(chat[2], createStructuredPlotRecord({ + rawUserInput: 'raw latest', + plannerAugmentedMessage: 'raw latest\n\nstructured', + plotText: 'structured', + })); + const history = readPlannerPlotHistory(chat, { count: 2 }); + assert.equal(history.source, 'structured'); + assert.deepEqual(history.plots, ['structured']); + assert.ok(history.block.includes('structured')); + assert.ok(!history.block.includes('legacy stale')); +} + +{ + const chat = [ + { is_user: true, mes: 'raw old', extra: {} }, + { is_user: false, mes: 'legacy old' }, + ]; + chat[0].extra.st_bme_plot = { version: 999, plotText: 'bad' }; + const history = readPlannerPlotHistory(chat, { count: 1 }); + assert.equal(history.source, 'legacy'); + assert.deepEqual(history.plots, ['legacy old']); +} + +{ + const chat = [ + { is_user: true, mes: 'first input', extra: {} }, + { is_user: false, mes: 'assistant' }, + { is_user: true, mes: 'second input', extra: {} }, + ]; + const result = writeStructuredPlotRecordToMatchingUserMessage(chat, { + rawUserInput: 'first input', + plannerAugmentedMessage: 'first input\n\nfirst plan', + plotText: 'first plan', + }); + assert.equal(result.index, 0); + assert.equal(chat[0].extra.st_bme_plot.plotText, 'first plan'); + assert.equal(chat[2].extra.st_bme_plot, undefined); +} + { const order = []; const result = applyPlannerResultAndSend({ From d7536707cfefa88ffa27e3752f51cc674c2fe00e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 9 Jun 2026 15:54:07 +0000 Subject: [PATCH 05/10] 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 7d3c0ea..668016e 100644 --- a/manifest.json +++ b/manifest.json @@ -6,6 +6,6 @@ "js": "index.js", "css": "style.css", "author": "Youzini", - "version": "7.6.5", + "version": "7.6.6", "homePage": "https://github.com/Youzini-afk/ST-Bionic-Memory-Ecology" } From 566901bf95c441af108f700dd2ffb5f98111711e Mon Sep 17 00:00:00 2001 From: youzini Date: Tue, 9 Jun 2026 16:52:12 +0000 Subject: [PATCH 06/10] refactor(planner): lock send and plot history boundaries --- ena-planner/ena-planner-runtime-utils.js | 45 ++++++++++-- ena-planner/ena-planner.js | 19 +++-- ena-planner/planner-plot-history.js | 42 ++++++++--- index.js | 20 ++++- runtime/reroll-recall-input.js | 82 ++++++++++++++++++++- tests/ena-planner-plots.mjs | 94 ++++++++++++++++++++++-- 6 files changed, 266 insertions(+), 36 deletions(-) diff --git a/ena-planner/ena-planner-runtime-utils.js b/ena-planner/ena-planner-runtime-utils.js index 69690a7..f31d9cc 100644 --- a/ena-planner/ena-planner-runtime-utils.js +++ b/ena-planner/ena-planner-runtime-utils.js @@ -47,15 +47,22 @@ export function applyPlannerResultAndSend({ plannerState.lastInjectedText = merged; } + const plotRecordPayload = plannerPlotRecord && typeof plannerPlotRecord === 'object' + ? { + ...plannerPlotRecord, + rawUserInput: raw, + plannerAugmentedMessage: merged, + } + : null; + + let plotHandoffPrepared = false; + if (runtime?.preparePlannerPlotRecordHandoff && plotRecordPayload) { + runtime.preparePlannerPlotRecordHandoff(plotRecordPayload); + plotHandoffPrepared = true; + } + let handoffPrepared = false; if (runtime?.preparePlannerRecallHandoff && plannerRecall?.result) { - const plotRecordPayload = plannerPlotRecord && typeof plannerPlotRecord === 'object' - ? { - ...plannerPlotRecord, - rawUserInput: raw, - plannerAugmentedMessage: merged, - } - : null; runtime.preparePlannerRecallHandoff({ rawUserInput: raw, plannerAugmentedMessage: merged, @@ -69,5 +76,27 @@ export function applyPlannerResultAndSend({ plannerState.bypassNextSend = true; } button.click(); - return { applied: true, merged, handoffPrepared }; + return { applied: true, merged, handoffPrepared, plotHandoffPrepared }; +} + +export function shouldInterceptPlannerSend({ + enabled = false, + isPlanning = false, + hasTextarea = false, + textareaValue = '', + isTrivial = false, + bypassNextSend = false, + skipIfPlotPresent = false, +} = {}) { + if (!enabled) return { shouldIntercept: false, reason: 'disabled' }; + if (isPlanning) return { shouldIntercept: false, reason: 'planning' }; + if (!hasTextarea) return { shouldIntercept: false, reason: 'missing-textarea' }; + const text = String(textareaValue ?? '').trim(); + if (!text) return { shouldIntercept: false, reason: 'empty-input' }; + if (isTrivial) return { shouldIntercept: false, reason: 'trivial' }; + if (bypassNextSend) return { shouldIntercept: false, reason: 'bypass' }; + if (skipIfPlotPresent && / 0) { - const plots = structuredRecords.map((record) => record.plotText || record.plotBlocks.join('\n')).filter(Boolean); - return { - source: 'structured', - records: structuredRecords, - plots, - block: formatPlotsBlock(plots), - }; + for (const record of structuredRecords) { + const recordBlocks = record.plotBlocks.length > 0 + ? record.plotBlocks + : extractLastNPlots([{ mes: record.plotText || '' }], want); + const plot = recordBlocks.join('\n').trim(); + if (!plot || seen.has(plot)) continue; + plots.push(plot); + seen.add(plot); + if (plots.length >= want) break; + } } - const plots = extractLastNPlots(chat, count); + if (plots.length < want) { + for (const legacyPlot of extractLastNPlots(chat, want)) { + if (!legacyPlot || seen.has(legacyPlot)) continue; + plots.push(legacyPlot); + seen.add(legacyPlot); + usedLegacy = true; + if (plots.length >= want) break; + } + } + + const source = structuredRecords.length > 0 + ? (usedLegacy ? 'structured+legacy' : 'structured') + : (plots.length > 0 ? 'legacy' : 'empty'); return { - source: plots.length > 0 ? 'legacy' : 'empty', - records: [], + source, + records: structuredRecords, plots, block: formatPlotsBlock(plots), }; diff --git a/index.js b/index.js index 5b8151d..aa6616a 100644 --- a/index.js +++ b/index.js @@ -14747,6 +14747,16 @@ function preparePlannerRecallHandoff({ }); } +function preparePlannerPlotRecordHandoff(plannerPlotRecord = null) { + if (!plannerPlotRecord || typeof plannerPlotRecord !== "object") { + return null; + } + return rerollRecallInput.preparePlannerPlotRecordHandoff({ + ...plannerPlotRecord, + chatId: getCurrentChatId(), + }); +} + function persistPlannerPlotRecordToUserMessage(newUserMessageIndex) { const context = getContext(); const chat = context?.chat; @@ -14757,16 +14767,19 @@ function persistPlannerPlotRecordToUserMessage(newUserMessageIndex) { ) { return false; } - const handoff = peekPlannerRecallHandoff(context?.chatId || getCurrentChatId()); - const plannerPlotRecord = handoff?.plannerPlotRecord; + const chatId = context?.chatId || getCurrentChatId(); + const plotHandoff = rerollRecallInput.peekPlannerPlotRecordHandoff?.(chatId); + const handoff = peekPlannerRecallHandoff(chatId); + const plannerPlotRecord = plotHandoff || handoff?.plannerPlotRecord; if (!plannerPlotRecord || typeof plannerPlotRecord !== "object") { return false; } const wrote = writeStructuredPlotRecordToMessage(chat[newUserMessageIndex], { ...plannerPlotRecord, - recallHandoffId: handoff.id || plannerPlotRecord.recallHandoffId || "", + recallHandoffId: handoff?.id || plannerPlotRecord.recallHandoffId || "", }); if (wrote) { + rerollRecallInput.consumePlannerPlotRecordHandoff?.(chatId); triggerChatMetadataSave(context, { immediate: false }); } return wrote; @@ -17918,6 +17931,7 @@ async function onCompactLukerSidecar() { getExtensionPath: () => `scripts/extensions/third-party/${MODULE_NAME}`, getPlannerRecallTimeoutMs, isTrivialUserInput, + preparePlannerPlotRecordHandoff, preparePlannerRecallHandoff, runPlannerRecallForEna, }); diff --git a/runtime/reroll-recall-input.js b/runtime/reroll-recall-input.js index 3f04986..f3b53b7 100644 --- a/runtime/reroll-recall-input.js +++ b/runtime/reroll-recall-input.js @@ -1,5 +1,6 @@ export function createRerollRecallInput(deps = {}) { const plannerRecallHandoffs = new Map(); + const plannerPlotRecordHandoffs = new Map(); const getCurrentChatId = (...args) => deps.getCurrentChatId?.(...args); const normalizeChatIdCandidate = (value = "") => @@ -250,6 +251,16 @@ export function createRerollRecallInput(deps = {}) { plannerRecallHandoffs.delete(chatId); } } + for (const [chatId, handoff] of plannerPlotRecordHandoffs.entries()) { + if ( + !handoff || + String(handoff.chatId || "") !== String(chatId || "") || + now - Number(handoff.updatedAt || handoff.createdAt || 0) > + getPlannerRecallHandoffTtlMs() + ) { + plannerPlotRecordHandoffs.delete(chatId); + } + } } function peekPlannerRecallHandoff( @@ -278,14 +289,37 @@ export function createRerollRecallInput(deps = {}) { ) { cleanupPlannerRecallHandoffs(); if (clearAll) { - const removed = plannerRecallHandoffs.size; + const removed = plannerRecallHandoffs.size + plannerPlotRecordHandoffs.size; plannerRecallHandoffs.clear(); + plannerPlotRecordHandoffs.clear(); return removed; } const normalizedChatId = normalizeChatIdCandidate(chatId); if (!normalizedChatId) return 0; - return plannerRecallHandoffs.delete(normalizedChatId) ? 1 : 0; + let removed = 0; + if (plannerRecallHandoffs.delete(normalizedChatId)) removed += 1; + if (plannerPlotRecordHandoffs.delete(normalizedChatId)) removed += 1; + return removed; + } + + function peekPlannerPlotRecordHandoff( + chatId = getCurrentChatId(), + now = Date.now(), + ) { + cleanupPlannerRecallHandoffs(now); + const normalizedChatId = normalizeChatIdCandidate(chatId); + if (!normalizedChatId) return null; + return plannerPlotRecordHandoffs.get(normalizedChatId) || null; + } + + function consumePlannerPlotRecordHandoff(chatId = getCurrentChatId()) { + const normalizedChatId = normalizeChatIdCandidate(chatId); + if (!normalizedChatId) return null; + const handoff = peekPlannerPlotRecordHandoff(normalizedChatId); + if (!handoff) return null; + plannerPlotRecordHandoffs.delete(normalizedChatId); + return handoff; } function consumePlannerRecallHandoff( @@ -354,12 +388,56 @@ export function createRerollRecallInput(deps = {}) { return handoff; } + function preparePlannerPlotRecordHandoff({ + rawUserInput = "", + plannerAugmentedMessage = "", + plotText = "", + plotBlocks = null, + promptProfileId = "", + taskResults = [], + chatId = getCurrentChatId(), + } = {}) { + const normalizedChatId = normalizeChatIdCandidate(chatId); + const normalizedRawUserInput = normalizeRecallInputText(rawUserInput); + const normalizedPlannerAugmentedMessage = normalizeRecallInputText( + plannerAugmentedMessage, + ); + const normalizedPlotText = normalizeRecallInputText(plotText); + if (!normalizedChatId || !normalizedRawUserInput || !normalizedPlotText) { + return null; + } + cleanupPlannerRecallHandoffs(); + const createdAt = Date.now(); + const handoff = { + id: [ + normalizedChatId, + hashRecallInput(normalizedRawUserInput), + "plot", + createdAt, + ].join(":"), + chatId: normalizedChatId, + rawUserInput: normalizedRawUserInput, + plannerAugmentedMessage: normalizedPlannerAugmentedMessage, + plotText: normalizedPlotText, + plotBlocks: Array.isArray(plotBlocks) ? [...plotBlocks] : null, + promptProfileId: String(promptProfileId || ""), + taskResults: Array.isArray(taskResults) ? taskResults : [], + createdAt, + updatedAt: createdAt, + }; + plannerPlotRecordHandoffs.set(normalizedChatId, handoff); + return handoff; + } + return { clearPendingRerollRecallReuse, buildNormalGenerationRecallInput, buildHistoryGenerationRecallInput, buildGenerationAfterCommandsRecallInput, preparePlannerRecallHandoff, + preparePlannerPlotRecordHandoff, + peekPlannerPlotRecordHandoff, + consumePlannerPlotRecordHandoff, cleanupPlannerRecallHandoffs, peekPlannerRecallHandoff, clearPlannerRecallHandoffsForChat, diff --git a/tests/ena-planner-plots.mjs b/tests/ena-planner-plots.mjs index befdb82..7960e45 100644 --- a/tests/ena-planner-plots.mjs +++ b/tests/ena-planner-plots.mjs @@ -4,7 +4,9 @@ import { applyPlannerResultAndSend, extractLastNPlots, formatPlotsBlock, + shouldInterceptPlannerSend, } from '../ena-planner/ena-planner-runtime-utils.js'; +import { createRerollRecallInput } from '../runtime/reroll-recall-input.js'; import { createStructuredPlotRecord, readPlannerPlotHistory, @@ -77,6 +79,65 @@ import { assert.equal(plannerState.bypassNextSend, true); } +{ + const order = []; + const textarea = { value: 'raw' }; + const button = { click: () => order.push('click') }; + const plannerState = { bypassNextSend: false, lastInjectedText: '' }; + const runtime = { + preparePlannerPlotRecordHandoff(payload) { + order.push('plot-handoff'); + assert.deepEqual(payload, { + rawUserInput: 'raw input', + plannerAugmentedMessage: 'raw input\n\nnext', + plotText: 'next', + }); + }, + preparePlannerRecallHandoff() { + order.push('recall-handoff'); + }, + }; + + const result = applyPlannerResultAndSend({ + textarea, + button, + rawUserInput: 'raw input', + filtered: 'next', + plannerRecall: null, + plannerPlotRecord: { plotText: 'next' }, + runtime, + plannerState, + }); + + assert.deepEqual(order, ['plot-handoff', 'click']); + assert.equal(result.applied, true); + assert.equal(result.plotHandoffPrepared, true); + assert.equal(result.handoffPrepared, false); + assert.equal(textarea.value, 'raw input\n\nnext'); + assert.equal(plannerState.lastInjectedText, textarea.value); + assert.equal(plannerState.bypassNextSend, true); +} + +{ + const cases = [ + [{ enabled: false, hasTextarea: true, textareaValue: 'go' }, false, 'disabled'], + [{ enabled: true, isPlanning: true, hasTextarea: true, textareaValue: 'go' }, false, 'planning'], + [{ enabled: true, hasTextarea: false, textareaValue: 'go' }, false, 'missing-textarea'], + [{ enabled: true, hasTextarea: true, textareaValue: ' ' }, false, 'empty-input'], + [{ enabled: true, hasTextarea: true, textareaValue: 'go', isTrivial: true }, false, 'trivial'], + [{ enabled: true, hasTextarea: true, textareaValue: 'go', bypassNextSend: true }, false, 'bypass'], + [{ enabled: true, hasTextarea: true, textareaValue: 'done', skipIfPlotPresent: true }, false, 'plot-present'], + [{ enabled: true, hasTextarea: true, textareaValue: 'not a plot tag', skipIfPlotPresent: true }, true, 'ok'], + [{ enabled: true, hasTextarea: true, textareaValue: 'done', skipIfPlotPresent: false }, true, 'ok'], + [{ enabled: true, hasTextarea: true, textareaValue: 'continue the scene' }, true, 'ok'], + ]; + for (const [input, expectedShouldIntercept, expectedReason] of cases) { + const result = shouldInterceptPlannerSend(input); + assert.equal(result.shouldIntercept, expectedShouldIntercept, expectedReason); + assert.equal(result.reason, expectedReason); + } +} + { const chat = [ { is_user: true, mes: 'raw old', extra: {} }, @@ -85,14 +146,16 @@ import { ]; writeStructuredPlotRecordToMessage(chat[2], createStructuredPlotRecord({ rawUserInput: 'raw latest', - plannerAugmentedMessage: 'raw latest\n\nstructured', - plotText: 'structured', + plannerAugmentedMessage: 'raw latest\n\nprivate\nstructured\nhidden', + plotText: 'private\nstructured\nhidden', })); const history = readPlannerPlotHistory(chat, { count: 2 }); - assert.equal(history.source, 'structured'); - assert.deepEqual(history.plots, ['structured']); + assert.equal(history.source, 'structured+legacy'); + assert.deepEqual(history.plots, ['structured', 'legacy stale']); assert.ok(history.block.includes('structured')); - assert.ok(!history.block.includes('legacy stale')); + assert.ok(history.block.includes('legacy stale')); + assert.ok(!history.block.includes('private')); + assert.ok(!history.block.includes('hidden')); } { @@ -122,6 +185,27 @@ import { assert.equal(chat[2].extra.st_bme_plot, undefined); } +{ + const runtime = createRerollRecallInput({ + getCurrentChatId: () => 'chat-a', + normalizeChatIdCandidate: (value) => String(value || '').trim(), + normalizeRecallInputText: (value) => String(value || '').trim(), + hashRecallInput: (value) => `hash:${String(value || '').length}`, + }); + const handoff = runtime.preparePlannerPlotRecordHandoff({ + chatId: 'chat-a', + rawUserInput: 'raw input', + plannerAugmentedMessage: 'raw input\n\nnext', + plotText: 'next', + }); + assert.ok(handoff?.id?.includes(':plot:')); + assert.equal(handoff.plotText, 'next'); + assert.equal(runtime.peekPlannerRecallHandoff('chat-a'), null); + assert.equal(runtime.peekPlannerPlotRecordHandoff('chat-a')?.plotText, 'next'); + assert.equal(runtime.consumePlannerPlotRecordHandoff('chat-a')?.plotText, 'next'); + assert.equal(runtime.peekPlannerPlotRecordHandoff('chat-a'), null); +} + { const order = []; const result = applyPlannerResultAndSend({ From 0d7b43506bf591d62ebd030d87950e88ddd6b17f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 9 Jun 2026 16:52:29 +0000 Subject: [PATCH 07/10] 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 668016e..95f1401 100644 --- a/manifest.json +++ b/manifest.json @@ -6,6 +6,6 @@ "js": "index.js", "css": "style.css", "author": "Youzini", - "version": "7.6.6", + "version": "7.6.7", "homePage": "https://github.com/Youzini-afk/ST-Bionic-Memory-Ecology" } From 7df1bd8cf52102b7b60e41b6e63069f4bd688f4b Mon Sep 17 00:00:00 2001 From: youzini Date: Tue, 9 Jun 2026 17:03:48 +0000 Subject: [PATCH 08/10] refactor(recall): extract persisted reuse helpers --- retrieval/recall-controller.js | 130 +++++++++++++++------------- tests/recall-controller-helpers.mjs | 89 +++++++++++++++++++ 2 files changed, 161 insertions(+), 58 deletions(-) create mode 100644 tests/recall-controller-helpers.mjs diff --git a/retrieval/recall-controller.js b/retrieval/recall-controller.js index 3018c87..e1fc4b1 100644 --- a/retrieval/recall-controller.js +++ b/retrieval/recall-controller.js @@ -91,40 +91,81 @@ function buildPersistedRecallReuseResult(record = {}) { }; } -function resolveReusablePersistedRecallRecord(chat, recallInput, runtime) { - const generationType = String(recallInput?.generationType || "normal").trim() || "normal"; +export function normalizeRecallGenerationType(value = "normal") { + return String(value || "normal").trim() || "normal"; +} - let targetUserMessageIndex = Number.isFinite(recallInput?.targetUserMessageIndex) - ? Math.floor(Number(recallInput.targetUserMessageIndex)) - : null; +export function normalizeRecallTargetUserMessageIndex(value) { + return Number.isFinite(value) ? Math.floor(Number(value)) : null; +} - const readPersistedRecallFromUserMessage = runtime.readPersistedRecallFromUserMessage; - if (typeof readPersistedRecallFromUserMessage !== "function") return null; +export function normalizeRecallTextForRuntime(runtime, value = "") { + return typeof runtime?.normalizeRecallInputText === "function" + ? runtime.normalizeRecallInputText(value) + : String(value ?? "") + .replace(/\r\n/g, "\n") + .trim(); +} - const normalizeText = (value = "") => - typeof runtime.normalizeRecallInputText === "function" - ? runtime.normalizeRecallInputText(value) - : String(value ?? "") - .replace(/\r\n/g, "\n") - .trim(); - const currentRecallInputText = normalizeText(recallInput?.userMessage || ""); - const recallSource = String(recallInput?.source || "").trim(); - const activeInputSources = new Set([ +export function isActiveRecallInputSource(source = "") { + return new Set([ "send-intent", "generation-started-send-intent", "generation-started-textarea", "host-generation-lifecycle", "textarea-live", "planner-handoff", - ]); - const isActiveInputSource = activeInputSources.has(recallSource); - const noNewUserGenerationTypes = new Set([ - "swipe", - "regenerate", - "continue", - "history", - ]); - const isNoNewUserGeneration = noNewUserGenerationTypes.has(generationType); + ]).has(String(source || "").trim()); +} + +export function isNoNewUserGenerationType(generationType = "normal") { + return new Set(["swipe", "regenerate", "continue", "history"]).has( + normalizeRecallGenerationType(generationType), + ); +} + +export function isTrustedUserFloorRecallSource(source = "") { + return new Set([ + "chat-last-user", + "chat-latest-user", + "chat-tail-user", + "message-sent", + "persisted-user-floor", + ]).has(String(source || "").trim()); +} + +export function buildPersistedReuseRecallInput(recallInput = {}, record = {}, runtime) { + const boundUserFloorText = normalizeRecallTextForRuntime( + runtime, + record.boundUserFloorText || recallInput.boundUserFloorText || "", + ); + return { + ...recallInput, + source: "persisted-user-floor", + sourceLabel: "复用用户楼层召回", + reason: "persisted-user-floor-reuse", + authoritativeInputUsed: Boolean( + record.authoritativeInputUsed || recallInput.authoritativeInputUsed, + ), + boundUserFloorText, + }; +} + +function resolveReusablePersistedRecallRecord(chat, recallInput, runtime) { + const generationType = normalizeRecallGenerationType(recallInput?.generationType); + + let targetUserMessageIndex = normalizeRecallTargetUserMessageIndex( + recallInput?.targetUserMessageIndex, + ); + + const readPersistedRecallFromUserMessage = runtime.readPersistedRecallFromUserMessage; + if (typeof readPersistedRecallFromUserMessage !== "function") return null; + + const normalizeText = (value = "") => normalizeRecallTextForRuntime(runtime, value); + const currentRecallInputText = normalizeText(recallInput?.userMessage || ""); + const recallSource = String(recallInput?.source || "").trim(); + const isActiveInputSource = isActiveRecallInputSource(recallSource); + const isNoNewUserGeneration = isNoNewUserGenerationType(generationType); if (isActiveInputSource && !isNoNewUserGeneration) { return null; } @@ -213,18 +254,11 @@ function resolveReusablePersistedRecallRecord(chat, recallInput, runtime) { !isActiveInputSource && String(record?.injectionText || "").trim(), ); - const userFloorSources = new Set([ - "chat-last-user", - "chat-latest-user", - "chat-tail-user", - "message-sent", - "persisted-user-floor", - ]); const canTrustUserFloorRecord = Boolean( (!isActiveInputSource || isNoNewUserGeneration) && !boundUserFloorText && !recordRecallInputMismatch && - (generationType !== "normal" || userFloorSources.has(recallSource)), + (generationType !== "normal" || isTrustedUserFloorRecallSource(recallSource)), ); if ( @@ -618,31 +652,11 @@ export async function runRecallController(runtime, options = {}) { runtime, ); if (earlyPersistedReuse) { - const normalizedBoundUserFloorText = - typeof runtime.normalizeRecallInputText === "function" - ? runtime.normalizeRecallInputText( - earlyPersistedReuse.record.boundUserFloorText || - recallInput.boundUserFloorText || - "", - ) - : String( - earlyPersistedReuse.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( - earlyPersistedReuse.record.authoritativeInputUsed || - recallInput.authoritativeInputUsed, - ), - boundUserFloorText: normalizedBoundUserFloorText, - }; + const effectiveRecallInput = buildPersistedReuseRecallInput( + recallInput, + earlyPersistedReuse.record, + runtime, + ); const reusedResult = buildPersistedRecallReuseResult(earlyPersistedReuse.record); const applied = runtime.applyRecallInjection( settings, diff --git a/tests/recall-controller-helpers.mjs b/tests/recall-controller-helpers.mjs new file mode 100644 index 0000000..d56521c --- /dev/null +++ b/tests/recall-controller-helpers.mjs @@ -0,0 +1,89 @@ +import assert from 'node:assert/strict'; + +import { + buildPersistedReuseRecallInput, + isActiveRecallInputSource, + isNoNewUserGenerationType, + isTrustedUserFloorRecallSource, + normalizeRecallGenerationType, + normalizeRecallTargetUserMessageIndex, + normalizeRecallTextForRuntime, +} from '../retrieval/recall-controller.js'; + +assert.equal(normalizeRecallGenerationType(' regenerate '), 'regenerate'); +assert.equal(normalizeRecallGenerationType(''), 'normal'); +assert.equal(normalizeRecallGenerationType(null), 'normal'); + +assert.equal(normalizeRecallTargetUserMessageIndex(3.9), 3); +assert.equal(normalizeRecallTargetUserMessageIndex(Number.NaN), null); +assert.equal(normalizeRecallTargetUserMessageIndex('3'), null); + +assert.equal(normalizeRecallTextForRuntime(null, ' a\r\nb '), 'a\nb'); +assert.equal( + normalizeRecallTextForRuntime({ normalizeRecallInputText: (value) => `x:${String(value).trim()}` }, ' a '), + 'x:a', +); + +for (const source of [ + 'send-intent', + 'generation-started-send-intent', + 'generation-started-textarea', + 'host-generation-lifecycle', + 'textarea-live', + 'planner-handoff', +]) { + assert.equal(isActiveRecallInputSource(source), true, source); +} +assert.equal(isActiveRecallInputSource('chat-last-user'), false); + +for (const generationType of ['swipe', 'regenerate', 'continue', 'history']) { + assert.equal(isNoNewUserGenerationType(generationType), true, generationType); +} +assert.equal(isNoNewUserGenerationType('normal'), false); + +for (const source of [ + 'chat-last-user', + 'chat-latest-user', + 'chat-tail-user', + 'message-sent', + 'persisted-user-floor', +]) { + assert.equal(isTrustedUserFloorRecallSource(source), true, source); +} +assert.equal(isTrustedUserFloorRecallSource('textarea-live'), false); + +{ + const recallInput = { + source: 'chat-last-user', + sourceLabel: '历史最后用户楼层', + reason: 'chat-tail-fallback', + authoritativeInputUsed: false, + boundUserFloorText: ' fallback floor ', + deliveryMode: 'deferred', + }; + const record = { + authoritativeInputUsed: true, + boundUserFloorText: ' persisted floor ', + }; + const result = buildPersistedReuseRecallInput(recallInput, record, { + normalizeRecallInputText: (value) => String(value || '').trim().toUpperCase(), + }); + assert.equal(result.source, 'persisted-user-floor'); + assert.equal(result.sourceLabel, '复用用户楼层召回'); + assert.equal(result.reason, 'persisted-user-floor-reuse'); + assert.equal(result.authoritativeInputUsed, true); + assert.equal(result.boundUserFloorText, 'PERSISTED FLOOR'); + assert.equal(result.deliveryMode, 'deferred'); +} + +{ + const result = buildPersistedReuseRecallInput( + { authoritativeInputUsed: true, boundUserFloorText: 'input\r\ntext' }, + {}, + null, + ); + assert.equal(result.authoritativeInputUsed, true); + assert.equal(result.boundUserFloorText, 'input\ntext'); +} + +console.log('recall-controller-helpers tests passed'); From 8e7817a396c9947ca93ac67e8d5751206e8ab8af Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 9 Jun 2026 17:04:13 +0000 Subject: [PATCH 09/10] 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 95f1401..25a897f 100644 --- a/manifest.json +++ b/manifest.json @@ -6,6 +6,6 @@ "js": "index.js", "css": "style.css", "author": "Youzini", - "version": "7.6.7", + "version": "7.6.8", "homePage": "https://github.com/Youzini-afk/ST-Bionic-Memory-Ecology" } From c0019eba43fb700c555ffc289a0c0b5b40244841 Mon Sep 17 00:00:00 2001 From: youzini Date: Tue, 9 Jun 2026 17:07:39 +0000 Subject: [PATCH 10/10] docs(planner): document plot history boundaries --- docs/algorithms/retrieval.md | 4 ++++ docs/architecture/control-plane.md | 5 ++++- docs/contributing/testing.md | 3 ++- docs/features/ena-planner.md | 31 ++++++++++++++++++++++++++++-- 4 files changed, 39 insertions(+), 4 deletions(-) diff --git a/docs/algorithms/retrieval.md b/docs/algorithms/retrieval.md index 2a85f12..6e0161e 100644 --- a/docs/algorithms/retrieval.md +++ b/docs/algorithms/retrieval.md @@ -28,11 +28,15 @@ 召回输入按优先级解析(`resolveRecallInputController`):override → 待发送意图(send intent)→ 聊天尾部用户楼层 → 已发送用户 → 最新用户楼层。 +控制器里的来源/类型判定保持为小型纯 helper:active input source、no-new-user generation type、可信 user-floor source、持久复用输入构造分别独立测试。它们只做字符串规范化和布尔判定,不调用 `retrieve()`、不写消息、也不触碰生成事务。 + **持久召回复用有两条路径:** 1. **no-new-user 主路径**(`reapplyPersistedRecallBlock`):reroll / swipe / regenerate / continue 由宿主 `type` 判定为 no-new-user 后,`GENERATION_AFTER_COMMANDS` 不计算召回;`GENERATE_BEFORE_COMBINE_PROMPTS` 直接读取父 user 楼层的 `message.extra.bme_recall`,校验绑定文本未过期后确定性重放注入块。命中后不会进入 transaction / `runRecall` / 新检索。 2. **compute fallback 内部复用**(`resolveReusablePersistedRecallRecord`):当主路径没有可用记录(例如无记录或陈旧)而落回 `runRecallController()` 时,如果当前输入匹配某条已持久化的用户楼层召回记录,可在控制器内复用已存注入内容,跳过新检索,返回 `llm.status="persisted"`。 +内部复用命中后,控制器只重写本次 effective recall input 的来源为 `persisted-user-floor`,并保留原 delivery mode / hook / source candidates 等上下文字段;真正注入、generation count bump、metadata save 仍由原路径执行。 + fresh `normal` 发送仍走正常输入选择与召回计算路径;no-new-user 的父楼层绑定来自宿主生成上下文,而不是根据 textarea / send-intent 等输入源猜测(见 [`../architecture/control-plane.md`](../architecture/control-plane.md) 的 reroll 不变量)。 ## 5. 向量预筛 diff --git a/docs/architecture/control-plane.md b/docs/architecture/control-plane.md index 715e05f..92462e9 100644 --- a/docs/architecture/control-plane.md +++ b/docs/architecture/control-plane.md @@ -79,7 +79,8 @@ - `vector/vector-gate.js` — 向量准备/修复前置门禁,决定 skip / repair / blocked / sync。 - `runtime/generation-context.js` — 记录宿主本轮生成的 `type`(`normal` / `swipe` / `regenerate` / `continue` 等),并解析本轮应绑定的父 user 楼层。 -- `runtime/reroll-recall-input.js` — 基于代际上下文构造召回输入;不再用一次性 marker 猜测 reroll。 +- `runtime/reroll-recall-input.js` — 基于代际上下文构造召回输入,并保存 planner recall handoff / plot record handoff;不再用一次性 marker 猜测 reroll。 +- `retrieval/recall-controller.js` — 召回控制器;来源/类型/持久复用输入构造是纯 helper,检索执行和注入副作用仍留在控制器热路径里。 **reroll 不变量:** @@ -96,6 +97,8 @@ no-new-user 的稳定路径分两段: 旧的召回事务机制仍保留为 fresh normal 和 fallback compute 的基础设施;它不再是 reroll 已存召回注入的唯一门闸。 +ENA Planner 另有一条 plot record handoff:它只负责把 planner 产出的剧情推进记录绑定到新 user 楼层的 `message.extra.st_bme_plot`,不参与召回决策。这样剧情历史持久化不依赖 planner recall 是否成功。 + ## 副本一致性模型 Authority 场景下有三处存储,它们**不是平级的版本副本**: diff --git a/docs/contributing/testing.md b/docs/contributing/testing.md index 150c4d1..2d5d0ae 100644 --- a/docs/contributing/testing.md +++ b/docs/contributing/testing.md @@ -9,7 +9,7 @@ ST-BME 的测试是 Node 回归测试(`tests/*.mjs`),`npm run test:stable` | 控制平面 | `identity-resolver` / `persistence-reducer` | 身份解析、持久化状态机不变量 | | 数据格式 | `graph-snapshot-schema` / `graph-snapshot-upgrade` / `snapshot-forward-compat` | 快照契约、宽容解析、向前兼容往返 | | 持久化 | `graph-persistence` / `indexeddb-*` | 图谱持久化、IndexedDB 快照/增量/hydrate | -| 检索/召回 | `p0-regressions` 内相关、`trivial-user-input` | 召回、reroll 复用、注入 | +| 检索/召回 | `p0-regressions` 内相关、`recall-controller-helpers`、`recall-reroll-reuse`、`trivial-user-input` | 召回来源判定、reroll 复用、注入 | | 向量 | `vector-gate` / `vector-connection-probe` / `vector-sync-coalescer` | 向量门禁、连接探测、后台同步合并 | | Native | `native-layout-parity` / `native-rollout-matrix` | native/JS 一致性、灰度门控 | | 防线 | `index-slicing-ratchet` / `runtime-deps-completeness` / `i18n-user-visible-ratchet` | 见下 | @@ -53,6 +53,7 @@ ST-BME 的测试是 Node 回归测试(`tests/*.mjs`),`npm run test:stable` ## 重要测试文件 - **`tests/p0-regressions.mjs`** — 主回归集合,覆盖提取、召回、恢复、UI 关键路径。 +- **`tests/recall-controller-helpers.mjs`** — 召回控制器的纯来源/类型/持久复用输入 helper。 - **`tests/runtime-history.mjs`** — 消息 hash、历史 dirty、恢复状态。 - **`tests/message-render-limit.mjs`** — 聊天区渲染限制和渲染切片历史保护。 - **`tests/graph-persistence.mjs`** — 图谱持久化基础行为。 diff --git a/docs/features/ena-planner.md b/docs/features/ena-planner.md index 086bf3b..9a23fab 100644 --- a/docs/features/ena-planner.md +++ b/docs/features/ena-planner.md @@ -20,7 +20,7 @@ ENA Planner 是一个**可选的、发送前剧情规划**子系统。它独立 ``` 拦截发送(点击发送/回车) → 构建规划消息(buildPlannerMessages) - → 收集上下文:角色卡 + BME 记忆召回 + 近期 AI 对话 + 历史 + 世界书 + 用户输入 + → 收集上下文:角色卡 + BME 记忆召回 + 近期 AI 对话 + 结构化/旧式 plot 历史 + 世界书 + 用户输入 → 渲染模板/宏(EJS、ST 宏) → 组装提示词块(优先用 planner 任务预设,回退遗留块) → 调用规划师 LLM(callPlanner,可流式) @@ -34,10 +34,11 @@ ENA Planner 是一个**可选的、发送前剧情规划**子系统。它独立 ## 与 ST-BME 的集成 -ENA Planner 集成的是**召回**,不是提取: +ENA Planner 集成的是**召回**和**剧情历史记录**,不是提取: - 它调用 BME 召回获取记忆块作为规划上下文(`runPlannerRecallForEna`)。 - 规划输出注入用户文本后,主生成会把规划标签当作用户消息的一部分看到。 +- 规划输出会以结构化记录写入用户楼层 `message.extra.st_bme_plot`;后续规划优先读取这个记录,读不到时再回退扫描历史文本中的 ``。 - 它**不**直接运行提取,也**不**把规划结果写进记忆图谱。后续提取走正常聊天/提取路径。 ### 召回交接(handoff) @@ -48,6 +49,32 @@ ENA Planner 集成的是**召回**,不是提取: 这套机制的实现见 `runtime/planner-recall-controller.js`、`runtime/reroll-recall-input.js`、`runtime/generation-recall-transactions.js`。 +### 结构化剧情历史 + +历史 plot 不再只依赖“从聊天文本里扫描 ``”。Planner 发送时会准备一条独立的 plot record handoff;`MESSAGE_SENT` 绑定到新 user 楼层后写入: + +```js +message.extra.st_bme_plot = { + version: 1, + rawUserInput, + plannerAugmentedMessage, + plotText, + plotBlocks, + inputHash, + createdAt, + recallHandoffId, + taskResults: [] +} +``` + +读取顺序: + +1. 优先读取 `message.extra.st_bme_plot` 中的结构化 `` 块。 +2. 若结构化记录不足 `plotCount`,用历史消息文本里的旧式 `` 补足。 +3. 只把 `` 内容喂回 planner;`` / `` 等标签不会因为结构化记录而混入历史 plot 区。 + +plot record handoff 和 recall handoff 是两条独立通道:即使 planner 召回失败或被禁用,只要 planner 产出了 ``,剧情历史仍可持久化。这避免了“剧情推进记录依赖召回成功”的隐式耦合。 + ## 规划召回 vs 正常召回 | 维度 | 规划召回 | 正常召回 |