Local branch had diverged from origin/master (sibling commits on the same base). Rewrote local history linearly on top of origin/master, folding in all local content: exa/hot-platform discovery hunters, leaks ledger, nightly sweep orchestrator, updated .gitignore and skill docs, plus the latest hunt output and vault state. Remote-only files (vault keys, channel scripts) were restored rather than dropped, so the resulting tree is a full union of both sides.
1 line
344 KiB
JSON
1 line
344 KiB
JSON
{"repo": "biggieb327-lgtm/sillytavernpresets", "files": {"telegram-companion-bot/CHANGELOG.md": {"72ef57da37b976375b4f2d16a461c97d41e56bc1": {"t": 1786068552.594794, "n": 343477, "h": "724a46bd20476c5f", "c": "# Changelog — telegram-companion-bot\n\nRead this before making changes; add an entry after shipping one. See `CLAUDE.md` for\nwhy this file exists and the rule for keeping it updated.\n\nEntries are newest first. Each one names the actual root cause, not just the code diff —\nthat's the part worth reading twice, since re-diagnosing a solved problem from scratch is\nexactly what this file is meant to prevent.\n\n## v2026-08-06.1 — Add xAI Grok Imagine as a third selfie provider\n\n**Root cause: not a bug fix — a requested provider option.** `SELFIE_PROVIDER` already\nswitched between \"gemini\" (Google's Gemini API directly) and \"nanogpt\" (NanoGPT's image\nendpoint); the owner asked to add xAI's Grok Imagine as a third choice, called directly\nrather than through NanoGPT's proxy.\n\n**What shipped:** `SELFIE_PROVIDER=xai` routes through a new `_generate_selfie_xai`,\nmirroring the shape of the existing Gemini/NanoGPT functions. It calls\n`XAI_IMAGE_URL/edits` when a reference photo is set (`_has_base_image()`, same face-lock\npath the other two providers use) and `XAI_IMAGE_URL/generations` otherwise, defaulting\nto `grok-imagine-image-quality`. `XAI_API_KEY`, `XAI_IMAGE_MODEL`, `XAI_IMAGE_URL` are new\nenv vars (all optional except the key, required only when `SELFIE_PROVIDER=xai` — same\nfail-fast-at-startup pattern the Gemini path already uses). `SELFIE_SIZE`'s \"WxH\" pixel\nstring is converted to xAI's `aspect_ratio` ratio format via GCD reduction\n(`_xai_aspect_ratio`) rather than a hardcoded lookup table, since any per-instance\n`SELFIE_SIZE` value needs to carry over, not just the couple of sizes already in use.\nxAI's `b64_json` response field has been observed in the wild both as raw base64 and as a\nfull `data:image/...;base64,...` URI (the two mirrors of xAI's own docs disagreed, and\nthe docs page itself 403s to a plain fetch) — decoding strips the `data:` prefix if\npresent rather than assuming one shape.\n\n**Verification:** `TestXaiAspectRatio` (4 tests) + `TestGenerateSelfieXai` (6 tests,\ncovering both endpoints, the b64_json/data-URI ambiguity, the URL-fallback path, and the\nneither-field error) + `_selfie_provider_label`/`gather_audit_data` cases extended for\n\"xai\" the same way the existing gemini/nanogpt cases work. `python3 -m py_compile bot.py`\nclean. `bash .claude/tools/verify.sh` — see run output in the PR/session record.\n\n## v2026-08-04.7 — Model-version guard + cap backported to the memory/lore embedding caches\n\n**Root cause: episodic recall's design was more careful than the caches it sits next\nto.** Comparing episodic recall's (v2026-08-04.6) archive design against the\npre-existing `_embeddings_cache`/`_lore_embeddings` (memory/lore semantic recall,\nshipped independently on `main` 2026-07-06 — a sibling design, not an ancestor of the\nepisode code) surfaced a real, live gap: `_load_embeddings`/`_load_lore_embeddings`\nhad no model-fingerprint check at all. Changing `EMBEDDING_MODEL` would silently mix\nvectors from different models into the same cosine comparison, producing meaningless\nsimilarity scores with no error — external research on this exact failure mode\n(embedding cache invalidation) confirms it's a well-documented pitfall: degradation is\ngradual and distributional, not a single wrong answer, so it goes unnoticed for days.\n`_embeddings_cache` also had no cap: unlike memories.txt/facts (already bounded and\nconsolidated), it keeps every distinct text ever embedded, including lines later\nreplaced during consolidation — genuinely unbounded growth over the bot's lifetime.\n\n**What shipped:** both caches now write a `.model` sidecar fingerprint file\n(`.embeddings.model`, `.lore_embeddings.model`) on save and check it on load, discarding\nand rebuilding from scratch on mismatch — same pattern episodic recall already used for\n`.episodes.model`. `EMBEDDINGS_MAX` (default 5000) caps `_embeddings_cache`, trimming\nto the newest entries by insertion order on both load and save. No cap added to\n`_lore_embeddings`: lore entries are bounded by the character card's lorebook size, not\norganically growing at runtime the way conversational facts are, so a cap there would\nguard against nothing.\n\n**Web research done before writing any code** (per the owner's request, to check for\nbetter patterns before building): confirmed model-version fingerprinting as the\ncritical, well-documented fix; confirmed a flat-JSON cache with a version sidecar is\nitself a legitimate lightweight pattern for a personal-scale system, not something to\nreplace with heavier machinery (Redis/LRU libraries, vector DBs) that this system's\nscale doesn't call for. Nothing else in the research suggested a change beyond what\nepisodic recall's own design had already demonstrated.\n\n**Verification:** `TestEmbeddingsCacheGuard` + `TestLoreEmbeddingsCacheGuard`\n(12 tests) — round-trip, fingerprint discard on mismatch, fingerprint kept on match,\ncap trimming on both load and save, no-op when not dirty, no raw exception in the save\nlog (structural check, matching the Garmin/WSDOT convention). Break-tested RED three\nways (both model-mismatch guards disabled, the load-time cap removed). `bash\n.claude/tools/verify.sh` green: 1051 passed, 38 evals, 45/45 gate-corpus.\n\n## v2026-08-04.6 — Episodic recall + on-this-day reminiscing, reimplemented from a deeper dependency chain\n\n**Root cause: the biggest thing on the lost branch, and not a simple port.**\n`9fa21af` (2026-06-29) built episodic recall; `a485b1b` (2026-06-30) built on-this-day\nreminiscing on top of it. Both are from `claude/push-to-repo-7i2f3c` and never merged.\nUnlike the four earlier ports this session, this one could not be a straight\nreimplementation: the abandoned branch's episodic recall was built on that branch's\nOWN embedding infrastructure (`EMBED_MODEL`, a batch `_embed()` call, a numpy-matrix\nvector cache) — none of which exists on `main`. Current `main` has a completely\ndifferent, simpler embedding subsystem (`EMBEDDING_MODEL`, single-text `_embed_text`,\na flat `_embeddings_cache` dict, no numpy at all before this session). This is\nrewritten against `main`'s actual primitives, not ported.\n\n**What it does:** when conversation ages out of the verbatim window (`maintain_memory`\nscroll-off), the dropped turns are chunked (`EPISODE_CHUNK_MSGS`), embedded one at a\ntime via the existing `_embed_text`, and archived to `.episodes.jsonl` — a numpy\nmatrix in RAM for fast cosine similarity (numpy is a real dependency as of\nv2026-08-04.4, no longer a reason to skip this). Each turn, `triggered_episode` reuses\nthe query vector already computed for live semantic recall (zero extra per-turn embed\ncost) to pull back the single most relevant past exchange above `EPISODE_MIN_SIM`,\ntime-gated by `EPISODE_MIN_AGE_HOURS` so the live window is never echoed back to\nitself. `EPISODE_MAX` caps the archive (~4000 chunks); a model change discards and\nrebuilds it, since cross-model vectors aren't comparable. `/episodes` shows the\narchive size.\n\nOn top of that, `onthisday_job` runs once daily: if an archived episode's anniversary\n(~1mo/6mo/1yr ago, `ONTHISDAY_INTERVALS`) lands today, she reaches out unprompted to\nreminisce about it (\"hey, remember when...\") — min-gap and per-episode dedup keep it\nfeeling special, not chatty. `/diag` extended to report both toggles and the archive\ncount, matching how the abandoned branch itself grew `/diag` incrementally as each\nfeature landed.\n\n**Deliberately not ported in this pass:** the branch's two follow-on commits —\n`1054506` (archiving sent photos as episodes) and `767aab6` (an optional cross-encoder\nreranker) — are enhancements to this subsystem, not required for on-this-day\nreminiscing to work. Flagged as separate follow-ups rather than folded in, keeping\nthis release to one theme.\n\n**Verification:** `TestEpisodesCore` + `TestTriggeredEpisode` + `TestOnThisDay` +\n`TestEpisodesCmd` + `TestMaintainMemoryArchivesOnScrollOff` +\n`TestAssembleMessagesEpisodicRecall` + `TestEpisodicConfig` (34 tests) — archive\nround-trip, model-change discard, cap trimming, similarity floor, age-gating,\nanniversary-window matching (including \"prefers the longer interval\" and exclude-ts\ndedup), the `maintain_memory` scroll-off wiring (a real call through `maintain_memory`\nitself, not a source-read), and the new `/episodes` command driven directly. Break-\ntested RED five ways (similarity floor, age gate, anniversary window, scroll-off\nwiring, model-mismatch discard — each removed, confirmed the matching test failed,\nreverted). Also fixed two things `verify.sh` caught that weren't part of the plan:\nan eval-pinned optional-block count needed bumping from 7 to 8 (a real new block, not\na bug), and two hardcoded `pip install` strings needed to go through the existing\n`_pip_hint()` helper instead. `bash .claude/tools/verify.sh` green: 1039 passed, 38\nevals, 45/45 gate-corpus, sweep 0 candidates.\n\n## v2026-08-04.5 — /diag: a compact behavior-toggle status command\n\n**Root cause: the fifth thing from the same lost branch, scoped down rather than\nstraight-ported.** `71dfa44` (2026-06-29) added `/diag` bundled with log rotation and\nan RHR monitor. The RHR monitor already shipped separately (`RHR_ALERTS` exists on\n`main`). Log rotation was Termux-era (`run-bot.sh` size-check-and-`mv`) — the fleet's\nbeen on systemd since 2026-07-26, and `errors.log` already rotates properly via\nPython's `RotatingFileHandler`, which is strictly better. `/diag`'s own design also\ndoesn't fit as-is: its log-error tail duplicates the existing `/errors` command, and\nits flag list (`EPISODIC_RECALL`, `SCENE_CONTINUITY`, `EVENT_REMINDERS`,\n`READING_ENABLED`) names branch features not on `main`.\n\n**What shipped instead:** `/diag` as a compact status line for the toggles this\nsession added — semantic memory, safety, style mirror, offline life, voice tone,\ngarmin/stress/RHR/body-battery — a genuinely different axis from `/audit`'s\n`_FEATURES` dict (selfie/meme/gif/voice-backend/traffic/maps/health integrations),\nnot a duplicate of it. `_is_allowed`-gated like the original, not admin-only.\n\n**Verification:** `TestDiagCmd` (4 tests) — answers, reports the new toggles by name,\ngated for non-allowed users, and an explicit check that the log-error tail stays\ndropped. Break-tested RED two ways (the gate check removed, two toggle lines\nremoved). `bash .claude/tools/verify.sh` green: 1009 passed, 38 evals, 45/45\ngate-corpus.\n\n## v2026-08-04.4 — Voice-note acoustic tone analysis, reimplemented from the same abandoned branch\n\n**Root cause: a fourth feature from the same lost branch.** `bae2dcb` (2026-07-01,\n`claude/push-to-repo-7i2f3c`) vendored the offline half of `menelly/AI_Ears` (MIT) as\n`acoustic_ears.py` and it never merged either.\n\n**What it does:** `VOICE_TONE_ENABLED` runs a local FFT analysis on every voice note\n(pace, volume, pitch brightness, notable pauses) — pure NumPy, no network call, no\nextra API key — and folds a short note (\"~140 wpm, dynamic volume, warm tone\") alongside\nthe transcript. `_analyze_voice_tone` kicks off concurrently with the existing NanoGPT\ntranscription call in `handle_voice`, so it adds no serial latency; cancelled cleanly if\ntranscription fails or comes back empty. `acoustic_ears.py` is vendored unmodified.\n\n**Two things beyond bot.py:** `numpy` added to `requirements.txt` as a real dependency\n(not commented-out-optional like `garminconnect` — no risky native build, every instance\nhandles voice messages). `deploy/vps-sync.sh` only copies explicitly-named files, not a\ndirectory sync, so `acoustic_ears.py` needed an explicit sync line next to `bot.py`'s —\nwithout it the feature would have silently never reached any instance, same failure\nshape the abandoned branch's `update-all.sh` fix already worked around once.\n\n**Verification:** `TestAcousticEars` (vendored-module tests against a synthetic WAV:\ntone analysis, empty-audio error path, `describe_acoustic` formatting including wpm/\npause counts) + `TestAnalyzeVoiceTone` (the bot.py wrapper: missing-output-file\nfail-safe, success path, ffmpeg-exception fail-safe) — 10 tests. Break-tested RED three\nways (the missing-wav-file check removed, `describe_acoustic`'s None-guard removed, the\npause-count line removed). `bash .claude/tools/verify.sh` green: 1005 passed, 38 evals,\n45/45 gate-corpus.\n\n## v2026-08-04.3 — Offline life events, reimplemented from the same abandoned branch\n\n**Root cause: a third feature from the same lost branch.** `b0eb485` (2026-06-29,\n`claude/push-to-repo-7i2f3c`, same branch as the safety detector and style mirroring)\nbuilt offline life events and it never merged either.\n\n**What it does:** `LIFE_SIM_ENABLED` generates ONE concrete event in her own world a\ncouple times a day (`LIFE_EVENT_TIMES`, default 13:00/20:30) — grounded in her\nschedule/people/projects/life-arc, a cheap chat model call, no embeddings. Stored in\n`life_events.txt` (capped at `LIFE_EVENTS_MAX`), injected into `assemble_messages` as\n\"What's been happening in NAME's life\" and into `_generate_proactive_hook`'s context,\nso unprompted check-ins carry real news instead of generic small talk. `/news` shows\nrecent events, `/newsnow` forces one. All helper functions (`_read_schedule_today`,\n`_read_people`, `_read_projects`, `_read_life_arc`) already existed on `main` —\nthis port reused them rather than rebuilding anything.\n\n**Verification:** `TestLifeEvents` + `TestAssembleMessagesLifeEvents` +\n`TestNewsCommands` (17 tests) — file round-trip, cap enforcement, \"none\"-response\nfiltering, broken-classifier fail-open, the on/off wiring into `assemble_messages`,\nand both new `*_cmd` handlers driven directly (matching the delivery gate's\ncall-not-mention requirement). Break-tested RED three ways (cap enforcement removed,\n`assemble_messages` wiring removed, \"none\" filter removed). `bash\n.claude/tools/verify.sh` green: 995 passed, 38 evals, 45/45 gate-corpus.\n\n## v2026-08-04.2 — Adaptive texting-style mirroring, reimplemented from the same abandoned branch\n\n**Root cause: another feature built once and lost.** `a485b1b` (2026-06-30, same\n`claude/push-to-repo-7i2f3c` branch as the safety detector) built `STYLE_MIRROR` and it\nnever merged either. Found during a follow-up audit of that branch for other\nunreferenced work, prompted by the owner asking for the rest of what `ROADMAP.md` 3.10\nalready flagged as unported.\n\n**What it does:** `_user_style_note` passively reads the user's last `STYLE_SAMPLE`\nmessages (default 20, needs at least `STYLE_MIN_MSGS`=6) and nudges her register to\nsubtly match — message length, emoji use, lowercase habits, exclamation frequency,\ncasual textspeak (lol/idk/rn/tbh). **Zero model calls** — pure heuristics off the\nin-RAM `conversation_history`, so it adds no per-message LLM cost or latency at all\n(no rule-3 question here, unlike the safety detector). Bracket-tagged synthetic\nentries (`[sent ...]`, heartbeat messages) are excluded from the sample. Injected into\n`assemble_messages` right after the texting-style/preset-layer block. On by default,\n`STYLE_MIRROR=0` disables.\n\n**Verification:** `TestUserStyleNote` (11 tests) — each trait heuristic (short/long,\nemoji high/low, lowercase, textspeak), too-few-messages silence, bracket-tag exclusion,\nthe on/off wiring into `assemble_messages`. Break-tested RED two ways (the disabled\nearly-return skipped, the `assemble_messages` wiring removed). `bash\n.claude/tools/verify.sh` green: 978 passed, 38 evals, 45/45 gate-corpus.\n\n## v2026-08-04.1 — Safety: distress detection, reimplemented after being built once and never merged\n\n**Root cause: this feature already existed, once.** `d141e84` (\"Add safety: detect\ngenuine distress and respond with care\", 2026-06-29) built `_assess_safety` and shipped\nit on `claude/push-to-repo-7i2f3c` — but that branch diverged from `main` on 2026-06-24\nand was never merged. `main` is 509 commits past that divergence point with no trace of\nit. Not removed after shipping — built once, on a branch that got abandoned in favor of\ncontinued work directly on `main`, and the feature never made the jump. Found via an\nexternal-improvement-ideas scan that proposed disclosure/dependency safeguards; the\nowner recognized the idea and asked to confirm it wasn't already live. It wasn't.\n\n**Fix:** `_assess_safety` (cheap off-loop classifier, no character/history context) and\n`_safety_prompt` reimplemented against current `bot.py`. `SAFETY_ENABLED` (default on),\n`SAFETY_MODEL`, `SAFETY_RESOURCES` (988 Suicide & Crisis Lifeline by default).\n`assemble_messages`/`assemble_messages_async` gained a `distress` param: when true, the\nperformative inner-voice block is skipped and `_safety_prompt` is appended last (highest\nsalience), same as the original design. Wired into `handle_message`'s existing\n`parallel` concurrency list alongside inner voice and link fetch, so it costs no serial\nlatency. Deliberately independent of `INNER_VOICE_ENABLED` (default off) — a safety net\nmust not be silently inert because an unrelated cosmetic feature is off. Scope matches\nwhat the abandoned branch actually shipped: `handle_message` (private text) only, not\ngroup/voice/photo paths — those never had it either.\n\n**bot-code-invariants rule 3 exception (owner-approved 2026-08-04, in the same\nsession):** this adds a genuine new per-message LLM side call, which rule 3 bars.\nApproved because it is not the \"small cheap call\" the rule's common-mistake note warns\nagainst — no character/history context, so it doesn't re-pay the ~17k-token prompt\nrule 3's cost argument is about. Folding it into `post_reply_analysis` (the sanctioned\nextension point) was considered and rejected: that call fires after the reply is\nalready sent, so distress on THIS message could only change the NEXT reply — one\nmessage late is a real degradation for a safety feature. Documented as a second\ncarve-out in `bot-code-invariants` rule 3, next to the existing `MEMORY_SEMANTIC_LIVE`\none, so a future session doesn't flag it as a violation.\n\n**Verification:** `TestSafetyClassifier` + `TestAssembleMessagesDistress` (10 tests) —\nclassifier yes/no parsing, fail-open on a broken classifier, no raw exception in the\nsafety log (structural check, matching the Garmin/WSDOT convention), distress\nsuppresses inner voice and appends the safety prompt last, no distress by default. All\nbreak-tested RED (three separate injections: classifier forced False, the\ninner-voice-suppression gate removed, the safety-prompt append removed — each\nconfirmed the matching test failed, then reverted). `bash .claude/tools/verify.sh`\ngreen: 967 passed, 38 evals, 45/45 gate-corpus, sweep 0 candidates.\n\n## 2026-08-04 — The second source-assertion backlog: 7 helpers with zero real test coverage (no bot.py change, no version bump)\n\n**Root cause:** `sweep.py`'s widened `_handler_coverage()` (2026-08-03) flagged\n`save_feature_prefs`, `save_state`, `save_wardrobe`, `send_gif`, `send_meme`,\n`send_selfie`, and `update_garmin` as mentioned-but-never-called by any test —\n`test_the_backlog_stays_empty` had been red on `main`, and CI failing on every push,\nsince that scan landed. Three of the seven (`save_state`, `save_wardrobe`,\n`save_feature_prefs`) were deliberately monkeypatched to a no-op in every `*_cmd` test\nthat calls them, for filesystem isolation — leaving their real write path itself with\nzero coverage. The other four had only `inspect.getsource` structural checks, never a\nreal call — `send_selfie`, the function at the center of the entire multi-release\nface-drift investigation, had never once actually been invoked by a test.\n\n**Fix:** `TestTheSecondBacklogDriven` in `tests/test_pure.py` drives each of the 7\ndirectly, with fakes for its I/O (a fake Telegram `bot.*` object, monkeypatched\nGiphy/selfie-image/Garmin calls, `tmp_path`-redirected persistence files). No bot.py\nchange — this closes a test gap, not a behavior bug; none of the 7 turned out to hide\nan actual defect.\n\n**Verification:** each of the 7 break-tested RED one at a time (an injected early\n`return`, confirmed the matching new test failed, then reverted before the next).\n`bash .claude/tools/verify.sh` green: 957 passed, 38 evals, 45/45 gate-corpus, sweep 0\ncandidates.\n\n## v2026-08-03.6 — /audit said \"gemini\" and stopped there\n\n**Root cause: only the NanoGPT branch named its model.** `selfie_provider` rendered\n`nanogpt (flux-kontext)` but plain `gemini` — and `GEMINI_IMAGE_MODEL` is per-instance,\nchanges with an `.env` edit and a restart rather than a deploy, and is exactly the field you\ncheck after changing it. With the fleet about to move to `gemini-3-pro-image-preview`, the\none value worth verifying was the one value not reported.\n\n`_selfie_provider_label()` now renders\n`gemini (gemini-3-pro-image-preview, modalities TEXT+IMAGE)` and keeps\n`nanogpt (flux-kontext)` unchanged. Both `/audit` and the `=== STARTUP AUDIT ===` line call\nit — the same function, because those two surfaces disagreed once already (v2026-08-02.1,\nwhere the startup line had the selfie base and `/audit` did not). The startup line gains a\n`Selfie model:` field it never had.\n\n**Third instance of one class in a single session**, and worth naming as such: the selfie\nprompt had no surface (fixed by `tools/selfie_prompt_preview.py`), the reference photo had no\nsurface (still open — `/audit` names the file, never what is in it), and the image model had\nno surface. Each was found only when someone needed to check it and could not. The standing\nquestion for this subsystem is not \"does it work\" but \"can the owner see what it is using\".\n\n**One pre-existing test rewritten, not loosened.**\n`test_nanogpt_reports_the_model_too` read `gather_audit_data`'s *source* for the strings\n`SELFIE_MODEL` and `nanogpt`, so it went red the moment that logic moved into the shared\nhelper — while the behavior it is named for was still correct. Rather than repoint the grep\nat the new function, it now calls `gather_audit_data()` and asserts the rendered value is\n`nanogpt (flux-kontext)`. A source assertion cannot fail for the reason the test exists; that\nis the family that shipped the `/features` `ValueError` past twelve green tests\n(v2026-08-02.4). Break-tested RED against a stubbed-out NanoGPT branch.\n\n**Verification:** `.claude/tools/verify.sh` green. 3 new tests plus one rewritten, all four\nbreak-tested RED one injection at a time.\n\n## v2026-08-03.5 — The selfie model was tunable; what the model demands was not\n\n**Root cause: `GEMINI_IMAGE_MODEL` has been an env var since the Gemini backend landed, but\n`responseModalities` was hardcoded to `[\"IMAGE\"]` in the payload.** `gemini-3-pro-image-preview`\nrequires `[\"TEXT\", \"IMAGE\"]` and rejects IMAGE alone, so \"switch the selfie model in .env\" —\nwhich every doc implied was a one-line change — could not work for the model most worth\nswitching to. Asked how to move to Pro, the honest answer was that the knob was only half\nthere.\n\n**Fix:** `GEMINI_RESPONSE_MODALITIES`, comma-separated, default `IMAGE` (unchanged behavior).\n`_parse_modalities` normalizes case and whitespace, because `.env` values are hand-typed and\n` text , image ` must not become a 400, and it never returns an empty list — an empty\n`responseModalities` is itself a 400.\n\n**Deliberately not sniffed from the model name.** A model string is not a capability, and the\nnext image model will not be named after either of the two we know about. `.env.example`\ncarries both working pairs instead.\n\n**A text-only answer now says what it said.** With TEXT enabled a refusal comes back as prose\nexplaining why, and the parts loop discarded it — leaving `no image data`, which is the least\ninformative thing the response contained. Non-refusal text alongside an image is still\nignored; the image wins.\n\n**Two operational facts found while checking the model IDs, both dated 2026-08-03 and both\nworth re-verifying before acting on:**\n- **`gemini-2.5-flash-image` is scheduled to shut down 2026-10-02.** That is the fleet default\n and what all seven instances run. When it goes, every instance loses selfies at once. This\n is a deadline, not a preference, and it is bigger than the reason it was found.\n- Pro costs ~$0.134 per 1K/2K image against ~$0.039 for the current flash model — roughly\n 3.4x. A `gemini-3.1-flash-image-preview` sits between them at ~$0.067.\n\n**Verification:** `.claude/tools/verify.sh` green. 7 new tests, four assertions break-tested\nRED one injection at a time.\n\n## v2026-08-03.4 — A one-second Gemini outage was a failed selfie\n\n**Root cause: `requests` does not raise on a 5xx, and the image retry loop only caught\ntransport exceptions.** A 503 came back as an ordinary response object, went straight past\n`except (ConnectionError, Timeout)`, was returned to the caller, and became an error one\nframe later at `raise_for_status()` — where nothing retried it. Owner-reported live:\n\n 📷 Couldn't make that one: 503 Server Error: Service Unavailable for url:\n https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-image:generateContent\n\nGemini's image endpoint returns 503 under load and 429 when rate-limited, and both clear on\ntheir own within seconds. The retry loop had been in place since the Termux days and read as\nthough it covered this; it never did. Same family as v2026-07-26's finding that\n`requests` does not raise on 4xx/5xx, in the one place that lesson had not been applied.\n\n**Fix:**\n- `_image_request_with_retries` — one loop for both verbs, retrying transport failures **and**\n `_IMAGE_RETRY_STATUSES` (429, 500, 502, 503, 504). `_post_with_retries` and\n `_get_with_retries` are now thin wrappers, so the NanoGPT URL fetch gets the same treatment\n as the generate call rather than a copy that would drift.\n- **A 4xx that is not 429 is never retried.** A bad prompt or a bad key is ours; retrying only\n delays the real error by six seconds.\n- `Retry-After` is honored when the server sends a usable one, **capped at 10s** so an absurd\n or hostile header cannot stall the handler; otherwise the original 2s/4s ramp.\n- The final response is **returned, not raised**, even when it is still a retryable status —\n the caller's `raise_for_status()` stays the single place an HTTP failure becomes an\n exception.\n- `_media_error_text` gives a transient outage plain words instead of a status line and a\n URL: *\"The image service is busy right now (HTTP 503) — I tried 3 times. Ask me again in a\n minute.\"* Anything without a recognised transient status keeps the raw text, because an\n unfamiliar error is exactly when the details matter. Same split as v2026-08-02.9's\n missing-asset vs switched-off, one layer out.\n\nKill switch `IMAGE_RETRY_TRANSIENT=0` restores transport-only retries. Runs inside the\nexisting `asyncio.to_thread` hop, so the added sleeps do not touch the event loop.\n\n**Verification:** `.claude/tools/verify.sh` green. 10 new tests; five assertions break-tested\nRED one injection at a time, including one that widens `_IMAGE_RETRY_STATUSES` to include 400\nand proves the not-retried test would catch it.\n\n## v2026-08-03.3 — The prompt told the model to change her face\n\n**Root cause: `Her mood right now: {mood} — let it read in her face.` is the only\ninstruction in the entire selfie prompt that tells the model to modify her face, and it sits\n~1500 characters ahead of every rule that says copy it exactly.** It is gated on\n`chat_id is not None`, which production always satisfies, so it has been in every live\nselfie — and in none of the 22 A/B images v2026-08-03.2 was judged on, because the preview\ntool passed `chat_id=None`. The owner noticed the gap from the other end: *\"the selfies\nseemed better in the test than the actual ones.\"* The mood line and the scene-dedup block\nare the two things the test prompt was missing.\n\nThis is the same shape as the rest of v2026-08-03.2 — a contradiction hands an edit model\nlatitude — except this one is not implicit. `_SELFIE_PRESERVE_RULE` says copy her face out\nof the reference; the mood line says make her face show wistfulness. Both cannot hold.\n\n**Fix:** when a reference photo is attached, the mood reaches the image through the\nexpression already drawn above it and through posture, which is where a mood shows in a\nphotograph anyway:\n\n Her mood right now: {mood} — let it colour that expression and how she's holding herself.\n\nThe mood itself is untouched; the fix is the verb, not the feature, and a test pins that the\nvalue still reaches the prompt. **Text-only instances keep the old wording** — with no\nreference photo there is no preserved face for it to contradict. Reuses the\n`SELFIE_FACE_LOCK` kill switch rather than adding a second one: it is the same feature\n(stop the prompt inviting face edits), and `SELFIE_FACE_LOCK=0` now restores the whole\nv2026-08-03.1 prompt including this line.\n\n**Sequencing, stated plainly:** the reference photo is still the bigger lever. Emily's is a\nfull-body beach shot with her face at ~8% of frame height (see v2026-08-03.2 above), and no\nprompt wording recovers identity from ~100px of face. This change is justified on internal\nconsistency alone — an instruction to alter the face contradicts four appended rules to\npreserve it — but if the reference is swapped and this deploys together, neither will be\nattributable. Swap the photo first, watch a few selfies, then deploy.\n\n**Verification:** `.claude/tools/verify.sh` green. 4 new tests, all four break-tested RED one\ninjection at a time.\n\n## v2026-08-03.2 — Emily's selfie was a better-looking stranger with no glasses\n\n**Root cause: the prompt asked the model to keep her face without ever saying what a face\nis made of, and handed it a written description of her in the same breath as the photo.**\nOwner-reported with a before/after pair: the reference is a freckled woman in oversized\nround glasses; the selfie came back with different bone structure, no freckles and no\nglasses — recognisably a different, more conventionally attractive person with the right\nhair colour. The reference photo was attached and correct, so this is none of the earlier\ncauses (v2026-08-01.10's missing photo, v2026-08-02.2's rejected mime type,\nv2026-08-02.5's weaker `flux-kontext` — Emily is on Gemini).\n\nTwo shapes in the prompt, both of which survived v2026-08-01.9:\n\n**1. `SELFIE_APPEARANCE` sat INSIDE the identity sentence.** `bits[0]` ended\n`\"...just in a new pose/setting. She's {NAME}, {SELFIE_APPEARANCE}\"`, so one sentence gave\nthe model a face to copy *and* a written spec — \"auburn waves, hazel eyes, oversized round\nglasses\" — that it can satisfy with a face it invents. Every one of these is a full\nre-render (pose, framing, setting and clothes all change), and on a re-render synthesising\nfrom the words is the cheaper path than copying from the pixels. The paragraph's later\nclauses go first, which is exactly where Emily's glasses are.\n\nThis is **not** a reversal of v2026-08-01.9, which added `appearance.txt` files precisely\nso a face had a verbal anchor and not only a photo pointer. The words stay. What changed is\ntheir rank: they are now introduced as *\"Who she is, as context only — the photo outranks\nevery word of it, and her face is never drawn from this text\"*, ahead of the scene block\nrather than fused into the identity claim.\n\n**2. \"Keep her face identical\" never said which parts of a face.** Nothing in the prompt\ncontradicted a plausible, better-boned woman with the right hair. `_SELFIE_PRESERVE_RULE`\nnow enumerates what gets copied — face shape and bone structure, eyes, nose, mouth, brows,\nskin tone and skin marks, hairline, hair colour and texture, apparent age — and names the\nfailure directly: *\"an ordinary face that matches the reference is right, and a\nbetter-looking one that does not is wrong.\"* Image models regress faces toward an\nattractive mean unless told not to.\n\n**Eyewear is stated both ways, never asserted** (*\"if she is wearing glasses there she is\nwearing those same glasses here; if she is wearing none, add none\"*). Asserting glasses in\na rule shared by all seven instances is the character-bleed trap of v2026-08-01.8's courier\njacket and .9's hardcoded freckles, one release later; a test pins it.\n\n**Fix:**\n- `_SELFIE_PRESERVE_RULE` and `_SELFIE_FACE_CLARITY_RULE`, appended next to\n `_SELFIE_IDENTITY_TAIL` — nearest the output is where an edit instruction lands hardest\n (v2026-08-01.9's finding). The tail keeps the last word; it is the shortest statement of\n the same constraint.\n- `_SELFIE_CHANGE_SCOPE` (\"Everything that follows changes the pose, the setting, her\n clothes and the camera. None of it changes her.\") before the scene block, so ~1000 characters of\n pose/weather/camera read as an edit spec instead of a description of a photo to produce.\n- `\"New shot:\"` → `\"Framing:\"` on the edit branch. \"New shot\" is a generate cue in the one\n place the model must not generate.\n- The clarity rule is deliberately compatible with every framing in the pools. \"Her face is\n large in frame\" would contradict the half-in-frame and wider draws, and a prompt that\n contradicts itself hands back the latitude this removes — a test pins that too.\n- All of it is gated on `_has_base_image()`. \"Copy her face out of the reference photo\"\n with no photo attached is an instruction to copy nothing.\n\nKill switch `SELFIE_FACE_LOCK=0` restores the v2026-08-03.1 prompt. Separate from\n`SELFIE_IDENTITY_GUARD` on purpose: that one also owns the de-stacking and the tail, both\nof which should survive turning this off. Prompt assembly only, no new LLM calls.\n\n**The cost, stated plainly:** the prompt grows 1785 → 2734 characters for Emily, +53%.\nMore text is itself a dilution risk, and the honest position is that this trades a general\ndilution for a specific, named constraint. It is worth A/B-ing before it is believed.\n\n**New: `tools/selfie_prompt_preview.py`.** Every face-drift release so far has been argued\nfrom generated images and inference, because nothing could show the prompt itself.\n`python3 tools/selfie_prompt_preview.py emily --diff` renders what an instance would send,\nwith the face lock off and on, ready to paste into Gemini with the reference photo. Repo-only\n— `vps-sync.sh` does not copy `tools/`. It reads the *committed* seed files, so where a live\ninstance has diverged the live instance is authoritative (v2026-08-01.10).\n\n**One test widened, with the reason:** `test_shared_prompt_hardcodes_no_character_specific_feature`\nscanned `build_selfie_prompt`'s source only, so a character trait living in one of the\nappended `_SELFIE_*_RULE` constants escaped it — and this release adds two more of those.\nIt now scans their values alongside the function source. Break-tested by putting \"freckles\"\ninto `_SELFIE_PRESERVE_RULE`: RED.\n\n**Not proven when shipped:** the mechanisms are real and the before/after prompts are\nreadable, but no image had been generated from either — this container has no\n`GEMINI_API_KEY`. Same caveat v2026-08-01.9 carried, and the preview tool exists so the next\nperson does not have to guess.\n\n**A/B result, 2026-08-03 (owner-run, 4 seeds × off/on, Gemini + Emily's reference photo).**\nThe face lock is closer to the reference in 3 pairs of 4 and worse in 1 — a real signal, not\na settled one. Where it wins it wins in the predicted direction: fuller cheeks, a softer jaw,\nand freckles at the reference's density and distribution instead of a smoother, narrower,\nmore conventionally attractive face. The loss (seed 2, `a mirror selfie` + `shot from just\nslightly too close up`) produced a longer, more angular face with the freckles nearly gone,\nso no structural story separates it from the wins — that pair drew a soft framing, and so did\na pair the lock won.\n\n**What the A/B did NOT test, and this is the important part.** Seeds 0–3 drew\n`a bathroom mirror selfie`, `a selfie with her face half-cut-off the frame`, `a mirror\nselfie` and `a selfie with her face half-cut-off the frame` — every one a close or\npartial-face shot. The reported failure was a **wide** shot with the room behind her and the\nface small in frame, and none of the four seeds drew a wide framing at all. Both arms kept\nher glasses in all 8 images, so **the original bug did not reproduce in either arm** — this\ntest measured the lock's effect on draws that were not failing, not its effect on the draw\nthat was. Contiguous seeds sample the pools evenly, which is the wrong sample when hunting\none draw; `--seeds 4,16,22,26,44` covers the five wide framings, one seed each.\n\n**Round 2 (owner-run, the five wide framings, off/on) — the win does not survive it.** The\nlock is clearly closer on 2 pairs (seeds 22 and 44), clearly worse on 1 (seed 4), roughly\nlevel on 2. Seed 44 is the most informative: it is the wide-with-the-room-behind-her draw\nthat most resembles the report, and the unlocked arm came back near-freckleless and\nsmoothed — the exact regression `_SELFIE_PRESERVE_RULE` names — while the locked arm kept\nfreckles at the reference's density. Combined across both rounds the lock leads roughly 5–3\nwith 1 level over 9 pairs. That is a mild preference, not a fix, and it should be described\nthat way.\n\n**The wide framings never rendered.** Seed 16 asked for `a low-angle selfie from below` and\ncame back at eye level; seed 22 asked for `a high-angle selfie looking up at the camera` and\ncame back at eye level; seed 26 asked for `a selfie held up high looking down` and came back\nas a third-person kitchen shot with both her hands occupied (the anatomy rule did not hold\neither); seed 44 asked for `a wider selfie with the room visible behind her` and came back\noutdoors at the Capitol. **Gemini is largely ignoring `Framing:`.** So round 2 did not test\nwhat it was built to test — the framing pool was selected on the prompt's text, and the text\nis not what the model drew. Second-order C8, one layer under the first: the sample was\nchosen by a directive the model does not obey.\n\n**Where this points instead.** If framing directives do not render, the \"face small in frame\"\ncondition in the reported image did not come from a random framing draw. That image showed a\nspecific narrative moment — polaroids spread over the floor — which is the shape of a\n`[selfie: …]` hint written by the model mid-conversation, not of anything in `SELFIE_FRAMINGS`.\nA hint replaces the atlas scene and lands in `Background/setting:`, and it can be far more\nelaborate than any pooled draw. **Reproducing with `--hint` is the next test, and nothing\nbefore it has actually reproduced the bug.**\n\n**Found while building that test, not fixed here:** `_daypart()` is appended after the hint,\nso a `[selfie: … at dusk]` produces `Background/setting: … showing dusk, Olympia, WA, in the\nmorning.` A user- or model-pinned time of day is silently contradicted by the clock — the\nsame contradiction-equals-latitude shape this release is about, in a different clause.\n`--location` was added to the preview tool in the same pass: the fake instance has no\n`WEATHER_LOCATION`, so every previewed prompt said \"Seattle\", which is wrong for all seven\ninstances and was landing in the pasted text.\n\n**Round 3 (owner-run, the reported scene via `--hint`, off/on, 2 seeds) — clean again, and\nthe tool was the reason.** The hint reproduced the scene faithfully: cross-legged on the\nfloor, polaroids spread around her, window behind. All four images kept her glasses,\nfreckles and hair colour, and the locked arm on seed 44 is the closest match to the\nreference of the 22 images generated so far — hair up in the reference's own messy curly\nstyle. **The bug has now failed to reproduce 22 times out of 22.**\n\n**Root cause of the non-reproduction: `selfie_prompt_preview.py` was not previewing\nproduction.** It called `build_selfie_prompt(hint, None)`, and `chat_id is None` gates off\ntwo blocks that every live selfie carries:\n\n1. `Her mood right now: {_mood_vibe(chat_id)} — let it read in her face.` — an explicit\n instruction to make her face reflect something, sitting immediately after `Expression:`\n and ~1500 characters before the identity tail. Nothing else in the prompt tells the model\n to change her face.\n2. The scene-dedup list, which names other setups — the block v2026-08-01.9 already\n identified as the one appended text that could pull the image away from the reference,\n and deliberately put the identity tail after.\n\nThree rounds of A/B therefore compared two variants of a prompt **no instance has ever\nsent**, and the one instruction in the live prompt that targets her face was absent from all\n22 images. This is the third C8 recurrence in the same investigation and the worst of them:\nthe first two picked the wrong sample, this one used the wrong prompt.\n\n`--mood` and `--recent` render the production shape; `--mood \"\"` restores the old\n`chat_id=None` behavior and the header now says so explicitly. The live prompt is 1905/2861\ncharacters, not 1721/2677.\n\n**Not yet tested:** whether the mood line is a drift lever. It is a plausible mechanism, not\na demonstrated one — no image has been generated with it present. Round 4 is that A/B; do\nnot treat it as diagnosed before it runs.\n\n### The reference photo is a full-body beach shot, and the \"baseline\" was never the reference\n\nAsked for `emily_base.png` itself, the owner sent a file that is **a standing full-body\nphoto on a beach** — sea and sand behind her, half-up curly hair, round glasses, crop top\nand denim shorts. Her face occupies roughly **8% of the frame height**, on the order of a\nhundred pixels tall.\n\nThat is very likely the whole story, and it makes every earlier diagnosis secondary:\n\n- **An edit model cannot copy a face it cannot see.** Given a reference with ~100px of face,\n there is almost no identity information to carry into a close phone selfie, so the model\n synthesises one. Glasses survive because they are large and high-contrast; bone structure,\n freckle pattern and jawline do not. That matches every symptom in the original report\n exactly, and it explains the intermittency the prompt-side theories never did.\n- **The transformation distance is enormous.** Every prompt asks for a close, indoor,\n handheld phone selfie; the reference is a standing full-body outdoor shot in summer\n clothes. Pose, distance, lens, lighting and wardrobe all change at once.\n- **The 22 A/B images were scored against the wrong image.** The grey-hoodie portrait the\n owner has been sending as \"the baseline\" is itself a *generated selfie* — it carries the\n same Gemini watermark as the outputs. So the comparisons measured drift from one\n generation to another, not from the reference. Every \"closer to baseline\" verdict in the\n three rounds above is weaker than it reads.\n\n**Fix is content, not code:** replace the reference with a close, front-facing portrait crop\nwhere the face fills much of the frame — `/setbase` sent as a **file**, not a photo, so\nTelegram does not recompress it (v2026-08-02.3). The grey-hoodie image is, ironically, a far\nbetter reference than the actual reference.\n\n**Not established, and it needs one command.** The uploaded copy hashes\n`026711a0…` against the VPS's `27ff3293…`, and arrives with JPEG magic bytes (`ff d8 ff e0`)\nunder a `.png` name while the VPS file has genuine PNG magic (`89 50 4e 47`) — consistent\nwith the upload path transcoding it, but that is an inference, and a hash of a re-encoded\ncopy settles nothing. `sha256sum` on the owner's local file, compared against `27ff3293…`,\nis what confirms this beach photo is the one the fleet sends.\n\n**The observability gap this exposes is the same one twice.** Nothing in the system ever\nshowed anyone what the reference photo *is*. `/audit` reports the filename and provider —\nenough to prove a file is in play, never enough to see that the face in it is unusably\nsmall. Two releases were spent tuning prompt text against an image nobody had looked at.\n\n**Follow-up, not in this diff:** Emily's `appearance.txt` ends with *\"Dresses in layered\nmuted greens and greys — oversized sweaters, soft and worn-in\"*, and the prompt separately\nappends `\"Wearing {outfit}.\"` — two clothing instructions in one prompt, which is the\ncontradiction-equals-latitude problem in the content layer. Appearance files should describe\na body and a face, not a wardrobe; the wardrobe rotation owns clothes. Left alone here\nbecause the repo seed and the live instance can differ and the live one is authoritative.\n\n**Verification:** see the report — `.claude/tools/verify.sh`, 8 new tests, five assertions\nbreak-tested RED one injection at a time.\n\n## v2026-08-03.1 — Priya sent her whole deliberation as the reply, in four messages\n\n**Root cause: a thinking model can emit its ENTIRE chain-of-thought as ordinary\n`content`, and every existing chain-of-thought guard keys on a signature that variant\ndoesn't have.** On 2026-08-03 priya (`zai-org/glm-5.1:thinking`) answered a quiet\nin-scene message with ~12k chars of her own deliberation — \"Let me work through this\nstep by step: 1. How does Priya feel about what Brian just said?... Let me draft...\nOption 1:...\" — reasoning openly about her format contract, scene mode, and drafted\nreplies, with the real reply buried at the end. It reached Telegram as four chunked\nmessages (`send_bubbles` splits at 4096 chars and nothing upstream objected).\n\nThis is the third variant of the chain-of-thought leak class, and each earlier guard\nchecks for exactly one signature:\n- v2026-07-20.1 blocks `reasoning_content` delivered when `content` is **empty** —\n here `content` was non-empty, so that path never fired (`/errors` showed no\n `[model] … reasoning but no content` warning, which is what confirmed the variant).\n- `_strip_thinking` removes `<think>…</think>` — there were no tags.\n- v2026-07-29.1's `_strip_directive_lines` drops ALL-CAPS bracket lines — the leak\n was plain prose.\n\n**Fix: refuse and re-roll, never salvage.** New `_looks_like_reasoning_leak(text,\nname)` detects a reasoning-shaped completion by a conjunction of independent signals —\nlength ≥ 2000 chars (far above any real texting-register reply; a reply needing\nTelegram chunking at all is already abnormal) AND ≥ 3 distinct meta-reasoning marker\ncategories (\"the user\", \"let me draft/work through/…\", \"in/out of character\", prompt\nvocabulary like \"format contract\"/\"scene mode\", \"option N\", ≥3 numbered analysis\nlines, and the character's **first** name ≥3 times — a first-person persona almost\nnever writes its own name; leaked deliberation is saturated with it. First token\nbecause the card `name` field is the full name — \"Emily Harper\", \"Bonnie\n(Libertarian)\" — and deliberation writes \"Emily\", never \"Emily Harper\"; the\npre-review draft matched the full string, which left this category inert on five of\nseven instances). Both floors are env-tunable (`REASONING_LEAK_MIN_CHARS`,\n`REASONING_LEAK_MIN_MARKERS`) so a production misfire is fixed by raising a floor,\nnot by turning the guard off. A tripped completion is treated exactly like an empty\none in `call_nanogpt`: retry with backoff, then fall through to the non-thinking\n`FALLBACK_MODEL` where one is configured — the main chat path passes it; the\nselfie/meme caption helpers have none and degrade to no caption, as they already did\nfor empty completions. The tempting alternative — extracting the real reply from the\ntail of the leak — is deliberately rejected: there is no reliable boundary between\ndeliberation and answer, and a wrong guess ships a fragment of monologue as her.\n\n**What is deliberately NOT a marker.** Adversarial review of the first draft proved\n\"let me think\", \"overthinking this\", and \"going back and forth\" are ordinary texting\nvocabulary on this fleet — a long in-character reply weighing life options tripped\nthe draft detector on phrasing alone. Those were removed; the shipped marker list is\nvocabulary about *the reply as an artifact*, not deliberation-flavored chat.\n\n**Scope: persona replies only.** `call_nanogpt` grew a `leak_guard` flag (default\noff) that `generate_reply` and `reply_with_typing` pass through (default on), so the\npersona reply sites and caption helpers are guarded while analysis/summary/extraction\nJSON callers are structurally outside (same isolation the directive-leak guard\nkeeps). Two deliberate exemptions: the three `DOCUMENT_MODEL` sites in the document\nhandlers pass `leak_guard=False`, because the card-review branch *asks* for a long\ncritique that discusses prompts and characters — review showed a normal card review\ntrips every marker the guard looks for, and with no fallback model the trip would\nsurface as \"❌ something broke\" — and `recap_cmd`, which is owner-invoked and\nlegitimately long, third-person, and name-heavy.\n\nEvery refusal logs `[reasoning-leak]` at WARNING with the model, length, and head of\nthe rejected text, and counts under its own `reasoning_leak` key in `/errors` (not\njust the shared `api` count, so \"guard fired\" is distinguishable from \"API flaked\") —\nstripping silently would turn a visible model fault into an undiagnosable one, and if\nglm-5.1:thinking does this weekly, that counter is how the model-choice conversation\nstarts. Rejected completions still feed `_track_llm_usage`, so a 12k-char burned\nthinking budget shows up in `/audit`'s token figures instead of vanishing. Kill\nswitch `REASONING_LEAK_GUARD=0` (default ON, owner policy 2026-07-18). Pinned by\n`TestReasoningLeakGuard` (15 tests; the real leaked transcript is the fixture, and\nthe false-positive shapes review found are must-pass fixtures) and the\n`reasoning-leak-guard` eval.\n\n## v2026-08-02.15 — /features told you less about features than /audit did\n\n**Root cause: the detail suffix was written into `_features_summary()` — the `/audit`\nline — and never into the listing.** `/features` showed `voice: on`; `/audit` showed\n`voice=on(inworld)`. So the command *dedicated* to features answered less about them than\nthe general audit line, and the question v2026-08-02.10 added those suffixes to answer —\nwhich TTS backend is actually live, since NanoGPT TTS works without Inworld and the\ncapability probe can only ever return `True` — was reachable only from the other command.\n\nThe listing now carries the same three details: the voice backend, the GIF safety level,\nand the selfie provider. Both call sites go through one new `_feature_detail()` rather\nthan a second copy of the logic, because a second copy is exactly how the listing drifts\nback to saying less than the line it exists to expand on. `/audit` keeps its packed form\n(`voice=on(inworld)`); the listing spaces it (`voice: on (inworld)`).\n\n**A switched-off feature shows no detail at all** — naming a backend beside `off` claims\nsomething is running that isn't.\n\n**Verification:** 887/887 pytest (2 new), 34/34 evals, `py_compile` clean. Both new tests\nbreak-tested RED. They drive `features_cmd` with fake Telegram objects rather than reading\nits source — the rule v2026-08-02.14 established, and the delivery gate now enforces it\nfor any `*_cmd` a diff touches.\n\n## v2026-08-02.14 — /features never actually flipped anything, and five more from one review\n\n**Root cause of the batch: the tests for this week's features asserted on handler\n*source* instead of calling the handler.** `features_cmd` ended with\n`_, probe = _FEATURES[name]` — a 3-tuple unpacked into two names — so **every**\n`/features <name> on|off` raised `ValueError` before reaching the flip. The switch never\nmoved, nothing persisted, and the owner got silence, because an exception inside a PTB\nhandler is logged and swallowed. The suite was green throughout: one test asserted the\nspecs *are* 3-tuples, another read the handler's source for `_is_admin`, and the\n`probe` name the unpack bound was never used, so no linter cared either. This is the fifth\nmember of the family v2026-08-02.4 named (C8: reading a function's source proves the code\nexists, never that it runs), and the second to reach the fleet. **Every test added in this\nrelease exercises the handler.**\n\nThe rest of the batch, all from the same review:\n\n- **Proactive messages dropped their GIFs.** `send_triggered` computed `gif_query` and\n never used it — `_deliver` grew the GIF path and this second caller was missed. A\n tag-only proactive message sent *nothing* and stored an empty assistant turn, putting a\n blank into history.\n- **`/features health off` didn't stop the health monitors.** `STRESS_ALERTS`,\n `BB_ALERTS` and `RHR_ALERTS` were computed as `GARMIN_ENABLED and <env>` **at import**,\n so flipping `GARMIN_ENABLED` at runtime left the monitors reading a frozen copy: alerts\n kept firing while `/features` and `/audit` both reported `health=off`. They are now the\n static env preference alone, read through **`_alerts_on()`**, which ANDs the live parent\n at call time. `_garmin_off_reason()` gained the runtime-switch case for the same reason —\n `/health` and `/healthnow` were answering normally with the feature switched off.\n- **Switching a feature off was a one-way trip until restart.** The Garmin jobs and the\n traffic poll were *registered* under their switches, so `/features health on` (or\n `traffic on`) could not start anything that startup had skipped. Both now register on\n **capability** — every one of those jobs already re-checks its own gate when it fires.\n- **\"Off\" was still being reported as \"never configured\"** in `send_selfie`, `send_meme`,\n `/route`, `/nearby`, `/place`, `/food`, `/traffic` and `/incidents` — the exact\n conflation v2026-08-02.9 split `*_capable` from `*_ready` to end. They need different\n fixes (a `.env` edit or a file, versus one `/features` command), and the old message sent\n the owner hunting for a reference photo that was already there. New helper\n **`_feature_off_reason()`**; `send_gif` already did this correctly and was the template.\n The paths in those messages were phone-era (`~/telegram-bot/…`) and now render from\n `BASE_DIR` / `MEME_TEMPLATES_DIR`, so they name the file the instance actually reads.\n- **`/setbase` could claim a backup it didn't make.** It decided the\n \"previous kept as `.prev`\" suffix by stat-ing the path *after* the write, so a leftover\n `.prev` from an earlier run made a first-ever install report a backup of a file that had\n never been there. It now tracks whether the backup branch ran.\n- **`GROUP_CHAT_DESIGN.md` §3 contradicted itself.** The throttle and budget bullets still\n said `GROUP_MIN_GAP_SECONDS=20` / `GROUP_DAILY_BOT_BUDGET=30` after v2026-08-02.13 moved\n them to 8 / 50 — the same doc's own table and the code both said the new values. §3 is\n the section `group-chat-changes` makes you read before touching any `GROUP_*` code.\n\n**Verification:** 885/885 pytest (18 new), 33/33 evals, `py_compile` clean. Five defects\nbreak-tested RED by re-injecting the original code; the two that could not be (both\ndocs) are covered by the eval suite's own consistency checks. The `/features`,\n`send_triggered` and `/setbase` tests drive the real handlers with fake Telegram objects,\nso they fail on a broken dispatch path rather than on a changed string.\n\n**Not changed, deliberately:** the review also flagged the traffic *poll job* as ignoring\nthe switch. It does not — `traffic_poll_job` re-checks `TRAFFIC_ENABLED` on every firing\n(the registration asymmetry above was the real defect there, in the opposite direction).\n\n## v2026-08-02.13 — the group bots could only ever answer once, and three separate limits said so\n\n**Root cause: the v1 group-chat tuning made a real back-and-forth arithmetically\nimpossible, not merely unlikely.** Owner report: \"they only reply once per my reply.\"\nThree defaults compound, and fixing any one alone would have changed nothing:\n\n1. **`GROUP_BOT_CHAIN_MAX=2`** is a hard ceiling on consecutive bot messages since the\n last human one. Two bots means the best case was always `human → A → B → silence` —\n the third message was never reachable, whatever anyone said.\n2. **`GROUP_BOT_REPLY_PROB=0.35`** flat. So the *second* message only happened about a\n third of the time when the first bot didn't name the second — which is why the\n observed behaviour was usually one reply, not two.\n3. **`GROUP_MIN_GAP_SECONDS=20`** throttles a bot's own consecutive group messages, and\n in a live exchange a bot's turns land **~16s apart** (poll ≤5s + claim delay 0.5–3s +\n generation, twice). The throttle is *inside* that window, so it silently kills\n alternation. This is the one that matters most: raising the chain cap alone would\n have hit the throttle instead and produced the same single reply.\n\nNone of this was a bug — §3 of `GROUP_CHAT_DESIGN.md` chose these numbers to bound loop\nrisk and cost on an unproven pilot. The pilot has now run since 2026-07-28 without a\nrunaway, so the trade is being re-struck deliberately.\n\n**Fix — `GROUP_BANTER` (default on, `GROUP_BANTER=0` reverts every number below):**\n\n| Knob | v1 | now | why |\n|---|---|---|---|\n| `GROUP_BOT_CHAIN_MAX` | 2 | **6** | a chain of 3+ is reachable at all |\n| `GROUP_BOT_REPLY_PROB` | 0.35 | **0.5** | first comeback is a coin flip, not a third |\n| `GROUP_MIN_GAP_SECONDS` | 20 | **8** | below the ~16s exchange round-trip, so alternation survives |\n| `GROUP_DAILY_BOT_BUDGET` | 30 | **50** | longer chains spend it faster; running dry mid-evening reproduces the original complaint silently |\n| `GROUP_CHAIN_DECAY` | — | **0.75** | new |\n\n**The decay is what makes the higher cap safe.** `_should_reply_to_bot` no longer uses a\nflat probability: the chance is multiplied by `GROUP_CHAIN_DECAY ** depth`, where depth\nis how many bot messages deep the exchange already is. A chain typically runs 3–4\nmessages and reaches the 6 cap rarely, so exchanges end by petering out rather than\nstopping mid-sentence at a wall. Expected cost per human beat is a geometric series\n(≈3.3 model calls), not the cap (6).\n\n**Being named no longer bypasses the gate** — it sets the starting probability to 1.0\n(v1 behaviour for the first comeback) and then decays with everything else. This is a\n*tightening*, and it is load-bearing: naming the peer is the LLM's favourite register in\nthese exchanges (\"jules, no\" / \"priya, wrong\"), so under the old free pass a higher cap\nwould have driven **every** chain to the ceiling — precisely where the cost is. The\nclaim, the under-lock pre-send cap re-check, the throttle and the daily budget are all\nuntouched; §3's defence-in-depth argument still holds with one more layer.\n\n`/audit`'s group line now reports `banter on|off (decay N)` alongside the existing\nchain/budget counters, so \"why did they stop?\" stays answerable from Telegram.\n\n**Not changed:** the human-facing path. An addressed human message is still answered\ndeterministically by whoever was addressed; an unaddressed one still goes to exactly one\nbot via the claim. `_group_deliver`'s allowlist and `GROUP_ALLOWED_COMMANDS` are\nuntouched, so the group↔DM memory boundary is exactly where it was.\n\n**Tests:** 8 new (867 total), break-tested red — with the decay reverted to a flat gate\nthe depth tests fail, and with the v1 cap the reachability test fails. Both group evals\n(`group-deliver-clean`, `group-cmd-allowlist`) green and untouched.\n\n## v2026-08-02.12 — the seed check looked at a different file than the loader\n\n**Root cause: `_SEED_FILES` hardcoded `\"atlas.txt\"` while `ATLAS_FILE` makes that name\nconfigurable.** Any instance pointing the atlas somewhere else would get\n`Seeds: MISSING: atlas.txt` from `/audit` while loading its atlas perfectly well — a\nfalse alarm from the exact line added in v2026-08-02.9 to stop false silence.\n\nEmily was that instance: she had `places.txt` alongside an `ATLAS_FILE` override, which\nis why her Portland→Olympia relocation appeared to land in `atlas.txt` and change nothing\nlive. The `Seeds:` line could not have caught it, because it was checking a filename\nnobody had told it about. (Her override and the stale `places.txt` are both retired now —\n`grep -c 'Portland\\|Burnside\\|Powell' /opt/telegram-bots/emily/*.txt` returns 0 across\nevery file.)\n\n`_seed_paths()` now resolves the atlas through the same global the loader reads, and\nreports whatever filename that turns out to be. The general rule this is an instance of:\n**an audit that checks a different path than the code loads is worse than no audit**, because\nit answers confidently about the wrong thing.\n\nAlso removes a duplicated orphan comment block left above `features_cmd` in v2026-08-02.9.\n\n**Verification:** 859/859 pytest, 33/33 evals. 1 new test, break-tested RED (it reported\n`MISSING: atlas.txt` for a present `places.txt` — the live failure exactly).\n\n## v2026-08-02.11 — life.txt evolves instead of sitting there\n\n**Root cause: nothing ever wrote `life.txt`.** `LIFE_ARC_FILE` was even commented\n`# user-maintained`, `_read_life_arc()` only reads, and `/life` edits by hand — so a seeded\narc stayed frozen indefinitely. The owner noticed Bonnie's hadn't moved in a while; it was\nnever going to.\n\n`day.txt` already answers \"what happened today\" and regenerates nightly. The arc underneath\n— what she is currently *in* — had no equivalent. `_maybe_rotate_life_arc()` now runs from\nthe midnight job and acts once every `LIFE_ROTATE_DAYS` (7).\n\n**It evolves rather than regenerates**, which is the whole design:\n- the current arc goes **into** the prompt, so unresolved threads carry over in the same\n words where nothing about them has changed\n- **exactly one thing** may move per rotation — resolve, worsen, or a new thread starts. An\n arc that turns over completely every week is not an arc, it is a new character weekly\n- the form is pinned: one paragraph, present tense, 40-60 words, and the small grievance she\n is taking personally is explicitly part of it\n- vague filler is forbidden by name — \"she has been reflecting on things\" is the exact\n failure mode for this kind of prompt\n- it is fed the last week of `day_*.txt` archives, so the arc moves because of what actually\n happened rather than drifting on its own\n\n**Cadence uses a stamp file, not a weekday.** A weekday check skips the whole week if the\nbot is down that night; a stamp delays it instead. First run stamps and waits a full period,\nso a freshly seeded arc is not rewritten on its very first midnight.\n\n**Failure is always toward keeping what exists**: a short or empty result leaves the arc\nuntouched, the previous version is archived as `life_YYYY-MM-DD.txt`, and `_life_arc_cache`\nis invalidated so the new text takes effect immediately rather than after its 5-minute TTL.\n\nOne extra LLM call per week per instance. Invariant #3 governs per-message calls; this is\nweekly, and it runs off the loop.\n\n**Verification:** 858/858 pytest, 33/33 evals. 12 new tests, three break-tested RED —\nfirst-run guard, short-result guard, and whether the midnight job actually calls it. That\nlast one matters: a rotation nothing invokes is the same bug as a `life.txt` nothing writes.\n\n## v2026-08-02.10 — voice=on told you nothing, and Location was missing\n\n**A fleet-wide `/audit` sweep across all seven exposed two flaws in the audit itself,\nshipped an hour earlier.**\n\n`voice=on` was true on every instance, because the capability probe I wrote was\n`lambda: True` — NanoGPT TTS works without an Inworld key, so it could never say anything\nelse. It didn't distinguish Emily, the Inworld instance, from the six on the fallback. The\nfeature registry now carries an optional **detail probe**, so it reads `voice=on(inworld)`\nor `voice=on(nanogpt)`, `gif=on(high)`, `selfie=on(gemini)`. Details are omitted when a\nfeature is off, and a raising probe can't take the summary down with it — diagnostic output\nmust never be the thing that fails.\n\n`Location:` joins the Owner/TZ line. Its absence was conspicuous: this entire session began\nwith a weather bug, `WEATHER_LOCATION` is what drives weather, and nothing reported it.\n\n**What the sweep found in the fleet** (config, not code):\n- **Marcus runs `America/New_York`** while his atlas says Portland, OR and every other\n instance is Pacific. Three hours off across schedule busy-blocks, midnight rotation, the\n 07:00 wardrobe pick and quiet windows — and he shares a group with Emily on Pacific. The\n `TZ:` line added in v2026-08-02.9 found this on first use.\n- **`setting.txt` is missing on all seven** and exists in no repo seed. `SETTING` has never\n been populated for anyone.\n- **`life.txt` is missing on priya, marcus and jules**; the other four have one, live-only.\n- **Four instances still load the monolithic `preset.txt`** while marcus/jules/cass are\n layered. Every per-character layer already exists, and core+rp+explicit+stepped is 33,610\n bytes against the monolith's 34,241 — so this is a `.env` line, not a rewrite.\n- **`DEFAULT_SETTING` is Nora's setting text**, the same home-instance character bleed as\n `_APPEARANCE_DEFAULT` before v2026-08-01.11. Unreachable on named instances, wrong in the\n file.\n\n**All five closed 2026-08-02** — recorded here because a findings list with no resolution\nreads as current state to the next session. Marcus moved to `America/Los_Angeles` (and to\nOlympia, with Emily, so her WSDOT traffic matches her state); `setting.txt` authored for all\nseven and `life.txt` for the four that lacked one, then made self-evolving in\nv2026-08-02.11; bonnie/emily switched to layered presets in this pass, nora/priya with the\nv2026-08-02.12 deploy, so all seven are layered; `DEFAULT_SETTING` neutralized in code.\nConfig remedies are owner-applied and owner-reported verified — `.env` files are not in\nthis repo, so a per-instance `/audit` is the only reading that settles any of them.\n\n**Verification:** 846/846 pytest, 33/33 evals. 5 new tests, both fixes break-tested RED.\n\n## v2026-08-02.9 — /features, and an audit that answers the questions we kept asking\n\n**A systematic pass over what `/audit` could not see**, prompted by three separate blind\nspots in one week that each cost a round trip: four bots with no reference photo, three on\na weaker image backend, and \"I'm not sure if memes are turned on for anyone besides\nBonnie\". The common shape is **per-instance things that fail silently**, so the audit went\nlooking for the rest of them rather than waiting for the next one.\n\n**Three new lines:**\n- `Features:` — every integration as `on` / `off` / `n/a`, where `n/a` means never\n configured. **`off` and `never configured` are different problems with different fixes**,\n and conflating them is precisely what made those three incidents slow.\n- `Seeds:` — `all present`, or `MISSING: <files>`. Six files (`atlas` — under whatever\n name `ATLAS_FILE` resolves to, since v2026-08-02.12 — `people`,\n `projects`, `schedule`, `life`, `setting`) are read straight into her prompts, and a\n missing one costs content with no error whatsoever. jules ran without `atlas.txt` on the\n VPS for a stretch; `vps-sync.sh` still carries the comment about it.\n- `Owner:` / `TZ:` — no owner means nothing proactive can ever fire, and a wrong timezone\n quietly breaks busy-blocks, midnight rotation and the 07:00 wardrobe pick. Both look like\n healthy silence.\n\nVoice and traffic were the two credential-gated integrations reporting nothing at all,\ndespite maps and health already being covered — half the traffic stack was visible and half\nwasn't, which is worse than neither.\n\n**`/features <name> on|off`** flips selfie, meme, gif, voice, traffic, maps or health at\nruntime and persists to `feature_prefs.json`. Each target is a plain module global read at\ncall time, so flipping it reaches all 8-10 call sites without touching any of them — the\nsame mechanism `/setmodel` already uses. It refuses to switch on anything the instance\nisn't capable of, and says why. `selfie_ready`/`meme_ready` split into `*_capable` (assets\npresent) and `*_ready` (capable **and** switched on).\n\n**Verification:** 841/841 pytest, 33/33 evals. 10 new tests, three break-tested RED. A\nfourth injection — unwiring the switch from `selfie_ready` — **passed** its test, because\nthe fixture has no selfie assets so `selfie_capable()` was False either way and the\nassertion held for the wrong reason. Hardened to force capability True, then confirmed RED.\nSame masking C13 describes, caught only because the injection was run.\n\n## v2026-08-02.8 — How often she reaches for a GIF or a meme\n\n**`GIF_CHANCE` and `MEME_CHANCE` (both 0.35) gate whether she is OFFERED the option in a\ngiven reply — not whether a tag she emitted is honoured.** That distinction is the whole\ndesign. Dropping a tag after the fact would leave her text referring to an image that never\narrives (\"this is you →\" with nothing following), so the roll happens when the prompt is\nassembled: some replies simply don't mention that GIFs exist, and she writes normally.\n\n**Asking always works.** If your own message contains \"gif\"/\"jif\" or \"meme\", the option is\noffered regardless of the dice. Being told no because of a coin flip you can't see is the\nworst version of this feature.\n\n**Also: `/audit` gained a `Media:` line** — `meme=on/off gif=on/off (safety)`. This is the\nthird time the same blind spot has cost a round trip. `Selfie base:` was added when four\nbots turned out to have no reference photo; `via <provider>` when three were silently on a\nweaker backend; and this release began with \"I'm not sure if memes are turned on for anyone\nbesides Bonnie\" — a question the bot could not answer about itself.\n\n**Worth recording, since it caused that uncertainty:** `MEME_TEMPLATES_DIR` resolves\nagainst `bot.py`, not the instance directory, so it is `/opt/telegram-bots/meme_templates/`\nand **shared by all seven**. Meme support is all-or-nothing across the fleet; no single bot\ncan have it while others don't. `gifs.txt`, `appearance.txt` and the rest are per-instance —\nthis one is not, and the asymmetry is easy to misread.\n\n**Verification:** 831/831 pytest, 33/33 evals. 7 new tests; the offer gating and the audit\nline break-tested RED. A test pins that neither send path contains a probability roll, so\nthe gate cannot migrate from the offer to the send later.\n\n## v2026-08-02.7 — /gif, and the API key that would have leaked into a chat\n\n**`/gif <words>` is the parity command for `/selfie` and `/meme`**, and the reason it\nmatters is that every failure on the GIF path is deliberately silent — so without it, \"she\nnever sends GIFs\" and \"the Giphy call is broken\" look identical and you'd wait days to tell\nthem apart. Same blind spot as `/audit` not naming the image backend, which cost several\nrounds a day earlier.\n\nIt announces what went wrong, and the messages distinguish the cases that need different\nfixes: no `GIPHY_API_KEY`, `GIF_ENABLED=0`, Giphy unreachable, or nothing surviving the\nfilter at the current level. The auto path keeps `announce_errors=False` — mid-conversation\na missing GIF must stay invisible.\n\n**Building it surfaced a real leak in v2026-08-02.6.** Giphy takes `api_key` as a **query\nparameter**, so a `requests` exception carries the full URL — key included — and\n`log.warning(\"[gif] search failed: %s\", e)` wrote that into `errors.log`. `/errors` echoes\nrecent errors to the owner in Telegram, so the key had a path from the log into a chat\nmessage. `_redact_key()` now scrubs `api_key=`/`key=` values from both the search and send\nfailure paths before anything is logged, keeping the rest of the URL for diagnosis.\n\nThat is a hazard of every key-in-query-string API, not just this one: the secret ends up in\nexception text that error handling then treats as safe to log.\n\n**Verification:** 824/824 pytest, 33/33 evals. 8 new tests; the redaction break-tested RED\non both paths.\n\n## v2026-08-02.6 — GIFs, via Giphy, chosen in her own words\n\n**Tenor was the plan until research killed it.** Google stopped issuing Tenor API keys on\n2026-01-13 and terminated the API entirely on **2026-06-30** — a month before this was\nwritten. Existing keys return errors; Discord, X, Bluesky and WhatsApp all migrated. The\nintegration was never started. KLIPY was evaluated next: its content filter is a better\nfit (`high`/`medium`/`low`/`off`, matching the requested knobs exactly) and it is free\nforever, but it **inserts advertisements into search results** and its endpoint takes a\n`CUSTOMER_ID` — a per-user identifier handed to an ad network from private chats. Its docs\nwere unreadable from this container (403), so the field marking an ad could not be\nidentified, and an integration was not built on guesses. Giphy by elimination.\n\n**How it works:** she emits `[gif: a short search phrase]` in her own wording, the phrase\nis searched, candidates are filtered, and one is sent with `send_animation`. **No new LLM\ncall** — it rides the reply she was already generating, which is what invariant #3\nrequires.\n\n**Safety is layered, because Giphy's own filter cannot be trusted alone.** `/gifsafety\nhigh|medium|low` maps to Giphy's `rating` (`g` / `pg` / `pg-13`) and persists to\n`gif_prefs.json` — unlike `/setmodel`, since a safety level silently reverting on restart\nis the wrong failure direction, and an unrecognised persisted value falls back to `high`\nrather than open. Giphy's rating is **cumulative** (`pg-13` also returns `g` and `pg`) and\nits issue tracker carries long-standing reports of mixed ratings coming back regardless,\nso a local deny-list runs at **every** level on each candidate's title and slug. Its\nfourth rating, `r`, is unreachable by construction. The deny-list errs broad on sexual and\ngraphic terms — a false positive costs one missing GIF — but stays narrow on violence,\nsince \"kill\" would eat \"killing it\".\n\n**In-character selection** comes from two places: her own query wording, and a per-instance\n`gifs.txt` where a leading `-` bans a term and any other line is a term she is scored\ntoward. You curate the vocabulary, not the GIFs — the same division as `appearance.txt`\nversus the reference photo. Plus a recent-id ring buffer so she doesn't repeat herself.\n\n**The tag is stripped in the same commit that teaches her to emit it.** `extract_tags`\nremoves tags by name, so an unregistered one reaches the user verbatim — that is\nv2026-07-29.1, and you watched `[setbase: 60°F, clear…]` do it on 2026-08-02. `[gif:]`\nrides alongside `[search:]` rather than extending the pinned 4-tuple contract.\n\nEvery failure is silent: no API key, a search timeout, nothing passing the filter, a send\nerror. A missing GIF must never surface as an error mid-conversation, and never delays the\nreply it follows.\n\n**Verification:** 816/816 pytest, 33/33 evals. 14 new tests; four break-tested RED — the\ntag leak, the deny-list, per-instance bans, and the unknown-value fail-safe. The\n`env-vars-documented` eval caught `GIPHY_SEARCH_URL` undocumented before this shipped.\n\n## v2026-08-02.5 — /audit names the image backend\n\n**Root cause of a fleet split nobody could see: `SELFIE_PROVIDER` defaults to `nanogpt`\nunless `GEMINI_API_KEY` is set, and three instances have no key.** `grep -H\n'GEMINI_API_KEY' /opt/telegram-bots/*/.env` returns nothing at all for jules and only\ncommented lines for priya and marcus. Those three run NanoGPT's `flux-kontext`; bonnie,\ncass, emily and nora run Gemini.\n\nThat maps exactly onto the complaints. Jules's selfie came back as a visibly older,\ndifferently-boned woman while her reference was correctly attached and correctly cropped —\n`/audit` confirmed `Selfie base: jules_base.png`, and the file was 752x1085, one clean\ncrop. Priya, also on NanoGPT, was \"a bit different\". Every bot reported as fine is on\nGemini. (Nora was the earlier exception and had a separate, established cause: no\nreference photo attached at all until v2026-08-01.10.)\n\n**`/audit` reported which photo was in play but never which backend consumed it**, so a\nthree-instance split in image quality was invisible from Telegram — the same observability\ngap as the selfie-base one, one layer further down. The `Selfie base:` line now reads\n`<file> via <provider>`, and names the model on NanoGPT since \"nanogpt\" alone doesn't say\n`flux-kontext`.\n\n**Not a code fix.** `flux-kontext` preserving identity worse than Gemini on an edit is a\nmodel difference, not a bug — the remedy is a `GEMINI_API_KEY` in those three `.env` files,\nwhich is the owner's to apply.\n\n**Verification:** 802/802 pytest, 33/33 evals. 3 new tests; the render assertion\nbreak-tested RED, and `audit-keys-rendered` caught the same injection independently, which\nis the eval doing exactly the job it was added for one release ago.\n\n## v2026-08-02.4 — /setbase never worked as a caption\n\n**Root cause: PTB's `CommandHandler` matches `message.text` + `message.entities` only.**\nA photo or document caption populates `message.caption` / `caption_entities`, so a\n`/setbase` caption never reached the handler — the update fell through to `handle_photo`\nand the model answered it as conversation, inventing a `[setbase: 60°F, clear, wind 3mph,\nsummer]` tag by analogy with `[selfie: …]`. Verified by reading `check_update` in the\ninstalled wheel, not assumed.\n\nv2026-08-02.3 shipped that path *and documented it as the recommended one* the same day.\n\n**Why the tests were green on a path that could not run:** all eight asserted on the\nhandler's **source** — that `_is_admin` appears in it, that `CommandHandler(\"setbase\"` is\nin `main()`, that the write is atomic. Not one exercised dispatch. Reading a function's\nsource proves the code exists; it proves nothing about whether the framework will ever\ncall it. Fourth occurrence of the assert-without-exercising family (C8), and the first to\nreach the fleet.\n\n**Fix:** a `MessageHandler` on `(PHOTO | Document.IMAGE) & CaptionRegex(r\"^/setbase\\b\")`,\nregistered **before** `handle_photo` so it wins dispatch. The `CommandHandler` stays for\nthe reply-to-a-photo path, which was always fine since that is a text message.\n\n**Verification:** 799/799 pytest, 33/33 evals. 4 new tests, three break-tested RED. One of\nthem exercises PTB's `check_update` rather than describing it, so if a future PTB starts\nmatching captions the test fails and the extra handler can be reconsidered.\n\n## v2026-08-02.3 — /setbase: install a reference photo over Telegram\n\n**Root cause: the only route for getting a reference photo onto an instance was\nphone-local `scp`, and the owner's shell is on the VPS.** Three separate attempts in one\nsession went into the wrong shell — `termux-setup-storage: command not found`, then two\n`ls /sdcard/...` that matched nothing. That is C1's operator half: the agent can label a\nblock, but nothing stops a paste landing in the wrong terminal.\n\nRe-explaining it a fourth time was not going to work, so the transfer is gone instead.\n`/setbase` takes the image over Telegram — send it as a **file** with `/setbase` as the\ncaption, or reply to one with `/setbase`. A normal photo works too but Telegram\nrecompresses those, and the reference is the strongest identity signal in the selfie\npipeline, so the reply says so explicitly.\n\nDetails that matter:\n- **Format checked by magic bytes**, not the filename or Telegram's mime header — PNG,\n JPEG, WebP. Anything else is refused rather than installed.\n- **Writes to `SELFIE_BASE`'s existing name**, so no `.env` edit. Combined with\n v2026-08-02.2's byte-sniffed mime, a PNG landing at `nora_base.jpg` is now harmless.\n- **Previous photo kept as `<name>.prev`**, because a bad swap should be recoverable\n without another transfer.\n- **Atomic**: written to `.tmp` and renamed. A half-written reference is worse than a\n stale one.\n- **Takes effect immediately** — `_resolve_base_image()` stats the path per selfie, so\n there is no restart and no deploy.\n- Admin-gated; it overwrites a file in the instance directory.\n\n**Verification:** 795/795 pytest, 33/33 evals. 8 new tests; the admin gate, handler\nregistration and atomic write break-tested RED.\n\n## v2026-08-02.2 — The mime type comes from the bytes, not the filename\n\n**Root cause: `_base_image()` derived the mime type from the file extension.** Renaming a\nPNG to `.jpg` — routine when swapping reference photos between instances — declared PNG\ndata as `image/jpeg` to Gemini's `inline_data`. A rejected reference means the face falls\nback to whatever the text says, silently.\n\nInvestigated and cleared as the cause of Nora's drift (`ffd8 ffe0`, a genuine JFIF JPEG),\nand deliberately **not** shipped at that point: it fixed nothing observed. Shipping now\nbecause the owner is about to replace several base photos, which is exactly the operation\nthat produces an extension/format mismatch.\n\n`_sniff_mime()` reads the magic bytes — PNG signature, JPEG SOI, RIFF/WEBP — and falls\nback to the extension for anything unrecognised, so it is never worse than before.\n\n**Verification:** 787/787 pytest, 33/33 evals. 5 new tests including PNG-named-`.jpg`,\nJPEG-named-`.png`, WebP, unrecognised-bytes fallback, and an end-to-end pass through\n`_base_image()`.\n\n**Also, a new eval — `audit-keys-rendered`.** v2026-08-02.1 added `selfie_base` to\n`gather_audit_data()` and the startup log line, and the owner was told `/audit` would show\nit; `audit_cmd` builds its own lines and never rendered it. The eval now fails if any key\nin the audit data reaches no user-facing surface, unless listed as API-only. Break-tested\nRED by removing the `Selfie base:` line.\n\n## v2026-08-02.1 — /audit reports which reference photo is in play\n\n**Root cause: v2026-08-01.10 added the selfie-base status to the `=== STARTUP AUDIT ===`\nlog line, and I told the owner `/audit` would show it. Those are different code paths.**\nOwner checked and it was not there.\n\n`/audit` is the only surface for this that is reachable from Telegram, which matters\nbecause the question it answers — \"is this bot actually being sent its own face?\" — is\notherwise a `journalctl` away, on a host the owner has to SSH into.\n\n`gather_audit_data()` gained `selfie_base` (shared with the admin HTTP API) and `audit_cmd`\nrenders it as a `Selfie base:` line. Same `_base_image_status()` source as the startup line,\nso the two surfaces cannot disagree — a test pins that equality.\n\nAlso adds `cass/appearance.txt` and `marcus/appearance.txt`, written from the reference\nphotos the owner supplied rather than from the cards. Neither instance had one, and with no\nbase image either, both were generating from the bare fallback string.\n\n**Two card/photo discrepancies, deliberately resolved toward the photo** — the photo is what\nan image edit actually anchors on, so a description that fights it recreates the\ncontradiction class this week's releases have been removing:\n- Marcus's card says *\"close-cropped hair\"* and age **31**. His photo shows a clean-shaven\n scalp, a full beard going grey, and reads mid-40s. `appearance.txt` describes the photo.\n- Cass's card has no physical block at all, so the photo is the only source for her.\n\nNeither card was edited — that is a content decision for the owner, and `edit-cards-and-presets`\nis the right path if the cards should move instead.\n\n**Verification:** 782/782 pytest, 32/32 evals, 3 new tests, the rendered-line assertion\nbreak-tested RED.\n\n## v2026-08-01.11 — Marcus was being drawn as a woman\n\n**Root cause: the shared no-`appearance.txt` fallback hardcoded a sex.** `SELFIE_APPEARANCE`\nread `\"an adult woman in her late 20s, the same person as in the reference photo\"` for any\nnamed instance without an `appearance.txt`. Marcus Calder is 31, 6'2\", a man — and has no\nreference photo on disk, so whatever describes him in an image prompt is that string alone.\n\nFound while auditing `SELFIE_BASE` across the fleet after v2026-08-01.10, which turned up\nhow many instances fall back rather than configure. **Third instance of one class**, after\nIngrid's courier jacket (v2026-08-01.8) and hardcoded \"freckles\" (v2026-08-01.9): shared\ncode asserting one character's traits across all seven. The first two were cosmetic on a\ncharacter who happened not to match. This one changes the person.\n\n**Fix:** both fallbacks are sex-neutral (`\"an adult in their late 20s\"`), keeping the\nexplicit adult age that Gemini's image filter needs to avoid returning blacked-out frames.\nThe startup-audit `Selfie base:` field now distinguishes the worst case — no reference\nphoto *and* no `appearance.txt` reads `TEXT-ONLY, NO APPEARANCE.TXT — every selfie is a\ngeneric stranger`, because that state has nothing describing the character at all.\n\nThe real fix for cass and marcus is content, not code: both have no base image on disk, so\nv2026-08-01.10's autodetect cannot help them. They need a reference photo, an\n`appearance.txt`, or both.\n\n**Verification:** 779/779 pytest, 32/32 evals, 3 new tests, the neutrality assertion\nbreak-tested RED. That test also failed twice before it was right: first it flagged its own\nexplanatory comment (C14 — a scanner cannot tell doing-the-bad-thing from describing it),\nthen `\"he \"` matched inside `\"the \"`. Word boundaries and comment stripping, both needed.\n\n## v2026-08-01.10 — Nora's reference photo was never being sent\n\n**Root cause: `SELFIE_BASE` defaults to `priya_base.png`, nora's `.env` never set it, and\nher photo is `nora_base.jpg` — wrong name and wrong extension.** `_has_base_image()`\nreturned False, so every one of her selfies took the text-only branch and no reference\nphoto was ever attached to the Gemini call. The file has been sitting in\n`/opt/telegram-bots/nora/` since 27 June.\n\n**Nothing reported it**, which is the part worth fixing. `selfie_ready()` returns True if\nthe base image **or** `appearance.txt` exists; she has had an `appearance.txt` since 26\nJune, so the check passed and the degradation from image-edit to text-only was completely\nsilent. `[observed]` `grep -L SELFIE_BASE /opt/telegram-bots/*/.env` returned exactly one\ninstance — hers.\n\nThis supersedes v2026-08-01.9's diagnosis as the primary cause. That release measured two\nreal problems (the identity anchor sat ~1000 characters from the end of the prompt; 29.9%\nof draws stacked a face-obscuring framing on a face-degrading camera) and both fixes stand\non their own — but they were tuning an *edit* prompt for a call that was not editing\nanything. The v2026-08-01.9 analysis assumed a reference photo was attached. It was not.\n\n**Fix:**\n- `_resolve_base_image()` falls back to the single unambiguous `*_base.(png|jpg|jpeg|webp)`\n in the instance directory when `SELFIE_BASE` names a file that is not there. With two or\n more candidates it returns None rather than guessing — picking between two faces is how\n you ship the wrong woman.\n- `_base_image()` now takes its mime type from the **resolved** file, not the configured\n name. A `.jpg` announced as `image/png` is a working photo that still gets rejected.\n- The `=== STARTUP AUDIT ===` line gained `Selfie base:`, reporting the filename in play,\n `AUTODETECTED (… set it in .env)`, or `TEXT-ONLY` with the candidates it saw. `/audit`\n now answers \"is she actually being sent her own face?\" without a shell.\n- Kill switch `SELFIE_BASE_AUTODETECT=0`.\n\n**Reverted from v2026-08-01.9:** `telegram-companion-bot/nora/appearance.txt`, which that\nrelease added. Nora already had a real one on the instance, 381 bytes, dated 26 June — the\nrepo copy was written from her card by this session and had never been compared against it.\n`vps-sync.sh` only copies seed files that are *missing*, so nothing was overwritten, but\nleaving an invented file in the seed directory is the exact divergence trap that script\nwarns about (the jules atlas.txt case, 2026-07-29). The live instance is authoritative;\nthe fabricated seed and its three tests are removed.\n\n**Verification:** 776/776 pytest, 32/32 evals, 10 new tests. Three assertions break-tested\nRED. The first attempt broke all three at once and the ambiguity test still passed — the\nautodetect-off injection masked the guess-the-first-candidate injection, so it was passing\nfor the wrong reason. Re-run in isolation, it failed correctly. Break-tests need one\ninjection at a time.\n\n**Still open:** the `SELFIE_BASE` values across the fleet were read with `grep -h`, which\nstrips filenames, so two instances showing `SELFIE_BASE=nora_base.png` cannot be attributed\nto an instance. If a non-nora instance points at `nora_base.png` and has no such file, it\nis text-only too — the new startup audit line will say so on next restart.\n\n## v2026-08-01.9 — Sometimes the selfie wasn't her\n\n**Root cause: the identity instruction is the FIRST thing in the image prompt, and two\nreleases of appended scene text pushed ~1000 characters after it — while 30% of random\ndraws stacked a face-obscuring framing on a face-degrading camera.** Owner-reported:\nselfies that don't look like Nora, intermittently. (Same report confirmed v2026-08-01.7's\nweather fix is working.)\n\n`build_selfie_prompt` opens with \"Edit the attached photo of this exact woman — do not\ngenerate a new person…\" as `bits[0]`, then appends 17 more instructions: pose, expression,\nactivity, outfit, outerwear, scene, camera look, the weather clause (which v2026-08-01.7\nmade longer), the anatomy rule, the realism rule, and a scene-dedup list naming *other\nsetups*. On an image edit the last thing said sits nearest the output; the identity anchor\nwas as far from it as it could be.\n\nCompounding that, the framing and camera pools are full of choices that legitimately make\na candid phone photo — mirror shots, half-in-frame crops, motion blur, harsh flash, grainy\nlow light, backlit shadow — and they were drawn independently. Measured over 2000 seeds:\n**29.9% of prompts drew a face-obscuring framing AND a face-degrading camera**, and only\n16.7% were clean. One soft choice is candid; two leave an edit model enough latitude to\ndrift the face into a different woman. ~30% matches \"on occasion\" well.\n\nA third contributor, specific to Nora: she had **no `appearance.txt`**, so\n`SELFIE_APPEARANCE` fell back to `\"an adult woman in her late 20s, the same person as in\nthe reference photo\"` — a *pointer*, not a description. Every verbal identity signal in\nher prompt was a reference to an image. Any draw that weakened the photo's influence left\nnothing behind it.\n\n**Fix:**\n- `_SELFIE_IDENTITY_TAIL` restates the identity constraint as the genuinely last line —\n after the dedup list, deliberately, since that block names other setups.\n- A soft framing now filters soft camera looks out of the pool: stacked draws **29.9% → 0%**,\n with one soft choice still freely available.\n- `telegram-companion-bot/nora/appearance.txt` written from her card's `<physicality>`\n block, so her face has a verbal anchor and not just a photo pointer.\n- The shared identity line hardcoded **\"freckles\"** — Nora's trait, applied to all seven\n characters. Now \"distinguishing features\". Same character-bleed family as the courier\n jacket in v2026-08-01.8; a test pins the shared prompt against five such traits.\n\nKill switch `SELFIE_IDENTITY_GUARD=0`. Prompt assembly only, no new LLM calls.\n\n**Verification:** 769/769 pytest, 32/32 evals, 10 new tests. Three load-bearing assertions\nbreak-tested RED (de-stacking, tail position, trait hardcoding). The tail-position case\nneeded *two* tests: with `chat_id=None` there is no dedup block, so the simple \"ends with\"\nassertion still passed when the tail was moved back above it — only the dedup-present test\ncaught the regression.\n\n**Not proven:** that this fixes what the owner saw. The mechanisms are real and measured,\nbut nothing here confirms which draw produced any particular bad image — that needs\nselfies watched over time. Also unconfirmed: whether `SELFIE_BASE` is correctly set for\neach instance. It defaults to `priya_base.png`, so an instance whose `.env` omits it has\n**no** reference photo attached at all and generates from text alone.\n\n## v2026-08-01.8 — She dresses once a day, and her jacket exists again\n\n**Three owner-reported items, one subsystem.**\n\n**1. The wardrobe never changed on its own.** `wardrobe[\"current\"]` was only ever set by\nhand via `/outfit`; with nothing set, `build_selfie_prompt` drew a fresh random outfit per\nphoto, so she could wear three different things in an hour and nothing in particular on\nany given day. Now `wardrobe_rotate_job` picks one weather-appropriate outfit each morning\nand she wears it all day.\n\nIt runs at `WARDROBE_ROTATE_HOUR` (default 07:00 local), **not** at midnight, and re-reads\nthe weather first. Picking a day's clothes from midnight's reading is precisely the\nfrozen-overnight-snapshot mistake `world.txt` makes and v2026-08-01.7 was written to\nremove — rebuilding it one release later in a new place would have been the joke of the\nweek. For the same reason, an outfit the *rotation* chose is re-checked against live\nweather at selfie time and dropped if the afternoon outran it; an outfit set *by hand* is\nnever second-guessed.\n\nSelection prefers the `/addoutfit` wardrobe and falls back to the built-in pool when it's\nempty, so an instance with no wardrobe history still changes clothes daily. Free-text\noutfits are classified by keyword (`_OUTFIT_WARM_WORDS`/`_OUTFIT_COOL_WORDS`) against\n`SELFIE_WARM_F`/`SELFIE_COLD_F` — deterministic, and **no LLM call**. Unknown weather\nsuits everything: absent data must never narrow the wardrobe to nothing.\n\n`/outfit` holds for the rest of that day and rotation resumes the next morning (owner\ndecision, 2026-08-01) — implemented by having `/outfit` claim the day's `picked` stamp,\nwhich the job's same-day guard then honors without needing a second rule.\n\n**2. Ingrid's courier jacket had never once appeared.** It was gated on\n`SELFIE_APPEARANCE is _APPEARANCE_DEFAULT`, which holds only when `not IS_NAMED_INSTANCE`\n— an unnamed run from the code directory. `deploy/bot@.service` is\n`ExecStart=… bot.py /opt/telegram-bots/%i`, so every live instance is named and the branch\nwas dead on all seven. It is now `OUTDOOR_LAYER`, per-instance env config: a specific\nobject, unset by default, added only outdoors and only when it isn't warm. **Nora's\ninstance needs `OUTDOOR_LAYER` set in her `.env` for the jacket to come back** — this\nrelease makes it possible, not automatic.\n\n**3. `_APPEARANCE_DEFAULT` described nobody.** A half-shaved head, septum ring and sleeved\ntattoos — a relic of a discarded card that made Priya a tattoo artist (owner, 2026-08-01).\nUnreachable on the fleet for the same argv reason, but wrong in the file. Now a neutral\n`\"an adult woman in her late 20s.\"`, keeping the explicit adult age that Gemini's safety\nfilter needs.\n\n**Verification:** 19 new tests; four load-bearing assertions break-tested RED\nindependently (same-day guard, stale-auto-outfit re-check, warmth gate on outerwear,\nunknown-weather default). 759/759 pytest, 32/32 evals. One existing v2026-08-01.7\nassertion had been silently defanged by `OUTDOOR_LAYER` defaulting to empty — its\n\"courier jacket\" check could no longer fail — and was repaired to set the layer explicitly\n(C13).\n\n## v2026-08-01.7 — Nora sent a rainy selfie on a sunny day\n\n**Root cause: `build_selfie_prompt` composed the scene from weather-blind random pools,\nthen appended the real weather as a trailing hint that told the image model not to render\nit.** Owner-reported: a rainy selfie while Seattle was sunny all day.\n\nThe weather data was never wrong, and this is worth stating because it is where the\ninvestigation would naturally start. `/status` on nora showed `Weather: 70°F, clear,\nwind 11mph`, her day context was the Eastlake bike lane with no rain in it, and she was\ntexting about the sun on her neck. Three plausible mechanisms were ruled out by that one\ncommand: the Open-Meteo fetch had not failed, the cache was not stale, and `world.txt`\nhad not seeded a rainy day narrative.\n\nThe defect is in prompt assembly. `_weather_outdoor_ok` screens for *precipitation* and\n`_weather_camera_pool` screens *camera presets*, but nothing screened the scene itself\nfor **temperature**. `SELFIE_ACTIVITIES` contains \"bundled up against the cold\",\n`SELFIE_OUTFITS` contains \"a beanie and a hoodie\", and Ingrid's canvas courier jacket was\nappended to every outdoor shot unconditionally. A rendered prompt at 70°F clear:\n\n> ...She's **bundled up against the cold**. Over that, she's got on Ingrid's **oversized\n> vintage canvas courier jacket**... Somewhere in **Seattle**, in the afternoon...\n> Current weather: 70°F, clear, wind 11mph. Let it read in the lighting, atmosphere, and\n> what she might be wearing — **don't describe the weather explicitly, just let it show.**\n\nFour signals say cold-and-grey Seattle; one token says 70°F clear — and the final clause,\nphrased for a text model, reads to an image model as *suppress the weather*. The image\nfollowed the scene. 137 of 300 seeds produced contradictory content at that reading, which\nis why this was intermittent rather than constant.\n\n**Fix:** `_weather_temp_f` parses the air temperature (taking the first `°F` field, never\n\"feels like\"), and `_weather_scene_pool` drops cold-weather activities and outfits — and\nthe jacket — at or above `SELFIE_WARM_F` (68°F). The weather clause is now directive\n(\"which the image must match\") and a dry reading appends an explicit negative: *no rain,\nwet pavement, puddles, umbrellas, rain-streaked glass*. Clear-sky and no-precipitation are\nasserted separately, so an overcast day never claims \"no heavy grey overcast\".\n\nUnknown weather is deliberately **not** treated as warm — absent data must not strip her\njacket in January. Kill switch `SELFIE_WEATHER_MATCH=0` restores the previous prompt\nbyte-for-byte, pinned by a test. No new LLM calls; prompt assembly only.\n\n**Verification:** at 70°F clear, contradictory content across 300 seeds went 137 → 0, and\nthe no-rain negative appears in 300/300. Cold and rainy readings are unchanged (winter\ncontent still appears; the negative never does). 11 new tests; the three load-bearing\nassertions were break-tested RED before being trusted (C3).\n\n**Left open deliberately:** Ingrid's courier jacket — a Nora-specific inheritance — is\ngated on `SELFIE_APPEARANCE is _APPEARANCE_DEFAULT`, and that default describes a\nhalf-shaved head, septum ring and sleeved tattoos, which is Priya's look, not Nora's. Any\ninstance falling through to the default gets both. Not touched here (out of scope for a\nweather fix), and not yet confirmed against the live instance dirs.\n\n> **Correction (v2026-08-01.8):** the paragraph above is wrong, and the flagged\n> uncertainty is what was wrong. `_APPEARANCE_DEFAULT` is reachable only when\n> `not IS_NAMED_INSTANCE`, i.e. when bot.py runs with no instance-directory argument.\n> `deploy/bot@.service` is `ExecStart=… bot.py /opt/telegram-bots/%i`, so all seven live\n> instances are named, and neither the tattoo-artist description nor the jacket has ever\n> reached a live selfie. The description was a relic of a discarded card (owner,\n> 2026-08-01); the jacket was unreachable code. Both are fixed in v2026-08-01.8.\n\n## v2026-08-01.6 — /dupefacts: a read-only diagnostic for near-duplicate facts\n\n**Not a fix — a deliberately narrow tool to gather evidence before writing one.**\nFollow-up to `v2026-08-01.5`'s fusion fix: asked whether embeddings could improve\nmemory quality further. They can't fix the fusion bug (that was a generation-quality\nproblem; embeddings solve retrieval, and `facts`/`recent_facts` are injected into every\nprompt unconditionally — there's no retrieval step for embeddings to improve). But\n`_summarize()`/`_consolidate_facts()`'s own dedup is exact-lowercase-string matching\nonly, which would miss a fact reworded across consolidation passes sitting alongside\nits near-twin — and semantic dedup already exists for the *other* memory tier\n(`_is_semantic_dup`, gating `/addmem`'s auto path at `MEMORY_DEDUP_SIM`, default 0.92)\nbut was never extended to facts.\n\n**Deliberately not auto-merge.** A similarity threshold with no real data behind it\nrisks flagging genuinely distinct facts (two different Costco trips, worded\nsimilarly) as duplicates and silently discarding one — a new failure mode introduced\nspeculatively rather than fixing an observed one. `/dupefacts` only reports candidate\npairs for a human to judge; nothing is merged or deleted.\n\n**Shipped:**\n- `_embed_and_cache(text)` — like `_embed_memory_line` but returns the vector; shares\n `_embeddings_cache`/`embeddings.json` with the memories.txt path, so facts get a\n durable, reusable embedding cache for free.\n- `_find_near_duplicate_pairs(items, vecs, threshold)` — pure, the diagnostic sibling\n of `_is_semantic_dup`: instead of \"is this one new item a duplicate of anything,\"\n surfaces every near-duplicate pair already sitting in one list.\n- `/dupefacts` command (`_is_allowed`-gated, same as `/reviewmem`/`/editmem`/\n `/sourcemem` — not admin-only): checks `facts` and `recent_facts` independently,\n reports pairs at cosine ≥ `MEMORY_DEDUP_SIM` with their similarity score, or says\n plainly that none were found. Reuses `MEMORY_DEDUP_SIM` rather than adding a new env\n var — this is explicitly a temporary evidence-gathering tool, not a permanent\n feature needing its own tunable.\n\n**Tests:** `TestFindNearDuplicatePairs` (pure, synthetic vectors, no network),\n`TestEmbedAndCache` (cache hit/miss/failure via a monkeypatched `_embed_text`), and\n`TestDupefactsCmd` (reports a real pair, says \"none\" plainly when there are none,\nnever mutates `facts`/`recent_facts`, and a disallowed user gets nothing). The\ndisallowed-user gate was break-tested RED (temporarily removed the `_is_allowed`\ncheck, confirmed the test caught it) before being trusted, restored via the Edit tool.\n\n**Verified:** `python3 -m py_compile bot.py` clean, `pytest` 729/729, `run-evals.sh`\n32/32 green.\n\n## v2026-08-01.5 — recent-memory facts stopped fusing events with commentary about them\n\n**Root cause: `_summarize()`'s prompt had no instruction against conflating two\ndifferent things into one fact.** Owner reported Priya re-surfacing a two-day-old topic\nas if she'd never been told — `/recall costco` turned up the actual culprit: *\"Costco\nfood court trip: 'in and out like a bad lover'—Priya called it self-reporting; Brian\ncorrected it was a simile.\"* That's not one memory, it's three folded into a run-on\nsentence — the trip itself, a line Priya said about it, and a separate argument over\nhow to categorize her own phrasing.\n\n**Ruled out first, not assumed:** checked whether a weak background model was the\ncause (`v2026-07-29.3`'s `SUMMARY_MODEL`-doing-caption-work bug was the obvious prior).\nIt wasn't — `priya/.env` has no `SUMMARY_MODEL` override, so `_summarize()` was running\non her own chat model, `zai-org/glm-5.1:thinking`, a strong reasoning model. A capable\nmodel still produced this, because nothing in the prompt told it not to: \"a curated\nlist of specific, meaningful things... a continuous recollection, not a list of events\"\nis a compression instruction with no constraint keeping each fact resolvable on its\nown. `bot-code-invariants` #17 already mandates exactly this kind of discipline for\n`user_notes.txt` extraction (confidence gating, quote grounding, null-over-guess) — the\n`recent_facts`/summary pipeline had none of it.\n\n**Fix:** both `_summarize()` (writes new facts from scrolled-off messages) and\n`_consolidate_facts()` (merges the list when it passes `RECENT_FACTS_MAX`) now require\neach fact to describe ONE concrete thing, in one plain sentence, resolvable without\ncross-referencing another fact — and explicitly forbid fusing an event with separate\ncommentary about it (a remark on how something was phrased, categorized, or argued\nover) just because they share a topic. `_consolidate_facts` additionally: if two facts\ndon't reduce to one clean sentence without cross-referencing each other, keep them as\ntwo rather than force a merge — since repeated consolidation passes compound this exact\nerror over time with no way to re-check against the original messages once they've\nscrolled out of context.\n\n**Prompt-only change, both functions still route through `SUMMARY_MODEL`** — no new\ncall, no new env var, no kill switch needed (this fixes a defect, it doesn't add\noptional behavior).\n\n**Tests:** `TestFactAtomicity` pins the new constraint text in both function sources.\nBoth assertions break-tested RED (temporarily removed each constraint independently,\nconfirmed the corresponding test fails) before being trusted, restored via the Edit\ntool directly rather than the fragile string-replace-in-a-heredoc approach tried first,\nwhich left a syntactically mangled (though still-compiling) intermediate state — caught\nby re-reading the diff before trusting it, not shipped.\n\n**Verified:** `python3 -m py_compile bot.py` clean, `pytest` 715/715, `run-evals.sh`\n32/32 green.\n\n**Not yet confirmed:** whether this actually stops the re-surfacing behavior in\npractice — the fix targets the mechanism that produced the one bad example we have,\nbut there's no way to verify \"does Priya stop doing this\" without watching her memory\nover the next several days of real conversation.\n\n## v2026-08-01.4 — /model shows every model role, not just chat\n\n**Root cause of the request:** while investigating a live memory-quality complaint\n(Priya re-surfacing a two-day-old topic as if it were new), the diagnosis needed to\nknow which model was actually running `SUMMARY_MODEL`/`MOOD_MODEL` on that instance —\nand there was no cheap way to check. `/model` showed only the chat model.\n`/setmodel` with no args already lists every `MODEL_ROLES` entry, but it also fetches\nthe live subscription model list and adds picker/usage framing, so it's not the\nquick glance a live-ops question needs.\n\n**Shipped:** `/model` now lists every role in `MODEL_ROLES` (chat, summary, caption,\nreaction, mood, vision, fallback, visionfallback) with its current effective value,\nreading straight off `globals()[var]` — the same mechanism `/setmodel` already writes\nthrough, so a live override shows up here too, not just the `.env`-loaded default.\nNo live API call added; still cheap enough to check on every incident. Can't drift\nfrom `/setmodel`'s own role list because both read the same `MODEL_ROLES` dict.\n\n**Tests:** `TestModelInfoShowsEveryRole` — every role appears in the reply, the actual\nconfigured value shows (not a placeholder), and a source-level check that no\nsubscription-list call was added. Break-tested RED (reverted to the single-model\nversion, confirmed the role-coverage assertion fails with the missing role named)\nbefore being trusted, restored by re-editing per constraints C15.\n\n**Verified:** `python3 -m py_compile bot.py` clean, `pytest` 713/713, `run-evals.sh`\n32/32 green.\n\n## v2026-08-01.3 — MEMORY_TOKEN_BUDGET now means real tokens (ROADMAP 4.4, owner-approved)\n\n**What this closes:** since v2026-07-26.2 made every other reported token figure real\n(`usage.prompt_tokens`, per-instance calibration ratio), `MEMORY_TOKEN_BUDGET` was\ndeliberately left on the raw `len//4` unit — a regression test\n(`test_memory_budget_stays_on_the_raw_unit`) pinned it there specifically so the switch\ncouldn't ship as a side effect of some other release. Recalibrating this knob changes how\nmuch a character actually recalls per reply, which is a product decision, not an\naccounting fix — hence owner-gated rather than done the day 4.4 was filed.\n\n**Owner approved 2026-08-01** and supplied each instance's current calibration ratio\nstraight from `/audit` (all well past the ~15-call EMA reconvergence point, 46-235\nmeasured calls each):\n\n| instance | ratio | 300 (old default) × ratio |\n|---|---|---|\n| bonnie | 0.91 | 273 |\n| emily | 0.90 | 270 |\n| nora | 0.92 | 276 |\n| priya | 0.92 | 276 |\n| cass | 0.91 | 273 |\n| marcus | 0.91 | 273 |\n| jules | 0.93 | 279 |\n\n**The fleet's ratios cluster tightly (0.90-0.93)** — worth recording because it changes\nhow much this migration actually matters in practice: no character is far enough off\nfrom the others for the unit switch to meaningfully redistribute recall between them.\nThe risk this item was gated against was real in principle, small in this instance.\n\n**Shipped:** `triggered_memories()`'s budget loop now costs each candidate line with\n`_tokens()` (calibrated) instead of `_est_tokens()` (raw) — one line changed, per the\nROADMAP plan. `TOKEN_CALIBRATION=0` reverts this budget check to the raw unit too,\nsame kill switch as every other calibrated figure; no new kill switch needed. The\nregression test was updated in place (renamed, not deleted) to assert the new intended\nbehavior, `test_memory_budget_uses_calibrated_units` — same guard, opposite direction,\nso a future accidental revert back to raw units gets caught the same way this one\nprotected against a future accidental *forward* switch.\n\n**Not yet done — this is the step that actually preserves recall volume:** the table\nabove assumes every instance is still on the shared 300 default, which was not\nindependently confirmed (`/audit` doesn't surface `MEMORY_TOKEN_BUDGET`, and this\nsession has no VPS access to grep `.env` directly). **Check each instance's `.env` for\nan existing override before using these numbers** — multiply *that* value by the\ninstance's ratio instead of 300 if one exists. Assuming the default and being wrong\nwould undershoot or overshoot recall by whatever the real prior value was, not just the\n~8-10% the ratio itself accounts for. Once confirmed, set each instance's `.env`:\n```bash\n# host: VPS (as root)\necho \"MEMORY_TOKEN_BUDGET=273\" >> /opt/telegram-bots/bonnie/.env\necho \"MEMORY_TOKEN_BUDGET=270\" >> /opt/telegram-bots/emily/.env\necho \"MEMORY_TOKEN_BUDGET=276\" >> /opt/telegram-bots/nora/.env\necho \"MEMORY_TOKEN_BUDGET=276\" >> /opt/telegram-bots/priya/.env\necho \"MEMORY_TOKEN_BUDGET=273\" >> /opt/telegram-bots/cass/.env\necho \"MEMORY_TOKEN_BUDGET=273\" >> /opt/telegram-bots/marcus/.env\necho \"MEMORY_TOKEN_BUDGET=279\" >> /opt/telegram-bots/jules/.env\n```\nthen `vps-sync.sh` (or just `/restart`, since these are `.env`-only edits and the code\nis already merged) per instance.\n\n**Verified:** `python3 -m py_compile bot.py` clean, `pytest` 710/710, `run-evals.sh`\n32/32 green. The updated regression test was break-tested RED (reverted to\n`_est_tokens`, confirmed it fails with the expected message) before being trusted, then\nrestored by re-editing — not `git checkout`, per constraints C15.\n\n## v2026-08-01.2 — 17 commands worked if typed but never appeared in Telegram's menu\n\n**Root cause: `_build_command_menu` is hand-kept alongside the `CommandHandler`\nregistrations — its own docstring says so — and nothing enforced that until now.** An\naudit comparing every `app.add_handler(CommandHandler(...))` in `main()` against the\nmenu builder's output found 17 unconditionally-registered commands missing from every\nmenu list: `card`, `errors`, `fleet`, `life`, `meme`, `note`, `notes`, `people`,\n`projects`, `quiet`, `quietwin`, `recap`, `restart`, `schedule`, `setcard`, `today`,\n`update`. All 17 worked fine if a user typed them manually — the handlers were real —\nthey simply never showed up in Telegram's autocomplete popup, so a user would only find\nthem by already knowing they existed (from `OPS_MANUAL.md`, or trial and error).\n\nNo dead entries in the other direction — nothing in the menu lacked a working handler.\n\n**Fix:** added all 17 to `_BASE_COMMANDS`, grouped near their thematic neighbors\n(`recap`/`card`/`setcard` near `status`; `life`/`people`/`projects`/`schedule`/`today`/\n`note`/`notes` — the Context Files group — near the memory commands; `quiet`/`quietwin`\nnear `nudges`; `meme` near `selfie`; `errors`/`restart`/`update`/`fleet` alongside\n`audit`/`backup`, which were already unconditionally listed). This matches the existing\nconvention exactly: `_MAPS_COMMANDS`'s own comment says unconditionally-registered\nhandlers belong unconditionally in the menu, same as conditionally-registered ones\n(`traffic`, `payments`, `garmin`, `preset`) already mirror their own kill switches.\n`/update`'s description was written to match its actual current behavior (dead as a\ndeploy path on the private repo, replies pointing at `vps-sync.sh`) rather than the\nstale \"pull latest bot.py\" description it would otherwise have inherited.\n\n**New regression test, `TestCommandMenuMirrorsHandlers`**, extracts every\n`CommandHandler(\"...\")` name from `main()`'s source via `inspect.getsource` + regex and\nasserts it's a two-way match against `_build_command_menu(True, True, True, True)`'s\nfull command set — both directions (registered-but-missing, and menu-but-dead), so this\nexact class can't recur silently again. Both assertions break-tested RED before being\ntrusted: removing one menu entry and adding one fake unregistered entry each failed the\ncorrect assertion with the correct missing/dead name.\n\n**Self-inflicted near-miss while writing that break-test, logged as constraints C15:**\nreverted one break-test edit with `git checkout -- bot.py`, which restores the file to\nits last *committed* state, not \"current minus my last edit\" — and at that moment\nbot.py held this same commit's uncommitted menu-fix work. All 17 additions were\nsilently wiped in one command. Caught immediately by `git diff --stat` showing zero\nchanges where 18 lines were expected; the earlier edits were redone from memory rather\nthan lost. No broken code shipped, but this is the second time this exact command has\ndestroyed real uncommitted work in this repo (the first is `repo-change-control`'s own\n\"Common mistakes\" entry, \"this destroyed ~700 lines once\") — promoted straight to a\nnumbered constraint rather than waiting for a third occurrence.\n\n**Verified:** `python3 -m py_compile bot.py` clean, `pytest` 710/710 passed, `run-evals.sh`\n32/32 green, and the registered/menu name sets diffed programmatically both\ndirections (empty both ways) independent of the new pytest coverage.\n\n## 2026-08-01 — Chimera's banned-rhetoric block ported to preset-rp.txt, not preset-core.txt (content only, no bot.py change, no version bump)\n\n**ROADMAP 3.14 shipped, but to a different file than the item specified** — a roleplay\nsimulation before shipping caught that the original plan was wrong, which is the part\nworth recording. `Chimera_v2.json` names four prose constructions as \"the loudest\nmachine tells\" and forbids them outright: contrastive negation (`not X but Y`),\nfalse-correction/epanorthosis (`It was X. No — Y.`), negation-as-atmosphere (`it wasn't\nthe wind`), and litotes (`not unkind`). 3.14 planned to port these into\n`preset-core.txt`, reasoned as universal prose hygiene reaching all seven instances via\n3.13's layering.\n\n**The plan was tested, not just reviewed, before anything shipped.** Same message run\nagainst Priya's actual stack (`core+stepped+priya`, no narration) and Jules's\n(`core+rp+explicit+stepped+jules`, narrates in third person):\n- **Priya:** her natural reply included *\"not mad or anything, just tired\"* — an\n ordinary first-person texting hedge that happens to share contrastive-negation's\n surface shape. Under the rule as drafted for `preset-core.txt`, zero tolerance would\n have forced cutting it — sanding a real human speech habit to fix a problem that only\n exists in narrated prose. Priya never narrates; the rule doesn't apply to her at all,\n and shipping it to `preset-core.txt` would have applied it anyway, fleet-wide.\n- **Jules:** narration reaching for *\"It wasn't nothing, though\"* in a restraint beat —\n the actual tell. Rewritten as *\"It mattered.\"* — tighter, and more in-character per\n `preset-jules.txt` (her resolution is never a soft line).\n\n**Root cause of the near-miss:** Chimera's bans describe third-person narrated prose,\nnot first-person conversational hedging, and `preset-core.txt` is loaded by narrating\nand non-narrating instances alike. `preset-rp.txt` (the narration layer that 3.13 already\nbuilt) is loaded only by instances that actually narrate — nora, bonnie, emily, jules,\nmarcus — never cass or priya. Targeting `preset-rp.txt` instead gets the correct scoping\nfor free from the layer boundary, with no carve-out text needed for the two instances\nwhere the rule doesn't belong.\n\n**Shipped:** the four-ban paragraph added to `preset-rp.txt`'s `[NARRATION]` section,\nright after the opening paragraph, in the file's existing Bad/Good example style (~60\ntokens). `git diff` confirmed the change is isolated to exactly that insertion.\nVerified: `bash .claude/evals/run-evals.sh` 32/32 green.\n\n**Not yet done:** `vps-sync.sh` re-run on the five instances that load `preset-rp.txt`\nto actually pick this up (see `deploy-and-verify-fleet`) — content changes don't bump\n`BOT_VERSION`, so there's no version number to confirm against; the register itself\n(via `/audit`'s `Preset layers:` line, or just talking to the character) is the\nverification.\n\n## 2026-08-01 — vps-sync.sh's bot.py swap is now locked (no bot.py change, no version bump)\n\n**Root cause: bot.py's own concurrent-update fix (v2026-07-25.11) covered only one of\nthe two places that perform the swap.** `perform_self_update`'s host-wide `flock` fixed\n`/update` racing itself, but `deploy/vps-sync.sh` performs the identical\nfetch→compile→backup→swap sequence on the identical shared paths\n(`/opt/telegram-bots/bot.py`, `bot.py.bak`) with no guard at all — and the documented\nfleet deploy is exactly two-or-more back-to-back invocations against instances that\nshare a host (every instance does, since the 2026-07-26 migration). ROADMAP 1.6 named\nthis the other half of the class.\n\n**The race, unfixed:** two concurrent runs share the fetch, the compile target, the\nbackup, and the final file. The loud failure is one run's `mv` deleting the other's\n`bot.py.new` mid-copy. The silent one is worse and is the real reason this ranked above\ncosmetic work: if instance B's `cp bot.py bot.py.bak` lands *after* instance A's `mv`,\n`bot.py.bak` becomes a copy of the **new** code, not the old — the rollback path looks\nintact and is not. Nothing would have reported it; the owner would only find out at the\nmoment they actually needed to roll back.\n\n**Fix:** `flock -n` around the whole run, released automatically on exit. `flock` is\nutil-linux and present by default on Ubuntu 24.04 — Termux's absence of it is exactly\nwhy bot.py's own phone-side guard and `watchdog.sh`'s PID-file guard already use\ndifferent mechanisms; this is a fourth, VPS-specific one, not a unification of the\nother three. Only the code swap strictly needs covering (card/preset/`.env`/systemd-unit\nwork is per-instance and never races across instances), but locking the entire script is\nsimpler and equally correct, per the ROADMAP item's own note.\n\n**Also folded in:** the backup `cp` was `2>/dev/null || true` — a failed backup died\nsilently, which is the same silent-rollback-loss failure mode as the race itself, just\nvia a different door. Now unguarded and fatal like every other step. `install-vps.sh`\nseeds `bot.py` at `$BASE` before `vps-sync.sh` can ever run (step 2/8 of the installer),\nso the backup source existing is a precondition already established, not a new\nassumption this fix introduces.\n\n**Verified — break-tested, not inspected, per the ROADMAP item's own \"done when,\" in\ntwo passes.** First, the locking mechanism was extracted and raced in isolation from\nthis session (no VPS access here): a held lock's second concurrent `flock -n` attempt\nexits non-zero with the intended message while the first holds it, and a fresh solo run\nacquires the lock normally after release. `bash -n deploy/vps-sync.sh` clean.\n\nSecond — the real thing, owner-run on the VPS. Round one raced `nora` against `bonnie`\nas the first invocation after merging: both started from the *pre-fix* script (a\nsync's checkout-reset is its own first action, so the very first call after a merge\nnecessarily runs from whatever was already on disk), and instead surfaced a genuine\ngit-level race — `bonnie`'s `git fetch` hit `error: cannot lock ref\n'refs/remotes/origin/main'` against `nora`'s concurrent fetch+reset, and `set -e`\nkilled `bonnie`'s run right there, before it touched anything `bonnie`-specific.\n`nora` completed normally. Useful evidence (unguarded concurrent syncs on a shared\ncheckout really do collide) but not a test of the fix itself, since neither run had it\nloaded yet.\n\nRound two, now that `nora`'s successful run had pulled the checkout — and the script\nthat reads it — to the fix: raced `bonnie` against `cass`, baseline `bot.py` md5\ncaptured first. `cass` hit the flock and exited 1 with the intended retry message\n*before its own `git fetch` ever ran*. `bonnie` completed end-to-end — fetch, compile,\nbackup, swap, restart, full hash + `STARTUP AUDIT` verification. `bot.py.bak`'s md5\nafter the race matched the pre-race baseline exactly: the rollback point held the\ngenuinely-previous code, not a corrupted copy of the new. ROADMAP 1.6's done-when is\nsatisfied and closed.\n\n## v2026-08-01.1 — Selfie prompt's fixed rules are findable (refactor, no behavior change)\n\n**Root cause: the two prompt fragments most likely to need editing were the two hardest\nto find.** `build_selfie_prompt` is a ~70-line conditional builder, and every generic\nselfie fragment — `SELFIE_EXPRESSIONS`, `SELFIE_FRAMINGS`, `SELFIE_OUTFITS`,\n`SELFIE_ACTIVITIES`, `SELFIE_CAMERA` — is hoisted to a module constant with its peers.\nTwo were not: the anatomy rule (\"exactly two arms... no extra limbs\") and the realism/SFW\nrule, appended unconditionally from inline literals mid-function.\n\nThose two are precisely what you reach for when the image model misbehaves — extra limbs\nfrom Flux/Kontext, or Gemini's safety filter returning a blacked-out frame when the SFW\nsignal is weak (the same filter that already forced the explicit-adult-age workaround at\n`SELFIE_APPEARANCE`). Image-prompt tuning is recurring work here, and it was starting\nfrom a code read instead of a grep.\n\nNow `_SELFIE_ANATOMY_RULE` and `_SELFIE_REALISM_RULE`, next to the other `SELFIE_*`\npools. The conditional appends (weather, wardrobe, mood, scene dedup) are untouched —\nconditional assembly is correct and was never the problem.\n\n**No behavior change, proven not assumed:** 640 prompts across 40 seeds × both hint\nmodes × both `chat_id` modes × weather present/absent × wardrobe set/unset, captured\nbefore and after — byte-for-byte identical. Two new tests pin that both rules reach every\nprompt and that the two constraint phrases survive; both were break-tested RED before\nbeing trusted (C3).\n\nPrompted by an external commit (`ShopDevX/adeptlydev` b6d7437) that replaced ~30-call\n`lines.push()` chains with template literals. That codebase's problem does not exist\nhere — static prompt text lives in `preset.txt`, cards, and preset layers — so only the\nnarrow real instance was taken. **Deliberately not turned into an invariant:** the class\nhas zero occurrences in this repo, and `bot-code-invariants` rules are earned by\nincidents, not imported from other people's refactors.\n\n**Built to ride along, not to deploy alone.** No user-visible change, so this is not\nworth a seven-instance deploy on its own; it was parked on\n`claude/github-commit-workflow-integration-ak6ql6` to merge with the next functional\nrelease. If it reaches `main` under a later `BOT_VERSION`, that is expected.\n\n## 2026-07-29 — install-vps.sh could not authenticate to the private repo (no bot.py change)\n\n**Root cause: the private-repo migration was applied to one script and not the other.**\nv2026-07-28.3 switched `install-vps.sh`'s `REPO_URL` to SSH but never wired the deploy\nkey, so its `git clone`/`git pull` fell back to root's default identity. Standing up\nMarcus — the first `install-vps.sh` run since the repo went private — died at step 2/8\nwith `git@github.com: Permission denied (publickey)`. `vps-sync.sh` was unaffected: it\nsets `GIT_SSH_COMMAND` from `/root/.ssh/stpresets_ro` (line 62), which is precisely the\nline `install-vps.sh` was missing.\n\nChanging a URL scheme is not the same as changing an auth model; the URL edit *looked*\nlike the whole fix because the script it was copied from already had the other half.\n\nAlso fixed in the same pass, before it could bite next: both `git` calls now pass\n`-c safe.directory=\"$REPO_CHECKOUT\"`. Step 4/8 chowns the tree to `bot:bot`, so root\nrunning git there trips \"detected dubious ownership\" on every re-run — the auth failure\nwas simply hiding it. `vps-sync.sh` passes the same flag for the same reason.\n\n**Recovery is not circular**, which is worth stating because it looks like it should be:\nthe fixed `install-vps.sh` lives in the repo the broken `install-vps.sh` cannot read. But\n`vps-sync.sh` authenticates fine, and it fetches and hard-resets the checkout — so running\nit for any existing instance pulls the fix onto the box, and `install-vps.sh` is then run\nfrom the updated checkout.\n\nVerified: `bash -n` on both deploy scripts, evals 28/28.\n\n## v2026-07-29.3 — The character's voice comes from the character's model, on every bot\n\n**Root cause: `SUMMARY_MODEL` was two jobs wearing one name.** Eleven call sites read it.\nNine are background work — rolling summaries, `reflect`, `_consolidate_facts`,\n`_promote_to_long_term`, `_memory_audit_scan`, day events, world text. **Two write\nuser-facing prose in the character's voice**: `_selfie_caption` and\n`_generate_meme_captions`.\n\nNothing announced that. An operator pointing `SUMMARY_MODEL` at a small fast model — which\nis exactly what the variable name invites, and what `.env.example` recommended — was\nsilently handing that model the character's dialogue. Jules ran `glm-4.7-flash` and sent a\nselfie captioned with invented instruction blocks, three people who exist nowhere in her\ncard or seeds, and outright word salad. Her `/audit` reported `glm-5.1:thinking` the whole\ntime, because that is the *chat* slot, which is why the first two diagnoses this session\nblamed the wrong model.\n\n**New `CAPTION_MODEL` slot, defaulting to `NANOGPT_MODEL`.** Her voice now comes from her\nown model on every instance — the uniformity the owner asked for — and `SUMMARY_MODEL`\nfinally means what its name says, so pointing it at something cheap is safe again. The\ndefault is the fix; the env var is the per-instance override, no redeploy required\n(invariant #16). Registered in `MODEL_ROLES` as `caption`, so `/setmodel` can reach it\nlike every other slot.\n\n**No new LLM calls** (invariant #3) — the same two calls, on a different slot.\n\nVerified: `py_compile`, **706 pytest** (702 + 4), evals 28/28. Break-tested by pointing the\ncaptions back at `SUMMARY_MODEL`: `test_caption_helpers_no_longer_use_the_summary_slot`\nfailed, the other three held. One test deliberately pins `_summarize` to `SUMMARY_MODEL`\nso a future \"uniformity\" pass cannot drag summarisation onto the chat model too.\n\n**Version note:** requested as \"28.3\", shipped as **2026-07-29.3** — `v2026-07-28.3` is\nyesterday's private-repo deploy release, and reusing it would break\n`version-changelog-sync` and make `/audit` ambiguous about what is live.\n\n## v2026-07-29.2 — The guard was in the one place the selfie caption never goes\n\n**Root cause: v2026-07-29.1 put the guard in `extract_tags`, and a selfie caption never\nreaches it.** Found by the owner asking whether the vision model was responsible — a\nquestion that forced a proper reading of the image path instead of the assumed one.\n\nThere are three model slots near a selfie, and separating them is the answer to the\nquestion: `VISION_MODEL` (`bot.py:295`) *reads* images the user sends, `SELFIE_MODEL` /\n`GEMINI_IMAGE_MODEL` (`605`, `607`) *generate the picture*, and the chat model writes the\nwords. An image model emits pixels and cannot put text in a caption, so the leak was\nalways text-side. But **which** text call was wrong in the v2026-07-29.1 entry:\n\n`_selfie_caption` (`bot.py:5629`) is its own LLM call on `SUMMARY_MODEL or NANOGPT_MODEL`,\nand its result goes **straight to `send_photo(caption=...)`** at `5717` without ever\npassing `extract_tags` — whose only three call sites are `9171`, `9430`, `9932`. The meme\ncaption helper has the same shape. So if the leak came through the caption path, the\nguard shipped an hour earlier would not have caught it.\n\n**`generate_reply` is the real boundary**, and it is a clean one: all twelve\n`reply_with_typing` sites plus both caption helpers reach the model through it, while the\n~10 analysis and extraction paths (`3719`, `3840`, `4175`, `5262`, `5964`, `6005`, `6092`,\n`6179`, `10022`) call `call_nanogpt` directly. Moving the guard there covers every\nuser-facing path and still keeps a line-eating regex away from the analysis JSON — which\nwas the reason for avoiding `_do_request` in the first place. Removed from `extract_tags`\nso there is one home, not two.\n\n**A near-miss worth recording, because the break test is the only reason it isn't in the\ndiff.** Moving the guard earlier raised a real-looking risk: an UPPERCASE `[SELFIE: ...]`\nwould be stripped before `extract_tags` (which matches tag names case-insensitively) could\nparse it, and the selfie would silently never send. An exemption list for the four real\ntags was written to prevent that — then break-tested by neutering it, and **the tests\nstill passed**, because the exemption never fired. A tag is `[selfie: value]` with the\ncolon *inside* the brackets, and the directive pattern requires `]` immediately after the\nlabel, so it cannot match a tag in any case. The leaked directives are `[LABEL]: value`,\ncolon *outside*. That structural difference is what the guard actually keys on. The\nexemption was deleted rather than left as reassuring dead code; the tests stay, pinning\nthe guarantee.\n\n**Correction to v2026-07-29.1's root cause.** That entry attributed the bracket priming to\nthe `[selfie: ...]` convention at `bot.py:4459`. That line is in the *main reply* prompt;\n`_selfie_caption` builds its own message list and does not include it. Its bracket priming\ncomes from `SYSTEM_PROMPT_RAW` — her card's own `[ATTRACTION RULE]` / `[PACE CONTROL]` /\n`[THE FILE]` headers, which is consistent with the two real labels in the leak. Which of\nthe two paths actually fired is **still unresolved**: the message read as a full reply\nwith narration rather than the \"1-2 sentences max, don't describe the photo\" that\n`_selfie_caption` asks for, which points at the reply path — but that is inference, not\nevidence. The guard now covers both, so the fix does not depend on settling it.\n\nVerified: `py_compile`, **702 pytest** (699 − 1 replaced + 4), evals 28/28. Break-tested\ntwice: neutering the guard fails `test_guard_lives_at_the_generate_reply_choke_point`, and\nneutering the exemption failed nothing, which is how the dead code was found.\n\n## v2026-07-29.1 — Jules captioned a selfie with her own planning notes\n\n**Root cause: the reply prompt teaches the model a bracket-tag output format, and\n`extract_tags` only removes the tags it knows by name.** When selfies are available\nbot.py appends `[selfie: a short visual description — your pose, expression,\nsurroundings]` to the prompt (`bot.py:4459`). That is an instruction to emit\n`[TAG: value]` as *output*. `zai-org/glm-5.1:thinking`, holding `preset.txt`'s\n`[STEPPED THINKING]` block telling it to plan privately, rendered that planning in the\nvery syntax it had just been handed:\n\n```\n[ATTRACTION RULE]: Present-tense. Maintain the patronizing \"bud.\"\n[PACE CONTROL]: Mix it with a complaint. Business of the rink.\n[JULES TONE]: Concise, annoyed but using \"Sam\".\n```\n\n`extract_tags` strips `[react:]`, `[selfie:]`, `[meme:]` and `[search:]` **by name**;\neverything else bracketed went to Telegram verbatim, as the caption on a photo.\n\n**Why the evidence pointed here and not at prompt assembly.** A prompt-assembly leak\nwould reproduce her system prompt *verbatim* — `[THE FILE]`, `[KINK MECHANICS]`,\n`[CANTONESE FORMS]`. Instead two labels were real headers from her card and four\n(`[JULES TONE]`, `[NO LANGUAGES]`, `[CANON NOTE]`, `[CONTEXT]`) exist nowhere in the\nrepo. bot.py cannot invent `[NO LANGUAGES]`. The model borrowed the *style* and wrote\nits own content — which also rules out any fix keyed to known header names.\n\n**The guard:** `_strip_directive_lines` drops whole lines that are nothing but an\nALL-CAPS bracketed label, optionally followed by `\": rest of line\"`. Runs inside\n`extract_tags`, **after** the named tags, so it can never re-match one. Lowercase is\nexcluded deliberately: `[selfie: ...]` is handled above and an in-character `[laughs]`\nmust survive. Default ON, kill switch `DIRECTIVE_LEAK_GUARD=0`.\n\n**It logs every strip at WARNING.** Stripping alone would convert a visible model fault\ninto an invisible one — the guard hides the symptom, not the cause.\n\n**Deliberately NOT in `_do_request`** (invariant #4's choke point): that path also\ncarries the post-reply analysis JSON, and a line-eating regex has no business near it.\nThis runs on user-facing reply text only.\n\n**What this does not fix.** The same message contained `\"Liga Handball refer a 4t 7 my\"`\nand three confabulated people (`Sam`, `Chuck`, `Chronicle` — zero hits in her card or\nseeds). That is model degeneration, not a formatting bug, and the guard will not touch\nit. If it recurs, the question is `glm-5.1:thinking` and its sampling, not this code.\nThe trailing unclosed quote is consistent with the leaked plan burning the 4096-token\nbudget and truncating.\n\n**Also found:** her live stack is `preset.txt` (8018t monolith) + `preset-jules.txt`, set\nby a **runtime `/preset` override**, not by `.env` (`preset_override`, `bot.py:2474`) —\nwhich is why the owner had no memory of changing it. Moving her to the recommended\n`core,rp,explicit,stepped,jules` stack is handled separately, so the two changes are not\nconfounded.\n\nVerified: `py_compile`, **699 pytest** (691 + 8 new), evals 27/27. The 8 new tests were\nbreak-tested red by neutering the guard — 3 stripping assertions failed, and the 5\nmust-not-strip assertions correctly stayed green.\n\n## 2026-07-29 — Every doc still told the operator to curl a URL that 404s\n\n**Found by handing the owner a command that could not run.** The deploy instruction in\nCLAUDE.md — `curl -fsSL <raw-base>/deploy/vps-sync.sh | bash -s -- emily` — failed twice\nover: `<raw-base>` was a literal placeholder, and the URL is dead regardless, because\n**v2026-07-28.3 made the repo private the day before**. That release exists precisely\nbecause raw URLs 404 on a private repo, and the docs describing how to deploy were never\nupdated to match it.\n\n**The class:** an operational command living in prose is a historical claim about how the\nsystem worked when someone wrote it down. The release changed the mechanism; seven\ndocuments kept describing the old one, and the one an agent reads first (CLAUDE.md) was\namong them.\n\nRewritten to run from the checkout that is already on the box — `CLAUDE.md`,\n`OPS_MANUAL.md` (deploy + install), `CHEATSHEET.md`, `deploy/MIGRATION.md`, and the\n`deploy-and-verify-fleet` skill, which is what an agent loads when asked to deploy:\n\n```bash\n/opt/telegram-bots/.repo/telegram-companion-bot/deploy/vps-sync.sh <instance>\n```\n\nThe script fetches and hard-resets the checkout to `origin/main` before copying, so\nrunning the on-disk copy is correct even when the checkout is stale — which it was: the\ndiff that surfaced this showed `.repo` still holding pre-rename content while the live\ninstance had the new.\n\n**Phone-era paths annotated, not rewritten** (`update-all.sh`, `backup-all.sh`, the\nMIGRATION cutover step, all of `SETUP_GUIDE.md`). They target `~/telegram-bot` on a phone\nthat has been empty since 2026-07-26 and were already recorded as managing nothing; the\nfix there is a `DEAD` marker so nobody copies them, not a rewrite of dead tooling.\n\n**New eval `no-live-raw-urls`** (26 → 27) fails on any `curl`/`wget`/`BASE=` line carrying\na `raw.githubusercontent` URL unless annotated dead within 6 lines, or its file opens with\n`<!-- evals: raw-urls-historical -->`.\n\n**The first draft of that eval was blind, and the break test is why we know.** It allowed\na file-level opt-out on any marker word in the first 25 lines — and CHEATSHEET.md's header\n*explains* that raw URLs 404, so the whole file was exempt and a re-injected defect passed\nin the file the check most needed to guard. An opt-out matched loosely is an opt-out for\neverything. Now a literal pragma, break-tested red in a file other than the one it was\ndeveloped against.\n\n## 2026-07-28 — A seed file can be in the repo and absent from the fleet, forever, silently\n\n**Found by a failing rename, not by a check.** Renaming Jules's dealership manager on the\nVPS returned `sed: can't read /opt/telegram-bots/jules/atlas.txt: No such file or\ndirectory`. The repo has had `jules/atlas.txt` since the seed folders were added; her live\ninstance never got it.\n\n**Root cause: nothing syncs seed files, by design, and nothing reports on them either.**\n`install-vps.sh` seeds a character's `people/projects/schedule/atlas.txt` **once**, at\nfirst instance creation, and `vps-sync.sh` deliberately does not touch them — they are\nliving, hand-edited content. Both halves are correct. The gap is what falls between: a\nseed file added to the repo *after* an instance exists has no path to that instance, and\njules's dir came off the phone in the 2026-07-26 migration rather than through\n`install-vps.sh` seeding at all.\n\n**Why it stayed invisible.** `bot.py:649` reads the atlas once at import and falls back to\n`[]` when the file is absent — no warning, no error, and the fallback is correct behaviour\nfor a character who has no atlas. `/audit` does not mention seed files. So the only symptom\nis Jules quietly never referencing Bellingham, which reads as a model quirk rather than a\nmissing file. Same shape as the GROUP_MODE incident earlier today: **a correct silent\nfallback and a broken deploy are observationally identical** (C8).\n\n**`vps-sync.sh` now reports the gap** in its verification block: seed files present in the\nrepo for that instance but missing on it, each with the `cp` that would fix it.\n\n**It reports, it does not copy.** An operator may have deleted a seed file deliberately,\nand an absent file is indistinguishable from an intended absence (C10) — auto-seeding\nwould silently resurrect it on every deploy. This follows v2026-07-28.2's precedent:\nsurface the incoherence, name the fix, leave the decision with the operator.\n\nThree-branch break test (`scratchpad/seedreport-test.sh`, extracting the block verbatim\nfrom the script rather than a re-typed copy): missing file warns and names it; all-present\nprints an explicit all-clear; no seed folder in the repo says so instead of claiming\ncompleteness. **All three print something** — per C3, \"nothing printed\" must never be the\nonly signal a check can produce. The harness truncated at the first top-level `fi` on the\nfirst run and reported three false failures; the check was fine.\n\n**Not fixed here:** `/audit` still says nothing about seed files, so this is only visible\nat deploy time. Adding it is a bot.py change (version bump + changelog + delivery gate) and\nis left as a proposal rather than smuggled into a deploy-script fix.\n\n## 2026-07-28 — Three Marcuses, one of them keyed into Emily's lorebook (content only)\n\n**Found while drafting Marcus's seed files, and it blocks the planned Emily+Marcus\ngroup.** Adding a character named Marcus collided with two that already existed:\n\n| where | who | why it matters |\n|---|---|---|\n| `emily_harper.json` lorebook, **key `\"Marcus\"`** | her work supervisor, forties | keyed on the literal name — in a group chat with him, every message naming Marcus injects \"Marcus is her supervisor\" into Emily's prompt |\n| `emily/people.txt`, `emily/schedule.txt` | remote colleague, primary collaborator | same person, described differently |\n| `jules/people.txt`, `jules/atlas.txt` | sales manager at the dealership | no group planned, but the same trap |\n\nThe lorebook one is the sharp edge: a lorebook key is a trigger word, so the collision\nis not cosmetic — it would have fired on his own name and taught Emily that her groupmate\nis her boss. This is the group-chat sibling of C11 (a mechanism leaking into the fiction),\nexcept the mechanism here is the retrieval layer rather than a diagnostic.\n\n**Owner decision (2026-07-28): rename the existing ones**, since the new character's name\nruns through owner-supplied card prose while Emily's Marcus is deliberately faceless\n(\"mostly a Slack handle\", never met in person). Emily's is now **Warren**, Jules's is\n**Dale**. Both names were checked against every card, seed and preset before use.\n\n**Fixed a pre-existing contradiction in the same edit.** Emily's card called Marcus *\"her\nsupervisor\"*; her `people.txt` calls him a remote colleague and names Dr. Yuen as the\nsupervisor, and `schedule.txt` lists them as two separate people on the same call. The\nlorebook entry now says senior colleague and names Dr. Yuen as the supervisor explicitly,\nmatching the seeds. The `\"supervisor\"`/`\"boss\"` keys were dropped from that entry — it is\nno longer the entry about her boss, and `people.txt` carries Dr. Yuen on every prompt\nregardless.\n\n**Marcus's seed dir** (`marcus/{people,projects,schedule,atlas}.txt`) drafted from what\nthe card already establishes. Placed in **Portland** — the card names no city, and his only\nplanned groupmate is Emily, whose atlas is Portland-area; a shared group needs a shared\nmetro. Real geography, per the rule Priya's and Emily's atlases follow. **No family\nentries**: the card is silent there, and inventing parents is heavier invention than a gym\ncolleague, so it is left as a gap rather than a guess.\n\n## 2026-07-28 — Marcus's preset layer + two things his arrival exposed (no bot.py change, no version bump)\n\n**Inert until an `.env` names it.** `preset-marcus.txt` is the seventh per-character\nlayer. No instance loads it — there is no marcus instance yet. Intended stack, same as\nthe other scene characters:\n`PRESET_FILES=preset-core.txt,preset-rp.txt,preset-explicit.txt,preset-stepped.txt,preset-marcus.txt`\n\n**The arbitration is not the one that was expected.** The handoff predicted his card\nwould fight `preset-core.txt`'s \"one to three short paragraphs\" the way Bonnie's does.\nIt doesn't — Bonnie's card states a numeric contract (3-6 paragraphs) and his states no\nlength at all, and his own samples are short alternating beats. Writing that layer would\nhave solved a conflict he does not have.\n\nThe real conflict is with **`preset-explicit.txt` § Standing consent**, added\n2026-07-26: *\"Consent … does not need to be re-established, asked after, or checked in on\nbefore a scene proceeds, and no reply should open by hedging, warning, or seeking\npermission.\"* Marcus's card is built on the opposite behaviour — he opens by asking what\nsomeone actually wants, checks in mid-scene, and declines what crosses his code. Read\nflatly, the shared layer deletes the character.\n\nBoth rules are correct; they address different things, and the layer says so. The\nstanding-consent rule governs the **narrator's** relationship with `{{user}}` — it stops\nthe fiction breaking to ask permission it already has. Marcus's asking happens **inside**\nthe fiction, between characters, and is characterization. That distinction is the layer's\nload-bearing sentence. The `Dead Dove` preamble in the same file (\"no sanitization\",\n\"avoid ethical protocols\") pushes the same way and is answered by the same distinction:\nhis limits are traits, not content policy.\n\nLayer is 434 raw tokens against the 250-290 band the other six sit in. Deliberate — a\nsemantic contradiction needs the narrator/fiction distinction spelled out, where Bonnie's\nnumeric one is settled in a sentence. Costs ~2% on his stack, paid only by him.\n\n**`preset.txt:531` and `preset-explicit.txt:144` named the example speaker \"Marcus\".**\nCoincidence in a file written before the card existed, and inert for six bots — but on a\nmarcus instance that line stops being an example and becomes a voice sample attributed to\nthe character himself, in a register aimed at a partner he does not have. Every other\nexample in that block uses unnamed pronouns, so the name is now `he`. Removes the class,\nnot just the collision: any proper noun in a shared example can collide with a future\ncharacter.\n\n**`deploy/install-vps.sh` excluded seed folders by a hardcoded name list**\n(`nora|bonnie|cass|emily|priya|jules`), which silently misses every character added after\nit was written — and the miss is destructive, not cosmetic. A seed folder is named\nexactly like its instance directory, so an unlisted character's `marcus/` would be copied\nstraight onto the live `/opt/telegram-bots/marcus/` by step 2, reverting hand-edited\n`people.txt`/`atlas.txt` to the repo seed **on every re-run** — exactly what the\nsurrounding comment promises will never happen. Now matched by shape (a directory holding\n`people.txt`), which cannot fall behind the roster. Latent today: no `marcus/` seed folder\nexists yet, so this lands before the trap can spring rather than after.\n\nVerified: `run-evals.sh` 26 passed / 0 failed / 1 skipped (`bot-imports`, PIL missing\nlocally — runs in CI), `bash -n deploy/install-vps.sh`, and a fixture break-test of both\nloop bodies confirming the old one clobbers a live `marcus/people.txt` and the new one\ndoes not. pytest is not installed in this container; no Python changed in this diff.\n\n## v2026-07-28.3 — Deploys move to a git checkout so the repo can go private\n\n**Root cause: the entire deploy model assumed anonymous read.** Nine call sites fetch\nfrom `raw.githubusercontent.com`, including the two that matter — `deploy/vps-sync.sh`\nand bot.py's `perform_self_update`. Raw URLs 404 for a private repo and have no way to\nauthenticate, so flipping visibility would have broken every deploy path on all six live\ninstances simultaneously. Worse, the recovery is circular: the fix that teaches the fleet\nto authenticate would itself have to arrive over the channel that just broke, leaving\nhand-copying files to the VPS as the only way out.\n\nOwner decision (2026-07-28): make the repo private, because character cards were being\npublished via raw URLs under a personal GitHub account as a side effect of how deploys\nwork.\n\n**`deploy/vps-sync.sh` now syncs from a git checkout** at `/opt/telegram-bots/.repo`,\ncloned once with a **read-only deploy key**. This removes the circularity entirely — a\ncheckout behaves identically whether the repo is public or private, so it ships and gets\nverified *before* the flip, and the flip then changes nothing. Secondary win: nine fetch\ncall sites collapse to one working tree that is fetched and hard-reset to `origin/main`\nin a single step, so a partial deploy can no longer leave an instance with a new `bot.py`\nand a stale card. The script prints the resolved HEAD and compares repo-vs-instance\nhashes, so a stale checkout cannot silently deploy old content. A file named in `.env`\nbut absent from the repo stays fatal, same as the old 404-is-fatal rule.\n\n**`/update` cannot be saved and now says so.** Raw URLs can't authenticate, full stop.\nPreviously a private repo would have produced `⚠️ Download failed: 404 Client Error` —\nnaming neither the cause nor the fix, and looking exactly like a network fault, which is\nthe \"opaque error\" the debugging playbook says to instrument before it costs a session.\n`_perform_self_update_locked` now inspects `e.response.status_code` and returns a\ndistinct `repo_not_readable` reason for 401/403/404; `update_cmd` gains a matching\nbranch that replies with the `vps-sync.sh` command and confirms nothing changed. Note the\ncatch-all added in v2026-07-25.11 already prevented silence here — this makes the message\n*useful*, not merely present.\n\n**New card: `marcus_calder.json`** (Marcus Calder, `chara_card_v2`). 2,988 always-on\ntokens — between Emily (2,308) and Jules (5,290), so no prompt-budget concern. Its\n`character_book` (6 keyword-triggered entries) and `extensions.depth_prompt` are both\nsupported by `load_character` (bot.py:2352, 2357); every entry is `constant: false` /\n`selective: false`, so nothing is dropped by the parser's narrower feature set. Card\nadded but **no instance exists yet** — `vps-sync.sh` gains a `marcus` case so one can be\nstood up, and it stays inert until an instance dir and unit are created.\n\n**Not touched, deliberately:** `update-all.sh`, `sync-cards.sh`, `watchdog.sh`,\n`new-bot.sh`, `backup-all.sh` and `cleanup-all.sh` still carry raw URLs and are now dead\nagainst a private repo. They are phone-era and already managed nothing after the\n2026-07-26 migration; half-fixing tooling that runs nowhere would add risk for no\nbehavior change. Recorded here so their breakage is a known state, not a discovery.\n\nSetup, verification, and the mandatory order of operations: `deploy/MIGRATION.md`\n§ \"Private-repo deploys\".\n\n## v2026-07-28.2 — A group instance configured to do nothing looked exactly like a broken one\n\n**Root cause: correct fail-closed silence is indistinguishable from failure.** Priya was\nin the pilot group, running, receiving traffic, with `GROUP_ALLOWED_CHATS` and\n`GROUP_PEERS` both set correctly — and `GROUP_MODE` never added. `group_guard`\n(bot.py:9277) requires `GROUP_MODE and chat.id in GROUP_ALLOWED_CHATS` for plain text;\nwithout it the message is dropped at handler group -1 with **no reply, no log line, no\nerror**. That silence is right — a non-participating instance must not answer in a group\nit was merely added to (§6) — but it means \"not configured for this group\" and \"broken\"\nproduce byte-identical observable behavior.\n\nDiagnosing it took six rounds of live debugging on 2026-07-28. What made it slow is\nworth recording, because every signal pointed *away* from config: `/chatid` answered\nnormally (allowlisted commands return at bot.py:9268, **before** the `GROUP_MODE`\ncheck), so the bot was demonstrably present and receiving; `@priya_bot` was ignored too,\nwhich looks like a delivery problem but isn't — the @-mention changes Telegram delivery,\nnot the guard; and `/errors` was clean, because nothing errored. Jules, correctly\nconfigured, worked throughout.\n\n**Fix — `_group_config_warnings(mode, chats, peers)`**, appended to `_CONFIG_WARNINGS`\nat import, so `/audit` states the incoherence:\n\n| state | warning |\n|---|---|\n| allowlist and/or peers set, `GROUP_MODE` off | *\"… set but GROUP_MODE is off — this instance ignores ALL group traffic (it answers only `/chatid` there). Set GROUP_MODE=1 and restart to participate.\"* |\n| `GROUP_MODE` on, allowlist empty | *\"… the allowlist fails closed, so every group message is still ignored. Add the group's chat id (`/chatid` in the group) and restart.\"* |\n\n**Both halves of the class, not just the one that bit** (C2): the inverse — `GROUP_MODE=1`\nwith an empty allowlist — is the same defect with the same silent symptom (§6 fail\nclosed), and would have been the next incident.\n\n**Deliberately quiet on the three coherent states:** participating (mode on + allowlist\nset), fully unconfigured (the fleet's four non-group bots), and participating without\npeers (one bot + human is a valid group, not an error). A warning firing on every\n`/audit` fleet-wide would be noise, and noise is how the next real warning gets ignored.\n\n**No behavior change.** Nothing about participation, the guard, the ledger, claims, or\nthe caps is touched — this release only makes an existing state legible.\n`group-deliver-clean` and `group-cmd-allowlist` both green and untouched.\n\n**7 tests** over all four states of a pure helper, including a `test_priyas_actual_broken_config`\npinning the exact configuration from the incident.\n\n## v2026-07-28.1 — Diagnostics couldn't run while the bot was up, and said so dangerously\n\n**Root cause: `_acquire_pid_lock()` is a module-level call, so it beats its own\nexemptions.** `--check-config` and `--claim-test` are dispatched inside `main()`\n(bot.py:12875), but the PID lock runs at *import* (bot.py:2710) — long before. Both\ndiagnostics therefore aborted whenever the instance's bot was running, which is exactly\nwhen an operator reaches for them. Found trying to run the group-pilot atomicity smoke\ntest (`GROUP_CHAT_DESIGN.md` §10.5) against a live priya: it refused, and neither\ndiagnostic touches Telegram or needs the lock at all — `_run_claim_test` reads only\n`GROUP_LEDGER_DIR`, and the instance dir merely decides where `bot.pid` lands.\n\n**The second half is the part that could have cost data.** The refusal printed:\n\n```\nKill it first: kill 178039\nOr force-remove the lock: rm /opt/telegram-bots/priya/bot.pid\n```\n\nBoth are wrong on a systemd host, and the fleet has been 100% systemd since 2026-07-26.\n`Restart=always` undoes the kill within seconds, so it is disruptive *and* futile.\nRemoving the lock is worse: it admits a second process polling the same token — the\n`telegram.error.Conflict` class that cost hours during the jules and priya cutovers\n(operational log 2026-07-19, 2026-07-25). Generic single-instance advice, written before\nsystemd, aged into a recommendation to reproduce a known incident.\n\n**Fix.** A `DIAGNOSTIC_MODE` constant declared beside `BASE_DIR` — before both the lock\nand `load_state`, since module-level code consults it:\n\n- `_acquire_pid_lock()` returns early in diagnostic mode. The lock exists to stop a\n duplicate *poller* fighting for the token, not to serialize filesystem access, so a\n non-polling mode skipping it removes no protection.\n- The duplicate-instance message now says `systemctl stop bot@<instance>`, names the\n Conflict risk, points out diagnostics need no stop at all, and notes that a stale lock\n is already cleared automatically (the `ProcessLookupError` branch), so removing the\n file by hand should never be necessary.\n- **`load_state` no longer renames a corrupt state file in diagnostic mode.** This is the\n one destructive path reachable at import: a parse failure moves `state.json` to\n `.corrupted`. Harmless when the lock guaranteed exclusivity — but a diagnostic now runs\n *beside* the live bot, and must not move that bot's state file out from under it. It\n logs and continues on empty state; no diagnostic reads state.\n\n**Fixed as a class, not an instance** (C2): the defect was described as being about\n`--claim-test`, but `--check-config` sat behind the identical import-time lock. Both are\ncovered, and the flag list is pinned by a test so a third diagnostic flag added without\nlisting it fails loudly instead of silently re-colliding.\n\n**Not changed, deliberately:** the three lock mechanisms stay distinct — bot.py's\nPID-file guard, `perform_self_update`'s host-wide `flock`, and `watchdog.sh`'s PID file\nwere chosen differently per platform, and ROADMAP 1.6 explicitly says not to unify them.\nThis release changes *when* the PID guard is taken, never *what* it is.\n\n**8 new tests**, two classes: `TestDiagnosticModesSkipPidLock` (flag coverage, the\ndeclaration-order requirement that is the actual fix, early return, and the `load_state`\nguard preceding the rename) and `TestDuplicateInstanceAdviceIsSafe`, which fails if\n`Kill it first` or `force-remove the lock` ever returns — the same shape as\n`TestRestartStormAdviceIsCorrect`, for the same reason.\n\n## v2026-07-27.1 — Memory-loop defaults aligned to the default-on policy (fleet no-op)\n\n> **This entry was rewritten on 2026-07-28. Its original root cause was false.** It\n> claimed all six instances had been running with the memory-hygiene loops inert for two\n> weeks. They had not: every instance's `.env` already set `MEMORY_AUDIT=1`,\n> `MEMORY_HEDGE=1`, `MEMORY_DECAY_HALFLIFE_DAYS=90` explicitly. The claim was an\n> inference from bot.py's defaults plus a commented-out `.env.example`, never a reading\n> of the live files — and it was shipped as fact. The original text is preserved only in\n> git history; what follows is what the release actually is. See\n> `.claude/memory/constraints.md` C9.\n\n**What this release actually does.** v2026-07-12.3 shipped three memory-hygiene features\n— weekly audit, recency decay, confidence hedging — default-OFF, correct under the\nconvention then in force. v2026-07-18.1 reversed that convention (new features default ON\nwith a mandatory kill switch) and nothing swept backwards over features already shipped\nunder the old rule. This aligns those three defaults with the standing policy.\n\n**Live impact on the existing fleet: none.** All six `.env` files set all three\nexplicitly, and an `.env` value always wins over a bot.py default, so every running\ncharacter behaves exactly as it did before this release. Verified by the owner on the VPS\n2026-07-28, one line per instance per variable. `deploy/vps-sync.sh` cannot have written\nthose lines — it touches exactly one `.env` key, `CHARACTER_CARD` (vps-sync.sh:55-58).\n\n**Where it does change something:** a *new* instance. `new-bot.sh` / `SETUP_GUIDE.md`\nproduce an `.env` from `.env.example`, where all three ship commented out. Before this\nrelease a new bot silently came up with its memory-maintenance loop disabled and no\nsignal that it had; now it inherits the fleet's actual configuration by default. That is\nthe whole of the benefit, and it is worth having — but it is a provisioning fix, not the\nlive-defect fix the original entry claimed.\n\n**The flip.** Three one-line default changes; no logic touched:\n\n| var | was | now | kill switch |\n|---|---|---|---|\n| `MEMORY_DECAY_HALFLIFE_DAYS` | `0` (off) | `90` — the value the v2026-07-12.3 entry itself recommended | `=0` |\n| `MEMORY_HEDGE` | `0` | `1` | `=0` |\n| `MEMORY_AUDIT` | `0` | `1` | `=0` |\n\n**Why `MEMORY_AUDIT` defaults on despite invariant #16's higher-cost carve-out.** It adds\none `SUMMARY_MODEL` call per instance per *week* (not per message — the per-message budget\nis untouched) and can put up to `MEMORY_AUDIT_MAX_PROPOSALS` items a week per instance\ninto the owner's `/reviewmem` queue. Enabled at the owner's explicit instruction\n(2026-07-27). Note this imposes **no new cost on the existing fleet** — all six already\nran with it on, so that queue was already live; the corrected entry above supersedes the\noriginal's claim of \"18 new items a week.\" The cost applies only to newly provisioned\ninstances. If the queue becomes noise, `MEMORY_AUDIT_MAX_PROPOSALS=1` throttles it before\nthe kill switch does. The audit never mutates anything on its own — every delete/merge\nneeds `/reviewmem ok` and routes through the `_memory_replace` choke point.\n\n**What does not change.** No new per-message LLM call (invariant #3). Recency decay is\nfloored at 0.1 so old memories fade in the ranking and never leave it, and entries with no\nrecorded `ts` (pre-2026-07 legacy lines) stay at a neutral 1.0 — an instance whose\n`memory_meta.json` is sparse sees almost no ranking change. Hedging is display-time only;\nthe stored line is untouched.\n\n**Testing note (why no new tests).** The three pure helpers already carry 34 tests from\nv2026-07-12.3, and all of them pass the parameter explicitly rather than reading the\nmodule global — `_recency_weight(ts, now, halflife)`, `_hedge_memory_lines(lines, meta,\nautoconf, enabled)`. The `triggered_memories` ranking tests set no `ts` in `_memory_meta`,\nso the new 90-day default resolves to the neutral 1.0 path and leaves them byte-identical.\nThat decoupling is why a default flip needs no test changes — and is worth preserving.\n\n**Also in this release (`.claude/` only, no bot impact):** the operational log gains an\n`evidence_kind` tag and `verify-external-audit` gains a verdict/disposition split, both\nlifted from the reviewed protocol. See `REVIEW-SESSIONMEMORY-2026-07-27.md`.\n\n## 2026-07-26 — Content: per-character preset layers (no bot.py change, no version bump)\n\n**Inert until an `.env` names them.** Six new files, no instance loads any of them yet.\nDeploy is a `PRESET_FILES` edit per bot — or `/preset add <name>` to try one live and\n`/preset reset` to undo, which is what v2026-07-26.1 was built for.\n\n**The finding that shaped these.** The obvious reading of \"presets that fit each\ncharacter\" is that each bot needs more of its own content. It doesn't — the cards already\ncarry personality, and carry it well. What the cards *also* carry is a **format contract**,\nand those contracts contradict the one shared `[TEXT DELIVERY]` rule in `preset-core.txt`:\n\n| card says | `preset-core.txt` says |\n|---|---|\n| Bonnie: \"3-6 paragraphs, matched to scene energy\" | \"one to three short paragraphs… prefer shorter responses\" |\n| Priya: \"sometimes a reply is two words\", \"no asterisk actions, no stage direction\" | (same paragraph default) |\n| Emily: \"third person with italicized action beats\" | written throughout for a first-person text thread |\n| Cass: \"she texts, doesn't narrate\" | `preset-rp.txt`: \"describe physical action in simple direct sentences\" |\n\nBonnie's is a flat numeric contradiction — 3-6 versus 1-3 with a \"prefer shorter\" — fighting\non every message she sends. So the per-character layer's job is **arbitration, not\npersonality**: say which instruction wins where the shared preset and the card disagree,\nplus the one or two things about that character that are easy to smooth away.\n\n**Six layers, ~250-290 raw tokens each**, deliberately small: `preset-{nora,bonnie,cass,\nemily,priya,jules}.txt`. Each is a format contract plus a \"what not to smooth\" section —\nBonnie's tender beat must not snap back to goblin mode inside the same message; Jules's\nwarmth must never arrive as a soft line; Priya's warmth is closer attention, never an\naffirmation; Nora's grief shows as behaviour, never as narration; Cass names the fix in the\nsame message as the problem; Emily's interior runs on biology.\n\n**Recommended stacks** (raw / at the 0.92 calibration measured on cass):\n\n| bot | `PRESET_FILES` | raw | cal | vs today |\n|---|---|---|---|---|\n| cass | core, stepped, cass | 4827 | 4441 | **−3676** |\n| priya | core, stepped, priya | 4830 | 4444 | **−3673** |\n| nora | core, rp, explicit, stepped, nora | 8505 | 7825 | +2 |\n| bonnie | core, rp, explicit, stepped, bonnie | 8521 | 7839 | +18 |\n| emily | core, rp, explicit, stepped, emily | 8501 | 7821 | −2 |\n| jules | core, rp, explicit, stepped, jules | 8538 | 7855 | +35 |\n\ncass and priya shed ~43%; the four scene characters pay within ±35 tokens for a stack that\nactually fits them. This is a fit exercise, not a token-cutting one — the saving lands\nexactly where a character cannot use the scene machinery, which is the same shape as the\nv2026-07-25.6 result.\n\n**`preset-explicit.txt` gains a standing-consent block** (~55 tok) — the one genuinely new\nrule in the `EveningTruthGLM5.2` preset the owner supplied. The rest of that file was\nchecked line by line against the layers and is already covered, in places near-verbatim:\n\"{{user}} is imperfect… factually wrong\" is `preset-core.txt:245-246`, OOC handling is\n`:254-255`, non-omniscience is `[EPISTEMIC HORIZON]`, \"no melodramatic or cliché phrases\"\nis `[ANTI-SLOP]`, \"goals independent of {{user}}\" is `[CHARACTER AGENCY]`. Adopting it\nwhole would have cost ~650 tokens a message in duplication and imported a\n\"never-ending roleplay\" frame into what `preset-core.txt` calls a\n`[VOICEPRINT PRESET — TELEGRAM SINGLE SLOT]`.\n\n**Not done, deliberately:** no `.env` was changed, so nothing is live. Verified with\n`run-evals.sh` (26/26) and the full pytest suite (670) — content-only, so no `BOT_VERSION`\nbump and no delivery-gate entry.\n\n## v2026-07-26.8 — Install hints told the operator to run commands that cannot run\n\n**The class, in one sentence:** *an install hint that hardcodes a package manager sends\nthe operator to a command that does not exist on the host the bot is actually running\non.*\n\n**Root cause:** v2026-07-26.6 fixed the garminconnect `pip install` hint by\ninterpolating `sys.executable` — but that fixed the **instance, not the class**. Three\nmore hardcoded hints survived, one of them in a message sent to Telegram:\n\n| site | said | why it fails on the fleet |\n|---|---|---|\n| `bot.py` PDF fallback (user-facing) | `pkg install mupdf-tools` | `pkg` is Termux-only; it does not exist on Ubuntu |\n| `--check-config` timezone preflight | `pkg install tzdata` | same |\n| JobQueue-missing warning | `pip install \"python-telegram-bot[job-queue]\"` | PEP 668 refuses system-wide pip on Ubuntu 24.04 |\n\nThe third was found by the new scanner, not by hand — a targeted grep for\nTermux/Android terms missed it because the string names neither.\n\n**Fix — one derived helper per package manager, not four point edits:**\n- `_pkg_hint(pkg)` → `pkg install …` under Termux, `sudo apt install …` otherwise.\n- `_pip_hint(pkg)` → `{sys.executable} -m pip install …`, which is the venv interpreter\n by construction, so it needs no hardcoded venv path and sidesteps PEP 668.\n- All four sites now route through them, including the garminconnect hints from .6.\n\n**New sweep scanner `install-hint`** (`.claude/tools/sweep.py`) so the class cannot\nreturn silently: it flags any string literal containing a hardcoded `pkg`/`apt`/`pip\ninstall`, skipping comments, correct callers, and lines marked `# sweep-ok`. Reviewed\nexceptions carry their reason on the line, per `fix-the-class`. Break-tested: clean\ntree reports 0, re-injecting the exact `pkg install tzdata` defect reports 1.\n\n**Benign hit recorded, not \"fixed\":** `_acquire_termux_wake_lock()` still calls\n`termux-wake-lock`, guarded by `shutil.which(...)`. On the VPS the binary is absent, so\nthe function is silently inert — correct as written, and the guard is why.\n\n## v2026-07-26.7 — The restart-storm alarm fired on every deploy\n\n**Root cause (owner-reported, 2026-07-26):** `/audit` warned \"restarted 4x in the last\nhour — something is killing the process\" after a night of ordinary maintenance. The\nstorm detector excludes starts preceded by a `[restart] requested` or `; restarting`\nmarker, which covers `/restart` and `/update` — but **not** `systemctl restart` or a\n`vps-sync.sh` deploy. Those stop the bot with SIGTERM, which `post_shutdown` handles\nand logs as a graceful stop, and emit no marker. Since the fleet went 100% systemd on\n2026-07-26 and a fleet deploy is six back-to-back restarts, every deploy tripped the\nalarm.\n\nA test actively encoded the old behaviour — `test_graceful_stop_alone_still_counts`,\njustified by \"e.g. a battery-manager SIGTERM\". That rationale was phone-era: on the\nVPS there is no OEM battery manager, and a graceful SIGTERM means someone (or a\ndeploy) asked the bot to stop.\n\n**Fix:**\n- A `[shutdown] graceful stop` line now marks the following start as deliberate.\n **The direction of the inference matters:** the *presence* of that line proves the\n process was asked to stop; its *absence* still proves nothing (`/update` and\n `/restart` exit via `os._exit(0)` without logging one). Anything that kills the\n process outright — SIGKILL, OOM, an unhandled crash — leaves no graceful-stop line\n and is still counted, which is the case the alarm exists for.\n- The alert text was also phone-era and useless on the VPS: it sent the operator to\n `bot.log`'s `[run-bot] ... exited (code N)` line (that file is 0 bytes under\n systemd) and blamed the Android phantom-process killer and OEM battery managers.\n Rewritten for systemd — `systemctl status bot@<instance>`, a `journalctl` grep for\n `Main process exited|Killed|out of memory`, how to read `code=killed/status=9` vs a\n nonzero exit, and an OOM confirmation via `journalctl -k`. The instance name is\n interpolated so the commands are copy-pasteable.\n- Tests: the old assertion is inverted with the reason recorded inline, plus\n `test_sigkill_still_counts` (the guard that matters) and\n `test_deploy_loop_does_not_alarm` (six back-to-back deploys → 0).\n\n**Trade-off, stated deliberately:** a repeated *external* SIGTERM on the VPS would no\nlonger alarm. That was a real phone failure mode (battery managers) and is not a\nplausible VPS one; systemd's own logs cover it. Revisit if an instance ever runs\nsomewhere with an aggressive process manager again.\n\n## v2026-07-26.6 — The library-missing warning named a command that cannot work\n\n**Root cause (hit live, 2026-07-26):** both \"garminconnect is missing\" messages —\nthe `/audit` config warning and the health-feed status string — told the operator to\nrun `pip install garminconnect`. On Ubuntu 24.04 that fails outright with\n`error: externally-managed-environment` (PEP 668), and even where it succeeds it\ninstalls system-wide, which is not where the bots look: every instance runs from the\nshared venv at `/opt/telegram-bots/venv/`. Since the 2026-07-26 migration the fleet is\n100% VPS, so the suggested command was wrong for **every** instance. `requirements.txt`\nhas carried the correct venv-pip invocation in its comments all along; the runtime\nmessages simply disagreed with it.\n\n**Fix:** both messages now interpolate `sys.executable`, which *is* the venv's\ninterpreter by construction — so they render as\n`/opt/telegram-bots/venv/bin/python -m pip install garminconnect` and stay correct on\nany host without hardcoding a path (invariant #2: no hardcoded instance/host paths in\nshared logic).\n\n**Scope:** message text only. No behavior change, no new env var, no kill switch —\n\"off\" would mean restoring a command that cannot succeed.\n\n## v2026-07-26.5 — The dead man's switch reported OK while being rejected\n\n**Root cause (found live, 2026-07-26):** `requests` does not raise on 4xx/5xx — it\nraises only on connection errors and timeouts. The healthcheck ping was\n\n```python\nawait asyncio.to_thread(lambda: _get_session().get(HEALTHCHECK_URL, timeout=10))\n```\n\nwith no status check, so a **rejected** ping completed the `try` block normally and\nlogged nothing. Every `_self_audit` line read `[audit] OK`.\n\nDiscovered while verifying monitoring coverage after the VPS migration: five of six\ninstances had a **doubled** URL —\n`HEALTHCHECK_URL=https://hc-ping.com/https://hc-ping.com/<uuid>` — which hc-ping\nanswers with HTTP 400. Byte-identical `.env` files came off the phone in the migration\ntar, so those five had been broken *on the phone too*: the fleet ran for weeks with one\nworking dead man's switch out of six, and nothing in any log said so. Only nora's URL\nwas well-formed.\n\nThis is the same class the streaming path already pins as invariant #5 (force-read the\nbody, then `raise_for_status()`), applied to a path nobody had revisited since it was\nwritten.\n\n**Fix:**\n- `_self_audit` now inspects `resp.status_code`; anything ≥400 logs a loud warning\n naming the code and stating that the switch is NOT working, and counts a\n `healthcheck_rejected` error so it surfaces in `/audit`'s error breakdown and in the\n admin API — a silent monitor now shows up in the place operators actually look.\n- Connection failures keep their existing warning; the two failure modes are now\n distinguishable in the log.\n- No kill switch: this adds observability to an existing call. \"Off\" would mean\n restoring the silence that hid the bug.\n- `deploy/MIGRATION.md` step 8 no longer shows a literal `<your-jules-uuid>`\n placeholder inside a copy-pasteable block, and gains a verification step\n (`curl -fsS` the URL from the instance's own `.env`, plus a distinct-UUID count) —\n a placeholder in a paste-block is a plausible origin for the doubling.\n\n**Verification:** `py_compile`, pytest, `run-evals.sh` (new `healthcheck-status-checked`\neval, break-tested red-green). Live: all six instances now return HTTP 200 from the URL\nin their own `.env`, six distinct UUIDs.\n\n## v2026-07-26.4 — The fleet's voiceprint was addressing `{{char}}`, not the character\n\n**⚠️ CHANGES THE ASSEMBLED PROMPT FOR ALL SIX BOTS.** Not a behaviour flag — the preset\ntext every instance sends is different after this deploy. Read before shipping.\n\n**Root cause:** `fill()` substitutes `{{char}}`/`{{user}}` into every prose block the bot\nassembles — the merged card (`bot.py:4330`), the setting (`:4335`), the lorebook\n(`:4589`), post-history instructions (`:4618`), the greeting (`:6093`, `:7381`) — with\nexactly one exception: the preset layers, appended raw at `:4623`. Found while looking at\nthe layer files for the per-character preset work, not by any test or error.\n\nSo the placeholders reached the model **verbatim**, in the block that defines the voice:\n\n| layer | `{{…}}` occurrences | who loads it |\n|---|---|---|\n| `preset-core.txt` | **66** | cass |\n| `preset.txt` | **88** | the other five |\n| `preset-rp.txt` | 14 | — |\n| `preset-closeness.txt`, `preset-stepped.txt` | 3 each | — |\n\nLine 3 of `preset-core.txt` arrived as *\"You are {{char}} speaking to {{user}} in an\nongoing exchange.\"* — as did \"Preserve {{char}}'s voice\", \"Respond from {{char}}'s\nmotives\", \"{{char}} knows only what {{char}} has witnessed or been told\". The single\nhighest-leverage block in the prompt was issuing instructions about a placeholder, and\nthe model had to infer the referent from the card block above it.\n\n**Why it never surfaced:** it produces no error and no malformed output. The card names\nthe character a few hundred tokens earlier, so a capable model resolves it and replies in\ncharacter — the cost is silent, in how binding each rule is. This is the same shape as\nthe v2026-07-25.7 Markdown failure: invisible in code review, invisible in every local\ntest, visible only in quality.\n\n**Fix:** one line — the layer loop now appends `fill(_ltext, NAME, uname)`. Verified it is\nthe only injection site; `TEXTING_STYLE` (the joined form) is not used to build prompts\nanywhere, so there is no second path to miss.\n\n**Effect on tokens:** slightly *fewer* — `{{char}}` (8 chars) becomes a name (typically\n4–6). Immaterial next to the effect on instruction clarity.\n\n**Tests:** `TestPresetPlaceholdersAreFilled` — the injection applies `fill()`, the shipped\nlayers really do contain placeholders (so the guard can't silently protect nothing), and\nsubstitution works on realistic layer text.\n\n## v2026-07-26.3 — One /audit, two sizes for the same file (regression in .2)\n\n**Found by cass's first post-deploy `/audit`**, which reported `preset-core.txt` as\n**~4165t** on the `Prompt:` top-blocks line and **~3839t** on the `Preset layers:` line —\nthe same file, one screen, an 8% disagreement and nothing saying why.\n\n**Root cause: v2026-07-26.2 calibrated a value that gets STORED.** `_record_prompt_size`\naccumulates across the process lifetime, and .2 pointed it at the calibrated `_tokens()`.\nSo each sample froze whichever ratio was live the moment it was taken:\n- the first prompts after a restart are recorded before any API call has been measured,\n i.e. at ratio 1.0 — raw;\n- later samples are recorded at the real ratio — calibrated;\n- `sum`/`avg` adds those together, mixing units in a single average;\n- `max_blocks` keeps the ratio from whenever the peak happened to be hit, and a peak set\n in the first seconds after a restart is *always* the uncalibrated one.\n\nMeanwhile the `Preset layers:` line computes fresh at `/audit` time and is always\ncurrent. Hence two numbers for one file. cass's audit is the textbook case: uptime 0.1h,\npeak recorded during startup at ratio 1.0, then 9 measured calls moved the ratio to 0.92.\n\n**The rule this violated:** a calibrated number is only meaningful against the ratio that\nproduced it, so it must never be persisted or accumulated. Raw is the stable unit.\n\n**Fix:** stats are stored raw (`_prompt_token_total_raw`, `_msg_tokens_raw`, and\n`_prompt_top_blocks` back to `_est_tokens`) and the ratio is applied in\n`_prompt_audit_state()` at render. Every historical sample — including ones taken before\nthe first measurement — is now re-expressed in today's unit, and a later ratio change\nretroactively corrects the whole history instead of stranding it.\n\n**Deliberately still calibrated: `_trim_prompt_to_budget`.** It makes a live decision\nagainst a real ceiling and stores nothing, which is exactly the case calibration is for.\nThe split is now explicit: `_prompt_token_total` (calibrated, live decisions) vs\n`_prompt_token_total_raw` (raw, anything accumulated).\n\n**Buckets stay raw and are labelled.** They are counts already binned; history cannot be\nre-binned without the original samples. The edges are deliberately coarse — the question\nis \"anywhere near a ceiling\", not \"how big exactly\" — so the audit now reads\n`spread (raw est):` rather than implying a precision it doesn't have.\n\n**Swept for the class, and it was a class — two more instances**, both invisible in the\nreported symptom:\n- **`_card_field_tokens`** is filled once at card load, which is *always* before any API\n call has been measured, then rendered much later. Every `Card:` line on every instance\n was therefore frozen at ratio 1.0 while the `Preset layers:` line directly above it was\n calibrated. Now stored raw, calibrated in `gather_audit_data`.\n- **`_llm_stats[\"tok_in\"]`** had the *opposite* error. It holds the provider's real\n counts, but .2 left the no-usage fallback adding **raw** estimates into the same sum —\n two units in one total. That sum is rendered as-is and never re-scaled, so the fallback\n should contribute the best estimate of the real count: it now adds calibrated tokens.\n\nThe rule, now stated in the code at both sites: **store raw if the number will be\nre-rendered later; store calibrated if it is consumed immediately and never re-scaled.**\nThe two mistakes are mirror images, which is why one sweep found both.\n\n**Tests:** `TestPromptStatsUnits` — samples stored raw under a live ratio, render applies\nthe current ratio, a pre-calibration sample is re-expressed, the trim budget stays\ncalibrated, card fields stored raw and rendered calibrated, the daily sum adds real units\nin both branches, and the regression itself pinned: the same text must not report two\ndifferent sizes in one audit.\n\n## v2026-07-26.2 — Token counts are measured, not guessed\n\n**Root cause: every token number this repo has ever reported was `len(text) // 4`.**\n`_est_tokens` is a rule of thumb about English prose, and it was being applied to\nbracketed, bulleted markdown presets — which tokenize denser than prose — with **one\nconstant divisor for six bots running different models with different tokenizers**. It\ncould not have been right for more than one of them at a time. Every figure in the\nv2026-07-25.3/.5/.6 analyses, the `/audit` `Preset layers:` and `Card:` lines, `/usage`,\nand the `/preset` deltas shipped hours ago in .1 all inherited it, and all of them\nrendered it as `~Nt`, which reads like rounding rather than like a guess.\n\n**The provider was returning the right answer the whole time, and bot.py threw it away.**\nThe response to every chat completion carries a `usage` block — `prompt_tokens` counted\nby the real tokenizer for the real model, including the chat-template overhead we cannot\nsee. `_do_request` read `resp.json()[\"choices\"][0]` and discarded the rest, so\n`_track_llm_usage` re-derived from characters a number it had just been handed.\n\n**Why not a tokenizer library.** Considered and rejected. `tiktoken` would be the *wrong*\nvocabulary — the fleet runs GLM through NanoGPT, not an OpenAI model — so it would swap\none wrong number for a differently wrong one, while adding a binary wheel to a Termux /\nPython 3.14 fleet where cp314 wheels are scarce and anything new compiles from source.\nThe provider's count is both more accurate and free.\n\n**What shipped:**\n1. **Real usage is captured and spent.** Non-streaming reads `usage` off the body.\n Streaming asks for it with `stream_options: {\"include_usage\": true}` and parses the\n final chunk. `/usage` and `/audit` now report actual tokens, labelled `measured`,\n `est`, or `N measured / M est` when a day mixes both.\n2. **The heuristic is calibrated from those measurements.** Each measured call yields an\n (estimated, actual) pair for the *same text*; the ratio is folded into a persisted\n per-instance EMA and applied to everything that can only ever be estimated — the\n `/preset` layer costs, `/audit`'s preset and card lines, the prompt-size stats.\n3. **The numbers say which they are.** `/preset` and `/audit` carry\n `Counts: calibrated x1.28 from 41 measured call(s)` or\n `Counts: estimate — no measured API call yet`. Presenting a guess and a measurement\n identically was the actual defect.\n\n**Three ways this could have gone wrong, and what stops each:**\n- **The usage chunk has an empty `choices` list.** Read after the existing\n `chunk[\"choices\"][0]` access it would `IndexError` into the `continue` and be dropped —\n silently discarding the only real count the streaming path ever produces. Parsed\n before, and pinned by a test asserting the source order.\n- **A provider that rejects `stream_options`.** The existing 400 handler would have\n concluded \"this model can't stream\" and disabled streaming for it permanently — a\n latency regression on every future reply, caused by an accounting flag. The 400 path is\n now narrowest-first: drop `stream_options` (keep streaming), and only then fall back to\n non-streaming. Separate `_no_usage_stream_models` set, learned at runtime.\n- **A ratio poisoned by calls where it is meaningless.** Vision calls carry image tokens\n with no characters behind them; tiny prompts are dominated by per-message overhead.\n Samples are rejected outside 0.5–3.0 or under 200 estimated tokens, and a saved ratio\n is re-validated on load so a hand-edited `state.json` cannot put a nonsense multiplier\n on every number the bot reports.\n\n**Deliberately NOT recalibrated: `MEMORY_TOKEN_BUDGET`.** It is a tuned recall knob, not\na cost ceiling — every value in every `.env` was chosen against the raw 4-chars unit.\nSwapping in calibrated counts would change how many memories six live characters recall:\na personality change delivered as an accounting fix. It stays on `_est_tokens` with a\ncomment saying why, and retuning it in calibrated units is filed as ROADMAP 4.4.\n`CONTEXT_TOKEN_BUDGET` *is* calibrated (it genuinely is a cost ceiling) and defaults to\n0/off, so no instance changes behaviour on this deploy.\n\n**Honest about what is not verified here.** The mechanism is proven end-to-end against a\nsimulated NanoGPT response (streaming usage chunk, non-streaming body, stale-usage\nclearing, calibration, reporting). The **actual ratio for the fleet's models is not\nknown** — this container cannot reach nano-gpt.com, and no BPE vocabulary is obtainable\noffline to estimate it. Each bot measures its own on the first real conversation after\ndeploy; `/audit` will show it. Expect it above 1.0 for the markdown-heavy presets, but\nthat is a prediction, not a measurement. It also means **historical figures in the docs\nstay in raw units** — `preset.txt` is \"8,503 raw-estimate tokens\", and the calibrated\nnumber after deploy will differ. That is the numbers getting more correct, not drifting.\n\n**Tests:** 25 new. `TestUsageTokenParsing` (nulls, strings, negatives → 0, never raises —\naccounting hangs off an already-successful reply), `TestCalibrationSample` /\n`TestCalibrationBlend` (outlier rejection, first sample replaces the seed, convergence),\n`TestCalibratedTokens` (scaling, kill switch, confidence wording, and that the memory\nbudget still uses the raw unit), `TestUsageAccounting` (real usage preferred, fallback\nwithout it, consumed exactly once so one call's count can't be billed to the next,\ncalibration updated), `TestUsageCaptureWiring` (usage requested, parsed before the\n`choices` access, both transports, 400 ordering, stale clearing, saved-ratio validation).\n\n## v2026-07-26.1 — Switch preset layers from Telegram (`/preset`)\n\n**Not a bug fix — a gap.** `PRESET_FILES` (v2026-07-25.5) made the voiceprint layered and\nswappable, but the only way to swap it is to SSH or open Termux, edit an instance `.env`,\nand restart the bot. That cost is why ROADMAP 3.13 — the content split that layering was\nbuilt to enable — has been open since it shipped: evaluating whether cass reads better\nwithout `preset-rp.txt` means an edit-and-restart cycle per experiment, per bot, on a\nphone keyboard. The mechanism was there; the feedback loop wasn't.\n\n**Why it's cheap:** `assemble_messages` already re-reads `PRESET_LAYERS` from the module\nglobal on **every message** (v2026-07-25.5 injects one system block per layer). Rebinding\nthat global therefore takes effect on the next reply with no restart, and rebinding is\natomic — a reply already assembling its prompt keeps the list object it started with, so\nno lock is needed. The feature is a command plus persistence, not new prompt machinery.\n\n**What shipped** — `/preset`, admin-gated, per-instance:\n\n| form | effect |\n|---|---|\n| `/preset` | active layers, per-layer and total token cost, source (`.env` or override), what's on disk |\n| `/preset core,rp` | replace the stack |\n| `/preset add explicit` / `/preset drop explicit` | adjust one layer |\n| `/preset reset` | back to the `.env` stack, override cleared |\n\nNames resolve loosely (`core` → `preset-core.txt`) but **never approximately**: an\nunmatched name is rejected with the available list rather than resolved to a plausible\nneighbour. Every change reports the token delta (`~7102t -> ~11037t (+3935t per\nmessage)`), because the layers are large enough that a casual swap can quietly triple\nthe per-message bill.\n\n**Three refusals, each guarding the failure mode the fallback ladder was built for**\n(v2026-07-25.6 — silently dropping voice rules presents as a *model* regression, which is\nthe most expensive kind of bug to diagnose here):\n1. A stack is **dry-run resolved before the live one is touched**. If nothing resolves,\n the current stack stays and the warnings are shown.\n2. An empty stack is refused outright.\n3. On startup, a saved override whose files have vanished (renamed, or an `.env` deployed\n ahead of its files) reverts to the `.env` baseline and appends a config warning —\n never to the ~250-token built-in stub.\n\n**Persistence and the kill switch.** The choice is saved as `preset_override` in\n`state.json` and re-applied by `apply_overrides()` alongside `/setmodel` and `/settings`.\n`PRESET_COMMAND=0` unregisters the command **and makes startup ignore a saved override** —\nthat pairing is deliberate and is the recovery path: a stack that ruins a character's\nvoice is undone with one `.env` line plus a restart, with no state.json surgery on a\nphone. Default on, per the 2026-07-18 policy.\n\n**Plain text, by construction.** The command interpolates layer filenames and resolver\nwarning strings, which is exactly the shape that broke `/audit` in v2026-07-25.7 and the\n11 commands swept in .13 — a stray `_` or `[` makes Telegram reject the whole message, so\nthe command replies with silence. Pinned by a test rather than left to review; the\ngeneralised `TestNoUnescapedMarkdownInterpolation` guard also covers it automatically.\n\n**`/audit` now marks an active override** (`Preset layers (via /preset): …`). Without it,\nan audit showing layers the `.env` doesn't name sends the next reader to the wrong file —\nthe same class as the mislabelled prompt block corrected in v2026-07-25.5.\n\n**Known limit, not a defect.** `/preset` can only choose layers **on disk in that\ninstance directory**. Phone instances have all of them (`sync-cards.sh` copies every\n`preset-*.txt`), so any combination works. On the VPS, `vps-sync.sh` pulls only the layers\n`PRESET_FILES` names — deliberate, since v2026-07-25.5 rejected duplicating a layer list\ninto the script — so **cass and jules can only switch among the layers their `.env`\nalready names**. `/preset` with no arguments lists what that instance actually has, so the\nconstraint is visible at the point of use rather than surprising. Documented in\n`.env.example`.\n\n**Tests:** 27 across `TestPresetNameNormalization` (loose but never approximate matching),\n`TestPresetArgParsing` (comma/space equivalence, dedup — a layer listed twice would\nsilently double its cost), `TestPresetSwap` (both globals rebound, dry-run does not\nmutate), `TestPresetOverridePersistence` (re-applied on startup, stranded by the kill\nswitch, vanished layers revert to `.env`, serialized into state, flagged in `/audit`), and\n`TestPresetCommandInvariants` (admin gate, plain text, empty-stack refusal, menu mirrors\nthe kill switch, no new LLM call). The fixture restores the module-scope stack by\nre-editing, since `test_fixture_falls_back_to_builtin` asserts it.\n\n## v2026-07-25.14 — BOT_TIMEZONE never set the timezone (+ audit item 4)\n\n**⚠️ BEHAVIOUR CHANGE ON DEPLOY — read before shipping.** Any instance whose `.env` sets\n`BOT_TIMEZONE` has, until now, been ignoring it and running on `America/Los_Angeles`.\nAfter this release it uses the timezone you actually asked for. If that differs from\nPacific, **quiet hours, reminders, note follow-ups, schedule windows and midnight day\nrotation all shift accordingly** — correct, but a visible change in when things happen.\nCheck each instance's `/audit` and `.env` before deploying if that matters.\n\n**Root cause:** two timezone variables, and the documented one was inert.\n- `TIMEZONE` set the clock: `TZ = ZoneInfo(TIMEZONE)`, default `America/Los_Angeles`.\n- `BOT_TIMEZONE` was read in exactly one place — inside `--check-config` — purely to\n *label* a warning string.\n- `.env.example` has always documented `BOT_TIMEZONE` as the timezone setting, and the\n pytest fixture sets it too.\n\nSo the variable the docs told you to set did nothing, silently. Worse, the preflight\ncheck read as a pass: with `BOT_TIMEZONE` set and `TIMEZONE` unset, `TZ` resolves fine\n(to Pacific), so `--check-config` cheerfully reported a working timezone that wasn't the\none requested.\n\n`BOT_TIMEZONE` now wins, with `TIMEZONE` still honoured so existing `.env` files using it\nare unaffected; setting both to different values warns rather than picking silently.\n\n**How the earlier sweep missed it, worth recording:** v2026-07-25.12's check asked *\"is\nthis variable read anywhere?\"*. `BOT_TIMEZONE` **is** read — just not for its documented\npurpose. \"Is it read at all\" is a strictly weaker test than \"does it do what the docs\nclaim\", and only the second one would have caught this. It surfaced by accident while\nenumerating undocumented variables for item 4.\n\n**Audit item 4 — `.env.example` now accounts for every variable bot.py reads.** 194 read;\n177 documented as settable with defaults extracted *from the source*, not typed from\nmemory; 17 deliberately listed as internal.\n\nNewly documented, grouped by feature: weather/location, web search, voice & delivery,\nfollow-up bubbles, documents/PDFs/images, selfie tuning, TTS, memory & notes, mood &\nreactions, proactive timing, schedules, inner voice, and group-chat tuning. **Reddit link\nreading (`REDDIT_CLIENT_ID`/`SECRET`/`USER_AGENT`) was an entire undocumented feature** —\nReddit puts scraping behind a JS wall, so shared Reddit links silently fail without a\nfree OAuth app, and nothing told you that.\n\nThe 17 internal ones (`BOT_HOME`, ring-buffer sizes, memory-compaction thresholds, API\nendpoints, the legacy `TIMEZONE` alias) are listed **by name with no example values**, so\nthe file is a complete account of what bot.py reads without presenting implementation\ndetails as a menu to tune.\n\n**New eval `env-vars-documented`**, break-tested: green on the real file, red when a fake\n`os.getenv` is injected into a copy. It fails in both directions — documented-but-unread\nand read-but-undocumented — so this drift cannot silently return.\n\n**Tests:** `TestBotTimezoneTakesEffect` — the fixture's `BOT_TIMEZONE=America/New_York`\nnow actually resolves (it would have been Los_Angeles before), the clock is read at\nmodule scope rather than only in the preflight, the legacy name still works, and the\nconflict warning exists.\n\n## v2026-07-25.13 — Audit item 3: no command renders arbitrary content through Markdown\n\n**Root cause:** v2026-07-25.7 fixed `/audit` sending arbitrary diagnostic strings under\n`parse_mode=\"Markdown\"` — a stray `_` or unmatched `[` makes Telegram reject the **whole**\nmessage, so the command replies with silence, which is indistinguishable from a dead bot.\nThat fix was point-scoped to `/audit`. The audit sweep asked the obvious follow-up\nquestion — *where else?* — and found **13 more sites across 11 commands** interpolating\nvalues into Markdown outside backticks.\n\nConverted to plain text (matching `/audit` and the pre-existing `memory_cmd`, which\nalready documents this exact reasoning):\n\n| command | what it renders |\n|---|---|\n| `/card`, `/setcard` | character-card field contents and the user's new value |\n| `/notes` | the user's own note text |\n| `/status` | outfit, life-arc, day, about-you snippets **and the conversation tail** |\n| `/schedule`, `/people`, `/projects` | user-written file contents |\n| `/vibe`, `/energy`, `/setmodel`, `/settings` | user-supplied names and values |\n| `/model` | `NAME` from the card |\n\n**Latent, not firing.** All four character cards were tested against Markdown parsing\nfirst — balanced brackets, even underscore counts, nothing currently breaks. This is a\nfix for the failure that hadn't happened yet: `/notes` renders text you write and\n`/status` renders conversation content, so a single `_` was all it would take.\n\n**Backticks are not the safe wrapper they look like.** `setcard_cmd` wrapped raw user\ninput in a code span (`` `{field}` ``) — a backtick *in* that input closes the span early\nand breaks the parse. The scanner had scored backtick-wrapped values as safe; that\nassumption held for model ids and fixed strings but not for arbitrary input. Those two\nsites are now plain text with `{field!r}`.\n\n**Two sites deliberately left on Markdown**, allowlisted in the test with the reason:\n`quietwin_cmd` (an int index, fixed `Mon`–`Sun` names, `HH:MM` strings) and `fleet_cmd`\n(an int, inside a ``` fence). Neither can carry a metacharacter.\n\n**Test:** `TestNoUnescapedMarkdownInterpolation` is a *generalised* guard, not another\npoint fix — it re-derives the offender list from source on every run and fails on any new\none, so the next command that formats arbitrary data is caught at commit time rather than\nby an owner wondering why a command went quiet. A second assertion fails if the two\nallowlisted commands ever stop interpolating provably-safe values, so the allowlist can't\ngo stale silently.\n\n## v2026-07-25.12 — Audit items 1 & 2: dead env vars, and the group pilot split across hosts\n\nFrom the three-class audit (shared state / silent replies / doc-vs-reality drift).\n\n**Item 1 — `.env.example` documented seven variables bot.py never reads.** A mechanical\ndiff of `os.getenv`/`_env_int`/`_env_float` names against the template found 7 documented\nvars with zero readers. Setting any of them was a silent no-op, and the template is what\n`CLAUDE.md` calls the \"full documented template\":\n- `HEARTBEAT_MIN` / `HEARTBEAT_MAX` — **name mismatch**: those are bot.py's internal\n seconds-valued Python variables; the env vars are `HEARTBEAT_MIN_HOURS` /\n `HEARTBEAT_MAX_HOURS`.\n- `NUDGE_MAX` — **never existed**. The nudge budget is per-chat runtime state (default 3,\n 0 = unlimited) set from Telegram with `/nudges 3`. Documenting it as an env var meant\n an owner could believe they had capped proactive messages when they had not.\n- `PROACTIVE_HOUR_START` / `PROACTIVE_HOUR_END` — never existed. The real control is\n `QUIET_START` / `QUIET_END`, which are a *suppression* window (so the sense is inverted\n from the old names) and were themselves undocumented.\n- `CONTEXT_LIMIT` / `SUMMARY_EVERY` — never existed. The verbatim window is bounded by\n hardcoded `MAX_HISTORY=20` / `KEEP_RECENT=10` plus the settable `SHORT_TERM_HOURS`;\n summarisation is overflow-triggered, so there is no \"every N turns\" knob at all.\n\nAll seven corrected in place with the real names and an explicit note that the old ones\ndid nothing. Re-running the check now reports **0** documented-but-unread vars (65 remain\nundocumented in the other direction — audit item 4, not addressed here).\n\n**Item 2 — the group-chat pilot is split across two hosts.** `GROUP_CHAT_DESIGN.md` §3\nstates the assumption plainly: *\"all instances live on one phone\"*, *\"one ext4 filesystem,\nwhere flock is reliable\"*. Telegram never delivers one bot's messages to another, so the\nshared filesystem **is** the channel. Jules migrated to the VPS on 2026-07-19; Priya\nstayed on the phone. Each host now silently gets its own ledger and claim dir, which\nbreaks precisely what the design exists to prevent:\n- `_try_claim` always succeeds on both sides — there are no shared claim files to contend\n for, so both bots answer the same message;\n- `GROUP_BOT_CHAIN_MAX` and `GROUP_DAILY_BOT_BUDGET` are computed from separate ledgers,\n so the chain cap and alternation penalty do not apply across the pair.\n\n`GROUP_DAILY_BOT_BUDGET` still bounds each bot individually, so a runaway is capped rather\nthan infinite — but at roughly double the intended volume with no alternation control.\nContrast `world.txt`, whose equivalent split MIGRATION.md explicitly accepts: that one\ndegrades to independent weather, this one degrades to unbounded alternation.\n\nbot.py cannot know where a peer lives, so the fix is a loud startup `_CONFIG_WARNINGS`\nentry whenever `GROUP_MODE` is on with peers configured, printing the **resolved**\n`GROUP_LEDGER_DIR` so the owner can compare it across hosts. Documented in\n`GROUP_CHAT_DESIGN.md` (inline at the assumption itself) and in `deploy/MIGRATION.md`\nalongside the Nora/`world.txt` note, with a verification step for when priya migrates.\n\n**Recommendation while split: `GROUP_MODE=0` on both.** `GROUP_MODE` defaults to 0, so\nthis is dormant unless explicitly enabled.\n\n**Tests:** `TestGroupColocationWarning` — the warning is gated on `GROUP_MODE and\nGROUP_PEERS`, it names the resolved directory (the owner has to *compare* it, so printing\nit is the point), the fixture stays warning-free, and `GROUP_LEDGER_DIR` still defaults to\nthe shared code dir, which is why co-location matters at all.\n\n## v2026-07-25.11 — Concurrent /update corrupted the shared code dir\n\n**Root cause:** owner-reported from a VPS bot's `/errors`:\n\n```\nFile \"/opt/telegram-bots/bot.py\", line 11660, in perform_self_update\nFile \"/usr/lib/python3.12/py_compile.py\", line 161, in compile\nFileNotFoundError: [Errno 2] No such file or directory: '/opt/telegram-bots/bot.py.new'\n```\n\n`perform_self_update` writes `bot.py.new`, `bot.py.bak` and `bot.py` into\n`Path(__file__).parent` — the **shared** code directory. Every instance on a host shares\nit: `~/telegram-bot` for the four phone bots, `/opt/telegram-bots` for cass and jules. So\ntwo concurrent `/update` calls operate on the same three paths with no synchronisation:\n\n1. cass writes `bot.py.new`\n2. jules writes `bot.py.new`\n3. cass compiles, then `tmp.replace(target)` — which **removes** `bot.py.new`\n4. jules compiles → `FileNotFoundError`\n\n**The crash is the good outcome.** The silent variant is worse: the loser reaches\n`bot.py.bak <- target` *after* the winner has already swapped in the new file, so the\nrollback point becomes a copy of the **new** code. You would believe you had a rollback\nand not have one — and nothing would tell you.\n\nThe documented procedure (\"`/update` to ONE bot, then `/restart` the others\") avoids this\nby convention. Nothing enforced it, and the phone has the identical exposure.\n\n**Fix:** a host-wide `flock` on `.update.lock` in the code dir, taken **before** the\ndownload so a refused update does no work at all — no request, no temp file, nothing\ntouched. A second caller gets `{\"reason\": \"update_in_progress\"}` naming the correct\nprocedure. The body moved to `_perform_self_update_locked` so the lock and the work are\nseparable and testable. Held only inside a sync function invoked via `asyncio.to_thread`\nwith no awaits, so invariant #9 does not apply.\n\n**Second defect found while fixing the first:** `update_cmd` matched reasons with an\nif/elif chain and no `else`, so `update_in_progress` — and any future reason — fell\nthrough to a bare `return` and **replied nothing at all**. Same class as the `/audit`\noutage in v2026-07-25.7: a command that silently does nothing is indistinguishable from a\ndead bot. There is now a catch-all reply.\n\n**Also:** `.update.lock`, `bot.py.new` and `bot.py.bak` are gitignored. The lock file\nfirst appeared as an untracked artifact of the very tests written for it.\n\n**Tests:** `TestSelfUpdateLock` holds the lock the way a competing instance would and\nasserts the refusal happens before any network or filesystem work (including that\n`bot.py.new` is untouched — the exact file the bug destroyed), that the lock is released\nfor the next caller, and that the lock lives outside the extracted body.\n`TestUpdateCmdNeverRepliesSilently` pins the catch-all.\n\n**Operationally:** `/update` on a VPS instance works but is not the documented path —\n`deploy/vps-sync.sh` also pulls the card and preset layers and handles the systemd unit.\nUse it for cass and jules.\n\n## v2026-07-25.10 — Sanity-check sweep: the wrong triage rule had four more survivors\n\n**Root cause:** v2026-07-25.8 corrected \"`STARTUP AUDIT` with no preceding\n`[shutdown] graceful stop` = SIGKILL\" in `CLAUDE.md`, the Monitoring section, the\n`repo-debugging-playbook` table, and `_on_shutdown`'s docstring. An explicit sweep found\nit still standing in **four more places** — a correction pass that greps only where you\nremember writing something is not a correction pass.\n\nThe worst survivor was in `_self_audit`'s **restart-storm DM**: the message the owner\nreceives *at the moment they are debugging a restart storm* told them\n`no line = SIGKILL`. Worst possible placement for the one instruction guaranteed to be\nread under pressure. Now points at the exit code (137 / 143 / 0) and explicitly warns\nthat the graceful-stop line is not the discriminator.\n\nAlso fixed:\n- `CHEATSHEET.md` — the phantom-killer check still keyed off the graceful-stop line.\n Replaced with the exit-code grep, plus the note that `settings` cannot run in Termux\n (it needs adb) and the process-census one-liner that found the stacked watchdogs.\n- `vault/entities/termux-phone-host.md` — a live knowledge entry that past sessions have\n corrected during incidents. Carried **three** stale facts: the old triage rule, \"Python\n 3.13\" (it's 3.14.6), and \"all six bots\" on the phone (it's four; cass and jules are on\n the VPS).\n- The v2026-07-25.3 entry below still asserted the 4,715-token `ATTRACTION RULE` figure as\n fact, with only a forward correction in `.5`. Annotated inline so it can't be read in\n isolation and believed. Deliberately annotated rather than rewritten — the mistake, and\n how a mislabelled diagnostic produced it, are worth keeping legible.\n\n**Test:** `TestRestartStormAdviceIsCorrect` pins the DM to the exit-code rule and asserts\nthe old claim is absent, because this specific text has now been wrong through two\ncorrection passes.\n\n## 2026-07-25 — Ops: watchdog.sh single-instance guard (no bot.py change, no version bump)\n\n**Root cause:** `watchdog.sh` supports two install modes — `--once` (cron) and a bare\ninvocation that enters `while true; do run_checks; sleep $WATCHDOG_INTERVAL; done` and\nnever exits. Installed in cron **without** `--once`, every invocation starts another\nimmortal loop. Nothing stopped it: the script had no duplicate-instance guard of any kind.\n\nObserved on-device 2026-07-25: **92 `bash` + 91 `sleep` processes**, ~183 of the phone's\n191 Termux-uid processes, against an Android phantom-process limit of 32. Diagnosed by\ngrouping `ps -u $(id -u)` by command — the paired bash/sleep counts are the signature of\nN shell loops each parked in a sleep.\n\n**The process count was the lesser problem.** Every accumulated watchdog polls the same\n`.alive` heartbeat files, so a single stale heartbeat would produce ~91 simultaneous\nkill-and-relaunch decisions against one bot. That is the 2026-07-05 incident this script\nwas written to prevent, multiplied by however many copies have piled up.\n\n**Fix:** a PID-file guard ahead of the mode dispatch. A live owner (`kill -0` succeeds)\nmakes the new instance log why and exit; a stale file is cleared and taken over; `trap …\nEXIT INT TERM` removes it on the way out. PID file rather than `flock` to avoid a\nutil-linux dependency on Termux — the theoretical race between two simultaneous starts is\nirrelevant against a 5-minute cron cadence.\n\nBreak-tested through all four paths: clean start, refusal while a live instance holds the\nfile, self-heal from a stale PID, and cleanup on exit.\n\n**`watchdog.sh` is curl-installed and NOT pulled by `update-all.sh`** — re-install it by\nhand to get this (command in the script header). Also check `crontab -l`: the cron line\nmust include `--once`.\n\n## v2026-07-25.9 — A partial Garmin pull explains itself\n\n**Root cause:** on 2026-07-25 `/healthnow` returned only today's step count. The feature\nwas working correctly — the watch was in battery saver, which disables the optical HR\nsensor, so sleep, resting HR, body battery and stress had never been recorded, while\nsteps (accelerometer, phone-side) kept arriving. But **nothing in the product could say\nthat.** `/healthnow` printed the phrases it had and stayed silent about the rest, so a\ncorrect partial pull was indistinguishable from a broken integration.\n\nWorse, the diagnostics existed but were unreachable: `_fetch_garmin` logged per-endpoint\nfailures with `print()`, which reaches `bot.log` but **not** `errors.log` — so `/errors`\ncould not show them and the only route to an answer was a shell. That is the second time\nin one day that diagnosing a freshly shipped feature required device access it shouldn't\nhave (see v2026-07-25.7).\n\n**What shipped:**\n- **`_garmin_fields`** is now the single source of truth: payloads → ordered\n `[(metric label, phrase or None)]` for all six metrics. **`_garmin_bits` is a thin\n wrapper over it**, so the snapshot text and the missing-metric report cannot drift.\n (The first cut of this release inverted that dependency — deriving labels by\n prefix-matching finished phrases — and broke immediately, because `\"slept …\"` does not\n start with `\"sleep\"`. Caught by the tests; the direction of dependency is the fix.)\n- **`_garmin_missing`** names the metrics with no usable data; **`_garmin_gap_note`**\n turns that into a plain-text tail on `/health` and `/healthnow` that also states the\n two usual causes (watch not synced; battery saver holding the HR sensor off, which\n takes out four metrics at once while steps survive).\n- **Per-endpoint failures now go through `log.warning`**, so they land in `errors.log`\n and `/errors`, and count an error category. Still only the exception *class* is logged\n — Garmin exceptions can carry the request URL (the v2026-07-20.2 key-leak class).\n- The `\"no data for: …\"` summary is logged once per pull at WARNING. Routine, but it must\n be visible somewhere other than a shell.\n- `missing` is persisted into `.garmin_snapshot` so `/health` (the cached view) is honest\n too. Snapshots written before this release have no such key and load as `[]`.\n\n**Tests:** `TestGarminFields` (all six labels always reported; empty payloads mean\neverything missing; **the exact battery-saver case** — steps present, the five HR-derived\nmetrics absent; full payload reports nothing missing; and an agreement test pinning\n`_garmin_fields` against `_garmin_bits`), `TestGarminGapNote` (silent when nothing is\nmissing, names the metrics, states the cause, stays plain text), and\n`TestGarminFailuresReachErrors` (no `print` survives in `_fetch_garmin`, the exception\nobject itself is never logged, the return contract is `(text, missing)`, and the loader\ntolerates pre-`.9` snapshot files). All 14 original `_garmin_bits` tests pass unchanged,\nwhich is what demonstrates the wrapper preserved its contract.\n\n## v2026-07-25.8 — Client-side API errors stop hiding in the network bucket\n\n**Root cause:** `BadRequest` **subclasses `NetworkError`** in python-telegram-bot\n(verified on 21.11.1: `BadRequest → NetworkError → TelegramError`). `on_error` tested\n`isinstance(err, (NetworkError, TimedOut))` first, so every 400 from the Bot API —\nmalformed markup, message-too-long, invalid parameters, bad file — was logged as\n`[net] transient: BadRequest: …` at WARNING and counted under `network`. That bucket reads\nas ambient phone flakiness and is rightly ignored.\n\n**This is how v2026-07-25.5's `/audit` markup bug stayed invisible.** From Emily's\n`errors.log`:\n\n```\n07:38:01 [WARNING] [net] transient: BadRequest: Can't parse entities: ... byte offset 484\n07:46:46 [WARNING] [net] transient: BadRequest: Can't parse entities: ... byte offset 484\n```\n\nTwo reproductions of a code defect, presented to the owner in `/audit` as `network: 3`.\nA 400 means *we sent something invalid*; it has nothing to do with the connection, and\nconflating the two removed the only signal that would have named the bug.\n\n**Fix:** `BadRequest` is now tested **before** `NetworkError` (mandatory given the\ninheritance), logged via `log.error` with `[api] bad request — client-side defect, not the\nnetwork`, and counted in its own `bad_request` category so it appears as a distinct line\nin `/audit`. Real `NetworkError`/`TimedOut` behaviour is unchanged.\n\nConsidered and accepted: a few Bot API 400s are semi-benign (`Message is not modified`,\n`Query is too old`). Those will now log at ERROR. Left unfiltered rather than\npre-emptively suppressed — if one shows up in practice it is a one-line substring filter,\nwhereas guessing at the list now risks re-hiding a real defect.\n\n**Also in this release** (deferred from .7 to avoid a redeploy for a comment): the\n`_on_shutdown` docstring stated that a startup audit with no preceding graceful-stop line\nmeans SIGKILL. That is false — `/update` and `/restart` exit via `_schedule_exit()` →\n`os._exit(0)`, bypassing `post_shutdown`, so an ordinary deploy logs no graceful stop\neither. Corrected in place to point at the exit code instead (`0` clean, `137` SIGKILL,\n`143` uncaught SIGTERM), matching the CLAUDE.md and `repo-debugging-playbook` fixes made\nalongside it. Reading that comment literally cost two debugging rounds on a bot that had\nhad zero unexpected restarts.\n\n**Tests:** `TestBadRequestNotNetwork` — asserts the PTB subclass relationship itself (so a\nfuture PTB fix surfaces as a signal rather than silently making the guard redundant),\nthat a `BadRequest` increments `bad_request` and **not** `network`, that genuine\n`NetworkError`/`TimedOut` still count as `network`, that the isinstance ordering is\nexplicit in source, and that the branch logs at ERROR rather than WARNING. Driven through\n`on_error` itself, not a source grep.\n\n## v2026-07-25.7 — /audit was broken by its own output (regression in .5/.6)\n\n**Root cause:** `audit_cmd` sends with `parse_mode=\"Markdown\"`, and v2026-07-25.5/.6 added\nlines that interpolate **arbitrary diagnostic strings** into it:\n- card field names from the new `Card:` line — `system_prompt`, `mes_example`,\n `post_history_instructions` all contain `_`, which opens italics in Telegram's legacy\n Markdown;\n- prompt block headings from the `Prompt:` top-blocks line — e.g.\n `[VOICEPRINT PRESET — TELEGRAM SINGLE SLOT]`, an unmatched `[` that legacy Markdown\n parses as the start of a link;\n- and, pre-existing but far rarer, config warnings naming env vars (`STRESS_THRESHOLD`).\n\nTelegram rejects the whole message with `400: can't parse entities`, so nothing sends.\nFrom the owner's side `/audit` simply does nothing — **the command whose entire purpose is\ndiagnosing the bot became the thing that silently failed**, on every instance running .5\nor .6, not just the one where it was noticed. Verified by rendering Emily's real audit\npayload and counting the metacharacters: three of the added lines are individually\nunbalanced.\n\n**Fix:** `/audit` now sends **plain text**. The only Markdown it ever used was a bold\n`*Self-Audit*` header, which is not worth a failure mode where the diagnostic is\nun-sendable because of what it found. Escaping was the alternative and was rejected: the\nset of interpolated values is open-ended (field names, headings, file paths, model ids,\nexception class names, future additions), so escaping would need to be remembered forever\nat every new call site, while plain text cannot regress.\n\n**Not affected, and why:** `/errors` never set a `parse_mode`. `/fleet` wraps its table in\na ``` fence, where `_` and `[` are literal, and only carries version/uptime/error counts.\nBoth were checked rather than assumed.\n\n**Eval + tests.** New `audit-plain-text` eval, **break-tested red-green** — and the first\nversion of it was itself broken: a plain awk range\n`/^async def audit_cmd/,/^async def /` collapses to a single line, because the opening\nline also matches the end pattern, so the check could never fail. Replaced with a\nflag-based scan that skips comment lines (the fix's own comment necessarily mentions\n`parse_mode=\"Markdown\"` to explain the ban). Confirmed 0 on the fixed file and 1 on a copy\nwith `parse_mode` re-injected. `TestAuditIsPlainText` pins the same contract in pytest,\nincluding that the card-field names really do contain underscores — so a future reader\ncan't mistake the plain-text requirement for cosmetic preference.\n\n**Why an eval for a first occurrence** (the usual bar is twice): the failure is invisible\nin code review and in every local test, produces no user-visible error, and disables the\nfleet's primary diagnostic. The same shape would recur the moment anyone adds a formatted\nline to `/audit`.\n\n## v2026-07-25.6 — The preset split, Cass first (+ fallback ladder)\n\n**Content, plus one small bot.py hardening.** v2026-07-25.5 shipped the layering\nmechanism; this carves the actual layers and moves **Cass alone** onto them. `preset.txt`\nis **unchanged**, so the other five bots are byte-identically unaffected until their\n`.env` opts in.\n\n**What the split revealed.** `[CHARACTER AUTHENTICITY]` (2,386 tok — the largest section\nin the file) is two unrelated bodies of text concatenated under one heading:\n- ~490 tok of genuinely universal guidance: autistic characters, scientists and\n professionals rendered as full people; technical vocabulary belongs to the work, not the\n interior.\n- ~1,900 tok of a Dead Dove content guide + explicit content module — anatomical\n sex-writing mechanics, action-reaction chains, worked examples, a scene-quality checklist.\n\nSo **Cass — a developmental editor — was carrying ~1,900 tokens of explicit scene-writing\nmechanics on every message.** The misleading heading is exactly why it stayed invisible:\nthe same failure mode as the `ATTRACTION RULE` mislabel corrected in v2026-07-25.5. The\nsection is split at the `### Dead Dove Content Guide` boundary; the universal part keeps\nthe heading and goes to core.\n\n**Layers created** (partitioned programmatically, so text is preserved byte-for-byte):\n\n| layer | tok | contents |\n|---|---|---|\n| `preset-core.txt` | 4,166 | voiceprint, priority order, voice, epistemic horizon, anti-slop, character agency, authenticity (universal part), anti-echo, text delivery, repair, self-check |\n| `preset-rp.txt` | 1,680 | narration, NPC management, scene continuity, scene rhythm |\n| `preset-explicit.txt` | 1,930 | the Dead Dove guide + explicit content module |\n| `preset-stepped.txt` | 403 | `[STEPPED THINKING]` — coupled to `STEP_INTENT` |\n| `preset-closeness.txt` | 323 | `[RELATIONSHIP STAGE]` — coupled to `CLOSENESS_ENABLED`, which **defaults to 0** |\n\nVerified by reconstruction: every section lands in exactly one layer, and the multiset of\nnon-whitespace characters across the layers equals the original `preset.txt` — no text\nlost, none duplicated (8,502 tok of layers vs 8,503 original, the delta being join\nwhitespace in the estimator).\n\n**Measured:**\n| instance | today | layered | layers |\n|---|---|---|---|\n| **cass** | 11,037 | **7,102** (−3,935, −36%) | core + stepped |\n| jules | 14,425 | 14,099 (−326) | core + rp + explicit + stepped |\n\nJules's −326 is the `[RELATIONSHIP STAGE]` section that was dead weight on all six bots.\nCass sheds the explicit module, the scene machinery, and closeness.\n\n**bot.py change — the fallback ladder.** `vps-sync.sh` reads `PRESET_FILES` from the\ninstance `.env` to know which layers to pull, so the `.env` edit necessarily precedes the\nfile arriving. If nothing resolved, the previous code dropped straight to the ~250-token\nbuilt-in `_DEFAULT_TEXTING_STYLE` — silently stripping thousands of tokens of tuned voice\nrules, which presents as a model regression rather than a deploy error. The ladder is now\n**named layers → the shared `preset.txt` → built-in**, with a warning at each rung.\nVerified: with `.env` naming layers that aren't on disk yet, Cass resolves to\n`preset.txt (fallback)` at 11,037 tok — identical to today — and logs three diagnosable\nwarnings.\n\nExtracted as pure `_resolve_preset_layers(names, read, default_text, warn)` with the reader\ninjected, so the ladder is testable without a filesystem. 11 tests in\n`TestResolvePresetLayers`, including that a reader exception's message never reaches the\nwarning (paths/credentials stay out of `errors.log`, per the v2026-07-20.2 class) and that\na resolvable `preset.txt` listed as a normal layer is not relabelled as the fallback rung.\n\n**Not done:** the other five bots stay on the monolithic `preset.txt`. Moving them is a\none-line `.env` change each (`PRESET_FILES=preset-core.txt,preset-rp.txt,preset-explicit.txt,preset-stepped.txt`)\nonce Cass has proven the split in use.\n\n## v2026-07-25.5 — Layered presets, and an honest card breakdown (PRESET_FILES)\n\n**Correction to v2026-07-25.3's findings — read this before trusting that entry's numbers.**\nThat release reported jules carrying \"a 4,715-token `ATTRACTION RULE` block\". **Wrong.**\n`[ATTRACTION RULE]` is **84 tokens**. The 4,715 was the entire *merged* card block —\n`load_character` joins `system_prompt` + boilerplate + `description` (1,762) + `scenario`\n(81) + `mes_example` (1,444) into one system message, and `_prompt_top_blocks` labelled\neach block by its **first line**. Jules's `system_prompt` opens with `[ATTRACTION RULE]`,\nso an 84-token section was credited with a whole card. Jules's `system_prompt` is 1,375\ntokens across seven sections, the largest being `[PACE CONTROL]` at 348.\n\nThat was a defect in the diagnostic shipped one release earlier, and it sent a real\ninvestigation down the wrong path (\"move `ATTRACTION RULE` to the lorebook to cut her floor\nby a third\" — it would have saved 84 tokens). Both halves are fixed here:\n- `_prompt_top_blocks` gives `messages[0]` the fixed label `(card:\n system_prompt+description+…)` instead of inheriting whatever the card opens with.\n- `load_character` now records a real per-field breakdown (`_card_field_tokens`) *where the\n fields still exist*, before the merge destroys the structure. `/audit` gains a `Card:`\n line separating always-on fields from the lorebook (which only costs on a trigger), plus\n the four biggest fields by name.\n\n**Root cause this release addresses (the actual feature):** one shared `preset.txt` is\n8,503 tokens injected on **every message for every bot**, and by section it is ~1,760\nuniversal / ~6,020 roleplay-scene machinery / ~727 coupled to features that have their own\nenv flags (`[RELATIONSHIP STAGE]` is 323 tokens instructing every bot about\n`CLOSENESS_ENABLED`, which **defaults to 0** — dead weight on all six). Cass is a\ndevelopmental editor with no scenes and no NPCs, carrying ~6k tokens of scene-management\ninstruction she cannot use. The cost isn't really tokens — it's signal-to-noise: ~700\ntokens of live per-turn context (mood, day, schedule, watch metrics, capabilities) were\ncompeting against 8,500 tokens of largely inapplicable generic instruction, which is the\nsame failure mode as the recall bias fixed in v2026-07-25.1.\n\n**What shipped:** `PRESET_FILES` — an ordered, comma-separated list of layer files, each\nread from the instance directory and injected as **its own system block** (so `/audit`\nshows what each layer costs, and a layer can be added or dropped without editing a\nmonolith). Unset falls back to `PRESET_FILE`, which still defaults to `preset.txt`, so a\nsingle-layer config produces exactly one block as before and **the fleet's assembled prompt\nis unchanged until an `.env` opts in**. A named-but-missing layer appends a\n`_CONFIG_WARNINGS` entry rather than silently vanishing — quietly dropping voice rules\nreads as a model regression, not a deploy error. If no layer resolves at all, the built-in\n`_DEFAULT_TEXTING_STYLE` still applies, and the documented \"no preset.txt\" case does not\nwarn.\n\n**Deploy paths made layer-aware in the same commit**, so the content split can't half-land:\n- `sync-cards.sh` copies every `preset-*.txt` in the repo to every instance (each bot's\n `PRESET_FILES` decides what it loads). Globs safely when no layers exist yet.\n- `deploy/vps-sync.sh` can't list a remote directory over raw URLs, so it parses the\n instance's own `PRESET_FILES` and pulls exactly those. Self-maintaining — no layer list\n duplicated in the script. A named layer that 404s is **fatal on purpose**: starting a bot\n with missing voice rules is worse than a failed deploy.\n\n**Measured with a prototype core/rp/feature split** (not shipped — see below):\n| instance | today | layered | layers used |\n|---|---|---|---|\n| cass | 11,031 | **4,758** (−57%) | core |\n| jules | 14,419 | 14,419 | core + rp + feature |\n\nJules is unchanged *by design* — she uses every layer, so there is nothing to drop. The win\nis concentrated exactly where a character doesn't need the roleplay machinery, which is the\nhonest shape of this change.\n\n**The content split is deliberately NOT in this release.** `preset.txt` is voice-critical\nand deliberately tuned (see v2026-07-18.1's anti-echo work), and `[CHARACTER AUTHENTICITY]`\nalone is 2,386 tokens. Carving it up is an owner-reviewed content decision, one layer at a\ntime, using the new `/audit` numbers as the evidence base. This release ships only the\nmechanism, inert by default. ROADMAP 3.13 tracks the split.\n\n**Tests:** `TestPresetLayers` (layers load, name/text shape, joined `TEXTING_STYLE`\nequivalence, built-in fallback, no spurious warning for a missing default,\n`PRESET_FILE` back-compat, per-layer injection, audit reporting), `TestCardBlockLabelling`\n(the card block gets the fixed label, later blocks keep their headings, and the label leaks\nno card content), `TestCardFieldTokens` (breakdown recorded, empty fields omitted, lorebook\ntracked separately, surfaced in `/audit`). One v2026-07-25.3 test was updated because\n`messages[0]` is now labelled deliberately rather than by its first line.\n\n## v2026-07-25.4 — Prompt trimming gives up the right things first\n\n**Root cause this release addresses:** `_trim_history_to_budget` computed\n`protected = system_indices | {final_user}` — i.e. **every** system block was exempt and\nonly conversation history was droppable. Two consequences, both demonstrated by\nexercising the function rather than reading it:\n\n1. **The priority was inverted.** On the real assembled prompt at a 15,000-token budget it\n kept 9/9 system blocks and dropped 13/20 conversation turns. It would delete a dozen\n turns of live conversation to preserve a triggered lorebook entry or a randomly sampled\n list of local restaurants — context the character demonstrably does not need to hold a\n conversation.\n2. **The budget was unenforceable and silent about it.** Because the protected set could\n exceed the budget on its own, the loop drained every droppable message and returned\n over budget anyway. With a 14,000-token system stack and a budget of 8,000: 0 of 40\n history messages kept, prompt still 14,003, logged as a success. (v2026-07-25.3 made\n that case log a WARNING; this release makes it rare.)\n\n**What shipped** — `_trim_history_to_budget` → **`_trim_prompt_to_budget`**, now tiered:\n1. optional system blocks, **largest first** (fewest distinct blocks lost per token freed);\n2. history older than `KEEP_RECENT`, oldest first;\n3. last resort — dip below `KEEP_RECENT`, oldest first (a degraded prompt that fits beats\n a hard context failure);\n4. still over → WARNING + `prompt_budget` error, as before.\n\nThe final user message and every unmarked system block are never dropped.\n\n**Marking is opt-in and fails safe.** A block is droppable only if built with the new\n`_sys_opt()` helper, which tags it `_tier = _TIER_OPTIONAL`; `_strip_tiers()` removes the\ninternal key before the list reaches the API (same reason history's `ts` is dropped when\ncopied into the prompt). Seven blocks are marked: `# Relevant background` (lore),\n`# Relevant memories`, `# Inside jokes`, `# Local places`, `# Open threads`,\n`# What's going on today`, and the recent-questions list. Everything else — the card, the\npreset, capabilities, post-history instructions, mood, the initiative note — stays\nprotected **by default**, so a newly added block, or one whose heading someone rewrites,\ncannot silently become droppable. The rejected alternative was classifying by heading\nstring at trim time, which reclassifies a block the moment its wording changes.\n\n**Measured effect** (priya, populated with threads/jokes/recent-questions, 20 turns,\n13,005 tokens untrimmed): at a 12,800 budget the tiered trimmer drops 2 optional blocks\nand keeps **18/20** turns. **Honest limit:** the benefit is proportional to how much\noptional context is live. For a card-heavy instance like jules — 14,417 tokens of system\nstack that is almost entirely *protected* (preset 8,503 + card 5,224) — there is little\noptional context to give up, and a budget below ~14.5k still costs conversation. The only\nreal lever there is reducing the protected content itself, which is a content decision\n(see the `Prompt:` top-blocks line added in v2026-07-25.3), not a trimming one. This\nrelease does not claim to fix that case.\n\n`CONTEXT_TOKEN_BUDGET` still defaults to **0/off** — nothing changes for the fleet until\nit is deliberately set, and `.env.example` now says to set it from the `/audit` numbers\nrather than by guessing.\n\n**Pure helpers + tests:** `_sys_opt`, `_strip_tiers`. 25 new tests across `TestSysOpt`,\n`TestStripTiers`, `TestTieredTrimOrder` (optional-before-history, largest-optional-first,\noldest-history-first with the survivors proven contiguous and newest-ending, last-resort\ndip, final-user survival, protected-block survival, early stop, unfittable warning) and\n`TestOptionalBlocksAreMarked` (the seven blocks carry the marker; `POST_HISTORY_RAW` and\n`TEXTING_STYLE` deliberately do not). The pre-existing `TestTrimPromptToBudget` cases pass\nunchanged after the rename — unmarked blocks behave exactly as before.\n\n## v2026-07-25.3 — Measure the assembled prompt (PROMPT_STATS)\n\n**Root cause this release addresses:** a question came up about whether large character\ncards were starving other features of prompt attention, and **nothing in the codebase\ncould answer it**. `_llm_stats[\"tok_in\"]` accumulates a daily running sum across every LLM\ncall (chat, summary, analysis, reaction), so it cannot report the size of a single\nassembled prompt, its maximum, or which block drove it. There is no context-overflow\ndetection either. Instrumenting first is this repo's own protocol (\"opaque error →\ninstrument first\") and it decides whether the follow-up trimmer work is urgent or\ntheoretical. No trimming behaviour changes here.\n\nMeasured while diagnosing (recorded so the next session doesn't re-derive it — estimates\nvia `_est_tokens`, 4 chars/token, on an empty instance with 20 short history messages):\n\n| source | tokens | conditional? |\n|---|---|---|\n| `preset.txt` (shared voiceprint) | **8,503** | no — every message, every bot |\n| card's unconditional fields | 1,834 (cass) → 5,224 (jules) | no |\n| lorebook | 0 → 3,991 (jules) | yes, only on trigger |\n| capabilities / mood / initiative / env / etc. | ~700 | mostly |\n| history (20 short msgs) | 405 | bounded by COUNT, not tokens |\n\nFull assembled prompt: **cass 11,435 → jules 14,822**. The headline finding is that\n**card file size is a poor proxy for prompt cost**: jules's card is 5.8× cass's on disk\nbut her prompt is only 1.3× larger, because most of that 52KB is lorebook (conditional)\nand JSON structure. The largest single line item for every bot is the *shared* preset —\n77% of cass's system stack, 59% of jules's. Jules's one real outlier is a 4,715-token\n`ATTRACTION RULE` block inside her card, which ships unconditionally every message.\n\n> ⚠️ **The previous sentence is WRONG — corrected in v2026-07-25.5.** `[ATTRACTION RULE]`\n> is **84 tokens**. The 4,715 figure is the entire *merged* card block (system_prompt +\n> description + scenario + mes_example); `_prompt_top_blocks` labelled every block by its\n> first line, and jules's card happens to open with that heading. Do not act on the 4,715\n> number. Left in place rather than rewritten so the mistake, and how a mislabelled\n> diagnostic produced it, stay legible.\n\n**What shipped**, behind `PROMPT_STATS` (default **1 = on**; `0` disables the bookkeeping):\n- `_record_prompt_size` at the end of `assemble_messages` — count, running sum, max (with\n timestamp and chat), and a coarse histogram. On-loop and O(messages), the same walk\n `_track_llm_usage` already does per call. In-memory only, like `_recent_questions`; a\n restart resets it rather than adding a state-serialization path.\n- On a new maximum it also records the three largest system blocks by heading, so `/audit`\n says *which* block drove the peak instead of only that a peak happened.\n- `/audit` gains a `Prompt:` line (avg, max, age of max, sample count), a bucket spread,\n and those top blocks.\n\n**Two logging defects fixed in `_trim_history_to_budget`** (both found by exercising the\nfunction directly rather than by reading it):\n- The completion line read `\"~%dk tokens over budget\"` while printing the **final total**,\n not the overage — a successful trim reported itself as an overshoot. Now\n `\"dropped N history msg(s); final ~Xk tokens (budget ~Yk)\"`.\n- **The budget was silently unenforceable and said nothing.** Because `protected =\n system_indices | {final_user}` exempts every system block, once the system blocks alone\n exceed the budget the function strips the entire conversation and returns over budget\n anyway. Demonstrated at a 14,000-token system stack: budget 8,000 → 0 of 40 history\n messages kept, prompt still 14,003, logged as success. It now emits a WARNING (so it\n reaches `errors.log` and `/errors`) and counts a `prompt_budget` error.\n\n`CONTEXT_TOKEN_BUDGET` remains **0/off** and `.env.example` now warns against setting it\nuntil the trimmer's priority order is fixed — with all system blocks protected, a budget\nbelow the system total destroys the conversation to preserve optional blocks like a\ntriggered lorebook entry. That inversion is the next release, deliberately kept separate\nso this one is pure observation.\n\n**Pure helpers + tests:** `_msg_tokens`, `_prompt_token_total`, `_prompt_bucket`,\n`_prompt_top_blocks`. New `TestPromptTokenTotal`, `TestPromptBucket`,\n`TestPromptTopBlocks`, `TestRecordPromptSize`, `TestTrimBudgetLogging`.\n\n## v2026-07-25.2 — Garmin health feed ported onto main (GARMIN_FEED)\n\n**Root cause this release addresses:** the owner asked why the bots never bring up Garmin\ndata. They never had any. The Garmin health feed was built on\n`origin/claude/push-to-repo-7i2f3c`, and that branch **shares no git history with `main`** —\n`git merge-base main origin/claude/push-to-repo-7i2f3c` returns empty, and neither branch is\nan ancestor of the other. It's a separate lineage rooted at `76223f9 2026-04-15 \"Add files\nvia upload\"` (9,101-line `bot.py`, last touched 2026-07-04) while main's is 11,487 lines.\n`main` had **zero** references to Garmin in `bot.py`, `.env.example`, or any doc. Since every\ndeploy path (`/update`, `sync-cards.sh`, `vps-sync.sh`) pulls from `main`, no bot ever had\nthe feature. Not a prompt-attention problem — a missing-code problem.\n\nBecause the histories are unrelated, this is a **hand-port, not a cherry-pick** (a merge\nwould have dragged in a whole parallel bot.py). Each piece was rewritten against current\nmain and its API surface re-verified against the installed library: `Garmin(email,\npassword)` positional, `login(tokenstore)`, and `get_sleep_data` / `get_stats` /\n`get_activities` / `get_stress_data` all confirmed present.\n\n**What shipped**, behind `GARMIN_FEED` (default **1 = on**; `0` disables without deleting\ncredentials — the feed is additionally inert with no credentials, so unset stays a no-op):\n- **Snapshot feed.** `GARMIN_TIMES` (default 07:30,16:00) plus once at startup pulls sleep,\n resting HR, steps, Body Battery, avg stress, and last workout into one short line, cached\n to `.garmin_snapshot` and re-read after a restart. Injected as `# How {user} is doing\n physically today` and told to work it in without ever reciting numbers. Stops being\n injected past `GARMIN_MAX_AGE_HOURS` (18) so she never speaks to yesterday's body.\n- **Three proactive check-ins**, each with its own persisted cooldown so a restart can't\n re-fire one: sustained high stress (`STRESS_*`), Body Battery bottomed out (`BB_*`), and\n resting HR above the user's own rolling median baseline (`RHR_*`).\n- **`/health`, `/healthnow`, `/stress`** — registered whenever credentials exist, even when\n the kill switch is off or the library is missing, so they can explain *why* they're inert\n (an unregistered command answers nothing, which is undiagnosable from the user side).\n `/audit` gained a `Health feed (Garmin)` line reporting off / inert / snapshot staleness.\n- **Login-cooldown hardening carried over from the branch:** a failed login persists a\n `GARMIN_LOGIN_COOLDOWN` (1800s) backoff to `.garmin_cooldown`, because Garmin rate-limits\n the login endpoint and a restart loop otherwise hammers it. A client that breaks\n *mid-runtime* is dropped (`_drop_garmin_session`) so the next poll re-logs in instead of\n retrying a dead session forever.\n\n**Invariants this diff had to satisfy** (each was a real risk here):\n- **#8, no bare blocking calls in async:** garminconnect is blocking `requests` underneath.\n Every call site goes through `asyncio.to_thread`; pinned by a test that greps all four\n async entry points.\n- **#3, no new LLM calls:** the snapshot is prompt context and the check-ins reuse the\n existing `send_triggered` path. Zero completion calls added; pinned by a test.\n- **GROUP_CHAT_DESIGN.md §5:** watch metrics are private 1:1 state, so the block is gated\n `GARMIN_ENABLED and not group`, same as `user_notes` and inside jokes. Pinned by a test —\n without this, health data would be narrated to Priya and Jules's group thread.\n- **v2026-07-20.2 key-leak class:** Garmin exceptions can carry the request URL, so no log\n line interpolates the exception — only `type(e).__name__`. Pinned by a test.\n- **#15:** every numeric knob goes through `_env_int`/`_env_float`; a typo warns and falls\n back instead of bricking the instance.\n- Check-ins go through the same proactive gate as `note_followup_job` (quiet flag, away,\n quiet hours, per-chat quiet windows) and **consume the shared nudge budget** — a health\n check-in is a nudge and isn't exempt from it. Extracted as `_health_nudge_ok`.\n\n**`garminconnect` is deliberately NOT in `requirements.txt`.** Four of six instances have no\nwatch and the phone venv is shared, so a venv rebuild shouldn't pull a dep most bots never\nimport. It stays an optional try/except import, documented in `requirements.txt` and\n`.env.example` with the per-instance pip command. Credentials-set-but-library-missing is not\nsilent: it appends a `_CONFIG_WARNINGS` entry and shows as `inert` in `/audit`.\n\n**Pure helpers + tests:** `_garmin_bits` (payload → phrases, so a Garmin field rename is\ncaught by a test rather than by silence), `_stress_sustained` (skips Garmin's -1/-2\nunmeasurable markers; returns `avg=None` for \"no data\", deliberately distinct from a calm\naverage that rounds to 0), `_rhr_baseline` (excludes today so one elevated reading can't\nraise the baseline it's compared against). 45 new tests across `TestGarminBits`,\n`TestStressSustained`, `TestRhrBaseline`, `TestGarminConfig`, `TestGarminInvariants`.\n\n**Not ported from the branch** (out of scope, recorded so it isn't mistaken for an\noversight): that lineage's on-this-day reminiscing, offline life events, adaptive\ntexting-style mirroring, acoustic_ears, and `/diag`. Several overlap features main already\nsolved differently. Only the health feed was requested.\n\n## v2026-07-25.1 — Topic initiative rebalanced away from recall (PROMPT_BALANCE)\n\n**Root cause this release addresses:** owner-reported symptom was \"the bots are too focused\non bringing up memories and notes and don't bring up things to do with other features\". The\ncause is not card size and not context pressure — it's an **asymmetry in the directive text**\nof the injected blocks. Of the ~15 system blocks `assemble_messages` appends, exactly two\ncarried an explicit instruction to raise their contents — `# Things you know {user} has going\non` (\"Ask about these naturally if one fits\") and `# Open threads between you two` (\"Let them\nsurface naturally if one fits\"). Both are recall. Meanwhile the live-context blocks were\neither bare statements (`environment_note()`'s time+weather one-liner, `# {NAME}'s schedule\ntoday`) or **actively suppressed**: `# What's going on today` ended with \"don't narrate it\nlike a list\". So the only sanctioned way for the character to open a topic was to remember\nsomething, and the block describing her actual present day was the one told to stay in the\nbackground. The model was following the prompt correctly.\n\nTwo things ruled out while diagnosing, recorded so they aren't re-investigated:\n- **Card size is not a factor.** `CONTEXT_TOKEN_BUDGET` defaults to `0`, so\n `_trim_history_to_budget` returns immediately and nothing is trimmed at all. Even with a\n budget set, `protected = system_indices | {final_user}` — every injected block is exempt\n and only conversation history is dropped. A 53KB card (jules) costs recent turns, never a\n feature block.\n- This is the follow-on release v2026-07-18.1 explicitly deferred (\"Lore, `memory_block`\n facts/summaries, `user_notes`, pinned, and `day.txt` … injected wholesale (not ranked), so\n suppression there is a different mechanism — left for a future release\"). That release\n fixed *repetition of one ranked memory*; this one fixes *which category of thing she\n reaches for at all*. Different mechanism, as predicted.\n\n**What shipped**, behind `PROMPT_BALANCE` (default **1 = on**; `0` restores the previous\nprompt text byte-for-byte):\n- New pure `_initiative_note(name, uname)` → a `# Bringing things up` block appended after\n every recall block (so it frames them) and before `POST_HISTORY_RAW` (so the card keeps the\n last word on voice). It says plainly that recalling is one option among several and not the\n default, that what she's doing//what's around her/what she noticed today are equally valid\n openings, and that recalled facts are context rather than a supply of topics to draw down.\n- `user_notes` tail rewritten: still \"ask if it fits\", now explicitly \"not a checklist to work\n through and not your default way of showing you care. Most messages shouldn't touch it.\"\n- `open_threads` tail rewritten: adds \"don't reach for these just because you have nothing\n else.\"\n- `day.txt` tail rewritten: the anti-list guard is kept (it's load-bearing against recitation)\n but the block now grants initiative — \"yours to bring up unprompted, the way anyone mentions\n what they're in the middle of.\"\n\n**Deliberately unchanged:** no block was removed and none had its content narrowed.\n`user_notes` injection into every 1:1 prompt stays as-is — the 2026-07-10 audit already\nrejected \"injected into every chat's prompt\" as a defect (by design for single-owner bots).\nRanked-memory suppression (`MEMORY_REPEAT_SUPPRESS_TURNS`) is untouched and orthogonal.\n\n**Pure helper + tests:** `_initiative_note`. New `TestInitiativeNote` (names interpolated,\nstates the live-over-recall preference, no leftover \"checklist\" framing) and\n`TestPromptBalanceTails` (each rewritten tail differs from its legacy string, and the legacy\nstring is what the kill switch restores).\n\n## v2026-07-23.2\n\nAnti-hallucination: note confidence gating (article: \"Stop AI Hallucinations Before\nThey Start\"). Root cause class: notes lacked the confidence-gating defense that memories\nalready had. A plausible-but-wrong note that passed quote grounding (because the user\nhappened to mention the character's event verbatim) was stored as fact. Memories have\nhad `memory_confidence` + `MEMORY_AUTOCONF` since v2026-07-10.2; notes had no equivalent.\n\nChanges:\n1. **`user_note_confidence` field** in `post_reply_analysis` — the analysis model now\n self-reports 1-10 confidence on each note extraction, parallel to `memory_confidence`.\n2. **`NOTE_AUTOCONF` env var** (default 3, kill switch 0) — deterministic backstop rejects\n notes below the confidence threshold, even if quote-grounded. Mirrors the article's\n \"supported=true but no evidence → blocked\" pattern.\n3. **\"Null over plausible guess\" instruction** — explicit language added to the analysis\n prompt: \"When evidence is missing or ambiguous, return null. A missed real event is\n recoverable; a stored fabrication is not.\" The existing CRITICAL instruction told the\n model *what* to extract; this tells it *when not to*.\n4. **Workflow**: OPERATING_MANUAL §4 updated with explicit false-success prevention\n (\"never report success from intention, memory, or an empty tool response\") and eval\n suite extended with anti-hallucination trap evals.\n\nDeploy: `/update` one bot, `/restart` the rest, verify `/audit` shows 2026-07-23.2.\n\n## v2026-07-23.1\n\nFeature: stepped-thinking \"intent\" seed (owner request — port the idea behind the\nSillyTavern `st-stepped-thinking` extension). That extension improves replies by making\nthe model think as the character *before* answering; its native mechanism is one extra\nLLM completion per configured thinking-prompt, per message. That mechanism is a\nnon-starter here: invariant #3 forbids per-message side completion calls because six\nbots share one phone radio, and even post-VPS those calls still cost latency on the\nuser-facing reply plus money. So the idea is folded into the machinery we already have,\non **zero extra calls**:\n\n1. **bot.py — forward-looking intent, on the existing single call.** The combined\n `post_reply_analysis` pass now emits one extra JSON key, `\"intent\"`: a one-line,\n third-person \"frame of mind\" note for {{char}} going into her *next* reply (an\n emotional read / a guard / a small want). It's stored in a new ephemeral\n `next_intent` dict — NOT persisted, NOT written to any user-fact store. Provenance\n (invariant #10): intent is generated content, so it lives only where mood lives —\n injected into the next reply's system prompt, never into `user_notes`/memory. The\n next reply builder injects it right after the mood note, freshness-gated by\n `_step_intent_seed` (pure, unit-tested) so a stale seed (>`STEP_INTENT_TTL_SEC`,\n default 6h) never resurfaces. Worker→loop writes go via `call_soon_threadsafe`\n (invariant #6). Default ON with kill switch `STEP_INTENT` (owner policy 2026-07-18).\n2. **preset.txt — `[STEPPED THINKING]` block (fleet-wide, content).** A staged\n plan-then-write instruction (feel → want → write) that shapes the hidden reasoning\n the `:thinking` chat model already produces, at no cost. Mirrors the existing\n `[SELF-CHECK]` \"silently, keep it out of the reply\" framing precisely — that framing\n is the guard against the documented planning-leak class (Priya once leaked her\n planning monologue) on fallback models that don't wrap reasoning in `<think>`.\n\nTwo deploy paths: bot.py via `/update` (+ `/restart` the rest, verify `/audit` shows\n2026-07-23.1); preset.txt via `sync-cards.sh` + `/restart` each bot (fleet-wide file).\n\n## 2026-07-20 — Ops: reconcile Nora's instance dir in launch/sync scripts (no bot.py change, no version bump)\n\nRoot cause: `update-all.sh`, `watchdog.sh`, and `sync-cards.sh` all passed `$BOT_SRC`\n(`~/telegram-bot`, the shared *code* dir) as Nora's *instance* dir, but her instance dir\nis `~/nora-bot` (confirmed on-device 2026-07-11 via the STARTUP AUDIT `Instance:` line;\nrecorded in CLAUDE.md, vault/entities/nora.md, SETUP_GUIDE, OPS_MANUAL). This was the\nopen item CLAUDE.md flagged (\"verify update-all.sh matches on-device\") and never closed.\nLatent, not yet fired: `/restart` restarts the running process in place and watchdog only\nrelaunches a *down* session, so the wrong dir never executed — but the next `update-all.sh`\nfull redeploy or post-crash watchdog relaunch would have brought Nora up from `~/telegram-bot`\n(wrong `.env`/token/state), and watchdog's freeze check was already reading the wrong\n`~/telegram-bot/.alive` heartbeat. Fixed all three to use `~/nora-bot`; `$BOT_SRC` stays the\ncode dir (run-bot.sh is still invoked from it). Pinned by the new `nora-instance-dir` eval\n(break-tested red-green). Surfaced during the preset.txt deploy-path work below.\n\n## 2026-07-20 — Content: preset.txt anti-slop banlist extended, fleet-wide (no bot.py change, no version bump)\n\nCherry-picked the emotional-narration clichés from the Megumin Suite V9 banlist that our\nshared `preset.txt` didn't already cover. Root reason to record: `preset.txt`'s existing\n`[ANTI-SLOP]` list targets structural/assistant tells (\"X, not Y\" frames, rule of three,\n\"a testament to\"); Megumin's targets emotional-cliché phrasing — largely non-overlapping,\nso the additions are additive, not redundant. Added to `[ANTI-SLOP]`: three construction\nbullets (action+symbolic-meaning double, mask-drop announcements, feelings-as-machinery\nverbs) and inline phrases (\"washed over,\" \"flooded through,\" \"let out a breath she didn't\nknow she was holding,\" \"before she could stop herself,\" \"seemed to physically flinch,\"\n\"hit like a physical blow,\" \"the weight of it\" as standalone metaphor). Fleet-wide: all\nsix bots read the shared preset. Deploy: `sync-cards.sh` + `/restart` each bot. Note:\n`sync-cards.sh` previously synced only the card + seed files and never touched\n`preset.txt` — a shared fleet-wide file with no deploy path — so this change adds a\n`preset.txt` pull to it (verified via `--dry-run`). Origin: mobile Tavo port task — see\n`megumin-mobile/`; wholesale-merging the port was rejected (it duplicated ~70% of\npreset.txt and carried CYOA/\"the PC\" vocabulary that doesn't fit a texting companion).\n## 2026-07-20 — Fleet preset.txt: three refinements (no bot.py change, no version bump)\n\nFrom the preset review (`character-review/PROPOSALS-2026-07-presets.md`). All three\ntouch `preset.txt`, the shared voiceprint feeding all six bots (fleet-wide):\n1. **Anti-slop \"deliberate craft\" clause** (mined from Atelier 2.0's No-Slop): the\n banned-construction list is now explicitly \"defaults to avoid, not a checklist to\n freeze against\" — a banned move made on purpose because the voice earned it is\n craft, not slop. Prevents the hard banned-list from stiffening prose into hedged,\n generic replies.\n2. **Anti-diagnosis register rule** (mined from UnifiedWritersRoom's Reaction\n Patterns), added to EPISTEMIC HORIZON: {{char}} reads {{user}} but doesn't\n armchair-diagnose them (\"you use humor to deflect\") unless literally a therapist —\n voice a read as observation + question, not a packaged verdict.\n3. **\"Punchy\" wording fix**: TEXT DELIVERY said \"prefer shorter, punchier responses\"\n while ANTI-SLOP bans closing on a punchy one-liner; clarified to \"brevity, not a\n dramatic one-liner\" so the two sections stop contradicting.\nRoot/inbox presets needed no edits (TheAtelierV5 was already replaced; Megumin's\nArabic CoT is intentional — scoped to hidden thinking, output stays English).\nDeploy: `sync-cards.sh` + `/restart` all six bots (preset.txt is fleet-wide).\n\n## 2026-07-20 — Content: Priya geography fix + Jules seed files (no bot.py change, no version bump)\n\nFrom the first character-pass review (proposals in `character-review/PROPOSALS-2026-07.md`\non `claude/character-review`): the 2026-07 Austin→Bellevue relocation missed Priya's\napartment line — \"small Belltown apartment\" (Seattle) survived in both her description\nand the Nimbus lorebook entry, contradicting CLAUDE.md, her atlas, and her Eastside\nhabits. Moved to \"small apartment in downtown Bellevue\" in both places (+ mes_example\n\"rent in seattle\" → \"rent in bellevue\"). Also created `jules/` seed files\n(people/projects/schedule/atlas, Bellingham-grounded — she was the only character\nwithout any) and corrected the Bonnie personality-order note in CLAUDE.md + the\nedit-cards skill, which had recorded the card's section order reversed since it was\nwritten. Deploy: `sync-cards.sh` + `/restart` priya and jules.\n\n## v2026-07-20.3 — Default MAX_TOKENS 2048 → 4096 (headroom for thinking models)\n\n**Root cause:** the whole fleet runs thinking models (`glm-5:thinking` chat,\noften the same for summaries), which spend tokens reasoning *before* the answer. With\nthe default cap at 2048 an instance with no `MAX_TOKENS` line (Emily) regularly hit the\ncap mid-reasoning and returned empty content — the trigger behind the repeated\n`returned empty content, retry` / `recent fact consolidation … empty completion` lines.\nv2026-07-20.1 made that safe (no chain-of-thought leak, falls back), but the empties\nthemselves were pure waste.\n\n**Fix:** default `MAX_TOKENS` is now 4096. It's a cap, not a target — replies stay short,\nso cost is unaffected for normal turns while thinking models get room to reason and still\nanswer. Only streaming calls apply the cap; per-instance `.env` can still override (lower\nit for a non-thinking model to bound cost). `.env.example` updated. No behavior change for\nany instance that already sets `MAX_TOKENS` explicitly.\n\n## v2026-07-20.2 — WSDOT traffic errors no longer leak the AccessCode into the log\n\n**Root cause:** `_fetch_wsdot_alerts` / `_fetch_wsdot_times` logged the raw requests\nexception (`log.warning(\"… failed: %s\", e)`). WSDOT takes its API key as an `AccessCode`\nquery-string param, and a requests connection/timeout error's string contains the full\nURL — so the key landed in `errors.log` in plaintext and reached a shared paste\n(observed 2026-07-20 on Emily). This is the same class the TomTom path already fixed in\nv2026-07-11.9 (errors made key-free); the WSDOT path predated that discipline.\n\n**Fix:** new `_wsdot_err_reason(e)` classifies the exception by status/type into a short,\nkey-free reason (\"HTTP 500\" / \"timed out\" / \"network/DNS error\" / the exception class\nname) — never `str(e)`. Both WSDOT fetches log the reason instead of the raw exception,\nmirroring `_tomtom_err_reason`. Pinned by `test_wsdot_err_reason_never_leaks_key` and the\n`wsdot-key-not-logged` eval. (Owners with an exposed AccessCode should rotate it — the\nlog fix doesn't unspill what already leaked.)\n\n## v2026-07-20.1 — Reasoning models no longer leak raw chain-of-thought as the reply\n\n**Root cause:** both model-output paths fell back to `reasoning_content` when `content`\ncame back empty (`_extract_content` for non-streaming; the `reasoning_parts` join in\n`_do_request` for streaming). `reasoning_content` is raw chain-of-thought with **no\n`<think>` tags**, so `_strip_thinking` (which only removes `<think>…</think>`) can't\ntouch it — it went to the user verbatim. This fired when a reasoning model (e.g. the\ndefault `glm-5:thinking`) spent its whole token budget *thinking* and emitted an empty\n`content`: Priya replied to a normal message with her planning monologue (\"Current\nstate: … I should incorporate the Asha thing naturally … maybe\"), truncated mid-sentence\nwhere the token budget ran out. The `reasoning_content` fallback was explicitly called a\n\"leak vector\" in the 2026-07-10 audit, but that pass only stripped tool-call XML from it,\nnever plain reasoning — so the hole stayed open.\n\n**Fix:** neither path delivers `reasoning_content` anymore. Empty `content` → empty\nstring, and `call_nanogpt` now treats an empty completion like a transient miss —\nretry, then fall through to the non-thinking `FALLBACK_MODEL` (`magnum-v4-72b`), which\nreliably produces `content`. A one-line `[model] … reasoning but no content` warning\nmakes the condition visible in `/errors`. Contributing config trigger (handled\nseparately, per instance): `MAX_TOKENS` set too low for a thinking model leaves no room\nfor a final answer after the reasoning — raise it on affected instances. Pinned by\n`test_extract_content_never_returns_reasoning` and the `no-reasoning-content-leak` eval.\n\n## v2026-07-19.2 — Note ownership: her events no longer become the user's calendar\n\n**Root cause (owner-reported):** bots brought up events from *their own* fictional\nlives and then asked the owner \"how it went\" as if it were the owner's plan. Third\ngeneration of the provenance-leak class (2026-07-10 hallucinated memories,\nv2026-07-12.4 note grounding): the v2026-07-12.4 fix requires `user_note_quote` to\nbe a verbatim substring of the *user's* lines — a topic gate, not an ownership\ngate. When the character has a scrimmage Saturday and the user replies \"good luck\nat the scrimmage,\" the user's own line states the event verbatim, the note passes\ngrounding legitimately, and is stored ownerless. `note_followup_job`'s trigger then\nhard-codes ownership the wrong way (\"{user} mentioned this — ask how it went\"),\ncompleting the flip. Every guard checked whose *mouth* the words came from; none\nchecked whose *life* the event belonged to.\n\n**Fix (prompt-level, both ends of the pipe, zero new LLM calls, no new code paths):**\n- Extraction (`post_reply_analysis`): `user_note` now requires the event to be part\n of the user's OWN life; the user asking about / reacting to / wishing luck on the\n character's event is explicitly null. The CRITICAL clause adds the principle:\n ownership of the event decides, not whose message mentioned it.\n- Follow-up backstop (`note_followup_job` trigger): if a stored note actually\n describes the character's own event, she must not ask the user how it went — she\n tells them how it went for her instead. This degrades already-polluted notes\n gracefully instead of gaslighting the owner.\n- No kill switch: pure prompt-text bugfix on existing behavior — \"off\" would mean\n \"keep the bug.\" Existing polluted entries should be pruned manually via\n `/notes` + `/notes del <n>` on affected bots; the backstop covers what remains.\n\n## v2026-07-19.1 — /fleet: Telegram-native fleet console over the admin API\n\n**Root cause (a gap, not a bug):** fleet visibility required a shell.\n`fleet-status.sh` answers \"is everyone up, what version\" but only from a terminal\n— and the VPS migration is exactly when that answer is needed from a phone with\nno SSH at hand: instances now live on two hosts, and the jules pilot's failure\nmode (two hosts polling one token) is the kind of thing a glanceable per-host\nview catches early. The admin API already served all the data\n(`/admin/health`, `/admin/audit`); nothing consumed it from inside Telegram.\n\n**What shipped:**\n- `/fleet` (admin-gated, `_is_admin`): probes every peer in `FLEET_PEERS`\n concurrently (`asyncio.gather` over `asyncio.to_thread` — no bare `requests`\n in the async handler) and replies with one table: UP/DOWN, version, uptime,\n and `err:<n>` last-hour error count when the fleet shares one\n `ADMIN_API_TOKEN` (audit probe degrades to `?` on a token mismatch or older\n peer). DOWN is labeled as \"admin API unreachable\", not \"bot dead\" — the\n common case is `ADMIN_API_ENABLED` unset on a healthy bot.\n- `FLEET_PEERS=name=port,name=host:port,…` — host defaults to localhost, so\n the same config works on-phone now and across the tailnet mid-migration.\n Parsed by pure `_fleet_parse_peers`; bad entries warn via `_CONFIG_WARNINGS`\n and are skipped (the .env-typo-must-not-brick rule, v2026-07-10.2).\n- `/admin/health` now includes `instance` and `uptime_hours` (still\n unauthenticated liveness only) — `fleet-status.sh` already tried to read\n `uptime_hours` and rendered 0.0h for everyone; now it's real.\n- Kill switch `FLEET_CMD` (unset = on, `0` = off) per owner policy 2026-07-18;\n feature is inert without `FLEET_PEERS` regardless. `FLEET_TIMEOUT` (default\n 4s) bounds each probe. Group-safe automatically: commands in groups are\n default-deny (`GROUP_ALLOWED_COMMANDS`), untouched.\n\n**Tests:** `TestFleetParsePeers` (5) + `TestFleetFormat` (3) in test_pure.py.\n\n## v2026-07-18.5 — /usage speaks NanoGPT's token-based subscription shape\n\n**Root cause:** the v2026-07-18.4 self-describing error did its job — the owner's\nvery next `/usage` captured the real response. NanoGPT's subscription API is no\nlonger daily/monthly request counts; it's **token-based**: top-level per-section\nusage dicts (`weeklyInputTokens`, `dailyInputTokens`, `dailyImages`, each\n`{used, remaining, percentUsed}`) keyed identically to the `limits` dict, plus\n`period.currentPeriodEnd`. Jules's real numbers: 60M weekly input tokens limit,\n`dailyInputTokens` limit null (= uncapped), 100 daily images.\n\n**What shipped:** `_usage_summary` now recognizes the token shape first (sections\nmissing their usage dict are skipped; a null limit renders as ∞; renewal date\nappended) and falls back to the legacy daily/monthly shape, else None → the\nv2026-07-18.4 self-describing path. New pure `_fmt_count` humanizes counts\n(60000000 → 60M, 15400 → 15.4k). The captured real body is pinned in the tests\nso the next API drift fails loudly against known-good data.\n\n**Tests:** `TestUsageSummaryTokenShape` (4, incl. the real captured body) +\n`TestFmtCount` (2); legacy-shape tests unchanged and still green.\n\n## v2026-07-18.4 — /usage no longer crashes on an unexpected API response shape\n\n**Root cause:** `check_usage` trusted the NanoGPT subscription endpoint's response\nshape — it gated on `data.get(\"active\")` but then indexed `data[\"daily\"]` /\n`data[\"monthly\"]` / `data[\"limits\"]` directly. On Jules the endpoint returned\n`active` truthy **without** a `daily` key (account tier or API shape change — the\ncrash destroyed the evidence of which), so `/usage` died with\n`KeyError: 'daily'` and an `[unhandled]` traceback instead of telling anyone what\nthe API actually said. Same defect class as the streaming-error-body rule: an\nexternal response consumed without validation is undiagnosable when it changes.\n\n**What shipped:**\n- New pure `_usage_summary(data)`: validates the daily/monthly/limits shape\n (returns None on mismatch), tolerates missing inner keys with `?` placeholders.\n- `check_usage` now handles all three failure modes gracefully: non-JSON body\n (HTTP status to chat, body to log), inactive subscription (unchanged), and\n active-but-unrecognized shape — the reply names the keys the API returned and\n the full body goes to the log, so the *next* shape change is self-describing\n (debugging protocol #3) instead of a KeyError.\n- No new env vars; bugfix to an existing command, no kill switch needed.\n\n**Tests:** `TestUsageSummary` (5) — full shape, missing/wrong-typed sections,\nmissing inner keys, empty response.\n\n## v2026-07-18.3 — Social battery + minimal-reply license + day-mood residue (ROADMAP 3.7)\n\n**Root causes this release addresses (three related realism tells, one plumbing area):**\n1. Mood tracks what she feels *about* things, but nothing tracked remaining social\n capacity — a six-hour intense conversation left her exactly as available as minute\n one. Single-axis mood can't express \"great day, no energy left.\"\n2. Every incoming message earned a full-length reply — no real person pads \"k\" into a\n paragraph, but the prompt never licensed anything less.\n3. Her generated life (`day.txt`) never colored how she *opened* — mood changed ONLY\n through conversation (`post_reply_analysis` + gap decay in `nudge_mood`), so the\n flat tire in her day was invisible unless the user happened to ask\n (`REVIEW-YURALUME-2026-07-18.md`, the one adoption from that review).\n\n**What shipped (zero extra LLM calls; `FATIGUE_STATE=0` / `DAY_MOOD_RESIDUE=0` kill\nswitches, default on):**\n- **Social battery:** per-chat `fatigue` 0–100, persisted with state. Pure\n `_fatigue_update` runs inside the existing analysis worker on the valence it\n already extracts: time decay first (`FATIGUE_DECAY_PER_HOUR`, default 10), then\n |valence| ≥ 2 → +12 (intensity drains regardless of sign — a big high costs energy\n too), calm-positive → −15, else −5. Worker-thread writes go back via\n `call_soon_threadsafe`, mirroring the adjacent mood write. `[fatigue]` log line on\n threshold crossings only.\n- **Read-time decay:** `_fatigue_effective` applies passive decay at prompt-assembly\n time so a long silent gap recovers her *before* the first reply of a new\n conversation, not one reply late.\n- **Drained register:** above `FATIGUE_THRESHOLD` (default 70), one system line —\n shorter replies, less patience, winds the chat down; explicitly not\n BrainEngine's \"ego depletion\" (losing social regulation was rejected in the\n review as a liability for a long-running relationship).\n- **Minimal-reply license:** when drained, mid-busy-block (3.6), or in a low mood\n (≤ −1.2), a system line makes 'k'/'lol'/an emoji a legitimate complete reply.\n Master switch is `FATIGUE_STATE`.\n- **Day-mood residue:** the midnight day generator now ends with one\n `MOOD: <label> | <valence>` line. Pure `_split_opening_mood` peels it off BEFORE\n `day.txt` is written — the meta line never reaches prompts or memory — and seeds\n the owner's mood state (on-loop job, direct write is correct there). A model that\n ignores the instruction degrades to no residue that day. Provenance unaffected:\n mood is presentation state, not a fact store; the `[own-day]` rule is untouched.\n- `assemble_messages` now reads `schedule.txt` once per assembly (previously the 3.6\n block re-read it); busy state is shared between the license and the schedule\n section.\n\n**Config:** `FATIGUE_STATE`, `FATIGUE_THRESHOLD`, `FATIGUE_DECAY_PER_HOUR`,\n`DAY_MOOD_RESIDUE` in `.env.example`; numerics via `_env_float`.\n\n**Pure helpers + tests:** `_fatigue_update`, `_fatigue_effective`,\n`_split_opening_mood`; `TestFatigue` (8) + `TestSplitOpeningMood` (6): drain/recharge\narithmetic, clamps, gap decay ordering, read-time decay, MOOD-line parse/strip/clamp,\nmid-text immunity, graceful absence.\n\n## v2026-07-18.2 — Schedule-driven unavailability (SCHED_BUSY; ROADMAP 3.6)\n\n**Root cause this release addresses:** `schedule.txt` was injected into context every\nturn but nothing **enforced** it behaviorally — the character read as always instantly\navailable, never mid-anything, never having to leave. The always-on companion is the\nsingle biggest \"puppet\" tell (identified in `REVIEW-BRAINENGINE-2026-07-18.md`, the\none idea from that review worth its weight). Context alone doesn't shift register;\nmodels treat an injected schedule as trivia unless the prompt states what it means\n*right now*.\n\n**What shipped (zero extra LLM calls):** behind `SCHED_BUSY` (default **on**, `0`\ndisables without redeploy):\n- New pure `_parse_busy_blocks(sched_text)`: extracts `(start_min, end_min, activity)`\n from today's schedule lines carrying an **explicit** `HH:MM-HH:MM` range (hyphen/en\n dash/em dash). Deliberately conservative — loose wording (\"morning shift\", \"gym\n later\") never fires; invalid clock values and overnight ranges (end ≤ start) are\n skipped rather than guessed. `_busy_now(sched_text, now)` returns the activity the\n current time falls inside, else \"\".\n- `assemble_messages`: when mid-block, one system line after the schedule — she's\n answering from her phone in stolen moments, shorter replies, and may say she has to\n get back to it. Logs `[sched-busy] <activity>` per assembly so over-firing is\n visible in bot.log (the ROADMAP-specified tripwire).\n- Private reply path: compose delay (`_typing_delay_secs`) is multiplied by\n `SCHED_BUSY_DELAY_MULT` (default 3.0, clamped 1–10 at use) while busy. The **group\n path keeps its own timing untouched** — no group-behavior change in this release.\n- Proactive sends unchanged: quiet hours + nudge budget stay authoritative; this\n feature only adds restraint, never sends.\n\n**Config:** `SCHED_BUSY`, `SCHED_BUSY_DELAY_MULT` documented in `.env.example`.\nNumeric parsing via `_env_float` (bad values warn + fall back).\n\n**Pure helpers + tests:** `_parse_busy_blocks`, `_busy_now`; `TestBusyBlocks` (8\ntests: explicit ranges, loose-wording immunity, dash variants, overnight/invalid\nskip, boundary minutes, empty schedule).\n\n## v2026-07-18.1 — Stop memory latching (MEMORY_REPEAT_SUPPRESS_TURNS)\n\n**Root cause this release addresses:** `triggered_memories` is deterministic and\n**stateless across turns** — every reply it re-scores all of `memories.txt` against the\nrecent conversation (keyword overlap + cosine ×3.0), sorts, and greedily fills the\n300-token budget. Nothing recorded which memories were injected on prior turns, so while\na conversation stayed on one theme the *same* top-scoring lines won the budget every\nsingle turn and the character re-told one memory endlessly, reworded each time. It's a\nfeedback loop: the memory she mentions lands in the recent-history scan text, which\nre-ranks it to the top again next turn. The repo already solved this exact shape for\n*questions* (`_recent_questions` → \"don't repeat these\") but had no equivalent for\nmemories; write-time dedup (`MEMORY_DEDUP_SIM`) only stops *storing* duplicates, not\nre-injecting the same stored line.\n\n**What shipped:** behind `MEMORY_REPEAT_SUPPRESS_TURNS` (default **6 = on**; set 0 to\ndisable — the first release under the new owner default-on policy, see below), per-chat\nin-memory tracking of recently-injected memory lines, consumed as a score multiplier in\n`triggered_memories`:\n- New pure `_repeat_penalty(last_turn, current_turn, window, floor)`: full penalty\n (`MEMORY_REPEAT_PENALTY`, default 0.15) the turn right after a line is injected, fading\n linearly back to 1.0 over `window` turns. A **multiplier, never exclusion** — a memory\n the user directly asks about still outscores the penalty and surfaces.\n- `triggered_memories` gains `chat_id=None`. With a `chat_id` (the live reply path) and\n the flag on, it increments a per-chat turn counter, down-weights recently-seen lines,\n and records the winners. `chat_id=None` (e.g. `/recall`) and the flag off are both\n byte-identical to old behavior, so existing callers/tests are untouched.\n- Trackers (`_mem_inject_turn`, `_mem_last_injected`) are **in-memory only**, matching\n `_recent_questions` — no state-serialization changes, no cross-thread concerns; a\n restart just clears suppression (worst case: one repeated theme after a restart).\n- A gated one-liner is appended to the `# Relevant memories` block telling the character\n not to re-raise a memory she's referenced recently unless the user brings it up. The\n kill switch (0) restores the exact old prompt.\n\n**Policy change (owner, 2026-07-18):** new features now default **ON** with a mandatory\nenv kill switch (unset = active, `0` = off), reversing the prior default-off convention.\nThe kill switch is now the required safety mechanism rather than the off-by-default state.\nRecorded in `CLAUDE.md`, `bot-code-invariants` #16, and `repo-change-control`. This\nrelease is the first under it — hence `MEMORY_REPEAT_SUPPRESS_TURNS=6` by default.\n\n**Preset (`preset.txt`, ships alongside; content, no BOT_VERSION dependency):** added a\ncontrastive `[ANTI-ECHO / NO REHASH]` section and inline bad→good example pairs under\nseveral existing abstract rules (narrated emotion, narrator tipping off lies, generic\nvoice, repeated sensory detail, NPC servility) — bad→good pairs steer models harder than\nabstract prose. Deploys via the card/seed path (`sync-cards.sh` / curl into instance\ndirs), not `/update`.\n\n**Deliberately out of scope (so it isn't re-litigated):**\n- `MEMORY_DECAY_HALFLIFE_DAYS` (default 0) remains a config-only, orthogonal mitigation\n for *old* memories dominating — it does nothing about within-conversation latching,\n which is what this release fixes.\n- Lore, `memory_block` facts/summaries, `user_notes`, pinned, and `day.txt` can also\n carry a latching theme but are injected wholesale (not ranked), so suppression there is\n a different mechanism — left for a future release to keep this diff minimal.\n\n**Pure helper + tests:** `_repeat_penalty`. New `TestRepeatPenalty` and\n`TestTriggeredMemoriesRepeatSuppression` (suppression rotates the winner, fades after the\nwindow, kill switch and `chat_id=None` preserve old behavior, per-chat isolation).\n\n## v2026-07-17.1 — Generalized map intent (MAP_INTENT; ROADMAP 3.5 phase 2)\n\n**Root cause this release addresses:** FOOD_SUGGESTIONS (v2026-07-11.14) proved that\npre-fetching real TomTom data into the single reply stops the character inventing\nplaces — but only for food. Asked \"how far is bellevue square\" or \"is there a\npharmacy nearby\", she still answered from imagination: fabricated minutes, distances,\nand place names, exactly the hallucination class the food path closed.\n\n**What shipped:** behind `MAP_INTENT=1` (default off; unset = prior behavior), an\nexplicit map-shaped ask in a 1:1 chat pre-fetches real data and injects it as the\nsame one-turn bracketed note the food path uses, riding the single existing reply:\n- **Route asks** (\"how do I get to X\", \"how far is (it to) X\", \"directions to X\",\n \"how long to bike/drive/walk to X\", \"what's the commute to X\"): geocode the\n destination, route from the user's stored location with the instance's\n `TOMTOM_TRAVEL_MODE`, inject a plain `_route_brief` (\"drive to X: 18 min, 7.9 mi\n (incl. +4 min traffic)\") with use-ONLY-these-facts phrasing. Max 2 REST calls.\n- **Nearby asks** (\"is there a <thing> nearby\", \"any <thing> around here\",\n \"closest/nearest <thing>\"): POI search around the user's location (5 km), injected\n via `_places_brief` (`_restaurants_brief` generalized; the old name delegates so the\n food path and its tests are untouched). 1 REST call.\n\n**Design decisions (why, so they aren't re-litigated):**\n- **Keyword regex intent, not an LLM classifier** — intent runs on every 1:1 message\n and a per-message LLM side call is banned (bot-code-invariants #3, same reason\n `_is_food_query` is a regex). The negative space is test-pinned: \"how do I get to\n sleep\", \"how far is too far\", \"closest thing to heaven\" etc. must never fire; a\n destination stoplist (`_MAP_DEST_REJECT`) catches figurative objects, \\b-anchored\n so real places (\"Knoxville\") survive. Misses on creative phrasings are accepted v1\n cost, same as food.\n- **Location freshness gate** (`_fresh_location`): route origins and nearby centers\n use the stored location only when <4h old (the photo path's precedent) or inside a\n live-share window — a route from last week's pin would be confidently wrong.\n Without one she nudges for a pin instead of guessing (food-path pattern).\n- **Un-geocodable destinations fail honestly:** \"home\"/\"work\" geocode literally,\n usually miss, and inject a \"you couldn't find it — say so, don't invent\" note.\n Resolving them from her memory of the user is a deliberate follow-up\n (owner-settled 2026-07-17), as is \"what's near <remote place>\" — v1 nearby is\n user-location-only.\n- **No cooldown/cache** (owner-approved): the precise intent gate is the budget\n control, matching how FOOD_SUGGESTIONS shipped; a `[map] intent=...` log line\n instruments the fire rate. If fleet logs show over-firing, a per-chat cooldown is\n the pre-agreed follow-up.\n- Food wins when both flags fire (elif chain) — at most one injection + one TomTom\n fetch sequence per message. Group chats never reach the block (it lives in the 1:1\n `handle_message` path); `_TomTomError` anywhere degrades silently to a normal\n reply; error logging stays key-free via the existing `_tomtom_err_reason` path.\n\n**Pure helpers + tests:** `_map_intent`, `_clean_map_dest`, `_route_brief`,\n`_places_brief`, `_fresh_location`. 17 new tests; `TestRestaurantsBrief` passes\nunmodified against the delegate, proving the refactor is behavior-preserving.\n\n## v2026-07-13.2 — Error hygiene + --check-config preflight (R2+R3, operability)\n\nTwo small items from the same hardening plan as v2026-07-13.1, shipped together.\n\n**R2 — raw exceptions no longer reach chat.** Eight handler sites sent\n`❌ Something went wrong: {e}` (and variants) and the global `on_error` sent\n`{type(err).__name__}: {err}` to whatever chat triggered the failure. That leaks\ninternals (paths, library errors, provider details) and — the sharp edge — `on_error`\nwould post them into a *group* chat, i.e. to every human in the pilot group. All\nuser-facing errors are now a fixed generic line pointing at `/errors` (admin-gated,\nwhere the detail already lives via the existing `log.error` calls; the one site that\ndidn't log, the menu heartbeat button, now does). `on_error` additionally goes silent\nin group chats. Kept as-is: human-authored messages (\"file's too big\"), and the JSON\nupload parse error now shows only the structured `e.msg`/`e.lineno` from\nJSONDecodeError — that's about the user's own file, not internals. Eval-pinned\n(`no-exception-leak`).\n\n**R3 — `python bot.py <dir> --check-config`.** No-network preflight for standing up\nan instance: token shape, timezone actually resolving (`BOT_TIMEZONE` set but\n`ZoneInfo` failing = missing tzdata, the silent naive-time failure mode), card\nloaded, instance dir writable, every present state file parses as JSON (a corrupt\none means silent empty-state startup — restore from backup first), owner claimed or\nnot, ALLOWED_USERS empty or not, models set. Exit 0 = ready to launch. Missing\ntoken/key/card already hard-fail at import with actionable messages, so the check\ncovers the failures that were previously only discoverable at 3am.\n\n## v2026-07-13.1 — Ownership hardening: claim-once owner + private-chat gate (R1, security)\n\n**Root cause (found while fact-checking an external review, confirmed in code):**\ntwo authorization gaps, one severe.\n\n1. **Ownership takeover via /start.** `set_owner` refused group ids but had no\n \"already claimed\" guard — it rewrote `owner_chat.txt` on every call whenever\n `OWNER_CHAT_ID` was unset. Six of its seven call sites wrapped it in\n `if get_owner() is None:`; the `/start` handler called it bare. Telegram bots are\n publicly addressable by username, so any stranger who found the bot and sent\n `/start` silently became the owner — heartbeats, note follow-ups, and every\n proactive message redirected to them, and the real owner got no signal.\n2. **ALLOWED_USERS only half-enforced.** `_is_allowed` was a per-handler check that\n drifted: media/text handlers had it, `/start` and most of the ~80 commands never\n got it. With an allowlist configured, a stranger couldn't send a photo but could\n run `/notes`, `/mems`, `/settings` — and `/start`.\n\n**Fixes (both eval-pinned, mirroring how the group boundary is protected):**\n- `set_owner` claims once: first contact binds ownership, chat traffic can never\n reassign it. Deliberate transfer = edit `owner_chat.txt` on-device or set\n `OWNER_CHAT_ID` (env stays authoritative). Behavior change to know about: sending\n /start from a NEW chat no longer moves ownership there.\n- `_private_gate`, a sibling of `group_guard` in handler group -1: when\n ALLOWED_USERS is set, private-chat updates from anyone else stop at one choke\n point — commands, messages, media, callbacks, and any future update type — instead\n of relying on per-handler checks (which is exactly how the gap formed). The owner\n always passes even if left out of ALLOWED_USERS (locking the owner out of their\n own fleet is worse than redundancy). Empty ALLOWED_USERS = today's open behavior;\n group chats are untouched (group_guard's jurisdiction, GROUP_CHAT_DESIGN.md).\n Existing per-handler checks stay as defense in depth.\n\nNew evals: `owner-claim-once` (set_owner keeps its guard) and\n`private-gate-registered` (the gate stays wired in group -1).\n\n## v2026-07-12.4 — User-note quality: grounding, debris sanitation, real dedup, stale-note expiry\n\n**Root cause (owner's live user_notes.txt, reviewed 2026-07-12):** a 15-note file\nwhere ~12 entries were garbage, four distinct failure modes visible at once:\n\n1. **Character fiction stored as user facts** (\"has a husband trapped in the bedroom\n who is confused by her performance\", her own banana bread as *his* baking). The\n extraction prompt forbids this, but during roleplay scenes the analysis model does\n it anyway. This is the exact failure class as the 2026-07-10 hallucinated-memories\n bug — and the memory path got quote-grounding as its fix while **the notes path had\n no grounding check at all**.\n2. **JSON debris in note text**: \"mentions his upcoming DOR audit tour… (valence\n null)\" — the model leaks schema fragments into the note string. Worse than\n cosmetic: a model-emitted \"(due …)\" would reach the follow-up parser without\n passing the caller's date validation.\n3. **Dedup misses obvious duplicates**: \"has a call with Yuen in eight minutes\" and\n \"has a 2pm call with Yuen\" were both stored — the 20-char-prefix check can't see\n past a differing sentence opening.\n4. **Retired notes never leave**: \"(asked …)\" lines linger in the prompt block every\n reply until the 15-note cap happens to evict them.\n\n**Fixes (each with its own kill switch, unset = on):**\n- `user_note_quote` added to the combined analysis JSON (zero new LLM calls); the\n note is rejected unless the quote is a verbatim substring of the user's own lines\n (reuses `_quote_grounded`). Rejections logged as `note_ungrounded`. `NOTE_GROUNDED=0`\n restores old behavior. Prompt also now demands ONE event per note (the \"walk thing +\n Yuen tab\" mashup).\n- `_sanitize_note` strips machinery-shaped parentheticals — `(due|every|asked|noted|\n valence|mood|confidence …)` — from model-supplied note text before markers are\n appended, so stored markers are only ever written by us.\n- `_note_is_dup`: legacy prefix check plus word-containment (shared tokens / smaller\n set ≥ `NOTE_DEDUP_SIM`, default 0.8; 0 = prefix-only).\n- `note_followup_job` prunes `(asked …)` notes older than `NOTE_ASKED_TTL_DAYS`\n (default 7; 0 = keep forever) in its daily pass.\n\n## v2026-07-12.3 — Recurring events from conversation actually recur (and stay in character)\n\n**Root cause (owner-reported):** \"recurring event reminders added via conversation get\nlogged but never come up naturally — just a regular reminder message.\" Recurring events\nfell into a gap between three disconnected subsystems. (1) The conversation-capture\npath (`post_reply_analysis`) only had a single `user_note_date` — \"practice every\nThursday\" captured at most the first Thursday, and after `note_followup_job` asked\nabout it once, the line was rewritten to `(asked …)` and retired forever. (2) The only\nrecurring machinery, `/setreminder`, delivers through `fire_reminder`'s literal\n`⏰ Reminder: <text>` — mechanical, no character voice, no LLM involved. (3) The\nnatural-delivery machinery (`send_triggered`, used by cron jobs and note follow-ups)\nexisted but nothing recurring was ever wired into it.\n\n**Fix — wire the existing pieces together, per the owner's chosen split:**\n- `post_reply_analysis` gains a `user_note_recurring` JSON key in the same combined\n call (zero new LLM calls, per the invariant): `weekly:<day>` / `monthly:<1-31>` /\n `yearly:<MM-DD>`, null for one-offs and vague cadences.\n- Recurring notes are stored as `<note> (every <rule>) (due <date>)` in\n user_notes.txt; if the model gave a rule but no date, the first due anchor is the\n next occurrence. Rules are validated by `_parse_recurrence` — anything garbled\n degrades to a one-off note rather than crashing the pass.\n- `note_followup_job` (already natural, in-character, quiet-hours- and\n nudge-budget-aware) now rolls a recurring note's due date forward via\n `_next_recurrence` after asking, instead of retiring it. Next occurrence is\n computed from *today*, not the stored due date, so a note overdue by weeks (phone\n off) can't roll to a past date and refire daily while catching up. Overflow days\n clamp (monthly:31 fires Apr 30).\n- **Deliberately NOT changed:** `/remindme` and `/setreminder` keep the mechanical\n `⏰` format — owner-confirmed split. Hard reminders (meds, timers) should be\n unambiguous and never lost in character flavor; only conversation-captured events\n go the natural route.\n- Kill switch `NOTE_RECURRING=0` (default on — owner requested the behavior;\n disabling restores exact previous behavior: capture as one-off, `(asked …)` retire).\n Cancel a recurring note anytime with `/notes del <n>`.\n\n## v2026-07-12.2 — Embeddings that actually recall: live semantic memory + lore, semantic dedup, eviction & provenance fixes\n\n**Root cause (the headline):** every memory write embeds the line via a blocking\nNanoGPT `/embeddings` call, but **those vectors were never read during a live reply.**\n`triggered_memories` skipped semantic scoring whenever it ran on the event loop (a\nguard added so the 30s blocking embed couldn't freeze the loop), and `assemble_messages`\nalways runs on the loop — so semantic recall only ever fired for the manual `/recall`\nand `[memcheck:]` commands. Normal chat was keyword-only and the `*3.0` semantic\nweight was dead code. We paid the write cost and got almost none of the read benefit.\n\n**Live semantic recall (`MEMORY_SEMANTIC_LIVE`, default on):** the blocking query\nembed is now hoisted into the async handler via a new `assemble_messages_async`\nwrapper — `_embed_query_cached` runs `_embed_text` in `asyncio.to_thread` bounded by\n`MEMORY_QUERY_EMBED_TIMEOUT` (3s), with a small LRU so repeated openers don't\nre-embed. The vector is threaded through `assemble_messages` → `triggered_memories`,\nwhich now ranks with a pure-cosine `_semantic_recall_vec` (no HTTP, no event-loop\nskip). Keyword scoring, recency decay, hedging, and the token budget are unchanged —\nthey finally operate on a real semantic candidate set. On timeout/failure/disable it\ndegrades to exactly today's keyword-only behavior.\n\n*Deliberate, recorded decision:* this adds **one embedding round-trip per reply** — a\nper-message side call, which the bot-code-invariants caution against for phone\nbandwidth. The owner accepted it explicitly: an embedding is a tiny, fast request (not\na 150s chat completion), it is cached + timeout-bounded + off-loop, and it has a\ndefault-on kill switch (`MEMORY_SEMANTIC_LIVE=0`). It is NOT a new LLM analysis call,\nso the \"one combined `post_reply_analysis` call\" invariant is untouched. Logged in\n`.claude/memory/operational-log.md` so it isn't later flagged as a regression.\n\n**Semantic lorebook matching:** the same per-reply vector now also powers\n`triggered_lore` — non-constant lore entries are embedded once into\n`lore_embeddings.json` by a startup job (`_embed_lore_job`, off-loop), and up to\n`MEMORY_LORE_SEMANTIC_TOPK` (3) semantically-close entries above the 0.3 floor are\nadded to the keyword hits. A user paraphrasing a topic without hitting a lore keyword\nnow surfaces the relevant lore. No extra call — reuses the memory query vector.\n\n**Semantic write-dedup (`MEMORY_DEDUP_SIM`, 0.92):** the lexical dedup in\n`_append_memory` missed reworded duplicates (\"lives in Seattle\" → \"moved to\nPortland\"). The auto path now embeds the entry once (the embed `_memory_replace`\nwould do anyway — passed through via `precomputed_vec`, so it is not embedded twice)\nand skips it if `_is_semantic_dup` finds a near-duplicate already stored. Manual adds\nand audit-merges are intentional and bypass it.\n\n**Sidecar orphan leak fixed + confidence-aware eviction:** `MEMORIES_MAX` overflow\nused a FIFO slice that dropped lines but **never popped their `memory_meta.json` /\n`embeddings.json` entries** — both sidecars grew unbounded and stale meta could shadow\na new identical line. New pure `_evict_by_value` drops the lowest-value entries\n(confidence, ties broken by oldest ts; legacy no-meta = neutral 5) and the caller pops\nevery evicted key from both sidecars — so a hand-corrected conf-10 fact now outlives a\ntrivial conf-3 one, and R1's \"three files stay in sync\" holds on the eviction path too.\n\n**Provenance shown to the model:** `_hedge_memory_lines` now appends the recorded\nsource snippet to hedged (low-confidence) memories — `(unsure) <line> [you recall this\nfrom: \"<source>\"]` — so the character can self-check a shaky memory against the\nsentence that created it, instead of provenance being admin-only (`/sourcemem`).\n\n27 new tests (semantic recall vec, semantic dedup, value eviction, lore semantic hits,\nprovenance hedge, query-embed cache, and a live-path regression proving semantic recall\nnow returns a hit that shares no keywords with the query).\n\n## v2026-07-12.1 — Memory loops: weekly audit → review queue, recency decay, confidence hedging\n\n**Root cause (all three, one theme):** the memory system had a write path with\nprovenance (R1) but no *loop* — nothing ever went back over what was stored.\nmemories.txt was append-only apart from manual `/delmem`/`/editmem`: contradictions\nand superseded facts accumulated until the count cap evicted something arbitrary.\nRecall ranked purely by keyword hits + cosine similarity, so a 6-month-old one-off\noutranked yesterday's correction if it shared a keyword. And a low-confidence memory\nthe owner approved from the review queue was asserted at recall with the same\ncertainty as a conf-10 quote-grounded fact — the meta confidence existed but was\nnever consulted at injection.\n\n**Weekly memory audit (`MEMORY_AUDIT`, off by default):** `memory_audit_job` rides\nthe nightly `reflection_job` and fires once a week (`MEMORY_AUDIT_WEEKDAY`): one\nbatched `SUMMARY_MODEL` call over memories.txt (+ age/conf annotations from\nmemory_meta) proposing at most `MEMORY_AUDIT_MAX_PROPOSALS` contradiction/\nsuperseded/stale findings. Proposals land in the EXISTING `memory_review.json` →\n`/reviewmem` flow as `kind: \"audit\"` items — the bot never deletes or merges on its\nown. Approving applies delete/merge through `_memory_replace` (meta+embeddings stay\nin sync; merges get `origin: audit-merge`, min-of-parents confidence, and a\n`merged: a | b` source trail for `/sourcemem`); a target edited since the proposal\naborts safely (\"memory changed since proposed\"). Rejecting records an\norder-insensitive pair key in `memory_audit_seen.json` so a declined proposal never\nreturns; queue-cap overflow just re-proposes next week (never evicts pending items).\n\n**Recency decay (`MEMORY_DECAY_HALFLIFE_DAYS`, off by default, recommend 90):**\n`triggered_memories` now multiplies the merged keyword+semantic score by\n`_recency_weight` — exponential half-life on the memory_meta timestamp, floored at\n0.1 so old memories fade in the ranking but never disappear from it. Entries with\nno recorded ts (legacy, pre-meta) stay at neutral 1.0 — never punished.\n\n**Confidence hedging (`MEMORY_HEDGE`, off by default):** at injection,\n`_hedge_memory_lines` prefixes `(unsure)` on recalled memories whose meta\nconfidence is below `MEMORY_AUTOCONF` and appends one instruction line telling the\ncharacter to hedge rather than assert. Display-time only; the stored line is\nuntouched.\n\n**Invariant compliance:** zero new per-message LLM calls (the audit is a weekly\nscheduled call under `_SUMMARIZE_SEM`, via `asyncio.to_thread`); all mutations go\nthrough the `_memory_replace` choke point behind the owner's explicit `/reviewmem\nok`; audit-merged text is labeled provenance (origin + source), honoring the\ngenerated-content rule. Also fixed in passing: `/reviewmem ok` called\n`_append_memory` directly on the event loop — `_memory_replace → _embed_memory_line`\nmakes a blocking HTTP call (up to 30s stall); now wrapped in `asyncio.to_thread`.\n\n34 new tests (recency weight, hedging, audit parse/dedup/queue/apply).\n\n## 2026-07-12 — Ops: eval-fix retry loop (Stop hook) + improvement Routine activated\n\nNot a bot release (bot.py untouched). Two workflow loops from the AI-loops gap\nanalysis, `.claude/` only:\n\n**Eval-fix retry loop (`.claude/hooks/eval-gate.sh`, new Stop hook):** the evals\ncould fail with nothing feeding the failure back — the delivery gate blocks once,\nCI blocks on push, but no mechanism re-presented the failing output for another fix\nround; a session could end its turn with red evals it caused. The new hook runs\n`run-evals.sh` (+ pytest when evals are green) whenever the session has uncommitted\nchanges to gated surfaces, and on failure blocks turn-end with the failing lines: 3\nbounded fix rounds, then one escalation block (summarize for the owner, never edit\nan eval to pass), then it stands aside so the turn can always end. Counter in\ngitignored `.claude/.runtime/`, keyed by session id.\n\n**Improvement Routine activated:** CLAUDE.md described a \"monthly Routine\" that had\nnever actually been scheduled — the improvement loop existed only as prose. Created\n`improvement-loop-monthly` (cron `0 9 1 * *`, fresh session per firing) and recorded\nits schedule + verbatim prompt in `.claude/operating/routines.md` with a sync rule so\nthe live trigger can't silently drift from the repo's record.\n\n## v2026-07-11.15 — Two niggles: command menu completeness + restart-storm false alarm\n\n**Command autocomplete menu (`set_my_commands`):** the hand-kept menu list had drifted\nfrom the actual handler registrations — the maps commands (`/route /nearby /place\n/food`) and traffic commands (`/traffic /incidents`) were registered but absent from\nthe menu, so they didn't autocomplete. Added them via a testable `_build_command_menu`:\nmaps always (those handlers are unconditional), traffic only when `WSDOT_API_KEY` is\nset (mirrors registration), payments as before.\n\n**Restart-storm false alarm:** `_self_audit`'s \"restarted Nx in the last hour —\nsomething is killing the process\" counted every `STARTUP AUDIT`, including the owner's\nown `/restart` and `/update`. During ordinary maintenance (like this session's rapid\ndeploys) that tripped a false alarm. `_tally_unexpected_restarts` (pure, tested) now\nskips any start preceded by a `[restart] requested` or `[update] …; restarting` marker,\nso only real crashes/kills (SIGKILL, watchdog, battery manager) count — a graceful-stop\nwith no such marker (a battery-manager SIGTERM) still counts.\n\n8 new tests.\n\n## v2026-07-11.14 — In-character restaurant recs (release B; FOOD_SUGGESTIONS)\n\n**What this adds (the \"they recommend\" layer):** with `FOOD_SUGGESTIONS=1` (+ a\nTomTom key), when the user sends a food-ish message and has shared their location,\n`handle_message` pre-fetches real nearby restaurants and appends them to the user\ncontent as a bracketed note before `assemble_messages`. The character then\nrecommends *those real places in her own voice* — no list, no command.\n\n**Invariant compliance (bot-code-invariants):** this rides the **single** existing\nreply call — NO new per-message LLM call (#3). The TomTom fetch is off the event\nloop via `asyncio.to_thread` (#8) and wrapped so a failure degrades to a normal\nreply. It reuses the exact one-turn `[Note: …]` injection pattern the gap-aware and\nlull-detection notes already use. Default off (#16): unset = today's behavior.\n\n**Anti-hallucination:** the injected note says use ONLY the listed places and don't\ninvent restaurants. If no location is shared, a different note tells her to ask the\nuser to drop a pin rather than name places she can't verify.\n\n**Pure helpers + tests:** `_is_food_query` (keyword heuristic, v1), `_restaurants_brief`\n(plain prompt-format lines). 6 new tests. Trigger is keyword-based for v1 — it will\nmiss creative phrasings; broadening it is a future tweak.\n\n## v2026-07-11.13 — /food: nearby restaurant recommendations (release A of 2)\n\n**What this adds:** `/food [cuisine]` uses the user's shared GPS location to list real\nnearby restaurants (name · cuisine · distance, nearest first). `/food` alone lists\nrestaurants generally; `/food thai` filters by cuisine. Registered unconditionally\nlike the other maps commands (replies \"Maps aren't set up\" without a key); requires a\nshared location. Reuses `_fetch_tomtom_search` (5 km bias). New pure helpers\n`_poi_cuisine` + `_format_restaurants`, 7 tests.\n\n**Deliberately out of v1:** \"open now\". It needs opening-hours parsing + local-time\ncomparison, and this project has a scar from tz-aware-vs-naive `datetime` (fleet\nstartup crash, v2026-07-05.5) — shipping it correct is a fast follow-up, not a guess.\n\n**This is release A of the owner's \"both\" choice.** Release B (ROADMAP 3.5) is the\nin-character layer: when a location is shared and the user asks something food-ish,\npre-fetch nearby restaurants and inject them into the *single* reply so the character\nrecommends in her own voice — no extra per-message LLM call.\n\n## v2026-07-11.12 — /update cache-busts GitHub's raw CDN\n\n**Root cause this release addresses:** `/update` fetches `main/bot.py` from\n`raw.githubusercontent.com`, which Fastly caches for ~5 min. Running `/update` right\nafter a push repeatedly fetched the stale prior version, matched it against the\nrunning version, and reported \"already current\" — the deploy appeared stuck (cost\nreal time across this session's rapid releases).\n\n**Fix:** `perform_self_update` now requests the raw URL with a unique `_cb=<ms>` query\nparam (Fastly keys its cache on the full URL, so a new param = cache miss = fresh\nfetch) plus `Cache-Control: no-cache` / `Pragma: no-cache` headers. Verified the raw\nhost still serves the file with an arbitrary query param. `update-all.sh` (the shell\ndeploy path) still hits the plain URL — left as a follow-up.\n\n## v2026-07-11.11 — TomTom routing: routeType \"fastest\" (REST spelling, not MCP \"fast\")\n\n**Root cause this release addresses:** the route call sent `routeType=fast` →\nTomTom \"HTTP 400 — Invalid route type: [fast]\" (surfaced by v.10's error-body\nplumbing). `fast` is the *MCP tool's* parameter name; the raw `api.tomtom.com`\nRouting REST API uses **`fastest`**. Same MCP-names-≠-REST-names trap that the\nGeoJSON-vs-native response shape hit earlier — copying an MCP param value into the\nREST call. Fixed to `fastest`; a test pins the REST spelling so it can't regress.\n\n## v2026-07-11.10 — TomTom routing: no traffic= for bike/pedestrian; surface 4xx body\n\n**Root cause this release addresses:** `/route` sent `traffic=true` on *every* route,\nbut TomTom's Routing API only accepts that parameter for motorized modes — so a\nbicycle (Nora) or pedestrian (Priya) route came back **HTTP 400**. And v.9's key\nredaction had over-corrected: it dropped TomTom's error *body*, so the 400 surfaced\nas a bare \"HTTP 400\" with no reason.\n\n**Fixes:**\n- `_tomtom_route_params(mode)` adds `traffic=true` only for `_TOMTOM_TRAFFIC_MODES`\n (car/truck/taxi/bus/van/motorcycle); bicycle/pedestrian omit it. Fixes the 400 for\n Nora and Priya.\n- `_tomtom_err_detail(resp)` extracts TomTom's human error message from the response\n body (`detailedError.message` / `error.description` / `message`) and appends it to\n HTTP-error reasons, so a 400 now reads e.g. \"HTTP 400 — <TomTom's reason>\". The body\n is key-free (the key is only ever in the query string, which we never log); a guard\n drops anything containing a `key=` token just in case. 6 new tests.\n\n## v2026-07-11.9 — TomTom: honest error messages + key never logged\n\n**Root cause this release addresses (both found during on-device rollout):**\n1. The fetch helpers returned the *same* empty result for a genuine \"not found\" and\n for a network/HTTP failure, so `/route` reported \"Couldn't find Bellevue\" when the\n real cause was a **401 Unauthorized** (a placeholder key had been pasted into\n `.env`). The misleading message cost several debugging round-trips.\n2. On failure the helpers logged `str(exception)`, and `requests` puts the API key in\n the query string — so the **full key was written to `bot.log`/`errors.log`** (and\n thus into any backup or pasted log). A public-repo fleet must never log secrets.\n\n**Fixes:**\n- New `_TomTomError` carries a short, **key-free** reason. Fetch helpers now raise it\n on a network/HTTP failure and return empty only for a genuine miss. Handlers reply\n \"Maps lookup failed: HTTP 401 — key rejected …\" / \"rate limited\" / \"timed out\" /\n \"network/DNS error\" instead of a misleading \"Couldn't find X\".\n- `_tomtom_err_reason()` classifies the exception from `response.status_code` / type\n name and never includes the URL or key; the log line prints only that reason. 7 new\n tests, incl. one asserting the reason never contains `tomtom.com` or `key=`.\n\n**Doc fix (confirmed on-device):** CLAUDE.md's instance table listed Nora's directory\nas `~/telegram-bot/`, but her running instance is `~/nora-bot/` (the STARTUP AUDIT\n`Instance:` line is authoritative). Corrected the table and `vault/entities/nora.md`.\n\n## v2026-07-11.8 — TomTom observability: unsilence disabled state + audit visibility\n\n**Root cause this release addresses:** v.7 registered `/route /nearby /place` only\nwhen `TOMTOM_API_KEY` was present (the WSDOT pattern). When the key wasn't loaded,\nthe commands weren't registered, so Telegram returned **no reply at all** — an\nundiagnosable silence. During rollout this made \"is the key actually loaded?\"\nimpossible to answer from the user side (the bot exposed no TomTom state anywhere),\nwhich cost several debugging round-trips.\n\n**Fixes (observability, no behavior change when a key is set):**\n- `/route /nearby /place` are now registered **unconditionally**; with no key they\n reply \"Maps aren't set up (TOMTOM_API_KEY missing).\" instead of going silent.\n- The `STARTUP AUDIT` log line now includes `Maps: <mode>|off`, so a restart shows\n whether the running process actually loaded the key (and which travel mode).\n- `/audit` and `gather_audit_data()` now report `Maps (TomTom): <mode>|off`.\n\nThis is the repo's \"opaque error → instrument first\" rule applied: make the failure\nself-describing rather than guess at it from outside.\n\n## v2026-07-11.7 — TomTom Maps: /route, /nearby, /place (gated, default off)\n\n**Root cause this release addresses:** three characters are grounded in real\ngeography (Nora bikes Seattle, Emily does western-WA traffic, Priya references real\nBellevue/Eastside places) but the bot had no way to answer routing, travel-time, or\n\"what's near me\" questions with real data — only WSDOT incident feeds for Emily.\n\n**What shipped (slash commands, phase 1 of 2):** three user-initiated commands,\nregistered only when `TOMTOM_API_KEY` is set (fail-closed, same gate shape as WSDOT):\n- `/route <from> to <dest>` — geocodes both endpoints, then a traffic-aware TomTom\n route; reports ETA + distance (+ traffic delay when ≥1 min). Travel mode is\n per-instance via `TOMTOM_TRAVEL_MODE` (validated; bad value warns → car).\n- `/nearby <thing>` — POI search around the user's shared location, distance-sorted.\n- `/place <name>` — geocode/business lookup, location-biased when a location is shared.\n\n**Architecture notes:** bot.py calls the raw `api.tomtom.com` REST endpoints (native\nJSON), not the GeoJSON the Claude MCP connector returns — so each bot needs its own\nkey (documented in `.env.example`). All parsers are defensive/total (deep `.get()`\nchains; a response-shape change degrades to a message, never a crash), mirroring the\nWSDOT integration's discipline. Network fetches run via `asyncio.to_thread`; no new\nper-message LLM calls, no new processes. Pure parsers/formatters covered by 25 new\ntests; live REST round-trip is verified on-device (needs a real key).\n\n**Deferred to phase 2 (owner-approved \"both, slash first\"):** an in-character layer\nthat lets Nora/Emily/Priya weave map data into conversation via a taught intent tag\n(like `[search:]`), rather than only explicit commands. Tracked in ROADMAP 3.5.\n\n## v2026-07-11.6 — R6 evolution experiments (all gated, default off)\n\n**Root cause this release addresses:** the bot had no mechanism for users to\nsignal approval/disapproval of individual messages without typing, no derived\nmeasure of relationship depth to modulate system behavior, `next_goals` was a\nsingle string that couldn't track parallel conversation threads, and humorous\ncallbacks couldn't be surfaced for curation without a dedicated LLM call.\n\n**Reaction feedback (`FEEDBACK_REACTIONS=1`):** registers PTB\n`MessageReactionHandler`; 👍/👎 on bot messages → bounded per-chat `feedback_log`\n(capped 50) + ±0.3 mood nudge. 👎 also injects a one-turn recalibration note\ninto the next reply prompt. `allowed_updates` extended to include\n`message_reaction` only when the flag is on.\n\n**Closeness score (`CLOSENESS_ENABLED=1`):** pure `_compute_closeness(days_active,\nmessage_count, milestones_count, beliefs_count)` → (float 0-1, bucket). Buckets:\n\"getting to know each other\" / \"comfortable\" / \"deeply familiar\". Recomputed\ndaily at midnight rotation; shown in `/status`; injected as a one-line system\nnote in `assemble_messages`. Five new tests pin the formula.\n\n**Open threads (`THREADS_ENABLED=1`):** migrates `next_goals` str → per-chat\n`open_threads` list (capped 3) on load. `post_reply_analysis` JSON gains\n`\"thread_update\"` (add/resolved). Prompt block \"Open threads between you two\"\nreplaces the single next-goal line. When THREADS_ENABLED is off, existing\nnext_goals behavior is unchanged.\n\n**Auto inside-joke candidates (`JOKE_CANDIDATES=1`):** `post_reply_analysis` JSON\ngains `\"joke_candidate\"` ({phrase, meaning, tone} | null). Candidates go to the\nexisting `/reviewmem` queue — never auto-added to jokes.json.\n\nAll four features default off and have zero per-message LLM cost (reactions are\nlocal, closeness is a formula, threads/jokes piggyback on the existing\npost-reply analysis call that already runs).\n\n## v2026-07-11.5 — R5 UX: status tail & recurring quiet windows\n\n**Root cause this release addresses:** `/status` gave no visibility into what was\njust said (you had to scroll up), and suppressing proactive messages on a schedule\n(e.g. every Friday night) required remembering to `/quiet` each week.\n\n**/status tail:** appends the last 3 conversation messages, speaker-labeled and\ntruncated to ~80 chars each, so you can see the recent thread at a glance.\n\n**Recurring quiet windows (`/quietwin`):** three subcommands:\n- `/quietwin add Fri 23:00-08:00` — adds a weekly quiet window (midnight crossing\n supported: start > end spans into the next day).\n- `/quietwin list` — shows numbered list.\n- `/quietwin del 2` — removes by index.\n\nPer-chat state `quiet_windows` (list of `{dow, start, end}`). Checked via pure\npredicate `_in_quiet_window(now, windows)` in the same proactive gates as\n`quiet_until` (heartbeat and note-followup jobs). Twelve new tests cover midnight\ncrossing, wrong day, boundary minutes, and multiple windows. `/status` also shows\nactive quiet windows inline.\n\n## v2026-07-11.4 — R4 prompt hygiene & safety\n\n**Root cause this release addresses:** long conversations could silently exceed the\nmodel's effective context window (no trimming), `triggered_lore` returned duplicate\nentries when multiple keys in the same lorebook entry matched, models occasionally\nbroke character with \"as an AI\" responses that reached the user unfiltered, and\nmulti-chat summarization bursts could stack bandwidth-heavy LLM calls on the phone\nsimultaneously.\n\n**Token-budget trimming:** new pure function `_trim_history_to_budget(messages,\nbudget)` drops oldest non-system, non-final-user messages until the estimated token\ncount is under `CONTEXT_TOKEN_BUDGET` (env, default 0 = disabled; recommended 24000).\nCalled at the end of `assemble_messages`. Logs when it trims.\n\n**Lore dedupe:** `triggered_lore` now uses a `seen` set on entry content — duplicate\ncontent from multiple matching keys in the same entry is suppressed.\n\n**Persona-break guardrail:** regex catches first-person AI admissions (`I'm an AI`,\n`as an AI language model`, `large language model`, `I don't have feelings/a body/\npersonal experiences`). Applied in `_deliver` and `send_triggered` on the final\n`clean` text: offending sentence is stripped, counted as `persona_break` (visible in\n`/audit`). Third-person references (\"my AI coworker\") pass through (first-person\npattern required). Empty result after strip = nothing sent (no auto-regenerate).\n\n**Summarization semaphore:** `_SUMMARIZE_SEM = asyncio.Semaphore(1)` serializes\nsummarization across chats in `maintain_memory` and `maintain_long_term_memory` —\nprevents multi-chat bursts from stacking on phone bandwidth. Per-chat overlap was\nalready prevented by the `summarizing` set.\n\n**/start full:** `/start full` wipes conversation history AND all per-chat memory\n(summaries, facts, recent_summaries, recent_facts, milestones, pinned, moods,\nbeliefs) after an inline-button confirmation. Character-level memories\n(memories.txt) are untouched. Normal `/start` behavior unchanged.\n\n## v2026-07-11.3 — R3 observability & robustness\n\n**Root cause this release addresses:** restart-storm triage lost its own evidence\nbecause `_error_counts` was memory-only (wiped on every restart). Bad `.env` values\nwere warned only to the log file nobody checks from Telegram. Small-file saves\n(jokes, reminders, cron, payments, wardrobe) used non-atomic writes that could\ntruncate on a process death. `/update` and `/restart` could cut a reply mid-stream\nbecause there was no drain. And there was no visibility into LLM call volume.\n\n**Persist `_error_counts`:** error history now survives restarts — serialized into\n`state.json` alongside `_llm_stats`. Restart-storm triage from `/audit` no longer\nloses the evidence it was generated to show.\n\n**Config warnings surfaced:** `_env_int`/`_env_float`/`_parse_id_set` now collect\nwarnings into `_CONFIG_WARNINGS` (in addition to logging). `/audit` shows count + first\n3 warnings. All warnings also log at startup in one consolidated message.\n\n**Atomic small-file writes:** new `_atomic_write_text(path, text)` helper (tmp +\n`os.replace`) used by `save_jokes`, `save_reminders`, `save_cron_jobs`,\n`save_payments`, `save_wardrobe`. A death mid-write no longer truncates these files.\n\n**Graceful drain on /restart and /update:** `_schedule_exit` now waits up to 5s for\n`_replies_in_flight == 0` before writing state and exiting. Replies in progress\ncomplete rather than being cut mid-stream.\n\n**LLM usage counters in /audit:** module dict `_llm_stats` tracks daily calls and\nestimated token counts (via `_est_tokens`). Bumped in `call_nanogpt` on every\nsuccessful call. Persisted in state, resets on date change. `/audit` shows:\n`LLM today: N calls, ~Xk in / ~Yk out (est)`.\n\n**Prune `_last_request`:** `_self_audit` (every 30 min) now drops entries older than\n1h from the rate-limit dict, preventing unbounded growth in long-running instances\nwith many unique users.\n\n## v2026-07-11.2 — R2 availability awareness: /away, /back, remote-default framing\n\n**Root cause this release addresses:** characters would \"walk over to you\" or describe\nbeing in the same room during normal texting — there was no framing that the\nconversation is remote by default. Proactive messages (heartbeats, note follow-ups,\ntraffic alerts) also had no way to be suppressed when the user is driving, in a\nmeeting, or otherwise unavailable without using the heavier /quiet command.\n\n**Remote-default framing:** `assemble_messages` now injects a system note when\n`active_vibe` is not `\"in-person\"`: \"You and {user} are texting from different places —\nyou're not physically together unless the scene explicitly says so.\" Kills the class of\nroleplay slips where the character describes physical proximity during texting.\n\n**/away and /back commands:** new `away` state dict persisted in state.json. `/away\ndriving` or `/away meeting until 3` stores the reason verbatim and suppresses all\nproactives (heartbeat, note follow-ups, traffic alerts). `/back` clears it manually.\nAny incoming text message auto-clears away status (they're back by definition) and\ninjects a one-turn \"just got back from: {reason}\" prompt note so the character can\nacknowledge naturally.\n\n**Auto-extraction from conversation:** `post_reply_analysis` JSON schema gains an\n`\"availability\"` field (`\"driving\"|\"working\"|\"busy\"|null`). When the user explicitly\nstates availability (e.g. \"gotta drive, ttyl\"), away is set automatically with\n`origin: auto` and a configurable `AWAY_AUTO_HOURS` (default 3h) expiry as a\nbelt-and-suspenders against stuck flags.\n\n**New vibe presets:** `busy`, `working`, `driving` — shorter replies, no long questions,\nlow-demand register. `driving` is ultra-short and non-initiating.\n\n**Away in /status and /audit:** `/status` shows current away state with reason, duration,\norigin, and expiry. `/audit` includes `away_users` in its data.\n\n## v2026-07-11.1 — R1 memory auditor: source-attached memories, quote grounding, review queue\n\n**Root cause this release addresses:** the 2026-07-10 audit found that auto-extracted\nmemories had no provenance — no way to know where a memory came from, no mechanical\ncheck that the extraction was grounded in what the user actually said, no way to\ncorrect a bad memory from Telegram in under a minute. This release makes every memory\ntraceable, every extraction grounded, and every correction fast.\n\n**Source-attached memories:** new sidecar `memory_meta.json` stores provenance for each\nmemory line: timestamp, chat_id, origin (`auto`/`manual`/`manual-edit`), confidence\n(1-10), and the verbatim source quote from the user's message. New `_memory_replace`\nhelper is the single choke point for all memory mutations (add/edit/delete) — keeps\n`memories.txt`, `embeddings.json`, and `memory_meta.json` in sync. `/delmem` migrated\nto use it. `/sourcemem <n>` shows stored provenance; pre-2026-07 memories show a\n\"no source recorded\" fallback.\n\n**Quote grounding (anti-hallucination, mechanical not prompt-hope):** the post-reply\nanalysis LLM call now returns `memory_quote` (verbatim substring from the user's\nmessages) and `memory_confidence` (1-10). Code-side validation requires the quote to be\na case/whitespace-normalized substring of the user's actual lines — if it fails, the\nmemory is rejected and counted as `memory_ungrounded` (visible in `/errors`/`/audit`).\nPure function `_quote_grounded` with tests (exact match, case tolerance, fabricated\nquote rejection, empty inputs).\n\n**Confidence + review queue:** memories with confidence >= `MEMORY_AUTOCONF` (env,\ndefault 7) AND grounded are stored directly. Grounded but lower-confidence memories go\nto `memory_review.json` instead (capped at 20, oldest dropped). `/reviewmem` lists\npending with confidence + source; `/reviewmem ok <n>` promotes, `/reviewmem no <n>`\ndrops. `/audit` shows count when nonzero.\n\n**Correction flow (`[memcheck:]` tag):** new capability tag taught to the character:\nwhen the user disputes a memory (\"that never happened\"), include\n`[memcheck: what's disputed]`. Tag handling runs existing recall machinery (keyword +\nsemantic) over the query, DMs the numbered hits with their sources and exact fix\ncommands (`/delmem N`, `/editmem N <text>`). Handled via separate regex in `_deliver`\nonly — the `extract_tags` 4-tuple contract is untouched.\n\n**`/editmem <n> <new text>`:** replaces a memory line through `_memory_replace`\n(re-embeds, moves meta with `origin: \"manual-edit\"`, preserves original source).\n\n**Memory audit log:** `memory_log.txt` (append-only) records every mutation:\n`ADD auto/manual`, `EDIT`, `DEL`, `REVIEW-OK`, `REVIEW-NO`, `REVIEW-DROP`,\n`REVIEW-QUEUE`, `MEMCHECK`. Trims to 500 lines when >1000.\n\nNew tests: `TestQuoteGrounded` (9 cases), `TestMemoryReplace` (4 cases). Total test\ncount: 108 (was 95).\n\n## v2026-07-10.2 — audit release: memory hallucination + tool-call leak + concurrency fixes\n\nTriggered by two user-observed symptoms plus an external (Deepseek) audit of bot.py.\nEvery audit claim was verified against the code before being fixed — 10 of 15 confirmed,\n4 false, 1 by-design. Full triage in `AUDIT-2026-07-10.md`.\n\n**Heartbeat memory hallucination (root cause):** `_rotate_day_context` archived each\nday's `day.txt` — the character's own GENERATED fiction — into `recent_facts[owner]` as\na plain `\"[Jul 09] …\"` fact. `memory_block` rendered it under \"Recent specifics\" (read\nby the model as real shared history), weekly promotion folded it into permanent\nlong-term facts, and `_todays_memory_note` could flag her own archive as \"a significant\ndate\". Fix: own-day provenance tag (`[own-day …]`) honored by every consumer — separate\nclearly-framed prompt section, never LLM-merged into user facts, never promoted, skipped\nby the date scan, capped at `OWN_DAYS_KEPT=5`; `load_state` migrates legacy entries\n(sparing `[… ] Voice note:` user content). Extraction prompt also tightened: notes and\nmemories only from what the USER said.\n\n**Raw `<tool_call>` XML sent to the user (root cause):** models taught the bracket\n`[search: …]` tag sometimes emit the intent in their NATIVE function-call XML instead;\nnothing anywhere parsed or stripped that syntax, so it sailed through to Telegram.\nFix: `_strip_native_tool_calls` at the model-output choke point (both `_do_request`\nreturn paths, including the `reasoning_content` fallback, itself a leak vector) —\nsearch-like calls become `[search: q]` so `maybe_search` still runs; others stripped.\n\n**Concurrency (audit-confirmed):** state serialization now always happens on the event\nloop (worker-thread saves hand off via `call_soon_threadsafe`; only the file write runs\nin a thread) — the old path iterated ~28 live dicts cross-thread (`RuntimeError: dict\nchanged size during iteration`). Post-reply analysis snapshots the history tail on the\nloop. Voice/video transcription and `/usage` no longer run bare synchronous `requests`\ncalls on the event loop (each froze every chat for up to 60s). `_error_counts`\niteration snapshotted.\n\n**Smaller confirmed bugs:** `ALLOWED_USERS`/`GROUP_ALLOWED_CHATS` no longer crash the\nimport on malformed ids (`--123`); schedule day-headings require the first word to BE a\nday name (\"money\" is not Monday, \"wedding\" is not Wednesday); `_extract_json` recovers\nthe first balanced object when a stray brace follows; `get_owner` warns loudly on a\nnon-numeric `OWNER_CHAT_ID`; `/backup` skips files that vanish mid-run; all 64\n`int()/float()` env parses fall back to defaults with a warning instead of\ncrash-looping the bot on a typo'd `.env`; deleted 13 lines of unreachable dead code in\n`_weather_camera_pool`.\n\n## v2026-07-10.1 — group chat prototype (GROUP_MODE, Priya + Jules pilot)\n\n**Group chat / bot-to-bot (ROADMAP 3.4):** two character bots + one human in one\nTelegram group, behind `GROUP_MODE=1` + `GROUP_ALLOWED_CHATS` on the pilot instances\nonly. Full design + rationale in `GROUP_CHAT_DESIGN.md` — it survived four rounds of\nadversarial-critic review before any of this code was written, and the review caught\nreal bugs (a poll/live double-answer race, a chain-cap race, and two rounds of missed\nflat-file write paths), so read it before touching this feature.\n\n**The platform fact everything is built around:** Telegram never delivers one bot's\nmessages to another bot (API-level anti-loop policy, regardless of privacy mode).\nBot-to-bot therefore flows through a shared ledger file (`group_<chat_id>.jsonl`\nalongside bot.py, same cross-instance pattern as world.txt): each bot appends what it\nposts, a 5s poll job reads what peers said. Human messages arrive live via Telegram\n(privacy mode must be DISABLED via BotFather for the pilot bots) and are never acted\non from the ledger — acting on both would double-answer every addressed message.\n\n- **Turn-taking:** addressed messages (@mention / first name on word boundary / reply\n to own message) answered deterministically; unaddressed ones through an atomic claim\n file (`O_CREAT|O_EXCL`) so exactly one bot answers, with a jittered delay biased\n toward alternation.\n- **Loop prevention, layered:** every reply to a bot message needs the claim (even\n when addressed by name — the LLM's favorite register is exactly the loop risk);\n chain cap `GROUP_BOT_CHAIN_MAX=2` re-checked under the ledger lock right before\n send (generated reply discarded if the chain filled meanwhile); 20s send throttle;\n 30/day bot-to-bot budget.\n- **Fleet-wide fail-closed (deliberate behavior change):** group chats are ignored by\n every instance unless GROUP_MODE + allowlist say otherwise, and ALL commands except\n `/chatid` are refused in any group via a single TypeHandler choke point (group -1).\n Previously any bot added to a random group would execute `/note`, `/backup`, etc.\n there — same latent-bug class as `set_owner` being claimable by a group, which is\n also fixed (central guard: negative chat_ids can never become the proactive owner).\n- **Memory read-only in groups:** group prompts read the character's life (memories,\n people, projects, day/world context) but never `user_notes.txt` or inside jokes\n (private 1:1 state); nothing in a group writes any flat file — `_group_deliver` is\n allowlist-built (no post_reply_analysis, no joke tracking, no TTS/selfie/meme) and\n the command guard blocks the manual paths. Two new evals pin this boundary in CI\n (`group-deliver-clean`, `group-cmd-allowlist`).\n- **Cost:** ≤2 chat-model calls per human message fleet-wide + amortized summarization\n (groups summarize half as often); zero side calls in groups.\n- **Ops:** `python bot.py <dir> --claim-test` smoke-tests both atomicity primitives\n on-device; `/audit` shows ledger size, budget, and chain state per group; new error\n categories `group_ledger` / `group_claim`.\n\n## v2026-07-07.2 — repair server-side mojibake from NanoGPT SSE\n\n**Root cause:** The encoding issue was never on our side. NanoGPT's SSE infrastructure\ndecodes model output (UTF-8) as Latin-1 and re-encodes to UTF-8 before sending. By the\ntime the bytes reach our socket, they already spell `’` instead of `'`. Our previous\nfixes (v2026-07-06.1 `resp.encoding`, v2026-07-07.1 manual `.decode(\"utf-8\")`) correctly\ndecoded the wire bytes — but those bytes were already wrong.\n\n**Fix:** `_fix_mojibake(text)` reverses the Latin-1 misinterpretation:\n`text.encode(\"latin-1\").decode(\"utf-8\")`. If the text is clean (no mojibake), the\nencode step either round-trips harmlessly or raises `UnicodeEncodeError` (characters\nabove U+00FF can't encode to Latin-1), in which case we keep the original. Applied to\nboth streaming and non-streaming return paths in `_do_request`.\n\n## v2026-07-07.1 — fix SSE mojibake for real (manual UTF-8 decode)\n\n**Root cause:** The v2026-07-06.1 fix (`resp.encoding = \"utf-8\"`) relied on `requests`'\n`iter_lines(decode_unicode=True)` honoring the encoding override. On the phone's\n`requests` version it didn't — the response was still decoded as Latin-1, producing\ndouble-mojibake (`âÂÂ` instead of `'`) as the already-garbled text was re-encoded\nthrough another layer.\n\n**Fix:** Drop `decode_unicode=True` entirely. Call `resp.iter_lines()` to get raw bytes,\nthen decode each line explicitly with `raw.decode(\"utf-8\", errors=\"replace\")`. This\nbypasses `requests`' encoding detection completely — no Content-Type header, no\n`apparent_encoding`, no library version variance. The bytes come from the socket and we\ndecode them ourselves.\n\n## v2026-07-06.5 — semantic memory recall via NanoGPT embeddings\n\n**Semantic recall (ROADMAP 3.3):** memory retrieval now supplements keyword matching with\ncosine-similarity search over NanoGPT embeddings (`text-embedding-3-small` by default,\nconfigurable via `EMBEDDING_MODEL`).\n\n- **On memory write:** `_append_memory` embeds the new line and caches the vector in\n `embeddings.json` (sidecar to `memories.txt`). One API call per new memory.\n- **On context assembly:** `triggered_memories` merges keyword hits (existing behavior) with\n semantic matches (cosine top-k, threshold 0.3). Scores are summed so a line that matches\n both keyword AND meaning ranks highest.\n- **On /recall:** semantic results (with similarity percentage) appear alongside keyword\n hits, so paraphrased queries (\"remember when we talked about my sister's wedding?\")\n work even when the stored fact uses different words.\n- **Fallback:** any embedding API failure falls back silently to keyword-only — the\n feature is additive, never subtractive.\n\n## v2026-07-06.4 — shared world context, test suite, new-bot bootstrap\n\n**Shared world context (ROADMAP 3.2):** all six characters now share the same weather\nand ambient happenings each day. The designated world-generator instance\n(`WORLD_GENERATOR=1`, typically nora) writes `world.txt` at midnight — a 2-3 line shared\nbackdrop (weather mood, local color). Every instance's `_generate_daily_events` reads it\nas context, so Nora's rainstorm is also Priya's rainstorm. Degrades gracefully: if the\nfile is absent (generator not configured, or it failed), behavior is unchanged from\nbefore — each instance generates its own weather independently.\n\n**Test suite (ROADMAP 2.1):** `tests/test_pure.py` (pytest) covering the pure functions\nwhere a regression is fleet-breaking: `extract_tags` (4-tuple contract), `parse_cron_schedule`/\n`describe_cron_schedule` (roundtrip), `_extract_json` (prose/fence extraction), `parse_when`\n(reminder time parsing), `_est_tokens`, `_count_error` cap. 41 tests. CI workflow updated\nto run pytest after the eval suite. Fixture in `conftest.py` stands up a temporary instance\ndirectory with a minimal `.env` and character card so bot.py imports cleanly.\n\n**new-bot.sh (ROADMAP 2.2):** interactive bootstrap script for new instances — creates the\ndirectory, prompts for tokens/models, pulls the card and seed files, starts the bot. A\nseventh instance can be stood up in under five minutes.\n\n## v2026-07-06.3 — voice reply symmetry + degradation alerts\n\n**Voice symmetry (ROADMAP 3.1):** sending a voice note to a bot with `/voice` on now\nbiases toward replying in kind — `VOICE_REPLY_TO_VOICE` (default 0.9) replaces the\nambient `TTS_CHANCE` (default 0.3) when the incoming message is a voice note.\nImplementation: `_deliver` gains a `voice_input` flag; `handle_voice` sets it; the\nTTS probability check picks the higher value. Text messages are unaffected.\n\n**Degradation alerts (ROADMAP 1.4):** `_self_audit` now watches two new signals:\n- *Fallback rate*: a new `\"fallback\"` error category is incremented each time\n `call_nanogpt` falls from the primary model to `FALLBACK_MODEL` (budget exceeded or\n retries exhausted). If fallback fires ≥3 times in the last hour, the owner gets a DM\n (2h cooldown, same pattern as restart-storm alerts).\n- *Monthly spend*: if `USAGE_BUDGET_MONTHLY` is set in `.env`, `_self_audit` hits the\n NanoGPT subscription/usage API every 30 min and DMs the owner at 80% and 100% of\n budget (24h cooldown). Inert when unset.\n\nAlso in this release: `watchdog.sh`, `fleet-status.sh`, `sync-cards.sh` committed to the\nrepo (ROADMAP 1.1, 1.3, 2.3 — shell scripts only, no bot.py change for those).\n\n## v2026-07-06.2 — fix selfie crash when no base PNG (NanoGPT path)\n\n**Root cause:** `_generate_selfie_nanogpt` unconditionally called `_base_data_url()`,\nwhich reads `SELFIE_BASE` (e.g. `priya_base.png`) from disk. If no base image exists\nbut an `appearance.txt` does, `selfie_ready()` returns True (text-only generation is\nfine), and `build_selfie_prompt` correctly takes the text-only branch — but then\n`_generate_selfie_nanogpt` crashes with `FileNotFoundError` because it never checked\n`_has_base_image()` first. The Gemini path already had this guard (line 2778); the\nNanoGPT path was missing it.\n\n**Fix:** Only include `imageDataUrl` in the NanoGPT payload when `_has_base_image()`\nis True, matching the Gemini path's behavior.\n\n## v2026-07-06.1 — fix UTF-8 mojibake + suppress \"almost did X\" model tic\n\n**Mojibake root cause:** `requests`' `iter_lines(decode_unicode=True)` uses the response's\n`Content-Type` charset to decode bytes. NanoGPT's SSE endpoint returns\n`text/event-stream` without an explicit `charset=utf-8`, so `requests` falls back to\nISO-8859-1 (the HTTP/1.1 default for `text/*`). Any multi-byte UTF-8 character — curly\nquotes, em dashes, accented letters — gets decoded as Latin-1 garbage (e.g. `'` →\n`’`). Always latent, but never triggered until GLM 5.2 started using smart\npunctuation instead of ASCII apostrophes.\n\n**Mojibake fix:** Force `resp.encoding = \"utf-8\"` on the streaming response before\n`iter_lines(decode_unicode=True)` in `_do_request`.\n\n**\"Almost texted you\" tic:** GLM 5.2 heavily favors narrating actions it almost took\n(\"almost texted you,\" \"I deleted a whole argument,\" \"was going to send you this\") as a\nlow-effort way to perform emotional attachment. Added a rule to the default texting style\ncalling this out — either do it or don't mention it.\n\n## 2026-07-06 — ops tooling: fleet backup script, CI evals, secret scan (no bot.py change)\n\n**Not a bot release — no BOT_VERSION bump.** (New heading convention, enforced by the\n`version-changelog-sync` eval: only actual bot.py releases get `## v<version>` headings,\nwhich must match `BOT_VERSION`; ops/docs entries use dated headings like this one.)\n\n- **`backup-all.sh`**: nightly-cronable fleet state backup. Motivation: all character\n memory/state lives on one phone; `/backup` is manual and per-bot, so a dead phone\n meant losing everything. Archives each instance's state files (same list as `/backup`,\n `.env` always excluded) to `~/storage/shared/bot-backups/` (survives Termux\n uninstall), prunes after 14 days, optional `BACKUP_RCLONE_REMOTE` for off-phone push.\n Like `watchdog.sh` it must be curl-installed once and is not touched by\n `update-all.sh`. Setup instructions in the script header and `OPS_MANUAL.md`.\n- **CI** (`.github/workflows/evals.yml`): runs `.claude/evals/run-evals.sh` on every\n push to `main`/`claude/**` and on PRs. Rationale: bots deploy by curling raw files\n from `main`, and session-side checks only run when a session runs them — a web-UI\n edit or phone push had no gate at all before this.\n- **Two new evals**: `secret-scan` (token-shaped strings in tracked files — Telegram\n bot tokens, sk- keys, AWS key IDs; this repo is pulled over public raw URLs, so a\n committed token is instantly public) and `version-changelog-sync` (BOT_VERSION must\n equal the newest `## v` changelog heading — the delivery gate checks both changed,\n but not that they agree). Both break-it tested.\n\n## v2026-07-05.12 — admin HTTP API (Phase 1 of VPS migration)\n\n**New capability, not a bug fix.** Adds an opt-in HTTP admin API that mirrors\n`/audit /errors /backup /update /restart` for non-Telegram clients — the motivating\ncase is a future native Android control-panel app, which can't just be a second\nTelegram client (only one process can poll a bot token for updates at a time, and\nthere's no way to route a \"send as the user\" reply back to a second client via the\nBot API). Fully inert unless `ADMIN_API_ENABLED=1` is set — existing Termux instances\nthat never set it are unaffected.\n\nRefactored `audit_cmd`/`errors_cmd`/`update_cmd`/`_send_backup` so their logic lives in\nplain functions (`gather_audit_data`, `tail_error_lines`, `backup_file_list`,\n`build_backup_zip`, `perform_self_update`) called by both the Telegram handler and the\nmatching HTTP route — no duplicated logic, and the Telegram-facing output text/format\nis unchanged.\n\n`/update` and `/restart`'s old pattern — reply, `_write_state()`, immediate\n`os._exit(0)` — doesn't hold for HTTP: `ThreadingHTTPServer` writes its response on the\nsame thread handling the request, so an immediate exit right after building the\nresponse risks killing the process before those bytes reach the socket (the caller\nwould see a connection reset instead of the 200 they were just sent). New\n`_schedule_exit()` helper fires `os._exit(0)` from a `threading.Timer` after a short\ndelay instead, used by `/update`, `/restart`, and the matching `/admin/update`,\n`/admin/restart` HTTP routes. `threading.Timer` runs on its own thread regardless of\ncaller, so this works uniformly from both the asyncio event loop thread (Telegram\nhandlers) and an admin API request-handling thread.\n\nAuth: every route except `GET /admin/health` requires `Authorization: Bearer\n<ADMIN_API_TOKEN>`, compared with `secrets.compare_digest`. `/admin/health` is\ndeliberately unauthenticated (trivial liveness ping, no sensitive data) so uptime\nmonitors and the future app's connectivity check don't need the token wired in.\n`ADMIN_API_BIND` defaults to `127.0.0.1` (fails closed) — set it to the host's\nTailscale IP to actually expose it over the private tailnet; never bind `0.0.0.0`.\n\nAlso new: `telegram-companion-bot/deploy/bot@.service` (systemd template unit,\n`Restart=always`, `RestartSec=2`) and `deploy/install-vps.sh` (idempotent VPS\ninstaller — clones/pulls the repo, builds the venv from `requirements.txt` as the\nsingle source of truth, prompts per-instance for tokens, installs the systemd unit,\nprints Tailscale setup instructions). Confirmed `_PID_FILE`'s stale-lock detection\n(`os.kill(pid, 0)`) and `os._exit(0)` are already compatible with `Restart=always` as-is\n— `RestartSec=2` exists specifically to make PID-reuse races between an old exiting\nprocess and systemd's relaunch practically impossible, not to work around a new bug.\n\n## v2026-07-05.11 — meme generation (`/meme` + `[meme:]` tag)\n\n**New feature, not a bug fix.** Bots can now make and send a meme via `/meme [hint]`,\nor decide to send one unprompted mid-conversation via a `[meme: top | bottom]` tag —\nmirrors exactly how `/selfie`/`[selfie: hint]` already work (same tag-parsing pattern\nin `extract_tags`, same `_deliver`/`send_triggered` wiring, same `_is_allowed` gating,\nsame `_keep_uploading`/`BytesIO`/`send_photo` send path).\n\n**Deliberate design choice:** memes are template images + Pillow text overlay, not\nAI-generated. AI image models render text unreliably (garbled/misspelled captions),\nwhich defeats the entire point of a meme — the caption *is* the joke. Templates\n(`meme_templates/*.jpg`) and the font (`fonts/Anton-Regular.ttf`, OFL-licensed) are\nshared assets alongside `bot.py`, not per-instance, and not part of `update-all.sh`'s\nroutine pull — see `SETUP_GUIDE.md` Step 8 for the one-time fetch.\n\nNew: `meme_ready`/`_pick_meme_template` (with per-chat dedup, mirrors\n`_recent_selfie_hints`)/`render_meme` (word-wrap + auto-shrink-to-fit + stroked text)/\n`_generate_meme_captions` (one LLM call, JSON `{\"top\", \"bottom\"}`, reuses the existing\n`_extract_json` helper)/`send_meme`/`meme_cmd`. `extract_tags` now returns a 4-tuple\n(`clean_text, reaction, selfie_hint, meme_caption`) instead of 3 — both call sites\n(`_deliver`, `send_triggered`) updated; verified via isolated extraction tests since a\nmismatch here would break every message, not just meme ones.\n\nNew dependency: `Pillow>=10.0,<11.0`. Termux install can be flaky — see the Termux\nquirks note in `CLAUDE.md` for the `pkg install python-pillow` + `--system-site-packages`\nfallback if a source build fails.\n\nBOT_VERSION 2026-07-05.11.\n\n## v2026-07-05.10 — repo cleanup: dead launchers, stale docs, Priya's real location\n\n**Not a bug fix — a documentation/hygiene pass**, prompted by finding that several files\nin the repo hadn't been touched since before this project's reliability work (this\nsession, v2026-07-05.1 through .9) and had drifted into actively misleading territory:\n\n- `run.sh` and `start-bots.sh` both still launched with bare `python` (no supervisor,\n no crash recovery) — the exact `ModuleNotFoundError` crash-loop bug already fixed in\n `run-bot.sh`. Deleted both; `run-bot.sh` (with no folder argument) and `update-all.sh`\n already cover everything they did.\n- `PROJECT_CONTEXT.md`/`PROJECT_INSTRUCTIONS.md` were snapshot docs from an earlier,\n now-superseded session — wrong instance list, a stale git branch reference, \"as of\n last session\" state. Fully superseded by `CLAUDE.md` + this changelog. Deleted rather\n than left to be trusted over the real docs by mistake.\n- `Dockerfile`/`docker-compose.yml` were incomplete (no multi-instance/`BOT_HOME`\n support, single hardcoded `CMD`) and unreferenced by `CLAUDE.md` — this project is\n Termux-first and the Docker path was never actually wired up. Removed; `requirements.txt`\n is now the single source of truth for pip installs (`SETUP_GUIDE.md`, `CLAUDE.md`'s venv\n rebuild recipe) instead of being duplicated inline in three places, which is exactly\n the kind of drift that caused the missing-`tzdata` bug in v2026-07-05.5.\n- `SETUP_GUIDE.md` told users to set `NANOGPT_BASE_URL` — the actual code reads\n `NANOGPT_BASE`; the wrong name would silently do nothing and leave every non-NanoGPT\n provider setup broken. Fixed, along with the default URL (code default is\n `https://nano-gpt.com/api/v1`, guide said `https://api.nano-gpt.com/v1`).\n- `DOCUMENT_MODEL`'s code default (`meta-llama/llama-3.3-70b-instruct`) never matched\n what `CLAUDE.md` documented (`deepseek/deepseek-v4-flash`) or what's actually run in\n practice. Changed the code default to match.\n- `ATLAS_FILE` default renamed `portland_places.txt` → `atlas.txt` (code default and all\n five character subdirectories) — the old name was a copy-paste artifact that was\n actively wrong for most characters.\n- **Found and fixed a real, pre-existing bug while investigating a Priya relocation\n request:** `DEFAULT_SETTING` in `bot.py` — the fallback setting text for the\n *unnamed/home instance* (Nora's slot) — was hardcoded describing \"Priya... Houston,\n Texas... moved to Portland, Oregon to tattoo at a shop.\" It never matched Nora (the\n only character it could actually apply to) and never applied to the real Priya either\n (named instances don't fall back to `DEFAULT_SETTING` at all unless they lack their\n own `setting.txt`). Rewritten to actually describe Nora (Chicago South Side → Seattle,\n per her real card) instead of an orphaned, wrong-character placeholder.\n- Priya (the real, deployed `priya.json` — Tamil-American software engineer, Rutgers\n grad) relocated from Austin, TX to Bellevue, WA at the user's request. Updated her\n card description and rewrote `priya/atlas.txt` with real Eastside/Seattle-area places\n (Meydenbauer Bay Park, Cougar Mountain, Stone Gardens, etc.), keeping the same\n personality-revealing-annotation structure as the original. `people.txt`/\n `projects.txt`/`schedule.txt` needed no changes — they had no Austin-specific content.\n Added Priya's (and Jules's) missing entries to `CLAUDE.md`'s Character notes — Priya\n had never actually had one; the Houston/Portland description some earlier session may\n have gone by was always `DEFAULT_SETTING` (see above), never a documented fact about her.\n\n## v2026-07-05.9 — `.alive` heartbeat for watchdog.sh\n\n**Root cause:** a phone-side script (`~/telegram-bot/watchdog.sh`, not part of this repo)\nrestarts any bot whose `.alive` file is older than 300s, treating it as frozen. `bot.py`\nnever wrote that file. `watchdog.log` showed every bot flagged `frozen (heartbeat ~70000s\nold)` and relaunched every 5 minutes, forever, on all six bots — via `run-bot.sh`'s own\n`kill $OLD_PID`, a real SIGTERM against perfectly healthy processes. This was the actual\ncause of the entire restart-storm saga (v2026-07-05.4 through .8 below); Samsung battery\nsettings, Auto Blocker, and the phantom-process-killer fix were all real issues but never\nthe cause of *this* pattern.\n\n**Fix:** added `_touch_alive`, a 60s repeating job that touches `BASE_DIR/.alive`,\nmatching what `watchdog.sh` expects. Documented the `watchdog.sh`/`.alive` contract in\nCLAUDE.md's Monitoring section, including the one-command diagnostic that would have\nfound this immediately: `tail watchdog.log` logs its exact reason (`session down` vs\n`frozen (heartbeat Ns old)`) before every relaunch.\n\n## v2026-07-05.8 — dead shutdown signal handler\n\n**Root cause:** `python-telegram-bot`'s `run_polling()` installs its own SIGINT/SIGTERM\nhandlers internally, silently overriding whatever `signal.signal()` was registered in\n`main()` beforehand. Our custom `_shutdown` handler (logging \"Received signal...\") had\nnever fired, not once, through any restart all session — the entire \"no signal line =\nSIGKILL\" diagnostic this session's phantom-killer theory was built on was unreliable\nfrom the start.\n\n**Fix:** replaced the dead `signal.signal()` registration with\n`ApplicationBuilder().post_shutdown(_on_shutdown)` — an async hook that runs as part of\nPTB's own already-correct graceful-shutdown sequence regardless of what triggered it.\nRemoved the now-unused `signal` import.\n\n## v2026-07-05.7 — false-positive restart-storm alerts\n\n**Root cause:** the v2026-07-05.4 restart counter used `time.mktime(time.strptime(...))`\nto parse log timestamps, which depends on the OS's local-time calibration\n(`/etc/localtime`/`TZ`) — a different mechanism than Python's `zoneinfo`, but one the\nsame `pkg upgrade` (see v.5) evidently also disrupted. All six bots run on the same\nphone, so all of them misjudged old `STARTUP AUDIT` lines as \"within the last hour\"\nidentically, producing a fleet-wide false alarm (~199 restarts reported on healthy bots).\n\n**Fix:** compare naive wall-clock `datetime` objects directly instead of converting\nthrough Unix epoch — only needs \"same frame, consistent relative diff,\" not absolute\nUTC correctness.\n\n## v2026-07-05.6 — Python 3.14 asyncio incompatibility\n\n**Root cause:** an unrelated `pkg upgrade` (run to fix an adb/libprotobuf error) landed\nTermux on Python 3.14, which removed the auto-create fallback that\n`asyncio.get_event_loop()` used to provide. `python-telegram-bot` v21's `run_polling()`\ndepends on that fallback, so every launch crashed with `RuntimeError: There is no\ncurrent event loop in thread 'MainThread'` before the bot could even start polling.\n\n**Fix:** explicitly create and set an event loop in `main()` before `run_polling()` if\nnone exists — a no-op on Python versions where the old fallback still works.\n\n## v2026-07-05.5 — startup crash from missing `tzdata`\n\n**Root cause:** the same `pkg upgrade` (v.6) bumped Python enough that the venv needed\nrebuilding (see the pre-versioning `dca3c30` fix below), but the rebuild recipe didn't\ninclude `tzdata`. Termux has no system IANA timezone database, so `zoneinfo.ZoneInfo`\nsilently fell back to `TZ = None`. A previously-saved reminder's `due` timestamp was\nstill timezone-aware (saved before the break), so comparing it against a now-naive\n`datetime.now()` raised `TypeError: can't compare offset-naive and offset-aware\ndatetimes` while re-arming reminders at startup — crashing the entire process before it\ncould serve Telegram at all.\n\n**Fix:** `schedule_reminder` normalizes mismatched aware/naive timestamps instead of\ncrashing; the startup reminder-rearm loop wraps each reminder in try/except so one bad\nentry can't block the rest (or the bot itself). CLAUDE.md's venv rebuild recipe now\nincludes `tzdata`.\n\n## v2026-07-05.4 — monitoring: restart-storm self-report + dead man's switch\n\nAdded `_self_audit` restart counting (buggy until v.7 — see above) and `HEALTHCHECK_URL`\nsupport: when set in an instance's `.env`, the bot pings that URL every 30 min so an\nexternal service (e.g. healthchecks.io) can alert on silence — covers bot-fully-down and\nphone-dead, which nothing on-device can self-report.\n\n## v2026-07-05.3 — continuity features\n\n- **Date-aware note follow-ups**: the combined post-reply analysis call now also\n extracts a date when a user note is datable (\"interview Tuesday\"); stored as a\n `(due YYYY-MM-DD)` suffix in `user_notes.txt`. A daily job (`NOTE_FOLLOWUP_TIME`,\n default 18:00) proactively asks how it went once the date passes, then rewrites the\n marker to `(asked ...)` so it never re-fires. Respects quiet hours and the nudge\n budget; max one per day.\n- **Multi-day life threads**: the midnight day-context rotation now feeds yesterday's\n `day.txt` into today's event generation, so a hanging thread (a plan, an errand, a\n person) may continue or resolve instead of the character's life resetting daily.\n\n## v2026-07-05.2 — ops hardening\n\n- `/backup` and the weekly auto-backup now include `memories.txt`, `user_notes.txt`,\n `setting.txt` (previously only `state.json`/`reminders.json`/`payments.json` — a\n character's accumulated relationship history was never actually backed up).\n `.env` stays excluded on purpose.\n- New `_is_admin` gate (allowlist member or owner only) on `/update`, `/restart`,\n `/errors`, `/audit`, `/backup` — previously `_is_allowed` returned true for *anyone*\n when `ALLOWED_USERS` was unset, so these operational commands were wide open on any\n instance without an explicit allowlist.\n- `/restart`: clean supervisor restart from Telegram, no shell needed for `.env` edits.\n- Supervisor trims `bot.log` to its last 1 MB once it exceeds 5 MB (previously unbounded;\n `errors.log` already rotated at 2 MB via `RotatingFileHandler`).\n\n## v2026-07-05.1 — self-deploy, consolidated analysis call, leaner supervisor\n\n- **`BOT_VERSION` introduced** — shown in `/audit` and the startup log, so \"did the\n update take?\" is answerable from Telegram instead of guessed at.\n- **`/update` command**: downloads `bot.py` from `main`, refuses to install anything\n that doesn't `py_compile`, keeps a `bot.py.bak`, swaps, and restarts via the\n supervisor. No Termux shell needed for routine deploys.\n- **One combined post-reply analysis call** (`post_reply_analysis`) replaces three\n separate LLM calls (mood appraisal, user-note extraction, NPC memory extraction) that\n ran after every message. On a phone connection those side calls competed with the\n user-facing reply for bandwidth — a real driver of Emily's earlier timeout storm (see\n the pre-versioning entries below). Auto-react also now skips while a reply is\n in flight.\n- `run-bot.sh`: supervisor logs via `>>` redirect instead of `tee` — one fewer process\n per bot, six fewer toward Android's 32-phantom-process kill limit.\n\n## Pre-versioning fixes (same debugging session, before `BOT_VERSION` existed)\n\nThese landed before the version stamp was introduced above; find them by commit message\nvia `git log` if you need the exact diff. In the order they were actually found and\nfixed — root causes only:\n\n- **`run-bot.sh` launched bare `python`, not the venv's.** Only worked if the venv\n happened to be on `PATH` when tmux started; otherwise crash-looped on\n `ModuleNotFoundError: No module named 'requests'`. This exact bug recurred later\n (unrelated to this fix — a `pkg upgrade` broke the venv itself; see v2026-07-05.5)\n and is a recurring hazard worth checking first on any instance that won't start.\n- **Emily's \"Vision API Error: 400 —\" had an empty body.** The streaming response's\n `with` block closed the connection before `raise_for_status()`'s error body could be\n read. Fixed by force-reading `resp.content` on any status ≥ 400 before raising —\n this pattern must be kept if `_do_request` is ever touched again, or every future\n 4xx/5xx becomes undiagnosable again.\n- **`VISION_MODEL` defaulted to `NANOGPT_MODEL`** (a text-only reasoning model), so any\n instance without an explicit `VISION_MODEL` in `.env` sent photos to a model that\n rejects images. Changed the default to `zai-org/glm-4.6v`.\n- **`STREAM_TIMEOUT` (30s) was too tight** for a phone connection running several\n concurrent side-calls per message; every model, including fast flash-tier ones,\n timed out constantly. Raised to 90s.\n- **WSDOT `GetAlertsAsJson` returns a bare array**, but the parser called `.get(\"Alerts\")`\n on it, crashing the traffic poller every 10 minutes (silently, since it was caught and\n logged, not fatal — but `/traffic`/`/incidents` were broken the whole time). Fixed to\n accept both a bare array and a wrapped object.\n- **Inworld voice IDs sent to NanoGPT's OpenAI-style TTS endpoint 400'd** — voice and\n model must come from the same provider. Added native Inworld TTS support\n (`INWORLD_API_KEY`/`INWORLD_TTS_MODEL`), auto-selected when the key is set.\n- **Added `/errors` command** — tails `errors.log` into chat, so future bug reports\n carry the actual error text instead of a vague \"it's down.\"\n\nFor history before this debugging session (memory system fixes, latency work, thread\nsafety, etc.), `git log` is the source of truth — those commits predate this file.\n"}}}} |