2026-09-08 The Picture Was Inside the Picture
What I set out to do
Answer whether the card tooling writes ccv2 or ccv3 at the parser-to-intermediary boundary. A two-line question that turned into a quarter of the library.
What I actually did
The answer to the spec question is V2 everywhere except one field. build-card.ts stamps chara_card_v2 / 2.0 and writes a single chara chunk; withSpec in the scripts mirrors whatever the file already carries and never invents a ccv3. The only V3 thing is data.nickname, which sits on 422 V2-stamped cards and works because getV3SpecNickname reads character.data.nickname without ever checking spec.
Adam pushed on it twice, and the second push was the good one: why keep duplicates at all, just write ccv3 and let ST read it. That dissolves the duplication argument, and it does work. ST’s reader returns ccv3 when present; our scripts and the extension both read either. I had to correct myself on the timeline too. I’d said opening a nicknamed card makes ST re-save it and grow the twin, and it doesn’t: the seed path for our cards is card-has-nickname / global-empty, which calls applyGlobalCharNickname and returns without touching the PNG. A ccv3-only card would stay ccv3-only.
The argument against it in the end was not duplication but reach. chara is the keyword everything has read since V1. A V2-only reader hitting a ccv3-only card gets no metadata at all, which is a harder failure than staleness, and the only V3 feature in play already works without the stamp. Left it alone.
What made the detour worth it was measuring chunk sizes on the way past. Median 259 KB of base64 metadata per card, and a card carrying 23 MB of it.
The picture was in the picture
Fake Friends.png was 26.8 MB. 8.9 MB of that was a base64 WebP in the card’s avatar field, and another 8.9 MB was the same string again in data.avatar, inside a PNG that already is that image.
Across the library: 706 of 1,286 cards, 687 MB, a quarter of 2.74 GB. Median 423 KB each. Every one stamped creator: "OpenRouter Interceptor", so it was ours, not inherited from JanitorAI or SillyTavern.
Nothing reads it. processCharacter does jsonObject.avatar = item on every load, overwriting the root field with the filename, and data.avatar is not a V2 field so ST never looks at it. The 14 cards where the two levels disagree explain themselves in one line of ST: charaFormatData does _.set(char, 'avatar', 'none') on every edit and leaves the nested copy orphaned. Those 14 are simply the cards that have been edited in the UI.
I did not want to take the field on reasoning alone, because 2026-08-04 The Card Kept Its Picture in the Field Nothing Reads is the day that field saved five cards whose raster had been destroyed. So I measured both halves of it. Sampled 40 cards evenly across the 706, decoded each embedded avatar and compared it to the card’s own raster: 27 pixel-identical at RMSE 0, 13 within resampling noise (max 0.012, all canvas downscales), 0 genuinely different. And ran the validator’s own flat-image heuristic over all 706: not one raster falls below the threshold or the 2x2 floor. The copy is the same picture, and there is nothing left to recover with it.
The recovery case also can’t recur. toCardImage has parsed the injected result rather than counting it since that same day, so a degenerate raster throws before reaching disk.
The fix, and the sweep
The field genuinely earns its keep during a capture: background.ts uses it to draw the card locally when in-page rasterisation fails, which was the other half of the August fix. So the rule is not “stop fetching it,” it is “the picture does not travel inside the picture.” withoutEmbeddedAvatar clears both levels at the two points a card goes into a PNG, and returns the card untouched otherwise, which matters because the JSON fallback download has no raster and the data URL is the only copy there is. Immutability is load-bearing, so it has a test of its own.
The sweep went in as an avatar pass in cards.ts rather than a throwaway, so the walk reports it forever instead of a script settling it once. Declared avatar on CardData so the rule is typed at both call sites rather than asserted through the loose object. Nothing is written to revision_history: what it removes is a duplicate of the PNG it is removed from, and putting the data URL in previous_values would store the picture a third time to record deleting the second.
Result: 707 cards, 687 MB, 2.74 GB down to 2.07 GB. Verified against a snapshot of every card taken beforehand. Zero image hashes changed, zero text fields changed except the two cards that were captured while I was working, 707 files smaller and none larger. Re-run plans 0 changes, no card carries an embedded avatar, ST returns all 1,288 and serves the largest repaired card (26.8 MB down to 2.55 MB) with its text intact.
The dry run that would have wrecked the library
The first dry run reported macros: 892. The library had ended the previous session at zero. The reason is that I ran npm run cards without --nickname, and in that mode the macro pass macroises against the name field, which on these cards is deliberately the JanitorAI listing title. Applying it would have written a listing title into 892 macros, undoing a whole session’s work.
The number is what caught it. Not a check, not a test, just a figure that had no business being large. Worth keeping in mind that the dangerous flag here is the absent one: --nickname is what the library was built with, and the default is the destructive reading.
What was striking
The spec question was not the interesting question, and I would not have found the interesting one by asking it directly. Measuring something adjacent while passing through is how the 23 MB card surfaced.
Two arguments I made in this session were wrong in the same direction: I asserted a mechanism (ST re-stamps on save; opening a card re-saves it) instead of reading the code, and both times reading the code gave a more specific and different answer. write doesn’t stamp, charaFormatData does. Opening doesn’t save, editing does. Neither correction changed the verdict, which is exactly why it would have been easy to leave them standing.
And the field that was worth 687 MB was one I had a documented reason to keep, from a day when it genuinely mattered. The reason had expired and the field hadn’t. Checking whether a justification still holds is different from remembering that there was one.
The hand fix that was undone 28 seconds later
Auditing again found five cards whose nickname reads LIORA while their prose reads Liora 52 times over. I had corrected exactly these by hand earlier in the session, and the revision history said what happened to that correction:
02:46:16 nickname "" -> LIORA
03:33:01 nickname LIORA -> Liora the hand fix
03:33:29 nickname Liora -> LIORA the repair run 28 seconds later
The run at 03:33:29 used the macroiser from before the nickname fix, which read the listing title rather than the nickname and put the shouted spelling back. The root cause was already fixed by then; nothing had gone back to re-correct the five.
So it went in as a rule instead. nameAsTheCardSpellsIt takes the card’s own spelling when the field shouts, which recovered 215 macros across the five. Only entirely-uppercase fields, and only where the card spells it exactly one other way: MIZUKI writes both Mizuki and mizuki, so it is refused rather than guessed. The reverse direction is refused deliberately, because 22 cards shout their name once in a section heading and the field is right on all 22.
The general lesson is the one this session keeps teaching in different clothes. A hand fix has no way to survive the next run of the thing that caused the problem. The revision history was what made this visible at all, which is worth noting given what happened to it next.
Two hypotheses, both measured to nothing
revision_history had reached 49.6 MB, 45.7% of everything in the library’s cards and 2.8 times the card text it describes. I had two theories about where the bulk was and both were wrong:
- the same field value stored repeatedly across entries: 411 values, 1.1 MB
system_promptduplicating theoriginal_system_promptbeside it: 21 entries, 0.2 MB
10,817 of the values are genuinely distinct. So there was no redundancy to reclaim, and the actual answer was that 5,602 of the 5,739 entries were written by repair runs in a single week, each holding a real intermediate state of a field my own tooling rewrote on its way to the current one.
The prune keeps both ends per field: the earliest recorded value, which is the card as captured, and the latest, which is what it held before the change now in force. Every entry keeps its timestamp and changed_fields, so what changed and when is still entirely on the card. 51.3 MB to 31.2 MB, and the verification checked that no audit trail was altered rather than only that no text was.
Worth recording alongside: 29 backup directories now hold 4.7 GB against a 2.1 GB library.
A brace missing on one side, 644 times
Chasing broken macros turned up {user} with one brace on each side, which SillyTavern renders as those seven characters. 644 occurrences across 59 cards.
The distribution was the same shape as the JanitorAI pronoun macros: 642 of the 644 in a greeting or a first message, which are scraped from the page rather than read out of the prompt. That looked briefly like our scraper stripping a brace, and one sentence killed the idea: "{user}. {{char}}'s {{poss}}... um... sister." has both forms three words apart, so nothing mechanical did it. Then the guard question, whether a creator might mean the braces literally: all 59 cards also write the correct form somewhere, so on every one of them the author has demonstrably slipped.
It went into macros.ts rather than a repair script, so the parser refuses to build a card carrying it and the walk repairs what is already written. Guarded on both sides, because the single-braced form sits inside the correct one and unguarded it rewrites {{user}} as {{{{user}}}}.
Nine authored macro names were left that no rule can settle, and they are why {{users}} is deliberately absent from that grammar: it is possessive on four cards (“sees {{users}} reaction”) and plain on two (“living with {{users}} and Dani”), with no apostrophe to decide it. Fixed by hand, one reading each, scoped to the live fields so the revision history still quotes what the card actually said. Fixing Beatrix’s name turned {{{users}} into {{{user}}, which the existing stray-brace rule then recognised on the next run: the handoff the module’s own docstring predicts.
Where I stopped
{{{user}}} on one card is a real defect and macros.ts deliberately excludes it, on the grounds that a creator may have meant the outer pair. I wrote the rule, an existing test caught it, and I reverted rather than overturn a documented decision on a single occurrence. The card got a hand fix and the reasoning is in the throwaway. The distinguishing rule is clean if it is ever wanted: an outer pair holding nothing but the macro is a typo, one holding words as well is the {comment about {{user}}} placeholder four cards write.
Also left deliberately: 72 cards carrying 595 JanitorAI pronoun macros ({{sub}}, {{obj}}, {{poss}}) that SillyTavern has no equivalent for. Fixing them means choosing pronouns for someone else’s persona, which is not a call the tooling should make.
The modality I had not run at all
Everything so far had been cards.ts, the per-card script. dedupe.ts, the per-group one, had not been run once this session. It found two duplicate pairs with identical definitions, differing only in greetings and in which name they were filed under. Before applying I checked where the chats lived, because the survivors were being renamed onto the dropped files’ names: the chats sit under Alexia and Kaela, which are the names being kept, so nothing needed moving and “chats to move: 0” was right rather than merely reassuring.
Worth remembering as a habit: a library has two modalities and running one of them is not auditing it.
A witness asked of the wrong thing
21 chat folders point at no card, holding 29 conversations. reattach-chats refused all 21, and its refusals were correct: seven record character_name as “unused”, Alice3 names six candidate cards and Mina2 three, and the rest are placeholders or names no card ever carried.
But looking at why, chatOwner asks two witnesses of two different things: the folder against card filenames, and the chat header against card names. A filename is derived from a name, so a folder matching no filename may still match the name that filename came from, and nothing asked that. He's the upgrade, sorry against He's_the_upgrade_sorry.png differs by one comma.
Three folders recovered, zero ambiguous. Small, but it is the script’s own job and the gap was a missing pairing rather than a threshold. I had gone in expecting the revision history’s record of former names to be the missing witness; it recovered nothing, and the actual answer was simpler and one step earlier.
The repair that was looking in the wrong field
Scanning for encoding damage found none: no mojibake, no HTML entities, no control characters. It did find 65 cards carrying HTML tags, and the interesting part was where. Not the description, which is what html-in-description reads. personality, 1,020 tags across 64 cards.
Those personality fields hold the JanitorAI listing blurb as raw HTML, <p>ANYPOV || WLA || You're a mafia boss..., while the real definition sits in the description and is clean. So a repair that existed, worked, and was named after the field it read had never seen a single one of these, and 64 cards were sending <p> and <strong> to the model as part of the character.
This is the third instance in two days of the same shape: a rule scoped to one field while the defect lives in another. repair-card.ts already contains the argument against it, written for a different pass — “a macro spelled with a bracket is not a property of the description, so every text field is read” — and the fix was to make the same argument about markup.
Widening it needed one thing that was not obvious from the description-only case. An inline tag becomes a space, so <strong>Mr Yamada</strong>. comes out as “Mr Yamada .” That was invisible at the old scale and would have been introduced on 113 <strong> pairs and 68 <span> pairs. Verified against the backup afterwards: macros 2,603 unchanged, words 78,679 unchanged, spaces before punctuation 6 unchanged, nothing lost on any of the 65.
The witness that was not a name
Adam asked what “detached folder” actually meant, then said to try to recover them. Explaining it was what showed the way in. Every witness chatOwner had was a name: folder against filename, folder against card name, chat header against card name. Names run out. 63 in this library are worn by more than one bot, so Alice3 could never be settled by any witness of that kind, no matter how many I added.
SillyTavern writes the greeting into the chat as its first turn. So every chat carries a verbatim copy of the card’s own prose, and prose is the one thing a rename cannot touch. That is a witness of a different kind: it identifies the card by what it is rather than by what it was called.
Two design choices were forced by the data rather than guessed:
- Split the card’s text on macros. The stored chat has
{{char}}and{{user}}already substituted, so a run spanning one of them is not in the chat verbatim and could only ever match by accident. The runs between the macros are exact. - Walk the whole card, not its greetings. My first version indexed
first_mesandalternate_greetingsand missed two folders. One opens on text the card keeps inpersonality; another on text that survives only in therevision_historyof a field this tooling has since rewritten. The greeting is where the text usually is, not where it always is.
13 of 18 folders, 15 chats, unanimous across every chat in every folder, no folder matching two cards. The same 13 come back for any anchor length from 40 characters to 120, which is the part I actually trust: the reading sits on a plateau, not a threshold. And the evidence per match is not marginal, the runs are 500 to 2,700 characters long.
The five refusals are the interesting half. Three folders hold no messages at all, only a header, so there is nothing to read. The other two open on scenarios whose cards are gone from the library and from all 3,109 cards across the 37 backup directories. Misa1 is the sharp one: a card called Misa.png exists, its name matches, and every name-shaped witness would have claimed it. Its personality opens “Following {{char}}‘s VIP boutique [Pt1]” — it is the sequel to the card those chats belong to. The prose witness refused it because the prose genuinely does not match, which is a better outcome than any of the name witnesses would have produced.
Verifying the moves also turned up two chats sitting loose in chats/ rather than in any folder, where SillyTavern reaches them from no character at all and lists them nowhere. Both Alice Maren Holloway’s. reattach-chats had never seen them because it iterated directories. Alice’s history was in three detached folders and two loose files, seven chats in five places, and it is now in one.
Where it ends: 8 chats across 2 folders, and their cards do not exist anywhere on this disk.
The rule I shipped was wrong, and finding out was the next check
Having a content witness meant I could ask it a question it had never been asked: not “which card owns this orphan” but “is this chat under the right card at all”. 915 attached chats. Six came back filed under a card that never said what they open with.
Then the check I ran on my own result found a seventh, and it was a false positive. Halloween_Party_Betrayal_Closet_Swap looked misfiled, and it was not: its opening matches its own card’s greeting for 2,359 characters and the accused card’s for 327. The anchor rule I had shipped an hour earlier had preferred the wrong card outright.
The mechanism is worth writing down because it is not a near miss, it is a structural flaw. A greeting with no macros in it becomes one enormous anchor, so a chat that diverges from it anywhere — an edit, a swipe, a trailing sentence — matches none of it. Meanwhile a near-duplicate card whose greeting does carry a macro is split into short runs, one of which fits. The all-or-nothing anchor loses to the fragment. Long anchors are not more reliable than short ones; they are more brittle, and I had reasoned about them as if length were only evidence.
The replacement is two readings instead of one:
- Rendering, which is decisive. SillyTavern substitutes the macros before writing the greeting into the chat, so the chat holds the card’s own words with holes where the macros were. Match the literal runs in order, anchored at both ends: the holes take whatever went into them and nothing else is allowed to differ. 726 of 915 chats open on a greeting that renders exactly, and not one is rendered by two different cards.
- Twenty-word shingles, when the reader edited it. An asterisk added at the front, a last sentence rewritten: every other shingle stays exactly where it was. Every answer this gave beats its runner-up from zero, which is the margin I actually wanted and the boolean never had.
Two corrections fell out of scoping it properly. Greetings only, not the whole card: counting the description had the two near-identical Sarah Jane cards scoring against each other on a shared blurb while the greeting that separates them went unweighed. And every greeting the card has ever carried, because revision_history is the only place the original survives on a card this tooling has rewritten.
Nine chats moved. Serena held one of Hazel’s, Judy one of Fidelia’s, Renee one of Pia’s whose text names Pia outright. Three of Nene’s belonged to Nene Pt2, and two of Sarah Jane’s to its twin.
I re-ran the new rule against the snapshot taken before this morning’s moves: same 15 folders, same cards, 13 by rendering and 2 by shingles, same 5 refusals. So the moves I had already made stand, and the rule that made them no longer could have made them for a bad reason.
The lesson is not “test more”. I had verified those 15 by hand, with match lengths and self-evident pairings, and the verification was sound. What I had not done is ask the rule a question whose answer I already knew, on data it was not built for. Turning the witness on the chats that were not broken is what exposed it.
What was striking the second time
Three checks I was confident about came back clean, and each would have been a plausible finding to report: 138 keyless lorebook entries are all constant: true and correct; {{User}} on 50 cards resolves because MacroRegistry lowercases every lookup; the revision history’s bulk is not duplication. Measuring before reporting is cheap in a way that retracting is not.
And twice in one session the interesting defect was found by measuring something adjacent to the question actually asked. The 23 MB avatar came out of a chunk-size tally run while answering a spec question. The 644 broken macros came out of scanning for a dozen typos.
Related
- 2026-08-04 The Card Kept Its Picture in the Field Nothing Reads
- 2026-08-04 The ccv3 Chunk SillyTavern Actually Reads
- 2026-09-06 The Revision History Measured the Tooling
src/character-card/avatar.ts,scripts/lib/card-passes.ts, commits0b3a60eand05aa86ascripts/lib/chat-owner.ts,scripts/reattach-chats.ts, commitsa0e63c1,710a886and1224c9a