Skip to content

Commit d4053b2

Browse files
committed
feat(memory): implement retention decay model with strength-based ordering
- Add retention model constants (45-day half-life, 6.0 safety factor) - Add TYPE_MAX caps (feedback:10, decision:10, project:8, reference:6) - Add strength calculation: initialStrength × 2^(-age/halfLife) - Integrate strength-based sorting into enforceLongTermLimits - Safety-critical entries bypass type caps - Add fields: retentionClock, reinforcementCount, userImportance, safetyCritical
1 parent 85e11be commit d4053b2

4 files changed

Lines changed: 348 additions & 56 deletions

File tree

src/types.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,11 @@ export type LongTermMemoryEntry = {
2020
promotionAttempts?: number;
2121
lastPromotionAttemptAt?: string;
2222
lastPromotionFailureReason?: string;
23+
retentionClock?: number; // Unix timestamp when retention started
24+
reinforcementCount?: number; // Number of times this memory was reinforced
25+
lastReinforcedAt?: number; // Unix timestamp of last reinforcement
26+
userImportance?: "low" | "normal" | "high";
27+
safetyCritical?: boolean;
2328
};
2429

2530
export type WorkspaceMemoryStore = {

src/workspace-memory.ts

Lines changed: 118 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,87 @@ const MIN_ENVELOPE_LENGTH = 80;
1212
const MIGRATION_ID = "2026-04-26-p0-cleanup";
1313
const QUALITY_CLEANUP_MIGRATION_ID = "2026-04-28-quality-cleanup";
1414

15+
// Retention decay model constants (v1.5)
16+
const BASE_HALF_LIFE_DAYS = 45;
17+
const REINFORCEMENT_HALFLIFE_FACTOR = 0.85;
18+
const REINFORCEMENT_MAX_COUNT = 6;
19+
const REINFORCEMENT_MIN_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
20+
const WORKSPACE_DORMANT_AFTER_DAYS = 14;
21+
const DORMANT_DECAY_MULTIPLIER = 0.25;
22+
23+
const TYPE_FACTOR = {
24+
reference: 1.0,
25+
project: 1.25,
26+
feedback: 2.25,
27+
decision: 2.5,
28+
} as const;
29+
30+
const SOURCE_FACTOR = {
31+
compaction: 1.0,
32+
manual: 1.4,
33+
explicit: 2.0,
34+
} as const;
35+
36+
const USER_IMPORTANCE_FACTOR = {
37+
low: 0.7,
38+
normal: 1.0,
39+
high: 1.5,
40+
} as const;
41+
42+
const SAFETY_CRITICAL_FACTOR = 6.0;
43+
44+
const TYPE_MAX = {
45+
feedback: 10,
46+
decision: 10,
47+
project: 8,
48+
reference: 6,
49+
} as const;
50+
51+
export function calculateInitialStrength(memory: LongTermMemoryEntry): number {
52+
const typeFactor = TYPE_FACTOR[memory.type] ?? 1.0;
53+
const sourceFactor = SOURCE_FACTOR[memory.source] ?? 1.0;
54+
const importanceFactor = USER_IMPORTANCE_FACTOR[memory.userImportance ?? "normal"] ?? 1.0;
55+
const safetyFactor = memory.safetyCritical ? SAFETY_CRITICAL_FACTOR : 1.0;
56+
57+
return typeFactor * sourceFactor * importanceFactor * safetyFactor;
58+
}
59+
60+
export function calculateEffectiveHalfLife(memory: LongTermMemoryEntry): number {
61+
const reinforcementCount = Math.min(
62+
memory.reinforcementCount ?? 0,
63+
REINFORCEMENT_MAX_COUNT,
64+
);
65+
const factor = Math.pow(REINFORCEMENT_HALFLIFE_FACTOR, reinforcementCount);
66+
return BASE_HALF_LIFE_DAYS / factor;
67+
}
68+
69+
export function calculateRetentionStrength(
70+
memory: LongTermMemoryEntry,
71+
now: number,
72+
dormantDays: number,
73+
): number {
74+
const initialStrength = calculateInitialStrength(memory);
75+
const effectiveHalfLife = calculateEffectiveHalfLife(memory);
76+
77+
// Use retentionClock if available, fallback to updatedAt.
78+
const retentionStart = memory.retentionClock ?? memory.updatedAt;
79+
const createdAtMs = new Date(retentionStart).getTime();
80+
const ageMs = now - createdAtMs;
81+
const ageDays = ageMs / (24 * 60 * 60 * 1000);
82+
83+
// Apply dormant boost to effective age.
84+
let effectiveAgeDays = ageDays;
85+
if (dormantDays > 0) {
86+
// Dormant workspace ages faster.
87+
effectiveAgeDays += dormantDays * DORMANT_DECAY_MULTIPLIER;
88+
}
89+
90+
// Calculate strength using exponential decay.
91+
const strength = initialStrength * Math.pow(2, -effectiveAgeDays / effectiveHalfLife);
92+
93+
return Math.max(0, strength);
94+
}
95+
1596
export type MemoryConsolidationReason =
1697
| "promoted"
1798
| "absorbed_exact"
@@ -497,6 +578,9 @@ export function enforceLongTermLimits(entries: LongTermMemoryEntry[]): LongTermM
497578

498579
export function enforceLongTermLimitsWithAccounting(entries: LongTermMemoryEntry[]): LongTermLimitResult {
499580
const now = Date.now();
581+
// Store-level last activity tracking is not available yet; dormant handling
582+
// will be wired in a later wave.
583+
const dormantDays = 0;
500584
const staleDropped: MemoryConsolidationEvent[] = [];
501585

502586
// Phase 1: filter active, prune by age
@@ -511,8 +595,9 @@ export function enforceLongTermLimitsWithAccounting(entries: LongTermMemoryEntry
511595
}
512596

513597
const dedupeResult = dedupeLongTermEntriesWithAccounting(phase1);
514-
const sorted = [...dedupeResult.kept].sort(compareLongTermMemoryForRetention);
515-
const kept = sorted.slice(0, LONG_TERM_LIMITS.maxEntries);
598+
const sorted = [...dedupeResult.kept].sort((a, b) => compareLongTermMemoryForRetention(a, b, now, dormantDays));
599+
const capped = applyTypeMaxCaps(sorted);
600+
const kept = capped.slice(0, LONG_TERM_LIMITS.maxEntries);
516601
const keptIds = new Set(kept.map(entry => entry.id));
517602
const capacityDropped = sorted
518603
.filter(entry => !keptIds.has(entry.id))
@@ -526,6 +611,27 @@ export function enforceLongTermLimitsWithAccounting(entries: LongTermMemoryEntry
526611
};
527612
}
528613

614+
function applyTypeMaxCaps(entries: LongTermMemoryEntry[]): LongTermMemoryEntry[] {
615+
const capped: LongTermMemoryEntry[] = [];
616+
const typeCounts: Partial<Record<LongTermMemoryEntry["type"], number>> = {};
617+
618+
for (const entry of entries) {
619+
if (entry.safetyCritical) {
620+
capped.push(entry);
621+
continue;
622+
}
623+
624+
const count = typeCounts[entry.type] ?? 0;
625+
const max = TYPE_MAX[entry.type] ?? Infinity;
626+
if (count >= max) continue;
627+
628+
capped.push(entry);
629+
typeCounts[entry.type] = count + 1;
630+
}
631+
632+
return capped;
633+
}
634+
529635
export function dedupeLongTermEntriesWithAccounting(entries: LongTermMemoryEntry[]): LongTermLimitResult {
530636
const absorbed: MemoryConsolidationEvent[] = [];
531637
const superseded: MemoryConsolidationEvent[] = [];
@@ -598,29 +704,23 @@ export function dedupeLongTermEntriesWithAccounting(entries: LongTermMemoryEntry
598704
};
599705
}
600706

601-
function compareLongTermMemoryForRetention(a: LongTermMemoryEntry, b: LongTermMemoryEntry): number {
602-
const pA = priority(a);
603-
const pB = priority(b);
604-
if (pB !== pA) return pB - pA;
707+
function compareLongTermMemoryForRetention(
708+
a: LongTermMemoryEntry,
709+
b: LongTermMemoryEntry,
710+
now: number,
711+
dormantDays: number,
712+
): number {
713+
const strengthA = calculateRetentionStrength(a, now, dormantDays);
714+
const strengthB = calculateRetentionStrength(b, now, dormantDays);
715+
if (strengthB !== strengthA) return strengthB - strengthA;
716+
605717
const sourceDiff = sourcePriority(b.source) - sourcePriority(a.source);
606718
if (sourceDiff !== 0) return sourceDiff;
607719
const createdDiff = new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
608720
if (createdDiff !== 0) return createdDiff;
609721
return a.id.localeCompare(b.id);
610722
}
611723

612-
function priority(entry: LongTermMemoryEntry): number {
613-
const typeWeight = {
614-
feedback: 400,
615-
decision: 300,
616-
project: 200,
617-
reference: 100,
618-
}[entry.type];
619-
620-
const sourceWeight = entry.source === "explicit" ? 1000 : 0;
621-
return sourceWeight + typeWeight + entry.confidence * 10;
622-
}
623-
624724
function wouldFit(
625725
lines: string[],
626726
nextLine: string,

tests/plugin.test.ts

Lines changed: 104 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1422,9 +1422,9 @@ test("session.compacted clears compaction pending memory rejected by workspace e
14221422
try {
14231423
const now = new Date().toISOString();
14241424
await updateWorkspaceMemory(tmpDir, store => {
1425-
for (let i = 0; i < 28; i += 1) {
1425+
for (let i = 0; i < 10; i += 1) {
14261426
store.entries.push({
1427-
id: `mem_high_${i}`,
1427+
id: `mem_high_feedback_${i}`,
14281428
type: "feedback",
14291429
text: `High priority user feedback memory ${i} that should outrank low priority references.`,
14301430
source: "explicit",
@@ -1434,6 +1434,30 @@ test("session.compacted clears compaction pending memory rejected by workspace e
14341434
updatedAt: now,
14351435
});
14361436
}
1437+
for (let i = 0; i < 10; i += 1) {
1438+
store.entries.push({
1439+
id: `mem_high_decision_${i}`,
1440+
type: "decision",
1441+
text: `High priority decision memory ${i} that should outrank low priority references.`,
1442+
source: "explicit",
1443+
confidence: 1,
1444+
status: "active",
1445+
createdAt: now,
1446+
updatedAt: now,
1447+
});
1448+
}
1449+
for (let i = 0; i < 8; i += 1) {
1450+
store.entries.push({
1451+
id: `mem_high_project_${i}`,
1452+
type: "project",
1453+
text: `High priority project memory ${i} that should outrank low priority references.`,
1454+
source: "explicit",
1455+
confidence: 1,
1456+
status: "active",
1457+
createdAt: now,
1458+
updatedAt: now,
1459+
});
1460+
}
14371461
return store;
14381462
});
14391463

@@ -1476,9 +1500,9 @@ test("session.compacted keeps explicit pending memory rejected by workspace entr
14761500
try {
14771501
const now = new Date().toISOString();
14781502
await updateWorkspaceMemory(tmpDir, store => {
1479-
for (let i = 0; i < 28; i += 1) {
1503+
for (let i = 0; i < 10; i += 1) {
14801504
store.entries.push({
1481-
id: `mem_high_explicit_reject_${i}`,
1505+
id: `mem_high_explicit_reject_feedback_${i}`,
14821506
type: "feedback",
14831507
text: `Pinned high priority feedback for explicit reject ${i}.`,
14841508
source: "explicit",
@@ -1488,6 +1512,30 @@ test("session.compacted keeps explicit pending memory rejected by workspace entr
14881512
updatedAt: now,
14891513
});
14901514
}
1515+
for (let i = 0; i < 10; i += 1) {
1516+
store.entries.push({
1517+
id: `mem_high_explicit_reject_decision_${i}`,
1518+
type: "decision",
1519+
text: `Pinned high priority decision for explicit reject ${i}.`,
1520+
source: "explicit",
1521+
confidence: 1,
1522+
status: "active",
1523+
createdAt: now,
1524+
updatedAt: now,
1525+
});
1526+
}
1527+
for (let i = 0; i < 8; i += 1) {
1528+
store.entries.push({
1529+
id: `mem_high_explicit_reject_project_${i}`,
1530+
type: "project",
1531+
text: `Pinned high priority project for explicit reject ${i}.`,
1532+
source: "explicit",
1533+
confidence: 1,
1534+
status: "active",
1535+
createdAt: now,
1536+
updatedAt: now,
1537+
});
1538+
}
14911539
return store;
14921540
});
14931541

@@ -1531,9 +1579,9 @@ test("explicit capacity rejection records bounded retry metadata", async () => {
15311579
try {
15321580
const now = new Date().toISOString();
15331581
await updateWorkspaceMemory(tmpDir, store => {
1534-
for (let i = 0; i < 28; i += 1) {
1582+
for (let i = 0; i < 10; i += 1) {
15351583
store.entries.push({
1536-
id: `mem_high_bounded_reject_${i}`,
1584+
id: `mem_high_bounded_reject_feedback_${i}`,
15371585
type: "feedback",
15381586
text: `Pinned high priority feedback for bounded rejection ${i}.`,
15391587
source: "explicit",
@@ -1543,6 +1591,30 @@ test("explicit capacity rejection records bounded retry metadata", async () => {
15431591
updatedAt: now,
15441592
});
15451593
}
1594+
for (let i = 0; i < 10; i += 1) {
1595+
store.entries.push({
1596+
id: `mem_high_bounded_reject_decision_${i}`,
1597+
type: "decision",
1598+
text: `Pinned high priority decision for bounded rejection ${i}.`,
1599+
source: "explicit",
1600+
confidence: 1,
1601+
status: "active",
1602+
createdAt: now,
1603+
updatedAt: now,
1604+
});
1605+
}
1606+
for (let i = 0; i < 8; i += 1) {
1607+
store.entries.push({
1608+
id: `mem_high_bounded_reject_project_${i}`,
1609+
type: "project",
1610+
text: `Pinned high priority project for bounded rejection ${i}.`,
1611+
source: "explicit",
1612+
confidence: 1,
1613+
status: "active",
1614+
createdAt: now,
1615+
updatedAt: now,
1616+
});
1617+
}
15461618
return store;
15471619
});
15481620

@@ -1614,9 +1686,9 @@ test("session.compacted clears compaction pending memories when all rejected by
16141686
try {
16151687
const now = new Date().toISOString();
16161688
await updateWorkspaceMemory(tmpDir, store => {
1617-
for (let i = 0; i < 28; i += 1) {
1689+
for (let i = 0; i < 10; i += 1) {
16181690
store.entries.push({
1619-
id: `mem_high_all_rejected_${i}`,
1691+
id: `mem_high_all_rejected_feedback_${i}`,
16201692
type: "feedback",
16211693
text: `Pinned high priority feedback ${i} that keeps the workspace entry cap full.`,
16221694
source: "explicit",
@@ -1626,6 +1698,30 @@ test("session.compacted clears compaction pending memories when all rejected by
16261698
updatedAt: now,
16271699
});
16281700
}
1701+
for (let i = 0; i < 10; i += 1) {
1702+
store.entries.push({
1703+
id: `mem_high_all_rejected_decision_${i}`,
1704+
type: "decision",
1705+
text: `Pinned high priority decision ${i} that keeps the workspace entry cap full.`,
1706+
source: "explicit",
1707+
confidence: 1,
1708+
status: "active",
1709+
createdAt: now,
1710+
updatedAt: now,
1711+
});
1712+
}
1713+
for (let i = 0; i < 8; i += 1) {
1714+
store.entries.push({
1715+
id: `mem_high_all_rejected_project_${i}`,
1716+
type: "project",
1717+
text: `Pinned high priority project ${i} that keeps the workspace entry cap full.`,
1718+
source: "explicit",
1719+
confidence: 1,
1720+
status: "active",
1721+
createdAt: now,
1722+
updatedAt: now,
1723+
});
1724+
}
16291725
return store;
16301726
});
16311727

0 commit comments

Comments
 (0)