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