Skip to content

Commit ed4590c

Browse files
committed
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.
1 parent 09cc4a2 commit ed4590c

5 files changed

Lines changed: 166 additions & 159 deletions

File tree

scripts/memory-diag.ts

Lines changed: 12 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,13 @@ import { dataHome, extractionRejectionLogPath, migrationLogPath, workspaceKey, w
1212
import { assessMemoryQuality, HARD_QUALITY_REASONS } from "../src/memory-quality.ts";
1313
import { redactCredentials } from "../src/redaction.ts";
1414
import { scanWorkspaceResidues } from "../src/workspace-cleanup.ts";
15-
import { calculateRetentionStrength, renderWorkspaceMemory } from "../src/workspace-memory.ts";
15+
import { renderWorkspaceMemory } from "../src/workspace-memory.ts";
16+
import {
17+
DORMANT_DECAY_MULTIPLIER,
18+
RETENTION_TYPE_MAX,
19+
WORKSPACE_DORMANT_AFTER_DAYS,
20+
calculateRetentionStrength,
21+
} from "../src/retention.ts";
1622
import type { LongTermMemoryEntry, LongTermSource, LongTermType, PendingMemoryJournalStore, WorkspaceMemoryStore } from "../src/types.ts";
1723
import { LONG_TERM_LIMITS, PROMOTION_RETRY_LIMITS } from "../src/types.ts";
1824

@@ -65,14 +71,6 @@ type MigrationLogRecord = {
6571
};
6672

6773
const TYPES: LongTermType[] = ["feedback", "decision", "project", "reference"];
68-
const TYPE_MAX_FOR_DIAG: Record<LongTermType, number> = {
69-
feedback: 10,
70-
decision: 10,
71-
project: 8,
72-
reference: 6,
73-
};
74-
const WORKSPACE_DORMANT_AFTER_DAYS_FOR_DIAG = 14;
75-
const DORMANT_DECAY_MULTIPLIER_FOR_DIAG = 0.25;
7674
const SUSPICIOUS_REASONS = [
7775
"progress_snapshot",
7876
"active_file_snapshot",
@@ -280,7 +278,7 @@ function retentionCandidatesForDiag(store: WorkspaceMemoryStore): {
280278

281279
for (const item of sorted) {
282280
const count = typeCounts[item.entry.type] ?? 0;
283-
const max = TYPE_MAX_FOR_DIAG[item.entry.type] ?? Infinity;
281+
const max = RETENTION_TYPE_MAX[item.entry.type] ?? Infinity;
284282
if (count >= max) {
285283
typeCapped.push(item);
286284
continue;
@@ -430,7 +428,7 @@ async function printWorkspaceHealth(input: {
430428
const storedCount = active.filter(entry => entry.type === type).length;
431429
const renderedCount = renderedEntries.filter(entry => entry.type === type).length;
432430
const supersededCount = superseded.filter(entry => entry.type === type).length;
433-
console.log(` ${type.padEnd(9)} stored=${String(storedCount).padEnd(3)} rendered=${String(renderedCount).padEnd(3)} typeCap=${TYPE_MAX_FOR_DIAG[type]} superseded=${supersededCount}`);
431+
console.log(` ${type.padEnd(9)} stored=${String(storedCount).padEnd(3)} rendered=${String(renderedCount).padEnd(3)} typeCap=${RETENTION_TYPE_MAX[type]} superseded=${supersededCount}`);
434432
}
435433
console.log("");
436434

@@ -452,16 +450,16 @@ async function printWorkspaceHealth(input: {
452450
console.log("");
453451

454452
const wallDaysSinceActivity = daysSinceIso(store.lastActivityAt);
455-
const dormantDiscountActive = wallDaysSinceActivity !== null && wallDaysSinceActivity > WORKSPACE_DORMANT_AFTER_DAYS_FOR_DIAG;
453+
const dormantDiscountActive = wallDaysSinceActivity !== null && wallDaysSinceActivity > WORKSPACE_DORMANT_AFTER_DAYS;
456454
const dormantDaysPastGrace = wallDaysSinceActivity === null
457455
? 0
458-
: Math.max(0, wallDaysSinceActivity - WORKSPACE_DORMANT_AFTER_DAYS_FOR_DIAG);
456+
: Math.max(0, wallDaysSinceActivity - WORKSPACE_DORMANT_AFTER_DAYS);
459457
console.log("Dormancy:");
460458
console.log(` lastActivityAt: ${store.lastActivityAt ?? "(missing)"}`);
461459
console.log(` wall days since activity: ${wallDaysSinceActivity === null ? "unknown" : wallDaysSinceActivity.toFixed(1)}`);
462460
console.log(` dormant discount active: ${dormantDiscountActive ? "yes" : "no"}`);
463461
console.log(` dormant days past grace: ${dormantDaysPastGrace.toFixed(1)}`);
464-
console.log(` dormant multiplier: ${DORMANT_DECAY_MULTIPLIER_FOR_DIAG}`);
462+
console.log(` dormant multiplier: ${DORMANT_DECAY_MULTIPLIER}`);
465463
console.log("");
466464

467465
const highImportanceCount = active.filter(entry => entry.userImportance === "high").length;

src/plugin.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,8 @@ import {
3636
updateWorkspaceMemory,
3737
updateWorkspaceMemoryWithAccounting,
3838
renderWorkspaceMemory,
39-
reinforceMemory,
4039
} from "./workspace-memory.ts";
40+
import { reinforceMemory } from "./retention.ts";
4141
import {
4242
appendPendingMemories,
4343
clearPendingMemories,

src/retention.ts

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import type { LongTermMemoryEntry, WorkspaceMemoryStore } from "./types.ts";
2+
3+
// Retention decay model constants (v1.5)
4+
export const BASE_HALF_LIFE_DAYS = 45;
5+
export const REINFORCEMENT_HALFLIFE_FACTOR = 0.85;
6+
export const REINFORCEMENT_MAX_COUNT = 6;
7+
export const REINFORCEMENT_MIN_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
8+
export const WORKSPACE_DORMANT_AFTER_DAYS = 14;
9+
export const DORMANT_DECAY_MULTIPLIER = 0.25;
10+
export const DAY_MS = 24 * 60 * 60 * 1000;
11+
12+
export const TYPE_FACTOR = {
13+
reference: 1.0,
14+
project: 1.25,
15+
feedback: 2.25,
16+
decision: 2.5,
17+
} as const;
18+
19+
export const SOURCE_FACTOR = {
20+
compaction: 1.0,
21+
manual: 1.4,
22+
explicit: 2.0,
23+
} as const;
24+
25+
export const USER_IMPORTANCE_FACTOR = {
26+
low: 0.7,
27+
normal: 1.0,
28+
high: 1.5,
29+
} as const;
30+
31+
export const RETENTION_TYPE_MAX = {
32+
feedback: 10,
33+
decision: 10,
34+
project: 8,
35+
reference: 6,
36+
} as const;
37+
38+
export function calculateInitialStrength(memory: LongTermMemoryEntry): number {
39+
const typeFactor = TYPE_FACTOR[memory.type] ?? 1.0;
40+
const sourceFactor = SOURCE_FACTOR[memory.source] ?? 1.0;
41+
const importanceFactor = USER_IMPORTANCE_FACTOR[memory.userImportance ?? "normal"] ?? 1.0;
42+
43+
return typeFactor * sourceFactor * importanceFactor;
44+
}
45+
46+
export function calculateEffectiveHalfLife(memory: LongTermMemoryEntry): number {
47+
const reinforcementCount = Math.min(
48+
memory.reinforcementCount ?? 0,
49+
REINFORCEMENT_MAX_COUNT,
50+
);
51+
const factor = Math.pow(REINFORCEMENT_HALFLIFE_FACTOR, reinforcementCount);
52+
return BASE_HALF_LIFE_DAYS / factor;
53+
}
54+
55+
function timestampMs(value: unknown, fallback: number): number {
56+
const ms = typeof value === "number" ? value : new Date(String(value)).getTime();
57+
return Number.isFinite(ms) ? ms : fallback;
58+
}
59+
60+
export function calculateRetentionStrength(
61+
memory: LongTermMemoryEntry,
62+
now: number,
63+
lastActivityAt?: string,
64+
): number {
65+
const initialStrength = calculateInitialStrength(memory);
66+
const effectiveHalfLife = calculateEffectiveHalfLife(memory);
67+
68+
// Use retentionClock if available, fallback to updatedAt.
69+
const retentionStart = Number.isFinite(memory.retentionClock)
70+
? memory.retentionClock
71+
: memory.updatedAt ?? memory.createdAt;
72+
const createdAtMs = timestampMs(retentionStart, now);
73+
const effectiveAgeDays = calculateEffectiveAgeDays(createdAtMs, now, lastActivityAt);
74+
75+
// Calculate strength using exponential decay.
76+
const strength = initialStrength * Math.pow(2, -effectiveAgeDays / effectiveHalfLife);
77+
78+
return Number.isFinite(strength) ? Math.max(0, strength) : 0;
79+
}
80+
81+
export function calculateDormantDays(store: WorkspaceMemoryStore, now: number): number {
82+
const lastActivity = store.lastActivityAt
83+
? new Date(store.lastActivityAt).getTime()
84+
: now;
85+
if (!Number.isFinite(lastActivity)) return 0;
86+
87+
const daysSinceActivity = (now - lastActivity) / DAY_MS;
88+
return Math.max(0, daysSinceActivity);
89+
}
90+
91+
export function calculateEffectiveAgeDays(
92+
entryStartMs: number,
93+
now: number,
94+
lastActivityAt?: string,
95+
): number {
96+
const wallAgeDays = Math.max(0, (now - entryStartMs) / DAY_MS);
97+
98+
if (!lastActivityAt) return wallAgeDays;
99+
100+
const lastActivityMs = new Date(lastActivityAt).getTime();
101+
if (!Number.isFinite(lastActivityMs)) return wallAgeDays;
102+
103+
const dormantStartMs = lastActivityMs + WORKSPACE_DORMANT_AFTER_DAYS * DAY_MS;
104+
const overlapStartMs = Math.max(entryStartMs, dormantStartMs);
105+
const dormantOverlapDays = Math.max(0, (now - overlapStartMs) / DAY_MS);
106+
const activeDays = wallAgeDays - dormantOverlapDays;
107+
108+
return activeDays + dormantOverlapDays * DORMANT_DECAY_MULTIPLIER;
109+
}
110+
111+
export function reinforceMemory(
112+
memory: LongTermMemoryEntry,
113+
sessionId: string,
114+
now: number,
115+
): LongTermMemoryEntry {
116+
if (memory.lastReinforcedSessionID === sessionId) {
117+
return memory;
118+
}
119+
120+
if (memory.lastReinforcedAt && now - memory.lastReinforcedAt < REINFORCEMENT_MIN_INTERVAL_MS) {
121+
return memory;
122+
}
123+
124+
if ((memory.reinforcementCount ?? 0) >= REINFORCEMENT_MAX_COUNT) {
125+
return memory;
126+
}
127+
128+
return {
129+
...memory,
130+
reinforcementCount: (memory.reinforcementCount ?? 0) + 1,
131+
lastReinforcedAt: now,
132+
lastReinforcedSessionID: sessionId,
133+
retentionClock: now,
134+
};
135+
}

src/workspace-memory.ts

Lines changed: 6 additions & 135 deletions
Original file line numberDiff line numberDiff line change
@@ -6,146 +6,17 @@ import { migrationLogPath, workspaceKey, workspaceMemoryPath } from "./paths.ts"
66
import { atomicWriteJSON, readJSON, updateJSON } from "./storage.ts";
77
import { assessMemoryQuality, isHardQualityReason, isProgressSnapshotViolation } from "./memory-quality.ts";
88
import { redactCredentials } from "./redaction.ts";
9+
import {
10+
RETENTION_TYPE_MAX,
11+
calculateRetentionStrength,
12+
reinforceMemory,
13+
} from "./retention.ts";
914

1015
// Minimum length for workspace_memory envelope: <workspace_memory>\n...\n</workspace_memory>
1116
const MIN_ENVELOPE_LENGTH = 80;
1217
const MIGRATION_ID = "2026-04-26-p0-cleanup";
1318
const QUALITY_CLEANUP_MIGRATION_ID = "2026-04-28-quality-cleanup";
1419

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-
const DAY_MS = 24 * 60 * 60 * 1000;
23-
24-
const TYPE_FACTOR = {
25-
reference: 1.0,
26-
project: 1.25,
27-
feedback: 2.25,
28-
decision: 2.5,
29-
} as const;
30-
31-
const SOURCE_FACTOR = {
32-
compaction: 1.0,
33-
manual: 1.4,
34-
explicit: 2.0,
35-
} as const;
36-
37-
const USER_IMPORTANCE_FACTOR = {
38-
low: 0.7,
39-
normal: 1.0,
40-
high: 1.5,
41-
} as const;
42-
43-
const TYPE_MAX = {
44-
feedback: 10,
45-
decision: 10,
46-
project: 8,
47-
reference: 6,
48-
} as const;
49-
50-
export function calculateInitialStrength(memory: LongTermMemoryEntry): number {
51-
const typeFactor = TYPE_FACTOR[memory.type] ?? 1.0;
52-
const sourceFactor = SOURCE_FACTOR[memory.source] ?? 1.0;
53-
const importanceFactor = USER_IMPORTANCE_FACTOR[memory.userImportance ?? "normal"] ?? 1.0;
54-
55-
return typeFactor * sourceFactor * importanceFactor;
56-
}
57-
58-
export function calculateEffectiveHalfLife(memory: LongTermMemoryEntry): number {
59-
const reinforcementCount = Math.min(
60-
memory.reinforcementCount ?? 0,
61-
REINFORCEMENT_MAX_COUNT,
62-
);
63-
const factor = Math.pow(REINFORCEMENT_HALFLIFE_FACTOR, reinforcementCount);
64-
return BASE_HALF_LIFE_DAYS / factor;
65-
}
66-
67-
function timestampMs(value: unknown, fallback: number): number {
68-
const ms = typeof value === "number" ? value : new Date(String(value)).getTime();
69-
return Number.isFinite(ms) ? ms : fallback;
70-
}
71-
72-
export function calculateRetentionStrength(
73-
memory: LongTermMemoryEntry,
74-
now: number,
75-
lastActivityAt?: string,
76-
): number {
77-
const initialStrength = calculateInitialStrength(memory);
78-
const effectiveHalfLife = calculateEffectiveHalfLife(memory);
79-
80-
// Use retentionClock if available, fallback to updatedAt.
81-
const retentionStart = Number.isFinite(memory.retentionClock)
82-
? memory.retentionClock
83-
: memory.updatedAt ?? memory.createdAt;
84-
const createdAtMs = timestampMs(retentionStart, now);
85-
const effectiveAgeDays = calculateEffectiveAgeDays(createdAtMs, now, lastActivityAt);
86-
87-
// Calculate strength using exponential decay.
88-
const strength = initialStrength * Math.pow(2, -effectiveAgeDays / effectiveHalfLife);
89-
90-
return Number.isFinite(strength) ? Math.max(0, strength) : 0;
91-
}
92-
93-
export function calculateDormantDays(store: WorkspaceMemoryStore, now: number): number {
94-
const lastActivity = store.lastActivityAt
95-
? new Date(store.lastActivityAt).getTime()
96-
: now;
97-
if (!Number.isFinite(lastActivity)) return 0;
98-
99-
const daysSinceActivity = (now - lastActivity) / DAY_MS;
100-
return Math.max(0, daysSinceActivity);
101-
}
102-
103-
export function calculateEffectiveAgeDays(
104-
entryStartMs: number,
105-
now: number,
106-
lastActivityAt?: string,
107-
): number {
108-
const wallAgeDays = Math.max(0, (now - entryStartMs) / DAY_MS);
109-
110-
if (!lastActivityAt) return wallAgeDays;
111-
112-
const lastActivityMs = new Date(lastActivityAt).getTime();
113-
if (!Number.isFinite(lastActivityMs)) return wallAgeDays;
114-
115-
const dormantStartMs = lastActivityMs + WORKSPACE_DORMANT_AFTER_DAYS * DAY_MS;
116-
const overlapStartMs = Math.max(entryStartMs, dormantStartMs);
117-
const dormantOverlapDays = Math.max(0, (now - overlapStartMs) / DAY_MS);
118-
const activeDays = wallAgeDays - dormantOverlapDays;
119-
120-
return activeDays + dormantOverlapDays * DORMANT_DECAY_MULTIPLIER;
121-
}
122-
123-
export function reinforceMemory(
124-
memory: LongTermMemoryEntry,
125-
sessionId: string,
126-
now: number,
127-
): LongTermMemoryEntry {
128-
if (memory.lastReinforcedSessionID === sessionId) {
129-
return memory;
130-
}
131-
132-
if (memory.lastReinforcedAt && now - memory.lastReinforcedAt < REINFORCEMENT_MIN_INTERVAL_MS) {
133-
return memory;
134-
}
135-
136-
if ((memory.reinforcementCount ?? 0) >= REINFORCEMENT_MAX_COUNT) {
137-
return memory;
138-
}
139-
140-
return {
141-
...memory,
142-
reinforcementCount: (memory.reinforcementCount ?? 0) + 1,
143-
lastReinforcedAt: now,
144-
lastReinforcedSessionID: sessionId,
145-
retentionClock: now,
146-
};
147-
}
148-
14920
export type MemoryConsolidationReason =
15021
| "promoted"
15122
| "absorbed_exact"
@@ -658,7 +529,7 @@ function applyTypeMaxCaps(entries: LongTermMemoryEntry[]): LongTermMemoryEntry[]
658529

659530
for (const entry of entries) {
660531
const count = typeCounts[entry.type] ?? 0;
661-
const max = TYPE_MAX[entry.type] ?? Infinity;
532+
const max = RETENTION_TYPE_MAX[entry.type] ?? Infinity;
662533
if (count >= max) continue;
663534

664535
capped.push(entry);

0 commit comments

Comments
 (0)