2026-09-09 The Card Prompt That Replaced the Instruction

What I set out to do

Another round of “check some more cards” on the SillyTavern library. No specific target, which by now is the point: the last several findings all came from measuring a field nobody had asked about.

The field that overrides rather than adds

104 cards carry a system_prompt. I had been treating that field as inert. It is not.

prefer_character_prompt and prefer_character_jailbreak are both on, main_api is openai, and the active preset’s main prompt does not set forbid_overrides. All three conditions are what public/scripts/openai.js checks before doing this:

const mainOriginalContent = systemPrompt.content;
systemPrompt.content = systemPromptOverride;

It replaces. It does not append. The prompt it was replacing is the entire instruction:

Write {{char}}'s next reply in a fictional chat between {{char}} and {{user}}.

On 73 cards the system_prompt is the card’s own description, byte for byte. Evie.png: 2,875 characters in each field, identical. Lana.png: 15,073 and identical.

The prompt order is main, worldInfoBefore, personaDescription, charDescription, ..., so on those cards the model was reading the bio as slot one, the same bio again as slot four, and never being told to write a reply at all. 587,738 duplicated characters, near 147,000 tokens, 370,795 of them on the 54 cards that have chats open against them and are therefore paying it per message.

Equality, not resemblance

The first cut used shingles, the way scenarioEcho does, and found 86 at 80% or better. Reading the thirteen that were not exact is what stopped me shipping that number.

They are my own tooling’s drift, and each one differs in a way that decides something:

Roselia.png   system_prompt:  - Name: Roselia Eden
              description:    - Name: {{char}} Eden

macroise-card rewrote the description and left the system prompt as the pre-macro copy. But Chloe.png goes the other way: its system prompt keeps a <UserPersona>Male 6'2, muscular, black hair</UserPersona> line the description no longer carries, so clearing it would lose that line.

So the shipped rule is exact equality after trim, and the thirteen are left for a person. Clearing an exact copy decides nothing, which is the only reason it can be automatic. This is the same discipline as the 2026-09-08 The Picture Was Inside the Picture avatar sweep: the measurement that justifies the change has to be the one the rule actually applies.

Two things I checked that came back clean

post_history_instructions sits on 26 cards and looked like the same problem. It is not: 19 are the deliberate group-member [Write the next reply only as X.] nudges, 4 append through {{original}}, and the global jailbreak prompt is empty anyway, so nothing is being displaced.

{{original}} is worth writing down as the safe idiom for this field generally. It expands to the prompt it would otherwise replace. Lea.png sets system_prompt to literally {{original}} and nothing else, which is a no-op, and three cards use it properly to append.

The stale root field that turned out not to matter

Checking root against data found 104 system prompt mismatches and, unexpectedly, 4 scenario mismatches. Those 4 are cards my own scenarioEcho commit cleared last week: it set data.scenario and left the root copy holding the old text. Cassandra.png still has 313 characters at the root.

I went looking for how bad that was and it is not bad. readFromV2 in src/endpoints/characters.js runs on every load of a v2 card and copies data.scenario down onto the root through a field-mapping table, so the root value is derived, not authoritative. The stale text is dead bytes.

The same table is what settled the new pass: system_prompt is not in it, and charaFormatData never writes a root system_prompt either. All 1,293 cards hold "" there already. So clearing data is what makes the two agree, and touching root would have been wrong.

Worth the ten minutes. I had been about to widen the pass to clear both levels on the strength of mirroredToRoot existing.

Result

73 cards cleared, verified through SillyTavern’s own API rather than by re-reading my own writer: data.system_prompt 0, root 0, description 2,875 intact at both levels, and the full old text in revision_history.previous_values. 0 exact copies remain, 31 cards still carry a system prompt and all 31 are the near-misses, the own-text ones, or {{original}}.

Originals in characters-repair-backup-2026-09-10T0232, 187 MB, which restarts the pile I deleted 36 directories of yesterday. Retention in backupDirFor is still unbuilt and still the obvious next thing.

What was striking

The finding was not in the cards. It was in settings.json, in two booleans I had never read, and the cards were only where the consequence landed. Every audit round so far has read the library against itself. This one needed the library read against the thing that consumes it, and that is a class of defect none of the previous rounds could have found.

Second: I have now twice written a rule from an 80%-style threshold and twice had reading the exceptions change the rule rather than confirm it. The threshold is not the answer, it is a way of producing a short list to read.

The bug that wasn’t, and the loss that was

Chasing a macroise bug I had claimed on Hana1 ({{char}} "{{char}}" Whitaker, rendering Hana "Hana" Whitaker where the card meant Hanami "Hana"). I was wrong: macroiseNames respects word boundaries, verified directly on four shapes including that exact string. What produced it was two runs with a rename between them, under Hanami and then under Hana. One card in 1,293. Fixed by hand.

Then the real question: what did the rebuilds drop. Against the capture each card still holds, nothing worth naming: 98.7% of eight-word runs still live, nothing below 85%, and as contiguous spans only 679 words across 1,039 cards.

Two wrong readings on the way, both mine, both the same species:

  • The first run said 89.1% with cards down at 46%, and every missing sample contained “dragon”. JanitorAI resolves {{user}} to the persona name before sending, so the capture says “Dragon” where the live card says {{user}}. I had already handled the character’s name in that same function and not the reader’s.
  • I was estimating missing words as missingRuns + RUN - 1, which assumes one contiguous gap. A card scoring 85% “lost” 260 words by that formula and 21 by actual measurement.

The loss is real but it is a different thing: 60 cards hold 67,292 words of their own prose only in revision_history, two thirds of it from April 2026. Reading them, they are not trimmed drafts. Mary was a 19-year-old student at the Academy of Iria and is now a 27-year-old American wife. Rebecca2’s live description is a superhero story premise with no Rebecca in it. Different bots, same card file.

Checking the chats against that: 33 chats, 216 turns, across 23 cards open on a greeting only the card’s history holds, with a character the card no longer describes. Worst are Rebecca2 at 47 turns over five chats and Mary at 26 over three. Only one has its character elsewhere in the library; the other 32 are recoverable only from the card’s own revision history.

And one more artifact caught at the last step, which is why the number is 33 and not 36. A chat rendering from a historical greeting and not the current one is not by itself evidence of a replaced character: removeSingleAsterisks took the asterisks off Kim’s greeting, so Kim’s own chat stopped matching it while the same scene under another filename still did. The discriminator had to be the two greetings against each other, not the chat against either.

Same lesson as the four artifacts in 2026-09-08 The Picture Was Inside the Picture, now at five and six: every one came from comparing two things that were never comparable, and every one was resolved by reading a single actual example rather than refining the measure.

Restoring them, and finding out what did it

Restored 22 of the 23 as real cards beside the ones that replaced them, with their chats moved on: Mary (before 2026-04-05).png and the rest, verified through SillyTavern’s own API, re-run plans nothing. The picture cannot be restored, since a card is a PNG and the raster on disk belongs to the replacing character, so each restored card borrows it and says so in creator_notes.

The cause was already in the git log. 224559c, 2026-04-05: “deduplicate source files by character name in findMatches”. merge-revisions, the Downloads-to-library path that ran on every capture, paired a new capture to an existing card on the lowercased name field alone, with a name -> file map where the last write won. Two bots both called Mary matched, and which card a capture landed in came down to directory read order. 29 cards on 2026-04-05, 3 more on 2026-04-20.

I had already found and fixed this on 2026-09-06 in d6cdc93, from the other end: I noticed the pairing rule was unguarded and demonstrated it on a card that would have been overwritten, without ever checking whether it already had been. The fix was right and the damage it described in the future tense was three months old.

The rule I shipped an hour earlier was wrong

Two cards showed replacements dated after that fix, which should have been impossible. They were Sienna_Emma and Jamal_Carter, from the 2026-09-07 dedupe merges, and reading them they are not replacements at all: same character, new opening scene, beach to restaurant and hospital room to elevator. My rule read the greeting alone.

Checking that against the 23 found Erica- Your Wife [NTR] on the wrong side of it: same 24-year-old to the word, greeting rewritten from scratch with the other man renamed Marcus to Ryan. I had restored a duplicate. Reverted it, one card and one chat.

The description settles all three, and the split is clean: the 22 real replacements share 0.0% of their description with what replaced them, the three rewrites share 95%, 97% and 98%.

What saved the first run from writing those two was not the rule but the requirement I put beside it, that a card only counts when a chat filed under it actually opens on the replaced greeting. Neither had chats. The evidence I added as corroboration was the evidence doing the work, and I would not have found that out by testing the rule against itself.