mirror of
https://github.com/bal-spec/sillytavern-character-memory.git
synced 2026-08-21 22:43:45 +00:00
fix: migrate batchState keys from character name to avatar
#26 re-keyed batch-extraction progress from `${characterName}:${chatName}` to `${avatar}:${chatName}` — correct, since SillyTavern doesn't enforce unique display names — but shipped no migration for existing records. Without one, every pre-existing record becomes unreachable: runBatchExtraction's `lastExtractedIndex ?? -1` lookup misses on the new key and falls back to -1, so the next batch run re-extracts every chat from message 0 and duplicates memories. The orphaned keys also linger in settings permanently, since resetBatchProgress and clearAllMemories now only match the avatar prefix. Adds remapBatchStateKeys() to lib.js (pure, tested) plus a thin settings-level wrapper in index.js that runs once and is idempotent. Deliberately conservative: records that can't be attributed to exactly one character — duplicate display names, or a card that's since been deleted — are passed through untouched rather than dropped. An unmatched key is inert, whereas guessing an owner would reintroduce the very cross-character boundary corruption the avatar re-key exists to prevent. Matching is longest-name-wins rather than a split on the first ':', since both character names and chat names can themselves contain colons. Runs from onChatChanged (self-heals as soon as the roster loads) and defensively at the top of all three batchState consumers. Defers while the character list is still empty so it never migrates against a partial roster. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
b3a9d14bbe
commit
130f43a26c
3 changed files with 236 additions and 0 deletions
64
index.js
64
index.js
|
|
@ -56,6 +56,7 @@ import {
|
|||
packBlocksIntoChunks,
|
||||
classifyBlocksForConsolidation,
|
||||
shouldSkipStaleMetadataReset,
|
||||
remapBatchStateKeys,
|
||||
} from './lib.js';
|
||||
import { createMemoryEditor } from './editor.js';
|
||||
import { runChunkedConsolidation } from './consolidation.js';
|
||||
|
|
@ -3464,6 +3465,10 @@ function onCharacterMessageRendered(_messageIndex, type) {
|
|||
* Event handler for CHAT_CHANGED — reset status display.
|
||||
*/
|
||||
async function onChatChanged() {
|
||||
// Self-heal batch keys as soon as the character roster is available, rather than
|
||||
// waiting for the user to open a batch tool. No-ops once migrated.
|
||||
migrateBatchStateKeys();
|
||||
|
||||
const context = getContext();
|
||||
const chatId = context.chatId || '(none)';
|
||||
const charName = getCharacterName() || '(none)';
|
||||
|
|
@ -8757,6 +8762,59 @@ function markChatAsFullyExtracted() {
|
|||
logActivity(`Marked chat as fully extracted: lastExtractedIndex=${lastIdx}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* One-time migration of batchState keys from `${characterName}:${chatName}` to
|
||||
* `${avatar}:${chatName}`.
|
||||
*
|
||||
* batchState used to be keyed by character display name, which collides across cards
|
||||
* sharing a name (duplicate imports, common persona names). Re-keying by avatar fixed
|
||||
* that, but without this migration every pre-existing record becomes unreachable:
|
||||
* runBatchExtraction's `lastExtractedIndex ?? -1` lookup falls back to -1 on the new
|
||||
* key, so the next batch run re-extracts every chat from message 0 and duplicates
|
||||
* memories, while the old keys linger in settings forever (resetBatchProgress and
|
||||
* clearAllMemories now only match the avatar prefix).
|
||||
*
|
||||
* Deliberately conservative: records that can't be attributed to exactly one character
|
||||
* are left untouched rather than dropped. An unmatched key is inert (nothing reads it),
|
||||
* whereas guessing an owner would reintroduce the very cross-character boundary
|
||||
* corruption the avatar re-key exists to prevent.
|
||||
*
|
||||
* Idempotent, and safe to call before the character list has loaded — it defers instead
|
||||
* of migrating against an empty roster.
|
||||
*/
|
||||
function migrateBatchStateKeys() {
|
||||
const settings = extension_settings[MODULE_NAME];
|
||||
if (!settings || settings.batchStateKeyedByAvatar) return;
|
||||
|
||||
const state = settings.batchState;
|
||||
if (!state || Object.keys(state).length === 0) {
|
||||
settings.batchStateKeyedByAvatar = true;
|
||||
saveSettingsDebounced();
|
||||
return;
|
||||
}
|
||||
|
||||
// Characters load asynchronously — migrating against an empty/partial roster would
|
||||
// leave real records unattributed. Defer; every call site fires repeatedly.
|
||||
if (!Array.isArray(characters) || characters.length === 0) return;
|
||||
|
||||
const { batchState, moved, ambiguous, unmatched } = remapBatchStateKeys(state, characters);
|
||||
|
||||
settings.batchState = batchState;
|
||||
settings.batchStateKeyedByAvatar = true;
|
||||
saveSettingsDebounced();
|
||||
|
||||
if (moved || ambiguous || unmatched) {
|
||||
const notes = [];
|
||||
if (ambiguous) notes.push(`${ambiguous} ambiguous (duplicate character names)`);
|
||||
if (unmatched) notes.push(`${unmatched} unmatched (character no longer present)`);
|
||||
logActivity(
|
||||
`Migrated ${moved} batch-progress record(s) to avatar keys` +
|
||||
(notes.length ? ` — left ${notes.join(', ')} untouched` : ''),
|
||||
moved ? 'success' : 'info',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear batch extraction progress records for all of this character's chats.
|
||||
* Does NOT affect the current chat's regular extraction pointer (chat_metadata).
|
||||
|
|
@ -8764,6 +8822,7 @@ function markChatAsFullyExtracted() {
|
|||
* extraction pointers live in each chat's metadata and can only be reset when that chat is open.
|
||||
*/
|
||||
function resetBatchProgress() {
|
||||
migrateBatchStateKeys();
|
||||
// Keyed by avatar to match runBatchExtraction's batchStateKey — see the comment there
|
||||
// for why display name (getCharacterName()) would collide across duplicate-named cards.
|
||||
const avatar = characters[this_chid]?.avatar;
|
||||
|
|
@ -8799,6 +8858,7 @@ async function clearAllMemories() {
|
|||
|
||||
// Also clear batch state for all chats of this character (keyed by avatar — see
|
||||
// resetBatchProgress/runBatchExtraction for why display name would collide)
|
||||
migrateBatchStateKeys();
|
||||
const avatar = characters[this_chid]?.avatar;
|
||||
if (avatar && extension_settings[MODULE_NAME].batchState) {
|
||||
const prefix = `${avatar}:`;
|
||||
|
|
@ -9808,6 +9868,10 @@ function updateBatchButtons() {
|
|||
}
|
||||
|
||||
async function runBatchExtraction() {
|
||||
// Must run before any batchState read below — an unmigrated name-keyed record would
|
||||
// miss the avatar-keyed lookup and silently re-extract the chat from message 0.
|
||||
migrateBatchStateKeys();
|
||||
|
||||
const selected = [];
|
||||
$('.charMemory_batchChatCheck:checked').each(function () {
|
||||
selected.push(String($(this).data('filename')));
|
||||
|
|
|
|||
67
lib.js
67
lib.js
|
|
@ -681,3 +681,70 @@ export function shouldSkipStaleMetadataReset({ isGroup, unresolvedCount, totalAc
|
|||
if (totalActive === 0) return true; // can't conclude anything from zero members
|
||||
return unresolvedCount > 0; // any unresolved member invalidates the conclusion
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-key batch-extraction progress records from `${characterName}:${chatName}` to
|
||||
* `${avatar}:${chatName}`.
|
||||
*
|
||||
* Character display names collide across cards (duplicate imports, common persona
|
||||
* names); avatars don't. Records that can't be attributed to exactly one character are
|
||||
* passed through untouched rather than dropped — an unmatched key is inert, whereas
|
||||
* guessing an owner would reintroduce the cross-character boundary corruption that
|
||||
* keying by avatar exists to prevent.
|
||||
*
|
||||
* @param {Record<string, any>} batchState Existing batch state, keyed by name or avatar.
|
||||
* @param {{name?: string, avatar?: string}[]} characters The loaded character roster.
|
||||
* @returns {{batchState: Record<string, any>, moved: number, ambiguous: number, unmatched: number}}
|
||||
*/
|
||||
export function remapBatchStateKeys(batchState, characters) {
|
||||
const result = { batchState: {}, moved: 0, ambiguous: 0, unmatched: 0 };
|
||||
if (!batchState || typeof batchState !== 'object') return result;
|
||||
if (!Array.isArray(characters)) characters = [];
|
||||
|
||||
const avatars = [];
|
||||
const avatarsByName = new Map();
|
||||
for (const char of characters) {
|
||||
if (!char?.avatar) continue;
|
||||
avatars.push(char.avatar);
|
||||
if (!char.name) continue;
|
||||
if (!avatarsByName.has(char.name)) avatarsByName.set(char.name, []);
|
||||
avatarsByName.get(char.name).push(char.avatar);
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(batchState)) {
|
||||
// Already avatar-keyed (partially-migrated settings, or written post-re-key).
|
||||
if (avatars.some(avatar => key.startsWith(`${avatar}:`))) {
|
||||
result.batchState[key] = value;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Longest match wins — character names and chat names may both contain ':',
|
||||
// so splitting on the first separator would mis-attribute those records.
|
||||
let bestName = null;
|
||||
for (const name of avatarsByName.keys()) {
|
||||
if (key.startsWith(`${name}:`) && (bestName === null || name.length > bestName.length)) {
|
||||
bestName = name;
|
||||
}
|
||||
}
|
||||
|
||||
if (bestName === null) {
|
||||
result.batchState[key] = value;
|
||||
result.unmatched++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const owners = avatarsByName.get(bestName);
|
||||
if (owners.length > 1) {
|
||||
// The exact collision the re-key fixes: this boundary can't be attributed
|
||||
// to either card, so keep it inert rather than assign it to one of them.
|
||||
result.batchState[key] = value;
|
||||
result.ambiguous++;
|
||||
continue;
|
||||
}
|
||||
|
||||
result.batchState[`${owners[0]}:${key.slice(bestName.length + 1)}`] = value;
|
||||
result.moved++;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
|
|
|||
105
test/unit/batchStateMigration.test.js
Normal file
105
test/unit/batchStateMigration.test.js
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { remapBatchStateKeys } from '../../lib.js';
|
||||
|
||||
const CHARS = [
|
||||
{ name: 'Seraphina', avatar: 'Seraphina.png' },
|
||||
{ name: 'Flux', avatar: 'Flux.png' },
|
||||
];
|
||||
|
||||
describe('remapBatchStateKeys', () => {
|
||||
it('re-keys a name-keyed record to the matching avatar', () => {
|
||||
const { batchState, moved } = remapBatchStateKeys(
|
||||
{ 'Seraphina:chat-1': { lastExtractedIndex: 42 } }, CHARS);
|
||||
expect(batchState).toEqual({ 'Seraphina.png:chat-1': { lastExtractedIndex: 42 } });
|
||||
expect(moved).toBe(1);
|
||||
});
|
||||
|
||||
it('preserves the record value verbatim', () => {
|
||||
const value = { lastExtractedIndex: 7, totalMemories: 3 };
|
||||
const { batchState } = remapBatchStateKeys({ 'Flux:my chat': value }, CHARS);
|
||||
expect(batchState['Flux.png:my chat']).toEqual(value);
|
||||
});
|
||||
|
||||
it('is idempotent — a second pass leaves avatar-keyed records untouched', () => {
|
||||
const once = remapBatchStateKeys({ 'Seraphina:chat-1': { lastExtractedIndex: 42 } }, CHARS);
|
||||
const twice = remapBatchStateKeys(once.batchState, CHARS);
|
||||
expect(twice.batchState).toEqual(once.batchState);
|
||||
expect(twice.moved).toBe(0);
|
||||
});
|
||||
|
||||
it('leaves ambiguous records untouched when two cards share a display name', () => {
|
||||
const dupes = [
|
||||
{ name: 'Alice', avatar: 'alice-v1.png' },
|
||||
{ name: 'Alice', avatar: 'alice-v2.png' },
|
||||
];
|
||||
const { batchState, moved, ambiguous } = remapBatchStateKeys(
|
||||
{ 'Alice:chat-1': { lastExtractedIndex: 5 } }, dupes);
|
||||
// Assigning this to either card is exactly the corruption the re-key prevents.
|
||||
expect(batchState).toEqual({ 'Alice:chat-1': { lastExtractedIndex: 5 } });
|
||||
expect(moved).toBe(0);
|
||||
expect(ambiguous).toBe(1);
|
||||
});
|
||||
|
||||
it('leaves records for characters that are no longer present untouched', () => {
|
||||
const { batchState, moved, unmatched } = remapBatchStateKeys(
|
||||
{ 'DeletedChar:chat-1': { lastExtractedIndex: 5 } }, CHARS);
|
||||
expect(batchState).toEqual({ 'DeletedChar:chat-1': { lastExtractedIndex: 5 } });
|
||||
expect(moved).toBe(0);
|
||||
expect(unmatched).toBe(1);
|
||||
});
|
||||
|
||||
it('handles a character name containing a colon via longest-match', () => {
|
||||
const chars = [
|
||||
{ name: 'Doc', avatar: 'doc.png' },
|
||||
{ name: 'Doc: The Sequel', avatar: 'doc-sequel.png' },
|
||||
];
|
||||
const { batchState } = remapBatchStateKeys(
|
||||
{ 'Doc: The Sequel:chat-9': { lastExtractedIndex: 1 } }, chars);
|
||||
// Shortest match would have produced 'doc.png: The Sequel:chat-9'.
|
||||
expect(batchState).toEqual({ 'doc-sequel.png:chat-9': { lastExtractedIndex: 1 } });
|
||||
});
|
||||
|
||||
it('preserves a chat name containing a colon', () => {
|
||||
const { batchState } = remapBatchStateKeys(
|
||||
{ 'Flux:2024-01-01 12:30:00': { lastExtractedIndex: 2 } }, CHARS);
|
||||
expect(batchState).toEqual({ 'Flux.png:2024-01-01 12:30:00': { lastExtractedIndex: 2 } });
|
||||
});
|
||||
|
||||
it('migrates a mixed set and reports each category', () => {
|
||||
const { batchState, moved, ambiguous, unmatched } = remapBatchStateKeys({
|
||||
'Seraphina:a': { lastExtractedIndex: 1 },
|
||||
'Flux.png:b': { lastExtractedIndex: 2 },
|
||||
'Ghost:c': { lastExtractedIndex: 3 },
|
||||
}, CHARS);
|
||||
expect(batchState).toEqual({
|
||||
'Seraphina.png:a': { lastExtractedIndex: 1 },
|
||||
'Flux.png:b': { lastExtractedIndex: 2 },
|
||||
'Ghost:c': { lastExtractedIndex: 3 },
|
||||
});
|
||||
expect(moved).toBe(1);
|
||||
expect(ambiguous).toBe(0);
|
||||
expect(unmatched).toBe(1);
|
||||
});
|
||||
|
||||
it('never drops a record', () => {
|
||||
const input = {
|
||||
'Seraphina:a': 1, 'Ghost:b': 2, 'Flux.png:c': 3,
|
||||
};
|
||||
const { batchState } = remapBatchStateKeys(input, CHARS);
|
||||
expect(Object.keys(batchState)).toHaveLength(Object.keys(input).length);
|
||||
});
|
||||
|
||||
it('tolerates empty and malformed input', () => {
|
||||
expect(remapBatchStateKeys({}, CHARS).batchState).toEqual({});
|
||||
expect(remapBatchStateKeys(null, CHARS).batchState).toEqual({});
|
||||
expect(remapBatchStateKeys(undefined, undefined).batchState).toEqual({});
|
||||
expect(remapBatchStateKeys({ 'X:y': 1 }, []).batchState).toEqual({ 'X:y': 1 });
|
||||
});
|
||||
|
||||
it('ignores roster entries missing a name or avatar', () => {
|
||||
const messy = [{ avatar: 'no-name.png' }, { name: 'NoAvatar' }, ...CHARS];
|
||||
const { batchState, moved } = remapBatchStateKeys({ 'Seraphina:a': 1 }, messy);
|
||||
expect(batchState).toEqual({ 'Seraphina.png:a': 1 });
|
||||
expect(moved).toBe(1);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue