feat(memory): add retention model test gaps and health diagnostics

Wave 1 - P0 Test Gaps:
- Add hard stale prune removed regression test
- Add dormant overlap tests (entry created during dormancy)
- Add invalid timestamp NaN protection test
- Add reinforcement ordering test with reference type
- Add dedupe same-session/under-1hr guard tests
- Fix NaN handling with Number.isFinite check

Wave 2 - Helper Functions:
- Add timestampMs() for safe timestamp conversion
- Add isSafetyCriticalForDiag() aligned with runtime

Wave 3 - Health Output Format:
- Fix top rendered candidates sorted by strength (not text length)
- Add stored vs rendered counts breakdown
- Add type caps and global cap overflow display
- Track globalCapped array explicitly
- Add dormant status section

Wave 4 - Monitoring Metrics:
- Add high_importance_ratio (alert > 30%)
- Add safety_critical_count (alert > 5)
- Add max_reinforced_count (alert > 10% active)

Wave 5 - Integration Fixture:
- Add 34-entry over-cap test
- Add mixed retention regression fixture
- Test TYPE_MAX caps, safety-critical exemption, reinforcement ordering

Tests: 224 → 237
This commit is contained in:
Ralph Chang
2026-04-29 15:26:44 +08:00
parent 04233f8452
commit 73384ca0a4
4 changed files with 474 additions and 17 deletions
+124 -7
View File
@@ -12,7 +12,7 @@ import { dataHome, extractionRejectionLogPath, migrationLogPath, workspaceKey, w
import { assessMemoryQuality, HARD_QUALITY_REASONS } from "../src/memory-quality.ts";
import { redactCredentials } from "../src/redaction.ts";
import { scanWorkspaceResidues } from "../src/workspace-cleanup.ts";
import { renderWorkspaceMemory } from "../src/workspace-memory.ts";
import { calculateRetentionStrength, renderWorkspaceMemory } from "../src/workspace-memory.ts";
import type { LongTermMemoryEntry, LongTermSource, LongTermType, PendingMemoryJournalStore, WorkspaceMemoryStore } from "../src/types.ts";
import { LONG_TERM_LIMITS, PROMOTION_RETRY_LIMITS } from "../src/types.ts";
@@ -65,6 +65,14 @@ type MigrationLogRecord = {
};
const TYPES: LongTermType[] = ["feedback", "decision", "project", "reference"];
const TYPE_MAX_FOR_DIAG: Record<LongTermType, number> = {
feedback: 10,
decision: 10,
project: 8,
reference: 6,
};
const WORKSPACE_DORMANT_AFTER_DAYS_FOR_DIAG = 14;
const DORMANT_DECAY_MULTIPLIER_FOR_DIAG = 0.25;
const SUSPICIOUS_REASONS = [
"progress_snapshot",
"active_file_snapshot",
@@ -229,6 +237,68 @@ function ageDays(entry: LongTermMemoryEntry): number | null {
return Math.floor((Date.now() - time) / 86_400_000);
}
function formatStrength(value: number): string {
return Number.isFinite(value) ? value.toFixed(3) : "0.000";
}
function daysSinceIso(value: string | undefined, now = Date.now()): number | null {
if (!value) return null;
const ms = new Date(value).getTime();
if (!Number.isFinite(ms)) return null;
return Math.max(0, (now - ms) / 86_400_000);
}
function formatPercent(ratio: number): string {
return `${(ratio * 100).toFixed(1)}%`;
}
type RetentionDiagItem = {
entry: LongTermMemoryEntry;
strength: number;
};
function isSafetyCriticalForDiag(entry: LongTermMemoryEntry): boolean {
return entry.safetyCritical === true;
}
function retentionCandidatesForDiag(store: WorkspaceMemoryStore): {
sorted: RetentionDiagItem[];
rendered: RetentionDiagItem[];
typeCapped: RetentionDiagItem[];
globalCapped: RetentionDiagItem[];
} {
const now = Date.now();
const active = store.entries.filter(entry => entry.status !== "superseded");
const sorted = active
.map(entry => ({ entry, strength: calculateRetentionStrength(entry, now, store.lastActivityAt) }))
.sort((a, b) => b.strength - a.strength || a.entry.id.localeCompare(b.entry.id));
const rendered: RetentionDiagItem[] = [];
const typeCapped: RetentionDiagItem[] = [];
const globalCapped: RetentionDiagItem[] = [];
const typeCounts: Partial<Record<LongTermType, number>> = {};
for (const item of sorted) {
if (!isSafetyCriticalForDiag(item.entry)) {
const count = typeCounts[item.entry.type] ?? 0;
const max = TYPE_MAX_FOR_DIAG[item.entry.type] ?? Infinity;
if (count >= max) {
typeCapped.push(item);
continue;
}
typeCounts[item.entry.type] = count + 1;
}
if (rendered.length < LONG_TERM_LIMITS.maxEntries) {
rendered.push(item);
} else {
globalCapped.push(item);
}
}
return { sorted, rendered, typeCapped, globalCapped };
}
function promotionLimit(source: LongTermSource): number {
if (source === "manual") return PROMOTION_RETRY_LIMITS.maxManualAttempts;
return PROMOTION_RETRY_LIMITS.maxExplicitAttempts;
@@ -333,10 +403,13 @@ async function printWorkspaceHealth(input: {
const active = store.entries.filter(entry => entry.status !== "superseded");
const superseded = store.entries.filter(entry => entry.status === "superseded");
const retention = retentionCandidatesForDiag(store);
const renderedEntries = retention.rendered.map(item => item.entry);
const renderedEstimate = renderWorkspaceMemory(store).length;
console.log(`Active memories: ${active.length}`);
console.log(`Stored active memories: ${active.length}`);
console.log(`Superseded memories: ${superseded.length}`);
console.log(`Rendered candidates: ${renderedEntries.length}`);
console.log(`Rendered estimate: ${renderedEstimate.toLocaleString()} chars`);
console.log("");
@@ -356,12 +429,18 @@ async function printWorkspaceHealth(input: {
console.log("By type:");
for (const type of TYPES) {
const activeCount = active.filter(entry => entry.type === type).length;
const storedCount = active.filter(entry => entry.type === type).length;
const renderedCount = renderedEntries.filter(entry => entry.type === type).length;
const supersededCount = superseded.filter(entry => entry.type === type).length;
console.log(` ${type.padEnd(9)} active=${String(activeCount).padEnd(3)} superseded=${supersededCount}`);
console.log(` ${type.padEnd(9)} stored=${String(storedCount).padEnd(3)} rendered=${String(renderedCount).padEnd(3)} typeCap=${TYPE_MAX_FOR_DIAG[type]} superseded=${supersededCount}`);
}
console.log("");
console.log("Retention caps:");
console.log(` type-capped entries: ${retention.typeCapped.length}`);
console.log(` global-cap overflow: ${retention.globalCapped.length}`);
console.log("");
const olderThan30 = active.filter(entry => (ageDays(entry) ?? 0) > 30).length;
const olderThan90 = active.filter(entry => (ageDays(entry) ?? 0) > 90).length;
const staleMarked = active.filter(entry => {
@@ -374,6 +453,33 @@ async function printWorkspaceHealth(input: {
console.log(` older than 90d: ${olderThan90}`);
console.log("");
const wallDaysSinceActivity = daysSinceIso(store.lastActivityAt);
const dormantDiscountActive = wallDaysSinceActivity !== null && wallDaysSinceActivity > WORKSPACE_DORMANT_AFTER_DAYS_FOR_DIAG;
const dormantDaysPastGrace = wallDaysSinceActivity === null
? 0
: Math.max(0, wallDaysSinceActivity - WORKSPACE_DORMANT_AFTER_DAYS_FOR_DIAG);
console.log("Dormancy:");
console.log(` lastActivityAt: ${store.lastActivityAt ?? "(missing)"}`);
console.log(` wall days since activity: ${wallDaysSinceActivity === null ? "unknown" : wallDaysSinceActivity.toFixed(1)}`);
console.log(` dormant discount active: ${dormantDiscountActive ? "yes" : "no"}`);
console.log(` dormant days past grace: ${dormantDaysPastGrace.toFixed(1)}`);
console.log(` dormant multiplier: ${DORMANT_DECAY_MULTIPLIER_FOR_DIAG}`);
console.log("");
const highImportanceCount = active.filter(entry => entry.userImportance === "high").length;
const safetyCriticalCount = active.filter(isSafetyCriticalForDiag).length;
const maxReinforcedCount = active.filter(entry => (entry.reinforcementCount ?? 0) >= 6).length;
const highImportanceRatio = active.length === 0 ? 0 : highImportanceCount / active.length;
const maxReinforcedRatio = active.length === 0 ? 0 : maxReinforcedCount / active.length;
const highImportanceAlert = highImportanceRatio > 0.3;
const safetyCriticalAlert = safetyCriticalCount > 5;
const maxReinforcedAlert = maxReinforcedRatio > 0.1;
console.log("Retention monitoring:");
console.log(` high_importance_ratio: ${formatPercent(highImportanceRatio)} (alert > 30%)${highImportanceAlert ? " ALERT" : ""}`);
console.log(` safety_critical_count: ${safetyCriticalCount} (alert > 5)${safetyCriticalAlert ? " ALERT" : ""}`);
console.log(` max_reinforced_count: ${maxReinforcedAlert ? `${maxReinforcedCount} (${formatPercent(maxReinforcedRatio)}, alert > 10%) ALERT` : `${maxReinforcedCount} (alert > 10% active)`}`);
console.log("");
const qualityByEntry = active.map(entry => ({ entry, quality: assessMemoryQuality(entry) }));
const duplicateCounts = countBy(active.map(entry => `${entry.type}:${canonicalMemoryText(entry.text)}`));
const duplicateExtras = [...duplicateCounts.values()].reduce((sum, count) => sum + Math.max(0, count - 1), 0);
@@ -400,12 +506,23 @@ async function printWorkspaceHealth(input: {
console.log("");
console.log("Top rendered candidates:");
const top = [...active].sort((a, b) => b.text.length - a.text.length).slice(0, 5);
const top = retention.rendered.slice(0, 5);
if (top.length === 0) {
console.log(" (none)");
} else {
for (const entry of top) {
console.log(` - [${entry.type}] ${truncate(cleanText(entry.text, input.raw))}`);
for (const item of top) {
console.log(` - strength=${formatStrength(item.strength)} [${item.entry.type}] ${truncate(cleanText(item.entry.text, input.raw))}`);
}
}
console.log("");
console.log("Weakest active memories:");
const weakest = retention.sorted.slice(-5).reverse();
if (weakest.length === 0) {
console.log(" (none)");
} else {
for (const item of weakest) {
console.log(` - strength=${formatStrength(item.strength)} [${item.entry.type}] ${truncate(cleanText(item.entry.text, input.raw))}`);
}
}
}
+10 -3
View File
@@ -67,6 +67,11 @@ export function calculateEffectiveHalfLife(memory: LongTermMemoryEntry): number
return BASE_HALF_LIFE_DAYS / factor;
}
function timestampMs(value: unknown, fallback: number): number {
const ms = typeof value === "number" ? value : new Date(String(value)).getTime();
return Number.isFinite(ms) ? ms : fallback;
}
export function calculateRetentionStrength(
memory: LongTermMemoryEntry,
now: number,
@@ -76,14 +81,16 @@ export function calculateRetentionStrength(
const effectiveHalfLife = calculateEffectiveHalfLife(memory);
// Use retentionClock if available, fallback to updatedAt.
const retentionStart = memory.retentionClock ?? memory.updatedAt;
const createdAtMs = new Date(retentionStart).getTime();
const retentionStart = Number.isFinite(memory.retentionClock)
? memory.retentionClock
: memory.updatedAt ?? memory.createdAt;
const createdAtMs = timestampMs(retentionStart, now);
const effectiveAgeDays = calculateEffectiveAgeDays(createdAtMs, now, lastActivityAt);
// Calculate strength using exponential decay.
const strength = initialStrength * Math.pow(2, -effectiveAgeDays / effectiveHalfLife);
return Math.max(0, strength);
return Number.isFinite(strength) ? Math.max(0, strength) : 0;
}
export function calculateDormantDays(store: WorkspaceMemoryStore, now: number): number {
+143
View File
@@ -0,0 +1,143 @@
import test from "node:test";
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import type { LongTermMemoryEntry, WorkspaceMemoryStore } from "../src/types.ts";
import { LONG_TERM_LIMITS } from "../src/types.ts";
import { workspaceKey, workspaceMemoryPath } from "../src/paths.ts";
const execFileAsync = promisify(execFile);
const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
function entry(id: string, text: string, type: LongTermMemoryEntry["type"]): LongTermMemoryEntry {
const now = new Date().toISOString();
return {
id,
type,
text,
source: "compaction",
confidence: 0.75,
status: "active",
createdAt: now,
updatedAt: now,
};
}
async function writeWorkspaceStore(root: string, entries: LongTermMemoryEntry[], options: { lastActivityAt?: string; omitLastActivityAt?: boolean } = {}): Promise<void> {
const key = await workspaceKey(root);
const path = await workspaceMemoryPath(root);
const now = new Date().toISOString();
const store: WorkspaceMemoryStore = {
version: 1,
workspace: { root, key },
limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries },
entries,
migrations: [],
updatedAt: now,
};
if (!options.omitLastActivityAt) store.lastActivityAt = options.lastActivityAt ?? now;
await mkdir(dirname(path), { recursive: true });
await writeFile(path, JSON.stringify(store, null, 2), "utf8");
}
async function runMemoryDiagHealth(root: string): Promise<string> {
const { stdout } = await execFileAsync(process.execPath, [
"--experimental-strip-types",
"scripts/memory-diag.ts",
"health",
"--workspace",
root,
], { cwd: repoRoot });
return stdout;
}
test("memory health reports stored vs rendered retention counts", async () => {
const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-"));
try {
const entries: LongTermMemoryEntry[] = [
...Array.from({ length: 17 }, (_, i) => entry(`feedback-${i}`, `Unique feedback preference for memory health ${i}`, "feedback")),
...Array.from({ length: 11 }, (_, i) => entry(`decision-${i}`, `Unique durable decision for memory health ${i}`, "decision")),
];
await writeWorkspaceStore(root, entries);
const stdout = await runMemoryDiagHealth(root);
assert.match(stdout, /Stored active memories:/);
assert.match(stdout, /Rendered candidates:/);
assert.match(stdout, /feedback\s+stored=17\s+rendered=10/);
assert.match(stdout, /Top rendered candidates:\n\s+- strength=/);
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("memory health reports dormancy and retention monitoring alerts", async () => {
const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-"));
try {
const lastActivityAt = new Date(Date.now() - 19 * 24 * 60 * 60 * 1000).toISOString();
const entries = Array.from({ length: 10 }, (_, i) => ({
...entry(`monitoring-${i}`, `Unique monitoring memory ${i} for retention health`, i % 2 === 0 ? "feedback" : "decision"),
userImportance: i < 4 ? "high" as const : "normal" as const,
safetyCritical: i < 6,
reinforcementCount: i < 2 ? 6 : 0,
}));
await writeWorkspaceStore(root, entries, { lastActivityAt });
const stdout = await runMemoryDiagHealth(root);
assert.match(stdout, /Dormancy:/);
assert.match(stdout, /wall days since activity: 19\.0/);
assert.match(stdout, /dormant discount active: yes/);
assert.match(stdout, /dormant days past grace: 5\.0/);
assert.match(stdout, /high_importance_ratio: 40\.0% .* ALERT/);
assert.match(stdout, /safety_critical_count: 6 .* ALERT/);
assert.match(stdout, /max_reinforced_count: 2 \(20\.0%, alert > 10%\) ALERT/);
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("memory health reports global cap overflow separately from type caps", async () => {
const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-"));
try {
const entries: LongTermMemoryEntry[] = [
...Array.from({ length: 10 }, (_, i) => entry(`global-feedback-${i}`, `Unique global feedback preference ${i}`, "feedback")),
...Array.from({ length: 10 }, (_, i) => entry(`global-decision-${i}`, `Unique global durable decision ${i}`, "decision")),
...Array.from({ length: 8 }, (_, i) => entry(`global-project-${i}`, `Unique global project fact ${i}`, "project")),
...Array.from({ length: 6 }, (_, i) => entry(`global-reference-${i}`, `Unique global reference fact ${i}`, "reference")),
];
await writeWorkspaceStore(root, entries);
const stdout = await runMemoryDiagHealth(root);
assert.match(stdout, /Rendered candidates: 28/);
assert.match(stdout, /type-capped entries: 0/);
assert.match(stdout, /global-cap overflow: 6/);
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("memory health reports missing dormancy and non-alert monitoring defaults", async () => {
const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-"));
try {
await writeWorkspaceStore(root, [], { omitLastActivityAt: true });
const stdout = await runMemoryDiagHealth(root);
assert.match(stdout, /lastActivityAt: \(missing\)/);
assert.match(stdout, /wall days since activity: unknown/);
assert.match(stdout, /dormant discount active: no/);
assert.match(stdout, /high_importance_ratio: 0\.0% \(alert > 30%\)\n/);
assert.match(stdout, /safety_critical_count: 0 \(alert > 5\)\n/);
assert.match(stdout, /max_reinforced_count: 0 \(alert > 10% active\)/);
} finally {
await rm(root, { recursive: true, force: true });
}
});
+197 -7
View File
@@ -31,6 +31,8 @@ import { assessMemoryQuality, isHardQualityReason, isProgressSnapshotViolation }
import { reviewerCurrent28Fixture } from "./fixtures/memory-quality-current-28.ts";
import { REAL_WORKSPACE_FIXTURES } from "./fixtures/real-workspaces-snapshot.ts";
const DAY_MS = 24 * 60 * 60 * 1000;
function entry(id: string, text: string, type: LongTermMemoryEntry["type"] = "decision"): LongTermMemoryEntry {
const now = new Date().toISOString();
return {
@@ -289,7 +291,7 @@ test("calculateEffectiveHalfLife clamps reinforcement count at configured maximu
test("calculateRetentionStrength slows decay for dormancy after activity grace", () => {
const now = Date.UTC(2026, 3, 29);
const nineteenDaysAgo = now - 19 * 24 * 60 * 60 * 1000;
const nineteenDaysAgo = now - 19 * DAY_MS;
const memory: LongTermMemoryEntry = {
...entry("retention-clock", "Use TypeScript for plugin code", "reference"),
source: "compaction",
@@ -307,7 +309,7 @@ test("calculateRetentionStrength slows decay for dormancy after activity grace",
test("calculateEffectiveAgeDays matches plan worked dormant example", () => {
const now = Date.UTC(2026, 3, 29);
const twentyEightDaysAgo = now - 28 * 24 * 60 * 60 * 1000;
const twentyEightDaysAgo = now - 28 * DAY_MS;
assert.equal(
calculateEffectiveAgeDays(twentyEightDaysAgo, now, new Date(twentyEightDaysAgo).toISOString()),
@@ -320,13 +322,59 @@ test("calculateRetentionStrength falls back to updatedAt when retentionClock is
const memory: LongTermMemoryEntry = {
...entry("updated-at", "Prefer concise verification summaries", "feedback"),
source: "manual",
updatedAt: new Date(now - 45 * 24 * 60 * 60 * 1000).toISOString(),
updatedAt: new Date(now - 45 * DAY_MS).toISOString(),
};
const initialStrength = 2.25 * 1.4;
assert.equal(calculateRetentionStrength(memory, now), initialStrength / 2);
});
test("calculateEffectiveAgeDays does not charge old dormancy to fresh entries", () => {
const now = Date.UTC(2026, 3, 29);
const lastActivityAt = new Date(now - 180 * DAY_MS).toISOString();
assert.equal(calculateEffectiveAgeDays(now, now, lastActivityAt), 0);
});
test("calculateEffectiveAgeDays discounts dormant overlap since entry creation", () => {
const now = Date.UTC(2026, 3, 29);
const entryCreatedSevenDaysAgo = now - 7 * DAY_MS;
const lastActivityAt = new Date(now - 180 * DAY_MS).toISOString();
assert.equal(
calculateEffectiveAgeDays(entryCreatedSevenDaysAgo, now, lastActivityAt),
1.75,
);
});
test("calculateRetentionStrength returns finite value for invalid updatedAt fallback", () => {
const now = Date.UTC(2026, 3, 29);
const memory: LongTermMemoryEntry = {
...entry("bad-updated-at", "Invalid timestamps should not corrupt sorting", "feedback"),
updatedAt: "not-a-date",
retentionClock: undefined,
};
const strength = calculateRetentionStrength(memory, now);
assert.equal(Number.isFinite(strength), true);
assert.ok(strength >= 0);
});
test("calculateRetentionStrength returns finite value for invalid retentionClock", () => {
const now = Date.UTC(2026, 3, 29);
const memory: LongTermMemoryEntry = {
...entry("bad-retention-clock", "Invalid retention clocks should fall back safely", "decision"),
retentionClock: Number.NaN,
updatedAt: new Date(now - 45 * DAY_MS).toISOString(),
};
const strength = calculateRetentionStrength(memory, now);
assert.equal(Number.isFinite(strength), true);
assert.ok(strength >= 0);
});
test("calculateDormantDays applies fourteen day workspace activity grace", () => {
const now = Date.UTC(2026, 3, 29);
const activeWithinGrace: WorkspaceMemoryStore = {
@@ -335,11 +383,11 @@ test("calculateDormantDays applies fourteen day workspace activity grace", () =>
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(),
lastActivityAt: new Date(now - 13 * DAY_MS).toISOString(),
};
const dormantPastGrace: WorkspaceMemoryStore = {
...activeWithinGrace,
lastActivityAt: new Date(now - 20 * 24 * 60 * 60 * 1000).toISOString(),
lastActivityAt: new Date(now - 20 * DAY_MS).toISOString(),
};
assert.equal(calculateDormantDays(activeWithinGrace, now), 13);
@@ -350,7 +398,7 @@ test("normalizeWorkspaceMemoryWithAccounting uses dormant workspace days for str
const now = Date.now();
const reinforcedOldReference: LongTermMemoryEntry = {
...entry("reinforced-old", "Project uses the legacy local plugin architecture", "project"),
retentionClock: now - 100 * 24 * 60 * 60 * 1000,
retentionClock: now - 100 * DAY_MS,
reinforcementCount: 6,
};
const freshReference: LongTermMemoryEntry = {
@@ -363,7 +411,7 @@ test("normalizeWorkspaceMemoryWithAccounting uses dormant workspace days for str
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(),
lastActivityAt: new Date(now - (14 + 1000) * DAY_MS).toISOString(),
};
const result = await normalizeWorkspaceMemoryWithAccounting("/repo", store);
@@ -417,6 +465,73 @@ test("dedupeLongTermEntriesWithAccounting reinforces absorbed exact duplicates",
assert.ok(typeof result.kept[0].retentionClock === "number");
});
test("reinforced memory with same initial strength and age ranks above unreinforced memory", () => {
const now = Date.now();
const age = now - 120 * DAY_MS;
const unreinforced: LongTermMemoryEntry = {
...entry("unreinforced", "Always run typecheck before commit", "feedback"),
retentionClock: age,
createdAt: new Date(age).toISOString(),
updatedAt: new Date(age).toISOString(),
};
const reinforced: LongTermMemoryEntry = {
...entry("reinforced", "Prefer functional composition over inheritance", "feedback"),
retentionClock: age,
createdAt: new Date(age).toISOString(),
updatedAt: new Date(age).toISOString(),
reinforcementCount: 6,
};
const kept = enforceLongTermLimits([unreinforced, reinforced]);
assert.deepEqual(kept.map(memory => memory.id), ["reinforced", "unreinforced"]);
});
test("dedupe reinforcement does not increment for same session", () => {
const now = Date.now();
const existing: LongTermMemoryEntry = {
...entry("existing", "Use pnpm for package management", "decision"),
source: "manual",
pendingOwnerSessionID: "same-session",
reinforcementCount: 1,
lastReinforcedAt: now - 2 * 60 * 60 * 1000,
lastReinforcedSessionID: "same-session",
};
const duplicate: LongTermMemoryEntry = {
...entry("duplicate", "use pnpm for package management!!!", "decision"),
pendingOwnerSessionID: "same-session",
};
const result = dedupeLongTermEntriesWithAccounting([existing, duplicate]);
const retained = result.kept.find(memory => memory.id === "existing");
assert.ok(retained, "existing manual memory should be retained");
assert.equal(retained.reinforcementCount, 1);
assert.equal(retained.lastReinforcedSessionID, "same-session");
});
test("dedupe reinforcement does not increment under one hour", () => {
const now = Date.now();
const existing: LongTermMemoryEntry = {
...entry("existing", "Prefer deterministic consolidation accounting", "feedback"),
source: "manual",
reinforcementCount: 1,
lastReinforcedAt: now - 30 * 60 * 1000,
lastReinforcedSessionID: "old-session",
};
const duplicate: LongTermMemoryEntry = {
...entry("duplicate", "prefer deterministic consolidation accounting!!!", "feedback"),
pendingOwnerSessionID: "new-session",
};
const result = dedupeLongTermEntriesWithAccounting([existing, duplicate]);
const retained = result.kept.find(memory => memory.id === "existing");
assert.ok(retained, "existing manual memory should be retained");
assert.equal(retained.reinforcementCount, 1);
assert.equal(retained.lastReinforcedSessionID, "old-session");
});
test("enforceLongTermLimits orders entries by retention strength", () => {
const now = Date.now();
const freshFeedback: LongTermMemoryEntry = {
@@ -462,6 +577,81 @@ test("enforceLongTermLimits exempts safety-critical entries from type caps", ()
assert.equal(kept.filter(memory => memory.type === "feedback" && !memory.safetyCritical).length, 10);
});
test("mixed retention scenario applies caps, safety exemption, and reinforcement ordering", () => {
const now = Date.now();
const oldAge = now - 120 * DAY_MS;
const ordinaryFeedback = Array.from({ length: 17 }, (_, i) =>
entry(`mixed-feedback-${i}`, `Unique mixed ordinary feedback preference ${i}`, "feedback")
);
const decisions = Array.from({ length: 11 }, (_, i) =>
entry(`mixed-decision-${i}`, `Unique mixed durable decision ${i}`, "decision")
);
const safetyCriticalFeedback: LongTermMemoryEntry = {
...entry("mixed-safety-feedback", "Never store production credentials in memory", "feedback"),
safetyCritical: true,
};
const oldReinforcedReference: LongTermMemoryEntry = {
...entry("old-reinforced", "Legacy reinforced reference lives at https://example.com/reinforced", "reference"),
retentionClock: oldAge,
createdAt: new Date(oldAge).toISOString(),
updatedAt: new Date(oldAge).toISOString(),
reinforcementCount: 6,
};
const oldUnreinforcedReference: LongTermMemoryEntry = {
...entry("old-unreinforced", "Legacy unreinforced reference lives at https://example.com/unreinforced", "reference"),
retentionClock: oldAge,
createdAt: new Date(oldAge).toISOString(),
updatedAt: new Date(oldAge).toISOString(),
};
const entries = [
...ordinaryFeedback,
...decisions,
safetyCriticalFeedback,
oldReinforcedReference,
oldUnreinforcedReference,
];
const store: 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).toISOString(),
};
assert.ok(entries.filter(memory => memory.type === "feedback" && !memory.safetyCritical).length > 10);
assert.ok(entries.filter(memory => memory.type === "decision" && !memory.safetyCritical).length > 10);
const result = enforceLongTermLimitsWithAccounting(entries, store);
assert.ok(result.kept.length <= 28);
assert.ok(result.kept.filter(memory => memory.type === "feedback" && !memory.safetyCritical).length <= 10);
assert.ok(result.kept.filter(memory => memory.type === "decision" && !memory.safetyCritical).length <= 10);
assert.ok(result.kept.some(memory => memory.safetyCritical));
assert.equal(result.kept.filter(memory => memory.type === "feedback" && !memory.safetyCritical).length, 10);
assert.equal(result.kept.filter(memory => memory.type === "feedback" && memory.safetyCritical).length, 1);
assert.equal(result.kept.filter(memory => memory.type === "feedback").length, 11);
const reinforcedIndex = result.kept.findIndex(memory => memory.id === "old-reinforced");
const unreinforcedIndex = result.kept.findIndex(memory => memory.id === "old-unreinforced");
assert.ok(reinforcedIndex >= 0, "old reinforced reference should be kept");
assert.ok(unreinforcedIndex >= 0, "old unreinforced reference should be kept");
assert.ok(reinforcedIndex < unreinforcedIndex);
});
test("type max sum above global cap still respects maxEntries", () => {
const entries: LongTermMemoryEntry[] = [
...Array.from({ length: 10 }, (_, i) => entry(`feedback-${i}`, `Unique feedback preference ${i}`, "feedback")),
...Array.from({ length: 10 }, (_, i) => entry(`decision-${i}`, `Unique durable decision ${i}`, "decision")),
...Array.from({ length: 8 }, (_, i) => entry(`project-${i}`, `Unique project fact ${i}`, "project")),
...Array.from({ length: 6 }, (_, i) => entry(`reference-${i}`, `Unique reference fact ${i}`, "reference")),
];
const kept = enforceLongTermLimits(entries);
assert.equal(entries.length, 34);
assert.equal(kept.length, LONG_TERM_LIMITS.maxEntries);
});
test("normalization ordering is deterministic for retention ties", () => {
const createdAt = "2026-04-28T00:00:00.000Z";
const a = {