From 942b42709159aa652ec3b348dc4f70684bef007b Mon Sep 17 00:00:00 2001 From: ARIA Date: Wed, 20 May 2026 22:06:14 +0000 Subject: [PATCH 1/4] ARIA self-improvement: filter newsletter senders from active-threads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drift audit 2026-05-20 flagged that the "Active threads" section of the working-memory prompt was being polluted by promotional/automated streams (currently the AutoScout24 "Nieuwe matches voor je Zoekopdracht" newsletter was the *only* active thread shown). Real signal-to-noise on that section had dropped to 0%. Defense in depth: - working-memory.ts: introduce isNewsletterParticipant() and reject new threads whose sender/chat matches noreply / no-reply / notifications. / newsletter / savedsearches / mailings. / updates@ / bounce, or known one-way notification domains (autoscout24, schoolkassa, rdw, anwb notifications). Also sweep any pre-existing newsletter threads on every update tick — fixes the currently-stuck AutoScout24 entry. - brain-prompt.ts: filter active threads at render time using the same helper, so even if a newsletter slips past the write-time guard via another path, the prompt stays clean. Intent-summary: Newsletter and automation senders were being promoted to "active conversation threads" in the working-memory prompt, crowding out real conversations Gillis is in. Intent-tokens: newsletter, noise, active-threads, prompt-pollution, working-memory, automation-sender, filter Co-Authored-By: Claude Opus 4.7 --- backend/brain-prompt.ts | 13 +++++++-- backend/memory/working-memory.ts | 48 ++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 2 deletions(-) diff --git a/backend/brain-prompt.ts b/backend/brain-prompt.ts index f62fd95f..3235a0a2 100644 --- a/backend/brain-prompt.ts +++ b/backend/brain-prompt.ts @@ -2,6 +2,7 @@ import type { Observation } from "./observer.js"; import type { MemoryNode, WorkingMemory } from "./memory/types.js"; import type { MemoryGraph } from "./memory/graph.js"; import { serializeNodesForPrompt, collectRelevantRejectedEdges, formatRejectedEdgesForPrompt } from "./memory/activation.js"; +import { isNewsletterParticipant } from "./memory/working-memory.js"; import { ariaPersonality } from "./aria-identity.js"; import type { CharacterOverride } from "./aria-identity.js"; import { getBrainConfig, getCharacterPreset, getOwnerLocalTime } from "./brain-config.js"; @@ -316,9 +317,17 @@ function formatWorkingMemory(wm: WorkingMemory): string { parts.push(`Follow-ups:\n${fuLines.join("\n")}`); } - // Active conversation threads + // Active conversation threads — filter newsletter/automation participants so + // promotional streams (AutoScout24 saved searches, no-reply notifications, etc.) + // don't crowd out real conversations in the prompt. if (wm.conversationThreads && wm.conversationThreads.length > 0) { - const activeThreads = wm.conversationThreads.filter(t => t.status === "active").slice(0, 5); + const activeThreads = wm.conversationThreads + .filter(t => t.status === "active") + .filter(t => { + const list = Array.isArray(t.participants) ? t.participants : (t.participants ? [t.participants] : []); + return !list.some(p => isNewsletterParticipant(p)); + }) + .slice(0, 5); if (activeThreads.length > 0) { const threadLines = activeThreads.map(t => { const who = Array.isArray(t.participants) ? t.participants.join(", ") : (t.participants || "unknown"); diff --git a/backend/memory/working-memory.ts b/backend/memory/working-memory.ts index 119e58fd..73365859 100644 --- a/backend/memory/working-memory.ts +++ b/backend/memory/working-memory.ts @@ -181,6 +181,46 @@ export function populateTemporalContext(wm: WorkingMemory): void { // ── Conversation Thread Tracking ── +// Newsletter / automation sender patterns. If a participant string matches one +// of these, the thread is treated as one-way noise — never promoted to "active" +// in the prompt, and never written as a fresh thread. Defense in depth: applied +// at both write time (here) and render time (brain-prompt.ts). +const NEWSLETTER_SUBSTRINGS = [ + "noreply", + "no-reply", + "notifications.", + "newsletter", + "savedsearches", + "mailings.", + "updates@", + "bounce", +]; + +const NEWSLETTER_DOMAINS = [ + "autoscout24", + "schoolkassa", + "rdw", + "anwb.nl/notifications", +]; + +export function isNewsletterParticipant(participant: string | undefined | null): boolean { + if (!participant) return false; + const p = participant.toLowerCase(); + for (const sub of NEWSLETTER_SUBSTRINGS) { + if (p.includes(sub)) return true; + } + for (const dom of NEWSLETTER_DOMAINS) { + if (p.includes(dom)) return true; + } + return false; +} + +function threadHasNewsletterParticipant(participants: string[] | string | undefined): boolean { + if (!participants) return false; + const list = Array.isArray(participants) ? participants : [participants]; + return list.some(isNewsletterParticipant); +} + export function updateConversationThreads(wm: WorkingMemory, observations: Observation[]): void { const now = Date.now(); const STALE_THRESHOLD = 48 * 60 * 60 * 1000; // 48 hours @@ -194,6 +234,10 @@ export function updateConversationThreads(wm: WorkingMemory, observations: Obser let thread = wm.conversationThreads.find(t => t.id === key); if (!thread) { + // Reject newsletter/automation senders at write time — they're never real conversations. + if (isNewsletterParticipant(obs.sender) || isNewsletterParticipant(obs.chatName) || isNewsletterParticipant(obs.chatJid)) { + continue; + } thread = { id: key, participants: [obs.sender], @@ -214,6 +258,10 @@ export function updateConversationThreads(wm: WorkingMemory, observations: Obser } } + // Sweep any pre-existing newsletter threads that slipped in during prior ticks + // (before this guard was added, or via an alternative write path). + wm.conversationThreads = wm.conversationThreads.filter(t => !threadHasNewsletterParticipant(t.participants)); + // Thread lifecycle: active → stale (48h) → closed (7d) → removed (14d) const CLOSED_THRESHOLD = 7 * 24 * 60 * 60 * 1000; // 7 days since last message const REMOVE_THRESHOLD = 14 * 24 * 60 * 60 * 1000; // 14 days since last message From 75a812935acf54b8d60bdaba571d18a6bfb45754 Mon Sep 17 00:00:00 2001 From: ARIA Date: Sat, 23 May 2026 10:07:47 +0000 Subject: [PATCH 2/4] ARIA self-improvement: skip commitment extraction on sa_moltbook sub-agent run summaries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reflect-tick commitment-review surface was mining intra-run scratch text from sa_moltbook sub-agent run summaries/details (e.g. "I'll reply to the 6 highest-signal comments", "let me write a helper that handles verification") and surfacing them as personal commitments needing follow-through. Those phrases were sub-agent self-narration about actions it already executed within that same run — not promises to a human channel. Fix in buildCommitmentsBlock (backend/brain-prompt.ts): the Moltbook activity coming from getRecentMoltbookActivity() is sourced entirely from sub-agent run summary/details fields, so stop running extractAndClassifyCommitments() over it. Still show the activity for context, but explicitly label the section as "already executed, NOT personal commitments" and add an action-line note telling reflect not to treat those phrases as promises. extractAndClassifyCommitments() is still applied to recentOutgoingActivity (whatsapp DMs, email, brain messages to human channels), where the audience is actually a human and commitment language is meaningful. Intent-summary: phantom commitments were being surfaced from sub-agent intra-run narrative text because the commitment extractor did not distinguish sub-agent task transcripts from real human-channel messages. Intent-tokens: subagent, commitment, attribution, phantom, moltbook, transcript, narration Co-Authored-By: Claude Opus 4.7 --- backend/brain-prompt.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/backend/brain-prompt.ts b/backend/brain-prompt.ts index 3235a0a2..2d3fc8e9 100644 --- a/backend/brain-prompt.ts +++ b/backend/brain-prompt.ts @@ -904,13 +904,14 @@ function buildCommitmentsBlock( ): string { const sections: string[] = []; - // Moltbook-specific section (backwards compat) + // Moltbook-specific section. recentMoltbookActivity is sourced from the + // sa_moltbook sub-agent's run summary/details fields — intra-run scratch + // narrative ("I'll reply to 6 comments", "let me write a helper") that was + // already executed within that sub-agent run. Show the activity for + // context, but do NOT mine commitments from it: those phrases are not + // promises made to a human channel, they are sub-agent self-narration. if (recentMoltbookActivity && recentMoltbookActivity.length > 0) { - const moltbookCommitments = recentMoltbookActivity.flatMap(text => extractAndClassifyCommitments(text)); - const detectedSection = moltbookCommitments.length > 0 - ? `\nDetected commitment language in Moltbook posts:\n${moltbookCommitments.map(c => `- [${c.weight}] "${c.commitment}" (pattern: ${c.pattern})`).join("\n")}\n` - : ""; - sections.push(`Moltbook posts/comments:\n${detectedSection}${recentMoltbookActivity.map((text, i) => ` ${i + 1}. ${text.slice(0, 300)}`).join("\n")}`); + sections.push(`Moltbook posts/comments (sa_moltbook sub-agent run summaries — already executed, NOT personal commitments):\n${recentMoltbookActivity.map((text, i) => ` ${i + 1}. ${text.slice(0, 300)}`).join("\n")}`); } // General outgoing activity (WhatsApp, email, brain messages) — grouped by conversation @@ -946,6 +947,7 @@ ACTION REQUIRED: 3. For any non-trivial commitment not already tracked, create a goal via goalOps. 4. Trivial commitments (quick lookups/checks) are filtered out automatically. 5. Update progress on existing commitment-sourced goals. +6. Moltbook sub-agent run summaries are shown for context only — do NOT treat phrases like "I'll reply to X comments" or "let me write a helper" inside those summaries as personal commitments. They were already executed inside the sub-agent run. `; } From 9aa4c8e5f83c284a4ef5c08447da4626ffcd2814 Mon Sep 17 00:00:00 2001 From: ARIA Date: Tue, 26 May 2026 22:05:18 +0000 Subject: [PATCH 3/4] ARIA self-improvement: cap sa_moltbook run summaries to 3 most recent, 150 chars The commitment-review block in buildCommitmentsBlock() listed all ~10 sa_moltbook sub-agent run summaries at 300 chars each. These are explicitly context-only / non-actionable (per 75a8129), so the full list wastes ~3KB of reflect-prompt budget every cycle without changing any decision. Slice to the 3 most recent entries and truncate each to 150 chars. Non-actionable labeling is unchanged; display volume only. Intent-summary: Non-actionable sub-agent run summaries flooded the reflect prompt, crowding out decision-relevant context with redundant noise. Intent-tokens: prompt, noise, truncation, moltbook, reflect, context, verbosity Co-Authored-By: Claude Opus 4.7 --- backend/brain-prompt.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/backend/brain-prompt.ts b/backend/brain-prompt.ts index 2d3fc8e9..ddc7be41 100644 --- a/backend/brain-prompt.ts +++ b/backend/brain-prompt.ts @@ -911,7 +911,11 @@ function buildCommitmentsBlock( // context, but do NOT mine commitments from it: those phrases are not // promises made to a human channel, they are sub-agent self-narration. if (recentMoltbookActivity && recentMoltbookActivity.length > 0) { - sections.push(`Moltbook posts/comments (sa_moltbook sub-agent run summaries — already executed, NOT personal commitments):\n${recentMoltbookActivity.map((text, i) => ` ${i + 1}. ${text.slice(0, 300)}`).join("\n")}`); + // These are non-actionable context only, so cap display to the 3 most + // recent runs and shorten each summary — showing all ~10 every reflect + // wastes prompt budget without changing decisions. + const moltbookForDisplay = recentMoltbookActivity.slice(0, 3); + sections.push(`Moltbook posts/comments (sa_moltbook sub-agent run summaries — already executed, NOT personal commitments):\n${moltbookForDisplay.map((text, i) => ` ${i + 1}. ${text.slice(0, 150)}`).join("\n")}`); } // General outgoing activity (WhatsApp, email, brain messages) — grouped by conversation From 154e7c08aa5e0ee678991c56838740f863b46df0 Mon Sep 17 00:00:00 2001 From: ARIA Date: Sun, 31 May 2026 10:09:30 +0000 Subject: [PATCH 4/4] ARIA self-improvement: extend promotional sender filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Today an AliExpress promo blast ("AliExpress : Uw voertuig wacht op u") still showed up as an active thread in the working-memory prompt. The newsletter filter from 942b427 didn't catch it because (a) the gmail-side PROMOTIONAL_SENDER_PATTERNS was a tiny allowlist of exact regexes and (b) the working-memory NEWSLETTER_* patterns didn't cover generic promotional mailbox prefixes or known bulk-marketing domains. Extensions: - backend/integrations/gmail.ts — isPromotionalSender now also matches strong promotional local-parts (promotion / promotions / marketing / newsletter / news / deals / offers / mailing), known bulk-marketing domains (aliexpress.com, temu.com, shein.com, wish.com, banggood.com, matched with subdomain support), and weak prefixes (info / hello / hi) *only when* the subject is clickbait. The clickbait subject patterns cover both NL and EN: "wacht op u", "klik hier", "X% korting", "laatste kans", "limited time", "act now", "alleen vandaag", etc. Subject extraction was moved above the filter so it can be passed in. - backend/memory/working-memory.ts — NEWSLETTER_SUBSTRINGS now also matches promotion@ / promotions@ / marketing@ / news@ / newsletter@ / deals@ / offers@ / info@ / mailing@ prefixes inside participant strings, and NEWSLETTER_DOMAINS adds the same bulk-marketing retailers. New isClickbaitTopic() helper applies the same clickbait patterns to thread topics (first 60 chars of obs text, which for emails starts with "[EMAIL] Subject: …"), used both at write time and in the sweep step so already-stuck promo threads get evicted on the next update tick. - backend/brain-prompt.ts — render-time active-threads filter also drops threads whose topic matches a clickbait pattern, even if the sender slipped past the participant filter. Defense in depth: same patterns applied at intake (gmail.ts), thread-write (working-memory.ts), and prompt-render (brain-prompt.ts). Note: observer.ts and history.ts were listed as candidate target files but neither contains email-filtering logic — the real plumbing lives in gmail.ts (intake) and working-memory.ts (active-threads). No changes needed there. Intent-summary: Promotional email blasts from generic mailbox prefixes (promotion@, marketing@) and mixed-use bulk-marketing domains (aliexpress.com, temu.com) were slipping past the newsletter-sender filter and showing up as active conversation threads in the prompt. Intent-tokens: promotional, marketing, active-threads, prompt-pollution, clickbait, aliexpress, mailbox-prefix, filter Co-Authored-By: Claude Opus 4.7 --- backend/brain-prompt.ts | 4 +- backend/integrations/gmail.ts | 95 +++++++++++++++++++++++++++++--- backend/memory/working-memory.ts | 55 +++++++++++++++++- 3 files changed, 142 insertions(+), 12 deletions(-) diff --git a/backend/brain-prompt.ts b/backend/brain-prompt.ts index ddc7be41..d6794aa8 100644 --- a/backend/brain-prompt.ts +++ b/backend/brain-prompt.ts @@ -2,7 +2,7 @@ import type { Observation } from "./observer.js"; import type { MemoryNode, WorkingMemory } from "./memory/types.js"; import type { MemoryGraph } from "./memory/graph.js"; import { serializeNodesForPrompt, collectRelevantRejectedEdges, formatRejectedEdgesForPrompt } from "./memory/activation.js"; -import { isNewsletterParticipant } from "./memory/working-memory.js"; +import { isNewsletterParticipant, isClickbaitTopic } from "./memory/working-memory.js"; import { ariaPersonality } from "./aria-identity.js"; import type { CharacterOverride } from "./aria-identity.js"; import { getBrainConfig, getCharacterPreset, getOwnerLocalTime } from "./brain-config.js"; @@ -325,7 +325,7 @@ function formatWorkingMemory(wm: WorkingMemory): string { .filter(t => t.status === "active") .filter(t => { const list = Array.isArray(t.participants) ? t.participants : (t.participants ? [t.participants] : []); - return !list.some(p => isNewsletterParticipant(p)); + return !list.some(p => isNewsletterParticipant(p)) && !isClickbaitTopic(t.topic); }) .slice(0, 5); if (activeThreads.length > 0) { diff --git a/backend/integrations/gmail.ts b/backend/integrations/gmail.ts index 1ff9189f..12a4303f 100644 --- a/backend/integrations/gmail.ts +++ b/backend/integrations/gmail.ts @@ -214,6 +214,57 @@ const PROMOTIONAL_SENDER_PATTERNS: RegExp[] = [ /calendar-notification@google\.com/i, ]; +// Strong promotional mailbox prefixes — local-part of the email address that +// is almost never used for real correspondence. Matched against the local part +// only (before "@") so we don't accidentally hit a domain that contains the +// substring. +const STRONG_PROMOTIONAL_LOCALPARTS: RegExp[] = [ + /^promotion[s]?$/i, + /^marketing$/i, + /^newsletter[s]?$/i, + /^news$/i, + /^deals$/i, + /^offers$/i, + /^mailing[s]?$/i, +]; + +// Weak promotional local parts — used by some legitimate businesses for real +// contact, so we only treat as promotional when paired with a clickbait subject. +const WEAK_PROMOTIONAL_LOCALPARTS: RegExp[] = [ + /^info$/i, + /^hello$/i, + /^hi$/i, +]; + +// Domains that send essentially nothing but bulk marketing. Sender from these +// is always treated as promotional regardless of local part. +const PROMOTIONAL_DOMAINS = [ + "aliexpress.com", + "temu.com", + "shein.com", + "wish.com", + "banggood.com", +]; + +// Clickbait subject patterns. Vague urgency / scarcity / "your X is waiting" +// copy that doesn't match how Gillis's actual correspondents write subject +// lines. Used in combination with weak promotional prefixes to catch promo +// blasts from mixed-use mailboxes like promotion@aliexpress.com sending +// "Uw voertuig wacht op u". +const CLICKBAIT_SUBJECT_PATTERNS: RegExp[] = [ + /wacht\s+op\s+u\b/i, + /\bklik\s+hier\b/i, + /\b\d{1,3}\s*%\s*(korting|off|discount)\b/i, + /\blaatste\s+kans\b/i, + /\blast\s+chance\b/i, + /\blimited\s+time\b/i, + /\bbeperkte?\s+aanbieding\b/i, + /\bspecial\s+offer\b/i, + /\bact\s+now\b/i, + /\bonly\s+today\b/i, + /\balleen\s+vandaag\b/i, +]; + /** * Extract the bare email address from a From header value. * "Display Name " → "user@example.com" @@ -223,9 +274,37 @@ function extractEmailAddress(from: string): string { return match ? match[1] : from.trim(); } -function isPromotionalSender(from: string): boolean { - const addr = extractEmailAddress(from); - return PROMOTIONAL_SENDER_PATTERNS.some(re => re.test(addr)); +function splitAddress(addr: string): { local: string; domain: string } { + const at = addr.lastIndexOf("@"); + if (at < 0) return { local: addr, domain: "" }; + return { local: addr.slice(0, at), domain: addr.slice(at + 1) }; +} + +function isClickbaitSubject(subject: string): boolean { + if (!subject) return false; + return CLICKBAIT_SUBJECT_PATTERNS.some(re => re.test(subject)); +} + +function isPromotionalSender(from: string, subject = ""): boolean { + const addr = extractEmailAddress(from).toLowerCase(); + if (PROMOTIONAL_SENDER_PATTERNS.some(re => re.test(addr))) return true; + + const { local, domain } = splitAddress(addr); + + // Known mass-promotional domains — always drop. + if (PROMOTIONAL_DOMAINS.some(d => domain === d || domain.endsWith("." + d))) return true; + + // Strong promotional prefixes — always drop. + if (STRONG_PROMOTIONAL_LOCALPARTS.some(re => re.test(local))) return true; + + // Weak prefixes (info@, hello@, hi@) — only drop when subject is clickbait. + // Real correspondence from info@ stays intact; promo blasts + // from info@ get filtered. + if (WEAK_PROMOTIONAL_LOCALPARTS.some(re => re.test(local)) && isClickbaitSubject(subject)) { + return true; + } + + return false; } async function fetchNewEmails(account: GmailAccount, state: GmailState): Promise { @@ -285,15 +364,17 @@ async function fetchNewEmails(account: GmailAccount, state: GmailState): Promise const headers = msg.payload?.headers; const from = getHeader(headers, "From"); + const subject = getHeader(headers, "Subject"); - // Drop known promotional senders before they consume brain context - if (isPromotionalSender(from)) { - log(`Skipped promotional email from ${from}`); + // Drop known promotional senders before they consume brain context. + // Subject is passed in so weak prefixes (info@, hello@) only trip the + // filter when paired with clickbait copy. + if (isPromotionalSender(from, subject)) { + log(`Skipped promotional email from ${from} (subject: "${subject}")`); return; } const to = getHeader(headers, "To"); - const subject = getHeader(headers, "Subject"); const body = msg.payload ? extractBody(msg.payload) : ""; const snippet = msg.snippet || ""; diff --git a/backend/memory/working-memory.ts b/backend/memory/working-memory.ts index 73365859..4e078d30 100644 --- a/backend/memory/working-memory.ts +++ b/backend/memory/working-memory.ts @@ -194,6 +194,17 @@ const NEWSLETTER_SUBSTRINGS = [ "mailings.", "updates@", "bounce", + // Promotional/automation mailbox prefixes. Participant strings look like + // "Display Name ", so substring match on "prefix@" is enough. + "promotion@", + "promotions@", + "marketing@", + "news@", + "newsletter@", + "deals@", + "offers@", + "info@", + "mailing@", ]; const NEWSLETTER_DOMAINS = [ @@ -201,6 +212,31 @@ const NEWSLETTER_DOMAINS = [ "schoolkassa", "rdw", "anwb.nl/notifications", + // Mass-promotional retail domains — sender from these is always marketing. + "aliexpress.com", + "temu.com", + "shein.com", + "wish.com", + "banggood.com", +]; + +// Clickbait subject/topic patterns. Email observations are stored as +// "[EMAIL] Subject: \n\n" so the thread topic (first 60 chars) +// captures the subject. If the topic matches one of these marketing tropes, +// the thread is treated as noise even when the sender slips past the address +// filter (e.g. a mixed-use domain that also sends real mail). +const CLICKBAIT_TOPIC_PATTERNS: RegExp[] = [ + /wacht\s+op\s+u\b/i, + /\bklik\s+hier\b/i, + /\b\d{1,3}\s*%\s*(korting|off|discount)\b/i, + /\blaatste\s+kans\b/i, + /\blast\s+chance\b/i, + /\blimited\s+time\b/i, + /\bbeperkte?\s+aanbieding\b/i, + /\bspecial\s+offer\b/i, + /\bact\s+now\b/i, + /\bonly\s+today\b/i, + /\balleen\s+vandaag\b/i, ]; export function isNewsletterParticipant(participant: string | undefined | null): boolean { @@ -215,6 +251,11 @@ export function isNewsletterParticipant(participant: string | undefined | null): return false; } +export function isClickbaitTopic(topic: string | undefined | null): boolean { + if (!topic) return false; + return CLICKBAIT_TOPIC_PATTERNS.some(re => re.test(topic)); +} + function threadHasNewsletterParticipant(participants: string[] | string | undefined): boolean { if (!participants) return false; const list = Array.isArray(participants) ? participants : [participants]; @@ -238,6 +279,10 @@ export function updateConversationThreads(wm: WorkingMemory, observations: Obser if (isNewsletterParticipant(obs.sender) || isNewsletterParticipant(obs.chatName) || isNewsletterParticipant(obs.chatJid)) { continue; } + // Also reject if the topic (first 60 chars of text) looks like marketing clickbait. + if (isClickbaitTopic(obs.text.slice(0, 60))) { + continue; + } thread = { id: key, participants: [obs.sender], @@ -258,9 +303,13 @@ export function updateConversationThreads(wm: WorkingMemory, observations: Obser } } - // Sweep any pre-existing newsletter threads that slipped in during prior ticks - // (before this guard was added, or via an alternative write path). - wm.conversationThreads = wm.conversationThreads.filter(t => !threadHasNewsletterParticipant(t.participants)); + // Sweep any pre-existing newsletter / clickbait threads that slipped in + // during prior ticks (before these guards were added, or via an alternative + // write path). Fixes already-stuck entries like the AliExpress + // "Uw voertuig wacht op u" promo blast. + wm.conversationThreads = wm.conversationThreads.filter( + t => !threadHasNewsletterParticipant(t.participants) && !isClickbaitTopic(t.topic), + ); // Thread lifecycle: active → stale (48h) → closed (7d) → removed (14d) const CLOSED_THRESHOLD = 7 * 24 * 60 * 60 * 1000; // 7 days since last message