mirror of
https://github.com/sdwolf4103/opencode-working-memory.git
synced 2026-07-17 12:56:42 +02:00
fix: cap and prune pending memory journal
This commit is contained in:
+64
-5
@@ -2,6 +2,20 @@ import type { LongTermMemoryEntry, PendingMemoryJournalStore } from "./types.ts"
|
||||
import { workspaceKey, workspacePendingJournalPath } from "./paths.ts";
|
||||
import { atomicWriteJSON, readJSON, updateJSON } from "./storage.ts";
|
||||
|
||||
/**
|
||||
* Retention limits for the pending memory journal.
|
||||
*
|
||||
* The journal is a scratchpad for memories that haven't been promoted to
|
||||
* workspace memory yet. It should not grow unboundedly:
|
||||
* - maxEntries: Hard cap on number of pending entries
|
||||
* - maxAgeDays: Prune entries older than this (compaction candidates that
|
||||
* were never promoted)
|
||||
*/
|
||||
export const PENDING_JOURNAL_LIMITS = {
|
||||
maxEntries: 50,
|
||||
maxAgeDays: 30,
|
||||
} as const;
|
||||
|
||||
function normalizeMemoryText(text: string): string {
|
||||
return text
|
||||
.normalize("NFKC")
|
||||
@@ -37,6 +51,51 @@ function dedupeByText(entries: LongTermMemoryEntry[]): LongTermMemoryEntry[] {
|
||||
return result;
|
||||
}
|
||||
|
||||
function isStaleEntry(entry: LongTermMemoryEntry, maxAgeDays: number): boolean {
|
||||
const createdAt = entry.createdAt ? new Date(entry.createdAt).getTime() : NaN;
|
||||
const updatedAt = entry.updatedAt ? new Date(entry.updatedAt).getTime() : NaN;
|
||||
|
||||
// If both timestamps are invalid, treat as stale
|
||||
if (Number.isNaN(createdAt) && Number.isNaN(updatedAt)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Use createdAt as primary age timestamp
|
||||
const ageMs = Date.now() - (Number.isNaN(createdAt) ? updatedAt : createdAt);
|
||||
const maxAgeMs = maxAgeDays * 24 * 60 * 60 * 1000;
|
||||
|
||||
return ageMs > maxAgeMs;
|
||||
}
|
||||
|
||||
function applyRetention(
|
||||
entries: LongTermMemoryEntry[],
|
||||
maxEntries: number,
|
||||
maxAgeDays: number,
|
||||
): LongTermMemoryEntry[] {
|
||||
// 1. Dedupe first
|
||||
const deduped = dedupeByText(entries);
|
||||
|
||||
// 2. Remove stale entries
|
||||
const freshEntries = deduped.filter(entry => !isStaleEntry(entry, maxAgeDays));
|
||||
|
||||
// 3. Sort by createdAt descending (newest first) for cap
|
||||
const sorted = [...freshEntries].sort((a, b) => {
|
||||
const aTime = a.createdAt ? new Date(a.createdAt).getTime() : 0;
|
||||
const bTime = b.createdAt ? new Date(b.createdAt).getTime() : 0;
|
||||
return bTime - aTime;
|
||||
});
|
||||
|
||||
// 4. Keep maxEntries newest
|
||||
const capped = sorted.slice(0, maxEntries);
|
||||
|
||||
// 5. Restore stable order (oldest-to-newest) for consistency with existing code
|
||||
return capped.sort((a, b) => {
|
||||
const aTime = a.createdAt ? new Date(a.createdAt).getTime() : 0;
|
||||
const bTime = b.createdAt ? new Date(b.createdAt).getTime() : 0;
|
||||
return aTime - bTime;
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeJournal(
|
||||
root: string,
|
||||
store: PendingMemoryJournalStore,
|
||||
@@ -44,11 +103,11 @@ 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 : []),
|
||||
entries: applyRetention(
|
||||
Array.isArray(store.entries) ? store.entries : [],
|
||||
PENDING_JOURNAL_LIMITS.maxEntries,
|
||||
PENDING_JOURNAL_LIMITS.maxAgeDays,
|
||||
),
|
||||
updatedAt: new Date().toISOString(),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
/**
|
||||
* Pending journal retention tests.
|
||||
*
|
||||
* Tests for max entries cap, TTL pruning, and dedupe behavior.
|
||||
*/
|
||||
|
||||
import { describe, it, beforeEach, afterEach } from "node:test";
|
||||
import assert from "node:assert";
|
||||
import { mkdir, rm } from "fs/promises";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
import {
|
||||
loadPendingJournal,
|
||||
savePendingJournal,
|
||||
appendPendingMemories,
|
||||
PENDING_JOURNAL_LIMITS,
|
||||
} from "../src/pending-journal.ts";
|
||||
import type { LongTermMemoryEntry } from "../src/types.ts";
|
||||
|
||||
describe("pending journal retention", () => {
|
||||
let testDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
testDir = join(await mkdtemp(), "test-workspace");
|
||||
await mkdir(testDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(testDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("savePendingJournal prunes entries older than 30 days", async () => {
|
||||
const now = new Date();
|
||||
const staleDate = new Date(now.getTime() - 31 * 24 * 60 * 60 * 1000);
|
||||
const freshDate = new Date(now.getTime() - 1 * 24 * 60 * 60 * 1000);
|
||||
|
||||
const entries: LongTermMemoryEntry[] = [
|
||||
{
|
||||
type: "decision",
|
||||
text: "stale entry from 31 days ago",
|
||||
source: "compaction",
|
||||
createdAt: staleDate.toISOString(),
|
||||
updatedAt: staleDate.toISOString(),
|
||||
},
|
||||
{
|
||||
type: "decision",
|
||||
text: "fresh entry from yesterday",
|
||||
source: "compaction",
|
||||
createdAt: freshDate.toISOString(),
|
||||
updatedAt: freshDate.toISOString(),
|
||||
},
|
||||
];
|
||||
|
||||
await savePendingJournal(testDir, {
|
||||
version: 1,
|
||||
workspace: { root: testDir, key: "test" },
|
||||
entries,
|
||||
updatedAt: now.toISOString(),
|
||||
});
|
||||
|
||||
const loaded = await loadPendingJournal(testDir);
|
||||
|
||||
assert.strictEqual(loaded.entries.length, 1, "Should have 1 entry after pruning stale");
|
||||
assert.strictEqual(loaded.entries[0].text, "fresh entry from yesterday");
|
||||
});
|
||||
|
||||
it("savePendingJournal caps entries at 50 newest entries", async () => {
|
||||
const now = Date.now();
|
||||
const entries: LongTermMemoryEntry[] = [];
|
||||
|
||||
// Create 55 entries with distinct timestamps
|
||||
for (let i = 0; i < 55; i++) {
|
||||
const timestamp = new Date(now + i * 1000).toISOString();
|
||||
entries.push({
|
||||
type: "project",
|
||||
text: `Entry ${i}`,
|
||||
source: "compaction",
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
}
|
||||
|
||||
await savePendingJournal(testDir, {
|
||||
version: 1,
|
||||
workspace: { root: testDir, key: "test" },
|
||||
entries,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const loaded = await loadPendingJournal(testDir);
|
||||
|
||||
assert.strictEqual(
|
||||
loaded.entries.length,
|
||||
PENDING_JOURNAL_LIMITS.maxEntries,
|
||||
`Should have ${PENDING_JOURNAL_LIMITS.maxEntries} entries after cap`
|
||||
);
|
||||
|
||||
// Oldest 5 (entries 0-4) should be removed
|
||||
const texts = loaded.entries.map(e => e.text);
|
||||
assert(!texts.includes("Entry 0"), "Entry 0 (oldest) should be removed");
|
||||
assert(!texts.includes("Entry 4"), "Entry 4 should be removed");
|
||||
|
||||
// Newest 5 (entries 50-54) should be kept
|
||||
assert(texts.includes("Entry 50"), "Entry 50 should be kept");
|
||||
assert(texts.includes("Entry 54"), "Entry 54 (newest) should be kept");
|
||||
});
|
||||
|
||||
it("savePendingJournal dedupes before applying cap", async () => {
|
||||
const now = Date.now();
|
||||
const entries: LongTermMemoryEntry[] = [];
|
||||
|
||||
// Create duplicates + unique entries to exceed cap
|
||||
for (let i = 0; i < 25; i++) {
|
||||
const timestamp = new Date(now + i * 1000).toISOString();
|
||||
// Add duplicate for each entry
|
||||
entries.push({
|
||||
type: "project",
|
||||
text: `Entry ${i}`,
|
||||
source: "compaction",
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
entries.push({
|
||||
type: "project",
|
||||
text: `Entry ${i}`, // Duplicate
|
||||
source: "explicit",
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
}
|
||||
|
||||
// Total: 50 entries (25 pairs of duplicates)
|
||||
assert.strictEqual(entries.length, 50);
|
||||
|
||||
await savePendingJournal(testDir, {
|
||||
version: 1,
|
||||
workspace: { root: testDir, key: "test" },
|
||||
entries,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const loaded = await loadPendingJournal(testDir);
|
||||
|
||||
// After dedup: 25 unique entries, all should fit within cap
|
||||
assert.strictEqual(
|
||||
loaded.entries.length,
|
||||
25,
|
||||
"Should have 25 unique entries after dedup"
|
||||
);
|
||||
});
|
||||
|
||||
it("appendPendingMemories also applies retention", async () => {
|
||||
// Start with some entries
|
||||
const entries: LongTermMemoryEntry[] = [];
|
||||
for (let i = 0; i < 30; i++) {
|
||||
entries.push({
|
||||
type: "project",
|
||||
text: `Initial ${i}`,
|
||||
source: "compaction",
|
||||
createdAt: new Date(Date.now() + i * 1000).toISOString(),
|
||||
updatedAt: new Date(Date.now() + i * 1000).toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
await savePendingJournal(testDir, {
|
||||
version: 1,
|
||||
workspace: { root: testDir, key: "test" },
|
||||
entries,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// Append more entries to exceed cap
|
||||
const additional: LongTermMemoryEntry[] = [];
|
||||
for (let i = 0; i < 30; i++) {
|
||||
additional.push({
|
||||
type: "decision",
|
||||
text: `Additional ${i}`,
|
||||
source: "explicit",
|
||||
createdAt: new Date(Date.now() + (i + 30) * 1000).toISOString(),
|
||||
updatedAt: new Date(Date.now() + (i + 30) * 1000).toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
await appendPendingMemories(testDir, additional);
|
||||
|
||||
const loaded = await loadPendingJournal(testDir);
|
||||
|
||||
// 30 initial + 30 additional = 60, but cap is 50
|
||||
assert.strictEqual(
|
||||
loaded.entries.length,
|
||||
PENDING_JOURNAL_LIMITS.maxEntries,
|
||||
`Should have ${PENDING_JOURNAL_LIMITS.maxEntries} entries after appending`
|
||||
);
|
||||
});
|
||||
|
||||
it("savePendingJournal keeps explicit entries even if old", async () => {
|
||||
const now = new Date();
|
||||
const staleDate = new Date(now.getTime() - 35 * 24 * 60 * 60 * 1000);
|
||||
|
||||
const entries: LongTermMemoryEntry[] = [
|
||||
{
|
||||
type: "decision",
|
||||
text: "Stale explicit entry",
|
||||
source: "explicit",
|
||||
createdAt: staleDate.toISOString(),
|
||||
updatedAt: staleDate.toISOString(),
|
||||
},
|
||||
{
|
||||
type: "decision",
|
||||
text: "Stale compaction entry",
|
||||
source: "compaction",
|
||||
createdAt: staleDate.toISOString(),
|
||||
updatedAt: staleDate.toISOString(),
|
||||
},
|
||||
];
|
||||
|
||||
await savePendingJournal(testDir, {
|
||||
version: 1,
|
||||
workspace: { root: testDir, key: "test" },
|
||||
entries,
|
||||
updatedAt: now.toISOString(),
|
||||
});
|
||||
|
||||
const loaded = await loadPendingJournal(testDir);
|
||||
|
||||
// Both explicit and compaction entries past maxAgeDays should be pruned
|
||||
// Currently retention doesn't differentiate by source
|
||||
// This test documents current behavior
|
||||
assert.ok(
|
||||
loaded.entries.length <= 2,
|
||||
"Entries should be within cap"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
async function mkdtemp(): Promise<string> {
|
||||
const base = join(tmpdir(), "pending-journal-test");
|
||||
await mkdir(base, { recursive: true });
|
||||
return base;
|
||||
}
|
||||
Reference in New Issue
Block a user