// --------------------------------------------------------------------------- // Memory retrospective — enqueue helper. // --------------------------------------------------------------------------- // // Enqueue a `memory_retrospective` job for the given conversation. Gates on: // - `memory.enabled` (the whole memory system) and // `memory.retrospective.enabled` (this pass alone). // - Source conversation isn't a memory-retrospective conversation itself // (recursion guard — we never run a retrospective over reflective // musings from the retrospective agent's own writes). // - Source isn't a `scheduled` thread or a memory-consolidation background // (low yield — see `isLowYieldRetrospectiveSource`). // - The unprocessed tail contains user activity, when // `memory.retrospective.requireUserActivity` is on. Assistant-only // stretches (proactive sends composed by turns running elsewhere) carry // no user turn, so their window anchor is undecidable and their content // is a recap of work captured at its source; the gate defers them until // real user activity arrives. Living here, every trigger kind funnels // through one check. // // All trigger types funnel through `upsertMemoryRetrospectiveJob` which // coalesces rapid enqueues into a single pending row per conversation. // `compaction` gets a small debounce so the job runs after the signal // settles; `interval`, `message_count`, and `sweep` fire immediately. // // The four triggers split by cadence: `interval` / `message_count` / // `compaction` are event-driven — evaluated from the post-turn indexing hook // and the compaction site, so they only fire while a conversation is actively // taking turns. `sweep` is the timer-driven backstop: a scheduled job // (`memory_retrospective_sweep`) re-scans conversations for unprocessed // messages the event triggers missed when a turn ended before its post-turn // hooks ran (crash / IPC drop) and the conversation then went idle. See // `memory-retrospective-sweep.ts`. import { getConfig } from "../../../config/loader.js"; import { getConversation, getConversationSource, } from "../../../persistence/conversation-crud.js"; import { MEMORY_V2_CONSOLIDATION_SOURCE } from "../../../persistence/conversation-types.js"; import { isMemoryEnabled, upsertMemoryRetrospectiveJob, } from "../../../persistence/jobs-store.js"; import { type TrustClass } from "../../../runtime/actor-trust-resolver.js"; import { resolveCapabilities } from "../../../runtime/capabilities.js"; import { getLogger } from "./logging.js"; import { hasQualifyingUserMessageAfter } from "./memory-retrospective-accounting.js"; import { isMemoryRetrospectiveSource } from "./memory-retrospective-constants.js"; import { getRetrospectiveState } from "./memory-retrospective-state.js"; const log = getLogger("memory-retrospective-enqueue"); export type MemoryRetrospectiveTrigger = | "interval" | "message_count" | "compaction" | "sweep"; const COMPACTION_DEBOUNCE_MS = 500; /** * Returns true when a job row was upserted, false when a gate skipped the * enqueue (or the upsert failed). The sweep counts only true returns toward * its per-pass cap. */ export function enqueueMemoryRetrospectiveIfEnabled(args: { conversationId: string; trigger: MemoryRetrospectiveTrigger; }): boolean { const { conversationId, trigger } = args; if (!isMemoryEnabled()) { return false; } if (!isRetrospectiveEnabled()) { log.debug( { conversationId, trigger }, "Skipping memory-retrospective enqueue: memory.retrospective.enabled is false", ); return false; } if (isMemoryRetrospectiveConversation(conversationId)) { log.debug( { conversationId, trigger }, "Skipping memory-retrospective enqueue: source is a memory-retrospective conversation", ); return false; } if (isLowYieldRetrospectiveSource(conversationId)) { log.debug( { conversationId, trigger }, "Skipping memory-retrospective enqueue: scheduled or consolidation source", ); return false; } if (!passesUserActivityGate(conversationId, trigger)) { return false; } const runAfter = trigger === "compaction" ? Date.now() + COMPACTION_DEBOUNCE_MS : Date.now(); try { upsertMemoryRetrospectiveJob({ conversationId }, runAfter); } catch (err) { log.warn( { err, conversationId, trigger }, "Failed to upsert memory-retrospective job", ); return false; } return true; } /** * The `memory.retrospective.enabled` kill switch. Deliberately fails CLOSED, * unlike the heuristic gates around it: this flag exists to stop a * fork-driven write burst from locking the DB, so an unreadable config must * not resurrect the very work an operator turned off. A read that throws is * logged rather than swallowed silently. */ function isRetrospectiveEnabled(): boolean { try { return getConfig().memory.retrospective.enabled; } catch (err) { log.warn( { err }, "Could not read memory.retrospective.enabled; treating retrospectives as disabled", ); return false; } } /** * The `memory.retrospective.requireUserActivity` gate: pass when the config * is off or the unprocessed tail (everything after the conversation's * `lastProcessedMessageId`) contains at least one user message carrying * non-tool_result content. A gate that cannot be evaluated (config or DB * unavailable) passes — an unevaluable gate must not silence retrospectives. */ function passesUserActivityGate( conversationId: string, trigger: MemoryRetrospectiveTrigger, ): boolean { try { if (!getConfig().memory.retrospective.requireUserActivity) { return true; } const state = getRetrospectiveState(conversationId); if ( hasQualifyingUserMessageAfter( conversationId, state?.lastProcessedMessageId ?? null, ) ) { return true; } log.debug( { conversationId, trigger }, "Skipping memory-retrospective enqueue: no user activity in the unprocessed tail", ); return false; } catch (err) { log.warn( { err, conversationId, trigger }, "User-activity gate check failed; enqueueing anyway", ); return true; } } /** * Recursion guard. The retrospective bootstraps its own background * conversation; without this check, that conversation's lifecycle would * enqueue another retrospective on top of it, recursing. */ export function isMemoryRetrospectiveConversation( conversationId: string, ): boolean { const source = getConversationSource(conversationId); return source !== null && isMemoryRetrospectiveSource(source); } /** * Scheduled task threads (location/health pulses) rarely carry anything worth * remembering, and memory-consolidation conversations already persist their * output to the corpus — a retrospective over either burns an inference pass * for no unique gain (and, for consolidation, re-stores already-captured * content). Heartbeat (`background`) and standard conversations are unaffected. */ function isLowYieldRetrospectiveSource(conversationId: string): boolean { const conversation = getConversation(conversationId); if (!conversation) { return false; } return ( conversation.conversationType === "scheduled" || conversation.source === MEMORY_V2_CONSOLIDATION_SOURCE ); } /** * Fire a memory-retrospective enqueue from the compaction site. Trust-class * gated (don't run a guardian-trust background loop over untrusted-actor * conversations) with best-effort error swallowing (never block compaction * on enqueue failures). */ export function enqueueMemoryRetrospectiveOnCompaction( conversationId: string, trustClass: TrustClass | undefined, ): void { if (!resolveCapabilities(trustClass).canAccessMemory) { return; } try { enqueueMemoryRetrospectiveIfEnabled({ conversationId, trigger: "compaction", }); } catch { // Best-effort — never block compaction on enqueue failures. } }