fix: rank receipt-backed memory first
This commit is contained in:
parent
975ef28b15
commit
3301a6be1e
|
|
@ -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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -17,7 +17,7 @@ import type { LoopIteration, StateEntry } from "../core/types.js";
|
||||||
|
|
||||||
// ── Types ──────────────────────────────────────────────────
|
// ── Types ──────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface EpisodicMemory {
|
export interface EpisodicMemory extends MemoryProvenance {
|
||||||
/** Unique episode identifier */
|
/** Unique episode identifier */
|
||||||
episodeId: string;
|
episodeId: string;
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
|
|
@ -37,7 +37,16 @@ export interface MemoryEvent {
|
||||||
importance: number; // 0-1, for retrieval weighting
|
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;
|
id: string;
|
||||||
concept: string;
|
concept: string;
|
||||||
content: string;
|
content: string;
|
||||||
|
|
@ -48,7 +57,7 @@ export interface SemanticMemory {
|
||||||
accessCount: number;
|
accessCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProceduralMemory {
|
export interface ProceduralMemory extends MemoryProvenance {
|
||||||
id: string;
|
id: string;
|
||||||
skillName: string;
|
skillName: string;
|
||||||
steps: string[];
|
steps: string[];
|
||||||
|
|
@ -100,9 +109,13 @@ export class PersistentMemory {
|
||||||
|
|
||||||
/** Record an episode (a completed session or significant subtask) */
|
/** Record an episode (a completed session or significant subtask) */
|
||||||
recordEpisode(episode: Omit<EpisodicMemory, "episodeId" | "timestamp">): EpisodicMemory {
|
recordEpisode(episode: Omit<EpisodicMemory, "episodeId" | "timestamp">): EpisodicMemory {
|
||||||
|
const timestamp = new Date().toISOString();
|
||||||
const full: EpisodicMemory = {
|
const full: EpisodicMemory = {
|
||||||
episodeId: StateStore.uid(),
|
episodeId: StateStore.uid(),
|
||||||
timestamp: new Date().toISOString(),
|
timestamp,
|
||||||
|
observed_at: timestamp,
|
||||||
|
verification: "unverified",
|
||||||
|
status: "active",
|
||||||
...episode,
|
...episode,
|
||||||
};
|
};
|
||||||
this.store.write("memory/episodic", `${full.episodeId}.json`, full);
|
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.tags = [...new Set([...existing.tags, ...entry.tags])];
|
||||||
existing.lastAccessed = new Date().toISOString();
|
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(
|
this.store.write(
|
||||||
"memory/semantic",
|
"memory/semantic",
|
||||||
`${existing.id}.json`,
|
`${existing.id}.json`,
|
||||||
|
|
@ -197,10 +216,14 @@ export class PersistentMemory {
|
||||||
return existing;
|
return existing;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const timestamp = new Date().toISOString();
|
||||||
const full: SemanticMemory = {
|
const full: SemanticMemory = {
|
||||||
id: StateStore.uid(),
|
id: StateStore.uid(),
|
||||||
lastAccessed: new Date().toISOString(),
|
lastAccessed: timestamp,
|
||||||
accessCount: 1,
|
accessCount: 1,
|
||||||
|
observed_at: timestamp,
|
||||||
|
verification: entry.verification ?? "unverified",
|
||||||
|
status: "active",
|
||||||
...entry,
|
...entry,
|
||||||
};
|
};
|
||||||
this.store.write("memory/semantic", `${full.id}.json`, full);
|
this.store.write("memory/semantic", `${full.id}.json`, full);
|
||||||
|
|
@ -272,6 +295,9 @@ export class PersistentMemory {
|
||||||
recordProcedural(entry: Omit<ProceduralMemory, "id">): ProceduralMemory {
|
recordProcedural(entry: Omit<ProceduralMemory, "id">): ProceduralMemory {
|
||||||
const full: ProceduralMemory = {
|
const full: ProceduralMemory = {
|
||||||
id: StateStore.uid(),
|
id: StateStore.uid(),
|
||||||
|
observed_at: new Date().toISOString(),
|
||||||
|
verification: "unverified",
|
||||||
|
status: "active",
|
||||||
...entry,
|
...entry,
|
||||||
};
|
};
|
||||||
this.store.write("memory/procedural", `${full.id}.json`, full);
|
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 = filtered
|
||||||
filtered.sort(() => 0); // stable sort
|
.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) {
|
if (query.limit) {
|
||||||
filtered = filtered.slice(0, query.limit);
|
filtered = filtered.slice(0, query.limit);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue