From 692328ad455732fdfbc4e590e2f5214ab7fcd90a Mon Sep 17 00:00:00 2001 From: Mikael Hugo Date: Sat, 9 May 2026 21:09:53 +0200 Subject: [PATCH] =?UTF-8?q?feat(memory):=20TTL=20expiry=20=E2=80=94=20supe?= =?UTF-8?q?rsede=20stale=20memories=20after=2028/90=20days?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add expireStaleMemories(unstartedTtlDays=28, maxTtlDays=90) to sf-db.js - Never-accessed (hit_count=0) memories expire after 28 days - All memories expire after 90 days regardless of hit_count - Marks superseded_by='ttl-expired' (non-destructive, same as CAP_EXCEEDED pattern) - Returns count of expired memories (non-fatal on failure) - Call from auto-start.js after DB opens at autonomous session start - Logs warning with count if any memories expired - Catches errors silently — TTL failure never blocks autonomous start Mirrors Copilot Memory's 28-day TTL model learned from research. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/resources/extensions/sf/auto-start.js | 14 ++++++++++- src/resources/extensions/sf/sf-db.js | 29 +++++++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/src/resources/extensions/sf/auto-start.js b/src/resources/extensions/sf/auto-start.js index 630a5a22f..b84d0c39e 100644 --- a/src/resources/extensions/sf/auto-start.js +++ b/src/resources/extensions/sf/auto-start.js @@ -87,7 +87,7 @@ import { updateSessionLock, } from "./session-lock.js"; import { getSessionModelOverride } from "./session-model-override.js"; -import { getMilestone, isDbAvailable, openDatabase } from "./sf-db.js"; +import { expireStaleMemories, getMilestone, isDbAvailable, openDatabase } from "./sf-db.js"; import { snapshotSkills } from "./skill-discovery.js"; import { deriveState, isGhostMilestone } from "./state.js"; import { isClosedStatus } from "./status-guards.js"; @@ -1036,6 +1036,18 @@ export async function bootstrapAutoSession( } // Initialize routing history initRoutingHistory(s.basePath); + // Expire stale memories to prevent poisoning future sessions. + // Never-accessed memories expire after 28 days; all memories after 90 days. + if (isDbAvailable()) { + try { + const expired = expireStaleMemories(); + if (expired > 0) { + logWarning("engine", `Expired ${expired} stale ${expired === 1 ? "memory" : "memories"} (TTL exceeded)`); + } + } catch { + // Non-fatal — TTL expiry failure must not block autonomous start + } + } // Restore the model that was active when auto bootstrap began (#650, #2829). if (startModelSnapshot) { s.autoModeStartModel = { diff --git a/src/resources/extensions/sf/sf-db.js b/src/resources/extensions/sf/sf-db.js index 04f586b0b..7af92b629 100644 --- a/src/resources/extensions/sf/sf-db.js +++ b/src/resources/extensions/sf/sf-db.js @@ -7720,6 +7720,35 @@ export function decayMemoriesBefore(cutoffTs, now) { WHERE superseded_by IS NULL AND updated_at < :cutoff AND confidence > 0.1`) .run({ ":now": now, ":cutoff": cutoffTs }); } +/** + * Supersede memories that have exceeded their TTL. + * + * Purpose: prevent stale memories from silently poisoning future sessions. + * Mirrors Copilot Memory's 28-day TTL model — memories that were never + * accessed expire sooner; memories actively used get a longer lease. + * + * Rules: + * - Never accessed (hit_count = 0) + older than unstartedTtlDays → expire + * - Any memory older than maxTtlDays → expire regardless of hit_count + * + * Consumer: called at autonomous mode startup from auto-start.js. + * Returns the number of memories superseded. + */ +export function expireStaleMemories(unstartedTtlDays = 28, maxTtlDays = 90) { + if (!currentDb) return 0; + const now = new Date().toISOString(); + const cutoffUnstarted = new Date(Date.now() - unstartedTtlDays * 86_400_000).toISOString(); + const cutoffMax = new Date(Date.now() - maxTtlDays * 86_400_000).toISOString(); + const result = currentDb + .prepare(`UPDATE memories SET superseded_by = 'ttl-expired', updated_at = :now + WHERE superseded_by IS NULL + AND ( + (hit_count = 0 AND updated_at < :cutoff_unstarted) + OR updated_at < :cutoff_max + )`) + .run({ ":now": now, ":cutoff_unstarted": cutoffUnstarted, ":cutoff_max": cutoffMax }); + return result.changes ?? 0; +} export function supersedeLowestRankedMemories(limit, now) { if (!currentDb) throw new SFError(SF_STALE_STATE, "sf-db: No database open"); currentDb