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