diff --git a/scripts/memory-diag.ts b/scripts/memory-diag.ts index e28909c..e6e349e 100644 --- a/scripts/memory-diag.ts +++ b/scripts/memory-diag.ts @@ -12,7 +12,13 @@ import { dataHome, extractionRejectionLogPath, migrationLogPath, workspaceKey, w import { assessMemoryQuality, HARD_QUALITY_REASONS } from "../src/memory-quality.ts"; import { redactCredentials } from "../src/redaction.ts"; import { scanWorkspaceResidues } from "../src/workspace-cleanup.ts"; -import { calculateRetentionStrength, renderWorkspaceMemory } from "../src/workspace-memory.ts"; +import { renderWorkspaceMemory } from "../src/workspace-memory.ts"; +import { + DORMANT_DECAY_MULTIPLIER, + RETENTION_TYPE_MAX, + WORKSPACE_DORMANT_AFTER_DAYS, + calculateRetentionStrength, +} from "../src/retention.ts"; import type { LongTermMemoryEntry, LongTermSource, LongTermType, PendingMemoryJournalStore, WorkspaceMemoryStore } from "../src/types.ts"; import { LONG_TERM_LIMITS, PROMOTION_RETRY_LIMITS } from "../src/types.ts"; @@ -65,14 +71,6 @@ type MigrationLogRecord = { }; const TYPES: LongTermType[] = ["feedback", "decision", "project", "reference"]; -const TYPE_MAX_FOR_DIAG: Record = { - feedback: 10, - decision: 10, - project: 8, - reference: 6, -}; -const WORKSPACE_DORMANT_AFTER_DAYS_FOR_DIAG = 14; -const DORMANT_DECAY_MULTIPLIER_FOR_DIAG = 0.25; const SUSPICIOUS_REASONS = [ "progress_snapshot", "active_file_snapshot", @@ -280,7 +278,7 @@ function retentionCandidatesForDiag(store: WorkspaceMemoryStore): { for (const item of sorted) { const count = typeCounts[item.entry.type] ?? 0; - const max = TYPE_MAX_FOR_DIAG[item.entry.type] ?? Infinity; + const max = RETENTION_TYPE_MAX[item.entry.type] ?? Infinity; if (count >= max) { typeCapped.push(item); continue; @@ -430,7 +428,7 @@ async function printWorkspaceHealth(input: { const storedCount = active.filter(entry => entry.type === type).length; const renderedCount = renderedEntries.filter(entry => entry.type === type).length; const supersededCount = superseded.filter(entry => entry.type === type).length; - console.log(` ${type.padEnd(9)} stored=${String(storedCount).padEnd(3)} rendered=${String(renderedCount).padEnd(3)} typeCap=${TYPE_MAX_FOR_DIAG[type]} superseded=${supersededCount}`); + console.log(` ${type.padEnd(9)} stored=${String(storedCount).padEnd(3)} rendered=${String(renderedCount).padEnd(3)} typeCap=${RETENTION_TYPE_MAX[type]} superseded=${supersededCount}`); } console.log(""); @@ -452,16 +450,16 @@ async function printWorkspaceHealth(input: { console.log(""); const wallDaysSinceActivity = daysSinceIso(store.lastActivityAt); - const dormantDiscountActive = wallDaysSinceActivity !== null && wallDaysSinceActivity > WORKSPACE_DORMANT_AFTER_DAYS_FOR_DIAG; + const dormantDiscountActive = wallDaysSinceActivity !== null && wallDaysSinceActivity > WORKSPACE_DORMANT_AFTER_DAYS; const dormantDaysPastGrace = wallDaysSinceActivity === null ? 0 - : Math.max(0, wallDaysSinceActivity - WORKSPACE_DORMANT_AFTER_DAYS_FOR_DIAG); + : Math.max(0, wallDaysSinceActivity - WORKSPACE_DORMANT_AFTER_DAYS); console.log("Dormancy:"); console.log(` lastActivityAt: ${store.lastActivityAt ?? "(missing)"}`); console.log(` wall days since activity: ${wallDaysSinceActivity === null ? "unknown" : wallDaysSinceActivity.toFixed(1)}`); console.log(` dormant discount active: ${dormantDiscountActive ? "yes" : "no"}`); console.log(` dormant days past grace: ${dormantDaysPastGrace.toFixed(1)}`); - console.log(` dormant multiplier: ${DORMANT_DECAY_MULTIPLIER_FOR_DIAG}`); + console.log(` dormant multiplier: ${DORMANT_DECAY_MULTIPLIER}`); console.log(""); const highImportanceCount = active.filter(entry => entry.userImportance === "high").length; diff --git a/src/plugin.ts b/src/plugin.ts index 468e5cb..a366916 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -36,8 +36,8 @@ import { updateWorkspaceMemory, updateWorkspaceMemoryWithAccounting, renderWorkspaceMemory, - reinforceMemory, } from "./workspace-memory.ts"; +import { reinforceMemory } from "./retention.ts"; import { appendPendingMemories, clearPendingMemories, diff --git a/src/retention.ts b/src/retention.ts new file mode 100644 index 0000000..b1b6438 --- /dev/null +++ b/src/retention.ts @@ -0,0 +1,135 @@ +import type { LongTermMemoryEntry, WorkspaceMemoryStore } from "./types.ts"; + +// Retention decay model constants (v1.5) +export const BASE_HALF_LIFE_DAYS = 45; +export const REINFORCEMENT_HALFLIFE_FACTOR = 0.85; +export const REINFORCEMENT_MAX_COUNT = 6; +export const REINFORCEMENT_MIN_INTERVAL_MS = 60 * 60 * 1000; // 1 hour +export const WORKSPACE_DORMANT_AFTER_DAYS = 14; +export const DORMANT_DECAY_MULTIPLIER = 0.25; +export const DAY_MS = 24 * 60 * 60 * 1000; + +export const TYPE_FACTOR = { + reference: 1.0, + project: 1.25, + feedback: 2.25, + decision: 2.5, +} as const; + +export const SOURCE_FACTOR = { + compaction: 1.0, + manual: 1.4, + explicit: 2.0, +} as const; + +export const USER_IMPORTANCE_FACTOR = { + low: 0.7, + normal: 1.0, + high: 1.5, +} as const; + +export const RETENTION_TYPE_MAX = { + feedback: 10, + decision: 10, + project: 8, + reference: 6, +} as const; + +export function calculateInitialStrength(memory: LongTermMemoryEntry): number { + const typeFactor = TYPE_FACTOR[memory.type] ?? 1.0; + const sourceFactor = SOURCE_FACTOR[memory.source] ?? 1.0; + const importanceFactor = USER_IMPORTANCE_FACTOR[memory.userImportance ?? "normal"] ?? 1.0; + + return typeFactor * sourceFactor * importanceFactor; +} + +export function calculateEffectiveHalfLife(memory: LongTermMemoryEntry): number { + const reinforcementCount = Math.min( + memory.reinforcementCount ?? 0, + REINFORCEMENT_MAX_COUNT, + ); + const factor = Math.pow(REINFORCEMENT_HALFLIFE_FACTOR, reinforcementCount); + return BASE_HALF_LIFE_DAYS / factor; +} + +function timestampMs(value: unknown, fallback: number): number { + const ms = typeof value === "number" ? value : new Date(String(value)).getTime(); + return Number.isFinite(ms) ? ms : fallback; +} + +export function calculateRetentionStrength( + memory: LongTermMemoryEntry, + now: number, + lastActivityAt?: string, +): number { + const initialStrength = calculateInitialStrength(memory); + const effectiveHalfLife = calculateEffectiveHalfLife(memory); + + // Use retentionClock if available, fallback to updatedAt. + const retentionStart = Number.isFinite(memory.retentionClock) + ? memory.retentionClock + : memory.updatedAt ?? memory.createdAt; + const createdAtMs = timestampMs(retentionStart, now); + const effectiveAgeDays = calculateEffectiveAgeDays(createdAtMs, now, lastActivityAt); + + // Calculate strength using exponential decay. + const strength = initialStrength * Math.pow(2, -effectiveAgeDays / effectiveHalfLife); + + return Number.isFinite(strength) ? Math.max(0, strength) : 0; +} + +export function calculateDormantDays(store: WorkspaceMemoryStore, now: number): number { + const lastActivity = store.lastActivityAt + ? new Date(store.lastActivityAt).getTime() + : now; + if (!Number.isFinite(lastActivity)) return 0; + + const daysSinceActivity = (now - lastActivity) / DAY_MS; + return Math.max(0, daysSinceActivity); +} + +export function calculateEffectiveAgeDays( + entryStartMs: number, + now: number, + lastActivityAt?: string, +): number { + const wallAgeDays = Math.max(0, (now - entryStartMs) / DAY_MS); + + if (!lastActivityAt) return wallAgeDays; + + const lastActivityMs = new Date(lastActivityAt).getTime(); + if (!Number.isFinite(lastActivityMs)) return wallAgeDays; + + const dormantStartMs = lastActivityMs + WORKSPACE_DORMANT_AFTER_DAYS * DAY_MS; + const overlapStartMs = Math.max(entryStartMs, dormantStartMs); + const dormantOverlapDays = Math.max(0, (now - overlapStartMs) / DAY_MS); + const activeDays = wallAgeDays - dormantOverlapDays; + + return activeDays + dormantOverlapDays * DORMANT_DECAY_MULTIPLIER; +} + +export function reinforceMemory( + memory: LongTermMemoryEntry, + sessionId: string, + now: number, +): LongTermMemoryEntry { + if (memory.lastReinforcedSessionID === sessionId) { + return memory; + } + + if (memory.lastReinforcedAt && now - memory.lastReinforcedAt < REINFORCEMENT_MIN_INTERVAL_MS) { + return memory; + } + + if ((memory.reinforcementCount ?? 0) >= REINFORCEMENT_MAX_COUNT) { + return memory; + } + + return { + ...memory, + reinforcementCount: (memory.reinforcementCount ?? 0) + 1, + lastReinforcedAt: now, + lastReinforcedSessionID: sessionId, + retentionClock: now, + }; +} diff --git a/src/workspace-memory.ts b/src/workspace-memory.ts index 9ce5ef6..a5fbdc5 100644 --- a/src/workspace-memory.ts +++ b/src/workspace-memory.ts @@ -6,146 +6,17 @@ import { migrationLogPath, workspaceKey, workspaceMemoryPath } from "./paths.ts" import { atomicWriteJSON, readJSON, updateJSON } from "./storage.ts"; import { assessMemoryQuality, isHardQualityReason, isProgressSnapshotViolation } from "./memory-quality.ts"; import { redactCredentials } from "./redaction.ts"; +import { + RETENTION_TYPE_MAX, + calculateRetentionStrength, + reinforceMemory, +} from "./retention.ts"; // Minimum length for workspace_memory envelope: \n...\n const MIN_ENVELOPE_LENGTH = 80; const MIGRATION_ID = "2026-04-26-p0-cleanup"; const QUALITY_CLEANUP_MIGRATION_ID = "2026-04-28-quality-cleanup"; -// Retention decay model constants (v1.5) -const BASE_HALF_LIFE_DAYS = 45; -const REINFORCEMENT_HALFLIFE_FACTOR = 0.85; -const REINFORCEMENT_MAX_COUNT = 6; -const REINFORCEMENT_MIN_INTERVAL_MS = 60 * 60 * 1000; // 1 hour -const WORKSPACE_DORMANT_AFTER_DAYS = 14; -const DORMANT_DECAY_MULTIPLIER = 0.25; -const DAY_MS = 24 * 60 * 60 * 1000; - -const TYPE_FACTOR = { - reference: 1.0, - project: 1.25, - feedback: 2.25, - decision: 2.5, -} as const; - -const SOURCE_FACTOR = { - compaction: 1.0, - manual: 1.4, - explicit: 2.0, -} as const; - -const USER_IMPORTANCE_FACTOR = { - low: 0.7, - normal: 1.0, - high: 1.5, -} as const; - -const TYPE_MAX = { - feedback: 10, - decision: 10, - project: 8, - reference: 6, -} as const; - -export function calculateInitialStrength(memory: LongTermMemoryEntry): number { - const typeFactor = TYPE_FACTOR[memory.type] ?? 1.0; - const sourceFactor = SOURCE_FACTOR[memory.source] ?? 1.0; - const importanceFactor = USER_IMPORTANCE_FACTOR[memory.userImportance ?? "normal"] ?? 1.0; - - return typeFactor * sourceFactor * importanceFactor; -} - -export function calculateEffectiveHalfLife(memory: LongTermMemoryEntry): number { - const reinforcementCount = Math.min( - memory.reinforcementCount ?? 0, - REINFORCEMENT_MAX_COUNT, - ); - const factor = Math.pow(REINFORCEMENT_HALFLIFE_FACTOR, reinforcementCount); - return BASE_HALF_LIFE_DAYS / factor; -} - -function timestampMs(value: unknown, fallback: number): number { - const ms = typeof value === "number" ? value : new Date(String(value)).getTime(); - return Number.isFinite(ms) ? ms : fallback; -} - -export function calculateRetentionStrength( - memory: LongTermMemoryEntry, - now: number, - lastActivityAt?: string, -): number { - const initialStrength = calculateInitialStrength(memory); - const effectiveHalfLife = calculateEffectiveHalfLife(memory); - - // Use retentionClock if available, fallback to updatedAt. - const retentionStart = Number.isFinite(memory.retentionClock) - ? memory.retentionClock - : memory.updatedAt ?? memory.createdAt; - const createdAtMs = timestampMs(retentionStart, now); - const effectiveAgeDays = calculateEffectiveAgeDays(createdAtMs, now, lastActivityAt); - - // Calculate strength using exponential decay. - const strength = initialStrength * Math.pow(2, -effectiveAgeDays / effectiveHalfLife); - - return Number.isFinite(strength) ? Math.max(0, strength) : 0; -} - -export function calculateDormantDays(store: WorkspaceMemoryStore, now: number): number { - const lastActivity = store.lastActivityAt - ? new Date(store.lastActivityAt).getTime() - : now; - if (!Number.isFinite(lastActivity)) return 0; - - const daysSinceActivity = (now - lastActivity) / DAY_MS; - return Math.max(0, daysSinceActivity); -} - -export function calculateEffectiveAgeDays( - entryStartMs: number, - now: number, - lastActivityAt?: string, -): number { - const wallAgeDays = Math.max(0, (now - entryStartMs) / DAY_MS); - - if (!lastActivityAt) return wallAgeDays; - - const lastActivityMs = new Date(lastActivityAt).getTime(); - if (!Number.isFinite(lastActivityMs)) return wallAgeDays; - - const dormantStartMs = lastActivityMs + WORKSPACE_DORMANT_AFTER_DAYS * DAY_MS; - const overlapStartMs = Math.max(entryStartMs, dormantStartMs); - const dormantOverlapDays = Math.max(0, (now - overlapStartMs) / DAY_MS); - const activeDays = wallAgeDays - dormantOverlapDays; - - return activeDays + dormantOverlapDays * DORMANT_DECAY_MULTIPLIER; -} - -export function reinforceMemory( - memory: LongTermMemoryEntry, - sessionId: string, - now: number, -): LongTermMemoryEntry { - if (memory.lastReinforcedSessionID === sessionId) { - return memory; - } - - if (memory.lastReinforcedAt && now - memory.lastReinforcedAt < REINFORCEMENT_MIN_INTERVAL_MS) { - return memory; - } - - if ((memory.reinforcementCount ?? 0) >= REINFORCEMENT_MAX_COUNT) { - return memory; - } - - return { - ...memory, - reinforcementCount: (memory.reinforcementCount ?? 0) + 1, - lastReinforcedAt: now, - lastReinforcedSessionID: sessionId, - retentionClock: now, - }; -} - export type MemoryConsolidationReason = | "promoted" | "absorbed_exact" @@ -658,7 +529,7 @@ function applyTypeMaxCaps(entries: LongTermMemoryEntry[]): LongTermMemoryEntry[] for (const entry of entries) { const count = typeCounts[entry.type] ?? 0; - const max = TYPE_MAX[entry.type] ?? Infinity; + const max = RETENTION_TYPE_MAX[entry.type] ?? Infinity; if (count >= max) continue; capped.push(entry); diff --git a/tests/workspace-memory.test.ts b/tests/workspace-memory.test.ts index f10bed4..b592d34 100644 --- a/tests/workspace-memory.test.ts +++ b/tests/workspace-memory.test.ts @@ -19,13 +19,16 @@ import { loadWorkspaceMemory, saveWorkspaceMemory, updateWorkspaceMemoryWithAccounting, +} from "../src/workspace-memory.ts"; +import { + RETENTION_TYPE_MAX, calculateInitialStrength, calculateEffectiveHalfLife, calculateRetentionStrength, calculateDormantDays, calculateEffectiveAgeDays, reinforceMemory, -} from "../src/workspace-memory.ts"; +} from "../src/retention.ts"; import { redactCredentials } from "../src/redaction.ts"; import { assessMemoryQuality, isHardQualityReason, isProgressSnapshotViolation } from "../src/memory-quality.ts"; import { reviewerCurrent28Fixture } from "./fixtures/memory-quality-current-28.ts"; @@ -580,7 +583,7 @@ test("enforceLongTermLimits applies per-type caps after strength sorting", () => assert.equal(kept.filter(memory => memory.type === "feedback").length, 10); }); -test("safetyCritical entries compete under TYPE_MAX caps like other entries", () => { +test("safetyCritical entries compete under RETENTION_TYPE_MAX caps like other entries", () => { const safetyEntries: LongTermMemoryEntry[] = Array.from({ length: 6 }, (_, i) => ({ ...entry(`safety-${i}`, `Safety memory ${i}`, "feedback"), source: "explicit", @@ -596,7 +599,7 @@ test("safetyCritical entries compete under TYPE_MAX caps like other entries", () const kept = enforceLongTermLimits(all); const feedbackCount = kept.filter(e => e.type === "feedback").length; - assert.equal(feedbackCount, 10); + assert.equal(feedbackCount, RETENTION_TYPE_MAX.feedback); // safetyCritical entries are no longer exempt from type caps assert.ok(kept.filter(e => e.safetyCritical).length < 6); }); @@ -639,7 +642,7 @@ test("workspace memory JSON with deprecated safetyCritical loads and competes no ); const kept = enforceLongTermLimits(loaded.entries); - assert.equal(kept.filter(memory => memory.type === "feedback").length, 10); + assert.equal(kept.filter(memory => memory.type === "feedback").length, RETENTION_TYPE_MAX.feedback); assert.ok(kept.filter(memory => memory.safetyCritical).length < safetyEntries.length); } finally { await rm(root, { recursive: true, force: true }); @@ -657,8 +660,8 @@ test("enforceLongTermLimits applies type caps to deprecated safetyCritical entri const kept = enforceLongTermLimits([safetyCriticalFeedback, ...ordinaryFeedback]); - assert.equal(kept.length, 10); - assert.equal(kept.filter(memory => memory.type === "feedback").length, 10); + assert.equal(kept.length, RETENTION_TYPE_MAX.feedback); + assert.equal(kept.filter(memory => memory.type === "feedback").length, RETENTION_TYPE_MAX.feedback); }); test("mixed retention scenario applies caps and reinforcement ordering", () => { @@ -709,9 +712,9 @@ test("mixed retention scenario applies caps and reinforcement ordering", () => { const result = enforceLongTermLimitsWithAccounting(entries, store); assert.ok(result.kept.length <= 28); - assert.ok(result.kept.filter(memory => memory.type === "feedback").length <= 10); - assert.ok(result.kept.filter(memory => memory.type === "decision").length <= 10); - assert.equal(result.kept.filter(memory => memory.type === "feedback").length, 10); + assert.ok(result.kept.filter(memory => memory.type === "feedback").length <= RETENTION_TYPE_MAX.feedback); + assert.ok(result.kept.filter(memory => memory.type === "decision").length <= RETENTION_TYPE_MAX.decision); + assert.equal(result.kept.filter(memory => memory.type === "feedback").length, RETENTION_TYPE_MAX.feedback); const reinforcedIndex = result.kept.findIndex(memory => memory.id === "old-reinforced"); const unreinforcedIndex = result.kept.findIndex(memory => memory.id === "old-unreinforced"); assert.ok(reinforcedIndex >= 0, "old reinforced reference should be kept");