diff --git a/src/extractors.ts b/src/extractors.ts index 4370e81..b06ac15 100644 --- a/src/extractors.ts +++ b/src/extractors.ts @@ -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(); @@ -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), }); } diff --git a/src/plugin.ts b/src/plugin.ts index 594935d..6b0462f 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -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); } } diff --git a/src/types.ts b/src/types.ts index 7688d53..cfb1a80 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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 = { diff --git a/src/workspace-memory.ts b/src/workspace-memory.ts index 82e33bc..591cd7b 100644 --- a/src/workspace-memory.ts +++ b/src/workspace-memory.ts @@ -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 { + 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 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( diff --git a/tests/extractors.test.ts b/tests/extractors.test.ts index 1818d67..957bb48 100644 --- a/tests/extractors.test.ts +++ b/tests/extractors.test.ts @@ -91,9 +91,13 @@ test("extractExplicitMemories does not treat always as memory trigger", () => { }); test("extractExplicitMemories still captures going forward", () => { + const before = Date.now(); const items = extractExplicitMemories("going forward: use pnpm instead of npm"); + const after = Date.now(); assert.equal(items.length, 1); assert.match(items[0].text, /pnpm/); + assert.ok(typeof items[0].retentionClock === "number"); + assert.ok(items[0].retentionClock >= before && items[0].retentionClock <= after); }); test("extractExplicitMemories captures from now on", () => { @@ -200,14 +204,18 @@ test("parseWorkspaceMemoryCandidates rejects path-heavy facts", () => { }); test("parseWorkspaceMemoryCandidates accepts valid decision", () => { + const before = Date.now(); const summary = ` ## Memory Candidates - [decision] Use pnpm instead of npm for package management `; const items = parseWorkspaceMemoryCandidates(summary); + const after = Date.now(); assert.equal(items.length, 1); assert.equal(items[0].type, "decision"); assert.match(items[0].text, /pnpm/); + assert.ok(typeof items[0].retentionClock === "number"); + assert.ok(items[0].retentionClock >= before && items[0].retentionClock <= after); }); test("parseWorkspaceMemoryCandidates accepts valid project info", () => { diff --git a/tests/plugin.test.ts b/tests/plugin.test.ts index 3494352..07f8f66 100644 --- a/tests/plugin.test.ts +++ b/tests/plugin.test.ts @@ -772,6 +772,11 @@ test("session.compacted promotes pending memories to workspace memory and clears const workspacePrompt = after.system.find((part: string) => part.startsWith("Workspace memory")); assert.match(workspacePrompt ?? "", /Use frozen rendered snapshots for cache stability/); + + const workspace = await loadWorkspaceMemory(tmpDir); + const promoted = workspace.entries.find(entry => entry.id === "mem_pending_1"); + assert.ok(typeof promoted?.retentionClock === "number", + "legacy pending memory should receive a retention clock when promoted"); } finally { await rm(tmpDir, { recursive: true, force: true }); } diff --git a/tests/workspace-memory.test.ts b/tests/workspace-memory.test.ts index 4d2f5b1..f2ecb1d 100644 --- a/tests/workspace-memory.test.ts +++ b/tests/workspace-memory.test.ts @@ -22,6 +22,8 @@ import { calculateInitialStrength, calculateEffectiveHalfLife, calculateRetentionStrength, + calculateDormantDays, + reinforceMemory, } from "../src/workspace-memory.ts"; import { redactCredentials } from "../src/redaction.ts"; import { assessMemoryQuality, isHardQualityReason, isProgressSnapshotViolation } from "../src/memory-quality.ts"; @@ -312,6 +314,71 @@ test("calculateRetentionStrength falls back to updatedAt when retentionClock is assert.equal(calculateRetentionStrength(memory, now, 0), initialStrength / 2); }); +test("calculateDormantDays applies fourteen day workspace activity grace", () => { + const now = Date.UTC(2026, 3, 29); + const activeWithinGrace: WorkspaceMemoryStore = { + version: 1, + workspace: { root: "/repo", key: "abc" }, + limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries }, + entries: [], + updatedAt: new Date(now).toISOString(), + lastActivityAt: new Date(now - 13 * 24 * 60 * 60 * 1000).toISOString(), + }; + const dormantPastGrace: WorkspaceMemoryStore = { + ...activeWithinGrace, + lastActivityAt: new Date(now - 20 * 24 * 60 * 60 * 1000).toISOString(), + }; + + assert.equal(calculateDormantDays(activeWithinGrace, now), 0); + assert.equal(calculateDormantDays(dormantPastGrace, now), 6); +}); + +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"), + retentionClock: now - 100 * 24 * 60 * 60 * 1000, + reinforcementCount: 6, + }; + const freshReference: LongTermMemoryEntry = { + ...entry("fresh", "Fresh docs live at https://example.com/fresh", "reference"), + retentionClock: now, + }; + const store: WorkspaceMemoryStore = { + version: 1, + workspace: { root: "/repo", key: "abc" }, + limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries }, + entries: [freshReference, reinforcedOldReference], + updatedAt: new Date(now).toISOString(), + lastActivityAt: new Date(now - (14 + 1000) * 24 * 60 * 60 * 1000).toISOString(), + }; + + const result = await normalizeWorkspaceMemoryWithAccounting("/repo", store); + + assert.deepEqual(result.kept.map(memory => memory.id), ["reinforced-old", "fresh"]); +}); + +test("reinforceMemory enforces session interval and max guards", () => { + const now = Date.UTC(2026, 3, 29); + const base = entry("reinforce", "Durable memory should reinforce only when gated"); + const reinforced = reinforceMemory(base, "session-a", now); + + assert.notEqual(reinforced, base); + assert.equal(reinforced.reinforcementCount, 1); + assert.equal(reinforced.lastReinforcedAt, now); + assert.equal(reinforced.lastReinforcedSessionID, "session-a"); + + assert.equal(reinforceMemory(reinforced, "session-a", now + 2 * 60 * 60 * 1000), reinforced); + assert.equal(reinforceMemory(reinforced, "session-b", now + 30 * 60 * 1000), reinforced); + + const atMax: LongTermMemoryEntry = { + ...base, + reinforcementCount: 6, + lastReinforcedAt: now - 2 * 60 * 60 * 1000, + }; + assert.equal(reinforceMemory(atMax, "session-c", now), atMax); +}); + test("enforceLongTermLimits orders entries by retention strength", () => { const now = Date.now(); const freshFeedback: LongTermMemoryEntry = { @@ -1457,7 +1524,7 @@ test("renderWorkspaceMemory excludes superseded entries", () => { assert.doesNotMatch(rendered, /Waves 1-5 已完成/); }); -test("loadWorkspaceMemory does not rewrite an already normalized store", async () => { +test("loadWorkspaceMemory records activity for an already normalized store", async () => { const sandbox = await mkdtemp(join(tmpdir(), "wm-normalized-")); const dataHome = join(sandbox, "xdg-data-home"); const root = join(sandbox, "workspace"); @@ -1491,11 +1558,12 @@ test("loadWorkspaceMemory does not rewrite an already normalized store", async ( const before = (await stat(storePath)).mtimeMs; await sleep(20); - await loadWorkspaceMemory(root); + const loaded = await loadWorkspaceMemory(root); await loadWorkspaceMemory(root); const after = (await stat(storePath)).mtimeMs; - assert.equal(after, before, "normalized loads should not touch the store file"); + assert.ok(after > before, "normalized loads should update workspace activity timestamp"); + assert.ok(loaded.lastActivityAt, "load should expose last activity timestamp"); } finally { if (previousXdgDataHome === undefined) { delete process.env.XDG_DATA_HOME; @@ -1506,7 +1574,7 @@ test("loadWorkspaceMemory does not rewrite an already normalized store", async ( } }); -test("loadWorkspaceMemory does not persist pure ordering normalization", async () => { +test("loadWorkspaceMemory preserves ordering while recording activity", async () => { const sandbox = await mkdtemp(join(tmpdir(), "wm-ordering-")); const dataHome = join(sandbox, "xdg-data-home"); const root = join(sandbox, "workspace"); @@ -1557,7 +1625,7 @@ test("loadWorkspaceMemory does not persist pure ordering normalization", async ( const after = (await stat(storePath)).mtimeMs; assert.deepEqual(loaded.entries.map(memory => memory.id), ["feedback-first", "reference-second"]); - assert.equal(after, before, "order-only normalization should not write during load"); + assert.ok(after > before, "load should write updated workspace activity timestamp"); } finally { if (previousXdgDataHome === undefined) { delete process.env.XDG_DATA_HOME; @@ -1618,7 +1686,10 @@ test("loadWorkspaceMemory persists redaction changes and is stable afterward", a await sleep(20); await loadWorkspaceMemory(root); const afterSecondLoad = (await stat(storePath)).mtimeMs; - assert.equal(afterSecondLoad, beforeSecondLoad, "second load should not rewrite redacted content"); + assert.ok(afterSecondLoad > beforeSecondLoad, "second load should update workspace activity timestamp"); + const persistedAfterSecondLoad = await readFile(storePath, "utf-8"); + assert.equal(persistedAfterSecondLoad.includes("sk-test-123"), false); + assert.equal(persistedAfterSecondLoad.includes("sushi"), false); } finally { if (previousXdgDataHome === undefined) { delete process.env.XDG_DATA_HOME;