From 909d6c7767f669b86e639d96f2d8a284ab6f02eb Mon Sep 17 00:00:00 2001 From: Ralph Chang Date: Mon, 27 Apr 2026 18:55:12 +0800 Subject: [PATCH] docs: document concise compatibility limitations --- CHANGELOG.md | 11 +++++++- README.md | 10 ++++++- src/pending-journal.ts | 36 ++++++++++++++---------- tests/pending-journal.test.ts | 53 ++++++++++++++++++++++++++++++----- 4 files changed, 86 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e38ed3..88192e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Remove absorbed/superseded keys from rejected set to avoid duplicate rejection tracking. - Memory quality evaluation fixtures covering accepted durable facts and rejected noisy facts. - Sharper compaction memory extraction prompt with concrete good/bad memory examples. +- Pending journal retention: max 50 entries, 30-day TTL, automatic pruning on save. +- Plugin capability test to catch missing OpenCode hooks before release. +- CI workflow for weekly OpenCode plugin API compatibility testing. ### Fixed @@ -27,12 +30,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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. - README structure was streamlined around the automatic memory flow and ongoing memory-quality work. - Architecture docs now describe `Memory candidates:` as the primary extraction format and XML candidate blocks as legacy. - Superpowers implementation plans are no longer tracked in git. +### Known Limitations + +- Compatibility is tested against OpenCode plugin API `>=1.2.0 <2.0.0`. +- Credential redaction is best-effort; do not store secrets. +- This is working memory, not semantic search. +- Multi-process writes to the same workspace are not fully serialized. + ## [1.2.3] - 2026-04-26 ### Added diff --git a/README.md b/README.md index 6e90757..9614372 100644 --- a/README.md +++ b/README.md @@ -214,9 +214,17 @@ npm run typecheck ## Requirements -- OpenCode >= 1.0.0 +- OpenCode plugin API `>=1.2.0 <2.0.0` - Node.js >= 18.0.0 +## Limitations + +- Requires OpenCode plugin API `>=1.2.0 <2.0.0`; OpenCode hook changes may break compatibility. +- Not a secret manager. Credential redaction is best-effort. Do not store secrets. +- Working memory only. No semantic search, embeddings, or vector knowledge base. +- Other prompt or compaction plugins may conflict depending on plugin order. +- Multiple OpenCode processes on the same workspace may race on local files. + ## License MIT License. See [LICENSE](LICENSE) for details. diff --git a/src/pending-journal.ts b/src/pending-journal.ts index 4e7e1f3..24f9dd5 100644 --- a/src/pending-journal.ts +++ b/src/pending-journal.ts @@ -51,17 +51,27 @@ function dedupeByText(entries: LongTermMemoryEntry[]): LongTermMemoryEntry[] { return result; } -function isStaleEntry(entry: LongTermMemoryEntry, maxAgeDays: number): boolean { - const createdAt = entry.createdAt ? new Date(entry.createdAt).getTime() : NaN; +/** + * Get the effective timestamp for an entry, preferring updatedAt over createdAt. + * Returns 0 if both are invalid/missing. + */ +function entryTime(entry: LongTermMemoryEntry): number { const updatedAt = entry.updatedAt ? new Date(entry.updatedAt).getTime() : NaN; + if (!Number.isNaN(updatedAt)) return updatedAt; + + const createdAt = entry.createdAt ? new Date(entry.createdAt).getTime() : NaN; + if (!Number.isNaN(createdAt)) return createdAt; + + return 0; +} + +function isStaleEntry(entry: LongTermMemoryEntry, maxAgeDays: number): boolean { + const time = entryTime(entry); - // If both timestamps are invalid, treat as stale - if (Number.isNaN(createdAt) && Number.isNaN(updatedAt)) { - return true; - } + // If timestamp is 0 (both invalid), treat as stale + if (time === 0) return true; - // Use createdAt as primary age timestamp - const ageMs = Date.now() - (Number.isNaN(createdAt) ? updatedAt : createdAt); + const ageMs = Date.now() - time; const maxAgeMs = maxAgeDays * 24 * 60 * 60 * 1000; return ageMs > maxAgeMs; @@ -78,11 +88,9 @@ function applyRetention( // 2. Remove stale entries const freshEntries = deduped.filter(entry => !isStaleEntry(entry, maxAgeDays)); - // 3. Sort by createdAt descending (newest first) for cap + // 3. Sort by entryTime descending (newest first) for cap, using updatedAt then createdAt 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; + return entryTime(b) - entryTime(a); }); // 4. Keep maxEntries newest @@ -90,9 +98,7 @@ function applyRetention( // 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; + return entryTime(a) - entryTime(b); }); } diff --git a/tests/pending-journal.test.ts b/tests/pending-journal.test.ts index 3b6b17f..f62c9b4 100644 --- a/tests/pending-journal.test.ts +++ b/tests/pending-journal.test.ts @@ -193,7 +193,7 @@ describe("pending journal retention", () => { ); }); - it("savePendingJournal keeps explicit entries even if old", async () => { + it("savePendingJournal prunes stale entries regardless of source", async () => { const now = new Date(); const staleDate = new Date(now.getTime() - 35 * 24 * 60 * 60 * 1000); @@ -223,12 +223,51 @@ describe("pending journal retention", () => { 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" + // Both explicit and compaction entries past maxAgeDays are pruned + // Retention does not differentiate by source + assert.strictEqual( + loaded.entries.length, + 0, + "Stale entries should be pruned regardless of source" + ); + }); + + it("savePendingJournal uses updatedAt when createdAt is missing", async () => { + const now = new Date(); + const freshDate = new Date(now.getTime() - 1 * 24 * 60 * 60 * 1000); + const staleDate = new Date(now.getTime() - 35 * 24 * 60 * 60 * 1000); + + const entries: LongTermMemoryEntry[] = [ + { + type: "decision", + text: "Entry with missing createdAt but fresh updatedAt", + source: "compaction", + createdAt: "", // invalid + updatedAt: freshDate.toISOString(), + }, + { + type: "decision", + text: "Entry with missing createdAt and stale updatedAt", + source: "compaction", + createdAt: "", // invalid + updatedAt: staleDate.toISOString(), + }, + ]; + + await savePendingJournal(testDir, { + version: 1, + workspace: { root: testDir, key: "test" }, + entries, + updatedAt: now.toISOString(), + }); + + const loaded = await loadPendingJournal(testDir); + + // Fresh entry should be kept, stale entry should be pruned + assert.strictEqual(loaded.entries.length, 1); + assert.strictEqual( + loaded.entries[0].text, + "Entry with missing createdAt but fresh updatedAt" ); }); });