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({