mirror of
https://github.com/sdwolf4103/opencode-working-memory.git
synced 2026-07-17 12:56:42 +02:00
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)
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [1.3.0] - 2026-04-27
|
||||
|
||||
### Added
|
||||
|
||||
- P0 consolidation accounting for workspace memory promotion.
|
||||
- Accounting-aware deduplication (`dedupeLongTermEntriesWithAccounting`).
|
||||
- Accounting-aware normalization (`normalizeWorkspaceMemoryWithAccounting`).
|
||||
- Promotion classification: promoted, absorbed, superseded, rejected.
|
||||
- Clear terminal low-value compaction candidates after promotion review.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Active vs superseded boundary when promoting pending memories (superseded entries no longer block promotion of same-key active memories).
|
||||
- Removed unused `rejected_duplicate_lower_quality` type.
|
||||
|
||||
### Changed
|
||||
|
||||
- Deferred pending journal safety cap implementation (see TODO in `src/pending-journal.ts`).
|
||||
- Clarified superseded accounting semantics: P0 emits events only, does not archive newly superseded records.
|
||||
|
||||
## [1.2.3] - 2026-04-26
|
||||
|
||||
### Fixed
|
||||
|
||||
- Account for absorbed pending memories in promotion accounting.
|
||||
- Clarify cache epoch semantics and add regression tests.
|
||||
|
||||
## [1.2.0] - 2026-04-25
|
||||
|
||||
### Added
|
||||
|
||||
- Memory quality evaluation fixtures (5 accepted, 7 rejected cases).
|
||||
- Compaction prompt examples for better memory extraction.
|
||||
- Promotion accounting for pending memories.
|
||||
|
||||
## [1.1.0] - 2026-04-24
|
||||
|
||||
### Added
|
||||
|
||||
- Workspace memory cache optimization with frozen snapshots.
|
||||
- Pending journal durability for same-session visibility.
|
||||
- Credential redaction always-on.
|
||||
|
||||
## [1.0.0] - 2026-04-23
|
||||
|
||||
### Added
|
||||
|
||||
- Initial release with three-layer memory architecture.
|
||||
- Hot session state, workspace memory, pending journal.
|
||||
- Memory extraction from user messages and compaction summaries.
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "opencode-working-memory",
|
||||
"version": "1.2.3",
|
||||
"version": "1.3.0",
|
||||
"description": "Three-layer memory architecture for OpenCode with workspace memory and hot session state",
|
||||
"type": "module",
|
||||
"main": "index.ts",
|
||||
|
||||
@@ -44,6 +44,10 @@ function normalizeJournal(
|
||||
return workspaceKey(root).then(key => ({
|
||||
version: 1,
|
||||
workspace: { root, key },
|
||||
// TODO(memory-consolidation follow-up): add the deferred pending journal
|
||||
// safety cap (max entries and old compaction pruning). P0 currently relies
|
||||
// on promotion accounting to clear terminal compaction candidates without
|
||||
// changing journal capacity behavior.
|
||||
entries: dedupeByText(Array.isArray(store.entries) ? store.entries : []),
|
||||
updatedAt: new Date().toISOString(),
|
||||
}));
|
||||
|
||||
+9
-3
@@ -34,6 +34,7 @@ import {
|
||||
import {
|
||||
loadWorkspaceMemory,
|
||||
updateWorkspaceMemory,
|
||||
updateWorkspaceMemoryWithAccounting,
|
||||
renderWorkspaceMemory,
|
||||
} from "./workspace-memory.ts";
|
||||
import {
|
||||
@@ -250,9 +251,13 @@ export const MemoryV2Plugin: Plugin = async (input) => {
|
||||
|
||||
let beforeEntries: Awaited<ReturnType<typeof loadWorkspaceMemory>>["entries"] = [];
|
||||
|
||||
const updatedWorkspaceMemory = await updateWorkspaceMemory(directory, workspaceMemory => {
|
||||
const updateResult = await updateWorkspaceMemoryWithAccounting(directory, workspaceMemory => {
|
||||
beforeEntries = [...workspaceMemory.entries];
|
||||
const existingKeys = new Set(workspaceMemory.entries.map(memory => memoryKey(memory)));
|
||||
const existingKeys = new Set(
|
||||
workspaceMemory.entries
|
||||
.filter(memory => memory.status !== "superseded")
|
||||
.map(memory => memoryKey(memory)),
|
||||
);
|
||||
|
||||
for (const memory of pending) {
|
||||
const key = memoryKey(memory);
|
||||
@@ -268,7 +273,8 @@ export const MemoryV2Plugin: Plugin = async (input) => {
|
||||
const accounting = accountPendingPromotions({
|
||||
pending,
|
||||
before: beforeEntries,
|
||||
after: updatedWorkspaceMemory.entries,
|
||||
after: updateResult.store.entries,
|
||||
events: updateResult.events,
|
||||
});
|
||||
|
||||
if (sessionID) {
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import type { LongTermMemoryEntry } from "./types.ts";
|
||||
import { memoryKey } from "./pending-journal.ts";
|
||||
import type { MemoryConsolidationEvent } from "./workspace-memory.ts";
|
||||
import { workspaceMemoryIdentityKey } from "./workspace-memory.ts";
|
||||
|
||||
export type PendingPromotionAccounting = {
|
||||
promotedKeys: Set<string>;
|
||||
absorbedKeys: Set<string>;
|
||||
supersededKeys: Set<string>;
|
||||
rejectedKeys: Set<string>;
|
||||
clearableKeys: Set<string>;
|
||||
};
|
||||
@@ -13,13 +15,18 @@ export function accountPendingPromotions(input: {
|
||||
pending: LongTermMemoryEntry[];
|
||||
before: LongTermMemoryEntry[];
|
||||
after: LongTermMemoryEntry[];
|
||||
events?: MemoryConsolidationEvent[];
|
||||
}): 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 beforeActive = input.before.filter(entry => entry.status !== "superseded");
|
||||
const afterActive = input.after.filter(entry => entry.status !== "superseded");
|
||||
const beforeExactKeys = new Set(beforeActive.map(entry => memoryKey(entry)));
|
||||
const afterExactKeys = new Set(afterActive.map(entry => memoryKey(entry)));
|
||||
const afterIdentityKeys = new Set(afterActive.map(entry => workspaceMemoryIdentityKey(entry)));
|
||||
const terminalEventByKey = new Map((input.events ?? []).map(event => [event.memoryKey, event]));
|
||||
|
||||
const promotedKeys = new Set<string>();
|
||||
const absorbedKeys = new Set<string>();
|
||||
const supersededKeys = new Set<string>();
|
||||
const rejectedKeys = new Set<string>();
|
||||
|
||||
for (const memory of input.pending) {
|
||||
@@ -36,6 +43,27 @@ export function accountPendingPromotions(input: {
|
||||
continue;
|
||||
}
|
||||
|
||||
const terminal = terminalEventByKey.get(key);
|
||||
if (terminal) {
|
||||
if (
|
||||
terminal.reason === "absorbed_exact" ||
|
||||
terminal.reason === "absorbed_identity"
|
||||
) {
|
||||
absorbedKeys.add(key);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (terminal.reason === "superseded_existing") {
|
||||
supersededKeys.add(key);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (terminal.reason === "rejected_capacity" || terminal.reason === "rejected_stale") {
|
||||
rejectedKeys.add(key);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (afterIdentityKeys.has(identityKey)) {
|
||||
absorbedKeys.add(key);
|
||||
continue;
|
||||
@@ -47,7 +75,21 @@ export function accountPendingPromotions(input: {
|
||||
return {
|
||||
promotedKeys,
|
||||
absorbedKeys,
|
||||
supersededKeys,
|
||||
rejectedKeys,
|
||||
clearableKeys: new Set([...promotedKeys, ...absorbedKeys]),
|
||||
clearableKeys: new Set([
|
||||
...promotedKeys,
|
||||
...absorbedKeys,
|
||||
...supersededKeys,
|
||||
...input.pending
|
||||
.filter(memory => {
|
||||
const terminal = terminalEventByKey.get(memoryKey(memory));
|
||||
return memory.source === "compaction" && (
|
||||
terminal?.reason === "rejected_capacity" ||
|
||||
terminal?.reason === "rejected_stale"
|
||||
);
|
||||
})
|
||||
.map(memory => memoryKey(memory)),
|
||||
]),
|
||||
};
|
||||
}
|
||||
|
||||
+180
-33
@@ -16,6 +16,35 @@ const PIN_PREFIX = String.raw`(\bPIN\b(?:\s*(?:是|=|:|:)\s*|\s+(?![是=::])
|
||||
const PASSWORD_PREFIX = String.raw`((?:${PASSWORD_LABELS.source})(?:\s*(?:是|=|:|:)\s*|\s+(?![是=::])))`;
|
||||
const USERNAME_PREFIX = String.raw`((?:${USERNAME_LABELS.source})(?:\s*(?:是|=|:|:)\s*|\s+(?![是=::])))`;
|
||||
|
||||
export type MemoryConsolidationReason =
|
||||
| "promoted"
|
||||
| "absorbed_exact"
|
||||
| "absorbed_identity"
|
||||
| "superseded_existing"
|
||||
| "rejected_capacity"
|
||||
| "rejected_stale";
|
||||
|
||||
export type MemoryConsolidationEvent = {
|
||||
memoryKey: string;
|
||||
identityKey: string;
|
||||
memory: LongTermMemoryEntry;
|
||||
reason: MemoryConsolidationReason;
|
||||
retainedId?: string;
|
||||
supersededId?: string;
|
||||
};
|
||||
|
||||
export type LongTermLimitResult = {
|
||||
kept: LongTermMemoryEntry[];
|
||||
dropped: MemoryConsolidationEvent[];
|
||||
absorbed: MemoryConsolidationEvent[];
|
||||
superseded: MemoryConsolidationEvent[];
|
||||
};
|
||||
|
||||
export type WorkspaceMemoryNormalizationResult = LongTermLimitResult & {
|
||||
store: WorkspaceMemoryStore;
|
||||
events: MemoryConsolidationEvent[];
|
||||
};
|
||||
|
||||
export async function emptyWorkspaceMemory(root: string): Promise<WorkspaceMemoryStore> {
|
||||
return {
|
||||
version: 1,
|
||||
@@ -83,18 +112,43 @@ export async function updateWorkspaceMemory(
|
||||
root: string,
|
||||
updater: (store: WorkspaceMemoryStore) => WorkspaceMemoryStore | Promise<WorkspaceMemoryStore>,
|
||||
): Promise<WorkspaceMemoryStore> {
|
||||
const path = await workspaceMemoryPath(root);
|
||||
const fallback = await emptyWorkspaceMemory(root);
|
||||
return updateJSON(path, () => fallback, async current => {
|
||||
const normalized = await normalizeWorkspaceMemory(root, current);
|
||||
return normalizeWorkspaceMemory(root, await updater(normalized));
|
||||
});
|
||||
return (await updateWorkspaceMemoryWithAccounting(root, updater)).store;
|
||||
}
|
||||
|
||||
async function normalizeWorkspaceMemory(
|
||||
export async function updateWorkspaceMemoryWithAccounting(
|
||||
root: string,
|
||||
updater: (store: WorkspaceMemoryStore) => WorkspaceMemoryStore | Promise<WorkspaceMemoryStore>,
|
||||
): Promise<WorkspaceMemoryNormalizationResult> {
|
||||
const path = await workspaceMemoryPath(root);
|
||||
const fallback = await emptyWorkspaceMemory(root);
|
||||
let finalResult: WorkspaceMemoryNormalizationResult | undefined;
|
||||
const store = await updateJSON(path, () => fallback, async current => {
|
||||
const normalized = await normalizeWorkspaceMemory(root, current);
|
||||
finalResult = await normalizeWorkspaceMemoryWithAccounting(root, await updater(normalized));
|
||||
return finalResult.store;
|
||||
});
|
||||
|
||||
return finalResult ?? {
|
||||
store,
|
||||
kept: store.entries.filter(entry => entry.status !== "superseded"),
|
||||
dropped: [],
|
||||
absorbed: [],
|
||||
superseded: [],
|
||||
events: [],
|
||||
};
|
||||
}
|
||||
|
||||
export async function normalizeWorkspaceMemory(
|
||||
root: string,
|
||||
store: WorkspaceMemoryStore,
|
||||
): Promise<WorkspaceMemoryStore> {
|
||||
return (await normalizeWorkspaceMemoryWithAccounting(root, store)).store;
|
||||
}
|
||||
|
||||
export async function normalizeWorkspaceMemoryWithAccounting(
|
||||
root: string,
|
||||
store: WorkspaceMemoryStore,
|
||||
): Promise<WorkspaceMemoryNormalizationResult> {
|
||||
const nowIso = new Date().toISOString();
|
||||
|
||||
let result: WorkspaceMemoryStore = {
|
||||
@@ -129,15 +183,27 @@ async function normalizeWorkspaceMemory(
|
||||
// One-time migration for legacy snapshot violations
|
||||
result = runMigrationP0Cleanup(result, nowIso);
|
||||
|
||||
// Enforce limits on active entries while preserving superseded entries in storage
|
||||
// P0 accounting only considers active entries. Entries that were already
|
||||
// superseded before this normalization are preserved in storage; entries that
|
||||
// lose during this enforcement are reported via accounting events but are not
|
||||
// archived as superseded records in this wave.
|
||||
const activeEntries = result.entries.filter(entry => entry.status !== "superseded");
|
||||
const supersededEntries = result.entries.filter(entry => entry.status === "superseded");
|
||||
const processedActive = enforceLongTermLimits(activeEntries);
|
||||
const accounting = enforceLongTermLimitsWithAccounting(activeEntries);
|
||||
|
||||
const normalizedStore = {
|
||||
...result,
|
||||
entries: [...accounting.kept, ...supersededEntries],
|
||||
updatedAt: nowIso,
|
||||
};
|
||||
|
||||
return {
|
||||
...result,
|
||||
entries: [...processedActive, ...supersededEntries],
|
||||
updatedAt: nowIso,
|
||||
store: normalizedStore,
|
||||
kept: accounting.kept,
|
||||
dropped: accounting.dropped,
|
||||
absorbed: accounting.absorbed,
|
||||
superseded: accounting.superseded,
|
||||
events: [...accounting.dropped, ...accounting.absorbed, ...accounting.superseded],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -235,6 +301,10 @@ function canonicalMemoryText(text: string): string {
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function workspaceMemoryExactKey(entry: Pick<LongTermMemoryEntry, "type" | "text">): string {
|
||||
return `${entry.type}:${canonicalMemoryText(entry.text)}`;
|
||||
}
|
||||
|
||||
/** Extract entity/destination keys for project and reference dedup */
|
||||
function extractEntityKey(text: string): string | null {
|
||||
const normalized = canonicalMemoryText(text);
|
||||
@@ -303,6 +373,21 @@ export function workspaceMemoryIdentityKey(entry: Pick<LongTermMemoryEntry, "typ
|
||||
return `decision:${decisionTopicKey(entry.text) ?? canonicalMemoryText(entry.text)}`;
|
||||
}
|
||||
|
||||
function consolidationEvent(
|
||||
memory: LongTermMemoryEntry,
|
||||
reason: MemoryConsolidationReason,
|
||||
retained?: LongTermMemoryEntry,
|
||||
): MemoryConsolidationEvent {
|
||||
return {
|
||||
memoryKey: workspaceMemoryExactKey(memory),
|
||||
identityKey: workspaceMemoryIdentityKey(memory),
|
||||
memory,
|
||||
reason,
|
||||
retainedId: retained?.id,
|
||||
supersededId: reason === "superseded_existing" ? memory.id : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/** 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
|
||||
@@ -348,22 +433,52 @@ function chooseBetterMemory(
|
||||
}
|
||||
|
||||
export function enforceLongTermLimits(entries: LongTermMemoryEntry[]): LongTermMemoryEntry[] {
|
||||
return enforceLongTermLimitsWithAccounting(entries).kept;
|
||||
}
|
||||
|
||||
export function enforceLongTermLimitsWithAccounting(entries: LongTermMemoryEntry[]): LongTermLimitResult {
|
||||
const now = Date.now();
|
||||
const staleDropped: MemoryConsolidationEvent[] = [];
|
||||
|
||||
// Phase 1: filter active, prune by age
|
||||
const phase1 = entries
|
||||
.filter(entry => entry.status !== "superseded")
|
||||
.filter(entry => !isPrunableByAge(entry, now))
|
||||
.map(entry => ({ ...entry, text: entry.text.slice(0, LONG_TERM_LIMITS.maxEntryTextChars) }));
|
||||
const phase1: LongTermMemoryEntry[] = [];
|
||||
for (const entry of entries) {
|
||||
if (entry.status === "superseded") continue;
|
||||
if (isPrunableByAge(entry, now)) {
|
||||
staleDropped.push(consolidationEvent(entry, "rejected_stale"));
|
||||
continue;
|
||||
}
|
||||
phase1.push({ ...entry, text: entry.text.slice(0, LONG_TERM_LIMITS.maxEntryTextChars) });
|
||||
}
|
||||
|
||||
const dedupeResult = dedupeLongTermEntriesWithAccounting(phase1);
|
||||
const sorted = [...dedupeResult.kept].sort(compareLongTermMemoryForRetention);
|
||||
const kept = sorted.slice(0, LONG_TERM_LIMITS.maxEntries);
|
||||
const keptIds = new Set(kept.map(entry => entry.id));
|
||||
const capacityDropped = sorted
|
||||
.filter(entry => !keptIds.has(entry.id))
|
||||
.map(entry => consolidationEvent(entry, "rejected_capacity"));
|
||||
|
||||
return {
|
||||
kept,
|
||||
dropped: [...staleDropped, ...dedupeResult.dropped, ...capacityDropped],
|
||||
absorbed: dedupeResult.absorbed,
|
||||
superseded: dedupeResult.superseded,
|
||||
};
|
||||
}
|
||||
|
||||
export function dedupeLongTermEntriesWithAccounting(entries: LongTermMemoryEntry[]): LongTermLimitResult {
|
||||
const absorbed: MemoryConsolidationEvent[] = [];
|
||||
const superseded: MemoryConsolidationEvent[] = [];
|
||||
|
||||
// For project/reference/feedback: detect entity keys FIRST, then dedupe by entity OR canonical
|
||||
const projectRefEntries = phase1.filter(e => e.type === "project" || e.type === "reference" || e.type === "feedback");
|
||||
const projectRefEntries = entries.filter(e => e.type === "project" || e.type === "reference" || e.type === "feedback");
|
||||
|
||||
// Build entity key dedup for project/reference/feedback
|
||||
const entityDeduped = new Map<string, LongTermMemoryEntry>();
|
||||
for (const entry of projectRefEntries) {
|
||||
const key = workspaceMemoryIdentityKey(entry);
|
||||
const hasTopicIdentity = key !== `${entry.type}:${canonicalMemoryText(entry.text)}`;
|
||||
const hasTopicIdentity = key !== workspaceMemoryExactKey(entry);
|
||||
|
||||
const existing = entityDeduped.get(key);
|
||||
if (!existing) {
|
||||
@@ -371,14 +486,28 @@ export function enforceLongTermLimits(entries: LongTermMemoryEntry[]): LongTermM
|
||||
} else {
|
||||
// Feedback topic conflicts use supersession mode (newer beats longer)
|
||||
const mode = entry.type === "feedback" && hasTopicIdentity ? "supersession" as const : "entity" as const;
|
||||
if (chooseBetterMemory(entry, existing, mode) === entry) {
|
||||
const retained = chooseBetterMemory(entry, existing, mode);
|
||||
const dropped = retained === entry ? existing : entry;
|
||||
const reason = workspaceMemoryExactKey(entry) === workspaceMemoryExactKey(existing)
|
||||
? "absorbed_exact" as const
|
||||
: mode === "supersession"
|
||||
? "superseded_existing" as const
|
||||
: "absorbed_identity" as const;
|
||||
|
||||
if (reason === "superseded_existing") {
|
||||
superseded.push(consolidationEvent(dropped, reason, retained));
|
||||
} else {
|
||||
absorbed.push(consolidationEvent(dropped, reason, retained));
|
||||
}
|
||||
|
||||
if (retained === entry) {
|
||||
entityDeduped.set(key, entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// For decisions: detect topic keys for supersession, or use canonical
|
||||
const decisionEntries = phase1.filter(e => e.type === "decision");
|
||||
const decisionEntries = entries.filter(e => e.type === "decision");
|
||||
const decisionDeduped = new Map<string, LongTermMemoryEntry>();
|
||||
for (const entry of decisionEntries) {
|
||||
const key = workspaceMemoryIdentityKey(entry);
|
||||
@@ -386,8 +515,22 @@ export function enforceLongTermLimits(entries: LongTermMemoryEntry[]): LongTermM
|
||||
const existing = decisionDeduped.get(key);
|
||||
if (!existing) {
|
||||
decisionDeduped.set(key, entry);
|
||||
} else if (chooseBetterMemory(entry, existing, "supersession") === entry) {
|
||||
decisionDeduped.set(key, entry);
|
||||
} else {
|
||||
const retained = chooseBetterMemory(entry, existing, "supersession");
|
||||
const dropped = retained === entry ? existing : entry;
|
||||
const reason = workspaceMemoryExactKey(entry) === workspaceMemoryExactKey(existing)
|
||||
? "absorbed_exact" as const
|
||||
: "superseded_existing" as const;
|
||||
|
||||
if (reason === "superseded_existing") {
|
||||
superseded.push(consolidationEvent(dropped, reason, retained));
|
||||
} else {
|
||||
absorbed.push(consolidationEvent(dropped, reason, retained));
|
||||
}
|
||||
|
||||
if (retained === entry) {
|
||||
decisionDeduped.set(key, entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -397,17 +540,21 @@ export function enforceLongTermLimits(entries: LongTermMemoryEntry[]): LongTermM
|
||||
phaseFinal.set(entry.id, entry);
|
||||
}
|
||||
|
||||
// Phase 6: sort and trim
|
||||
return [...phaseFinal.values()]
|
||||
.sort((a, b) => {
|
||||
const pA = priorityWithFreshness(a);
|
||||
const pB = priorityWithFreshness(b);
|
||||
if (pB !== pA) return pB - pA;
|
||||
const sourceDiff = sourcePriority(b.source) - sourcePriority(a.source);
|
||||
if (sourceDiff !== 0) return sourceDiff;
|
||||
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
|
||||
})
|
||||
.slice(0, LONG_TERM_LIMITS.maxEntries);
|
||||
return {
|
||||
kept: [...phaseFinal.values()],
|
||||
dropped: [],
|
||||
absorbed,
|
||||
superseded,
|
||||
};
|
||||
}
|
||||
|
||||
function compareLongTermMemoryForRetention(a: LongTermMemoryEntry, b: LongTermMemoryEntry): number {
|
||||
const pA = priorityWithFreshness(a);
|
||||
const pB = priorityWithFreshness(b);
|
||||
if (pB !== pA) return pB - pA;
|
||||
const sourceDiff = sourcePriority(b.source) - sourcePriority(a.source);
|
||||
if (sourceDiff !== 0) return sourceDiff;
|
||||
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
|
||||
}
|
||||
|
||||
function priority(entry: LongTermMemoryEntry): number {
|
||||
|
||||
+243
-15
@@ -9,7 +9,7 @@ import { parseWorkspaceMemoryCandidates } from "../src/extractors.ts";
|
||||
import type { OpenError } from "../src/types.ts";
|
||||
import { workspaceMemoryPath, workspacePendingJournalPath } from "../src/paths.ts";
|
||||
import { loadPendingJournal, savePendingJournal } from "../src/pending-journal.ts";
|
||||
import { updateWorkspaceMemory } from "../src/workspace-memory.ts";
|
||||
import { loadWorkspaceMemory, updateWorkspaceMemory } from "../src/workspace-memory.ts";
|
||||
|
||||
// Mock client for root session (not a sub-agent)
|
||||
function mockRootClient() {
|
||||
@@ -39,6 +39,23 @@ function mockClientWithLatestUser(text: string, messageID: string) {
|
||||
};
|
||||
}
|
||||
|
||||
function mockClientWithCompactionSummary(summary: string) {
|
||||
return {
|
||||
session: {
|
||||
get: async () => ({ data: { parentID: null } }),
|
||||
messages: async () => ({
|
||||
data: [
|
||||
{
|
||||
info: { role: "assistant", summary: true },
|
||||
parts: [{ type: "text", text: summary }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
todo: async () => ({ data: [] }),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Helper: create session state with pre-populated open error
|
||||
function createSessionWithError(sessionID: string, error: OpenError) {
|
||||
return {
|
||||
@@ -562,6 +579,106 @@ test("session.compacted promotes pending memories to workspace memory and clears
|
||||
}
|
||||
});
|
||||
|
||||
test("integration: explicit memory flows from user message through pending journal into workspace", async () => {
|
||||
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-"));
|
||||
|
||||
try {
|
||||
const plugin = await MemoryV2Plugin({
|
||||
directory: tmpDir,
|
||||
client: mockClientWithLatestUser("remember this: Prefer deterministic consolidation accounting.", "msg-explicit-flow"),
|
||||
});
|
||||
|
||||
await (plugin as Record<string, Function>)["experimental.chat.system.transform"](
|
||||
{ sessionID: "explicit-flow-session", model: {} },
|
||||
{ system: ["base header"] },
|
||||
);
|
||||
|
||||
assert.equal((await loadSessionState(tmpDir, "explicit-flow-session")).pendingMemories.length, 1);
|
||||
assert.equal((await loadPendingJournal(tmpDir)).entries.length, 1);
|
||||
|
||||
await (plugin as Record<string, Function>)["event"]({
|
||||
event: { type: "session.compacted", properties: { sessionID: "explicit-flow-session" } },
|
||||
});
|
||||
|
||||
assert.equal((await loadSessionState(tmpDir, "explicit-flow-session")).pendingMemories.length, 0);
|
||||
assert.equal((await loadPendingJournal(tmpDir)).entries.length, 0);
|
||||
|
||||
const output = { system: ["base header"] };
|
||||
await (plugin as Record<string, Function>)["experimental.chat.system.transform"](
|
||||
{ sessionID: "explicit-flow-session", model: {} },
|
||||
output,
|
||||
);
|
||||
|
||||
assert.match(output.system.join("\n"), /Prefer deterministic consolidation accounting/);
|
||||
} finally {
|
||||
await rm(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("integration: compaction candidate flows through journal promotion and clears pending journal", async () => {
|
||||
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-"));
|
||||
|
||||
try {
|
||||
const summary = `
|
||||
## Goal
|
||||
Continue durable memory work.
|
||||
|
||||
## Memory Candidates
|
||||
- [decision] Use accounting events to classify promoted absorbed superseded and rejected memories.
|
||||
`;
|
||||
const plugin = await MemoryV2Plugin({
|
||||
directory: tmpDir,
|
||||
client: mockClientWithCompactionSummary(summary),
|
||||
});
|
||||
|
||||
await (plugin as Record<string, Function>)["event"]({
|
||||
event: { type: "session.compacted", properties: { sessionID: "compaction-flow-session" } },
|
||||
});
|
||||
|
||||
assert.equal((await loadPendingJournal(tmpDir)).entries.length, 0,
|
||||
"compaction candidate should be promoted and then cleared from pending journal");
|
||||
|
||||
const output = { system: ["base header"] };
|
||||
await (plugin as Record<string, Function>)["experimental.chat.system.transform"](
|
||||
{ sessionID: "after-compaction-flow", model: {} },
|
||||
output,
|
||||
);
|
||||
|
||||
assert.match(output.system.join("\n"), /accounting events to classify promoted absorbed superseded and rejected memories/);
|
||||
} finally {
|
||||
await rm(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("integration: next session promotes prior explicit journal and leaves journal clean", async () => {
|
||||
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-"));
|
||||
|
||||
try {
|
||||
const firstPlugin = await MemoryV2Plugin({
|
||||
directory: tmpDir,
|
||||
client: mockClientWithLatestUser("remember this: Cross-session promotion keeps the journal clean.", "msg-cross-session"),
|
||||
});
|
||||
await (firstPlugin as Record<string, Function>)["experimental.chat.system.transform"](
|
||||
{ sessionID: "first-cross-session", model: {} },
|
||||
{ system: ["base header"] },
|
||||
);
|
||||
|
||||
assert.equal((await loadPendingJournal(tmpDir)).entries.length, 1);
|
||||
|
||||
const secondPlugin = await MemoryV2Plugin({ directory: tmpDir, client: mockRootClient() });
|
||||
const output = { system: ["base header"] };
|
||||
await (secondPlugin as Record<string, Function>)["experimental.chat.system.transform"](
|
||||
{ sessionID: "second-cross-session", model: {} },
|
||||
output,
|
||||
);
|
||||
|
||||
assert.equal((await loadPendingJournal(tmpDir)).entries.length, 0);
|
||||
assert.match(output.system.join("\n"), /Cross-session promotion keeps the journal clean/);
|
||||
} finally {
|
||||
await rm(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("same-session explicit memory does not mutate frozen system[1]", async () => {
|
||||
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-"));
|
||||
|
||||
@@ -779,6 +896,65 @@ test("session.compacted clears pending memory absorbed by existing workspace dup
|
||||
}
|
||||
});
|
||||
|
||||
test("session.compacted promotes pending memory when exact existing entry is only superseded", async () => {
|
||||
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-"));
|
||||
|
||||
try {
|
||||
const now = new Date().toISOString();
|
||||
await updateWorkspaceMemory(tmpDir, store => {
|
||||
store.entries.push({
|
||||
id: "mem_superseded_duplicate",
|
||||
type: "decision",
|
||||
text: "Revive superseded exact memories when remembered again.",
|
||||
source: "explicit",
|
||||
confidence: 1,
|
||||
status: "superseded",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
return store;
|
||||
});
|
||||
|
||||
await saveSessionState(tmpDir, {
|
||||
version: 1,
|
||||
sessionID: "superseded-boundary-session",
|
||||
turn: 0,
|
||||
updatedAt: now,
|
||||
activeFiles: [],
|
||||
openErrors: [],
|
||||
recentDecisions: [],
|
||||
pendingMemories: [{
|
||||
id: "mem_pending_revive",
|
||||
type: "decision",
|
||||
text: "Revive superseded exact memories when remembered again.",
|
||||
source: "explicit",
|
||||
confidence: 1,
|
||||
status: "active",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}],
|
||||
});
|
||||
|
||||
const plugin = await MemoryV2Plugin({ directory: tmpDir, client: mockRootClient() });
|
||||
await (plugin as Record<string, Function>)["event"]({
|
||||
event: { type: "session.compacted", properties: { sessionID: "superseded-boundary-session" } },
|
||||
});
|
||||
|
||||
const state = await loadSessionState(tmpDir, "superseded-boundary-session");
|
||||
assert.equal(state.pendingMemories.length, 0,
|
||||
"revived pending memory should be cleared after successful promotion");
|
||||
|
||||
const workspace = await loadWorkspaceMemory(tmpDir);
|
||||
const activeMatches = workspace.entries.filter(entry =>
|
||||
entry.status === "active" && /Revive superseded exact memories/.test(entry.text)
|
||||
);
|
||||
assert.equal(activeMatches.length, 1,
|
||||
"pending memory should become active even when only matching prior entry is superseded");
|
||||
} finally {
|
||||
await rm(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("session.compacted clears pending memory absorbed by existing workspace identity", async () => {
|
||||
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-"));
|
||||
|
||||
@@ -831,7 +1007,7 @@ test("session.compacted clears pending memory absorbed by existing workspace ide
|
||||
}
|
||||
});
|
||||
|
||||
test("session.compacted keeps pending memory rejected by workspace entry cap", async () => {
|
||||
test("session.compacted clears compaction pending memory rejected by workspace entry cap", async () => {
|
||||
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-"));
|
||||
|
||||
try {
|
||||
@@ -878,15 +1054,69 @@ test("session.compacted keeps pending memory rejected by workspace entry cap", a
|
||||
});
|
||||
|
||||
const state = await loadSessionState(tmpDir, "rejected-cap-session");
|
||||
assert.equal(state.pendingMemories.length, 1,
|
||||
"pending memory rejected by workspace cap should remain pending for retry");
|
||||
assert.match(state.pendingMemories[0].text, /Low priority reference/);
|
||||
assert.equal(state.pendingMemories.length, 0,
|
||||
"compaction pending memory rejected by workspace cap should be terminal and clearable");
|
||||
} finally {
|
||||
await rm(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("session.compacted keeps pending memories when all rejected by workspace cap", async () => {
|
||||
test("session.compacted keeps explicit pending memory rejected by workspace entry cap", async () => {
|
||||
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-"));
|
||||
|
||||
try {
|
||||
const now = new Date().toISOString();
|
||||
await updateWorkspaceMemory(tmpDir, store => {
|
||||
for (let i = 0; i < 28; i += 1) {
|
||||
store.entries.push({
|
||||
id: `mem_high_explicit_reject_${i}`,
|
||||
type: "feedback",
|
||||
text: `Pinned high priority feedback for explicit reject ${i}.`,
|
||||
source: "explicit",
|
||||
confidence: 1,
|
||||
status: "active",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
return store;
|
||||
});
|
||||
|
||||
await saveSessionState(tmpDir, {
|
||||
version: 1,
|
||||
sessionID: "explicit-rejected-cap-session",
|
||||
turn: 0,
|
||||
updatedAt: now,
|
||||
activeFiles: [],
|
||||
openErrors: [],
|
||||
recentDecisions: [],
|
||||
pendingMemories: [{
|
||||
id: "mem_explicit_low_priority_reference",
|
||||
type: "reference",
|
||||
text: "Explicit reference should remain pending when capacity rejected.",
|
||||
source: "explicit",
|
||||
confidence: 0.1,
|
||||
status: "active",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}],
|
||||
});
|
||||
|
||||
const plugin = await MemoryV2Plugin({ directory: tmpDir, client: mockRootClient() });
|
||||
await (plugin as Record<string, Function>)["event"]({
|
||||
event: { type: "session.compacted", properties: { sessionID: "explicit-rejected-cap-session" } },
|
||||
});
|
||||
|
||||
const state = await loadSessionState(tmpDir, "explicit-rejected-cap-session");
|
||||
assert.equal(state.pendingMemories.length, 1,
|
||||
"explicit pending memory rejected by workspace cap should remain pending for retry");
|
||||
assert.match(state.pendingMemories[0].text, /Explicit reference/);
|
||||
} finally {
|
||||
await rm(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("session.compacted clears compaction pending memories when all rejected by workspace cap", async () => {
|
||||
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-"));
|
||||
|
||||
try {
|
||||
@@ -946,19 +1176,18 @@ test("session.compacted keeps pending memories when all rejected by workspace ca
|
||||
});
|
||||
|
||||
const state = await loadSessionState(tmpDir, "all-rejected-session");
|
||||
assert.equal(state.pendingMemories.length, 1,
|
||||
"session pending memory must remain when all pending memories are rejected");
|
||||
assert.equal(state.pendingMemories.length, 0,
|
||||
"compaction session pending memory should clear when rejected by capacity accounting");
|
||||
|
||||
const pendingAfter = await loadPendingJournal(tmpDir);
|
||||
assert.equal(pendingAfter.entries.length, 1,
|
||||
"journal pending memories must not be cleared when accounting.clearableKeys is empty");
|
||||
assert.match(pendingAfter.entries[0].text, /another session/);
|
||||
assert.equal(pendingAfter.entries.length, 0,
|
||||
"compaction journal pending memories should clear when rejected by capacity accounting");
|
||||
} finally {
|
||||
await rm(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("session.compacted keeps rejected journal memories when no workspace entries survive", async () => {
|
||||
test("session.compacted clears stale rejected compaction journal memories", async () => {
|
||||
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-"));
|
||||
|
||||
try {
|
||||
@@ -983,9 +1212,8 @@ test("session.compacted keeps rejected journal memories when no workspace entrie
|
||||
});
|
||||
|
||||
const pendingAfter = await loadPendingJournal(tmpDir);
|
||||
assert.equal(pendingAfter.entries.length, 1,
|
||||
"rejected journal memory must not be cleared when accounting.clearableKeys is empty");
|
||||
assert.match(pendingAfter.entries[0].text, /Stale journal/);
|
||||
assert.equal(pendingAfter.entries.length, 0,
|
||||
"stale rejected compaction journal memory should be terminal and clearable");
|
||||
} finally {
|
||||
await rm(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ import assert from "node:assert/strict";
|
||||
import type { LongTermMemoryEntry } from "../src/types.ts";
|
||||
import { accountPendingPromotions } from "../src/promotion-accounting.ts";
|
||||
import { memoryKey } from "../src/pending-journal.ts";
|
||||
import type { MemoryConsolidationEvent } from "../src/workspace-memory.ts";
|
||||
import { workspaceMemoryExactKey, workspaceMemoryIdentityKey } from "../src/workspace-memory.ts";
|
||||
|
||||
function mem(
|
||||
id: string,
|
||||
@@ -26,6 +28,18 @@ function mem(
|
||||
};
|
||||
}
|
||||
|
||||
function event(
|
||||
memory: LongTermMemoryEntry,
|
||||
reason: MemoryConsolidationEvent["reason"],
|
||||
): MemoryConsolidationEvent {
|
||||
return {
|
||||
memoryKey: workspaceMemoryExactKey(memory),
|
||||
identityKey: workspaceMemoryIdentityKey(memory),
|
||||
memory,
|
||||
reason,
|
||||
};
|
||||
}
|
||||
|
||||
test("accountPendingPromotions marks exact retained pending memory as promoted", () => {
|
||||
const pending = [mem("pending", "Use frozen rendered snapshots for cache stability.")];
|
||||
const before: LongTermMemoryEntry[] = [];
|
||||
@@ -67,6 +81,24 @@ test("accountPendingPromotions marks same exact key present before promotion as
|
||||
assert.equal(result.rejectedKeys.size, 0);
|
||||
});
|
||||
|
||||
test("accountPendingPromotions ignores superseded exact keys when detecting existing absorption", () => {
|
||||
const superseded = mem("superseded", "Revive this memory when it is remembered again.", {
|
||||
source: "explicit",
|
||||
status: "superseded",
|
||||
});
|
||||
const pending = [mem("pending", "Revive this memory when it is remembered again.", {
|
||||
source: "explicit",
|
||||
})];
|
||||
const before = [superseded];
|
||||
const after = [superseded, pending[0]];
|
||||
|
||||
const result = accountPendingPromotions({ pending, before, after });
|
||||
|
||||
assert.deepEqual([...result.promotedKeys], [memoryKey(pending[0])]);
|
||||
assert.equal(result.absorbedKeys.size, 0);
|
||||
assert.deepEqual([...result.clearableKeys], [memoryKey(pending[0])]);
|
||||
});
|
||||
|
||||
test("accountPendingPromotions marks same-topic decision represented after normalization as absorbed", () => {
|
||||
const existing = mem("existing", "Parser supports 2 candidate formats.", {
|
||||
type: "decision",
|
||||
@@ -107,3 +139,91 @@ test("accountPendingPromotions keeps pending memory rejected when no equivalent
|
||||
assert.deepEqual([...result.rejectedKeys], [memoryKey(pending[0])]);
|
||||
assert.equal(result.clearableKeys.size, 0);
|
||||
});
|
||||
|
||||
test("accountPendingPromotions clears accounting absorbed identity events", () => {
|
||||
const pending = [mem("pending_identity", "This repo uses opencode-agenthub plugin system", {
|
||||
type: "project",
|
||||
source: "compaction",
|
||||
})];
|
||||
|
||||
const result = accountPendingPromotions({
|
||||
pending,
|
||||
before: [],
|
||||
after: [],
|
||||
events: [event(pending[0], "absorbed_identity")],
|
||||
});
|
||||
|
||||
assert.deepEqual([...result.absorbedKeys], [memoryKey(pending[0])]);
|
||||
assert.deepEqual([...result.clearableKeys], [memoryKey(pending[0])]);
|
||||
assert.equal(result.rejectedKeys.size, 0);
|
||||
});
|
||||
|
||||
test("accountPendingPromotions separates accounting superseded events", () => {
|
||||
const pending = [mem("pending_topic", "Parser supports 3 candidate formats.", {
|
||||
type: "decision",
|
||||
source: "compaction",
|
||||
})];
|
||||
|
||||
const result = accountPendingPromotions({
|
||||
pending,
|
||||
before: [],
|
||||
after: [],
|
||||
events: [event(pending[0], "superseded_existing")],
|
||||
});
|
||||
|
||||
assert.deepEqual([...result.supersededKeys], [memoryKey(pending[0])]);
|
||||
assert.deepEqual([...result.clearableKeys], [memoryKey(pending[0])]);
|
||||
assert.equal(result.absorbedKeys.size, 0);
|
||||
assert.equal(result.rejectedKeys.size, 0);
|
||||
});
|
||||
|
||||
test("accountPendingPromotions clears compaction capacity rejection from accounting", () => {
|
||||
const pending = [mem("pending_capacity", "Weak compaction reference that should lose capacity review.", {
|
||||
type: "reference",
|
||||
source: "compaction",
|
||||
})];
|
||||
|
||||
const result = accountPendingPromotions({
|
||||
pending,
|
||||
before: [],
|
||||
after: [],
|
||||
events: [event(pending[0], "rejected_capacity")],
|
||||
});
|
||||
|
||||
assert.deepEqual([...result.rejectedKeys], [memoryKey(pending[0])]);
|
||||
assert.deepEqual([...result.clearableKeys], [memoryKey(pending[0])]);
|
||||
});
|
||||
|
||||
test("accountPendingPromotions keeps explicit capacity rejection pending", () => {
|
||||
const pending = [mem("pending_explicit_capacity", "Explicit reference should retry if capacity rejected.", {
|
||||
type: "reference",
|
||||
source: "explicit",
|
||||
})];
|
||||
|
||||
const result = accountPendingPromotions({
|
||||
pending,
|
||||
before: [],
|
||||
after: [],
|
||||
events: [event(pending[0], "rejected_capacity")],
|
||||
});
|
||||
|
||||
assert.deepEqual([...result.rejectedKeys], [memoryKey(pending[0])]);
|
||||
assert.equal(result.clearableKeys.size, 0);
|
||||
});
|
||||
|
||||
test("accountPendingPromotions clears compaction stale rejection from accounting", () => {
|
||||
const pending = [mem("pending_stale", "Stale compaction reference should be terminal.", {
|
||||
type: "reference",
|
||||
source: "compaction",
|
||||
})];
|
||||
|
||||
const result = accountPendingPromotions({
|
||||
pending,
|
||||
before: [],
|
||||
after: [],
|
||||
events: [event(pending[0], "rejected_stale")],
|
||||
});
|
||||
|
||||
assert.deepEqual([...result.rejectedKeys], [memoryKey(pending[0])]);
|
||||
assert.deepEqual([...result.clearableKeys], [memoryKey(pending[0])]);
|
||||
});
|
||||
|
||||
@@ -9,11 +9,16 @@ import { workspaceMemoryPath } from "../src/paths.ts";
|
||||
import {
|
||||
renderWorkspaceMemory,
|
||||
enforceLongTermLimits,
|
||||
dedupeLongTermEntriesWithAccounting,
|
||||
enforceLongTermLimitsWithAccounting,
|
||||
normalizeWorkspaceMemoryWithAccounting,
|
||||
workspaceMemoryExactKey,
|
||||
redactCredentials,
|
||||
isProjectSnapshotViolation,
|
||||
runMigrationP0Cleanup,
|
||||
loadWorkspaceMemory,
|
||||
saveWorkspaceMemory,
|
||||
updateWorkspaceMemoryWithAccounting,
|
||||
} from "../src/workspace-memory.ts";
|
||||
|
||||
function entry(id: string, text: string, type: LongTermMemoryEntry["type"] = "decision"): LongTermMemoryEntry {
|
||||
@@ -243,6 +248,241 @@ test("enforceLongTermLimits respects maxEntries limit", () => {
|
||||
assert.ok(kept.length <= 28, `Should respect maxEntries. Got: ${kept.length}`);
|
||||
});
|
||||
|
||||
test("dedupeLongTermEntriesWithAccounting reports exact duplicates as absorbed", () => {
|
||||
const now = new Date().toISOString();
|
||||
const lower: LongTermMemoryEntry = {
|
||||
id: "lower",
|
||||
type: "decision",
|
||||
text: "OpenCode uses NPM CACHE for plugin loading",
|
||||
source: "compaction",
|
||||
confidence: 0.7,
|
||||
status: "active",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
const higher: LongTermMemoryEntry = {
|
||||
id: "higher",
|
||||
type: "decision",
|
||||
text: "opencode uses npm cache for plugin loading!!!",
|
||||
source: "compaction",
|
||||
confidence: 0.8,
|
||||
status: "active",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
const result = dedupeLongTermEntriesWithAccounting([lower, higher]);
|
||||
|
||||
assert.equal(result.kept.length, 1);
|
||||
assert.equal(result.kept[0].id, "higher");
|
||||
assert.deepEqual(result.absorbed.map(event => event.reason), ["absorbed_exact"]);
|
||||
assert.equal(result.absorbed[0].memory.id, "lower");
|
||||
});
|
||||
|
||||
test("dedupeLongTermEntriesWithAccounting reports identity duplicates as absorbed", () => {
|
||||
const older = agedEntry(
|
||||
"older",
|
||||
"This repo uses opencode-agenthub plugin system at /Users/sd_wo/work/opencode-working-memory/",
|
||||
"project",
|
||||
{ daysAgo: 5 },
|
||||
);
|
||||
const newer = agedEntry(
|
||||
"newer",
|
||||
"此 repo 在開發時使用 opencode-agenthub 插件系統,目錄位於 /Users/sd_wo/work/opencode-working-memory/.opencode-agenthub/",
|
||||
"project",
|
||||
{ daysAgo: 0 },
|
||||
);
|
||||
|
||||
const result = dedupeLongTermEntriesWithAccounting([older, newer]);
|
||||
|
||||
assert.equal(result.kept.length, 1);
|
||||
assert.equal(result.absorbed.length, 1);
|
||||
assert.equal(result.absorbed[0].reason, "absorbed_identity");
|
||||
assert.equal(result.absorbed[0].retainedId, result.kept[0].id);
|
||||
});
|
||||
|
||||
test("dedupeLongTermEntriesWithAccounting reports topic duplicates as superseded", () => {
|
||||
const older = agedEntry(
|
||||
"older",
|
||||
"Parser supports 3 formats: HTML comment, Markdown section, legacy XML",
|
||||
"decision",
|
||||
{ daysAgo: 5 },
|
||||
);
|
||||
const newer = agedEntry(
|
||||
"newer",
|
||||
"Parser supports 4 formats: plain text label, Markdown section, legacy section name, legacy XML",
|
||||
"decision",
|
||||
{ daysAgo: 0 },
|
||||
);
|
||||
|
||||
const result = dedupeLongTermEntriesWithAccounting([older, newer]);
|
||||
|
||||
assert.equal(result.kept.length, 1);
|
||||
assert.equal(result.kept[0].id, "newer");
|
||||
assert.equal(result.superseded.length, 1);
|
||||
assert.equal(result.superseded[0].reason, "superseded_existing");
|
||||
assert.equal(result.superseded[0].supersededId, "older");
|
||||
assert.equal(result.superseded[0].retainedId, "newer");
|
||||
});
|
||||
|
||||
test("enforceLongTermLimitsWithAccounting reports capacity drops", () => {
|
||||
const now = new Date().toISOString();
|
||||
const entries = Array.from({ length: LONG_TERM_LIMITS.maxEntries + 2 }, (_, i) => ({
|
||||
id: `mem_${i}`,
|
||||
type: "reference" as const,
|
||||
text: `Unique low priority reference ${i}`,
|
||||
source: "compaction" as const,
|
||||
confidence: i < LONG_TERM_LIMITS.maxEntries ? 0.8 : 0.1,
|
||||
status: "active" as const,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}));
|
||||
|
||||
const result = enforceLongTermLimitsWithAccounting(entries);
|
||||
|
||||
assert.equal(result.kept.length, LONG_TERM_LIMITS.maxEntries);
|
||||
assert.equal(result.dropped.filter(event => event.reason === "rejected_capacity").length, 2);
|
||||
assert.ok(result.dropped.every(event => event.memory.source === "compaction"));
|
||||
});
|
||||
|
||||
test("workspaceMemoryExactKey uses pending-compatible canonical semantics", () => {
|
||||
const now = new Date().toISOString();
|
||||
const entry: LongTermMemoryEntry = {
|
||||
id: "key_alignment",
|
||||
type: "decision",
|
||||
text: "OpenCode uses NPM CACHE for plugin loading!!!",
|
||||
source: "compaction",
|
||||
confidence: 0.75,
|
||||
status: "active",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
assert.equal(workspaceMemoryExactKey(entry), "decision:opencode uses npm cache for plugin loading");
|
||||
});
|
||||
|
||||
test("normalizeWorkspaceMemoryWithAccounting redacts credentials before accounting", async () => {
|
||||
const root = "/repo";
|
||||
const now = new Date().toISOString();
|
||||
const store: WorkspaceMemoryStore = {
|
||||
version: 1,
|
||||
workspace: { root, key: "abc" },
|
||||
limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries },
|
||||
migrations: ["2026-04-26-p0-cleanup"],
|
||||
entries: [{
|
||||
id: "cred",
|
||||
type: "reference",
|
||||
text: "Admin PIN 是 456123",
|
||||
rationale: "password: sushi",
|
||||
source: "explicit",
|
||||
confidence: 1,
|
||||
status: "active",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}],
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
const result = await normalizeWorkspaceMemoryWithAccounting(root, store);
|
||||
|
||||
assert.equal(result.kept.length, 1);
|
||||
assert.equal(result.kept[0].text, "Admin PIN 是 [REDACTED]");
|
||||
assert.equal(result.kept[0].rationale, "password: [REDACTED]");
|
||||
assert.equal(result.store.entries[0].text, "Admin PIN 是 [REDACTED]");
|
||||
});
|
||||
|
||||
test("normalizeWorkspaceMemoryWithAccounting reports overflow capacity drops", async () => {
|
||||
const root = "/repo";
|
||||
const now = new Date().toISOString();
|
||||
const store: WorkspaceMemoryStore = {
|
||||
version: 1,
|
||||
workspace: { root, key: "abc" },
|
||||
limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries },
|
||||
migrations: ["2026-04-26-p0-cleanup"],
|
||||
entries: Array.from({ length: LONG_TERM_LIMITS.maxEntries + 1 }, (_, i) => ({
|
||||
id: `overflow_${i}`,
|
||||
type: "reference" as const,
|
||||
text: `Overflow reference ${i}`,
|
||||
source: "compaction" as const,
|
||||
confidence: i < LONG_TERM_LIMITS.maxEntries ? 0.8 : 0.1,
|
||||
status: "active" as const,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})),
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
const result = await normalizeWorkspaceMemoryWithAccounting(root, store);
|
||||
|
||||
assert.equal(result.kept.length, LONG_TERM_LIMITS.maxEntries);
|
||||
assert.equal(result.store.entries.filter(entry => entry.status === "active").length, LONG_TERM_LIMITS.maxEntries);
|
||||
assert.equal(result.dropped.filter(event => event.reason === "rejected_capacity").length, 1);
|
||||
});
|
||||
|
||||
test("normalizeWorkspaceMemoryWithAccounting reports stale entry removal", async () => {
|
||||
const root = "/repo";
|
||||
const now = new Date().toISOString();
|
||||
const stale = agedEntry(
|
||||
"stale_normalize",
|
||||
"Old compaction decision should be removed by normalization accounting",
|
||||
"decision",
|
||||
{ daysAgo: 90, staleAfterDays: 1 },
|
||||
);
|
||||
const store: WorkspaceMemoryStore = {
|
||||
version: 1,
|
||||
workspace: { root, key: "abc" },
|
||||
limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries },
|
||||
migrations: ["2026-04-26-p0-cleanup"],
|
||||
entries: [stale],
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
const result = await normalizeWorkspaceMemoryWithAccounting(root, store);
|
||||
|
||||
assert.equal(result.kept.length, 0);
|
||||
assert.equal(result.store.entries.length, 0);
|
||||
assert.deepEqual(result.dropped.map(event => event.reason), ["rejected_stale"]);
|
||||
assert.equal(result.dropped[0].memory.id, "stale_normalize");
|
||||
});
|
||||
|
||||
test("updateWorkspaceMemoryWithAccounting emits accounting events for persisted updates", async () => {
|
||||
const sandbox = await mkdtemp(join(tmpdir(), "wm-accounting-update-"));
|
||||
const dataHome = join(sandbox, "xdg-data-home");
|
||||
const root = join(sandbox, "workspace");
|
||||
const previousXdgDataHome = process.env.XDG_DATA_HOME;
|
||||
process.env.XDG_DATA_HOME = dataHome;
|
||||
|
||||
try {
|
||||
const now = new Date().toISOString();
|
||||
const result = await updateWorkspaceMemoryWithAccounting(root, store => {
|
||||
store.entries.push(...Array.from({ length: LONG_TERM_LIMITS.maxEntries + 1 }, (_, i) => ({
|
||||
id: `persisted_${i}`,
|
||||
type: "reference" as const,
|
||||
text: `Persisted accounting reference ${i}`,
|
||||
source: "compaction" as const,
|
||||
confidence: i < LONG_TERM_LIMITS.maxEntries ? 0.8 : 0.1,
|
||||
status: "active" as const,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})));
|
||||
return store;
|
||||
});
|
||||
|
||||
assert.equal(result.store.entries.filter(entry => entry.status === "active").length, LONG_TERM_LIMITS.maxEntries);
|
||||
assert.equal(result.events.filter(event => event.reason === "rejected_capacity").length, 1);
|
||||
|
||||
const persisted = await loadWorkspaceMemory(root);
|
||||
assert.equal(persisted.entries.filter(entry => entry.status === "active").length, LONG_TERM_LIMITS.maxEntries);
|
||||
} finally {
|
||||
if (previousXdgDataHome === undefined) {
|
||||
delete process.env.XDG_DATA_HOME;
|
||||
} else {
|
||||
process.env.XDG_DATA_HOME = previousXdgDataHome;
|
||||
}
|
||||
await rm(sandbox, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// P0d: identity-key dedup, supersession, staleness
|
||||
// ============================================
|
||||
|
||||
Reference in New Issue
Block a user