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:
| Kind | Revisions |
|---|---|
| Layout migration, 2026-09-04 | 3,968 |
| Parser rebuild, 2026-09-05 | 104 |
| CRLF noise or identical re-capture | 52 |
| Different bot under the same name | 16 |
| Creator edit | 13 |
| Lorebook changed, definition identical | 4 |
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 name | cards | macros |
|---|---|---|
| the listing title already contains it | 36 | 419 |
| the page-scraped greeting names them | 53 | 937 |
| it appears elsewhere in the card | 8 | 79 |
cast tag, {{char}} still determinable | 12 | 414 |
| cast tag, nothing distinguishes them | 6 | 23 |
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:
| was | 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) |
| now | Name: 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.
isScenarioTitlewas inline inneedsCleaningwhile 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. Nowsrc/character-card/macros.ts, besidenames.ts, applied by the chain and asked by both the validator and the repair. NAME_PARTICLESwas about to be a second copy; moved tonames.tsfirst.
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_TAGwritten 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-entitywas a warning no repair could act on, so three cards had carried<150 wordssince they were built. Decoding existed only insidehtmlToText, which also strips tags and rewrites the line structure — far more than a stored card should go through to turn<into<. SplitdecodeEntitiesout.
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:
| written | reaches 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
withoutStrayMacroBracesfiltered[...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
- Watch the live extension for the first automatic runs under the new trigger, especially a card whose first request is generate-for-user.
- 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.
- Commit the working tree, which now carries the composer work, the macro
rewrite and the cleanup. Decide whether
repair-card-textshould loop until it finds nothing rather than needing a second run.
Related
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