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:
bal-spec 2026-07-27 21:51:48 -07:00
parent b3a9d14bbe
commit 130f43a26c
3 changed files with 236 additions and 0 deletions

View file

@ -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
View file

@ -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;
}

View 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);
});
});