Skip to content

Commit 406c160

Browse files
committed
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
1 parent 968aedd commit 406c160

6 files changed

Lines changed: 192 additions & 96 deletions

File tree

src/plugin.ts

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import {
3636
updateWorkspaceMemory,
3737
updateWorkspaceMemoryWithAccounting,
3838
renderWorkspaceMemory,
39+
reinforceMemory,
3940
} from "./workspace-memory.ts";
4041
import {
4142
appendPendingMemories,
@@ -311,21 +312,35 @@ export const MemoryV2Plugin: Plugin = async (input) => {
311312

312313
const updateResult = await updateWorkspaceMemoryWithAccounting(directory, workspaceMemory => {
313314
beforeEntries = [...workspaceMemory.entries];
314-
const existingKeys = new Set(
315-
workspaceMemory.entries
316-
.filter(memory => memory.status !== "superseded")
317-
.map(memory => memoryKey(memory)),
318-
);
315+
const existingByKey = new Map<string, { memory: typeof workspaceMemory.entries[number]; index: number }>();
316+
workspaceMemory.entries.forEach((memory, index) => {
317+
if (memory.status === "superseded") return;
318+
existingByKey.set(memoryKey(memory), { memory, index });
319+
});
319320

320321
const promotedAt = Date.now();
321322
for (const memory of pending) {
322323
const key = memoryKey(memory);
323-
if (!existingKeys.has(key)) {
324+
const existing = existingByKey.get(key);
325+
if (existing) {
326+
const reinforced = reinforceMemory(
327+
existing.memory,
328+
sessionID ?? memory.pendingOwnerSessionID ?? "workspace-promotion",
329+
promotedAt,
330+
);
331+
if (reinforced !== existing.memory) {
332+
workspaceMemory.entries[existing.index] = reinforced;
333+
existingByKey.set(key, { memory: reinforced, index: existing.index });
334+
}
335+
} else {
324336
workspaceMemory.entries.push({
325337
...memory,
326338
retentionClock: memory.retentionClock ?? promotedAt,
327339
});
328-
existingKeys.add(key);
340+
existingByKey.set(key, {
341+
memory: workspaceMemory.entries[workspaceMemory.entries.length - 1],
342+
index: workspaceMemory.entries.length - 1,
343+
});
329344
}
330345
}
331346

src/promotion-accounting.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ export function accountPendingPromotions(input: {
5959
continue;
6060
}
6161

62-
if (terminal.reason === "rejected_capacity" || terminal.reason === "rejected_stale") {
62+
if (terminal.reason === "rejected_capacity") {
6363
rejectedKeys.add(key);
6464
continue;
6565
}
@@ -80,10 +80,7 @@ export function accountPendingPromotions(input: {
8080
...input.pending
8181
.filter(memory => {
8282
const terminal = terminalEventByKey.get(memoryKey(memory));
83-
return memory.source === "compaction" && (
84-
terminal?.reason === "rejected_capacity" ||
85-
terminal?.reason === "rejected_stale"
86-
);
83+
return memory.source === "compaction" && terminal?.reason === "rejected_capacity";
8784
})
8885
.map(memory => memoryKey(memory)),
8986
]);

src/workspace-memory.ts

Lines changed: 56 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ const REINFORCEMENT_MAX_COUNT = 6;
1919
const REINFORCEMENT_MIN_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
2020
const WORKSPACE_DORMANT_AFTER_DAYS = 14;
2121
const DORMANT_DECAY_MULTIPLIER = 0.25;
22+
const DAY_MS = 24 * 60 * 60 * 1000;
2223

2324
const TYPE_FACTOR = {
2425
reference: 1.0,
@@ -69,23 +70,15 @@ export function calculateEffectiveHalfLife(memory: LongTermMemoryEntry): number
6970
export 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

106119
export 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

139152
export 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 */
585585
function 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

676672
export 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+
748754
function 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);

tests/plugin.test.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -782,6 +782,64 @@ test("session.compacted promotes pending memories to workspace memory and clears
782782
}
783783
});
784784

785+
test("session.compacted reinforces existing exact workspace memory", async () => {
786+
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-"));
787+
788+
try {
789+
const now = new Date().toISOString();
790+
const oldRetentionClock = Date.now() - 10 * 24 * 60 * 60 * 1000;
791+
await updateWorkspaceMemory(tmpDir, store => {
792+
store.entries.push({
793+
id: "existing-memory",
794+
type: "decision",
795+
text: "Use frozen rendered snapshots for cache stability.",
796+
source: "explicit",
797+
confidence: 1,
798+
status: "active",
799+
createdAt: now,
800+
updatedAt: now,
801+
retentionClock: oldRetentionClock,
802+
});
803+
return store;
804+
});
805+
806+
await saveSessionState(tmpDir, {
807+
version: 1,
808+
sessionID: "reinforce-existing-session",
809+
turn: 1,
810+
updatedAt: now,
811+
activeFiles: [],
812+
openErrors: [],
813+
recentDecisions: [],
814+
pendingMemories: [{
815+
id: "pending-duplicate",
816+
type: "decision",
817+
text: "Use frozen rendered snapshots for cache stability.",
818+
source: "explicit",
819+
confidence: 1,
820+
status: "active",
821+
createdAt: now,
822+
updatedAt: now,
823+
}],
824+
});
825+
826+
const plugin = await MemoryV2Plugin({ directory: tmpDir, client: mockRootClient() });
827+
await (plugin as Record<string, Function>)["event"]({
828+
event: { type: "session.compacted", properties: { sessionID: "reinforce-existing-session" } },
829+
});
830+
831+
const workspace = await loadWorkspaceMemory(tmpDir);
832+
const existing = workspace.entries.find(entry => entry.id === "existing-memory");
833+
assert.equal(workspace.entries.filter(entry => /frozen rendered/.test(entry.text)).length, 1);
834+
assert.equal(existing?.reinforcementCount, 1);
835+
assert.equal(existing?.lastReinforcedSessionID, "reinforce-existing-session");
836+
assert.ok((existing?.retentionClock ?? 0) > oldRetentionClock);
837+
assert.equal((await loadSessionState(tmpDir, "reinforce-existing-session")).pendingMemories.length, 0);
838+
} finally {
839+
await rm(tmpDir, { recursive: true, force: true });
840+
}
841+
});
842+
785843
test("integration: explicit memory flows from user message through pending journal into workspace", async () => {
786844
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-"));
787845

tests/promotion-accounting.test.ts

Lines changed: 0 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -229,20 +229,3 @@ test("accountPendingPromotions marks manual capacity rejection as retryable", ()
229229
assert.equal(result.clearableKeys.size, 0);
230230
assert.deepEqual([...result.retryableRejectedKeys], [memoryKey(pending[0])]);
231231
});
232-
233-
test("accountPendingPromotions clears compaction stale rejection from accounting", () => {
234-
const pending = [mem("pending_stale", "Stale compaction reference should be terminal.", {
235-
type: "reference",
236-
source: "compaction",
237-
})];
238-
239-
const result = accountPendingPromotions({
240-
pending,
241-
before: [],
242-
after: [],
243-
events: [event(pending[0], "rejected_stale")],
244-
});
245-
246-
assert.deepEqual([...result.rejectedKeys], [memoryKey(pending[0])]);
247-
assert.deepEqual([...result.clearableKeys], [memoryKey(pending[0])]);
248-
});

0 commit comments

Comments
 (0)