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); + }); +});