refactor(retention): extract retention module from workspace-memory

Move retention constants and math to a focused src/retention.ts module:
- All half-life, reinforcement, dormancy constants
- TYPE_FACTOR, SOURCE_FACTOR, USER_IMPORTANCE_FACTOR
- RETENTION_TYPE_MAX (renamed from TYPE_MAX)
- calculateInitialStrength, calculateEffectiveHalfLife,
  calculateRetentionStrength, calculateDormantDays,
  calculateEffectiveAgeDays, reinforceMemory

No behavior changes. retention.ts imports only types from types.ts.
Workspace-memory.ts still owns storage, consolidation, and rendering.
This commit is contained in:
Ralph Chang
2026-04-30 17:28:31 +08:00
parent 09cc4a2ffb
commit ed4590ca18
5 changed files with 166 additions and 159 deletions
+12 -14
View File
@@ -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<LongTermType, number> = {
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;
+1 -1
View File
@@ -36,8 +36,8 @@ import {
updateWorkspaceMemory,
updateWorkspaceMemoryWithAccounting,
renderWorkspaceMemory,
reinforceMemory,
} from "./workspace-memory.ts";
import { reinforceMemory } from "./retention.ts";
import {
appendPendingMemories,
clearPendingMemories,
+135
View File
@@ -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,
};
}
+6 -135
View File
@@ -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: <workspace_memory>\n...\n</workspace_memory>
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);
+12 -9
View File
@@ -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");