diff --git a/CHANGELOG.md b/CHANGELOG.md index b06b52f..c9adad0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Strength-based workspace memory retention using exponential decay instead of additive priority scoring. - Per-type rendered caps for workspace memory candidates: feedback 10, decision 10, project 8, and reference 6. -- Safety-critical memory weighting and type-cap exemption so important entries survive type floods while still competing under the global rendered cap. - Dormant-workspace effective age: after 14 days without activity, additional dormant time counts at 0.25x for retention decay. - Reinforcement tracking for repeated memories, with same-session and one-hour guards to prevent accidental reinforcement spam. - Memory health diagnostics for stored vs rendered counts, type caps, global cap overflow, dormancy, retention monitoring, and strength-ranked top/weakest entries. @@ -21,6 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Workspace memory rendering now ranks entries by retention strength, not the previous priority/penalty model. - Confidence is retained for compatibility but no longer affects retention scoring. +- Deprecated `safetyCritical` is retained for JSON compatibility but no longer affects retention strength or type-cap behavior. - Old or stale-marked memories are no longer hard-pruned; they remain stored and only fall out of rendered context through strength and cap competition. - Existing duplicate promotion and dedupe paths now reinforce the surviving memory instead of only absorbing the duplicate. - Health output now separates stored active memories from rendered candidates to make cap behavior easier to understand. diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index a2e0f60..ce7ba27 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -6,7 +6,7 @@ This release changes workspace memory retention from hard stale pruning and additive priority scoring to a strength-based decay model. -Think of it like a forgetting curve: memories fade over time, but important, reinforced, and safety-critical memories decay slower. Weak entries fall out of rendered prompt context by cap competition, not hard deletion. +Think of it like a forgetting curve: memories fade over time, but important and reinforced memories decay slower. Weak entries fall out of rendered prompt context by cap competition, not hard deletion. > **Memory should fade, so the agent can keep learning.** > Important memories decay slower, but every memory must leave room for newer project reality and avoid long-term memory pollution. @@ -27,10 +27,10 @@ Think of it like a forgetting curve: memories fade over time, but important, rei ### What Changed - **Strength-based retention**: workspace memory now uses exponential decay: initial strength × age decay. -- **Better initial strength**: type, source, user importance, and safety-critical status now determine how strong a memory starts. +- **Better initial strength**: type, source, and user importance now determine how strong a memory starts. - **No confidence scoring**: confidence remains in stored data for compatibility, but it no longer affects retention ranking. - **Type caps**: rendered workspace memory now caps feedback, decisions, project facts, and references separately so one type cannot monopolize all 28 slots. -- **Safety-critical protection**: safety-critical entries get stronger retention and are exempt from per-type caps, while still competing under the global rendered cap. +- **Deprecation:** `safetyCritical` field no longer affects retention strength or type-cap bypass. All system memories now fade according to the same rules. Safety rules belong in user-controlled `agent.md` files, not in system memory. - **Dormant-aware age**: after 14 inactive days, additional dormant workspace time counts at 0.25x so paused projects do not forget too aggressively. - **Reinforcement**: repeated matching memories reinforce the survivor and slow future decay, with same-session and one-hour guards to avoid accidental spam. - **No hard stale pruning**: old or stale-marked memories are no longer automatically dropped by age; they lose rendered space only through cap competition. diff --git a/docs/architecture.md b/docs/architecture.md index 52d73fe..7fb88ac 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -109,14 +109,14 @@ Retention then decides which active memories are rendered into prompt context. I strength = initialStrength * 2 ** (-effectiveAgeDays / effectiveHalfLifeDays) ``` -Initial strength is based on memory type, source, optional user importance, and safety-critical status. Confidence remains stored for compatibility but is not part of retention scoring. +Initial strength is based on memory type, source, and optional user importance. Confidence remains stored for compatibility but is not part of retention scoring. Rendered candidates are selected in this order: 1. Exclude `status: "superseded"` entries. 2. Compute current retention strength. 3. Sort by strength descending. -4. Apply per-type caps, with safety-critical entries exempt from type caps. +4. Apply per-type caps. 5. Keep the top 28 rendered entries under the workspace memory character budget. Default type caps: @@ -132,6 +132,10 @@ The type-cap total is 34, intentionally above the global 28-entry cap. These are Dormant workspaces age more slowly: after 14 inactive days, additional dormant time counts at 0.25x for retention decay. Repeated duplicate memories reinforce the surviving entry and slow future decay, but same-session and under-one-hour repeats do not stack reinforcement. +### Safety-Critical Deprecation + +The `safetyCritical` field on `LongTermMemoryEntry` is deprecated as of the retention v1.5.1 model update. It no longer affects retention strength or type-cap bypass. The field is preserved in the type definition for backward compatibility with existing workspace memory JSON files, but has no active behavior. Safety rules should be maintained in user-controlled files such as `agent.md` rather than in system memory. + ### System Prompt Injection Workspace memory is injected at the top of every message: diff --git a/docs/configuration.md b/docs/configuration.md index 6610705..588a975 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -34,7 +34,7 @@ const WORKSPACE_DORMANT_AFTER_DAYS = 14; const DORMANT_DECAY_MULTIPLIER = 0.25; ``` -Initial strength uses type, source, user importance, and safety-critical factors. Confidence is stored for compatibility but is not used for retention scoring. +Initial strength uses type, source, and user importance factors. Confidence is stored for compatibility but is not used for retention scoring. Rendered type caps prevent one type from filling all workspace memory slots: @@ -45,7 +45,7 @@ Rendered type caps prevent one type from filling all workspace memory slots: | `project` | 8 | | `reference` | 6 | -Safety-critical memories are exempt from type caps but still compete under the global `maxEntries` limit. Old or stale-marked memories are not hard-pruned by age; they lose rendered space through strength and cap competition. +Old or stale-marked memories are not hard-pruned by age; they lose rendered space through strength and cap competition. The deprecated `safetyCritical` field is preserved for compatibility but no longer affects strength or type caps. ## Hot Session State Limits diff --git a/scripts/memory-diag.ts b/scripts/memory-diag.ts index cae5ad3..e28909c 100644 --- a/scripts/memory-diag.ts +++ b/scripts/memory-diag.ts @@ -279,15 +279,13 @@ function retentionCandidatesForDiag(store: WorkspaceMemoryStore): { const typeCounts: Partial> = {}; 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; + 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); @@ -472,11 +470,11 @@ async function printWorkspaceHealth(input: { 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 safetyCriticalWarning = safetyCriticalCount > 0; 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(` safety_critical_count: ${safetyCriticalCount} (deprecated field)${safetyCriticalWarning ? " WARNING" : ""}`); console.log(` max_reinforced_count: ${maxReinforcedAlert ? `${maxReinforcedCount} (${formatPercent(maxReinforcedRatio)}, alert > 10%) ALERT` : `${maxReinforcedCount} (alert > 10% active)`}`); console.log(""); diff --git a/src/workspace-memory.ts b/src/workspace-memory.ts index db0e4e6..9ce5ef6 100644 --- a/src/workspace-memory.ts +++ b/src/workspace-memory.ts @@ -40,8 +40,6 @@ const USER_IMPORTANCE_FACTOR = { high: 1.5, } as const; -const SAFETY_CRITICAL_FACTOR = 6.0; - const TYPE_MAX = { feedback: 10, decision: 10, @@ -53,9 +51,8 @@ export function calculateInitialStrength(memory: LongTermMemoryEntry): number { const typeFactor = TYPE_FACTOR[memory.type] ?? 1.0; const sourceFactor = SOURCE_FACTOR[memory.source] ?? 1.0; const importanceFactor = USER_IMPORTANCE_FACTOR[memory.userImportance ?? "normal"] ?? 1.0; - const safetyFactor = memory.safetyCritical ? SAFETY_CRITICAL_FACTOR : 1.0; - return typeFactor * sourceFactor * importanceFactor * safetyFactor; + return typeFactor * sourceFactor * importanceFactor; } export function calculateEffectiveHalfLife(memory: LongTermMemoryEntry): number { @@ -660,11 +657,6 @@ function applyTypeMaxCaps(entries: LongTermMemoryEntry[]): LongTermMemoryEntry[] const typeCounts: Partial> = {}; for (const entry of entries) { - if (entry.safetyCritical) { - capped.push(entry); - continue; - } - const count = typeCounts[entry.type] ?? 0; const max = TYPE_MAX[entry.type] ?? Infinity; if (count >= max) continue; diff --git a/tests/memory-diag.test.ts b/tests/memory-diag.test.ts index 60a59c6..5b576e7 100644 --- a/tests/memory-diag.test.ts +++ b/tests/memory-diag.test.ts @@ -77,7 +77,7 @@ test("memory health reports stored vs rendered retention counts", async () => { } }); -test("memory health reports dormancy and retention monitoring alerts", async () => { +test("memory health reports dormancy and retention monitoring deprecations", async () => { const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-")); try { const lastActivityAt = new Date(Date.now() - 19 * 24 * 60 * 60 * 1000).toISOString(); @@ -96,7 +96,7 @@ test("memory health reports dormancy and retention monitoring alerts", async () 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, /safety_critical_count: 6 .*deprecated.* WARNING/); assert.match(stdout, /max_reinforced_count: 2 \(20\.0%, alert > 10%\) ALERT/); } finally { await rm(root, { recursive: true, force: true }); @@ -135,7 +135,7 @@ test("memory health reports missing dormancy and non-alert monitoring defaults", 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, /safety_critical_count: 0 \(deprecated field\)\n/); assert.match(stdout, /max_reinforced_count: 0 \(alert > 10% active\)/); } finally { await rm(root, { recursive: true, force: true }); diff --git a/tests/workspace-memory.test.ts b/tests/workspace-memory.test.ts index 38475cf..f10bed4 100644 --- a/tests/workspace-memory.test.ts +++ b/tests/workspace-memory.test.ts @@ -270,7 +270,7 @@ test("enforceLongTermLimits respects maxEntries limit", () => { assert.ok(kept.length <= 28, `Should respect maxEntries. Got: ${kept.length}`); }); -test("calculateInitialStrength multiplies type, source, importance, and safety factors", () => { +test("calculateInitialStrength multiplies type, source, and importance factors", () => { const memory: LongTermMemoryEntry = { ...entry("strength", "Never store raw credentials", "reference"), source: "explicit", @@ -278,7 +278,20 @@ test("calculateInitialStrength multiplies type, source, importance, and safety f safetyCritical: true, }; - assert.equal(calculateInitialStrength(memory), 18); + assert.equal(calculateInitialStrength(memory), 3); +}); + +test("calculateInitialStrength ignores deprecated safetyCritical field", () => { + const memory: LongTermMemoryEntry = { + ...entry("safety-deprecated", "Deprecated safety field should not affect strength", "decision"), + source: "explicit", + userImportance: "high", + safetyCritical: true, + }; + + const withoutSafety = { ...memory, safetyCritical: undefined }; + + assert.equal(calculateInitialStrength(memory), calculateInitialStrength(withoutSafety)); }); test("calculateEffectiveHalfLife clamps reinforcement count at configured maximum", () => { @@ -567,7 +580,73 @@ test("enforceLongTermLimits applies per-type caps after strength sorting", () => assert.equal(kept.filter(memory => memory.type === "feedback").length, 10); }); -test("enforceLongTermLimits exempts safety-critical entries from type caps", () => { +test("safetyCritical entries compete under TYPE_MAX caps like other entries", () => { + const safetyEntries: LongTermMemoryEntry[] = Array.from({ length: 6 }, (_, i) => ({ + ...entry(`safety-${i}`, `Safety memory ${i}`, "feedback"), + source: "explicit", + safetyCritical: true, + })); + + const ordinaryEntries: LongTermMemoryEntry[] = Array.from({ length: 10 }, (_, i) => ({ + ...entry(`ordinary-${i}`, `Ordinary memory ${i}`, "feedback"), + source: "explicit", + })); + + const all = [...safetyEntries, ...ordinaryEntries]; + const kept = enforceLongTermLimits(all); + + const feedbackCount = kept.filter(e => e.type === "feedback").length; + assert.equal(feedbackCount, 10); + // safetyCritical entries are no longer exempt from type caps + assert.ok(kept.filter(e => e.safetyCritical).length < 6); +}); + +test("workspace memory JSON with deprecated safetyCritical loads and competes normally", async () => { + const root = await mkdtemp(join(tmpdir(), "opencode-safety-compat-")); + try { + const key = await workspaceKey(root); + const path = await workspaceMemoryPath(root); + const now = new Date().toISOString(); + const safetyEntries: LongTermMemoryEntry[] = Array.from({ length: 6 }, (_, i) => ({ + ...entry(`safety-fixture-${i}`, `Safety fixture memory ${i}`, "feedback"), + source: "explicit", + userImportance: i === 0 ? "high" : "normal", + safetyCritical: true, + })); + const ordinaryEntries: LongTermMemoryEntry[] = Array.from({ length: 10 }, (_, i) => ({ + ...entry(`ordinary-fixture-${i}`, `Ordinary fixture memory ${i}`, "feedback"), + source: "explicit", + })); + const store: WorkspaceMemoryStore = { + version: 1, + workspace: { root, key }, + limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries }, + entries: [...safetyEntries, ...ordinaryEntries], + migrations: [], + updatedAt: now, + lastActivityAt: now, + }; + + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, JSON.stringify(store, null, 2), "utf8"); + + const loaded = await loadWorkspaceMemory(root); + const safetyEntry = loaded.entries.find(memory => memory.safetyCritical); + assert.ok(safetyEntry, "fixture should include deprecated safetyCritical entries"); + assert.equal( + calculateInitialStrength(safetyEntry), + calculateInitialStrength({ ...safetyEntry, safetyCritical: undefined }), + ); + + const kept = enforceLongTermLimits(loaded.entries); + assert.equal(kept.filter(memory => memory.type === "feedback").length, 10); + assert.ok(kept.filter(memory => memory.safetyCritical).length < safetyEntries.length); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("enforceLongTermLimits applies type caps to deprecated safetyCritical entries", () => { const ordinaryFeedback = Array.from({ length: 12 }, (_, i) => entry(`feedback_${i}`, `Unique safe ordinary feedback preference ${i}`, "feedback") ); @@ -578,12 +657,11 @@ test("enforceLongTermLimits exempts safety-critical entries from type caps", () const kept = enforceLongTermLimits([safetyCriticalFeedback, ...ordinaryFeedback]); - assert.equal(kept.length, 11); - assert.ok(kept.some(memory => memory.id === "safety-feedback")); - assert.equal(kept.filter(memory => memory.type === "feedback" && !memory.safetyCritical).length, 10); + assert.equal(kept.length, 10); + assert.equal(kept.filter(memory => memory.type === "feedback").length, 10); }); -test("mixed retention scenario applies caps, safety exemption, and reinforcement ordering", () => { +test("mixed retention scenario applies caps and reinforcement ordering", () => { const now = Date.now(); const oldAge = now - 120 * DAY_MS; const ordinaryFeedback = Array.from({ length: 17 }, (_, i) => @@ -625,18 +703,15 @@ test("mixed retention scenario applies caps, safety exemption, and reinforcement 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); + assert.ok(entries.filter(memory => memory.type === "feedback").length > 10); + assert.ok(entries.filter(memory => memory.type === "decision").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); + assert.ok(result.kept.filter(memory => memory.type === "feedback").length <= 10); + assert.ok(result.kept.filter(memory => memory.type === "decision").length <= 10); + assert.equal(result.kept.filter(memory => memory.type === "feedback").length, 10); 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");