fix(memory): correct dormant formula, remove hard prune, integrate reinforcement

P0.1 - Fix dormant effective age formula:
- Use overlap logic: only apply dormancy to entry's lifetime
- Formula: activeDays + dormantOverlapDays * 0.25
- calculateDormantDays now returns total days (not excess past grace)
- Test: 28 dormant days → 17.5 effective days

P0.2 - Remove hard stale pruning:
- Remove isPrunableByAge from enforcement
- Remove rejected_stale from accounting reasons
- Elimination now by cap competition only

P0.3 - Integrate reinforcement:
- Call reinforceMemory in dedupe absorption path
- Call reinforceMemory in promotion duplicate path
- Update retentionClock on reinforcement

A1 - Retention clock reset on reinforcement

A4 - Fix tests to encode correct formula
This commit is contained in:
Ralph Chang
2026-04-29 14:55:25 +08:00
parent ffb612226c
commit 04233f8452
6 changed files with 191 additions and 95 deletions
+22 -7
View File
@@ -36,6 +36,7 @@ import {
updateWorkspaceMemory,
updateWorkspaceMemoryWithAccounting,
renderWorkspaceMemory,
reinforceMemory,
} from "./workspace-memory.ts";
import {
appendPendingMemories,
@@ -311,21 +312,35 @@ export const MemoryV2Plugin: Plugin = async (input) => {
const updateResult = await updateWorkspaceMemoryWithAccounting(directory, workspaceMemory => {
beforeEntries = [...workspaceMemory.entries];
const existingKeys = new Set(
workspaceMemory.entries
.filter(memory => memory.status !== "superseded")
.map(memory => memoryKey(memory)),
);
const existingByKey = new Map<string, { memory: typeof workspaceMemory.entries[number]; index: number }>();
workspaceMemory.entries.forEach((memory, index) => {
if (memory.status === "superseded") return;
existingByKey.set(memoryKey(memory), { memory, index });
});
const promotedAt = Date.now();
for (const memory of pending) {
const key = memoryKey(memory);
if (!existingKeys.has(key)) {
const existing = existingByKey.get(key);
if (existing) {
const reinforced = reinforceMemory(
existing.memory,
sessionID ?? memory.pendingOwnerSessionID ?? "workspace-promotion",
promotedAt,
);
if (reinforced !== existing.memory) {
workspaceMemory.entries[existing.index] = reinforced;
existingByKey.set(key, { memory: reinforced, index: existing.index });
}
} else {
workspaceMemory.entries.push({
...memory,
retentionClock: memory.retentionClock ?? promotedAt,
});
existingKeys.add(key);
existingByKey.set(key, {
memory: workspaceMemory.entries[workspaceMemory.entries.length - 1],
index: workspaceMemory.entries.length - 1,
});
}
}
+2 -5
View File
@@ -59,7 +59,7 @@ export function accountPendingPromotions(input: {
continue;
}
if (terminal.reason === "rejected_capacity" || terminal.reason === "rejected_stale") {
if (terminal.reason === "rejected_capacity") {
rejectedKeys.add(key);
continue;
}
@@ -80,10 +80,7 @@ export function accountPendingPromotions(input: {
...input.pending
.filter(memory => {
const terminal = terminalEventByKey.get(memoryKey(memory));
return memory.source === "compaction" && (
terminal?.reason === "rejected_capacity" ||
terminal?.reason === "rejected_stale"
);
return memory.source === "compaction" && terminal?.reason === "rejected_capacity";
})
.map(memory => memoryKey(memory)),
]);
+55 -49
View File
@@ -19,6 +19,7 @@ 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,
@@ -69,7 +70,7 @@ export function calculateEffectiveHalfLife(memory: LongTermMemoryEntry): number
export function calculateRetentionStrength(
memory: LongTermMemoryEntry,
now: number,
dormantDays: number,
lastActivityAt?: string,
): number {
const initialStrength = calculateInitialStrength(memory);
const effectiveHalfLife = calculateEffectiveHalfLife(memory);
@@ -77,15 +78,7 @@ export function calculateRetentionStrength(
// Use retentionClock if available, fallback to updatedAt.
const retentionStart = memory.retentionClock ?? memory.updatedAt;
const createdAtMs = new Date(retentionStart).getTime();
const ageMs = now - createdAtMs;
const ageDays = ageMs / (24 * 60 * 60 * 1000);
// Apply dormant boost to effective age.
let effectiveAgeDays = ageDays;
if (dormantDays > 0) {
// Dormant workspace ages faster.
effectiveAgeDays += dormantDays * DORMANT_DECAY_MULTIPLIER;
}
const effectiveAgeDays = calculateEffectiveAgeDays(createdAtMs, now, lastActivityAt);
// Calculate strength using exponential decay.
const strength = initialStrength * Math.pow(2, -effectiveAgeDays / effectiveHalfLife);
@@ -99,8 +92,28 @@ export function calculateDormantDays(store: WorkspaceMemoryStore, now: number):
: now;
if (!Number.isFinite(lastActivity)) return 0;
const daysSinceActivity = (now - lastActivity) / (24 * 60 * 60 * 1000);
return Math.max(0, daysSinceActivity - WORKSPACE_DORMANT_AFTER_DAYS);
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(
@@ -125,6 +138,7 @@ export function reinforceMemory(
reinforcementCount: (memory.reinforcementCount ?? 0) + 1,
lastReinforcedAt: now,
lastReinforcedSessionID: sessionId,
retentionClock: now,
};
}
@@ -133,8 +147,7 @@ export type MemoryConsolidationReason =
| "absorbed_exact"
| "absorbed_identity"
| "superseded_existing"
| "rejected_capacity"
| "rejected_stale";
| "rejected_capacity";
export type MemoryConsolidationEvent = {
memoryKey: string;
@@ -568,19 +581,6 @@ function consolidationEvent(
};
}
/** Check if entry should be pruned by age (for compaction/manual entries only) */
function isPrunableByAge(entry: LongTermMemoryEntry, now: number): boolean {
// Never prune feedback or explicit entries
if (entry.type === "feedback") return false;
if (entry.source === "explicit") return false;
if (!entry.staleAfterDays) return false;
const createdAt = new Date(entry.createdAt).getTime();
const ageDays = (now - createdAt) / 86400000;
const grace = 30; // 30-day grace period
return ageDays > entry.staleAfterDays + grace;
}
/** Choose better memory when identity/topic keys conflict */
function chooseBetterMemory(
a: LongTermMemoryEntry,
@@ -621,22 +621,18 @@ export function enforceLongTermLimitsWithAccounting(
store?: WorkspaceMemoryStore,
): LongTermLimitResult {
const now = Date.now();
const dormantDays = store ? calculateDormantDays(store, now) : 0;
const staleDropped: MemoryConsolidationEvent[] = [];
const lastActivityAt = store?.lastActivityAt;
// Phase 1: filter active, prune by age
// Phase 1: filter active entries and trim text. Retention removal is by
// strength/cap competition, not hard stale pruning.
const phase1: LongTermMemoryEntry[] = [];
for (const entry of entries) {
if (entry.status === "superseded") continue;
if (isPrunableByAge(entry, now)) {
staleDropped.push(consolidationEvent(entry, "rejected_stale"));
continue;
}
phase1.push({ ...entry, text: entry.text.slice(0, LONG_TERM_LIMITS.maxEntryTextChars) });
}
const dedupeResult = dedupeLongTermEntriesWithAccounting(phase1);
const sorted = [...dedupeResult.kept].sort((a, b) => compareLongTermMemoryForRetention(a, b, now, dormantDays));
const sorted = [...dedupeResult.kept].sort((a, b) => compareLongTermMemoryForRetention(a, b, now, lastActivityAt));
const capped = applyTypeMaxCaps(sorted);
const kept = capped.slice(0, LONG_TERM_LIMITS.maxEntries);
const keptIds = new Set(kept.map(entry => entry.id));
@@ -646,7 +642,7 @@ export function enforceLongTermLimitsWithAccounting(
return {
kept,
dropped: [...staleDropped, ...dedupeResult.dropped, ...capacityDropped],
dropped: [...dedupeResult.dropped, ...capacityDropped],
absorbed: dedupeResult.absorbed,
superseded: dedupeResult.superseded,
};
@@ -674,6 +670,7 @@ function applyTypeMaxCaps(entries: LongTermMemoryEntry[]): LongTermMemoryEntry[]
}
export function dedupeLongTermEntriesWithAccounting(entries: LongTermMemoryEntry[]): LongTermLimitResult {
const now = Date.now();
const absorbed: MemoryConsolidationEvent[] = [];
const superseded: MemoryConsolidationEvent[] = [];
@@ -694,12 +691,14 @@ export function dedupeLongTermEntriesWithAccounting(entries: LongTermMemoryEntry
const reason = workspaceMemoryExactKey(entry) === workspaceMemoryExactKey(existing)
? "absorbed_exact" as const
: "absorbed_identity" as const;
const reinforced = reinforceMemory(
retained,
reinforcementSessionId(retained, dropped),
now,
);
absorbed.push(consolidationEvent(dropped, reason, retained));
if (retained === entry) {
entityDeduped.set(key, entry);
}
absorbed.push(consolidationEvent(dropped, reason, reinforced));
entityDeduped.set(key, reinforced);
}
}
@@ -718,16 +717,19 @@ export function dedupeLongTermEntriesWithAccounting(entries: LongTermMemoryEntry
const reason = workspaceMemoryExactKey(entry) === workspaceMemoryExactKey(existing)
? "absorbed_exact" as const
: "superseded_existing" as const;
const reinforced = reinforceMemory(
retained,
reinforcementSessionId(retained, dropped),
now,
);
if (reason === "superseded_existing") {
superseded.push(consolidationEvent(dropped, reason, retained));
superseded.push(consolidationEvent(dropped, reason, reinforced));
} else {
absorbed.push(consolidationEvent(dropped, reason, retained));
absorbed.push(consolidationEvent(dropped, reason, reinforced));
}
if (retained === entry) {
decisionDeduped.set(key, entry);
}
decisionDeduped.set(key, reinforced);
}
}
@@ -745,14 +747,18 @@ export function dedupeLongTermEntriesWithAccounting(entries: LongTermMemoryEntry
};
}
function reinforcementSessionId(retained: LongTermMemoryEntry, dropped: LongTermMemoryEntry): string {
return dropped.pendingOwnerSessionID ?? retained.pendingOwnerSessionID ?? "workspace-dedupe";
}
function compareLongTermMemoryForRetention(
a: LongTermMemoryEntry,
b: LongTermMemoryEntry,
now: number,
dormantDays: number,
lastActivityAt?: string,
): number {
const strengthA = calculateRetentionStrength(a, now, dormantDays);
const strengthB = calculateRetentionStrength(b, now, dormantDays);
const strengthA = calculateRetentionStrength(a, now, lastActivityAt);
const strengthB = calculateRetentionStrength(b, now, lastActivityAt);
if (strengthB !== strengthA) return strengthB - strengthA;
const sourceDiff = sourcePriority(b.source) - sourcePriority(a.source);
+58
View File
@@ -782,6 +782,64 @@ test("session.compacted promotes pending memories to workspace memory and clears
}
});
test("session.compacted reinforces existing exact workspace memory", async () => {
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-"));
try {
const now = new Date().toISOString();
const oldRetentionClock = Date.now() - 10 * 24 * 60 * 60 * 1000;
await updateWorkspaceMemory(tmpDir, store => {
store.entries.push({
id: "existing-memory",
type: "decision",
text: "Use frozen rendered snapshots for cache stability.",
source: "explicit",
confidence: 1,
status: "active",
createdAt: now,
updatedAt: now,
retentionClock: oldRetentionClock,
});
return store;
});
await saveSessionState(tmpDir, {
version: 1,
sessionID: "reinforce-existing-session",
turn: 1,
updatedAt: now,
activeFiles: [],
openErrors: [],
recentDecisions: [],
pendingMemories: [{
id: "pending-duplicate",
type: "decision",
text: "Use frozen rendered snapshots for cache stability.",
source: "explicit",
confidence: 1,
status: "active",
createdAt: now,
updatedAt: now,
}],
});
const plugin = await MemoryV2Plugin({ directory: tmpDir, client: mockRootClient() });
await (plugin as Record<string, Function>)["event"]({
event: { type: "session.compacted", properties: { sessionID: "reinforce-existing-session" } },
});
const workspace = await loadWorkspaceMemory(tmpDir);
const existing = workspace.entries.find(entry => entry.id === "existing-memory");
assert.equal(workspace.entries.filter(entry => /frozen rendered/.test(entry.text)).length, 1);
assert.equal(existing?.reinforcementCount, 1);
assert.equal(existing?.lastReinforcedSessionID, "reinforce-existing-session");
assert.ok((existing?.retentionClock ?? 0) > oldRetentionClock);
assert.equal((await loadSessionState(tmpDir, "reinforce-existing-session")).pendingMemories.length, 0);
} finally {
await rm(tmpDir, { recursive: true, force: true });
}
});
test("integration: explicit memory flows from user message through pending journal into workspace", async () => {
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-"));
-17
View File
@@ -229,20 +229,3 @@ test("accountPendingPromotions marks manual capacity rejection as retryable", ()
assert.equal(result.clearableKeys.size, 0);
assert.deepEqual([...result.retryableRejectedKeys], [memoryKey(pending[0])]);
});
test("accountPendingPromotions clears compaction stale rejection from accounting", () => {
const pending = [mem("pending_stale", "Stale compaction reference should be terminal.", {
type: "reference",
source: "compaction",
})];
const result = accountPendingPromotions({
pending,
before: [],
after: [],
events: [event(pending[0], "rejected_stale")],
});
assert.deepEqual([...result.rejectedKeys], [memoryKey(pending[0])]);
assert.deepEqual([...result.clearableKeys], [memoryKey(pending[0])]);
});
+54 -17
View File
@@ -23,6 +23,7 @@ import {
calculateEffectiveHalfLife,
calculateRetentionStrength,
calculateDormantDays,
calculateEffectiveAgeDays,
reinforceMemory,
} from "../src/workspace-memory.ts";
import { redactCredentials } from "../src/redaction.ts";
@@ -286,20 +287,32 @@ test("calculateEffectiveHalfLife clamps reinforcement count at configured maximu
);
});
test("calculateRetentionStrength decays from retentionClock and applies dormant age", () => {
test("calculateRetentionStrength slows decay for dormancy after activity grace", () => {
const now = Date.UTC(2026, 3, 29);
const fiveDaysAgo = now - 5 * 24 * 60 * 60 * 1000;
const nineteenDaysAgo = now - 19 * 24 * 60 * 60 * 1000;
const memory: LongTermMemoryEntry = {
...entry("retention-clock", "Use TypeScript for plugin code", "reference"),
source: "compaction",
retentionClock: fiveDaysAgo,
retentionClock: nineteenDaysAgo,
updatedAt: new Date(now).toISOString(),
};
const expectedEffectiveAgeDays = 5 + 4 * 0.25;
// 19 wall-clock days since last activity: 14 active days + 5 dormant days at 25% = 15.25 effective days.
const lastActivityAt = new Date(nineteenDaysAgo).toISOString();
const expectedEffectiveAgeDays = 14 + 5 * 0.25;
const expected = Math.pow(2, -expectedEffectiveAgeDays / 45);
assert.equal(calculateRetentionStrength(memory, now, 4), expected);
assert.equal(calculateRetentionStrength(memory, now, lastActivityAt), expected);
});
test("calculateEffectiveAgeDays matches plan worked dormant example", () => {
const now = Date.UTC(2026, 3, 29);
const twentyEightDaysAgo = now - 28 * 24 * 60 * 60 * 1000;
assert.equal(
calculateEffectiveAgeDays(twentyEightDaysAgo, now, new Date(twentyEightDaysAgo).toISOString()),
17.5,
);
});
test("calculateRetentionStrength falls back to updatedAt when retentionClock is absent", () => {
@@ -311,7 +324,7 @@ test("calculateRetentionStrength falls back to updatedAt when retentionClock is
};
const initialStrength = 2.25 * 1.4;
assert.equal(calculateRetentionStrength(memory, now, 0), initialStrength / 2);
assert.equal(calculateRetentionStrength(memory, now), initialStrength / 2);
});
test("calculateDormantDays applies fourteen day workspace activity grace", () => {
@@ -329,14 +342,14 @@ test("calculateDormantDays applies fourteen day workspace activity grace", () =>
lastActivityAt: new Date(now - 20 * 24 * 60 * 60 * 1000).toISOString(),
};
assert.equal(calculateDormantDays(activeWithinGrace, now), 0);
assert.equal(calculateDormantDays(dormantPastGrace, now), 6);
assert.equal(calculateDormantDays(activeWithinGrace, now), 13);
assert.equal(calculateDormantDays(dormantPastGrace, now), 20);
});
test("normalizeWorkspaceMemoryWithAccounting uses dormant workspace days for strength ordering", async () => {
const now = Date.now();
const reinforcedOldReference: LongTermMemoryEntry = {
...entry("reinforced-old", "Reinforced legacy docs live at https://example.com/legacy", "reference"),
...entry("reinforced-old", "Project uses the legacy local plugin architecture", "project"),
retentionClock: now - 100 * 24 * 60 * 60 * 1000,
reinforcementCount: 6,
};
@@ -367,6 +380,7 @@ test("reinforceMemory enforces session interval and max guards", () => {
assert.equal(reinforced.reinforcementCount, 1);
assert.equal(reinforced.lastReinforcedAt, now);
assert.equal(reinforced.lastReinforcedSessionID, "session-a");
assert.equal(reinforced.retentionClock, now);
assert.equal(reinforceMemory(reinforced, "session-a", now + 2 * 60 * 60 * 1000), reinforced);
assert.equal(reinforceMemory(reinforced, "session-b", now + 30 * 60 * 1000), reinforced);
@@ -379,6 +393,30 @@ test("reinforceMemory enforces session interval and max guards", () => {
assert.equal(reinforceMemory(atMax, "session-c", now), atMax);
});
test("dedupeLongTermEntriesWithAccounting reinforces absorbed exact duplicates", () => {
const now = Date.now();
const retained: LongTermMemoryEntry = {
...entry("retained", "Use pnpm for package management", "decision"),
retentionClock: now - 10 * 24 * 60 * 60 * 1000,
createdAt: new Date(now - 10 * 24 * 60 * 60 * 1000).toISOString(),
updatedAt: new Date(now - 10 * 24 * 60 * 60 * 1000).toISOString(),
};
const duplicate: LongTermMemoryEntry = {
...entry("duplicate", "use pnpm for package management!!!", "decision"),
pendingOwnerSessionID: "reinforce-session",
createdAt: new Date(now).toISOString(),
updatedAt: new Date(now).toISOString(),
};
const result = dedupeLongTermEntriesWithAccounting([retained, duplicate]);
assert.equal(result.kept.length, 1);
assert.equal(result.kept[0].id, "duplicate");
assert.equal(result.kept[0].reinforcementCount, 1);
assert.equal(result.kept[0].lastReinforcedSessionID, "reinforce-session");
assert.ok(typeof result.kept[0].retentionClock === "number");
});
test("enforceLongTermLimits orders entries by retention strength", () => {
const now = Date.now();
const freshFeedback: LongTermMemoryEntry = {
@@ -631,7 +669,7 @@ test("normalizeWorkspaceMemoryWithAccounting reports overflow capacity drops", a
assert.equal(result.dropped.filter(event => event.reason === "rejected_capacity").length, 1);
});
test("normalizeWorkspaceMemoryWithAccounting reports stale entry removal", async () => {
test("normalizeWorkspaceMemoryWithAccounting retains stale entries for cap competition", async () => {
const root = "/repo";
const now = new Date().toISOString();
const stale = agedEntry(
@@ -651,10 +689,10 @@ test("normalizeWorkspaceMemoryWithAccounting reports stale entry removal", async
const result = await normalizeWorkspaceMemoryWithAccounting(root, store);
assert.equal(result.kept.length, 0);
assert.equal(result.store.entries.length, 0);
assert.deepEqual(result.dropped.map(event => event.reason), ["rejected_stale"]);
assert.equal(result.dropped[0].memory.id, "stale_normalize");
assert.equal(result.kept.length, 1);
assert.equal(result.store.entries.length, 1);
assert.equal(result.dropped.length, 0);
assert.equal(result.kept[0].id, "stale_normalize");
});
test("updateWorkspaceMemoryWithAccounting emits accounting events for persisted updates", async () => {
@@ -809,14 +847,13 @@ test("enforceLongTermLimits feedback: exact canonical duplicates still collapse"
assert.equal(feedbackEntries.length, 1, "Exact canonical feedback duplicates should still collapse");
});
test("enforceLongTermLimits stale: compaction entry older than staleAfterDays+grace is pruned", () => {
// decision with staleAfterDays=45, 76 days old (> 45+30 grace=75)
test("enforceLongTermLimits stale: old compaction entry remains eligible for cap competition", () => {
const entries = [
agedEntry("stale", "Compaction output contract changed from XML to HTML comments to avoid Markdown rendering issues", "decision", { daysAgo: 76, staleAfterDays: 45 }),
];
const kept = enforceLongTermLimits(entries);
assert.equal(kept.length, 0, "Stale compaction entry should be pruned");
assert.equal(kept.length, 1, "Stale compaction entry should decay but not be hard-pruned");
});
test("enforceLongTermLimits stale: explicit entry is retained even if old", () => {