Skip to content

Commit 968aedd

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

7 files changed

Lines changed: 151 additions & 16 deletions

File tree

src/extractors.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,8 @@ export function extractExplicitMemories(text: string): LongTermMemoryEntry[] {
6868
/(?:^|\n)\s*(?:my preference is|i prefer)[:,]?\s*(.+)$/gim,
6969
];
7070

71-
const now = new Date().toISOString();
71+
const nowMs = Date.now();
72+
const now = new Date(nowMs).toISOString();
7273
const entries: LongTermMemoryEntry[] = [];
7374
const seen = new Set<string>();
7475

@@ -101,6 +102,7 @@ export function extractExplicitMemories(text: string): LongTermMemoryEntry[] {
101102
status: "active",
102103
createdAt: now,
103104
updatedAt: now,
105+
retentionClock: nowMs,
104106
staleAfterDays: staleAfterDaysFor(type),
105107
});
106108
}
@@ -317,7 +319,8 @@ export function parseWorkspaceMemoryCandidates(summary: string): LongTermMemoryE
317319
const block = extractCandidateBlock(summary);
318320
if (!block) return [];
319321

320-
const now = new Date().toISOString();
322+
const nowMs = Date.now();
323+
const now = new Date(nowMs).toISOString();
321324
const entries: LongTermMemoryEntry[] = [];
322325

323326
for (const line of block.split("\n")) {
@@ -348,6 +351,7 @@ export function parseWorkspaceMemoryCandidates(summary: string): LongTermMemoryE
348351
status: "active",
349352
createdAt: now,
350353
updatedAt: now,
354+
retentionClock: nowMs,
351355
staleAfterDays: staleAfterDaysFor(type),
352356
});
353357
}

src/plugin.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -317,10 +317,14 @@ export const MemoryV2Plugin: Plugin = async (input) => {
317317
.map(memory => memoryKey(memory)),
318318
);
319319

320+
const promotedAt = Date.now();
320321
for (const memory of pending) {
321322
const key = memoryKey(memory);
322323
if (!existingKeys.has(key)) {
323-
workspaceMemory.entries.push(memory);
324+
workspaceMemory.entries.push({
325+
...memory,
326+
retentionClock: memory.retentionClock ?? promotedAt,
327+
});
324328
existingKeys.add(key);
325329
}
326330
}

src/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export type LongTermMemoryEntry = {
2323
retentionClock?: number; // Unix timestamp when retention started
2424
reinforcementCount?: number; // Number of times this memory was reinforced
2525
lastReinforcedAt?: number; // Unix timestamp of last reinforcement
26+
lastReinforcedSessionID?: string;
2627
userImportance?: "low" | "normal" | "high";
2728
safetyCritical?: boolean;
2829
};
@@ -40,6 +41,7 @@ export type WorkspaceMemoryStore = {
4041
entries: LongTermMemoryEntry[];
4142
migrations?: string[];
4243
updatedAt: string;
44+
lastActivityAt?: string;
4345
};
4446

4547
export type PendingMemoryJournalStore = {

src/workspace-memory.ts

Lines changed: 48 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,41 @@ export function calculateRetentionStrength(
9393
return Math.max(0, strength);
9494
}
9595

96+
export function calculateDormantDays(store: WorkspaceMemoryStore, now: number): number {
97+
const lastActivity = store.lastActivityAt
98+
? new Date(store.lastActivityAt).getTime()
99+
: now;
100+
if (!Number.isFinite(lastActivity)) return 0;
101+
102+
const daysSinceActivity = (now - lastActivity) / (24 * 60 * 60 * 1000);
103+
return Math.max(0, daysSinceActivity - WORKSPACE_DORMANT_AFTER_DAYS);
104+
}
105+
106+
export function reinforceMemory(
107+
memory: LongTermMemoryEntry,
108+
sessionId: string,
109+
now: number,
110+
): LongTermMemoryEntry {
111+
if (memory.lastReinforcedSessionID === sessionId) {
112+
return memory;
113+
}
114+
115+
if (memory.lastReinforcedAt && now - memory.lastReinforcedAt < REINFORCEMENT_MIN_INTERVAL_MS) {
116+
return memory;
117+
}
118+
119+
if ((memory.reinforcementCount ?? 0) >= REINFORCEMENT_MAX_COUNT) {
120+
return memory;
121+
}
122+
123+
return {
124+
...memory,
125+
reinforcementCount: (memory.reinforcementCount ?? 0) + 1,
126+
lastReinforcedAt: now,
127+
lastReinforcedSessionID: sessionId,
128+
};
129+
}
130+
96131
export type MemoryConsolidationReason =
97132
| "promoted"
98133
| "absorbed_exact"
@@ -138,6 +173,7 @@ export type QualityCleanupMigrationLogEntry = {
138173
};
139174

140175
export async function emptyWorkspaceMemory(root: string): Promise<WorkspaceMemoryStore> {
176+
const nowIso = new Date().toISOString();
141177
return {
142178
version: 1,
143179
workspace: { root, key: await workspaceKey(root) },
@@ -147,7 +183,8 @@ export async function emptyWorkspaceMemory(root: string): Promise<WorkspaceMemor
147183
},
148184
entries: [],
149185
migrations: [],
150-
updatedAt: new Date().toISOString(),
186+
updatedAt: nowIso,
187+
lastActivityAt: nowIso,
151188
};
152189
}
153190

@@ -166,6 +203,7 @@ export async function loadWorkspaceMemory(root: string): Promise<WorkspaceMemory
166203
entries: Array.isArray(loaded.entries) ? loaded.entries : [],
167204
migrations: Array.isArray(loaded.migrations) ? loaded.migrations : [],
168205
updatedAt: loaded.updatedAt ?? fallback.updatedAt,
206+
lastActivityAt: loaded.lastActivityAt ?? loaded.updatedAt ?? fallback.lastActivityAt,
169207
};
170208

171209
// Always normalize on load so redaction/migrations are always-on.
@@ -195,6 +233,7 @@ function hasSecurityOrMigrationChange(
195233

196234
const beforeMigrations = JSON.stringify(before.migrations ?? []);
197235
const afterMigrations = JSON.stringify(after.migrations ?? []);
236+
if ((before.lastActivityAt ?? "") !== (after.lastActivityAt ?? "")) return true;
198237
return beforeMigrations !== afterMigrations;
199238
}
200239

@@ -302,12 +341,13 @@ export async function normalizeWorkspaceMemoryWithAccounting(
302341
// archived as superseded records in this wave.
303342
const activeEntries = result.entries.filter(entry => entry.status !== "superseded");
304343
const supersededEntries = result.entries.filter(entry => entry.status === "superseded");
305-
const accounting = enforceLongTermLimitsWithAccounting(activeEntries);
344+
const accounting = enforceLongTermLimitsWithAccounting(activeEntries, result);
306345

307346
const normalizedStore = {
308347
...result,
309348
entries: [...accounting.kept, ...supersededEntries],
310349
updatedAt: nowIso,
350+
lastActivityAt: nowIso,
311351
};
312352

313353
return {
@@ -576,11 +616,12 @@ export function enforceLongTermLimits(entries: LongTermMemoryEntry[]): LongTermM
576616
return enforceLongTermLimitsWithAccounting(entries).kept;
577617
}
578618

579-
export function enforceLongTermLimitsWithAccounting(entries: LongTermMemoryEntry[]): LongTermLimitResult {
619+
export function enforceLongTermLimitsWithAccounting(
620+
entries: LongTermMemoryEntry[],
621+
store?: WorkspaceMemoryStore,
622+
): LongTermLimitResult {
580623
const now = Date.now();
581-
// Store-level last activity tracking is not available yet; dormant handling
582-
// will be wired in a later wave.
583-
const dormantDays = 0;
624+
const dormantDays = store ? calculateDormantDays(store, now) : 0;
584625
const staleDropped: MemoryConsolidationEvent[] = [];
585626

586627
// Phase 1: filter active, prune by age
@@ -731,7 +772,7 @@ function wouldFit(
731772
}
732773

733774
export function renderWorkspaceMemory(store: WorkspaceMemoryStore): string {
734-
const active = enforceLongTermLimits(store.entries);
775+
const active = enforceLongTermLimitsWithAccounting(store.entries, store).kept;
735776
if (active.length === 0) return "";
736777

737778
const maxChars = Math.min(

tests/extractors.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,9 +91,13 @@ test("extractExplicitMemories does not treat always as memory trigger", () => {
9191
});
9292

9393
test("extractExplicitMemories still captures going forward", () => {
94+
const before = Date.now();
9495
const items = extractExplicitMemories("going forward: use pnpm instead of npm");
96+
const after = Date.now();
9597
assert.equal(items.length, 1);
9698
assert.match(items[0].text, /pnpm/);
99+
assert.ok(typeof items[0].retentionClock === "number");
100+
assert.ok(items[0].retentionClock >= before && items[0].retentionClock <= after);
97101
});
98102

99103
test("extractExplicitMemories captures from now on", () => {
@@ -200,14 +204,18 @@ test("parseWorkspaceMemoryCandidates rejects path-heavy facts", () => {
200204
});
201205

202206
test("parseWorkspaceMemoryCandidates accepts valid decision", () => {
207+
const before = Date.now();
203208
const summary = `
204209
## Memory Candidates
205210
- [decision] Use pnpm instead of npm for package management
206211
`;
207212
const items = parseWorkspaceMemoryCandidates(summary);
213+
const after = Date.now();
208214
assert.equal(items.length, 1);
209215
assert.equal(items[0].type, "decision");
210216
assert.match(items[0].text, /pnpm/);
217+
assert.ok(typeof items[0].retentionClock === "number");
218+
assert.ok(items[0].retentionClock >= before && items[0].retentionClock <= after);
211219
});
212220

213221
test("parseWorkspaceMemoryCandidates accepts valid project info", () => {

tests/plugin.test.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -772,6 +772,11 @@ test("session.compacted promotes pending memories to workspace memory and clears
772772

773773
const workspacePrompt = after.system.find((part: string) => part.startsWith("Workspace memory"));
774774
assert.match(workspacePrompt ?? "", /Use frozen rendered snapshots for cache stability/);
775+
776+
const workspace = await loadWorkspaceMemory(tmpDir);
777+
const promoted = workspace.entries.find(entry => entry.id === "mem_pending_1");
778+
assert.ok(typeof promoted?.retentionClock === "number",
779+
"legacy pending memory should receive a retention clock when promoted");
775780
} finally {
776781
await rm(tmpDir, { recursive: true, force: true });
777782
}

tests/workspace-memory.test.ts

Lines changed: 77 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ import {
2222
calculateInitialStrength,
2323
calculateEffectiveHalfLife,
2424
calculateRetentionStrength,
25+
calculateDormantDays,
26+
reinforceMemory,
2527
} from "../src/workspace-memory.ts";
2628
import { redactCredentials } from "../src/redaction.ts";
2729
import { assessMemoryQuality, isHardQualityReason, isProgressSnapshotViolation } from "../src/memory-quality.ts";
@@ -312,6 +314,71 @@ test("calculateRetentionStrength falls back to updatedAt when retentionClock is
312314
assert.equal(calculateRetentionStrength(memory, now, 0), initialStrength / 2);
313315
});
314316

317+
test("calculateDormantDays applies fourteen day workspace activity grace", () => {
318+
const now = Date.UTC(2026, 3, 29);
319+
const activeWithinGrace: WorkspaceMemoryStore = {
320+
version: 1,
321+
workspace: { root: "/repo", key: "abc" },
322+
limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries },
323+
entries: [],
324+
updatedAt: new Date(now).toISOString(),
325+
lastActivityAt: new Date(now - 13 * 24 * 60 * 60 * 1000).toISOString(),
326+
};
327+
const dormantPastGrace: WorkspaceMemoryStore = {
328+
...activeWithinGrace,
329+
lastActivityAt: new Date(now - 20 * 24 * 60 * 60 * 1000).toISOString(),
330+
};
331+
332+
assert.equal(calculateDormantDays(activeWithinGrace, now), 0);
333+
assert.equal(calculateDormantDays(dormantPastGrace, now), 6);
334+
});
335+
336+
test("normalizeWorkspaceMemoryWithAccounting uses dormant workspace days for strength ordering", async () => {
337+
const now = Date.now();
338+
const reinforcedOldReference: LongTermMemoryEntry = {
339+
...entry("reinforced-old", "Reinforced legacy docs live at https://example.com/legacy", "reference"),
340+
retentionClock: now - 100 * 24 * 60 * 60 * 1000,
341+
reinforcementCount: 6,
342+
};
343+
const freshReference: LongTermMemoryEntry = {
344+
...entry("fresh", "Fresh docs live at https://example.com/fresh", "reference"),
345+
retentionClock: now,
346+
};
347+
const store: WorkspaceMemoryStore = {
348+
version: 1,
349+
workspace: { root: "/repo", key: "abc" },
350+
limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries },
351+
entries: [freshReference, reinforcedOldReference],
352+
updatedAt: new Date(now).toISOString(),
353+
lastActivityAt: new Date(now - (14 + 1000) * 24 * 60 * 60 * 1000).toISOString(),
354+
};
355+
356+
const result = await normalizeWorkspaceMemoryWithAccounting("/repo", store);
357+
358+
assert.deepEqual(result.kept.map(memory => memory.id), ["reinforced-old", "fresh"]);
359+
});
360+
361+
test("reinforceMemory enforces session interval and max guards", () => {
362+
const now = Date.UTC(2026, 3, 29);
363+
const base = entry("reinforce", "Durable memory should reinforce only when gated");
364+
const reinforced = reinforceMemory(base, "session-a", now);
365+
366+
assert.notEqual(reinforced, base);
367+
assert.equal(reinforced.reinforcementCount, 1);
368+
assert.equal(reinforced.lastReinforcedAt, now);
369+
assert.equal(reinforced.lastReinforcedSessionID, "session-a");
370+
371+
assert.equal(reinforceMemory(reinforced, "session-a", now + 2 * 60 * 60 * 1000), reinforced);
372+
assert.equal(reinforceMemory(reinforced, "session-b", now + 30 * 60 * 1000), reinforced);
373+
374+
const atMax: LongTermMemoryEntry = {
375+
...base,
376+
reinforcementCount: 6,
377+
lastReinforcedAt: now - 2 * 60 * 60 * 1000,
378+
};
379+
assert.equal(reinforceMemory(atMax, "session-c", now), atMax);
380+
});
381+
315382
test("enforceLongTermLimits orders entries by retention strength", () => {
316383
const now = Date.now();
317384
const freshFeedback: LongTermMemoryEntry = {
@@ -1457,7 +1524,7 @@ test("renderWorkspaceMemory excludes superseded entries", () => {
14571524
assert.doesNotMatch(rendered, /Waves 1-5 /);
14581525
});
14591526

1460-
test("loadWorkspaceMemory does not rewrite an already normalized store", async () => {
1527+
test("loadWorkspaceMemory records activity for an already normalized store", async () => {
14611528
const sandbox = await mkdtemp(join(tmpdir(), "wm-normalized-"));
14621529
const dataHome = join(sandbox, "xdg-data-home");
14631530
const root = join(sandbox, "workspace");
@@ -1491,11 +1558,12 @@ test("loadWorkspaceMemory does not rewrite an already normalized store", async (
14911558
const before = (await stat(storePath)).mtimeMs;
14921559
await sleep(20);
14931560

1494-
await loadWorkspaceMemory(root);
1561+
const loaded = await loadWorkspaceMemory(root);
14951562
await loadWorkspaceMemory(root);
14961563

14971564
const after = (await stat(storePath)).mtimeMs;
1498-
assert.equal(after, before, "normalized loads should not touch the store file");
1565+
assert.ok(after > before, "normalized loads should update workspace activity timestamp");
1566+
assert.ok(loaded.lastActivityAt, "load should expose last activity timestamp");
14991567
} finally {
15001568
if (previousXdgDataHome === undefined) {
15011569
delete process.env.XDG_DATA_HOME;
@@ -1506,7 +1574,7 @@ test("loadWorkspaceMemory does not rewrite an already normalized store", async (
15061574
}
15071575
});
15081576

1509-
test("loadWorkspaceMemory does not persist pure ordering normalization", async () => {
1577+
test("loadWorkspaceMemory preserves ordering while recording activity", async () => {
15101578
const sandbox = await mkdtemp(join(tmpdir(), "wm-ordering-"));
15111579
const dataHome = join(sandbox, "xdg-data-home");
15121580
const root = join(sandbox, "workspace");
@@ -1557,7 +1625,7 @@ test("loadWorkspaceMemory does not persist pure ordering normalization", async (
15571625
const after = (await stat(storePath)).mtimeMs;
15581626

15591627
assert.deepEqual(loaded.entries.map(memory => memory.id), ["feedback-first", "reference-second"]);
1560-
assert.equal(after, before, "order-only normalization should not write during load");
1628+
assert.ok(after > before, "load should write updated workspace activity timestamp");
15611629
} finally {
15621630
if (previousXdgDataHome === undefined) {
15631631
delete process.env.XDG_DATA_HOME;
@@ -1618,7 +1686,10 @@ test("loadWorkspaceMemory persists redaction changes and is stable afterward", a
16181686
await sleep(20);
16191687
await loadWorkspaceMemory(root);
16201688
const afterSecondLoad = (await stat(storePath)).mtimeMs;
1621-
assert.equal(afterSecondLoad, beforeSecondLoad, "second load should not rewrite redacted content");
1689+
assert.ok(afterSecondLoad > beforeSecondLoad, "second load should update workspace activity timestamp");
1690+
const persistedAfterSecondLoad = await readFile(storePath, "utf-8");
1691+
assert.equal(persistedAfterSecondLoad.includes("sk-test-123"), false);
1692+
assert.equal(persistedAfterSecondLoad.includes("sushi"), false);
16221693
} finally {
16231694
if (previousXdgDataHome === undefined) {
16241695
delete process.env.XDG_DATA_HOME;

0 commit comments

Comments
 (0)