From efca4b7e64c286af0205368ca9088af55556d0cd Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 14 Jun 2026 01:00:03 +0800 Subject: [PATCH] fix(memory): keep skipped recall diagnostics opt-in Follow-up to #92745 after maintainer autoreview found that the skipped recall event widened the shipped MemoryHostEvent union and changed limited legacy reads. Keep readMemoryHostEvents() source-compatible by filtering diagnostic records before applying limits, and expose skipped recall diagnostics through the opt-in MemoryHostEventRecord/readMemoryHostEventRecords path. Original skipped-recall behavior landed in #92745 by @mushuiyu886. --- .../memory-core/src/memory-events.test.ts | 7 +- src/memory-host-sdk/events.ts | 66 ++++++++++++++---- src/plugin-sdk/memory-host-events.test.ts | 67 +++++++++++++++++++ 3 files changed, 126 insertions(+), 14 deletions(-) diff --git a/extensions/memory-core/src/memory-events.test.ts b/extensions/memory-core/src/memory-events.test.ts index 3dc8305721e7..32e85219a4d8 100644 --- a/extensions/memory-core/src/memory-events.test.ts +++ b/extensions/memory-core/src/memory-events.test.ts @@ -1,7 +1,10 @@ // Memory Core tests cover memory events plugin behavior. import fs from "node:fs/promises"; import path from "node:path"; -import { readMemoryHostEvents } from "openclaw/plugin-sdk/memory-host-events"; +import { + readMemoryHostEventRecords, + readMemoryHostEvents, +} from "openclaw/plugin-sdk/memory-host-events"; import { describe, expect, it } from "vitest"; import { writeDailyDreamingPhaseBlock } from "./dreaming-markdown.js"; import { @@ -135,7 +138,7 @@ describe("memory host event journal integration", () => { minUniqueQueries: 0, nowMs: Date.UTC(2026, 5, 13, 9, 5, 0), }); - const events = await readMemoryHostEvents({ workspaceDir }); + const events = await readMemoryHostEventRecords({ workspaceDir }); expect(candidates).toEqual([]); expect(events.map((event) => event.type)).toEqual(["memory.recall.skipped"]); diff --git a/src/memory-host-sdk/events.ts b/src/memory-host-sdk/events.ts index cb04eb4202ab..88ba020a7ccb 100644 --- a/src/memory-host-sdk/events.ts +++ b/src/memory-host-sdk/events.ts @@ -68,10 +68,12 @@ export type MemoryHostDreamCompletedEvent = { /** Append-only memory host event schema stored as JSONL. */ export type MemoryHostEvent = | MemoryHostRecallRecordedEvent - | MemoryHostRecallSkippedEvent | MemoryHostPromotionAppliedEvent | MemoryHostDreamCompletedEvent; +/** Full event-log record schema, including opt-in diagnostic variants. */ +export type MemoryHostEventRecord = MemoryHostEvent | MemoryHostRecallSkippedEvent; + /** Resolve the event log path inside a workspace without touching the filesystem. */ export function resolveMemoryHostEventLogPath(workspaceDir: string): string { return path.join(workspaceDir, MEMORY_HOST_EVENT_LOG_RELATIVE_PATH); @@ -80,7 +82,7 @@ export function resolveMemoryHostEventLogPath(workspaceDir: string): string { /** Append one memory host event, creating the dreams directory with symlink-safe writes. */ export async function appendMemoryHostEvent( workspaceDir: string, - event: MemoryHostEvent, + event: MemoryHostEventRecord, ): Promise { const eventLogPath = resolveMemoryHostEventLogPath(workspaceDir); await fs.mkdir(path.dirname(eventLogPath), { recursive: true }); @@ -91,11 +93,28 @@ export async function appendMemoryHostEvent( }); } -/** Read recent memory host events, ignoring corrupt JSONL lines left by partial writes. */ -export async function readMemoryHostEvents(params: { +function parseMemoryHostEventRecord(line: string): MemoryHostEventRecord | null { + try { + const record = JSON.parse(line) as MemoryHostEventRecord; + if ( + record.type === "memory.recall.recorded" || + record.type === "memory.recall.skipped" || + record.type === "memory.promotion.applied" || + record.type === "memory.dream.completed" + ) { + return record; + } + } catch { + // The log is best-effort diagnostics; one malformed line must not hide + // later valid events or break memory status rendering. + } + return null; +} + +async function readMemoryHostEventRecordsRaw(params: { workspaceDir: string; limit?: number; -}): Promise { +}): Promise { const eventLogPath = resolveMemoryHostEventLogPath(params.workspaceDir); const raw = await fs.readFile(eventLogPath, "utf8").catch((err: unknown) => { if ((err as NodeJS.ErrnoException)?.code === "ENOENT") { @@ -111,13 +130,8 @@ export async function readMemoryHostEvents(params: { .map((line) => line.trim()) .filter(Boolean) .flatMap((line) => { - try { - return [JSON.parse(line) as MemoryHostEvent]; - } catch { - // The log is best-effort diagnostics; one malformed line must not hide - // later valid events or break memory status rendering. - return []; - } + const record = parseMemoryHostEventRecord(line); + return record ? [record] : []; }); if (!Number.isFinite(params.limit)) { return events; @@ -125,3 +139,31 @@ export async function readMemoryHostEvents(params: { const limit = Math.max(0, Math.floor(params.limit as number)); return limit === 0 ? [] : events.slice(-limit); } + +function applyMemoryHostEventLimit(events: T[], limit: number | undefined): T[] { + if (!Number.isFinite(limit)) { + return events; + } + const normalizedLimit = Math.max(0, Math.floor(limit as number)); + return normalizedLimit === 0 ? [] : events.slice(-normalizedLimit); +} + +/** Read recent memory host events, ignoring corrupt JSONL lines left by partial writes. */ +export async function readMemoryHostEvents(params: { + workspaceDir: string; + limit?: number; +}): Promise { + const events = await readMemoryHostEventRecordsRaw({ workspaceDir: params.workspaceDir }); + const legacyEvents = events.filter( + (event): event is MemoryHostEvent => event.type !== "memory.recall.skipped", + ); + return applyMemoryHostEventLimit(legacyEvents, params.limit); +} + +/** Read recent memory host event records, including opt-in diagnostic variants. */ +export async function readMemoryHostEventRecords(params: { + workspaceDir: string; + limit?: number; +}): Promise { + return await readMemoryHostEventRecordsRaw(params); +} diff --git a/src/plugin-sdk/memory-host-events.test.ts b/src/plugin-sdk/memory-host-events.test.ts index a168dce5c8eb..2efacb43e021 100644 --- a/src/plugin-sdk/memory-host-events.test.ts +++ b/src/plugin-sdk/memory-host-events.test.ts @@ -7,6 +7,7 @@ import { afterEach, describe, expect, it } from "vitest"; import { resetPluginStateStoreForTests } from "../plugin-state/plugin-state-store.js"; import { appendMemoryHostEvent, + readMemoryHostEventRecords, readMemoryHostEvents, resolveMemoryHostEventLogPath, } from "./memory-host-events.js"; @@ -76,6 +77,72 @@ describe("memory host event journal helpers", () => { expect(tail).toHaveLength(1); expect(tail[0]?.type).toBe("memory.dream.completed"); }); + + it("keeps legacy event readers stable when diagnostic records are present", async () => { + const workspaceDir = await createTempDir("memory-host-events-diagnostics-"); + + await appendMemoryHostEvent(workspaceDir, { + type: "memory.recall.skipped", + timestamp: "2026-04-05T12:00:00.000Z", + query: "durable memory", + reason: "non-short-term-memory-path", + eligibleResultCount: 0, + skippedResultCount: 1, + results: [ + { + path: "MEMORY.md", + startLine: 3, + endLine: 3, + score: 0.9, + reason: "non-short-term-memory-path", + }, + ], + }); + + await appendMemoryHostEvent(workspaceDir, { + type: "memory.recall.recorded", + timestamp: "2026-04-05T12:05:00.000Z", + query: "daily memory", + resultCount: 1, + results: [ + { + path: "memory/2026-04-05.md", + startLine: 1, + endLine: 3, + score: 0.95, + }, + ], + }); + await appendMemoryHostEvent(workspaceDir, { + type: "memory.recall.skipped", + timestamp: "2026-04-05T12:10:00.000Z", + query: "durable memory again", + reason: "non-short-term-memory-path", + eligibleResultCount: 1, + skippedResultCount: 1, + results: [ + { + path: "MEMORY.md", + startLine: 4, + endLine: 4, + score: 0.8, + reason: "non-short-term-memory-path", + }, + ], + }); + + const legacyEvents = await readMemoryHostEvents({ workspaceDir }); + const legacyTail = await readMemoryHostEvents({ workspaceDir, limit: 1 }); + const records = await readMemoryHostEventRecords({ workspaceDir }); + + expect(legacyEvents.map((event) => event.type)).toEqual(["memory.recall.recorded"]); + expect(legacyTail.map((event) => event.type)).toEqual(["memory.recall.recorded"]); + expect(records.map((event) => event.type)).toEqual([ + "memory.recall.skipped", + "memory.recall.recorded", + "memory.recall.skipped", + ]); + }); }); describe("createPersistentDedupe", () => {