Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 14 additions & 5 deletions services/imessage-bridge/src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
39 changes: 39 additions & 0 deletions services/imessage-bridge/src/world-model-mute-scope.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading