2026-08-28 OpenRouter Interceptor Analyses a Character It Sees Twice

What I set out to do

Make the extension analyse a character card on its own once there have been at least two requests to it and no analysis exists. Until now every analysis was something you had to click for, which meant the cards worth analysing were exactly the ones you were too busy playing to analyse.

What I actually did

Two decisions up front, both settled by asking rather than guessing: the trigger is gated by an options toggle and a configurable threshold (not hardcoded, not always-on), and “zero analysis” means no query at all — a pending run is not duplicated, and one that errored is not retried on my credit behind my back.

The rule is a pure function. shouldAutoAnalyze in src/utils/auto-analysis.ts takes the capture, the stored queries, and the settings, and is unit-tested (11 cases, written before the implementation). Nothing about the decision lives inside the webRequest handler that acts on it. The threshold resolver follows the resolveMaxCaptures shape exactly: a positive number wins, anything else — unset, zero, NaN from an empty input — falls back to the default of 2.

The run goes through the code the router already used. runAnalysis was private to message-router.ts, so it moved to a new src/background/analysis-runner.ts exposing beginAnalysis, and RouterDeps now extends AnalysisDeps. ANALYZE_CAPTURE, REANALYZE_QUERY, and the automatic path are one code path; the background wires one analysisDeps object and spreads it into the router. 44 router tests stayed green through the move, which is the point of them.

Three things I had to get right that the feature description does not mention.

  1. upsertCaptureByCharacter returned only the array, and the entry the caller needs is the merged one — the one carrying requestCount and the surviving id. Reading it back as captures[0] is an invariant that only lives in a comment, and noUncheckedIndexedAccess is right to object. It now returns {captures, entry}. Same lesson as consolidate’s three parallel maps: the fix for an assertion is usually a data structure that cannot be missing the thing.
  2. shouldAutoAnalyze reads storage, and the write that would make it answer “already analysed” is an await later. Two requests to the same character inside that window both see zero analyses and both start a run. An in-flight Set<CaptureId> makes the check-and-start one turn.
  3. The extension’s own analysis request goes to openrouter.ai/api/v1/chat/completions, which is the endpoint being intercepted. No loop, but only because two guards already existed: the initiator check against the extension URL, and tabId === -1. Worth having confirmed rather than assumed.

Two DRY debts paid on the way through. The default settings object was duplicated verbatim in background.ts and options.ts — a setting added to one is a setting the other silently resets on the next save, which is exactly what I was about to do to it. Now DEFAULT_SETTINGS in capture-settings.ts. And "openai/gpt-4o-mini" was spelled out in five places; now DEFAULT_ANALYSIS_MODEL in constants.ts — which then made changing the default a one-line job, and it is now ~deepseek/deepseek-v4-flash-latest, the floating alias already at the top of the curated list. FALLBACK_MODEL_OPTIONS had to lead with it too: a dropdown that cannot offer the default silently selects its first entry, so a failed catalogue fetch would have quietly moved analyses onto GPT-4o Mini.

The sidebar needed telling. Its analyses reload only on ANALYSIS_UPDATED, so an automatic run would have appeared out of nowhere already finished. CapturesUpdated is now a named schema beside AnalysisUpdated, and features/queries.ts reloads on it, so the row says “Analyzing…” while the background run is going.

Settings chain wired end to end, because a control that silently does nothing is worse than no control: Settings, the three schemas (storage, messages, responses), options.html, controls.ts, options.ts, plus the README and an “Automatic Analysis” section with a sequence diagram in docs/architecture.md.

1003 unit tests and 303 Playwright green, lint clean, dist rebuilt.

What was striking

Both new settings are optional on Settings, not required, and that was not a shortcut — autoAnalyze !== false is the same shape as shouldCapture’s autoCapture !== false. A settings blob written by a build that predates a setting has to have an answer, and “on unless explicitly turned off” is a different default from “off unless explicitly turned on” in a way that only shows up on an upgrade. Making them required would also have broken a test fixture that omits every optional field, which was the codebase telling me the same thing.

The threshold being a <select> rather than a number input was a five-second decision that removed a whole class of input validation. The options page has no styling for text inputs anyway.

Top 3 next

  1. Verify in the live PWA: open a card twice, watch the row go to “Analyzing…” without touching anything
  2. Decide whether an errored analysis should eventually get a retry — the current answer is deliberate but may not survive contact with a flaky provider
  3. Commit