mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-12 21:26:08 +00:00
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 head9fa7d20a96. - Required merge gates passed before the squash merge. Prepared head SHA:9fa7d20a96Review: https://github.com/openclaw/openclaw/pull/97723#issuecomment-4831902831 Co-authored-by: momothemage <niuzhengnan@163.com> Approved-by: momothemage
177 lines
5.7 KiB
TypeScript
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);
|
|
}
|