diff --git a/src/core/persistence.js b/src/core/persistence.js index 1ad8e65..18f5719 100644 --- a/src/core/persistence.js +++ b/src/core/persistence.js @@ -224,6 +224,26 @@ export function saveChatData() { saveChatDebounced(); } +/** + * Mirrors a tracker data entry into message.swipe_info so it survives page reloads. + * ST only serializes swipe_info to disk; message.extra is in-memory only. + * Guard: skips silently if swipe_info[swipeId] doesn't exist yet + * + * @param {Object} message - The chat message object + * @param {number} swipeId - The swipe index to mirror into + * @param {Object} swipeEntry - { userStats, infoBox, characterThoughts } + */ +export function mirrorToSwipeInfo(message, swipeId, swipeEntry) { + if (!message.swipe_info || !message.swipe_info[swipeId]) return; + if (!message.swipe_info[swipeId].extra) { + message.swipe_info[swipeId].extra = {}; + } + if (!message.swipe_info[swipeId].extra.rpg_companion_swipes) { + message.swipe_info[swipeId].extra.rpg_companion_swipes = {}; + } + message.swipe_info[swipeId].extra.rpg_companion_swipes[swipeId] = swipeEntry; +} + /** * Updates the last assistant message's swipe data with current tracker data. * This ensures user edits are preserved across swipes and included in generation context. @@ -255,15 +275,7 @@ export function updateMessageSwipeData() { message.extra.rpg_companion_swipes[swipeId] = swipeEntry; // Mirror to swipe_info so data survives page reloads regardless of active swipe - if (message.swipe_info && message.swipe_info[swipeId]) { - if (!message.swipe_info[swipeId].extra) { - message.swipe_info[swipeId].extra = {}; - } - if (!message.swipe_info[swipeId].extra.rpg_companion_swipes) { - message.swipe_info[swipeId].extra.rpg_companion_swipes = {}; - } - message.swipe_info[swipeId].extra.rpg_companion_swipes[swipeId] = swipeEntry; - } + mirrorToSwipeInfo(message, swipeId, swipeEntry); // console.log('[RPG Companion] Updated message swipe data after user edit'); break; @@ -292,6 +304,43 @@ export function getSwipeData(message, swipeId) { return null; } +/** + * Commits tracker data from the assistant message immediately before currentMessageIndex. + * Walks backward through the chat skipping the current message, user messages, and system + * messages until it finds the prior assistant message, then loads its active swipe data. + * If no prior assistant message exists or exists without a tracker state, nulls out all fields so + * the AI generates from an empty context rather than a ghost state. + * + * @param {number} currentMessageIndex - Index of the message to start searching before + */ +export function commitTrackerDataFromPriorMessage(currentMessageIndex) { + const chat = getContext().chat; + if (!chat || chat.length === 0) { + committedTrackerData.userStats = null; + committedTrackerData.infoBox = null; + committedTrackerData.characterThoughts = null; + return; + } + + for (let i = currentMessageIndex - 1; i >= 0; i--) { + const message = chat[i]; + if (message.is_user || message.is_system) continue; + + // Found the prior assistant message — commit its active swipe data + const swipeId = message.swipe_id || 0; + const swipeData = getSwipeData(message, swipeId); + committedTrackerData.userStats = swipeData?.userStats || null; + committedTrackerData.infoBox = swipeData?.infoBox || null; + committedTrackerData.characterThoughts = swipeData?.characterThoughts || null; + return; + } + + // No prior assistant message found — use empty context + committedTrackerData.userStats = null; + committedTrackerData.infoBox = null; + committedTrackerData.characterThoughts = null; +} + /** * Loads RPG data from the current chat's metadata. * Automatically migrates v1 inventory to v2 format if needed. diff --git a/src/systems/generation/apiClient.js b/src/systems/generation/apiClient.js index 4c38f68..5f48715 100644 --- a/src/systems/generation/apiClient.js +++ b/src/systems/generation/apiClient.js @@ -20,7 +20,7 @@ import { setLastActionWasSwipe, $musicPlayerContainer } from '../../core/state.js'; -import { saveChatData } from '../../core/persistence.js'; +import { saveChatData, mirrorToSwipeInfo } from '../../core/persistence.js'; import { generateSeparateUpdatePrompt } from './promptBuilder.js'; @@ -317,11 +317,15 @@ export async function updateRPGData(renderUserStats, renderInfoBox, renderThough } const currentSwipeId = lastMessage.swipe_id || 0; - lastMessage.extra.rpg_companion_swipes[currentSwipeId] = { + const swipeEntry = { userStats: parsedData.userStats, infoBox: parsedData.infoBox, characterThoughts: parsedData.characterThoughts }; + lastMessage.extra.rpg_companion_swipes[currentSwipeId] = swipeEntry; + + // Mirror to swipe_info so this swipe survives page reload even if never manually edited + mirrorToSwipeInfo(lastMessage, currentSwipeId, swipeEntry); // console.log('[RPG Companion] Stored separate mode RPG data for message swipe', currentSwipeId); } diff --git a/src/systems/generation/injector.js b/src/systems/generation/injector.js index c920db9..0e7cfcf 100644 --- a/src/systems/generation/injector.js +++ b/src/systems/generation/injector.js @@ -30,6 +30,7 @@ import { SPOTIFY_FORMAT_INSTRUCTION } from './promptBuilder.js'; import { restoreCheckpointOnLoad } from '../features/chapterCheckpoint.js'; +import { commitTrackerDataFromPriorMessage } from '../../core/persistence.js'; // Track suppression state for event handler let currentSuppressionState = false; @@ -622,25 +623,15 @@ export async function onGenerationStarted(type, data, dryRun) { const shouldCommit = isUserMessage && !lastActionWasSwipe && currentChatLength !== lastCommittedChatLength; if (shouldCommit) { - // console.log('[RPG Companion] 📝 TOGETHER MODE COMMIT: User sent message - committing data from BEFORE user message'); + // console.log('[RPG Companion] 📝 TOGETHER MODE COMMIT: User sent message - committing from N-1 assistant message'); // console.log('[RPG Companion] Chat length:', currentChatLength, 'Last committed:', lastCommittedChatLength); - // console.log('[RPG Companion] BEFORE: committedTrackerData =', { - // userStats: committedTrackerData.userStats ? `${committedTrackerData.userStats.substring(0, 50)}...` : 'null', - // infoBox: committedTrackerData.infoBox ? 'exists' : 'null', - // characterThoughts: committedTrackerData.characterThoughts ? `${committedTrackerData.characterThoughts.substring(0, 100)}...` : 'null' - // // }); - // console.log('[RPG Companion] BEFORE: lastGeneratedData =', { - // userStats: lastGeneratedData.userStats ? `${lastGeneratedData.userStats.substring(0, 50)}...` : 'null', - // infoBox: lastGeneratedData.infoBox ? 'exists' : 'null', - // characterThoughts: lastGeneratedData.characterThoughts ? `${lastGeneratedData.characterThoughts.substring(0, 100)}...` : 'null' - // }); - // Commit displayed data (from before user sent message) - committedTrackerData.userStats = lastGeneratedData.userStats; - committedTrackerData.infoBox = lastGeneratedData.infoBox; - committedTrackerData.characterThoughts = lastGeneratedData.characterThoughts; + // Commit from the prior assistant message's swipe store (N-1 rule). + // currentChatLength - 1 is the new AI placeholder; the function walks backward + // past it and the user message to find the previous AI message's tracker state. + commitTrackerDataFromPriorMessage(currentChatLength - 1); - // Track chat length to prevent duplicate commits + // Track chat length to prevent duplicate commits from streaming lastCommittedChatLength = currentChatLength; // console.log('[RPG Companion] AFTER: committedTrackerData =', { @@ -668,38 +659,14 @@ export async function onGenerationStarted(type, data, dryRun) { // console.log('[RPG Companion DEBUG] Before generating:', lastGeneratedData.characterThoughts, ' , committed - ', committedTrackerData.characterThoughts); if ((extensionSettings.generationMode === 'separate' || extensionSettings.generationMode === 'external') && !isGenerating) { if (!lastActionWasSwipe) { - // User sent a new message - commit lastGeneratedData before generation - // console.log('[RPG Companion] 📝 COMMIT: New message - committing lastGeneratedData'); - // console.log('[RPG Companion] BEFORE commit - committedTrackerData:', { - // userStats: committedTrackerData.userStats ? 'exists' : 'null', - // infoBox: committedTrackerData.infoBox ? 'exists' : 'null', - // characterThoughts: committedTrackerData.characterThoughts ? 'exists' : 'null' - // // }); - // console.log('[RPG Companion] BEFORE commit - lastGeneratedData:', { - // userStats: lastGeneratedData.userStats ? 'exists' : 'null', - // infoBox: lastGeneratedData.infoBox ? 'exists' : 'null', - // characterThoughts: lastGeneratedData.characterThoughts ? 'exists' : 'null' - // }); - committedTrackerData.userStats = lastGeneratedData.userStats; - committedTrackerData.infoBox = lastGeneratedData.infoBox; - committedTrackerData.characterThoughts = lastGeneratedData.characterThoughts; - // console.log('[RPG Companion] AFTER commit - committedTrackerData:', { - // userStats: committedTrackerData.userStats ? 'exists' : 'null', - // infoBox: committedTrackerData.infoBox ? 'exists' : 'null', - // characterThoughts: committedTrackerData.characterThoughts ? 'exists' : 'null' - // }); - - // Reset flag after committing (ready for next cycle) - - } else { - // console.log('[RPG Companion] 🔄 SWIPE: Using existing committedTrackerData (no commit)'); - // console.log('[RPG Companion] committedTrackerData:', { - // userStats: committedTrackerData.userStats ? 'exists' : 'null', - // infoBox: committedTrackerData.infoBox ? 'exists' : 'null', - // characterThoughts: committedTrackerData.characterThoughts ? 'exists' : 'null' - // }); - // Reset flag after using it (swipe generation complete, ready for next action) + // User sent a new message - commit from the prior assistant message's swipe store + // (N-1 rule) rather than lastGeneratedData, which may reflect a sibling swipe's + // outcome and would poison the context for the new generation. + // currentChatLength - 1 is the new AI placeholder; search starts before it. + commitTrackerDataFromPriorMessage(currentChatLength - 1); } + // If lastActionWasSwipe, context was already committed by commitTrackerDataFromPriorMessage + // in onMessageSwiped before generation started. } // Use the committed tracker data as source for generation diff --git a/src/systems/integration/sillytavern.js b/src/systems/integration/sillytavern.js index 0e121a4..9757701 100644 --- a/src/systems/integration/sillytavern.js +++ b/src/systems/integration/sillytavern.js @@ -22,7 +22,7 @@ import { updateCommittedTrackerData, $musicPlayerContainer } from '../../core/state.js'; -import { saveChatData, loadChatData, autoSwitchPresetForEntity, getSwipeData } from '../../core/persistence.js'; +import { saveChatData, loadChatData, autoSwitchPresetForEntity, getSwipeData, commitTrackerDataFromPriorMessage, mirrorToSwipeInfo } from '../../core/persistence.js'; import { i18n } from '../../core/i18n.js'; // Generation & Parsing @@ -118,15 +118,12 @@ export function onMessageSent() { // The RPG data comes embedded in the main response // FAB spinning is handled by apiClient.js for separate/external modes when updateRPGData() is called - // For separate mode with auto-update disabled, commit displayed tracker + // For separate mode with auto-update disabled, commit from the prior assistant message's + // swipe store rather than lastGeneratedData to avoid ghost context from sibling swipes. + // At this point chat[chat.length - 1] is the user message, so search from before it. if (extensionSettings.generationMode === 'separate' && !extensionSettings.autoUpdate) { - if (lastGeneratedData.userStats || lastGeneratedData.infoBox || lastGeneratedData.characterThoughts) { - committedTrackerData.userStats = lastGeneratedData.userStats; - committedTrackerData.infoBox = lastGeneratedData.infoBox; - committedTrackerData.characterThoughts = lastGeneratedData.characterThoughts; - - // console.log('[RPG Companion] 💾 SEPARATE MODE: Committed displayed tracker (auto-update disabled)'); - } + commitTrackerDataFromPriorMessage(chat.length - 1); + // console.log('[RPG Companion] 💾 SEPARATE MODE: Committed from prior assistant message (auto-update disabled)'); } } @@ -192,11 +189,15 @@ export async function onMessageReceived(data) { } const currentSwipeId = lastMessage.swipe_id || 0; - lastMessage.extra.rpg_companion_swipes[currentSwipeId] = { + const swipeEntry = { userStats: parsedData.userStats, infoBox: parsedData.infoBox, characterThoughts: parsedData.characterThoughts }; + lastMessage.extra.rpg_companion_swipes[currentSwipeId] = swipeEntry; + + // Mirror to swipe_info so this swipe survives page reload even if never manually edited + mirrorToSwipeInfo(lastMessage, currentSwipeId, swipeEntry); // console.log('[RPG Companion] Stored RPG data for swipe', currentSwipeId); @@ -377,6 +378,9 @@ export function onMessageSwiped(messageIndex) { // This is a NEW swipe that will trigger generation setLastActionWasSwipe(true); setIsAwaitingNewMessage(true); + // Immediately commit context from the prior assistant message (N-1) so generation + // uses the world state before this message, not the last-viewed sibling swipe. + commitTrackerDataFromPriorMessage(messageIndex); // console.log('[RPG Companion] 🔵 NEW swipe detected - Set lastActionWasSwipe = true'); } else { // This is navigating to an EXISTING swipe - don't change the flag