From 9bad2af462951f86c2228ccf660da738a88b2946 Mon Sep 17 00:00:00 2001 From: Shekhar Mudarapu Date: Sat, 19 Sep 2026 10:00:22 -0400 Subject: [PATCH] =?UTF-8?q?fix(imessage):=20the=20world=20model=20must=20k?= =?UTF-8?q?eep=20learning=20on=20a=20MUTED=20channel=20=E2=80=94=20it=20wa?= =?UTF-8?q?s=20dead=20code=20for=20the=20one=20incident=20it=20was=20built?= =?UTF-8?q?=20for?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live finding, 24h after deploy: the world-model refresh had not run ONCE. The iMessage bridge is muted, and `maybeRefreshWorldModel` sat below the `proactivePaused()` early return — which is true whenever `this.muted` is set. On a muted channel the whole refresh was unreachable. That is exactly backwards. Mute means "don't SEND to contacts" (#250), not "stop thinking". The refresh messages no one: it reads the owner's own mail, calendar and self-chat notes and updates the OWNER's profile. And the bridge WAS muted on 2026-09-18 — so the refresh that would have corrected the stale grand-opening date never ran, on the very day the stale date made the bot correct a friend twice. The feature shipped switched off by an unrelated flag. - `maybeRefreshWorldModel` moves ABOVE the pause gate, under the killswitch. - Quiet hours and an explicit "quiet Nh" still defer it (its FYI would wake the owner); the next tick after the window picks it up. - The killswitch — the do-nothing-at-all switch — still stops it. - Regression test is structural, because the bug was WHERE the call sat: below that return it is dead code, and no behavioural test of the function itself would have caught it. Verified: imessage-bridge 120/0, tsc clean. --- services/imessage-bridge/src/session.ts | 19 ++++++--- .../src/world-model-mute-scope.test.ts | 39 +++++++++++++++++++ 2 files changed, 53 insertions(+), 5 deletions(-) create mode 100644 services/imessage-bridge/src/world-model-mute-scope.test.ts diff --git a/services/imessage-bridge/src/session.ts b/services/imessage-bridge/src/session.ts index 5ec00c74..649dd2b6 100644 --- a/services/imessage-bridge/src/session.ts +++ b/services/imessage-bridge/src/session.ts @@ -2216,21 +2216,30 @@ export class IMessageSession { const target = this.ownerSelfChatTarget(); if (!target) return; // no owner channel → nothing to DM + const now = Date.now(); + + // WORLD MODEL runs even when the channel is MUTED, and is therefore + // ABOVE the pause gate. Mute means "don't SEND to contacts" (#250), not + // "stop thinking": this reads the owner's own mail/calendar/notes and + // updates the OWNER's profile — it never messages a contact. Live proof + // this matters: the bridge was muted on 2026-09-18, so the refresh that + // would have corrected the stale opening date never ran at all, which is + // the very incident the world model was built for. Quiet hours and an + // explicit "quiet Nh" still defer it (the FYI would wake the owner); the + // next tick after the window picks it up. + const quiet = isQuietHours(new Date(), defaultQuietHours()) || now < this.proactiveMuteUntil; + if (!quiet) this.maybeRefreshWorldModel(target, now); + // Quiet hours: defer nudges (don't wake the owner). The next tick // picks them up once the window reopens; dedupe keys keep them fresh. if (this.proactivePaused()) return; - const now = Date.now(); - // EVENT SCOUT rides this tick (same killswitch/quiet-hours guards): // weekly web_search scan → numbered list to self-chat. Fire-and-forget // so a slow search never delays the nudges below. this.maybeRunEventScout(target, now); // SKILL FORGE rides the same tick: due owner-taught skills fire here. this.maybeRunSkills(target, now); - // WORLD MODEL rides the same tick: every few hours, derive the owner's - // present-tense Now/Public lines from mail + calendar (no owner typing). - this.maybeRefreshWorldModel(target, now); const input = await this.gatherProactiveSignals(now); const nudges = computeProactiveNudges({ now, ...input }); diff --git a/services/imessage-bridge/src/world-model-mute-scope.test.ts b/services/imessage-bridge/src/world-model-mute-scope.test.ts new file mode 100644 index 00000000..f00161f8 --- /dev/null +++ b/services/imessage-bridge/src/world-model-mute-scope.test.ts @@ -0,0 +1,39 @@ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; + +// The world-model refresh must run on a MUTED channel. Mute means "don't SEND +// to contacts" (#250), not "stop thinking" — the refresh reads the owner's own +// mail/calendar/notes and writes the OWNER's profile; it never messages a +// contact. Live proof: the bridge was muted on 2026-09-18, so the refresh that +// would have corrected the stale grand-opening date never ran once, which is +// the exact incident the world model exists to prevent. +// +// Structural, because the bug is WHERE the call sits: under the +// `proactivePaused()` return (which is true whenever muted), it is dead code. +const src = readFileSync(new URL("./session.ts", import.meta.url), "utf8"); + +describe("world model runs while muted", () => { + it("is called ABOVE the proactivePaused() early return", () => { + const tick = src.slice(src.indexOf("private async runAnticipationTick")); + const body = tick.slice(0, tick.indexOf("gatherProactiveSignals")); + const refresh = body.indexOf("this.maybeRefreshWorldModel("); + const paused = body.indexOf("if (this.proactivePaused()) return;"); + expect(refresh).toBeGreaterThan(-1); + expect(paused).toBeGreaterThan(-1); + expect(refresh).toBeLessThan(paused); + }); + + it("still defers for quiet hours / an explicit quiet window", () => { + const tick = src.slice(src.indexOf("private async runAnticipationTick")); + expect(tick).toMatch(/const quiet = isQuietHours\(new Date\(\), defaultQuietHours\(\)\) \|\| now < this\.proactiveMuteUntil;/); + expect(tick).toMatch(/if \(!quiet\) this\.maybeRefreshWorldModel\(target, now\);/); + }); + + it("the killswitch still stops it (do-nothing-at-all switch)", () => { + const tick = src.slice(src.indexOf("private async runAnticipationTick")); + const kill = tick.indexOf("if (this.killSwitch) return;"); + const refresh = tick.indexOf("this.maybeRefreshWorldModel("); + expect(kill).toBeGreaterThan(-1); + expect(kill).toBeLessThan(refresh); + }); +});