Compare commits

...

5 Commits

Author SHA1 Message Date
Spicy_Marinara f3deead868 v3.4.1: Fix Present Characters not included in <previous> section for separate generation mode
- Fixed bug where Present Characters data wasn't appearing in the <previous> section when generating new trackers in separate mode
- Root cause: committedTrackerData.characterThoughts is stored as a JS array, not a JSON string
- Solution: Check data type before parsing - handle both object/array and string formats
- Present Characters data now correctly included in unified previous tracker JSON regardless of showCharacterThoughts setting
2026-01-11 00:17:49 +01:00
Spicy_Marinara d5d649f122 Update promptBuilder.js 2026-01-10 21:21:25 +01:00
Spicy Marinara 0cd764c39b Merge pull request #90 from tomt610/feature/history-persistence
Feature/history persistence
2026-01-10 20:35:03 +01:00
tomt610 b9a15722d6 Fix history injection for prewarm extensions
- Use persistent event listeners instead of once() to inject into ALL generations
- Don't clear context map on GENERATION_ENDED so prewarm gets the same context
- Remove unused onGenerationEndedCleanup function
2026-01-10 19:33:26 +00:00
tomt610 db97f012b0 Refactor history injection to modify prompts instead of chat messages
This prevents any risk of injected context being accidentally saved to the chat.
Instead of modifying chat[].mes directly, we now:
1. Build a context map during GENERATION_STARTED
2. Inject into the prompt string (GENERATE_AFTER_COMBINE_PROMPTS) for text completion
3. Inject into the message array (CHAT_COMPLETION_PROMPT_READY) for chat completion

The original chat messages are never modified.
2026-01-10 19:10:33 +00:00
5 changed files with 194 additions and 64 deletions
+11 -1
View File
@@ -152,7 +152,8 @@ import {
onMessageSwiped,
updatePersonaAvatar,
clearExtensionPrompts,
onGenerationEnded
onGenerationEnded,
initHistoryInjection
} from './src/systems/integration/sillytavern.js';
// Old state variable declarations removed - now imported from core modules
@@ -1125,6 +1126,15 @@ jQuery(async () => {
// Non-critical - continue anyway
}
// Initialize history injection event listeners
// This must be done before event registration so listeners are ready
try {
initHistoryInjection();
} catch (error) {
console.error('[RPG Companion] History injection init failed:', error);
// Non-critical - continue without it
}
// Register all event listeners
try {
registerAllEvents({
+1 -1
View File
@@ -6,6 +6,6 @@
"js": "index.js",
"css": "style.css",
"author": "Marinara",
"version": "3.3.2",
"version": "3.4.1",
"homePage": "https://github.com/SpicyMarinara/rpg-companion-sillytavern"
}
+147 -50
View File
@@ -39,9 +39,8 @@ let currentSuppressionState = false;
// Track last chat length we committed at to prevent duplicate commits from streaming
let lastCommittedChatLength = -1;
// Store original message content for restoration after generation
// Map of message index -> original mes content
let originalMessageContent = new Map();
// Store context map for prompt injection (used by event handlers)
let pendingContextMap = new Map();
/**
* Builds a map of historical context data from ST chat messages with rpg_companion_swipes data.
@@ -164,81 +163,179 @@ function buildHistoricalContextMap() {
}
/**
* Injects historical context into chat messages by modifying them in-place.
* Stores original content for restoration after generation.
* This approach works for ALL API types (text completion and chat completion).
* Prepares historical context for injection into prompts.
* This builds the context map and stores it for use by prompt event handlers.
* Does NOT modify the original chat messages.
*/
function injectHistoricalContextIntoChat() {
function prepareHistoricalContextInjection() {
const historyPersistence = extensionSettings.historyPersistence;
if (!historyPersistence || !historyPersistence.enabled) {
// console.log('[RPG Companion] History persistence not enabled, skipping injection');
pendingContextMap = new Map();
return;
}
if (currentSuppressionState || !extensionSettings.enabled) {
// console.log('[RPG Companion] Skipping history injection: suppressed or disabled');
pendingContextMap = new Map();
return;
}
const context = getContext();
const chat = context.chat;
if (!chat || chat.length < 2) {
// console.log('[RPG Companion] Chat too short, skipping history injection');
pendingContextMap = new Map();
return;
}
// Build the context map
const contextMap = buildHistoricalContextMap();
if (contextMap.size === 0) {
// console.log('[RPG Companion] No historical context to inject');
return;
// Build and store the context map for use by prompt handlers
pendingContextMap = buildHistoricalContextMap();
}
/**
* Injects historical context into a text completion prompt string.
* Searches for message content in the prompt and appends context after matches.
*
* @param {string} prompt - The text completion prompt
* @returns {string} - The modified prompt with injected context
*/
function injectContextIntoTextPrompt(prompt) {
if (pendingContextMap.size === 0) {
return prompt;
}
// console.log(`[RPG Companion] Injecting historical context into ${contextMap.size} messages`);
// Clear any previous stored content
originalMessageContent.clear();
const context = getContext();
const chat = context.chat;
let modifiedPrompt = prompt;
let injectedCount = 0;
for (const [msgIdx, ctxContent] of contextMap) {
// Process each message that needs context injection
for (const [msgIdx, ctxContent] of pendingContextMap) {
const message = chat[msgIdx];
if (!message || typeof message.mes !== 'string') {
continue;
}
// Store original content for restoration
originalMessageContent.set(msgIdx, message.mes);
// Find the message content in the prompt
// Use a portion of the message to find it (last 100 chars should be unique enough)
const searchContent = message.mes.length > 100
? message.mes.slice(-100)
: message.mes;
const searchIndex = modifiedPrompt.lastIndexOf(searchContent);
if (searchIndex === -1) {
// Message not found in prompt (might be truncated)
continue;
}
// Modify the message in-place
message.mes = message.mes + ctxContent;
// Find the end of this message content in the prompt
const insertPosition = searchIndex + searchContent.length;
// Insert the context after the message
modifiedPrompt = modifiedPrompt.slice(0, insertPosition) + ctxContent + modifiedPrompt.slice(insertPosition);
injectedCount++;
// console.log(`[RPG Companion] Injected context into message ${msgIdx}`);
}
// console.log(`[RPG Companion] Successfully injected historical context into ${injectedCount} messages`);
if (injectedCount > 0) {
console.log(`[RPG Companion] Injected historical context into ${injectedCount} positions in text prompt`);
}
return modifiedPrompt;
}
/**
* Restores original message content after generation completes.
* This ensures the injected context doesn't persist in the actual chat data.
* Injects historical context into a chat completion message array.
* Modifies the content of messages in the array directly.
*
* @param {Array} chatMessages - The chat completion message array
* @returns {Array} - The modified message array with injected context
*/
function restoreOriginalMessageContent() {
if (originalMessageContent.size === 0) {
return;
function injectContextIntoChatPrompt(chatMessages) {
if (pendingContextMap.size === 0 || !Array.isArray(chatMessages)) {
return chatMessages;
}
const context = getContext();
const chat = context.chat;
let injectedCount = 0;
// console.log(`[RPG Companion] Restoring ${originalMessageContent.size} messages to original content`);
// Process each message that needs context injection
for (const [msgIdx, ctxContent] of pendingContextMap) {
const originalMessage = chat[msgIdx];
if (!originalMessage || typeof originalMessage.mes !== 'string') {
continue;
}
for (const [msgIdx, originalContent] of originalMessageContent) {
if (chat[msgIdx]) {
chat[msgIdx].mes = originalContent;
// Find this message in the chat completion array by matching content
// Use a portion of the message to find it
const searchContent = originalMessage.mes.length > 100
? originalMessage.mes.slice(-100)
: originalMessage.mes;
for (const promptMsg of chatMessages) {
if (promptMsg.content && typeof promptMsg.content === 'string' &&
promptMsg.content.includes(searchContent)) {
// Found the message - append context
promptMsg.content = promptMsg.content + ctxContent;
injectedCount++;
break;
}
}
}
originalMessageContent.clear();
if (injectedCount > 0) {
console.log(`[RPG Companion] Injected historical context into ${injectedCount} messages in chat prompt`);
}
return chatMessages;
}
/**
* Event handler for GENERATE_AFTER_COMBINE_PROMPTS (text completion).
* Injects historical context into the prompt string.
*
* @param {Object} eventData - Event data with prompt property
*/
function onGenerateAfterCombinePrompts(eventData) {
if (!eventData || typeof eventData.prompt !== 'string') {
return;
}
if (eventData.dryRun) {
return;
}
// Only inject if we have pending context
if (pendingContextMap.size === 0) {
return;
}
eventData.prompt = injectContextIntoTextPrompt(eventData.prompt);
// DON'T clear pendingContextMap here - let it persist for other generations
// (e.g., prewarm extensions). It will be cleared on GENERATION_ENDED.
}
/**
* Event handler for CHAT_COMPLETION_PROMPT_READY.
* Injects historical context into the chat message array.
*
* @param {Object} eventData - Event data with chat property
*/
function onChatCompletionPromptReady(eventData) {
if (!eventData || !Array.isArray(eventData.chat)) {
return;
}
if (eventData.dryRun) {
return;
}
// Only inject if we have pending context
if (pendingContextMap.size === 0) {
return;
}
eventData.chat = injectContextIntoChatPrompt(eventData.chat);
// DON'T clear pendingContextMap here - let it persist for other generations
// (e.g., prewarm extensions). It will be cleared on GENERATION_ENDED.
}
/**
@@ -636,22 +733,22 @@ Ensure these details naturally reflect and influence the narrative. Character be
// Set suppression state for the historical context injection
currentSuppressionState = shouldSuppress;
// Inject historical context directly into chat messages
// This temporarily modifies messages and will be restored after generation
injectHistoricalContextIntoChat();
// Register a one-time listener to restore messages after prompt is built
// Using .once() so it auto-removes after firing
eventSource.once(event_types.GENERATE_AFTER_COMBINE_PROMPTS, () => {
restoreOriginalMessageContent();
});
// Prepare historical context for injection into prompts
// This builds the context map but does NOT modify original chat messages
// The persistent event listeners will inject it into all prompts until cleared
prepareHistoricalContextInjection();
}
/**
* Called when generation ends to restore original message content.
* This should be called from the GENERATION_ENDED event handler.
* Initialize the history injection event listeners.
* These are persistent listeners that inject context into ALL generations
* while pendingContextMap has data. Should be called once at extension init.
*/
export function onGenerationEndedCleanup() {
restoreOriginalMessageContent();
export function initHistoryInjectionListeners() {
// Register persistent listeners for prompt injection
// These check pendingContextMap and only inject if there's data
eventSource.on(event_types.GENERATE_AFTER_COMBINE_PROMPTS, onGenerateAfterCombinePrompts);
eventSource.on(event_types.CHAT_COMPLETION_PROMPT_READY, onChatCompletionPromptReady);
console.log('[RPG Companion] History injection listeners initialized');
}
+26 -8
View File
@@ -31,7 +31,7 @@ export const DEFAULT_DIALOGUE_COLORING_PROMPT = `Wrap all character/NPC "dialogu
/**
* Default Deception System prompt text
*/
export const DEFAULT_DECEPTION_PROMPT = `When a character is lying or deceiving, you should follow up that line with the <lie> tag, containing a brief description of the truth and the lie's reason, using the template below (replace placeholders in brackets). This will be hidden from the user's view, but not to you, making it useful for future consequences: <lie>[Character] is [lying/deceiving/omitting], the truth is [truth]. Reason: [reason].</lie>`;
export const DEFAULT_DECEPTION_PROMPT = `When a character is lying or deceiving, you should follow up that line with the <lie> tag, containing a brief description of the truth and the lie's reason, using the template below (replace placeholders in quotation marks). This will be hidden from the user's view, but not to you, making it useful for future consequences: <lie character="name" type="lying/deceiving/omitting" truth="truth" reason="reason"/>.`;
/**
* Default CYOA prompt text
@@ -1055,16 +1055,34 @@ export function generateRPGPromptText() {
}
}
if (extensionSettings.showCharacterThoughts && committedTrackerData.characterThoughts) {
// Include Present Characters data if it exists, regardless of current showCharacterThoughts setting
// This ensures existing character data is preserved in context even if the setting is toggled off
if (committedTrackerData.characterThoughts) {
try {
// Try to parse as JSON - apply locks before adding to previous
const lockedData = applyLocks(committedTrackerData.characterThoughts, 'characters');
const parsed = JSON.parse(lockedData);
unifiedPrevious.characters = parsed;
} catch {
let parsed;
// Check if it's already a JavaScript object/array (not a JSON string)
if (typeof committedTrackerData.characterThoughts === 'object') {
// Already parsed - apply locks and use directly
parsed = applyLocks(committedTrackerData.characterThoughts, 'characters');
} else {
// It's a JSON string - apply locks and parse
const lockedData = applyLocks(committedTrackerData.characterThoughts, 'characters');
parsed = JSON.parse(lockedData);
}
// Only include if there's actual character data (non-empty array or object with content)
if (parsed && ((Array.isArray(parsed) && parsed.length > 0) ||
(parsed.characters && Array.isArray(parsed.characters) && parsed.characters.length > 0))) {
unifiedPrevious.characters = parsed;
}
} catch (e) {
// console.warn('[RPG Companion] Failed to process characters for previous section:', e);
// Old text format - show it separately for backward compat
if (!unifiedPrevious.userStats && !unifiedPrevious.infoBox) {
promptText += `${committedTrackerData.characterThoughts}\n`;
const charText = typeof committedTrackerData.characterThoughts === 'string'
? committedTrackerData.characterThoughts
: JSON.stringify(committedTrackerData.characterThoughts, null, 2);
promptText += `${charText}\n`;
}
}
}
+9 -4
View File
@@ -30,7 +30,7 @@ import { parseResponse, parseUserStats } from '../generation/parser.js';
import { parseAndStoreSpotifyUrl, convertToEmbedUrl } from '../features/musicPlayer.js';
import { updateRPGData } from '../generation/apiClient.js';
import { removeLocks } from '../generation/lockManager.js';
import { onGenerationStarted, onGenerationEndedCleanup } from '../generation/injector.js';
import { onGenerationStarted, initHistoryInjectionListeners } from '../generation/injector.js';
// Rendering
import { renderUserStats } from '../rendering/userStats.js';
@@ -467,9 +467,6 @@ export function clearExtensionPrompts() {
export async function onGenerationEnded() {
// console.log('[RPG Companion] 🏁 onGenerationEnded called');
// Restore original message content that was modified for historical context injection
onGenerationEndedCleanup();
// Note: isGenerating flag is cleared in onMessageReceived after parsing (together mode)
// or in apiClient.js after separate generation completes (separate mode)
@@ -477,3 +474,11 @@ export async function onGenerationEnded() {
// Re-apply checkpoint if one exists
await restoreCheckpointOnLoad();
}
/**
* Initialize history injection event listeners.
* Should be called once during extension initialization.
*/
export function initHistoryInjection() {
initHistoryInjectionListeners();
}