feat(memory): TTL expiry — supersede stale memories after 28/90 days

- 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>
This commit is contained in:
Mikael Hugo 2026-05-09 21:09:53 +02:00
parent d2eda0cc12
commit 692328ad45
2 changed files with 42 additions and 1 deletions

View file

@ -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 = {

View file

@ -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