2026-07-24 OpenRouter Interceptor Analysis Prompt Extraction
What I set out to do
Read the newest diagnostic capture (2026-07-24T22-08-59-570Z-diag-last-bot-bot-li.json, 1.0 MB), confirm the analysis query for the JanitorAI card “The Reunion”, and tweak the prompt that produces it.
What I actually did
Confirmed the query, discovered the capture can only show half of it, recovered the other half from outside the bundle, made the whole request reproducible from the CLI, then rewrote the prompt against the real card over two rounds.
What the capture shows. Sidebar on the Queries tab with bf4da02f expanded: deepseek/deepseek-v4-flash, completed, against capture mrzhpuiz1dwkeqhfg8m, active tab janitorai.com/chats/2704862514 titled “The Reunion”. expandedQuery.cardHtml carries the fully rendered card including the four-section output. 100 queries total, 20 in the recent list.
What it structurally cannot show. Query records are {id, model, status, result, timestamp, captureId} and nothing else: no customPrompt on any of the 20, no resolved prompt, no request body. stripHeavyFields (sidebar.ts:978) deliberately deletes systemPrompt and requestData.messages from every capture in the bundle. So a capture can show what the prompt produced but never what it was, and “the wrong prompt ran” is not a diagnosable class of bug from a bundle.
The gap closes itself. The auto-download already writes the missing field to disk. The Reunion.png (3.6 MB, 18:07 local) sits in the Firefox download dir for that PWA profile, which is the SillyTavern characters folder, the same directory holding the openrouter-captures symlink. Its chara tEXt chunk decodes to data.extensions.openrouter.original_system_prompt, 19,133 chars, the verbatim capture.systemPrompt. capture_metadata.captured_at is 22:07:37Z against a query at 22:08:22Z, 45 seconds apart, same run. No bundle change needed.
Extraction (option B of two). The prompt constants were module-private consts in message-router.ts:5-49, so nothing but the background bundle could read them and any harness would be a hand transcription that silently drifts on the next edit. Moved USER_PERSONA, ANALYSIS_SYSTEM_PROMPT, DEFAULT_ANALYSIS_PROMPT, CUSTOM_ANALYSIS_TEMPLATE and buildAnalysisPrompt into src/data/analysis-prompt.ts, next to the existing saved-prompts.ts. Text verified byte-identical to HEAD by diff, not by eye. TDD: analysis-prompt.test.ts written first and watched fail on the missing module; the 20 existing router tests are the regression guard on the wiring.
Harness. scripts/try-analysis-prompt.ts (npm run prompt:try) takes a card name or path, pulls original_system_prompt via the existing scripts/lib/character-card.ts, builds through the same exported buildAnalysisPrompt, and sends through the same OpenRouterService, so params and refusal handling match the extension too. --instructions swaps a variant in without rebuilding, --dry-run prints the assembled messages. Dry run on “The Reunion” reproduces a 19,745-char user message, diffed byte-identical against the earlier hand reconstruction.
Two incidental changes fell out: DEFAULT_CARDS_DIR moved into scripts/lib/character-card.ts rather than being duplicated, and openrouter-service.ts now casts response.json() explicitly, which is a no-op under DOM lib but required to import the service into the node-typed scripts project.
The prompt rewrite. The complaint: the output read like a description, and “You play” was generic identity restatement rather than the situation Dragon is dropped into. The diagnostic example was the card stating **Function:** The hidden knife. She looks like the safest person in the room, but every wound traces back to her, which the old prompt rendered as “Talia Rosen, Dragon’s loyal friend with a secret”. It read the card and then withheld it.
New instruction block names the reader (someone deciding whether to start the roleplay), says Spoil it, bans the specific hedges the old output produced, and tells the model to prefer the card’s own statements of function and motive over the surface impression a player would get. That last clause is what unlocks the **Function:** lines.
Iterated live through the harness across five runs, well under a cent total. v1 shipped six sections at 2,525 chars, too long for a 477px sidebar; caps brought it to 1,788. Second round cut further on request: dropped Sharp read, folded You’re walking into into Premise, and took Buried in the card from 3 points to 1-2. Final shape is Premise / Cast / Buried in the card / Tags at 1,470 chars. Spot-checked every spoiler against the card: the timer is line 126 verbatim, and “Callum is the decoy” is the guardrail on line 128. Nothing hallucinated.
Shipped as f072917, merged --no-ff as af5d4ec, condensed in 016c089.
What was striking
- The bundle’s blind spot had already been filled by a different feature. Auto-download exists to archive cards, not to support debugging, but it is the only thing on disk that preserves the exact analysis input. The instinct was to extend the capture schema; the right answer was to notice the data was already there. Worth checking before adding a
diagnostic.*field. - Module-private constants are a testability boundary, not just a style choice. Nothing was wrong with the prompt living in the router until something outside the extension needed to send the identical bytes.
- Section labels do real work. Renaming “You play” to “You’re walking into” changed the answer as much as the instruction text did. The old label asked “who are you”, so it got a bio. Merging that section back into Premise a round later kept the gain, because the instruction had by then absorbed the “situation not identity” rule.
- Every cut made it better, not just shorter. 2,525 → 1,788 → 1,470 chars, and each round read sharper. Forcing “the 3 sharpest things” produced better prioritization than an open list; cutting to 1-2 made the model consolidate related facts into one bullet rather than drop them, so it leads with a flat “Talia is the saboteur” instead of burying it mid-sentence. Caps are an editorial instruction, not just a budget.
- Format instructions need to name the form. At 1-2 points the model ran “Buried in the card” together as prose while Cast stayed a list. “One bullet each, same list style as Cast” fixed it. The count changed the form without being asked to.
- Dollar-pattern bug, found and fixed.
buildAnalysisPromptinlined the card as the replacement argument ofString.replace, so the four substitution patterns (ampersand, backtick, apostrophe and doubled dollar, each after a$) got interpreted instead of inserted. A card containing the ampersand form would have had{{systemPrompt}}re-inserted into its own text; the backtick form re-inserts everything before the match; a doubled dollar collapses to one. Custom instructions from the modal had the same hole. Fixed by passing the value as a function (replace(placeholder, () => value)), which suppresses pattern interpretation entirely. TDD’d, and the red output showed the corruption verbatim. - Then the same bug bit the vault. Patching this very note through
mcp__obsidian__patch_notecorrupted it: the tool does a plainString.replace, my replacement text quoted the dollar patterns literally to describe them, and they expanded into duplicated copies of the whole note. Had to rewrite the file wholesale. Two independent tools with the identical defect within ten minutes, the second one discovered by writing about the first. Any “replace this string with that string” API is suspect unless it takes a function or escapes the replacement.
Verification
480/480 unit tests (35 files), tsc --noEmit clean across all three projects, npm run build clean, lint clean, on main after each commit. Prompt text diffed byte-for-byte against HEAD at extraction time. Harness dry-run output on the real card diffed identical before and after the dollar-pattern fix, confirming it changed nothing for ordinary input. Five live API runs verified each prompt revision end to end.
Open follow-ups
- The new default has only been exercised on one card, and an unusually rich one with explicit
**Function:**lines and narrator guardrails. Worth running the harness over a few thinner cards in the downloads dir to see whether “Buried in the card” still finds anything.