feat: json format, et al.

This commit is contained in:
Subarashimo
2025-12-03 14:55:30 +01:00
parent 56349f30e6
commit 0f7fdfcef1
28 changed files with 5692 additions and 237 deletions
+34 -13
View File
@@ -16,7 +16,7 @@ import {
} from '../../core/state.js';
import { saveChatData } from '../../core/persistence.js';
import { generateSeparateUpdatePrompt } from './promptBuilder.js';
import { parseResponse, parseUserStats } from './parser.js';
import { parseResponse, parseUserStats, parseSkills, tryParseJSONResponse } from './parser.js';
import { renderUserStats } from '../rendering/userStats.js';
import { renderInfoBox } from '../rendering/infoBox.js';
import { renderThoughts } from '../rendering/thoughts.js';
@@ -133,18 +133,35 @@ export async function updateRPGData(renderUserStats, renderInfoBox, renderThough
if (response) {
// console.log('[RPG Companion] Raw AI response:', response);
const parsedData = parseResponse(response);
// console.log('[RPG Companion] Parsed data:', parsedData);
// console.log('[RPG Companion] parsedData.userStats:', parsedData.userStats ? parsedData.userStats.substring(0, 100) + '...' : 'null');
// Try JSON parsing first if structured data mode is enabled
const jsonParsed = tryParseJSONResponse(response);
if (jsonParsed) {
// JSON parsing succeeded - render all sections
console.log('[RPG Companion] JSON parsing successful');
renderUserStats();
renderInfoBox();
renderThoughts();
renderInventory();
renderQuests();
if (typeof renderSkills === 'function') renderSkills();
saveChatData();
} else {
// JSON parsing failed - try legacy text-based parsing as fallback
console.warn('[RPG Companion] JSON parsing failed, attempting legacy text parsing...');
const parsedData = parseResponse(response);
// console.log('[RPG Companion] Parsed data:', parsedData);
// console.log('[RPG Companion] parsedData.userStats:', parsedData.userStats ? parsedData.userStats.substring(0, 100) + '...' : 'null');
// DON'T update lastGeneratedData here - it should only reflect the data
// from the assistant message the user replied to, not auto-generated updates
// This ensures swipes/regenerations use consistent source data
// DON'T update lastGeneratedData here - it should only reflect the data
// from the assistant message the user replied to, not auto-generated updates
// This ensures swipes/regenerations use consistent source data
// Store RPG data for the last assistant message (separate mode)
const lastMessage = chat && chat.length > 0 ? chat[chat.length - 1] : null;
// console.log('[RPG Companion] Last message is_user:', lastMessage ? lastMessage.is_user : 'no message');
if (lastMessage && !lastMessage.is_user) {
// Store RPG data for the last assistant message (separate mode)
const lastMessage = chat && chat.length > 0 ? chat[chat.length - 1] : null;
// console.log('[RPG Companion] Last message is_user:', lastMessage ? lastMessage.is_user : 'no message');
if (lastMessage && !lastMessage.is_user) {
if (!lastMessage.extra) {
lastMessage.extra = {};
}
@@ -166,6 +183,9 @@ export async function updateRPGData(renderUserStats, renderInfoBox, renderThough
lastGeneratedData.userStats = parsedData.userStats;
parseUserStats(parsedData.userStats);
}
if (parsedData.skills) {
parseSkills(parsedData.skills);
}
if (parsedData.infoBox) {
lastGeneratedData.infoBox = parsedData.infoBox;
}
@@ -212,8 +232,9 @@ export async function updateRPGData(renderUserStats, renderInfoBox, renderThough
renderQuests();
}
// Save to chat metadata
saveChatData();
// Save to chat metadata
saveChatData();
}
}
} catch (error) {
+13 -4
View File
@@ -16,12 +16,21 @@ import {
import { evaluateSuppression } from './suppression.js';
import { parseUserStats } from './parser.js';
import {
generateTrackerExample,
generateTrackerInstructions,
generateJSONTrackerInstructions,
generateContextualSummary,
DEFAULT_HTML_PROMPT
} from './promptBuilder.js';
/**
* Gets tracker instructions (always uses JSON format)
* @param {boolean} includeHtmlPrompt
* @param {boolean} includeContinuation
* @returns {string}
*/
function getTrackerInstructions(includeHtmlPrompt, includeContinuation) {
return generateJSONTrackerInstructions(includeHtmlPrompt, includeContinuation);
}
/**
* Event handler for generation start.
* Manages tracker data commitment and prompt injection based on generation mode.
@@ -167,9 +176,9 @@ export function onGenerationStarted(type, data) {
if (extensionSettings.generationMode === 'together') {
// console.log('[RPG Companion] In together mode, generating prompts...');
const example = generateTrackerExample();
const example = ''; // JSON format includes schema in instructions, no separate example needed
// Don't include HTML prompt in instructions - inject it separately to avoid duplication on swipes
const instructions = generateTrackerInstructions(false, true);
const instructions = getTrackerInstructions(false, true);
// Clear separate mode context injection - we don't use contextual summary in together mode
setExtensionPrompt('rpg-companion-context', '', extension_prompt_types.IN_CHAT, 1, false);
+413 -4
View File
@@ -1,11 +1,14 @@
/**
* Parser Module
* Handles parsing of AI responses to extract tracker data
* Supports both legacy text format and new JSON format
*/
import { extensionSettings, FEATURE_FLAGS, addDebugLog } from '../../core/state.js';
import { saveSettings } from '../../core/persistence.js';
import { saveSettings, saveChatData } from '../../core/persistence.js';
import { extractInventory } from './inventoryParser.js';
import { validateTrackerData, mergeTrackerData } from '../../types/trackerData.js';
import { handleItemRemoved } from '../rendering/skills.js';
/**
* Helper to separate emoji from text in a string
@@ -133,19 +136,296 @@ function debugLog(message, data = null) {
}
}
/**
* Extracts JSON from a code block (handles ```json ... ``` format)
* @param {string} text - Text that may contain JSON code blocks
* @returns {Object|null} Parsed JSON object or null
*/
function extractJSONFromCodeBlock(text) {
if (!text) return null;
// Match ```json ... ``` or ``` ... ``` blocks
const jsonBlockRegex = /```(?:json)?\s*([\s\S]*?)```/gi;
const matches = [...text.matchAll(jsonBlockRegex)];
for (const match of matches) {
const content = match[1].trim();
// Check if content looks like JSON (starts with { or [)
if (content.startsWith('{') || content.startsWith('[')) {
try {
return JSON.parse(content);
} catch (e) {
debugLog('[RPG Parser] JSON parse failed:', e.message);
// Try to fix common JSON issues
const fixed = tryFixJSON(content);
if (fixed) return fixed;
}
}
}
return null;
}
/**
* Attempts to fix common JSON formatting issues
* @param {string} jsonStr - Potentially malformed JSON string
* @returns {Object|null} Fixed JSON object or null
*/
function tryFixJSON(jsonStr) {
try {
// Remove trailing commas
let fixed = jsonStr.replace(/,(\s*[}\]])/g, '$1');
// Fix unquoted keys
fixed = fixed.replace(/([{,]\s*)(\w+)(\s*:)/g, '$1"$2"$3');
return JSON.parse(fixed);
} catch (e) {
return null;
}
}
/**
* Parses JSON tracker data and applies it to extension settings
* @param {Object} jsonData - Parsed JSON tracker data
* @returns {boolean} Whether parsing was successful
*/
export function parseJSONTrackerData(jsonData) {
debugLog('[RPG Parser] ==================== JSON PARSING ====================');
const validation = validateTrackerData(jsonData);
if (!validation.valid) {
debugLog('[RPG Parser] JSON validation failed:', validation.errors);
return false;
}
const trackerConfig = extensionSettings.trackerConfig;
// Parse stats
if (jsonData.stats) {
debugLog('[RPG Parser] Parsing stats:', Object.keys(jsonData.stats));
const customStats = trackerConfig?.userStats?.customStats || [];
for (const [statName, value] of Object.entries(jsonData.stats)) {
// Find matching stat in config
const statConfig = customStats.find(s =>
s.name.toLowerCase() === statName.toLowerCase()
);
if (statConfig && typeof value === 'number') {
// Store in userStats using the stat id
extensionSettings.userStats[statConfig.id] = Math.max(0, Math.min(100, value));
debugLog(`[RPG Parser] Stat ${statConfig.name}: ${value}%`);
}
}
}
// Parse status
if (jsonData.status) {
if (jsonData.status.mood) {
extensionSettings.userStats.mood = jsonData.status.mood;
debugLog('[RPG Parser] Mood:', jsonData.status.mood);
}
if (jsonData.status.fields) {
// Store custom status fields
extensionSettings.userStats.conditions = Object.values(jsonData.status.fields).join(', ') || 'None';
debugLog('[RPG Parser] Status fields:', jsonData.status.fields);
}
}
// Parse infoBox - normalize values and filter out null
if (jsonData.infoBox) {
const infoBox = {};
// Only copy non-null values
for (const [key, val] of Object.entries(jsonData.infoBox)) {
if (val !== null && val !== undefined && val !== 'null') {
infoBox[key] = val;
}
}
// Normalize recentEvents - LLM sometimes returns string instead of array
if (infoBox.recentEvents && typeof infoBox.recentEvents === 'string') {
infoBox.recentEvents = [infoBox.recentEvents];
} else if (!infoBox.recentEvents || !Array.isArray(infoBox.recentEvents)) {
infoBox.recentEvents = [];
}
// Filter out null/empty events from array
infoBox.recentEvents = infoBox.recentEvents.filter(e => e && e !== 'null');
extensionSettings.infoBoxData = infoBox;
debugLog('[RPG Parser] InfoBox:', Object.keys(infoBox));
}
// Parse characters - store for UI rendering
if (jsonData.characters && Array.isArray(jsonData.characters)) {
extensionSettings.charactersData = jsonData.characters;
debugLog('[RPG Parser] Characters:', jsonData.characters.length);
}
// Parse inventory (structured format)
// Handle LLM variations: empty objects {} should become empty arrays []
if (jsonData.inventory) {
const normalizeArray = (val) => {
if (Array.isArray(val)) return val;
if (val && typeof val === 'object' && Object.keys(val).length === 0) return [];
return [];
};
// Get all item names from current inventory BEFORE updating
const getItemNamesFromInventory = (inv) => {
const names = new Set();
if (!inv) return names;
// From onPerson array
if (Array.isArray(inv.onPerson)) {
inv.onPerson.forEach(item => {
const name = typeof item === 'string' ? item : item?.name;
if (name) names.add(name.toLowerCase());
});
}
// From stored locations
if (inv.stored && typeof inv.stored === 'object') {
Object.values(inv.stored).forEach(items => {
if (Array.isArray(items)) {
items.forEach(item => {
const name = typeof item === 'string' ? item : item?.name;
if (name) names.add(name.toLowerCase());
});
}
});
}
// From assets
if (Array.isArray(inv.assets)) {
inv.assets.forEach(item => {
const name = typeof item === 'string' ? item : item?.name;
if (name) names.add(name.toLowerCase());
});
}
return names;
};
const previousItemNames = getItemNamesFromInventory(extensionSettings.inventoryV3);
extensionSettings.inventoryV3 = {
onPerson: normalizeArray(jsonData.inventory.onPerson || jsonData.inventory.items),
stored: jsonData.inventory.stored && typeof jsonData.inventory.stored === 'object'
? jsonData.inventory.stored : {},
assets: normalizeArray(jsonData.inventory.assets)
};
debugLog('[RPG Parser] Inventory parsed - onPerson:', extensionSettings.inventoryV3.onPerson.length);
// Detect removed items and handle skill links
const newItemNames = getItemNamesFromInventory(extensionSettings.inventoryV3);
previousItemNames.forEach(itemName => {
if (!newItemNames.has(itemName)) {
debugLog('[RPG Parser] Item removed by LLM:', itemName);
// Handle item removal - will unlink or delete skills based on settings
handleItemRemoved(itemName);
}
});
// Also update legacy inventory for backwards compatibility
const itemsToString = (items) => {
if (!items || items.length === 0) return 'None';
return items.map(i => i.name).join(', ');
};
extensionSettings.userStats.inventory = {
version: 2,
onPerson: itemsToString(extensionSettings.inventoryV3.onPerson),
stored: Object.fromEntries(
Object.entries(extensionSettings.inventoryV3.stored).map(([k, v]) => [k, itemsToString(v)])
),
assets: itemsToString(extensionSettings.inventoryV3.assets)
};
}
// Parse skills (structured format) - handle array/object/string variations
if (jsonData.skills && typeof jsonData.skills === 'object') {
// Normalize skills - each category should be an array
const normalizedSkills = {};
for (const [category, abilities] of Object.entries(jsonData.skills)) {
if (Array.isArray(abilities)) {
normalizedSkills[category] = abilities;
} else if (typeof abilities === 'string') {
// LLM returned string instead of array - split by comma
normalizedSkills[category] = abilities.split(',').map(a => ({ name: a.trim(), description: '' })).filter(a => a.name);
} else {
normalizedSkills[category] = [];
}
}
extensionSettings.skillsV2 = normalizedSkills;
debugLog('[RPG Parser] Skills parsed:', Object.keys(normalizedSkills));
// Update legacy skills data for backwards compatibility
for (const [category, abilities] of Object.entries(normalizedSkills)) {
if (!extensionSettings.skillsData) extensionSettings.skillsData = {};
const names = abilities.map(a => typeof a === 'string' ? a : (a?.name || '')).filter(n => n);
extensionSettings.skillsData[category] = names.join(', ') || 'None';
}
}
// Parse quests - handle different formats
if (jsonData.quests) {
// Normalize main quest - could be string, object with name, or null
let mainName = 'None';
let mainDesc = '';
if (jsonData.quests.main) {
if (typeof jsonData.quests.main === 'string') {
mainName = jsonData.quests.main;
} else if (jsonData.quests.main.name) {
mainName = jsonData.quests.main.name;
mainDesc = jsonData.quests.main.description || '';
}
}
// Normalize optional quests - could be array of strings or objects
const optionalQuests = Array.isArray(jsonData.quests.optional) ? jsonData.quests.optional : [];
const optionalNames = optionalQuests.map(q => typeof q === 'string' ? q : (q?.name || '')).filter(n => n);
const optionalDescs = optionalQuests.map(q => typeof q === 'string' ? '' : (q?.description || ''));
extensionSettings.quests = {
main: mainName,
mainDescription: mainDesc,
optional: optionalNames,
optionalDescriptions: optionalDescs
};
// Store structured quests too
extensionSettings.questsV2 = jsonData.quests;
debugLog('[RPG Parser] Quests - main:', mainName);
}
saveSettings();
saveChatData();
debugLog('[RPG Parser] JSON parsing complete');
debugLog('[RPG Parser] =======================================================');
return true;
}
/**
* Main entry point for parsing responses - tries JSON first, falls back to text
* @param {string} responseText - The raw AI response
* @returns {boolean} Whether JSON parsing was successful
*/
export function tryParseJSONResponse(responseText) {
const jsonData = extractJSONFromCodeBlock(responseText);
if (jsonData) {
return parseJSONTrackerData(jsonData);
}
debugLog('[RPG Parser] No valid JSON found, falling back to text parsing');
return false;
}
/**
* Parses the model response to extract the different data sections.
* Extracts tracker data from markdown code blocks in the AI response.
* Handles both separate code blocks and combined code blocks gracefully.
*
* @param {string} responseText - The raw AI response text
* @returns {{userStats: string|null, infoBox: string|null, characterThoughts: string|null}} Parsed tracker data
* @returns {{userStats: string|null, infoBox: string|null, characterThoughts: string|null, skills: string|null}} Parsed tracker data
*/
export function parseResponse(responseText) {
const result = {
userStats: null,
infoBox: null,
characterThoughts: null
characterThoughts: null,
skills: null
};
// DEBUG: Log full response for troubleshooting
@@ -213,7 +493,12 @@ export function parseResponse(responseText) {
(content.match(/Health:\s*\d+%/i) && content.match(/Energy:\s*\d+%/i)) ||
// Fallback: inventory-only or quests-only blocks (no stats header)
(content.match(/^(On Person:|Inventory:|Main Quests?:|Optional Quests:)/im) &&
!content.match(/Info Box/i) && !content.match(/Present Characters/i));
!content.match(/Info Box/i) && !content.match(/Present Characters/i) && !content.match(/Skills\s*\n\s*---/i));
// Match Skills section (separate from stats when showSkills is enabled)
const isSkills =
content.match(/Skills\s*\n\s*---/i) &&
!content.match(/Stats\s*\n\s*---/i); // Make sure it's not a combined block
// Match Info Box section - flexible patterns
const isInfoBox =
@@ -234,6 +519,9 @@ export function parseResponse(responseText) {
if (isStats && !result.userStats) {
result.userStats = stripBrackets(content);
debugLog('[RPG Parser] ✓ Matched: Stats section');
} else if (isSkills && !result.skills) {
result.skills = stripBrackets(content);
debugLog('[RPG Parser] ✓ Matched: Skills section');
} else if (isInfoBox && !result.infoBox) {
result.infoBox = stripBrackets(content);
debugLog('[RPG Parser] ✓ Matched: Info Box section');
@@ -249,12 +537,14 @@ export function parseResponse(responseText) {
debugLog('[RPG Parser] - Has info keywords?', !!(content.match(/Date:/i) && content.match(/Location:/i)));
debugLog('[RPG Parser] - Has "Present Characters\\n---"?', !!content.match(/Present Characters\s*\n\s*---/i));
debugLog('[RPG Parser] - Has new format ("- Name" + "Details:")?', !!(content.match(/^-\s+\w+/m) && content.match(/Details:/i)));
debugLog('[RPG Parser] - Has "Skills\\n---"?', !!content.match(/Skills\s*\n\s*---/i));
}
}
}
debugLog('[RPG Parser] ==================== PARSE RESULTS ====================');
debugLog('[RPG Parser] Found Stats:', !!result.userStats);
debugLog('[RPG Parser] Found Skills:', !!result.skills);
debugLog('[RPG Parser] Found Info Box:', !!result.infoBox);
debugLog('[RPG Parser] Found Characters:', !!result.characterThoughts);
debugLog('[RPG Parser] =======================================================');
@@ -425,6 +715,125 @@ export function parseUserStats(statsText) {
}
}
/**
* Parses skills from the separate skills code block.
* Used when showSkills is enabled (skills appear in their own section).
* Format expected:
* Skills
* ---
* Combat: Sword Fighting, Shield Block, Parry
* Stealth: Lockpicking (via Lockpicks), Sneaking, Pickpocketing
* Magic: None
*
* Each skill category contains a comma-separated list of abilities.
* Abilities can be linked to items using "(via ItemName)" format.
*
* @param {string} skillsText - The raw skills text from AI response
*/
export function parseSkills(skillsText) {
debugLog('[RPG Parser] ==================== PARSING SKILLS ====================');
debugLog('[RPG Parser] Skills text length:', skillsText?.length + ' chars');
if (!skillsText || typeof skillsText !== 'string') {
debugLog('[RPG Parser] No skills text to parse');
return;
}
try {
// Initialize data structures if needed
if (!extensionSettings.skillsData) {
extensionSettings.skillsData = {};
}
if (!extensionSettings.skillAbilityLinks) {
extensionSettings.skillAbilityLinks = {};
}
// Get configured skill categories
const configuredCategories = extensionSettings.trackerConfig?.userStats?.skillsSection?.customFields || [];
const lines = skillsText.split('\n');
const newSkillAbilityLinks = {};
for (const line of lines) {
// Skip header lines and notes
if (line.match(/^Skills\s*$/i) || line.match(/^---/) || !line.trim() || line.match(/^\(Note:/i)) {
continue;
}
// Parse skill category line: "CategoryName: ability1, ability2 (via Item), ability3"
const categoryMatch = line.match(/^(.+?):\s*(.*)$/);
if (categoryMatch) {
const categoryName = categoryMatch[1].trim();
const abilitiesText = categoryMatch[2].trim();
// Check if this is a configured category (case-insensitive match)
const matchedCategory = configuredCategories.find(c =>
c.toLowerCase() === categoryName.toLowerCase()
);
if (!matchedCategory) {
debugLog(`[RPG Parser] Skipping unknown skill category: ${categoryName}`);
continue;
}
// Parse abilities (comma-separated)
if (!abilitiesText || abilitiesText.toLowerCase() === 'none') {
extensionSettings.skillsData[matchedCategory] = 'None';
debugLog(`[RPG Parser] Skill category ${matchedCategory}: None`);
continue;
}
// Split by comma and parse each ability
const abilities = [];
const rawAbilities = abilitiesText.split(',').map(a => a.trim()).filter(a => a);
for (const rawAbility of rawAbilities) {
// Check for "(ItemName)" pattern - ability granted by item
// Supports both "AbilityName (ItemName)" and legacy "AbilityName (via ItemName)"
const itemMatch = rawAbility.match(/^(.+?)\s*\((?:via\s+)?(.+?)\)$/i);
if (itemMatch) {
const abilityName = itemMatch[1].trim();
const itemName = itemMatch[2].trim();
abilities.push(abilityName);
// Store the link
if (extensionSettings.enableItemSkillLinks) {
const linkKey = `${matchedCategory}::${abilityName}`;
newSkillAbilityLinks[linkKey] = itemName;
debugLog(`[RPG Parser] Linked: ${abilityName} <- ${itemName}`);
}
} else {
abilities.push(rawAbility);
}
}
// Store abilities for this category
const abilitiesString = abilities.length > 0 ? abilities.join(', ') : 'None';
extensionSettings.skillsData[matchedCategory] = abilitiesString;
debugLog(`[RPG Parser] Skill category ${matchedCategory}: ${abilitiesString}`);
}
}
// Update skill-ability links if item linking is enabled
if (extensionSettings.enableItemSkillLinks && Object.keys(newSkillAbilityLinks).length > 0) {
// Merge new links with existing ones
extensionSettings.skillAbilityLinks = {
...extensionSettings.skillAbilityLinks,
...newSkillAbilityLinks
};
debugLog('[RPG Parser] Skill-ability links updated:', Object.keys(newSkillAbilityLinks).length + ' new links');
}
saveSettings();
debugLog('[RPG Parser] Skills saved successfully');
debugLog('[RPG Parser] =======================================================');
} catch (error) {
console.error('[RPG Companion] Error parsing skills:', error);
debugLog('[RPG Parser] ERROR:', error.message);
}
}
/**
* Helper: Extract code blocks from text
* @param {string} text - Text containing markdown code blocks
+322 -53
View File
@@ -7,9 +7,11 @@ import { getContext } from '../../../../../../extensions.js';
import { chat, getCurrentChatDetails, characters, this_chid } from '../../../../../../../script.js';
import { selected_group, getGroupMembers, getGroupChat } from '../../../../../../group-chats.js';
import { extensionSettings, committedTrackerData, FEATURE_FLAGS } from '../../core/state.js';
import { generateSchemaExample } from '../../types/trackerData.js';
// Type imports
/** @typedef {import('../../types/inventory.js').InventoryV2} InventoryV2 */
/** @typedef {import('../../types/trackerData.js').TrackerData} TrackerData */
/**
* Default HTML prompt text
@@ -17,11 +19,17 @@ import { extensionSettings, committedTrackerData, FEATURE_FLAGS } from '../../co
export const DEFAULT_HTML_PROMPT = `If appropriate, include inline HTML, CSS, and JS segments whenever they enhance visual storytelling (e.g., for in-world screens, posters, books, letters, signs, crests, labels, etc.). Style them to match the setting's theme (e.g., fantasy, sci-fi), keep the text readable, and embed all assets directly (using inline SVGs only with no external scripts, libraries, or fonts). Use these elements freely and naturally within the narrative as characters would encounter them, including animations, 3D effects, pop-ups, dropdowns, websites, and so on. Do not wrap the HTML/CSS/JS in code fences!`;
/**
* Default tracker instruction prompt text
* Default tracker instruction prompt text (legacy text format)
* Use {{user}} as placeholder for the user's name (will be replaced at runtime)
*/
export const DEFAULT_TRACKER_PROMPT = `At the start of every reply, you must attach an update to the trackers in EXACTLY the same format as below, enclosed in separate Markdown code fences. Replace X with actual numbers (e.g., 69) and replace all [placeholders] with concrete in-world details that {{user}} perceives about the current scene and the present characters. Do NOT keep the brackets or placeholder text in your response. For example: [Location] becomes Forest Clearing, [Mood Emoji] becomes 😊. Consider the last trackers in the conversation (if they exist). Manage them accordingly and realistically; raise, lower, change, or keep the values unchanged based on the user's actions, the passage of time, and logical consequences (0% if the time progressed only by a few minutes, 1-5% normally, and above 5% only if a major time-skip/event occurs).`;
/**
* Default JSON tracker instruction prompt text
* Use {{user}} as placeholder for the user's name (will be replaced at runtime)
*/
export const DEFAULT_JSON_TRACKER_PROMPT = `At the start of every reply, output a JSON object inside a markdown code fence (with \`\`\`json). This tracks {{user}}'s stats, inventory, skills, and scene information. Follow the exact schema shown below. Use concrete values - no placeholders or brackets. Update stats realistically based on actions and time (0% change for minutes, 1-5% normally, 5%+ only for major events). Items and skills have "name" and "description" fields. Items can grant skills via "grantsSkill", and skills show their source via "grantedBy".`;
/**
* Gets character card information for current chat (handles both single and group chats)
* @returns {string} Formatted character information
@@ -173,6 +181,9 @@ function buildAttributesString() {
}
/**
* @deprecated Use generateJSONTrackerInstructions instead. This legacy text format
* is kept for backwards compatibility with older LLM responses.
*
* Generates an example block showing current tracker states in markdown code blocks.
* Uses COMMITTED data (not displayed data) for generation context.
*
@@ -240,6 +251,9 @@ export function generateTrackerExample() {
}
/**
* @deprecated Use generateJSONTrackerInstructions instead. This legacy text format
* is kept for backwards compatibility with older LLM responses.
*
* Generates the instruction portion - format specifications and guidelines.
*
* @param {boolean} includeHtmlPrompt - Whether to include the HTML prompt (true for main generation, false for separate tracker generation)
@@ -253,10 +267,10 @@ export function generateTrackerInstructions(includeHtmlPrompt = true, includeCon
const trackerConfig = extensionSettings.trackerConfig;
let instructions = '';
// Check if any trackers are enabled (including inventory and quests as independent sections)
// Check if any trackers are enabled (including inventory, skills and quests as independent sections)
const hasAnyTrackers = extensionSettings.showUserStats || extensionSettings.showInfoBox ||
extensionSettings.showCharacterThoughts || extensionSettings.showInventory ||
extensionSettings.showQuests;
extensionSettings.showCharacterThoughts || extensionSettings.showSkills ||
extensionSettings.showInventory || extensionSettings.showQuests;
// Only add tracker instructions if at least one tracker is enabled
if (hasAnyTrackers) {
@@ -295,8 +309,9 @@ export function generateTrackerInstructions(includeHtmlPrompt = true, includeCon
}
}
// Add skills section if enabled
if (userStatsConfig?.skillsSection?.enabled) {
// Add skills section if enabled in config AND NOT shown as separate section
// When showSkills is true, skills are in their own tab and have their own code block
if (userStatsConfig?.skillsSection?.enabled && !extensionSettings.showSkills) {
const skillFields = userStatsConfig.skillsSection.customFields || [];
const skillFieldsText = skillFields.map(f => `[${f}]`).join(', ');
instructions += `Skills: [${skillFieldsText || 'Skill1, Skill2, etc.'}]\n`;
@@ -329,6 +344,33 @@ export function generateTrackerInstructions(includeHtmlPrompt = true, includeCon
instructions += '```\n\n';
}
// Add separate skills section when showSkills is enabled
if (extensionSettings.showSkills) {
const skillsConfig = trackerConfig?.userStats?.skillsSection;
const skillFields = skillsConfig?.customFields || [];
if (skillFields.length > 0) {
instructions += '```\n';
instructions += 'Skills\n';
instructions += '---\n';
// Each skill category contains a list of abilities
for (const skillName of skillFields) {
if (extensionSettings.enableItemSkillLinks) {
instructions += `${skillName}: [Abilities in this category, e.g. "Sword Fighting (Iron Sword), Parry" or "None"]\n`;
} else {
instructions += `${skillName}: [Abilities in this category, e.g. "Lockpicking, Sneaking" or "None"]\n`;
}
}
if (extensionSettings.enableItemSkillLinks) {
instructions += '\n(Abilities from items use parentheses: "Skill (Item)". Remove if item is removed or unequipped.)\n';
}
instructions += '```\n\n';
}
}
if (extensionSettings.showInfoBox) {
const infoBoxConfig = trackerConfig?.infoBox;
const widgets = infoBoxConfig?.widgets || {};
@@ -459,6 +501,220 @@ export function generateTrackerInstructions(includeHtmlPrompt = true, includeCon
return instructions;
}
/**
* Generates JSON-based tracker instructions.
* Creates a prompt asking the LLM to output structured JSON data.
*
* @param {boolean} includeHtmlPrompt - Whether to include the HTML prompt
* @param {boolean} includeContinuation - Whether to include continuation instruction
* @param {boolean} includeAttributes - Whether to include RPG attributes
* @returns {string} Formatted JSON instruction text for the AI
*/
export function generateJSONTrackerInstructions(includeHtmlPrompt = true, includeContinuation = true, includeAttributes = true) {
const userName = getContext().name1;
const trackerConfig = extensionSettings.trackerConfig;
let instructions = '';
// Check which sections are enabled
const showStats = extensionSettings.showUserStats;
const showInfoBox = extensionSettings.showInfoBox;
const showCharacters = extensionSettings.showCharacterThoughts;
const showInventory = extensionSettings.showInventory;
const showSkills = extensionSettings.showSkills;
const showQuests = extensionSettings.showQuests;
const enableItemSkillLinks = extensionSettings.enableItemSkillLinks;
const hasAnyTrackers = showStats || showInfoBox || showCharacters || showInventory || showSkills || showQuests;
if (!hasAnyTrackers) {
return instructions;
}
// JSON instruction header
const jsonPrompt = (extensionSettings.customTrackerPrompt || DEFAULT_JSON_TRACKER_PROMPT).replace(/\{\{user\}\}/g, userName);
instructions += `\n${jsonPrompt}\n\n`;
// Build the JSON schema example based on enabled sections
instructions += '```json\n';
instructions += '{\n';
let sections = [];
// Stats section
if (showStats) {
const enabledStats = trackerConfig?.userStats?.customStats?.filter(s => s?.enabled && s?.name) || [];
if (enabledStats.length > 0) {
let statsJson = ' "stats": {\n';
statsJson += enabledStats.map(s => ` "${s.name}": 75`).join(',\n');
statsJson += '\n }';
sections.push(statsJson);
}
// Status section
const statusConfig = trackerConfig?.userStats?.statusSection;
if (statusConfig?.enabled) {
let statusJson = ' "status": {\n';
const statusParts = [];
if (statusConfig.showMoodEmoji) {
statusParts.push(' "mood": "😊"');
}
const customFields = statusConfig.customFields || [];
if (customFields.length > 0) {
const fieldsJson = customFields.map(f => ` "${f}": "[${f} description]"`).join(',\n');
statusParts.push(` "fields": {\n${fieldsJson}\n }`);
}
statusJson += statusParts.join(',\n');
statusJson += '\n }';
sections.push(statusJson);
}
}
// Info Box section
if (showInfoBox) {
const widgets = trackerConfig?.infoBox?.widgets || {};
const infoParts = [];
if (widgets.date?.enabled) infoParts.push(' "date": "Monday, March 15, 1242"');
if (widgets.time?.enabled) infoParts.push(' "time": "14:00 → 15:30"');
if (widgets.weather?.enabled) infoParts.push(' "weather": "☀️ Sunny"');
if (widgets.temperature?.enabled) {
const unit = widgets.temperature.unit === 'F' ? '°F' : '°C';
infoParts.push(` "temperature": "22${unit}"`);
}
if (widgets.location?.enabled) infoParts.push(' "location": "Forest Clearing"');
if (widgets.recentEvents?.enabled) infoParts.push(' "recentEvents": "Brief summary of recent events"');
if (infoParts.length > 0) {
sections.push(' "infoBox": {\n' + infoParts.join(',\n') + '\n }');
}
}
// Characters section
if (showCharacters) {
const charConfig = trackerConfig?.presentCharacters || {};
let charExample = ' {\n "name": "Character Name"';
if (charConfig.relationshipFields?.length > 0) {
charExample += `,\n "relationship": "${charConfig.relationshipFields[0]}"`;
}
const enabledFields = charConfig.customFields?.filter(f => f.enabled) || [];
if (enabledFields.length > 0) {
const fieldsJson = enabledFields.map(f => ` "${f.name}": "[${f.description || f.name}]"`).join(',\n');
charExample += `,\n "fields": {\n${fieldsJson}\n }`;
}
if (charConfig.thoughts?.enabled) {
charExample += ',\n "thoughts": "Character\'s inner thoughts in first person..."';
}
charExample += '\n }';
sections.push(' "characters": [\n' + charExample + '\n ]');
}
// Inventory section
if (showInventory) {
let invSection = ' "inventory": {\n';
if (extensionSettings.useSimplifiedInventory) {
// Simplified: single list
let itemExample = '{ "name": "Item Name", "description": "What it is" }';
if (enableItemSkillLinks) {
itemExample = '{ "name": "Iron Sword", "description": "A sturdy blade", "grantsSkill": "Sword Fighting" }';
}
invSection += ` "items": [${itemExample}]\n`;
} else {
// Full categorized inventory
let itemExample = '{ "name": "Item", "description": "Description" }';
if (enableItemSkillLinks) {
itemExample = '{ "name": "Iron Sword", "description": "A sturdy blade", "grantsSkill": "Sword Fighting" }';
}
invSection += ` "onPerson": [${itemExample}],\n`;
invSection += ' "stored": { "Location Name": [{ "name": "Stored Item", "description": "Description" }] },\n';
invSection += ' "assets": [{ "name": "Property/Vehicle", "description": "Description" }]\n';
}
invSection += ' }';
sections.push(invSection);
}
// Skills section
if (showSkills) {
const skillCategories = trackerConfig?.userStats?.skillsSection?.customFields || [];
if (skillCategories.length > 0) {
let skillsSection = ' "skills": {\n';
const categoryExamples = skillCategories.map(cat => {
let skillExample = '{ "name": "Ability Name", "description": "What this ability does" }';
if (enableItemSkillLinks) {
skillExample = '{ "name": "Ability", "description": "Description", "grantedBy": "Item Name" }';
}
return ` "${cat}": [${skillExample}]`;
});
skillsSection += categoryExamples.join(',\n');
skillsSection += '\n }';
sections.push(skillsSection);
}
}
// Quests section
if (showQuests) {
let questsSection = ' "quests": {\n';
questsSection += ' "main": { "name": "Main Quest Title", "description": "Primary objective" },\n';
questsSection += ' "optional": [{ "name": "Side Quest", "description": "Optional objective" }]\n';
questsSection += ' }';
sections.push(questsSection);
}
instructions += sections.join(',\n');
instructions += '\n}\n```\n\n';
// Add notes about the format
instructions += 'Important:\n';
instructions += '- Output ONLY valid JSON inside the code fence\n';
instructions += '- Use actual values, not placeholders like [Location]\n';
instructions += '- Stats are percentages (0-100)\n';
instructions += '- Empty arrays [] for sections with no items\n';
instructions += '- null for main quest if none active\n';
if (enableItemSkillLinks) {
instructions += '- Items can grant skills: add "grantsSkill": "Skill Name" to the item\n';
instructions += '- Skills from items: add "grantedBy": "Item Name" to the skill\n';
instructions += '- If an item is removed/lost, remove its linked skill too\n';
}
instructions += '\n';
// Continuation instruction
if (includeContinuation) {
instructions += `After the JSON block, continue the story naturally from where the last message left off. The tracker data should reflect and influence the narrative - fatigue affects performance, mood colors dialogue, etc.\n\n`;
}
// Attributes
if (includeAttributes) {
const alwaysSendAttributes = trackerConfig?.userStats?.alwaysSendAttributes;
const shouldSendAttributes = alwaysSendAttributes || extensionSettings.lastDiceRoll;
if (shouldSendAttributes) {
const attributesString = buildAttributesString();
instructions += `${userName}'s attributes: ${attributesString}\n`;
if (extensionSettings.lastDiceRoll) {
const roll = extensionSettings.lastDiceRoll;
instructions += `${userName} rolled ${roll.total} on ${roll.formula}. Determine success/failure based on attributes.\n\n`;
} else {
instructions += '\n';
}
}
}
// HTML prompt
if (extensionSettings.enableHtmlPrompt && includeHtmlPrompt) {
const htmlPrompt = extensionSettings.customHtmlPrompt || DEFAULT_HTML_PROMPT;
instructions += htmlPrompt;
}
return instructions;
}
/**
* Generates a formatted contextual summary for SEPARATE mode injection.
* Includes the full tracker data in original format (without code fences and separators).
@@ -561,75 +817,88 @@ export function generateContextualSummary() {
}
/**
* Generates the RPG tracking prompt text (for backward compatibility with separate mode).
* Uses COMMITTED data (not displayed data) for generation context.
* Generates the RPG tracking prompt text for separate mode.
* Shows previous data in JSON format and requests JSON response.
*
* @returns {string} Full prompt text for separate tracker generation
*/
export function generateRPGPromptText() {
// Use COMMITTED data for generation context, not displayed data
const userName = getContext().name1;
let promptText = '';
promptText += `Here are the previous trackers in the roleplay that you should consider when responding:\n`;
promptText += `Here are the previous trackers in JSON format that you should consider when responding:\n`;
promptText += `<previous>\n`;
// Build previous state as JSON
const previousState = {};
// Stats
if (extensionSettings.showUserStats) {
if (committedTrackerData.userStats) {
promptText += `Last ${userName}'s Stats:\n${committedTrackerData.userStats}\n\n`;
} else {
promptText += `Last ${userName}'s Stats:\nNone - this is the first update.\n\n`;
const customStats = extensionSettings.trackerConfig?.userStats?.customStats?.filter(s => s?.enabled) || [];
if (customStats.length > 0) {
previousState.stats = {};
for (const stat of customStats) {
previousState.stats[stat.name] = extensionSettings.userStats[stat.id] || 100;
}
}
// Add current skills to the previous data context
const skillsSection = extensionSettings.trackerConfig?.userStats?.skillsSection;
if (skillsSection?.enabled && skillsSection.customFields && skillsSection.customFields.length > 0) {
const skillsList = skillsSection.customFields.join(', ');
promptText += `Skills: ${skillsList}\n\n`;
// Status
const statusConfig = extensionSettings.trackerConfig?.userStats?.statusSection;
if (statusConfig?.enabled) {
previousState.status = {
mood: extensionSettings.userStats.mood || '😐',
fields: {}
};
const customFields = statusConfig.customFields || [];
for (const field of customFields) {
previousState.status.fields[field] = extensionSettings.userStats.conditions || 'None';
}
}
}
// Add current inventory to the previous data context - independent of showUserStats
if (extensionSettings.showInventory && extensionSettings.userStats?.inventory) {
const inventorySummary = buildInventorySummary(extensionSettings.userStats.inventory);
if (inventorySummary && inventorySummary !== 'None') {
promptText += `Last Inventory:\n${inventorySummary}\n\n`;
// InfoBox
if (extensionSettings.showInfoBox && extensionSettings.infoBoxData) {
previousState.infoBox = extensionSettings.infoBoxData;
}
// Characters
if (extensionSettings.showCharacterThoughts && extensionSettings.charactersData?.length > 0) {
previousState.characters = extensionSettings.charactersData;
}
// Inventory
if (extensionSettings.showInventory) {
if (extensionSettings.inventoryV3 && (extensionSettings.inventoryV3.onPerson?.length > 0 ||
Object.keys(extensionSettings.inventoryV3.stored || {}).length > 0 ||
extensionSettings.inventoryV3.assets?.length > 0)) {
previousState.inventory = extensionSettings.inventoryV3;
}
}
// Add current quests to the previous data context - independent of showUserStats
if (extensionSettings.showQuests && extensionSettings.quests) {
if (extensionSettings.quests.main && extensionSettings.quests.main !== 'None') {
promptText += `Main Quests: ${extensionSettings.quests.main}\n`;
}
if (extensionSettings.quests.optional && extensionSettings.quests.optional.length > 0) {
const optionalQuests = extensionSettings.quests.optional.filter(q => q && q !== 'None').join(', ');
promptText += `Optional Quests: ${optionalQuests || 'None'}\n`;
}
promptText += `\n`;
// Skills
if (extensionSettings.showSkills && extensionSettings.skillsV2) {
previousState.skills = extensionSettings.skillsV2;
}
if (extensionSettings.showInfoBox) {
if (committedTrackerData.infoBox) {
promptText += `Last Info Box:\n${committedTrackerData.infoBox}\n\n`;
} else {
promptText += `Last Info Box:\nNone - this is the first update.\n\n`;
}
// Quests
if (extensionSettings.showQuests && extensionSettings.questsV2) {
previousState.quests = extensionSettings.questsV2;
}
if (extensionSettings.showCharacterThoughts) {
if (committedTrackerData.characterThoughts) {
promptText += `Last Present Characters:\n${committedTrackerData.characterThoughts}\n`;
} else {
promptText += `Last Present Characters:\nNone - this is the first update.\n`;
}
// Output as JSON if we have any data, otherwise indicate first update
if (Object.keys(previousState).length > 0) {
promptText += '```json\n';
promptText += JSON.stringify(previousState, null, 2);
promptText += '\n```\n';
} else {
promptText += 'None - this is the first update.\n';
}
promptText += `</previous>\n`;
// Don't include HTML prompt, continuation instruction, or attributes for separate tracker generation
promptText += generateTrackerInstructions(false, false, false);
// Add JSON format instructions
promptText += generateJSONTrackerInstructions(false, false, false);
return promptText;
}