From 3301a6be1ea5717495fb466cc59b3d22d4cc1dd5 Mon Sep 17 00:00:00 2001 From: artale Date: Fri, 26 Jun 2026 22:57:17 +0200 Subject: [PATCH] fix: rank receipt-backed memory first --- src/upgrades/persistent-memory.test.ts | 58 ++++++++++++++++++++++++++ src/upgrades/persistent-memory.ts | 47 +++++++++++++++++---- 2 files changed, 98 insertions(+), 7 deletions(-) create mode 100644 src/upgrades/persistent-memory.test.ts diff --git a/src/upgrades/persistent-memory.test.ts b/src/upgrades/persistent-memory.test.ts new file mode 100644 index 0000000..2c3c672 --- /dev/null +++ b/src/upgrades/persistent-memory.test.ts @@ -0,0 +1,58 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { describe, expect, it } from "vitest"; +import { StateStore } from "../core/state-store.js"; +import { PersistentMemory } from "./persistent-memory.js"; + +function tmpStore() { + return new StateStore(fs.mkdtempSync(path.join(os.tmpdir(), "fable-memory-"))); +} + +describe("persistent memory provenance", () => { + it("ranks receipt-backed verified memories before stale unverified memory", () => { + const memory = new PersistentMemory(tmpStore()); + memory.recordEpisode({ + sessionId: "old", + task: "factory containers", + summary: "stale memory says 25 containers", + iterations: 1, + finalQuality: 0.5, + keyEvents: [], + tags: ["factory"], + observed_at: "2026-06-18T00:00:00.000Z", + verification: "unverified", + }); + memory.recordEpisode({ + sessionId: "new", + task: "factory containers", + summary: "live receipt says 16 containers", + iterations: 1, + finalQuality: 1, + keyEvents: [], + tags: ["factory"], + observed_at: "2026-06-26T00:00:00.000Z", + verification: "verified", + receipt_id: "verify-cycle:live", + }); + + const [first] = memory.queryEpisodic({ tags: ["factory"] }); + expect(first.summary).toContain("16 containers"); + expect(first.receipt_id).toBe("verify-cycle:live"); + }); + + it("filters superseded memories from recall", () => { + const memory = new PersistentMemory(tmpStore()); + memory.recordEpisode({ + sessionId: "old", + task: "deploy route", + summary: "8098 is deploy authority", + iterations: 1, + finalQuality: 0.1, + keyEvents: [], + tags: ["deploy"], + status: "superseded", + }); + expect(memory.queryEpisodic({ tags: ["deploy"] })).toEqual([]); + }); +}); diff --git a/src/upgrades/persistent-memory.ts b/src/upgrades/persistent-memory.ts index ab188d9..a4f5df1 100644 --- a/src/upgrades/persistent-memory.ts +++ b/src/upgrades/persistent-memory.ts @@ -17,7 +17,7 @@ import type { LoopIteration, StateEntry } from "../core/types.js"; // ── Types ────────────────────────────────────────────────── -export interface EpisodicMemory { +export interface EpisodicMemory extends MemoryProvenance { /** Unique episode identifier */ episodeId: string; sessionId: string; @@ -37,7 +37,16 @@ export interface MemoryEvent { importance: number; // 0-1, for retrieval weighting } -export interface SemanticMemory { +export interface MemoryProvenance { + observed_at?: string; + source?: string; + verification?: "verified" | "unverified" | "superseded"; + receipt_id?: string; + supersedes_id?: string; + status?: "active" | "superseded"; +} + +export interface SemanticMemory extends MemoryProvenance { id: string; concept: string; content: string; @@ -48,7 +57,7 @@ export interface SemanticMemory { accessCount: number; } -export interface ProceduralMemory { +export interface ProceduralMemory extends MemoryProvenance { id: string; skillName: string; steps: string[]; @@ -100,9 +109,13 @@ export class PersistentMemory { /** Record an episode (a completed session or significant subtask) */ recordEpisode(episode: Omit): EpisodicMemory { + const timestamp = new Date().toISOString(); const full: EpisodicMemory = { episodeId: StateStore.uid(), - timestamp: new Date().toISOString(), + timestamp, + observed_at: timestamp, + verification: "unverified", + status: "active", ...episode, }; this.store.write("memory/episodic", `${full.episodeId}.json`, full); @@ -189,6 +202,12 @@ export class PersistentMemory { ]; existing.tags = [...new Set([...existing.tags, ...entry.tags])]; existing.lastAccessed = new Date().toISOString(); + existing.observed_at = entry.observed_at ?? existing.observed_at ?? existing.lastAccessed; + existing.source = entry.source ?? existing.source; + existing.receipt_id = entry.receipt_id ?? existing.receipt_id; + existing.supersedes_id = entry.supersedes_id ?? existing.supersedes_id; + existing.status = entry.status ?? existing.status ?? "active"; + existing.verification = entry.verification ?? existing.verification ?? "unverified"; this.store.write( "memory/semantic", `${existing.id}.json`, @@ -197,10 +216,14 @@ export class PersistentMemory { return existing; } + const timestamp = new Date().toISOString(); const full: SemanticMemory = { id: StateStore.uid(), - lastAccessed: new Date().toISOString(), + lastAccessed: timestamp, accessCount: 1, + observed_at: timestamp, + verification: entry.verification ?? "unverified", + status: "active", ...entry, }; this.store.write("memory/semantic", `${full.id}.json`, full); @@ -272,6 +295,9 @@ export class PersistentMemory { recordProcedural(entry: Omit): ProceduralMemory { const full: ProceduralMemory = { id: StateStore.uid(), + observed_at: new Date().toISOString(), + verification: "unverified", + status: "active", ...entry, }; this.store.write("memory/procedural", `${full.id}.json`, full); @@ -418,8 +444,15 @@ export class PersistentMemory { ); } - // Sort by recency (assuming items have timestamp or lastAccessed) - filtered.sort(() => 0); // stable sort + filtered = filtered + .filter((item: T & MemoryProvenance) => (item.status ?? "active") === "active") + .sort((a: T & MemoryProvenance, b: T & MemoryProvenance) => { + const receiptRank = (b.receipt_id ? 1 : 0) - (a.receipt_id ? 1 : 0); + if (receiptRank !== 0) return receiptRank; + const verifiedRank = (b.verification === "verified" ? 1 : 0) - (a.verification === "verified" ? 1 : 0); + if (verifiedRank !== 0) return verifiedRank; + return Date.parse(b.observed_at ?? "") - Date.parse(a.observed_at ?? ""); + }); if (query.limit) { filtered = filtered.slice(0, query.limit);