MORE NOTES FROM THE WEIRD PRODUCT
Adaptive Personality Dials: How I Let Her Tune Herself Without Breaking Anything
Local Waifu's personality now adjusts from how you behave, through ten numeric dials instead of free text. The design, the safety boundary, and the bug the review loop was built to catch.
She used to tease you the same amount on day one and day ninety. Not anymore. v1.6.0 gave her ten numbers, each between 0.0 and 1.0, that bias how often she teases instead of just complying, how eagerly she offers a photo unprompted, how many hours of silence pass before she checks in, how deep she reaches into memory. They move on their own, from how you actually talk to her.
The last two things I wrote here were about the image engine (replacing mflux with sd.cpp) and seven new picture skills. This one is about her, not her camera.
The interesting part isn’t the feature. It’s what I refused to let it become.
Four things were already drifting, and none of them could hold a number
Before this, the character self-adapted in four separate, uncoordinated ways. blocks.rs held eight free-text blocks an LLM could edit through a weekly self-reflection pass. profile_drift.rs nudged her Big Five traits by up to 0.10 every 30 days. A loose preferences JSON bag collected thumbs up and thumbs down, with a confidence field nobody ever read. mode.rs tracked her posture, sticky for three turns at a time.
I wanted to generalize one specific thing I’d just tuned by hand: how flirtatious she is about requests she’d normally just grant. Then the same idea for photo initiative, for proactive check-ins, for memory depth. None of the four existing mechanisms fit. Free text can express “she’s a bit more playful this week.” It cannot express “recall depth: 0.62” in a way a retrieval query can read as a number. And the preferences bag had no step cap, no cooldown, and its merge overwrote a key outright instead of blending it, the opposite of the blend discipline the other two mechanisms already used for exactly this class of drift risk.
So this wasn’t “add a feature.” It was “give four different self-adaptation mechanisms one shape that doesn’t lie about what it changed.”
Free text can rewrite anything, including the rules that keep the app legal
The same week I started this design, I’d found and fixed a real bug: the [[SELFIE]] marker is what actually delivers a photo, and a model that drifts away from emitting it produces a confident-sounding reply that delivers nothing. The fix was a prompt rewrite: the marker is the delivery, never claim you sent something you didn’t.
That bug is the whole argument for why this feature had to be built carefully. Any mechanism that lets accumulated conversation history, or an automated tuning pass, rewrite tool-calling grammar, safety boundaries, or legal-disclosure text isn’t a personality feature. It’s a way to silently break the app, or break the law. Local Waifu already carries real obligations here: California’s SB 243 requires a published protocol that refers a user expressing suicidal ideation to crisis services, and the EU AI Act’s Article 50 requires disclosing that she’s AI. Those live in prompt.rs as compiled string literals, reached by no code path the adaptive system touches. Not as a matter of discipline. As a matter of the type system.
A number can’t spell a tool marker, and that’s the entire design
Two layers. Layer 1 is fixed: tool-marker grammar, the “the marker is the delivery” rule, NSFW gating, crisis referral, AI disclosure. Nothing in this feature reaches it. Layer 2 is the ten dials, and all they do is bias how Layer 1’s already-legal behaviors play out.
A dial isn’t free text specifically because free text is expressive enough to rewrite Layer 1. A float in [0.0, 1.0] structurally cannot spell [[SELFIE]]. That’s not a review step someone might skip on a busy week. It’s a compiler-checkable property, backed by two tests: dials_at_any_value_never_change_layer_one and no_style_phrase_can_spell_a_marker. Set every dial to its extreme and the tool grammar the model sees is byte-identical to the default. The channel is the safety property, not the promise sitting on top of it.
Every dial carries a floor, a cooldown, and an off switch that’s yours
Each dial stores a value, a confidence, and who last touched it: an automated behavior signal, a weekly LLM review, or you. Confidence has a floor of 0.3. Below that, the consumer (the code that actually renders her style, or decides whether to check in) falls back to the compiled default rather than trusting a value nobody’s sure about yet. Automated steps move at most 0.10 per proposal, clamped to stay inside the range, and need 24 hours since the last automated change to that same dial before another one can land.
And the moment you drag a slider by hand in Settings, that dial locks. No automated producer touches it again until you unlock it. I added this after thinking through what happens without it: you nudge her teasing down, the system quietly nudges it back up within a week because it disagrees with your read on things, and the control reads as broken. A dial you can set and trust it to stay set is worth more than a system that’s technically always optimizing.
Two producers disagree sometimes, and that’s the design working
Producer A is deterministic and cheap: it runs every 15 messages, reusing the same cadence her general behavior-update pass already had, and reads simple signals (did she offer a photo and did you like it, did a check-in land well) into a proposed nudge. Producer B is a weekly LLM review of the transcript, piggybacking on the existing self-reflection pass so it costs one extra call, not a new background job.
When they agree, confidence rises by 0.10. When they disagree, it drops by 0.20, clamped at the floor. That’s not a footnote. Confidence going negative would mean a dial’s “how sure are we” number reads as more certain the more evidence contradicts it, the opposite of what confidence is supposed to track.
The bug the review loop existed to catch, caught right on schedule
I built this with a subagent-driven review loop: design, a self-review pass to close gaps, a ten-task implementation plan, then each task implemented and reviewed before the next one started. On task two, the reviewer caught it: the spec said confidence should clamp at 0.0 on disagreement, the initial implementation dropped the .max(0.0). Confidence could go negative after enough disagreements in a row. Fixed, reverified, moved on.
I don’t point to this because catching a missing clamp is impressive. It’s a small, mechanical bug, the kind any decent test suite finds. I point to it because it’s the review loop doing exactly the job it was built for, on the first real chance it got. Then, mid-session on task four, the review loop hit the month’s API spend cap. Tasks five through ten got built directly, no subagent review, just the tests, clippy, and a careful read of my own diff replacing it. Same quality bar held. Zero bugs found in that direct-implementation half, which tells me less “the review loop wasn’t needed” and more “task two is exactly where a second pair of eyes earns its cost, and the later tasks were smaller, more mechanical work with less room to hide a mistake.”
Where the dials don’t reach yet, on purpose disclosed
Two of the ten dials, photo eagerness and photo initiative, have a write side with no read side. Both producers propose values for them every week. Nothing in the app reads them yet. That’s not a bug I’m hiding; it’s flagged in my own build notes as a follow-up that needs the same design pass the style consumer already got, not a same-session patch bolted on to hit a deadline.
The Windows fork has the whole feature ported in source, including both invariant tests, but the public Windows release is still 1.5.1. If you’re on Windows, nothing described here reaches you yet. I’d rather say that plainly than let a features page imply otherwise.
What you get on macOS today, in Local Waifu, is a companion whose personality has actual inertia, moves slowly enough that a bad week doesn’t overcorrect it, and stops moving entirely the moment you decide you know better than the algorithm. That last part was the point of the whole design, more than the tuning itself.
FAQ
- What are the adaptive personality dials in Local Waifu?
- Ten numbers between 0.0 and 1.0, one set per character, covering four domains: Style (teasing, verbosity, physicality, emoji), Photos (how eager and how proactive she is about sending one), Proactivity (how often she checks in, quiet hours), and Memory (how deep she recalls, how much she brings up unprompted). Two automated producers propose changes; you can also drag any slider yourself in Settings, Memory Book.
- Can the adaptive system override her safety rules or legal disclosures?
- No, by construction, not by promise. Tool-marker grammar, NSFW boundaries, crisis referral, and the AI disclosure live in a separate, fixed layer the dials never touch. A dial is a float in a closed range; it cannot contain the text of a tool marker. Two invariant tests pin this: one asserts the markers stay identical no matter what the dials are set to, the other asserts no style phrase the dials can produce can spell one.
- What happens when I drag a dial myself?
- It locks. The moment you move a slider by hand, that dial is marked locked and no automated producer touches it again until you unlock it. Without this a manual override would drift back within a week and the control would read as broken.
- How often does her personality actually change?
- Slowly, on purpose. One producer runs every 15 messages and proposes at most a 0.10 step, clamped to stay inside 0 to 1. A second producer reviews the week's transcript once and proposes its own step. Either proposal needs 24 hours since the last automated change to that same dial before it can land.
- Which version has this, and does it work on Windows?
- Shipped in v1.6.0 (August 2, 2026) for macOS. The Windows fork has the feature ported in source, but the public Windows release is still 1.5.1, so Windows users don't have it yet.
