Skip to content

Commit 09cc4a2

Browse files
committed
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.
1 parent c0ebd84 commit 09cc4a2

8 files changed

Lines changed: 114 additions & 45 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1111

1212
- Strength-based workspace memory retention using exponential decay instead of additive priority scoring.
1313
- Per-type rendered caps for workspace memory candidates: feedback 10, decision 10, project 8, and reference 6.
14-
- Safety-critical memory weighting and type-cap exemption so important entries survive type floods while still competing under the global rendered cap.
1514
- Dormant-workspace effective age: after 14 days without activity, additional dormant time counts at 0.25x for retention decay.
1615
- Reinforcement tracking for repeated memories, with same-session and one-hour guards to prevent accidental reinforcement spam.
1716
- 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
2120

2221
- Workspace memory rendering now ranks entries by retention strength, not the previous priority/penalty model.
2322
- Confidence is retained for compatibility but no longer affects retention scoring.
23+
- Deprecated `safetyCritical` is retained for JSON compatibility but no longer affects retention strength or type-cap behavior.
2424
- 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.
2525
- Existing duplicate promotion and dedupe paths now reinforce the surviving memory instead of only absorbing the duplicate.
2626
- Health output now separates stored active memories from rendered candidates to make cap behavior easier to understand.

RELEASE_NOTES.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
This release changes workspace memory retention from hard stale pruning and additive priority scoring to a strength-based decay model.
88

9-
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.
9+
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.
1010

1111
> **Memory should fade, so the agent can keep learning.**
1212
> 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
2727
### What Changed
2828

2929
- **Strength-based retention**: workspace memory now uses exponential decay: initial strength × age decay.
30-
- **Better initial strength**: type, source, user importance, and safety-critical status now determine how strong a memory starts.
30+
- **Better initial strength**: type, source, and user importance now determine how strong a memory starts.
3131
- **No confidence scoring**: confidence remains in stored data for compatibility, but it no longer affects retention ranking.
3232
- **Type caps**: rendered workspace memory now caps feedback, decisions, project facts, and references separately so one type cannot monopolize all 28 slots.
33-
- **Safety-critical protection**: safety-critical entries get stronger retention and are exempt from per-type caps, while still competing under the global rendered cap.
33+
- **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.
3434
- **Dormant-aware age**: after 14 inactive days, additional dormant workspace time counts at 0.25x so paused projects do not forget too aggressively.
3535
- **Reinforcement**: repeated matching memories reinforce the survivor and slow future decay, with same-session and one-hour guards to avoid accidental spam.
3636
- **No hard stale pruning**: old or stale-marked memories are no longer automatically dropped by age; they lose rendered space only through cap competition.

docs/architecture.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -109,14 +109,14 @@ Retention then decides which active memories are rendered into prompt context. I
109109
strength = initialStrength * 2 ** (-effectiveAgeDays / effectiveHalfLifeDays)
110110
```
111111

112-
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.
112+
Initial strength is based on memory type, source, and optional user importance. Confidence remains stored for compatibility but is not part of retention scoring.
113113

114114
Rendered candidates are selected in this order:
115115

116116
1. Exclude `status: "superseded"` entries.
117117
2. Compute current retention strength.
118118
3. Sort by strength descending.
119-
4. Apply per-type caps, with safety-critical entries exempt from type caps.
119+
4. Apply per-type caps.
120120
5. Keep the top 28 rendered entries under the workspace memory character budget.
121121

122122
Default type caps:
@@ -132,6 +132,10 @@ The type-cap total is 34, intentionally above the global 28-entry cap. These are
132132

133133
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.
134134

135+
### Safety-Critical Deprecation
136+
137+
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.
138+
135139
### System Prompt Injection
136140

137141
Workspace memory is injected at the top of every message:

docs/configuration.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ const WORKSPACE_DORMANT_AFTER_DAYS = 14;
3434
const DORMANT_DECAY_MULTIPLIER = 0.25;
3535
```
3636

37-
Initial strength uses type, source, user importance, and safety-critical factors. Confidence is stored for compatibility but is not used for retention scoring.
37+
Initial strength uses type, source, and user importance factors. Confidence is stored for compatibility but is not used for retention scoring.
3838

3939
Rendered type caps prevent one type from filling all workspace memory slots:
4040

@@ -45,7 +45,7 @@ Rendered type caps prevent one type from filling all workspace memory slots:
4545
| `project` | 8 |
4646
| `reference` | 6 |
4747

48-
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.
48+
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.
4949

5050
## Hot Session State Limits
5151

scripts/memory-diag.ts

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -279,15 +279,13 @@ function retentionCandidatesForDiag(store: WorkspaceMemoryStore): {
279279
const typeCounts: Partial<Record<LongTermType, number>> = {};
280280

281281
for (const item of sorted) {
282-
if (!isSafetyCriticalForDiag(item.entry)) {
283-
const count = typeCounts[item.entry.type] ?? 0;
284-
const max = TYPE_MAX_FOR_DIAG[item.entry.type] ?? Infinity;
285-
if (count >= max) {
286-
typeCapped.push(item);
287-
continue;
288-
}
289-
typeCounts[item.entry.type] = count + 1;
282+
const count = typeCounts[item.entry.type] ?? 0;
283+
const max = TYPE_MAX_FOR_DIAG[item.entry.type] ?? Infinity;
284+
if (count >= max) {
285+
typeCapped.push(item);
286+
continue;
290287
}
288+
typeCounts[item.entry.type] = count + 1;
291289

292290
if (rendered.length < LONG_TERM_LIMITS.maxEntries) {
293291
rendered.push(item);
@@ -472,11 +470,11 @@ async function printWorkspaceHealth(input: {
472470
const highImportanceRatio = active.length === 0 ? 0 : highImportanceCount / active.length;
473471
const maxReinforcedRatio = active.length === 0 ? 0 : maxReinforcedCount / active.length;
474472
const highImportanceAlert = highImportanceRatio > 0.3;
475-
const safetyCriticalAlert = safetyCriticalCount > 5;
473+
const safetyCriticalWarning = safetyCriticalCount > 0;
476474
const maxReinforcedAlert = maxReinforcedRatio > 0.1;
477475
console.log("Retention monitoring:");
478476
console.log(` high_importance_ratio: ${formatPercent(highImportanceRatio)} (alert > 30%)${highImportanceAlert ? " ALERT" : ""}`);
479-
console.log(` safety_critical_count: ${safetyCriticalCount} (alert > 5)${safetyCriticalAlert ? " ALERT" : ""}`);
477+
console.log(` safety_critical_count: ${safetyCriticalCount} (deprecated field)${safetyCriticalWarning ? " WARNING" : ""}`);
480478
console.log(` max_reinforced_count: ${maxReinforcedAlert ? `${maxReinforcedCount} (${formatPercent(maxReinforcedRatio)}, alert > 10%) ALERT` : `${maxReinforcedCount} (alert > 10% active)`}`);
481479
console.log("");
482480

src/workspace-memory.ts

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,6 @@ const USER_IMPORTANCE_FACTOR = {
4040
high: 1.5,
4141
} as const;
4242

43-
const SAFETY_CRITICAL_FACTOR = 6.0;
44-
4543
const TYPE_MAX = {
4644
feedback: 10,
4745
decision: 10,
@@ -53,9 +51,8 @@ export function calculateInitialStrength(memory: LongTermMemoryEntry): number {
5351
const typeFactor = TYPE_FACTOR[memory.type] ?? 1.0;
5452
const sourceFactor = SOURCE_FACTOR[memory.source] ?? 1.0;
5553
const importanceFactor = USER_IMPORTANCE_FACTOR[memory.userImportance ?? "normal"] ?? 1.0;
56-
const safetyFactor = memory.safetyCritical ? SAFETY_CRITICAL_FACTOR : 1.0;
5754

58-
return typeFactor * sourceFactor * importanceFactor * safetyFactor;
55+
return typeFactor * sourceFactor * importanceFactor;
5956
}
6057

6158
export function calculateEffectiveHalfLife(memory: LongTermMemoryEntry): number {
@@ -660,11 +657,6 @@ function applyTypeMaxCaps(entries: LongTermMemoryEntry[]): LongTermMemoryEntry[]
660657
const typeCounts: Partial<Record<LongTermMemoryEntry["type"], number>> = {};
661658

662659
for (const entry of entries) {
663-
if (entry.safetyCritical) {
664-
capped.push(entry);
665-
continue;
666-
}
667-
668660
const count = typeCounts[entry.type] ?? 0;
669661
const max = TYPE_MAX[entry.type] ?? Infinity;
670662
if (count >= max) continue;

tests/memory-diag.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ test("memory health reports stored vs rendered retention counts", async () => {
7777
}
7878
});
7979

80-
test("memory health reports dormancy and retention monitoring alerts", async () => {
80+
test("memory health reports dormancy and retention monitoring deprecations", async () => {
8181
const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-"));
8282
try {
8383
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 ()
9696
assert.match(stdout, /dormant discount active: yes/);
9797
assert.match(stdout, /dormant days past grace: 5\.0/);
9898
assert.match(stdout, /high_importance_ratio: 40\.0% .* ALERT/);
99-
assert.match(stdout, /safety_critical_count: 6 .* ALERT/);
99+
assert.match(stdout, /safety_critical_count: 6 .*deprecated.* WARNING/);
100100
assert.match(stdout, /max_reinforced_count: 2 \(20\.0%, alert > 10%\) ALERT/);
101101
} finally {
102102
await rm(root, { recursive: true, force: true });
@@ -135,7 +135,7 @@ test("memory health reports missing dormancy and non-alert monitoring defaults",
135135
assert.match(stdout, /wall days since activity: unknown/);
136136
assert.match(stdout, /dormant discount active: no/);
137137
assert.match(stdout, /high_importance_ratio: 0\.0% \(alert > 30%\)\n/);
138-
assert.match(stdout, /safety_critical_count: 0 \(alert > 5\)\n/);
138+
assert.match(stdout, /safety_critical_count: 0 \(deprecated field\)\n/);
139139
assert.match(stdout, /max_reinforced_count: 0 \(alert > 10% active\)/);
140140
} finally {
141141
await rm(root, { recursive: true, force: true });

tests/workspace-memory.test.ts

Lines changed: 90 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -270,15 +270,28 @@ test("enforceLongTermLimits respects maxEntries limit", () => {
270270
assert.ok(kept.length <= 28, `Should respect maxEntries. Got: ${kept.length}`);
271271
});
272272

273-
test("calculateInitialStrength multiplies type, source, importance, and safety factors", () => {
273+
test("calculateInitialStrength multiplies type, source, and importance factors", () => {
274274
const memory: LongTermMemoryEntry = {
275275
...entry("strength", "Never store raw credentials", "reference"),
276276
source: "explicit",
277277
userImportance: "high",
278278
safetyCritical: true,
279279
};
280280

281-
assert.equal(calculateInitialStrength(memory), 18);
281+
assert.equal(calculateInitialStrength(memory), 3);
282+
});
283+
284+
test("calculateInitialStrength ignores deprecated safetyCritical field", () => {
285+
const memory: LongTermMemoryEntry = {
286+
...entry("safety-deprecated", "Deprecated safety field should not affect strength", "decision"),
287+
source: "explicit",
288+
userImportance: "high",
289+
safetyCritical: true,
290+
};
291+
292+
const withoutSafety = { ...memory, safetyCritical: undefined };
293+
294+
assert.equal(calculateInitialStrength(memory), calculateInitialStrength(withoutSafety));
282295
});
283296

284297
test("calculateEffectiveHalfLife clamps reinforcement count at configured maximum", () => {
@@ -567,7 +580,73 @@ test("enforceLongTermLimits applies per-type caps after strength sorting", () =>
567580
assert.equal(kept.filter(memory => memory.type === "feedback").length, 10);
568581
});
569582

570-
test("enforceLongTermLimits exempts safety-critical entries from type caps", () => {
583+
test("safetyCritical entries compete under TYPE_MAX caps like other entries", () => {
584+
const safetyEntries: LongTermMemoryEntry[] = Array.from({ length: 6 }, (_, i) => ({
585+
...entry(`safety-${i}`, `Safety memory ${i}`, "feedback"),
586+
source: "explicit",
587+
safetyCritical: true,
588+
}));
589+
590+
const ordinaryEntries: LongTermMemoryEntry[] = Array.from({ length: 10 }, (_, i) => ({
591+
...entry(`ordinary-${i}`, `Ordinary memory ${i}`, "feedback"),
592+
source: "explicit",
593+
}));
594+
595+
const all = [...safetyEntries, ...ordinaryEntries];
596+
const kept = enforceLongTermLimits(all);
597+
598+
const feedbackCount = kept.filter(e => e.type === "feedback").length;
599+
assert.equal(feedbackCount, 10);
600+
// safetyCritical entries are no longer exempt from type caps
601+
assert.ok(kept.filter(e => e.safetyCritical).length < 6);
602+
});
603+
604+
test("workspace memory JSON with deprecated safetyCritical loads and competes normally", async () => {
605+
const root = await mkdtemp(join(tmpdir(), "opencode-safety-compat-"));
606+
try {
607+
const key = await workspaceKey(root);
608+
const path = await workspaceMemoryPath(root);
609+
const now = new Date().toISOString();
610+
const safetyEntries: LongTermMemoryEntry[] = Array.from({ length: 6 }, (_, i) => ({
611+
...entry(`safety-fixture-${i}`, `Safety fixture memory ${i}`, "feedback"),
612+
source: "explicit",
613+
userImportance: i === 0 ? "high" : "normal",
614+
safetyCritical: true,
615+
}));
616+
const ordinaryEntries: LongTermMemoryEntry[] = Array.from({ length: 10 }, (_, i) => ({
617+
...entry(`ordinary-fixture-${i}`, `Ordinary fixture memory ${i}`, "feedback"),
618+
source: "explicit",
619+
}));
620+
const store: WorkspaceMemoryStore = {
621+
version: 1,
622+
workspace: { root, key },
623+
limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries },
624+
entries: [...safetyEntries, ...ordinaryEntries],
625+
migrations: [],
626+
updatedAt: now,
627+
lastActivityAt: now,
628+
};
629+
630+
await mkdir(dirname(path), { recursive: true });
631+
await writeFile(path, JSON.stringify(store, null, 2), "utf8");
632+
633+
const loaded = await loadWorkspaceMemory(root);
634+
const safetyEntry = loaded.entries.find(memory => memory.safetyCritical);
635+
assert.ok(safetyEntry, "fixture should include deprecated safetyCritical entries");
636+
assert.equal(
637+
calculateInitialStrength(safetyEntry),
638+
calculateInitialStrength({ ...safetyEntry, safetyCritical: undefined }),
639+
);
640+
641+
const kept = enforceLongTermLimits(loaded.entries);
642+
assert.equal(kept.filter(memory => memory.type === "feedback").length, 10);
643+
assert.ok(kept.filter(memory => memory.safetyCritical).length < safetyEntries.length);
644+
} finally {
645+
await rm(root, { recursive: true, force: true });
646+
}
647+
});
648+
649+
test("enforceLongTermLimits applies type caps to deprecated safetyCritical entries", () => {
571650
const ordinaryFeedback = Array.from({ length: 12 }, (_, i) =>
572651
entry(`feedback_${i}`, `Unique safe ordinary feedback preference ${i}`, "feedback")
573652
);
@@ -578,12 +657,11 @@ test("enforceLongTermLimits exempts safety-critical entries from type caps", ()
578657

579658
const kept = enforceLongTermLimits([safetyCriticalFeedback, ...ordinaryFeedback]);
580659

581-
assert.equal(kept.length, 11);
582-
assert.ok(kept.some(memory => memory.id === "safety-feedback"));
583-
assert.equal(kept.filter(memory => memory.type === "feedback" && !memory.safetyCritical).length, 10);
660+
assert.equal(kept.length, 10);
661+
assert.equal(kept.filter(memory => memory.type === "feedback").length, 10);
584662
});
585663

586-
test("mixed retention scenario applies caps, safety exemption, and reinforcement ordering", () => {
664+
test("mixed retention scenario applies caps and reinforcement ordering", () => {
587665
const now = Date.now();
588666
const oldAge = now - 120 * DAY_MS;
589667
const ordinaryFeedback = Array.from({ length: 17 }, (_, i) =>
@@ -625,18 +703,15 @@ test("mixed retention scenario applies caps, safety exemption, and reinforcement
625703
lastActivityAt: new Date(now).toISOString(),
626704
};
627705

628-
assert.ok(entries.filter(memory => memory.type === "feedback" && !memory.safetyCritical).length > 10);
629-
assert.ok(entries.filter(memory => memory.type === "decision" && !memory.safetyCritical).length > 10);
706+
assert.ok(entries.filter(memory => memory.type === "feedback").length > 10);
707+
assert.ok(entries.filter(memory => memory.type === "decision").length > 10);
630708

631709
const result = enforceLongTermLimitsWithAccounting(entries, store);
632710

633711
assert.ok(result.kept.length <= 28);
634-
assert.ok(result.kept.filter(memory => memory.type === "feedback" && !memory.safetyCritical).length <= 10);
635-
assert.ok(result.kept.filter(memory => memory.type === "decision" && !memory.safetyCritical).length <= 10);
636-
assert.ok(result.kept.some(memory => memory.safetyCritical));
637-
assert.equal(result.kept.filter(memory => memory.type === "feedback" && !memory.safetyCritical).length, 10);
638-
assert.equal(result.kept.filter(memory => memory.type === "feedback" && memory.safetyCritical).length, 1);
639-
assert.equal(result.kept.filter(memory => memory.type === "feedback").length, 11);
712+
assert.ok(result.kept.filter(memory => memory.type === "feedback").length <= 10);
713+
assert.ok(result.kept.filter(memory => memory.type === "decision").length <= 10);
714+
assert.equal(result.kept.filter(memory => memory.type === "feedback").length, 10);
640715
const reinforcedIndex = result.kept.findIndex(memory => memory.id === "old-reinforced");
641716
const unreinforcedIndex = result.kept.findIndex(memory => memory.id === "old-unreinforced");
642717
assert.ok(reinforcedIndex >= 0, "old reinforced reference should be kept");

0 commit comments

Comments
 (0)