mirror of
https://github.com/sdwolf4103/opencode-working-memory.git
synced 2026-07-17 12:56:42 +02:00
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:
+13
-5
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user