refactor(planner): lock send and plot history boundaries

This commit is contained in:
youzini
2026-06-09 16:52:12 +00:00
parent a7d9486d0e
commit 566901bf95
6 changed files with 266 additions and 36 deletions

View File

@@ -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 && /<plot\b/i.test(text)) {
return { shouldIntercept: false, reason: 'plot-present' };
}
return { shouldIntercept: true, reason: 'ok' };
}

View File

@@ -3,6 +3,7 @@ import { getRequestHeaders, saveSettingsDebounced, substituteParamsExtended } fr
import { EnaPlannerStorage, migrateFromLWBIfNeeded } from './ena-planner-storage.js';
import {
applyPlannerResultAndSend,
shouldInterceptPlannerSend,
} from './ena-planner-runtime-utils.js';
import { readPlannerPlotHistory } from './planner-plot-history.js';
import { DEFAULT_PROMPT_BLOCKS, BUILTIN_TEMPLATES } from './ena-planner-presets.js';
@@ -1880,15 +1881,17 @@ function isTrivialPlannerInput(text) {
function shouldInterceptNow() {
const s = ensureSettings();
if (!s.enabled || state.isPlanning) return false;
const ta = getSendTextarea();
if (!ta) return false;
const txt = String(ta.value ?? '').trim();
if (!txt) return false;
if (isTrivialPlannerInput(txt)) return false;
if (state.bypassNextSend) return false;
if (s.skipIfPlotPresent && /<plot\b/i.test(txt)) return false;
return true;
const txt = String(ta?.value ?? '').trim();
return shouldInterceptPlannerSend({
enabled: Boolean(s.enabled),
isPlanning: Boolean(state.isPlanning),
hasTextarea: Boolean(ta),
textareaValue: txt,
isTrivial: Boolean(txt && isTrivialPlannerInput(txt)),
bypassNextSend: Boolean(state.bypassNextSend),
skipIfPlotPresent: Boolean(s.skipIfPlotPresent),
}).shouldIntercept;
}
async function doInterceptAndPlanThenSend() {

View File

@@ -80,21 +80,43 @@ export function collectStructuredPlotRecords(chat, count = 2) {
}
export function readPlannerPlotHistory(chat, { count = 2 } = {}) {
const want = Math.max(0, Number(count) || 0);
if (!want) {
return { source: 'empty', records: [], plots: [], block: '' };
}
const structuredRecords = collectStructuredPlotRecords(chat, count);
const seen = new Set();
const plots = [];
let usedLegacy = false;
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),
};
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),
};

View File

@@ -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,
});

View File

@@ -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,

View File

@@ -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\n<plot>next</plot>',
plotText: '<plot>next</plot>',
});
},
preparePlannerRecallHandoff() {
order.push('recall-handoff');
},
};
const result = applyPlannerResultAndSend({
textarea,
button,
rawUserInput: 'raw input',
filtered: '<plot>next</plot>',
plannerRecall: null,
plannerPlotRecord: { plotText: '<plot>next</plot>' },
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\n<plot>next</plot>');
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: '<plot>done</plot>', skipIfPlotPresent: true }, false, 'plot-present'],
[{ enabled: true, hasTextarea: true, textareaValue: '<plotter>not a plot tag</plotter>', skipIfPlotPresent: true }, true, 'ok'],
[{ enabled: true, hasTextarea: true, textareaValue: '<plot id="x">done</plot>', 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\n<plot>structured</plot>',
plotText: '<plot>structured</plot>',
plannerAugmentedMessage: 'raw latest\n\n<note>private</note>\n<plot>structured</plot>\n<state>hidden</state>',
plotText: '<note>private</note>\n<plot>structured</plot>\n<state>hidden</state>',
}));
const history = readPlannerPlotHistory(chat, { count: 2 });
assert.equal(history.source, 'structured');
assert.deepEqual(history.plots, ['<plot>structured</plot>']);
assert.equal(history.source, 'structured+legacy');
assert.deepEqual(history.plots, ['<plot>structured</plot>', '<plot>legacy stale</plot>']);
assert.ok(history.block.includes('<plot>structured</plot>'));
assert.ok(!history.block.includes('legacy stale'));
assert.ok(history.block.includes('legacy stale'));
assert.ok(!history.block.includes('<note>private</note>'));
assert.ok(!history.block.includes('<state>hidden</state>'));
}
{
@@ -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\n<plot>next</plot>',
plotText: '<plot>next</plot>',
});
assert.ok(handoff?.id?.includes(':plot:'));
assert.equal(handoff.plotText, '<plot>next</plot>');
assert.equal(runtime.peekPlannerRecallHandoff('chat-a'), null);
assert.equal(runtime.peekPlannerPlotRecordHandoff('chat-a')?.plotText, '<plot>next</plot>');
assert.equal(runtime.consumePlannerPlotRecordHandoff('chat-a')?.plotText, '<plot>next</plot>');
assert.equal(runtime.peekPlannerPlotRecordHandoff('chat-a'), null);
}
{
const order = [];
const result = applyPlannerResultAndSend({