mirror of
https://github.com/sdwolf4103/opencode-working-memory.git
synced 2026-06-02 06:19:36 +02:00
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:
+6
-2
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user