feat(memory): add dormant tracking and reinforcement mechanism

Wave 2c - Dormant workspace tracking:
- Add lastActivityAt to WorkspaceMemoryStore
- Implement calculateDormantDays with 14-day grace period
- Wire dormant days into retention-strength calculation

Wave 3 - Reinforcement:
- Add lastReinforcedSessionID to LongTermMemoryEntry
- Implement reinforceMemory with guards (same-session, 1hr interval, max 6)
- Set retentionClock on memory creation in extractors.ts and plugin.ts

Tests: 219 → 222, all pass
This commit is contained in:
Ralph Chang
2026-04-29 14:32:39 +08:00
parent 4097815f3e
commit ffb612226c
7 changed files with 151 additions and 16 deletions
+6 -2
View File
@@ -68,7 +68,8 @@ export function extractExplicitMemories(text: string): LongTermMemoryEntry[] {
/(?:^|\n)\s*(?:my preference is|i prefer)[::,,]?\s*(.+)$/gim,
];
const now = new Date().toISOString();
const nowMs = Date.now();
const now = new Date(nowMs).toISOString();
const entries: LongTermMemoryEntry[] = [];
const seen = new Set<string>();
@@ -101,6 +102,7 @@ export function extractExplicitMemories(text: string): LongTermMemoryEntry[] {
status: "active",
createdAt: now,
updatedAt: now,
retentionClock: nowMs,
staleAfterDays: staleAfterDaysFor(type),
});
}
@@ -317,7 +319,8 @@ export function parseWorkspaceMemoryCandidates(summary: string): LongTermMemoryE
const block = extractCandidateBlock(summary);
if (!block) return [];
const now = new Date().toISOString();
const nowMs = Date.now();
const now = new Date(nowMs).toISOString();
const entries: LongTermMemoryEntry[] = [];
for (const line of block.split("\n")) {
@@ -348,6 +351,7 @@ export function parseWorkspaceMemoryCandidates(summary: string): LongTermMemoryE
status: "active",
createdAt: now,
updatedAt: now,
retentionClock: nowMs,
staleAfterDays: staleAfterDaysFor(type),
});
}
+5 -1
View File
@@ -317,10 +317,14 @@ export const MemoryV2Plugin: Plugin = async (input) => {
.map(memory => memoryKey(memory)),
);
const promotedAt = Date.now();
for (const memory of pending) {
const key = memoryKey(memory);
if (!existingKeys.has(key)) {
workspaceMemory.entries.push(memory);
workspaceMemory.entries.push({
...memory,
retentionClock: memory.retentionClock ?? promotedAt,
});
existingKeys.add(key);
}
}
+2
View File
@@ -23,6 +23,7 @@ export type LongTermMemoryEntry = {
retentionClock?: number; // Unix timestamp when retention started
reinforcementCount?: number; // Number of times this memory was reinforced
lastReinforcedAt?: number; // Unix timestamp of last reinforcement
lastReinforcedSessionID?: string;
userImportance?: "low" | "normal" | "high";
safetyCritical?: boolean;
};
@@ -40,6 +41,7 @@ export type WorkspaceMemoryStore = {
entries: LongTermMemoryEntry[];
migrations?: string[];
updatedAt: string;
lastActivityAt?: string;
};
export type PendingMemoryJournalStore = {
+48 -7
View File
@@ -93,6 +93,41 @@ export function calculateRetentionStrength(
return Math.max(0, strength);
}
export function calculateDormantDays(store: WorkspaceMemoryStore, now: number): number {
const lastActivity = store.lastActivityAt
? new Date(store.lastActivityAt).getTime()
: now;
if (!Number.isFinite(lastActivity)) return 0;
const daysSinceActivity = (now - lastActivity) / (24 * 60 * 60 * 1000);
return Math.max(0, daysSinceActivity - WORKSPACE_DORMANT_AFTER_DAYS);
}
export function reinforceMemory(
memory: LongTermMemoryEntry,
sessionId: string,
now: number,
): LongTermMemoryEntry {
if (memory.lastReinforcedSessionID === sessionId) {
return memory;
}
if (memory.lastReinforcedAt && now - memory.lastReinforcedAt < REINFORCEMENT_MIN_INTERVAL_MS) {
return memory;
}
if ((memory.reinforcementCount ?? 0) >= REINFORCEMENT_MAX_COUNT) {
return memory;
}
return {
...memory,
reinforcementCount: (memory.reinforcementCount ?? 0) + 1,
lastReinforcedAt: now,
lastReinforcedSessionID: sessionId,
};
}
export type MemoryConsolidationReason =
| "promoted"
| "absorbed_exact"
@@ -138,6 +173,7 @@ export type QualityCleanupMigrationLogEntry = {
};
export async function emptyWorkspaceMemory(root: string): Promise<WorkspaceMemoryStore> {
const nowIso = new Date().toISOString();
return {
version: 1,
workspace: { root, key: await workspaceKey(root) },
@@ -147,7 +183,8 @@ export async function emptyWorkspaceMemory(root: string): Promise<WorkspaceMemor
},
entries: [],
migrations: [],
updatedAt: new Date().toISOString(),
updatedAt: nowIso,
lastActivityAt: nowIso,
};
}
@@ -166,6 +203,7 @@ export async function loadWorkspaceMemory(root: string): Promise<WorkspaceMemory
entries: Array.isArray(loaded.entries) ? loaded.entries : [],
migrations: Array.isArray(loaded.migrations) ? loaded.migrations : [],
updatedAt: loaded.updatedAt ?? fallback.updatedAt,
lastActivityAt: loaded.lastActivityAt ?? loaded.updatedAt ?? fallback.lastActivityAt,
};
// Always normalize on load so redaction/migrations are always-on.
@@ -195,6 +233,7 @@ function hasSecurityOrMigrationChange(
const beforeMigrations = JSON.stringify(before.migrations ?? []);
const afterMigrations = JSON.stringify(after.migrations ?? []);
if ((before.lastActivityAt ?? "") !== (after.lastActivityAt ?? "")) return true;
return beforeMigrations !== afterMigrations;
}
@@ -302,12 +341,13 @@ export async function normalizeWorkspaceMemoryWithAccounting(
// archived as superseded records in this wave.
const activeEntries = result.entries.filter(entry => entry.status !== "superseded");
const supersededEntries = result.entries.filter(entry => entry.status === "superseded");
const accounting = enforceLongTermLimitsWithAccounting(activeEntries);
const accounting = enforceLongTermLimitsWithAccounting(activeEntries, result);
const normalizedStore = {
...result,
entries: [...accounting.kept, ...supersededEntries],
updatedAt: nowIso,
lastActivityAt: nowIso,
};
return {
@@ -576,11 +616,12 @@ export function enforceLongTermLimits(entries: LongTermMemoryEntry[]): LongTermM
return enforceLongTermLimitsWithAccounting(entries).kept;
}
export function enforceLongTermLimitsWithAccounting(entries: LongTermMemoryEntry[]): LongTermLimitResult {
export function enforceLongTermLimitsWithAccounting(
entries: LongTermMemoryEntry[],
store?: WorkspaceMemoryStore,
): LongTermLimitResult {
const now = Date.now();
// Store-level last activity tracking is not available yet; dormant handling
// will be wired in a later wave.
const dormantDays = 0;
const dormantDays = store ? calculateDormantDays(store, now) : 0;
const staleDropped: MemoryConsolidationEvent[] = [];
// Phase 1: filter active, prune by age
@@ -731,7 +772,7 @@ function wouldFit(
}
export function renderWorkspaceMemory(store: WorkspaceMemoryStore): string {
const active = enforceLongTermLimits(store.entries);
const active = enforceLongTermLimitsWithAccounting(store.entries, store).kept;
if (active.length === 0) return "";
const maxChars = Math.min(