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