fix: account for absorbed pending memories

- Add workspaceMemoryIdentityKey() to unify dedup/supersession identity semantics
- Add accountPendingPromotions() to distinguish promoted/absorbed/rejected
- Wire promotion accounting into promotePendingMemories()
- Add clearableKeys.size > 0 guard to prevent journal wipe
- Add regression tests for absorbed duplicate, cap-rejected, all-rejected edge cases

Wave 1 of memory quality optimization plan.
This commit is contained in:
Ralph Chang
2026-04-27 14:27:43 +08:00
parent 097235e43b
commit 24f807fed0
5 changed files with 455 additions and 12 deletions
+13 -5
View File
@@ -59,6 +59,7 @@ import {
latestCompactionSummary,
pendingTodos,
} from "./opencode.ts";
import { accountPendingPromotions } from "./promotion-accounting.ts";
/**
* Build the complete compaction prompt.
@@ -231,7 +232,10 @@ export const MemoryV2Plugin: Plugin = async (input) => {
];
if (pending.length === 0) return;
let beforeEntries: Awaited<ReturnType<typeof loadWorkspaceMemory>>["entries"] = [];
const updatedWorkspaceMemory = await updateWorkspaceMemory(directory, workspaceMemory => {
beforeEntries = [...workspaceMemory.entries];
const existingKeys = new Set(workspaceMemory.entries.map(memory => memoryKey(memory)));
for (const memory of pending) {
@@ -245,19 +249,23 @@ export const MemoryV2Plugin: Plugin = async (input) => {
return workspaceMemory;
});
// Only clear pending memories that survived workspace normalization/limits.
// updateWorkspaceMemory() may dedupe, supersede, redact, or cap entries.
const retainedKeys = new Set(updatedWorkspaceMemory.entries.map(memory => memoryKey(memory)));
const accounting = accountPendingPromotions({
pending,
before: beforeEntries,
after: updatedWorkspaceMemory.entries,
});
if (sessionID) {
await updateSessionState(directory, sessionID, state => {
state.pendingMemories = state.pendingMemories.filter(memory => !retainedKeys.has(memoryKey(memory)));
state.pendingMemories = state.pendingMemories.filter(memory => !accounting.clearableKeys.has(memoryKey(memory)));
return state;
});
clearFrozenWorkspaceMemoryCache(sessionID);
}
await clearPendingMemories(directory, retainedKeys);
if (accounting.clearableKeys.size > 0) {
await clearPendingMemories(directory, accounting.clearableKeys);
}
}
function bashExitCode(hookOutput: unknown): number | undefined {
+53
View File
@@ -0,0 +1,53 @@
import type { LongTermMemoryEntry } from "./types.ts";
import { memoryKey } from "./pending-journal.ts";
import { workspaceMemoryIdentityKey } from "./workspace-memory.ts";
export type PendingPromotionAccounting = {
promotedKeys: Set<string>;
absorbedKeys: Set<string>;
rejectedKeys: Set<string>;
clearableKeys: Set<string>;
};
export function accountPendingPromotions(input: {
pending: LongTermMemoryEntry[];
before: LongTermMemoryEntry[];
after: LongTermMemoryEntry[];
}): PendingPromotionAccounting {
const beforeExactKeys = new Set(input.before.map(entry => memoryKey(entry)));
const afterExactKeys = new Set(input.after.map(entry => memoryKey(entry)));
const afterIdentityKeys = new Set(input.after.map(entry => workspaceMemoryIdentityKey(entry)));
const promotedKeys = new Set<string>();
const absorbedKeys = new Set<string>();
const rejectedKeys = new Set<string>();
for (const memory of input.pending) {
const key = memoryKey(memory);
const identityKey = workspaceMemoryIdentityKey(memory);
if (beforeExactKeys.has(key)) {
absorbedKeys.add(key);
continue;
}
if (afterExactKeys.has(key)) {
promotedKeys.add(key);
continue;
}
if (afterIdentityKeys.has(identityKey)) {
absorbedKeys.add(key);
continue;
}
rejectedKeys.add(key);
}
return {
promotedKeys,
absorbedKeys,
rejectedKeys,
clearableKeys: new Set([...promotedKeys, ...absorbedKeys]),
};
}
+16 -7
View File
@@ -291,6 +291,18 @@ function feedbackTopicKey(text: string): string | null {
return null;
}
export function workspaceMemoryIdentityKey(entry: Pick<LongTermMemoryEntry, "type" | "text">): string {
if (entry.type === "project" || entry.type === "reference") {
return `${entry.type}:${extractEntityKey(entry.text) ?? canonicalMemoryText(entry.text)}`;
}
if (entry.type === "feedback") {
return `${entry.type}:${feedbackTopicKey(entry.text) ?? canonicalMemoryText(entry.text)}`;
}
return `decision:${decisionTopicKey(entry.text) ?? canonicalMemoryText(entry.text)}`;
}
/** Check if entry should be pruned by age (for compaction/manual entries only) */
function isPrunableByAge(entry: LongTermMemoryEntry, now: number): boolean {
// Never prune feedback or explicit entries
@@ -350,17 +362,15 @@ export function enforceLongTermLimits(entries: LongTermMemoryEntry[]): LongTermM
// Build entity key dedup for project/reference/feedback
const entityDeduped = new Map<string, LongTermMemoryEntry>();
for (const entry of projectRefEntries) {
const entityKey = entry.type === "project" || entry.type === "reference"
? extractEntityKey(entry.text)
: feedbackTopicKey(entry.text);
const key = entityKey ? `${entry.type}:${entityKey}` : `${entry.type}:${canonicalMemoryText(entry.text)}`;
const key = workspaceMemoryIdentityKey(entry);
const hasTopicIdentity = key !== `${entry.type}:${canonicalMemoryText(entry.text)}`;
const existing = entityDeduped.get(key);
if (!existing) {
entityDeduped.set(key, entry);
} else {
// Feedback topic conflicts use supersession mode (newer beats longer)
const mode = entry.type === "feedback" && entityKey ? "supersession" as const : "entity" as const;
const mode = entry.type === "feedback" && hasTopicIdentity ? "supersession" as const : "entity" as const;
if (chooseBetterMemory(entry, existing, mode) === entry) {
entityDeduped.set(key, entry);
}
@@ -371,8 +381,7 @@ export function enforceLongTermLimits(entries: LongTermMemoryEntry[]): LongTermM
const decisionEntries = phase1.filter(e => e.type === "decision");
const decisionDeduped = new Map<string, LongTermMemoryEntry>();
for (const entry of decisionEntries) {
const topic = decisionTopicKey(entry.text);
const key = topic ? `decision:${topic}` : `decision:${canonicalMemoryText(entry.text)}`;
const key = workspaceMemoryIdentityKey(entry);
const existing = decisionDeduped.get(key);
if (!existing) {