Files
Moltbot/src/imessage/monitor/echo-cache.ts
OfflynAI adb9234d03 fix(imessage): prevent echo loop from leaking internal metadata and amplifying NO_REPLY into queue overflow (#33295)
* fix(imessage): prevent echo loop from leaking internal metadata and amplifying NO_REPLY into queue overflow

- Add outbound sanitization at channel boundary (sanitize-outbound.ts):
  strips thinking/reasoning tags, relevant-memories tags, model-specific
  separators (+#+#), and assistant role markers before iMessage delivery

- Add inbound reflection guard (reflection-guard.ts): detects and drops
  messages containing assistant-internal markers that indicate a reflected
  outbound message, preventing recursive echo amplification

- Harden echo cache: increase text TTL from 5s to 30s to catch delayed
  reflections that previously expired before the echo could be detected

- Add loop rate limiter (loop-rate-limiter.ts): per-conversation rapid-fire
  detection that suppresses conversations exceeding threshold within a
  time window, acting as a safety net against amplification

Closes #33281

* fix(imessage): address review — stricter reflection regex, loop-aware rate limiter

- Reflection guard: require closing > bracket on thinking/final/memory
  tag patterns to prevent false-positives on user phrases like
  '<final answer>' or '<thought experiment>' (#33295 review)

- Rate limiter: only record echo/reflection/from-me drops instead of
  all dispatches, so the limiter acts as a loop-specific escalation
  mechanism rather than a general throttle on normal conversation
  velocity (#33295 review)

* Changelog: add iMessage echo-loop hardening entry

* iMessage: restore short echo-text TTL

* iMessage: ignore reflection markers in code

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-03-06 19:19:57 -05:00

88 lines
2.6 KiB
TypeScript

export type SentMessageLookup = {
text?: string;
messageId?: string;
};
export type SentMessageCache = {
remember: (scope: string, lookup: SentMessageLookup) => void;
has: (scope: string, lookup: SentMessageLookup) => boolean;
};
// Keep the text fallback short so repeated user replies like "ok" are not
// suppressed for long; delayed reflections should match the stronger message-id key.
const SENT_MESSAGE_TEXT_TTL_MS = 5_000;
const SENT_MESSAGE_ID_TTL_MS = 60_000;
function normalizeEchoTextKey(text: string | undefined): string | null {
if (!text) {
return null;
}
const normalized = text.replace(/\r\n?/g, "\n").trim();
return normalized ? normalized : null;
}
function normalizeEchoMessageIdKey(messageId: string | undefined): string | null {
if (!messageId) {
return null;
}
const normalized = messageId.trim();
if (!normalized || normalized === "ok" || normalized === "unknown") {
return null;
}
return normalized;
}
class DefaultSentMessageCache implements SentMessageCache {
private textCache = new Map<string, number>();
private messageIdCache = new Map<string, number>();
remember(scope: string, lookup: SentMessageLookup): void {
const textKey = normalizeEchoTextKey(lookup.text);
if (textKey) {
this.textCache.set(`${scope}:${textKey}`, Date.now());
}
const messageIdKey = normalizeEchoMessageIdKey(lookup.messageId);
if (messageIdKey) {
this.messageIdCache.set(`${scope}:${messageIdKey}`, Date.now());
}
this.cleanup();
}
has(scope: string, lookup: SentMessageLookup): boolean {
this.cleanup();
const messageIdKey = normalizeEchoMessageIdKey(lookup.messageId);
if (messageIdKey) {
const idTimestamp = this.messageIdCache.get(`${scope}:${messageIdKey}`);
if (idTimestamp && Date.now() - idTimestamp <= SENT_MESSAGE_ID_TTL_MS) {
return true;
}
}
const textKey = normalizeEchoTextKey(lookup.text);
if (textKey) {
const textTimestamp = this.textCache.get(`${scope}:${textKey}`);
if (textTimestamp && Date.now() - textTimestamp <= SENT_MESSAGE_TEXT_TTL_MS) {
return true;
}
}
return false;
}
private cleanup(): void {
const now = Date.now();
for (const [key, timestamp] of this.textCache.entries()) {
if (now - timestamp > SENT_MESSAGE_TEXT_TTL_MS) {
this.textCache.delete(key);
}
}
for (const [key, timestamp] of this.messageIdCache.entries()) {
if (now - timestamp > SENT_MESSAGE_ID_TTL_MS) {
this.messageIdCache.delete(key);
}
}
}
}
export function createSentMessageCache(): SentMessageCache {
return new DefaultSentMessageCache();
}