Skip to content

Commit 3cc6dff

Browse files
committed
feat: add consolidation accounting for workspace memory promotion
P0 implementation with four waves: Wave 1: Dedup with accounting - Add dedupeLongTermEntriesWithAccounting() - Classify exact duplicate, identity duplicate, topic duplicate Wave 2: Normalization with accounting - Add normalizeWorkspaceMemoryWithAccounting() - Chain redaction → migration → enforceLongTermLimitsWithAccounting Wave 3: Promotion accounting integration - Update accountPendingPromotions() to use new accounting API - Add supersededKeys to classification - Distinguish promoted / absorbed / superseded / rejected Wave 4: Integration tests - End-to-end tests covering full pipeline Bug fixes: - Fix active vs superseded boundary (superseded entries no longer block promotion) - Remove unused rejected_duplicate_lower_quality type - Defer pending journal safety cap (TODO added) Tests: 135 passing (up from 115)
1 parent 1c748f3 commit 3cc6dff

9 files changed

Lines changed: 894 additions & 50 deletions

CHANGELOG.md

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
# Changelog
2+
3+
All notable changes to this project will be documented in this file.
4+
5+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7+
8+
## [1.3.0] - 2026-04-27
9+
10+
### Added
11+
12+
- P0 consolidation accounting for workspace memory promotion.
13+
- Accounting-aware deduplication (`dedupeLongTermEntriesWithAccounting`).
14+
- Accounting-aware normalization (`normalizeWorkspaceMemoryWithAccounting`).
15+
- Promotion classification: promoted, absorbed, superseded, rejected.
16+
- Clear terminal low-value compaction candidates after promotion review.
17+
18+
### Fixed
19+
20+
- Active vs superseded boundary when promoting pending memories (superseded entries no longer block promotion of same-key active memories).
21+
- Removed unused `rejected_duplicate_lower_quality` type.
22+
23+
### Changed
24+
25+
- Deferred pending journal safety cap implementation (see TODO in `src/pending-journal.ts`).
26+
- Clarified superseded accounting semantics: P0 emits events only, does not archive newly superseded records.
27+
28+
## [1.2.3] - 2026-04-26
29+
30+
### Fixed
31+
32+
- Account for absorbed pending memories in promotion accounting.
33+
- Clarify cache epoch semantics and add regression tests.
34+
35+
## [1.2.0] - 2026-04-25
36+
37+
### Added
38+
39+
- Memory quality evaluation fixtures (5 accepted, 7 rejected cases).
40+
- Compaction prompt examples for better memory extraction.
41+
- Promotion accounting for pending memories.
42+
43+
## [1.1.0] - 2026-04-24
44+
45+
### Added
46+
47+
- Workspace memory cache optimization with frozen snapshots.
48+
- Pending journal durability for same-session visibility.
49+
- Credential redaction always-on.
50+
51+
## [1.0.0] - 2026-04-23
52+
53+
### Added
54+
55+
- Initial release with three-layer memory architecture.
56+
- Hot session state, workspace memory, pending journal.
57+
- Memory extraction from user messages and compaction summaries.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "opencode-working-memory",
3-
"version": "1.2.3",
3+
"version": "1.3.0",
44
"description": "Three-layer memory architecture for OpenCode with workspace memory and hot session state",
55
"type": "module",
66
"main": "index.ts",

src/pending-journal.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,10 @@ function normalizeJournal(
4444
return workspaceKey(root).then(key => ({
4545
version: 1,
4646
workspace: { root, key },
47+
// TODO(memory-consolidation follow-up): add the deferred pending journal
48+
// safety cap (max entries and old compaction pruning). P0 currently relies
49+
// on promotion accounting to clear terminal compaction candidates without
50+
// changing journal capacity behavior.
4751
entries: dedupeByText(Array.isArray(store.entries) ? store.entries : []),
4852
updatedAt: new Date().toISOString(),
4953
}));

src/plugin.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import {
3434
import {
3535
loadWorkspaceMemory,
3636
updateWorkspaceMemory,
37+
updateWorkspaceMemoryWithAccounting,
3738
renderWorkspaceMemory,
3839
} from "./workspace-memory.ts";
3940
import {
@@ -250,9 +251,13 @@ export const MemoryV2Plugin: Plugin = async (input) => {
250251

251252
let beforeEntries: Awaited<ReturnType<typeof loadWorkspaceMemory>>["entries"] = [];
252253

253-
const updatedWorkspaceMemory = await updateWorkspaceMemory(directory, workspaceMemory => {
254+
const updateResult = await updateWorkspaceMemoryWithAccounting(directory, workspaceMemory => {
254255
beforeEntries = [...workspaceMemory.entries];
255-
const existingKeys = new Set(workspaceMemory.entries.map(memory => memoryKey(memory)));
256+
const existingKeys = new Set(
257+
workspaceMemory.entries
258+
.filter(memory => memory.status !== "superseded")
259+
.map(memory => memoryKey(memory)),
260+
);
256261

257262
for (const memory of pending) {
258263
const key = memoryKey(memory);
@@ -268,7 +273,8 @@ export const MemoryV2Plugin: Plugin = async (input) => {
268273
const accounting = accountPendingPromotions({
269274
pending,
270275
before: beforeEntries,
271-
after: updatedWorkspaceMemory.entries,
276+
after: updateResult.store.entries,
277+
events: updateResult.events,
272278
});
273279

274280
if (sessionID) {

src/promotion-accounting.ts

Lines changed: 46 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import type { LongTermMemoryEntry } from "./types.ts";
22
import { memoryKey } from "./pending-journal.ts";
3+
import type { MemoryConsolidationEvent } from "./workspace-memory.ts";
34
import { workspaceMemoryIdentityKey } from "./workspace-memory.ts";
45

56
export type PendingPromotionAccounting = {
67
promotedKeys: Set<string>;
78
absorbedKeys: Set<string>;
9+
supersededKeys: Set<string>;
810
rejectedKeys: Set<string>;
911
clearableKeys: Set<string>;
1012
};
@@ -13,13 +15,18 @@ export function accountPendingPromotions(input: {
1315
pending: LongTermMemoryEntry[];
1416
before: LongTermMemoryEntry[];
1517
after: LongTermMemoryEntry[];
18+
events?: MemoryConsolidationEvent[];
1619
}): PendingPromotionAccounting {
17-
const beforeExactKeys = new Set(input.before.map(entry => memoryKey(entry)));
18-
const afterExactKeys = new Set(input.after.map(entry => memoryKey(entry)));
19-
const afterIdentityKeys = new Set(input.after.map(entry => workspaceMemoryIdentityKey(entry)));
20+
const beforeActive = input.before.filter(entry => entry.status !== "superseded");
21+
const afterActive = input.after.filter(entry => entry.status !== "superseded");
22+
const beforeExactKeys = new Set(beforeActive.map(entry => memoryKey(entry)));
23+
const afterExactKeys = new Set(afterActive.map(entry => memoryKey(entry)));
24+
const afterIdentityKeys = new Set(afterActive.map(entry => workspaceMemoryIdentityKey(entry)));
25+
const terminalEventByKey = new Map((input.events ?? []).map(event => [event.memoryKey, event]));
2026

2127
const promotedKeys = new Set<string>();
2228
const absorbedKeys = new Set<string>();
29+
const supersededKeys = new Set<string>();
2330
const rejectedKeys = new Set<string>();
2431

2532
for (const memory of input.pending) {
@@ -36,6 +43,27 @@ export function accountPendingPromotions(input: {
3643
continue;
3744
}
3845

46+
const terminal = terminalEventByKey.get(key);
47+
if (terminal) {
48+
if (
49+
terminal.reason === "absorbed_exact" ||
50+
terminal.reason === "absorbed_identity"
51+
) {
52+
absorbedKeys.add(key);
53+
continue;
54+
}
55+
56+
if (terminal.reason === "superseded_existing") {
57+
supersededKeys.add(key);
58+
continue;
59+
}
60+
61+
if (terminal.reason === "rejected_capacity" || terminal.reason === "rejected_stale") {
62+
rejectedKeys.add(key);
63+
continue;
64+
}
65+
}
66+
3967
if (afterIdentityKeys.has(identityKey)) {
4068
absorbedKeys.add(key);
4169
continue;
@@ -47,7 +75,21 @@ export function accountPendingPromotions(input: {
4775
return {
4876
promotedKeys,
4977
absorbedKeys,
78+
supersededKeys,
5079
rejectedKeys,
51-
clearableKeys: new Set([...promotedKeys, ...absorbedKeys]),
80+
clearableKeys: new Set([
81+
...promotedKeys,
82+
...absorbedKeys,
83+
...supersededKeys,
84+
...input.pending
85+
.filter(memory => {
86+
const terminal = terminalEventByKey.get(memoryKey(memory));
87+
return memory.source === "compaction" && (
88+
terminal?.reason === "rejected_capacity" ||
89+
terminal?.reason === "rejected_stale"
90+
);
91+
})
92+
.map(memory => memoryKey(memory)),
93+
]),
5294
};
5395
}

0 commit comments

Comments
 (0)