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:
Ralph Chang
2026-04-30 17:23:01 +08:00
parent 830d97c6c6
commit 36f00147ca
8 changed files with 114 additions and 45 deletions
+3 -3
View File
@@ -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 });
+90 -15
View File
@@ -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");