mirror of
https://github.com/sdwolf4103/opencode-working-memory.git
synced 2026-06-01 22:11:08 +02:00
feat(deprecation): remove safetyCritical retention multiplier and type-cap bypass
- Remove SAFETY_CRITICAL_FACTOR = 6.0 from workspace-memory.ts - Remove safetyFactor from calculateInitialStrength() - all memories now fade according to the same rules - Remove safetyCritical bypass from applyTypeMaxCaps() - safetyCritical entries compete normally under TYPE_MAX caps - Preserve safetyCritical?: boolean in LongTermMemoryEntry type for backward compatibility (no producer sets it to true) - Update memory-diag to show deprecation warning instead of capacity alert - Update tests: add backward-compatibility fixture test, deprecation strength test, normal cap competition test - Update docs/architecture.md, RELEASE_NOTES.md, CHANGELOG.md, docs/configuration.md Phase 1.5 complete: safetyCritical is now a deprecated field with no active behavior. Safety rules belong in user-controlled agent.md files.
This commit is contained in:
+1
-1
@@ -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.
|
||||
|
||||
+3
-3
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+8
-10
@@ -279,15 +279,13 @@ function retentionCandidatesForDiag(store: WorkspaceMemoryStore): {
|
||||
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;
|
||||
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("");
|
||||
|
||||
|
||||
@@ -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<Record<LongTermMemoryEntry["type"], number>> = {};
|
||||
|
||||
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;
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user