Suzune has had three sensory triggers for a while: kiss, foot, pressure. Say the right thing in chat, and the character reacts in-character — not because an LLM decided to, but because a regex layer upstream caught it and injected a note into the system prompt telling her what just happened to her body.
The fourth one — gaze — sounded like the simplest of the four. Someone looks at Fumina, Fumina reacts. What could go wrong?
The regex almost shipped only handling the word “見つめ” (stare/gaze directly) while completely missing “凝視” (fixed stare), “見入る” (become absorbed in looking), and “目で追う” (follow with your eyes) — three ways native speakers actually write “he’s looking at you” that don’t share a root with the pattern the exclusion filter was built against. Code review caught it before it reached production. Here’s how the trigger works, and why that gap existed in the first place.
Table of contents
Open Table of contents
Why a Fourth Trigger, and Why It’s Different
The existing three sensory triggers detect contact: a kiss, touching a foot, applying pressure somewhere. Gaze detects attention — no contact at all. That distinction matters more than it sounds like it should, because “attention” patterns collide constantly with normal conversation. People say things like “let me look at the document” or “check on the kids” without meaning anything about eye contact with a character.
The existing three triggers had years of production tuning behind their false-positive exclusion lists. Gaze started at zero, in a language (Japanese) with far more ways to express “looking” than English has.
We built it as a fourth kind="gaze" inside the same core/sensory_trigger.py module that already handled kiss/foot/pressure, deliberately not touching the existing three:
# core/sensory_trigger.py
# kiss, foot, pressure: unchanged.
# gaze: new kind, evaluated last (most error-prone → last in judgment order)
Judgment order matters here. If gaze fired before the more established triggers and a message legitimately matched both a foot pattern and a gaze pattern, you want the mature, well-tested pattern to win. New, less-trusted detectors go last.
Personality → Expression Mapping (the one genuinely new layer)
Detecting the gaze is the easy half. The interesting design problem was: how does each character react differently to being looked at?
A shy character and a provocative character shouldn’t produce the same in-character note. We didn’t want a single hardcoded “character notices you staring” string — that reads as generic and breaks immersion for anyone who’s talked to more than one character.
The solution was a per-character gaze_profile.yaml:
# characters/moriya_fumina/gaze_profile.yaml
archetype: provocative
# archetype options: shy / provocative / smug / melt / defiant / composed
The archetype maps to one of the 13 existing emotion categories already defined in data/expressions.json — the same file that drives Suzune’s expression/portrait system. Provocative maps toward emotion-11 (挑発的), shy characters map toward emotion-03 (照れ隠し), and so on through a fallback dictionary from archetype to emotion ID.
We deliberately skipped an existing, more sophisticated system to do this. There’s a separate in-progress feature (internally FP-C5) for computing a character’s “dominant” personality trait dynamically at runtime, and it would have been the “correct” long-term way to drive this mapping. We used it as a reference design but didn’t take the dependency — hand-written per-character YAML files shipped faster and only one character (moriya_fumina) has a profile today. Every other character has no profile file, which means the gaze trigger is a silent no-op for them. That’s not a bug; it’s the rollout mechanism.
Dark-Shipping: Merge It, Don’t Turn It On
The whole feature landed in one commit — detection, mapping, image gating, regression tests — completely inert. Two independent config gates, both defaulting to off:
# config.yaml
sensory_trigger:
gaze:
enabled: false # text injection gate
image:
enabled: false # image generation gate — independent
probability: 0.06
cooldown_minutes: 90
This is the same pattern we use for every trigger-adjacent feature at this point: land the code path in production, keep it fully off via config, and flip the flag in a separate, tiny commit once you’re confident. The text gate flipped a day after the feature merged — commit 3f1aada, a one-line config diff. The image gate is still off as of this writing; sending an unprompted character selfie based on a false-positive gaze match is a much worse failure mode than an unwanted line of text, so it gets its own, later decision.
Compare that to a feature flag stored in a database or an environment variable. The YAML gate lives next to everything else about the trigger, gets code-reviewed alongside the logic it controls, and rolling back is a one-line revert with full git history — no separate admin panel, no runtime state to get out of sync with what’s deployed.
The Bug: An Exclusion List That Only Covered Half the Vocabulary
Here’s where it got interesting. During code review — not by a human reading every line, but by running the change through our standard /code-review pass — a gap turned up in _GAZE_OBJECT_EXCLUDE, the list meant to stop the trigger firing on “looking at” things that aren’t people (documents, screens, the weather).
The exclusion logic had been written and tested against the primary detection pattern, which centers on “見つめ” (gaze/stare directly at). But the actual gaze detection patterns cast a wider net than that single word — they also matched “凝視” (fixed/intense stare), “見入る” (become absorbed watching something), and “目で追う” (track something with your eyes). The exclusion filter simply never saw those three, because it had been built and validated by testing against the word family it was named after, not against the full set of things it needed to guard.
Net effect: someone writing “資料を凝視してる” (“staring intently at the documents”) would have matched the gaze trigger, produced an in-character “he’s looking at you” note, and been completely wrong — the character would react to being stared at by someone who was, in the fiction, looking at a spreadsheet.
The fix was mechanical once found — extend the exclusion set to cover all four detection root words instead of one — but finding it required treating the exclusion list as its own object to audit, not just trusting that “we have an exclusion list” meant the coverage was complete. We added regression tests targeting exactly this gap (tests/test_character_refactor.py) so a future pattern addition can’t silently reintroduce it.
That’s the part worth generalizing past this one feature: when a detector grows new match patterns, its exclusion/false-positive list needs an explicit audit against the new patterns. It does not inherit coverage automatically just because it lived in the same function.
Borrowing the Probability + Cooldown Model
The low-probability image attachment (6% chance, 90-minute cooldown) wasn’t designed from scratch. We copied the shape of an existing proactive system — core/scent.py’s scent-based proactive messaging — which solves the identical problem: this event will fire often; most firings should stay silent so the rare one lands as a surprise instead of a pattern.
Reusing that shape meant we didn’t have to re-litigate the cooldown-vs-probability tradeoff (a pure cooldown makes the timing predictable and gameable; pure probability with no cooldown can double-fire in the same minute) — it’s a solved problem elsewhere in the codebase, so gaze inherited the answer instead of re-deriving it.
What’s Still Off
As of publishing, only text injection is live, and only for one character. Two things are explicitly deferred:
- Image attachment — the probability/cooldown gate exists in code but stays at
enabled: falseuntil we’ve watched the text-only version in production long enough to trust the detection precision. - CCTV and post-image-send reaction triggers — two additional gaze-adjacent trigger types (noticing you watching through an in-fiction camera; reacting after you’ve just received a photo) were scoped in the same design pass but intentionally cut from this release. Both have murkier detection signals than “chat text says someone is looking,” so they’re queued as follow-up work with their own kill switches.
Splitting a feature into stages this granular feels like overhead until the code review catches something like the exclusion list gap above — at which point you’re very glad the blast radius was “one character, text only” instead of “every character, with photos attached.”
FAQ
What is a sensory trigger in an AI roleplay bot? A pattern-matching layer that detects physical/sensory actions in chat text (kiss, foot, pressure, gaze) and injects a contextual note into the system prompt so the character reacts in-character.
Why launch a feature “dark-shipped” with everything off by default? It lets you merge and test infrastructure in production without user-facing risk, then flip a config flag later once behavior is verified — no redeploy needed to activate or kill it.
How do you keep AI image generation from spamming users? Low probability (we used 6%) combined with a cooldown window (90 minutes) on top of the trigger match, borrowed from an existing proactive-messaging system.