@@ -19,6 +19,7 @@ const REINFORCEMENT_MAX_COUNT = 6;
1919const REINFORCEMENT_MIN_INTERVAL_MS = 60 * 60 * 1000 ; // 1 hour
2020const WORKSPACE_DORMANT_AFTER_DAYS = 14 ;
2121const DORMANT_DECAY_MULTIPLIER = 0.25 ;
22+ const DAY_MS = 24 * 60 * 60 * 1000 ;
2223
2324const TYPE_FACTOR = {
2425 reference : 1.0 ,
@@ -69,23 +70,15 @@ export function calculateEffectiveHalfLife(memory: LongTermMemoryEntry): number
6970export function calculateRetentionStrength (
7071 memory : LongTermMemoryEntry ,
7172 now : number ,
72- dormantDays : number ,
73+ lastActivityAt ?: string ,
7374) : number {
7475 const initialStrength = calculateInitialStrength ( memory ) ;
7576 const effectiveHalfLife = calculateEffectiveHalfLife ( memory ) ;
7677
7778 // Use retentionClock if available, fallback to updatedAt.
7879 const retentionStart = memory . retentionClock ?? memory . updatedAt ;
7980 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- }
81+ const effectiveAgeDays = calculateEffectiveAgeDays ( createdAtMs , now , lastActivityAt ) ;
8982
9083 // Calculate strength using exponential decay.
9184 const strength = initialStrength * Math . pow ( 2 , - effectiveAgeDays / effectiveHalfLife ) ;
@@ -99,8 +92,28 @@ export function calculateDormantDays(store: WorkspaceMemoryStore, now: number):
9992 : now ;
10093 if ( ! Number . isFinite ( lastActivity ) ) return 0 ;
10194
102- const daysSinceActivity = ( now - lastActivity ) / ( 24 * 60 * 60 * 1000 ) ;
103- return Math . max ( 0 , daysSinceActivity - WORKSPACE_DORMANT_AFTER_DAYS ) ;
95+ const daysSinceActivity = ( now - lastActivity ) / DAY_MS ;
96+ return Math . max ( 0 , daysSinceActivity ) ;
97+ }
98+
99+ export function calculateEffectiveAgeDays (
100+ entryStartMs : number ,
101+ now : number ,
102+ lastActivityAt ?: string ,
103+ ) : number {
104+ const wallAgeDays = Math . max ( 0 , ( now - entryStartMs ) / DAY_MS ) ;
105+
106+ if ( ! lastActivityAt ) return wallAgeDays ;
107+
108+ const lastActivityMs = new Date ( lastActivityAt ) . getTime ( ) ;
109+ if ( ! Number . isFinite ( lastActivityMs ) ) return wallAgeDays ;
110+
111+ const dormantStartMs = lastActivityMs + WORKSPACE_DORMANT_AFTER_DAYS * DAY_MS ;
112+ const overlapStartMs = Math . max ( entryStartMs , dormantStartMs ) ;
113+ const dormantOverlapDays = Math . max ( 0 , ( now - overlapStartMs ) / DAY_MS ) ;
114+ const activeDays = wallAgeDays - dormantOverlapDays ;
115+
116+ return activeDays + dormantOverlapDays * DORMANT_DECAY_MULTIPLIER ;
104117}
105118
106119export function reinforceMemory (
@@ -125,6 +138,7 @@ export function reinforceMemory(
125138 reinforcementCount : ( memory . reinforcementCount ?? 0 ) + 1 ,
126139 lastReinforcedAt : now ,
127140 lastReinforcedSessionID : sessionId ,
141+ retentionClock : now ,
128142 } ;
129143}
130144
@@ -133,8 +147,7 @@ export type MemoryConsolidationReason =
133147 | "absorbed_exact"
134148 | "absorbed_identity"
135149 | "superseded_existing"
136- | "rejected_capacity"
137- | "rejected_stale" ;
150+ | "rejected_capacity" ;
138151
139152export type MemoryConsolidationEvent = {
140153 memoryKey : string ;
@@ -568,19 +581,6 @@ function consolidationEvent(
568581 } ;
569582}
570583
571- /** Check if entry should be pruned by age (for compaction/manual entries only) */
572- function isPrunableByAge ( entry : LongTermMemoryEntry , now : number ) : boolean {
573- // Never prune feedback or explicit entries
574- if ( entry . type === "feedback" ) return false ;
575- if ( entry . source === "explicit" ) return false ;
576- if ( ! entry . staleAfterDays ) return false ;
577-
578- const createdAt = new Date ( entry . createdAt ) . getTime ( ) ;
579- const ageDays = ( now - createdAt ) / 86400000 ;
580- const grace = 30 ; // 30-day grace period
581- return ageDays > entry . staleAfterDays + grace ;
582- }
583-
584584/** Choose better memory when identity/topic keys conflict */
585585function chooseBetterMemory (
586586 a : LongTermMemoryEntry ,
@@ -621,22 +621,18 @@ export function enforceLongTermLimitsWithAccounting(
621621 store ?: WorkspaceMemoryStore ,
622622) : LongTermLimitResult {
623623 const now = Date . now ( ) ;
624- const dormantDays = store ? calculateDormantDays ( store , now ) : 0 ;
625- const staleDropped : MemoryConsolidationEvent [ ] = [ ] ;
624+ const lastActivityAt = store ?. lastActivityAt ;
626625
627- // Phase 1: filter active, prune by age
626+ // Phase 1: filter active entries and trim text. Retention removal is by
627+ // strength/cap competition, not hard stale pruning.
628628 const phase1 : LongTermMemoryEntry [ ] = [ ] ;
629629 for ( const entry of entries ) {
630630 if ( entry . status === "superseded" ) continue ;
631- if ( isPrunableByAge ( entry , now ) ) {
632- staleDropped . push ( consolidationEvent ( entry , "rejected_stale" ) ) ;
633- continue ;
634- }
635631 phase1 . push ( { ...entry , text : entry . text . slice ( 0 , LONG_TERM_LIMITS . maxEntryTextChars ) } ) ;
636632 }
637633
638634 const dedupeResult = dedupeLongTermEntriesWithAccounting ( phase1 ) ;
639- const sorted = [ ...dedupeResult . kept ] . sort ( ( a , b ) => compareLongTermMemoryForRetention ( a , b , now , dormantDays ) ) ;
635+ const sorted = [ ...dedupeResult . kept ] . sort ( ( a , b ) => compareLongTermMemoryForRetention ( a , b , now , lastActivityAt ) ) ;
640636 const capped = applyTypeMaxCaps ( sorted ) ;
641637 const kept = capped . slice ( 0 , LONG_TERM_LIMITS . maxEntries ) ;
642638 const keptIds = new Set ( kept . map ( entry => entry . id ) ) ;
@@ -646,7 +642,7 @@ export function enforceLongTermLimitsWithAccounting(
646642
647643 return {
648644 kept,
649- dropped : [ ...staleDropped , ... dedupeResult . dropped , ...capacityDropped ] ,
645+ dropped : [ ...dedupeResult . dropped , ...capacityDropped ] ,
650646 absorbed : dedupeResult . absorbed ,
651647 superseded : dedupeResult . superseded ,
652648 } ;
@@ -674,6 +670,7 @@ function applyTypeMaxCaps(entries: LongTermMemoryEntry[]): LongTermMemoryEntry[]
674670}
675671
676672export function dedupeLongTermEntriesWithAccounting ( entries : LongTermMemoryEntry [ ] ) : LongTermLimitResult {
673+ const now = Date . now ( ) ;
677674 const absorbed : MemoryConsolidationEvent [ ] = [ ] ;
678675 const superseded : MemoryConsolidationEvent [ ] = [ ] ;
679676
@@ -694,12 +691,14 @@ export function dedupeLongTermEntriesWithAccounting(entries: LongTermMemoryEntry
694691 const reason = workspaceMemoryExactKey ( entry ) === workspaceMemoryExactKey ( existing )
695692 ? "absorbed_exact" as const
696693 : "absorbed_identity" as const ;
697-
698- absorbed . push ( consolidationEvent ( dropped , reason , retained ) ) ;
699-
700- if ( retained === entry ) {
701- entityDeduped . set ( key , entry ) ;
702- }
694+ const reinforced = reinforceMemory (
695+ retained ,
696+ reinforcementSessionId ( retained , dropped ) ,
697+ now ,
698+ ) ;
699+
700+ absorbed . push ( consolidationEvent ( dropped , reason , reinforced ) ) ;
701+ entityDeduped . set ( key , reinforced ) ;
703702 }
704703 }
705704
@@ -718,16 +717,19 @@ export function dedupeLongTermEntriesWithAccounting(entries: LongTermMemoryEntry
718717 const reason = workspaceMemoryExactKey ( entry ) === workspaceMemoryExactKey ( existing )
719718 ? "absorbed_exact" as const
720719 : "superseded_existing" as const ;
720+ const reinforced = reinforceMemory (
721+ retained ,
722+ reinforcementSessionId ( retained , dropped ) ,
723+ now ,
724+ ) ;
721725
722726 if ( reason === "superseded_existing" ) {
723- superseded . push ( consolidationEvent ( dropped , reason , retained ) ) ;
727+ superseded . push ( consolidationEvent ( dropped , reason , reinforced ) ) ;
724728 } else {
725- absorbed . push ( consolidationEvent ( dropped , reason , retained ) ) ;
729+ absorbed . push ( consolidationEvent ( dropped , reason , reinforced ) ) ;
726730 }
727731
728- if ( retained === entry ) {
729- decisionDeduped . set ( key , entry ) ;
730- }
732+ decisionDeduped . set ( key , reinforced ) ;
731733 }
732734 }
733735
@@ -745,14 +747,18 @@ export function dedupeLongTermEntriesWithAccounting(entries: LongTermMemoryEntry
745747 } ;
746748}
747749
750+ function reinforcementSessionId ( retained : LongTermMemoryEntry , dropped : LongTermMemoryEntry ) : string {
751+ return dropped . pendingOwnerSessionID ?? retained . pendingOwnerSessionID ?? "workspace-dedupe" ;
752+ }
753+
748754function compareLongTermMemoryForRetention (
749755 a : LongTermMemoryEntry ,
750756 b : LongTermMemoryEntry ,
751757 now : number ,
752- dormantDays : number ,
758+ lastActivityAt ?: string ,
753759) : number {
754- const strengthA = calculateRetentionStrength ( a , now , dormantDays ) ;
755- const strengthB = calculateRetentionStrength ( b , now , dormantDays ) ;
760+ const strengthA = calculateRetentionStrength ( a , now , lastActivityAt ) ;
761+ const strengthB = calculateRetentionStrength ( b , now , lastActivityAt ) ;
756762 if ( strengthB !== strengthA ) return strengthB - strengthA ;
757763
758764 const sourceDiff = sourcePriority ( b . source ) - sourcePriority ( a . source ) ;
0 commit comments