2026-09-06 The Revision History Measured the Tooling

What I set out to do

Replace the OpenRouter Interceptor’s auto-analysis trigger. It fired on the second request to a character because JanitorAI’s generate-for-user request carries no card (a bare newline, or newline plus lorebook), and only the real request that follows it does. The intended replacement was “re-analyse whenever the stored card changes”, but first: how often does the stored card change, and is lorebook or script churn going to make that fire every turn?

What I actually did

The stored capture is a running maximum. pickRepresentative swaps the payload only for a strictly longer system prompt, and the prompt carries the lorebook tail and the <UserPersona> block, both of which move without the character moving. JanitorAI injects lorebook entries only while a keyword sits inside the message-depth window (Advanced Lorebook), and Scripts are JavaScript lorebook entries that run fresh on every message (What Are Scripts). So a raw-prompt trigger would fire on every new lorebook maximum.

The SillyTavern library has a revision history, so the obvious move was to count churn there. It measures almost nothing of the kind:

KindRevisions
Layout migration, 2026-09-043,968
Parser rebuild, 2026-09-05104
CRLF noise or identical re-capture52
Different bot under the same name16
Creator edit13
Lorebook changed, definition identical4

Every non-migration revision sits on one of four dates, each a one-to-four minute batch. Two of the four batches are tooling runs (a rebuild after parser fixes, and a merge run before the CRLF fix landed). And the download ledger hides churn by design: planDownload only writes a card when the prompt grew, so the library only ever learns of a re-capture that was longer.

The four lorebook-only revisions are one card, Hotwife, with five independent captures from five chats back when dedupe keyed by chat URL. Definition never changed. Lorebook tail changed every time: 7,474 to 6,820 to 7,924 to 5,280 to 10,174 characters. That single card is the only per-capture evidence in the library and it points the way the mechanism predicts.

The Alyssa and Emily cards flip between two unrelated definitions inside one run: name-keyed identity merging two bots, not churn. Line-set Jaccard under 0.3 separated those 16 from the 13 genuine edits.

Shipped the trigger as definition change. shouldAutoAnalyze(previous, current, settings) compares parseJanitorPrompt(...).definition of the entry the merge replaced against the entry it produced; upsertCaptureByCharacter now hands back previous. A first request with a card is a change from nothing; the generate-for-user request has no character, so it is not. Removed the autoAnalyzeAfter setting, its options dropdown, schema fields and default, and the “already analysed” check against stored queries. TDD: nine predicate cases plus two store cases, then the implementation. 1,299 unit tests, typecheck, lint and build green.

What was striking

A revision log that records every change is not a log of the changes you care about. Three filters sat between a capture and a revision (the length gate, the batch cadence, the name-keyed identity), and each one either hid churn or manufactured it. The honest number was a lower bound on creator edits and near zero visibility into lorebook churn, and it took classifying every entry to know which.

Also: the one card that leaked through did so because of a bug that has since been fixed (per-chat dedupe). The evidence path closed when the code improved.

Later the same day: the macros JanitorAI had already resolved

Noticed in SillyTavern that extracted cards write {{char}} and {{user}} out as literal names in places, which means they do not follow a rename or a persona switch. Measured it across all 1,323 cards before touching anything.

Two different failures. {{user}} was mostly handled — 982 of 1,109 stored original prompts carry a literal persona name, because JanitorAI resolves the macro before it sends, and the chain put it back. But the rewrite was one hardcoded string, prompt.replace(/\bDragon\b/g, "{{user}}"), and I have two personas: 38 cards captured under Dina kept it and were addressing me by a name I no longer play. {{char}} was worse: only 241 of 1,112 extension-built cards had the macro anywhere, because the tag converter only fired on a creator’s <Name>…</Name> wrapper over 100 characters and most prompts have none.

Fixed both in the chain, with the persona list in one module that the analysis prompt and the name extractor’s blocklist also read. The {{char}} pass has four guards, each from a card that broke a draft of it: only the whole name ({{char}} renders the entire name field, so a macro over “Alexis” alone reads “Alexis Sinclair said”); every word must be a name (keeps scenario titles out); capitalisation matters (“Rose keeps a rose”); no brace crossed ({Flora is 44} became {{{char}}); and not before another capitalised word, which both keeps “Celeste Marlowe” whole and makes a second pass a no-op.

The backfill is a text pass rather than a rebuild, so it needs no recorded prompt and reaches the 211 pre-extension cards and the greetings, which are scraped from the page and never went through the chain at all. 443 cards changed, 6,702 {{char}} and 1,304 {{user}}. One literal persona name survives, inside {Dragon part — an unclosed brace the card’s own author wrote.

Then a cleanup pass. Dead: the character-card barrel re-exported 15 names nothing ever took from it, every caller being in the same directory and importing directly. DRY: textarea[class*="_chatTextarea"] was written out in three modules with the bare class name in five more places beside it, the message wrapper in four, the avatar in three — all now one janitorai-selectors.ts, each class prefix named once and its selectors built from it. And every card script had two definitions, an npm script running ts-node and a just recipe running an npx tsx that was not a dependency of the project; the two sets had already drifted, three commands missing one way and four the other. The recipes now call the npm scripts, and how TypeScript runs is one ts script.

Finished by running the repair: 9 cards, 37,118 characters of duplicated text.

What was striking, second time

The selector consolidation had a trap in it. devtools-capture.ts builds a string of JavaScript for the inspected page, and half its selector literals were inside that template — a constant reaches them only through ${...}, so substituting the identifier compiled fine here and threw is not defined over there. Four separate edits, each caught by the expression tests. Left a comment on the template saying so.

Also: repair-card-text cuts one occurrence per run. Two cards carried their greeting in the description twice, so the first apply left one copy and a second apply was needed. It converges, but nothing says it might not be done.

The spot check earned its keep

Eyeballing cards was not enough, so I rendered every rewritten field back through the macros and diffed it against the pre-pass copy. That found a real defect on the first run: Sophia.png’s name field is "Sophia ", with a space on the end. I matched the trimmed name, but {{char}} renders the field as stored, so the card read “You and Sophia are the best of friends”. 23 library cards have an untrimmed name; only that one was name-shaped enough to be rewritten. Guarded, tested, card restored, pass re-run. All 443 now render byte-identically to their originals.

Then fixed the stranded tag. removeBlock takes the label that introduced a section, and <scenario> reads as a label because it ends in >, so the cut took the opener and left the closer. It now takes the matching closer with it, with a tag-balance check in cutIsSafe as the backstop — the same shape as the bracket check that was already there. Moved the repair logic into lib/repair-card.ts to have somewhere to test it, which is where the rest of this directory’s logic already lives; the CLI is 130 lines now.

Swept the library afterwards: 14 descriptions have a closing tag that opens nothing, and 12 of them carry the identical imbalance in their stored capture, so the creators wrote them that way. One (Hana1) predates the extension. One (Mira) has a balanced capture and an unbalanced description, so something in the hoist stranded a </setting> — separate, small, not chased.

The general lesson: a check that renders the transformation back and compares finds what reading the output does not. Both defects this session were invisible to inspection and obvious to a round trip.

The trailing space was the real bug

Committed the work as five commits on card-macros-and-cleanup, then kept spot checking, and the checks that paid were the ones that did not read the stored text. Going through SillyTavern’s own API rather than my parser confirmed it loads every rewritten card with the macros intact. Diffing three over-replacement patterns against the pre-pass copies found 55 macros glued to a letter, 969 unknown macros and 53 triple braces in the library and zero added by the pass; the six adjacent-macro pairs it did add all render back word for word.

What that turned up was that guarding the macro pass against "Sophia " had only fixed my half. The card’s own pre-existing {{char}} macros were already rendering the stray space, 66 times across two cards, and had been all along. The name field is what {{char}} renders, so padding there is not a cosmetic problem in the character list: it is in every sentence the card writes under the macro.

So: whitespace-in-name in the validator, a trim in the repair, the two sharing the definition the way they already share MIN_DUPLICATED_SECTION — a check the repair cannot act on produces cards reported forever and never fixed. 23 cards trimmed, warnings 31 back to 8. Only the ends, since the space in “Alexis Sinclair” is part of the name; the filename is untouched, so avatar keys and existing chats are unaffected. And with the name clean, Sophia finally qualified for the macro pass that had been refusing it, so that ran once more for two more macros.

The shape worth remembering: the guard I added to stop my code writing the defect hid the fact that the data already had it. A guard that skips a case is not the same as a case that is fine.

A leftover rule from a version that no longer exists

Kept spot checking, this time across all 684 macro-ised cards rather than a sample. The render diff against the pre-pass backup came back clean: 434 byte-identical, 7 differing only because they were captured under Dina and now follow whichever persona is active (which is the change working), 2 being the scenario block the repair removed. No glued macros, adjacent macros or triple braces added.

One metric looked alarming and was measuring my own detector. “Doubled space beside a macro” went 35 to 117, but those spaces were always in the text; a pattern that requires a macro adjacent only sees them once a macro is adjacent. The render diff is the check that settles it.

What the sweep did find: 118 cards still carry their own name literally, 267 occurrences. 201 are the surname guard doing its job — the card is named “Adeline”, the text says “Adeline Vautier”, and a macro there would read “Adeline Vautier Vautier” the day the card is renamed. The other 66 sit on nine cards, seven of which are titles rather than names and correctly refused.

The remaining two were my rule. nameSpelling required every word of a name to be three letters or more, so “Hu Tao” was refused for its first two, and that card carried the name 53 times in its own text beside the two {{char}} its creator had written by hand. The rule is a leftover: it was correct when the function split a name into parts and matched them individually, because a two-letter part matches inside half the prose on a card. I removed the part-splitting when I found the surname-doubling hazard, and it now returns only the whole name — so the whole name is what a minimum length has to guard.

Measured the relaxed rule before changing it: 12 cards newly qualify, 2 have anything to replace (Hu Tao and Naomi Valetine - NV, 106 macros between them). The other 10 are title-shaped names with no literal occurrence. Every field of both rewritten cards, character book included, renders byte-identically to its pre-pass copy; the only difference in the whole card is the version bump.

Worth noting what still holds the line: capitalisation, not length. “Your Wife is Excited That Barbarians Are Approaching” is refused for its lowercase “is”, which is why the length rule could be relaxed without letting scenario titles in.

Two data defects fell out and were not chased: Roland Blackwood] has a trailing bracket in its name field, and Lola de Amour is refused because “de” is lowercase. Both are the same shape as Sophia’s trailing space — a name field that renders through every {{char}} — and the validator catches whitespace there but not a stray bracket.

The lesson is narrower than the last one and more useful: a guard outlives the design that justified it. The per-word minimum was still doing something, just not the thing it was written for, and nothing about reading the function said so. It took asking why a specific card was untouched.

Reading the cards, not the aggregates

Cleared the two name defects I had left. The particle one is a closed list — “de”, “van”, “der” and seventeen others, allowed only between two words of the name, since a particle on either end is a fragment of something else. It admits “Lola de Amour” and “Emilie van der Meer” and nothing else in 1,323 cards, which is the point: a lowercase word inside the name field is otherwise the surest sign the field holds a scenario title, and the library has 608 distinct ones doing exactly that.

The bracket one turned out to have a cause. Three cards end in ] because the name extractor read a [Name: Roland Blackwood] line and kept the closer. The extractor has since learned to salvage that; the cards it built have not, and the worst renders the bracket 41 times. tidyName is now the single definition of what a name field should say, asked by both the validator and the repair.

I nearly used extractCharacterName for that, which would have been the DRY move and is wrong: run over stored names it renames “Your Wife Found Your NTR Chats” to “Half” and “She abandoned you to become an idol.” to “Seoul”. Measured before writing it, which is the only reason I know.

Then went looking at the cards themselves rather than the counters, and the counters had been hiding the biggest defect in the library.

97 cards are named the JanitorAI listing title while their own capture names the character, and 1,439 {{char}} render the title. The clearest is a card named “You and your elf wife are on a beach trip (avoidable NTR)“. Its description reads, 36 times over, sentences like “Name: You and your elf wife are on a beach trip (avoidable NTR) is called You and your elf wife are on a beach trip (avoidable NTR)” and “Likes: You and your elf wife are on a beach trip (avoidable NTR) likes to tease Dragon”. The capture says <Alisara's Persona><Alisara> and “Alisara is called Alisara”.

So the card contradicts itself. The macro conversion trusted the persona tag — that is the only reason those {{char}} exist, since JanitorAI resolves macros before it sends and no capture contains one — and then the name field was filled from somewhere else. Every one of those macros is now actively wrong, in a way that only shows up if you render.

Today’s extractor refuses a four-word name and would not repeat it. Re-run on the original captures it agrees with the persona tag on 41 of 96 and disagrees on 55, but the disagreements are mostly better: “Heather Palmer” for tag “Heather”, “Kaoruko Waguri” for “Kaoruko”. A handful are worse (“Behavior”, “Government”, “Eva is called Eva”). So the extractor is not the backfill source either; the persona tag is, because it is the source the macros were written against.

Two smaller things from the same sweep. 628 macros on 96 cards are ones SillyTavern will not resolve, and 533 of them are {{sub}}, {{obj}}, {{poss}} — persona pronouns, sitting right beside {{user}}: “the way {{poss}} hair fell as {{sub}} wrote something”. They reach the model as written. And the 63 the validator calls errors are not empty: every one has a description and a greeting scraped from the page, and 43 have a capture exactly one character long, the bare newline of a generate-for-user request. They are thin, not broken, and one real message in each chat would fill them in.

The source turned out to be a rule I wrote

Before fixing any of the 97, went looking for where the name actually comes from, and found the answer in my own code twice over.

resolveCharacterName in from-prompt.ts already asks personaTagName first, ahead of every prose strategy, with a docstring saying why: the extractor over the description “named seven of sixteen sampled cards after someone else in it”. So today’s builds are correct and would not repeat this.

The cards that carry a listing title were renamed — or rather, deliberately not renamed — by relayout-card.ts:

const namedByApi = existing.data.extensions.openrouter?.janitorai?.source === "api";
const name = namedByApi ? existingName : betterName(existingName, personaTagName(prompt));

“Only for a card the JanitorAI API did not name: the listing title is the API’s and stays.” 115 of the 116 are source === "api". So this is not a bug that slipped through; it is a trade I made, and the comment even notes that {{char}} resolves to the name field. What the comment weighs is the risk of naming a card after an NPC in its own prose. But that risk belongs to the prose extractor, not to the persona tag, and the guard applies the caution to the wrong source.

Then measured how well corroborated the tag actually is, because that is what the trade turns on:

evidence for the tag’s namecardsmacros
the listing title already contains it36419
the page-scraped greeting names them53937
it appears elsewhere in the card879
cast tag, {{char}} still determinable12414
cast tag, nothing distinguishes them623

Nothing landed in “the name appears nowhere else”. The greeting is the good evidence: it is scraped from the page, so it never passed through the tag, the cleaning chain or the macro conversion, and it agrees anyway.

The cast cards took one more step. A tag reading <Gilda, Richard, Kodi, William, Sandy's Persona> looks ambiguous, but the card answers the question itself: the conversion took one person’s <Name> block, so that name is the one missing from the converted fields while the rest are still written out. Gilda, Esme, Belle, Ivy, Lena. The test only works if you exclude the greetings, which are page-scraped and still name everyone — checking the whole card said “needs eyes” for 17 of 18 and was wrong 12 times.

One of the twelve is worth keeping: on “The Most Important Speech of Your Life” the tag lists “Gemma, Chad, Maude” and the name that is gone is Chad, not the first. So “first in the cast” was never the rule; “the one the conversion took” is, and it is readable off the card.

Six cards genuinely need a person. 23 macros between them.

The general shape, third time this session: the aggregate said 115 cards need a judgment call, and the judgment call was only ever needed on 6. The difference was entirely in asking each card what it already knew.

Applied, and a runner that had been broken all along

Changed the rule and backfilled. namedByApi now keeps the API’s name only when that name is a name, and isScenarioTitle is lifted out of needsCleaning, which had the rule inline: the whole defect was two places having to make one call and making different ones.

Testing that predicate immediately found it calling “Emilie van der Meer” a scenario title on a four-word count, so the particle list moved out of transformation-chains.ts into character-card/names.ts — the module that is about names and that nothing imports into — and the count now counts the words carrying the name. Writing the test found the bug in the thing the test was for.

Then the dry run would not run at all, and the reason was mine. ts-node’s ESM loader appends .js to a specifier that already has one, so @openrouter/sdk/models/chatresult.js resolves to chatresult.js.js and dies as an empty [Object: null prototype]. Every script reaching src/schemas/openrouter.ts went with it, and st-relayout and st-rebuild had been unrunnable since the consolidation commit earlier today. They ran under npx tsx before it; I moved them off because tsx was not a declared dependency, which was true and was the wrong fix. Declaring it gives one runner that works.

Worth sitting with: I checked that consolidation with tests, typecheck, lint and build, and every one of them passed on two scripts that could not start. Nothing in the suite runs a CLI. The failure mode was invisible until I wrote a script that happened to import the same module.

The backfill decides on the card’s own evidence and never on prose. 101 cards renamed, every other field byte-identical to its backup, 7 left for a person. The rename then qualified 94 cards for macroisation — 1,841 {{char}}, almost all in greetings, which are scraped from the page and never went through the chain — and all 94 render byte-identically, which is the whole claim: the text the model reads is unchanged, and now it follows a rename.

What the model reads now:

wasName: You and your elf wife are on a beach trip (avoidable NTR) is called You and your elf wife are on a beach trip (avoidable NTR)
nowName: Alisara is called Alisara

Checking the rename, and one alarm I raised wrongly

Went looking for what the rename cost, since 101 renames into a library of 1,323 is the sort of thing that has a cost.

It does. Distinct names went 1,286 to 1,247; names carried by more than one card went 29 to 57, and cards involved 66 to 133. 39 of the 101 landed on a name something else already had — four Evas, four Hanas, three Lexis. The prompt is right and the character list is ambiguous. Mitigated but not erased: the listing title survives in the filename and in capture_metadata.source_title, and creator_notes carries the blurb, which SillyTavern shows under the name.

Then validated the renames against a source I had not used. janitorai.chat_name is JanitorAI’s own name for the bot in a chat, and it is not the persona tag, not the greeting, and not the listing title. Of the 101: 87 agree exactly, 12 agree on the first name, 1 has none, 1 disagrees. The disagreement is “The Most Important Speech of Your Life”, where chat_name is the whole cast “Gemma, Chad, Maude” and the rename chose Chad. Read the card: <{{char}}> is the arrogant frat star, <Maude> is “{{char}}‘s official girlfriend”, <Gemma> is “{{user}}‘s girlfriend of two years”. Chad is right. All 101 stand.

Then I raised an alarm and it was wrong, which is worth writing down. merge-within-dir groups cards and folds duplicates, and I saw it report 28 groups including “Claire (6 copies)” and assumed my rename had armed it to merge six different Claires. It had not. The tool keys on source_url and falls back to the name only for cards that have none, behind an opt-in --include-name-fallback whose own help text says “Risky: different bots that happen to share a name will be merged”. Every one of the 28 default groups is [url], i.e. genuinely the same JanitorAI bot. The name-only groups are twelve pre-existing numbered families (Claire, Claire1..Claire4) that have nothing to do with today. And 100 of the 101 renamed cards carry a source_url, so they are never name-grouped at all.

I read a group heading printed with the card’s name and inferred the grouping key from it. The label was the name; the key was the URL. Checking the flag’s own documentation would have taken thirty seconds and I reached for the conclusion first.

What the merge plan actually is: 28 same-bot duplicates, 57.75 MB, mostly a “clean name” card and a “listing title” card for one bot — Amanda.png beside “She left you to become the Rank 2 Superhero in the World.png”, Candy.png beside “Another Ragebait! RAHHHH.png”. The rename is what made those pairs legible as pairs. Eight of them have byte-identical definitions.

Also traced the 45 {{{char}}} the validator files under “wrong in the source card”. The real shape is {{{char}}] — a brace and a bracket — on 23 cards, and 27 cards carry the same template sentence, so it is one popular JanitorAI template with a typo in it. Only one of the 23 has a stored capture, and that capture reads {Dragon]: the same mismatch with the persona name resolved. The neighbouring sentences are all bare {{char}}, so the intended text is {{char}} and the repair is mechanical. Not ours; 0 added by any pass.

Acting on the three, and two things checking first was worth

The malformed macros first, and the check that mattered was reading SillyTavern’s own source rather than assuming. substituteParams builds each macro’s pattern with the i flag (scripts/macros.js:684), so {{User}}, {{USER}} and {{Char}} all resolve — 51 cards I would otherwise have “fixed” were never broken. Only the delimiters are ever the defect.

What is genuinely broken is three spellings: {{{char}}] on 22 cards from one copied template, {{user]} and {[user}} on eight more. A closed list, because [{{user}} is a student] is how 49 cards are written and none is broken. Left {{users}} alone — possessive on one card (“sees {{users}} reaction”), plain on the next (“living with {{users}} and Dani”) — and {{{char}}}, a brace pair the creator may have meant. 27 repairs on 26 cards.

Then the merge, where I nearly did real damage by not looking. The plan reads “keep the larger file”, which sounds like keeping the better card. But foldLosersIntoWinner preserves the winner’s fields and files every loser’s into revision_history, and file size is almost entirely the PNG. Measured it: 18 of 28 groups would have kept the thinner definition, one by 13,391 characters — a card with 5,315 chars of definition beating one with 18,706 because its image was bigger. So pickWinner moved to lib/revision-merge.ts beside the function that makes the choice matter, and the fuller definition now wins with the image as tie-break. The plan flipped where it should.

28 duplicates merged away, 69.57 MB, 25 revisions recorded.

Then checked what the merge cost, and this is the one worth remembering. It renames winner files, and SillyTavern keys a chat folder by the card’s filename. 31 chat folders had no matching card. The largest was “Ashe & Sierra Our Wives’s Persona” with 82 chats, and my first instinct was that I had just orphaned them. Comparing the orphans against the merge backup: 28 folders and 123 chats were already orphaned before today, that one included. The merge orphaned three folders, six chats. Reattached all six to the surviving card for the same bot, matched by source_url.

Twice in two sessions now: an alarming number, and the honest measurement was that most of it predated me. Both times the fix was the same — diff against the backup instead of reading the current state and inferring a cause.

Library: healthy 1086 → 1097, errors 63 → 56, warnings 8 → 4, authored problems 204 → 150, cards 1323 → 1295.

Sorting the session’s defects by what would have caught them

Went back over everything found today and asked which class each belongs to: a type, a parse, a shared definition, or a correctly stated rule.

Almost none were type or parse failures. Searching for the signature of check-then-assert — as X, !., as unknown as — over src/ and scripts/ turns up one as Iterable<RegExpMatchArray> working around a lib gap, and import aliases. The valibot boundary holds. The one place a name’s invariant is not in the type is card-schema.ts, and that is right: it parses PNGs written by other tools, so it has to accept a dirty name. The guarantee belongs in the builder instead, and I checked that it is there — feeding the builder a trailing space, a [Name: Roland Blackwood] line and a listing title now yields “Sophia”, “Roland Blackwood” and “Alisara”. Those three are fixed at the source, not just in the library.

The failures were DRY and rule-encoding, and mostly the same shape: a rule stated in one place and needed in several.

  • isScenarioTitle was inline in needsCleaning while relayout made the opposite call. 115 cards. The whole defect was two places, one question.
  • The macro grammar lived in scripts/, so the builder could still emit {{{char}}] and the repair would have had to run again after every re-capture. Now src/character-card/macros.ts, beside names.ts, applied by the chain and asked by both the validator and the repair.
  • NAME_PARTICLES was about to be a second copy; moved to names.ts first.

Rule-encoding, in three flavours. A guard that outlived its design: the per-word length rule made sense when nameSpelling returned the words. A rule stated over the wrong quantity: pickWinner on file size, when file size is the PNG and the definition is the point. And a rule applied to a subset of what it is about — my own repair read only description, leaving six malformed macros in scenario, personality and a greeting, and the validator’s authored-problem check read description and greetings alone, so it never reported them either. Two independent instances of the same mistake, an hour apart, in code I wrote today. Both now read every text field the model reads, which also surfaced one undecoded string, two HTML entities and four unusable macros that were always there. Healthy 1097 → 1058: the cards did not change, the reporting got honest.

And the selector consolidation was incomplete, which is the finding I like least and learned most from. I had consolidated p[node], div[data-index], button[aria-label="Edit"] — no, I had not. I consolidated the selectors I happened to be looking at while editing other things, and never enumerated what existed. A grep for querySelector outside the module found five more concepts written two to five times each, plus isBotMessage written out identically twice. Fixed now, and ROW_AVATAR_SELECTOR is named but deliberately not merged with CHARACTER_AVATAR_SELECTOR: they match different elements and both work where they are used, so unifying them on the strength of a grep would be the same error in the other direction.

The pattern across all of it: I keep finding defects by asking a narrow, local question of the data, and I keep creating them by answering a general question from a local sample. The consolidation, the repair’s field list, the validator’s field list, pickWinner — every one is a rule written from the case in front of me rather than the set it is about.

Two more of the same shape, one of them a live bug

Kept auditing for the pattern I had just named, and it kept paying.

The card’s text fields were written out five times. TRACKED_FIELDS in revision-merge, CONTENT_FIELDS in character-card, and an inline copy in cleanup-duplicates and twice in analyze-character-cards. Identical, except the fifth, which had already lost mes_example — so calculateContentSimilarity scored two cards differing only in their example dialogue as identical, and nothing anywhere said so. This is the one that convinced me the pattern is structural rather than a run of carelessness: nobody chose to drop a field, the copy just aged differently. One CARD_TEXT_FIELDS now, in card-schema.ts.

And cleanCharacterName and tidyName both decide what a name field should say. I wrote tidyName this morning without noticing the builder had been answering the same question for months, differently. Comparing them on the same inputs found a live defect I had not gone looking for: the builder stripped every trailing closer, so “Sarah Jane | Girlfriend to {{user}}” became “…{{user” — a macro with its braces taken off, which can never resolve. withoutStrayCloser in names.ts is now what both ask, and it only takes a closer with no opener anywhere in the name.

They still disagree about one thing, and I left it: the builder drops a whole trailing bracketed epithet, the repair keeps it. 44 cards render “Brittany [Tinder Girlfriend NTR]” 1,041 times where a card built today would say “Brittany”. That is a question about someone’s library, not something to settle inside a refactor — the same call as leaving ROW_AVATAR_SELECTOR unmerged.

The audit has now found the same failure five times: isScenarioTitle, the macro grammar, the repair’s field list, the validator’s field list, the text field list, and this. Every one is a rule that had to be applied in two places and was written down in one of them twice. The tell is not duplication in the abstract — it is a question two modules both have to answer. Searching for that phrasing, rather than for copied text, is what found the last three.

The epithet rename, and the audit closing itself out

Did the epithet rename by making tidyName ask cleanCharacterName — the builder’s own answer — rather than keep its own idea of a tidy name. That is the whole fix: the library and a fresh capture should not disagree about what a card is called, and they had drifted into opposite policies.

Measuring all 77 first was worth it, because the epithets were only two thirds of it. 52 bracketed epithets, 18 trailing punctuation (“Clara Martinez.”, “Noelle, Jake, Kelsey, Kagami,”), and 7 doubled internal spaces — the same doubled-space class as Sophia, months of it, in a field every {{char}} renders. None of the 77 would have been renamed to the placeholder, which was the thing worth checking before applying.

Then the repair did not converge, and the reason was the shape again: cleanCharacterName stripped one trailing epithet per call, so “Ruka -Your Wife [NTR] [Drugged][Rape]” needed two runs — and the builder was writing a name it would itself have gone on to repair. Now it strips them all, stopping before the name is nothing. 82 names in total, converging in one run.

Kept auditing with the question that has been working — what does more than one module have to decide? — and it found two more:

  • HTML_TAG written out identically in the validator and the repair. That is the one pair where disagreement means a card is reported forever or stripped without being reported.
  • html-entity was a warning no repair could act on, so three cards had carried &lt;150 words since they were built. Decoding existed only inside htmlToText, which also strips tags and rewrites the line structure — far more than a stored card should go through to turn &lt; into <. Split decodeEntities out.

Then closed the audit empirically instead of by reading, which is the check I should have reached for hours earlier: run the repair to convergence and ask what the validator still reports. Everything left is authored (185, deliberately untouched) or has nothing to repair from — 56 with no definition in their capture, 3 with an empty description, one whose bytes were lost before the card was written. Nothing is reported that a repair could fix. That is the invariant I had been chasing one code at a time, and it is now a thing I can re-check in one command rather than by inspection.

Warnings 7 → 4, healthy 1058 → 1061.

The URL identified the chat, not the bot

Kept auditing. Two false alarms I raised and killed myself before they reached Adam, then the real thing under them.

First alarm. Checking that the 28 merged losers were recoverable, my script said six fields were gone, including 7,750 characters of Rina. The matcher was wrong twice over: it bailed early so it only checked 17 of 91 fields, and it compared raw text where the cleaning chain had normalised whitespace. All 91 loser fields are readable in the live library. Two of the “missing” greetings differed from the winner’s by whitespace alone.

Second alarm. I then found all 28 losers had no source_url and concluded the merge had run with --include-name-fallback, folding different characters together. Wrong again: I was reading extensions.openrouter.janitorai.source_url and the field lives at capture_metadata.source_url. All 28 were URL-keyed. My earlier correction to Adam had been right after all.

The real finding, which the wrong path walked me into. Every one of the 1,062 stored URLs is janitorai.com/chats/<id>. getIdentityKey preferred it over a comment calling it the “JanitorAI bot URL”, “unique per bot” and one that “survives re-captures”. It identifies the chat. Capturing one bot from two chats produced two cards that could never merge, and seven bots sat under two to four chat ids apiece — Rara under four names.

Identity is now every signal a card carries, linked transitively rather than ranked: JanitorAI’s own character_id (672 cards), the chat URL (1,062), and a description of 200 characters or more. Ranking would split a pair whose older side predates the id; the four Rara captures share no single signal.

Two things the merge was holding and discarding:

  • A loser’s greeting went only to revision_history, where the text survives and ST can never offer it. Every definition-linked pair differs in nothing else, so the merge that found them was the merge that hid them. 20 greetings became alternates.
  • A loser’s name where the winner’s is a scenario title. {{char}} renders the name verbatim, so the merge was about to keep “Your Girlfriend Cheated On You” over “Makoto, Sam, Blake”.

And chats: ST keys them by avatar filename, so renaming a PNG orphans its history. The previous run orphaned six and I reattached them by hand — which is not a fix, it is the same bug twice. The merge moves the folders now. 918 chats before and after, 16 moved, nothing newly orphaned.

What was striking, this time

Both alarms came from reading the wrong field and trusting the script over the data. The habit that saved it both times was the same one: open one card and look. “Rina Seo, 23, idol” versus “Rina Ayana, 28, housewife” is what made me doubt the matcher rather than the merge.

And the thing I was hunting all session was sitting in a doc comment. Not duplicated code this time — a single sentence asserting something about the data that a one-line count disproves. Grepping for copied text would never have found it. rg 'chats/' | wc -l did.

The card was always the right place to keep it

I fixed the missing backup directory and reported it. Adam’s reply was four words: “they should have revisions.” Right, and better than what I did. The backup directory is a second place to look; revision_history is the card’s own undo and it was sitting there unused. Only the merges wrote to it. The repair and both renamers rewrote fields and recorded nothing.

The design question that took the thinking: should name become a tracked field? No — and the reason is a bug I would have shipped. buildMergedCard copies every tracked field from the incoming card, so tracking the name would let a re-capture’s JanitorAI scenario title overwrite a corrected name, undoing the 115-card fix from earlier today. So the entry is written by the tool that made the change, not derived from TRACKED_FIELDS. previous_values is a plain record and already held any field; only the writers were narrow.

The repair derives its own entry by diffing the two cards rather than listing what it changed beside each repair. A list kept in step by hand is a list that falls out of step — twice today already.

Reconstructing what was already lost

55 of the 82 names came back, off the filename: derived from the name at download and untouched since. Two things I nearly got wrong.

Diana(1).png looked like a rename to “Diana”. It is a download-collision suffix — the file system’s, not the creator’s — and cleanCharacterName strips it like any other parenthetical. Excluding those took the count from 59 to 55.

My first sanitisation guard was createSafeFilename(stem) === stem, which rejected every real case: that function writes underscores and these filenames have spaces, so they never came from it. The guard that actually holds is whether the name carries a character no filename can hold.

The remaining 27 have no witness and I did not guess. A guess entered as history is worse than a gap: the gap is visible and the guess is not. Each recovered entry is marked reconstructed_from: "filename" so it never reads as a record written at the time.

95 chats that had been detached for months

The merge now carries chats along, but that only stops new orphans. There were 123 chats already sitting in 28 folders pointing at no card, and I had been treating that as a fixed backlog since I first counted it. It was not.

Two witnesses survive a detachment. The folder name is the filename the card had, and the difference is nearly always the file system’s rather than the creator’s: Ashe & Sierra Our Wives's Persona (two spaces) against Ashe_&_Sierra_Our_Wives's_Persona.png. That one folder held 82 chats. And each chat’s first line is a header recording character_name as it was when the chat was made.

The header is the weaker witness and I used it only where it names exactly one card. 63 names here are worn by more than one bot, so Alice3 — three candidate Alices — stays detached. Attaching someone’s history to the wrong Alice is worse than leaving it where it is at least visibly detached.

95 chats back to 8 cards. Verified through ST’s own /api/characters/chats rather than by looking at the filesystem: that card went from 37 chats to 119.

The 28 left name a placeholder — Character, Scenario, npcs, unused — or are ambiguous, and I left them.

The thing I keep getting wrong

Three times today I have written down a backlog number and treated it as weather. 28 orphaned folders, “already orphaned before this session, not mine.” True, and irrelevant: nobody had asked whether they were recoverable. The same with the 82 lost names, which I reported as lost and Adam turned into a question about revisions.

Reporting a number accurately is not the same as having looked at it.

The 56 errors were 56 false positives

I took the lesson from the last round and pointed it at the number I had reported most often: “56 errors, cards built from a prompt with no character in it, they need a re-capture.” I said that three times today without opening one.

All 56 have real definitions. Descriptions of 600 to 5,000 characters. The check read original_system_prompt and blamed the card.

43 of them store a lone "\n". That is not “a prompt with no character in it” — it is no prompt at all, and there is nothing to judge. The other 13 came from a scenario-shaped prompt with no persona tag, which is also fine, because hasCharacterDefinition answers “is this capture worth building a card from”, not “is this card sound”. Its own docstring says so. I wrote that docstring.

Errors: 56 to 0, with nothing repaired. The cards were always fine.

Then the same question of the 114

[ and ] do not pair was 114 cards, 62% of everything left in the report. The test for whether a check is worth having is whether there is a mechanism behind it, so I went and looked in the running container: substituteParams builds one pattern, {{ }}, and nothing reads a square bracket. An unclosed brace is a macro the reader sees raw. A stray [ is a character.

I tried twice to rescue it. Counting per paragraph found more (254), because bracket blocks spanning paragraphs are ordinary here. Looking for text that ends inside an open block — the truncated-capture signal, which would be worth having — found nine, and all nine end on a complete sentence or a closing tag.

Removed. The module’s own comment says mixing in problems nobody can act on is how a report stops being read; it was two thirds of the way there.

1218 of 1286 healthy, 0 errors, 4 warnings, 69 authored — from 1054 / 56 / 4 / 183, and not one card was edited to get there.

What was striking, third time

Every fix today made the report smaller by making a check truer, not by repairing cards. The library was in better shape than my own tooling said, and I had been quoting my tooling.

Past midnight: “look at one of them”

I had dismissed the 114 bracket cards on aggregate counts and written that no narrowing of the check found a defect. Adam’s reply: look at one of them.

Open, Still Ours uses [Section:] blocks throughout, and between two well-formed ones sits a ] alone on a line, closing nothing. I had tried counting per paragraph and looking for truncation. I never tried depth, which is the model that matches how brackets actually work.

Depth over the library: 63 cards have a ] closing nothing (112 instances), 58 have an unclosed [ (141). But only 7 instances in 4 cards are a line holding nothing but closers where nothing is open, and that is the only class where the repair touches no sentence the creator wrote. The other 75 stray closers are glued to prose (She is loyal to {{user}}.]), where the missing half is the opener; cutting the closer would edit their text. Futa Married Your Girlfriend delimits with [[ ... ]], and depth handles that correctly where any “doubled bracket” heuristic would have mangled it.

The count-based check stays removed. The 4 cards are different evidence, not a subset that was ever actionable.

The same question, twice more, of the braces

The bracket lesson generalised, and both of the remaining count-based checks were wrong in the same way.

triple-brace reported 26 cards under the label {{{char}}} and named the wrong defect in ten of them: **{comment about {{user}}}:** is a label four creators wrote and the outer braces pair; three cards embed a definition as JSON that closes three at a time; one writes [ {{{Char 1}}} as a section header; {{{user}}} has both extras pairing. What is left is a brace glued to a macro and pairing with nothing. Verified against ST’s actual regex rather than assumed:

writtenreaches the model
{{{user}}{Adam
{{user}}}Adam}
{{{users}}{{{users}}, since users is no macro and nothing substitutes

That last row is why the check asks whether the token resolves before claiming anything. 19 instances in 16 cards, every one user.

unbalanced-braces counted {{ against }} over 18 cards and was wrong about half of them in both directions. Twelve carried a delimiter typed as the key beside it: {{user} (5), {{user)) (7), {{char)} (4), ((user}} (1), {{User{{ (1). ) is shift-0 where ] is unshifted and } is shift-], so these are one hand off by a row or a shift — the same mechanism as the three spellings already on the closed list, each with one answer. Three of the eighteen were never defects at all: the JSON cards again.

What survives is a {{ or }} with no partner, which reaches the reader whole because SillyTavern substitutes nothing it cannot close. Three cards, reported and deliberately not repaired, since the repair would have to decide where the creator meant the macro to end.

Authored problems 69 → 43 → 28; healthy 1218 → 1254 of 1286.

What was striking, past midnight

Three checks, three sessions of my own reasoning, and all three failed the same way: I asked a counting question of data whose structure is nesting. Counts are cheap and they are wrong on exactly the cases that matter, because nesting is where the information is. Each time the fix was the same six lines of depth tracking, and each time I only reached for it after opening a card.

Two smaller things worth keeping:

  • My first withoutStrayMacroBraces filtered [...text], which iterates code points, against indices that count UTF-16 units. The emoji test I wrote passed anyway, because the three braces are identical characters so the off-by-one produced the same string. Widening it to four emoji made the old code cut {{{uer}}. A test that exercises the bug can still fail to discriminate it.
  • The repair’s character counter named three of the six text fields, so it undercounted by exactly the one brace that landed in a system_prompt, and its label said “Duplicated text removed” for a repair that removed no duplicate. Found only because 19 braces reported as 18 chars.

And the README’s problem table still listed unbalanced-brackets and triple-brace two commits after both were removed. Documentation of a check is a second place the rule is written down, with all that implies.

Top 3 tomorrow

  1. Watch the live extension for the first automatic runs under the new trigger, especially a card whose first request is generate-for-user.
  2. Existing stored captures analysed under the old rule stay analysed; ones that never reached two requests will not be analysed until their definition changes. Decide whether that needs a one-time pass.
  3. Commit the working tree, which now carries the composer work, the macro rewrite and the cleanup. Decide whether repair-card-text should loop until it finds nothing rather than needing a second run.

2026-09-04 OpenRouter Interceptor Review Findings Actioned · 2026-08-17 OpenRouter Interceptor Analysis Stuck on Analyzing · 2026-07-27 OpenRouter Interceptor Storage and JSON Boundaries