Files
openclaw/src/memory-host-sdk/events.ts
Momo db2488b6e3 fix(memory): record structured dreaming outcomes (#97723)
Summary:
- Merged fix(memory): record structured dreaming outcomes after ClawSweeper review.

Automerge notes:
- PR branch already contained follow-up commit before automerge: chore(plugin-sdk): update surface budgets

Validation:
- ClawSweeper review passed for head 9fa7d20a96.
- Required merge gates passed before the squash merge.

Prepared head SHA: 9fa7d20a96
Review: https://github.com/openclaw/openclaw/pull/97723#issuecomment-4831902831

Co-authored-by: momothemage <niuzhengnan@163.com>
Approved-by: momothemage
2026-06-29 11:23:38 +00:00

177 lines
5.7 KiB
TypeScript

// Memory host event helpers append and read memory host event logs.
import fs from "node:fs/promises";
import path from "node:path";
import { appendRegularFile } from "../infra/fs-safe.js";
import type { MemoryDreamingPhaseName } from "./dreaming.js";
/** Workspace-relative JSONL audit log for memory recall, promotion, and dream events. */
export const MEMORY_HOST_EVENT_LOG_RELATIVE_PATH = path.join("memory", ".dreams", "events.jsonl");
/** Event emitted when a recall query records the selected memory snippets. */
export type MemoryHostRecallRecordedEvent = {
type: "memory.recall.recorded";
timestamp: string;
query: string;
resultCount: number;
results: Array<{
path: string;
startLine: number;
endLine: number;
score: number;
}>;
};
/** Event emitted when recall hits are visible but excluded from short-term promotion. */
export type MemoryHostRecallSkippedEvent = {
type: "memory.recall.skipped";
timestamp: string;
query: string;
reason: "non-short-term-memory-path";
eligibleResultCount: number;
skippedResultCount: number;
results: Array<{
path: string;
startLine: number;
endLine: number;
score: number;
reason: "non-short-term-memory-path";
}>;
};
/** Event emitted when deep-dream candidates are promoted into durable memory. */
export type MemoryHostPromotionAppliedEvent = {
type: "memory.promotion.applied";
timestamp: string;
memoryPath: string;
applied: number;
candidates: Array<{
key: string;
path: string;
startLine: number;
endLine: number;
score: number;
recallCount: number;
}>;
};
/** Normalized outcome for a dreaming phase run. */
export type MemoryDreamOutcome = "completed" | "failed";
/** Event emitted after a dreaming phase writes inline memory and/or reports. */
export type MemoryHostDreamCompletedEvent = {
type: "memory.dream.completed";
timestamp: string;
phase: MemoryDreamingPhaseName;
/** Missing on older event logs; readers should treat absent as "completed". */
outcome?: MemoryDreamOutcome;
/** Error detail when outcome is "failed". */
error?: string;
inlinePath?: string;
reportPath?: string;
lineCount: number;
storageMode: "inline" | "separate" | "both";
};
/** Append-only memory host event schema stored as JSONL. */
export type MemoryHostEvent =
| MemoryHostRecallRecordedEvent
| 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);
}
/** Append one memory host event, creating the dreams directory with symlink-safe writes. */
export async function appendMemoryHostEvent(
workspaceDir: string,
event: MemoryHostEventRecord,
): Promise<void> {
const eventLogPath = resolveMemoryHostEventLogPath(workspaceDir);
await fs.mkdir(path.dirname(eventLogPath), { recursive: true });
await appendRegularFile({
filePath: eventLogPath,
content: `${JSON.stringify(event)}\n`,
rejectSymlinkParents: true,
});
}
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<MemoryHostEventRecord[]> {
const eventLogPath = resolveMemoryHostEventLogPath(params.workspaceDir);
const raw = await fs.readFile(eventLogPath, "utf8").catch((err: unknown) => {
if ((err as NodeJS.ErrnoException)?.code === "ENOENT") {
return "";
}
throw err;
});
if (!raw.trim()) {
return [];
}
const events = raw
.split("\n")
.map((line) => line.trim())
.filter(Boolean)
.flatMap((line) => {
const record = parseMemoryHostEventRecord(line);
return record ? [record] : [];
});
if (!Number.isFinite(params.limit)) {
return events;
}
const limit = Math.max(0, Math.floor(params.limit as number));
return limit === 0 ? [] : events.slice(-limit);
}
function applyMemoryHostEventLimit<T>(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<MemoryHostEvent[]> {
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<MemoryHostEventRecord[]> {
return await readMemoryHostEventRecordsRaw(params);
}