Skip to content

Commit 5b095b1

Browse files
steipeteekinnee
andcommitted
fix(dreaming): authenticate heartbeat transcript turns
Co-authored-by: Erick Kinnee <[email protected]>
1 parent 939e19f commit 5b095b1

13 files changed

Lines changed: 225 additions & 1060 deletions

extensions/memory-core/src/dreaming-repair-utils.ts

Lines changed: 0 additions & 426 deletions
This file was deleted.

extensions/memory-core/src/dreaming-repair.test.ts

Lines changed: 3 additions & 419 deletions
Large diffs are not rendered by default.
Lines changed: 138 additions & 148 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,148 @@
1-
import fs from "node:fs/promises";
21
// Memory Core plugin module implements dreaming repair behavior.
2+
import { randomUUID } from "node:crypto";
3+
import fs from "node:fs/promises";
34
import path from "node:path";
5+
import { extractErrorCode } from "openclaw/plugin-sdk/error-runtime";
46
import {
5-
requireAbsoluteWorkspaceDir,
6-
resolveExistingDreamsPath,
7-
listSessionCorpusFiles,
8-
isSuspiciousSessionCorpusLine,
9-
findHeartbeatContaminatedCorpusLines,
10-
hasSelfIngestedSessionCorpusLines,
11-
buildArchiveTimestamp,
12-
moveToArchive,
13-
clearSessionIngestionState,
14-
SESSION_CORPUS_RELATIVE_DIR,
15-
SESSION_INGESTION_RELATIVE_PATH,
16-
REPAIR_ARCHIVE_RELATIVE_DIR,
17-
} from "./dreaming-repair-utils.js";
18-
import type {
19-
DreamingArtifactsAuditIssue,
20-
DreamingArtifactsAuditSummary,
21-
HeartbeatContaminatedCorpusLine,
22-
RepairDreamingArtifactsResult,
23-
} from "./dreaming-repair-utils.js";
24-
import {
7+
clearMemoryCoreWorkspaceNamespace,
258
DREAMING_SESSION_INGESTION_FILES_NAMESPACE,
269
DREAMING_SESSION_INGESTION_SEEN_NAMESPACE,
2710
readMemoryCoreWorkspaceEntries,
2811
} from "./dreaming-state.js";
2912

30-
// Re-export types for CLI consumers.
31-
export type {
32-
DreamingArtifactsAuditSummary,
33-
RepairDreamingArtifactsResult,
34-
} from "./dreaming-repair-utils.js";
13+
type DreamingArtifactsAuditIssue = {
14+
severity: "warn" | "error";
15+
code:
16+
| "dreaming-session-corpus-unreadable"
17+
| "dreaming-session-corpus-self-ingested"
18+
| "dreaming-session-ingestion-unreadable"
19+
| "dreaming-diary-unreadable";
20+
message: string;
21+
fixable: boolean;
22+
};
23+
24+
export type DreamingArtifactsAuditSummary = {
25+
dreamsPath?: string;
26+
sessionCorpusDir: string;
27+
sessionCorpusFileCount: number;
28+
suspiciousSessionCorpusFileCount: number;
29+
suspiciousSessionCorpusLineCount: number;
30+
sessionIngestionPath: string;
31+
sessionIngestionExists: boolean;
32+
issues: DreamingArtifactsAuditIssue[];
33+
};
34+
35+
export type RepairDreamingArtifactsResult = {
36+
changed: boolean;
37+
archiveDir?: string;
38+
archivedDreamsDiary: boolean;
39+
archivedSessionCorpus: boolean;
40+
archivedSessionIngestion: boolean;
41+
archivedPaths: string[];
42+
warnings: string[];
43+
};
44+
45+
const DREAMS_FILENAMES = ["DREAMS.md", "dreams.md"] as const;
46+
const SESSION_CORPUS_RELATIVE_DIR = path.join("memory", ".dreams", "session-corpus");
47+
const SESSION_INGESTION_RELATIVE_PATH = path.join("memory", ".dreams", "session-ingestion.json");
48+
const REPAIR_ARCHIVE_RELATIVE_DIR = path.join(".openclaw-repair", "dreaming");
49+
const DREAMING_NARRATIVE_RUN_PREFIX = "dreaming-narrative-";
50+
const DREAMING_NARRATIVE_PROMPT_PREFIX = "Write a dream diary entry from these memory fragments";
51+
52+
function requireAbsoluteWorkspaceDir(rawWorkspaceDir: string): string {
53+
const trimmed = rawWorkspaceDir.trim();
54+
if (!trimmed) {
55+
throw new Error("workspaceDir is required");
56+
}
57+
if (!path.isAbsolute(trimmed)) {
58+
throw new Error("workspaceDir must be an absolute path");
59+
}
60+
return path.resolve(trimmed);
61+
}
62+
63+
async function resolveExistingDreamsPath(workspaceDir: string): Promise<string | undefined> {
64+
for (const fileName of DREAMS_FILENAMES) {
65+
const candidate = path.join(workspaceDir, fileName);
66+
try {
67+
await fs.access(candidate);
68+
return candidate;
69+
} catch (err) {
70+
if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
71+
throw err;
72+
}
73+
}
74+
}
75+
return undefined;
76+
}
77+
78+
async function listSessionCorpusFiles(sessionCorpusDir: string): Promise<string[]> {
79+
const entries = await fs.readdir(sessionCorpusDir, { withFileTypes: true });
80+
return entries
81+
.filter((entry) => entry.isFile() && entry.name.endsWith(".txt"))
82+
.map((entry) => path.join(sessionCorpusDir, entry.name))
83+
.toSorted();
84+
}
85+
86+
function isSuspiciousSessionCorpusLine(line: string): boolean {
87+
return (
88+
line.includes(DREAMING_NARRATIVE_PROMPT_PREFIX) &&
89+
(line.includes(DREAMING_NARRATIVE_RUN_PREFIX) || line.includes("dreaming-narrative-"))
90+
);
91+
}
92+
93+
function buildArchiveTimestamp(now: Date): string {
94+
return now.toISOString().replace(/[:.]/g, "-");
95+
}
96+
97+
async function ensureArchivablePath(targetPath: string): Promise<"file" | "dir" | null> {
98+
const stat = await fs.lstat(targetPath).catch((err: unknown) => {
99+
if (extractErrorCode(err) === "ENOENT") {
100+
return null;
101+
}
102+
throw err;
103+
});
104+
if (!stat) {
105+
return null;
106+
}
107+
if (stat.isSymbolicLink()) {
108+
throw new Error(`Refusing to archive symlinked path: ${targetPath}`);
109+
}
110+
if (stat.isDirectory()) {
111+
return "dir";
112+
}
113+
if (stat.isFile()) {
114+
return "file";
115+
}
116+
throw new Error(`Refusing to archive non-file artifact: ${targetPath}`);
117+
}
118+
119+
async function moveToArchive(params: {
120+
targetPath: string;
121+
archiveDir: string;
122+
}): Promise<string | null> {
123+
const kind = await ensureArchivablePath(params.targetPath);
124+
if (!kind) {
125+
return null;
126+
}
127+
await fs.mkdir(params.archiveDir, { recursive: true });
128+
const baseName = path.basename(params.targetPath);
129+
const destination = path.join(params.archiveDir, `${baseName}.${randomUUID()}`);
130+
await fs.rename(params.targetPath, destination);
131+
return destination;
132+
}
133+
134+
async function clearSessionIngestionState(workspaceDir: string): Promise<void> {
135+
await Promise.all([
136+
clearMemoryCoreWorkspaceNamespace({
137+
namespace: DREAMING_SESSION_INGESTION_FILES_NAMESPACE,
138+
workspaceDir,
139+
}),
140+
clearMemoryCoreWorkspaceNamespace({
141+
namespace: DREAMING_SESSION_INGESTION_SEEN_NAMESPACE,
142+
workspaceDir,
143+
}),
144+
]);
145+
}
35146

36147
export async function auditDreamingArtifacts(params: {
37148
workspaceDir: string;
@@ -42,8 +153,6 @@ export async function auditDreamingArtifacts(params: {
42153
const sessionIngestionPath = path.join(workspaceDir, SESSION_INGESTION_RELATIVE_PATH);
43154
const issues: DreamingArtifactsAuditIssue[] = [];
44155
let sessionCorpusFileCount = 0;
45-
let heartbeatContaminatedSessionCorpusFileCount = 0;
46-
let heartbeatContaminatedSessionCorpusLineCount = 0;
47156
let suspiciousSessionCorpusFileCount = 0;
48157
let suspiciousSessionCorpusLineCount = 0;
49158
let sessionIngestionExists = false;
@@ -124,32 +233,6 @@ export async function auditDreamingArtifacts(params: {
124233
}
125234
}
126235

127-
try {
128-
const heartbeatContaminated = await findHeartbeatContaminatedCorpusLines(workspaceDir);
129-
heartbeatContaminatedSessionCorpusLineCount = heartbeatContaminated.length;
130-
heartbeatContaminatedSessionCorpusFileCount = new Set(
131-
heartbeatContaminated.map((entry) => entry.filePath),
132-
).size;
133-
} catch (err) {
134-
if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
135-
issues.push({
136-
severity: "error",
137-
code: "dreaming-session-corpus-unreadable",
138-
message: `Dreaming heartbeat-derived corpus audit failed: ${(err as NodeJS.ErrnoException).code ?? "error"}.`,
139-
fixable: false,
140-
});
141-
}
142-
}
143-
144-
if (heartbeatContaminatedSessionCorpusLineCount > 0) {
145-
issues.push({
146-
severity: "warn",
147-
code: "dreaming-session-corpus-heartbeat-derived",
148-
message: `Dreaming session corpus contains heartbeat-derived assistant entries (${heartbeatContaminatedSessionCorpusLineCount} line${heartbeatContaminatedSessionCorpusLineCount === 1 ? "" : "s"}).`,
149-
fixable: true,
150-
});
151-
}
152-
153236
if (suspiciousSessionCorpusLineCount > 0) {
154237
issues.push({
155238
severity: "warn",
@@ -163,12 +246,6 @@ export async function auditDreamingArtifacts(params: {
163246
...(dreamsPath ? { dreamsPath } : {}),
164247
sessionCorpusDir,
165248
sessionCorpusFileCount,
166-
...(heartbeatContaminatedSessionCorpusFileCount > 0
167-
? { heartbeatContaminatedSessionCorpusFileCount }
168-
: {}),
169-
...(heartbeatContaminatedSessionCorpusLineCount > 0
170-
? { heartbeatContaminatedSessionCorpusLineCount }
171-
: {}),
172249
suspiciousSessionCorpusFileCount,
173250
suspiciousSessionCorpusLineCount,
174251
sessionIngestionPath,
@@ -189,8 +266,6 @@ export async function repairDreamingArtifacts(params: {
189266
let archivedDreamsDiary = false;
190267
let archivedSessionCorpus = false;
191268
let archivedSessionIngestion = false;
192-
let removedHeartbeatDerivedLines = 0;
193-
let clearedSessionCheckpointKeys: number | undefined;
194269

195270
const ensureArchiveDir = () => {
196271
archiveDir ??= path.join(
@@ -210,84 +285,6 @@ export async function repairDreamingArtifacts(params: {
210285
}
211286
};
212287

213-
const heartbeatContaminated = await findHeartbeatContaminatedCorpusLines(workspaceDir).catch(
214-
(err: unknown) => {
215-
warnings.push(
216-
`Failed auditing heartbeat-derived session corpus artifacts: ${
217-
err instanceof Error ? err.message : String(err)
218-
}`,
219-
);
220-
return [] as HeartbeatContaminatedCorpusLine[];
221-
},
222-
);
223-
if (heartbeatContaminated.length > 0) {
224-
removedHeartbeatDerivedLines = heartbeatContaminated.length;
225-
226-
const sessionCorpusDestination = await archivePathIfPresent(
227-
path.join(workspaceDir, SESSION_CORPUS_RELATIVE_DIR),
228-
);
229-
if (sessionCorpusDestination) {
230-
archivedSessionCorpus = true;
231-
archivedPaths.push(sessionCorpusDestination);
232-
}
233-
234-
const sessionIngestionDestination = await archivePathIfPresent(
235-
path.join(workspaceDir, SESSION_INGESTION_RELATIVE_PATH),
236-
);
237-
if (sessionIngestionDestination) {
238-
archivedSessionIngestion = true;
239-
archivedPaths.push(sessionIngestionDestination);
240-
}
241-
242-
await clearSessionIngestionState(workspaceDir)
243-
.then(() => {
244-
clearedSessionCheckpointKeys = 2;
245-
})
246-
.catch((err: unknown) => {
247-
warnings.push(
248-
`Failed clearing dreaming session-ingestion SQLite state: ${
249-
err instanceof Error ? err.message : String(err)
250-
}`,
251-
);
252-
});
253-
}
254-
255-
const shouldArchiveDerivedArtifacts = await hasSelfIngestedSessionCorpusLines(workspaceDir).catch(
256-
(err: unknown) => {
257-
warnings.push(
258-
`Failed auditing self-ingested session corpus artifacts: ${
259-
err instanceof Error ? err.message : String(err)
260-
}`,
261-
);
262-
return false;
263-
},
264-
);
265-
266-
if (!shouldArchiveDerivedArtifacts) {
267-
if (params.archiveDiary) {
268-
const dreamsPath = await resolveExistingDreamsPath(workspaceDir);
269-
if (dreamsPath) {
270-
const dreamsDestination = await archivePathIfPresent(dreamsPath);
271-
if (dreamsDestination) {
272-
archivedDreamsDiary = true;
273-
archivedPaths.push(dreamsDestination);
274-
}
275-
}
276-
}
277-
278-
return {
279-
changed: archivedDreamsDiary || removedHeartbeatDerivedLines > 0,
280-
...(archiveDir ? { archiveDir } : {}),
281-
archivedDreamsDiary,
282-
archivedSessionCorpus,
283-
archivedSessionIngestion,
284-
archivedPaths,
285-
warnings,
286-
...(removedHeartbeatDerivedLines > 0 ? { removedHeartbeatDerivedLines } : {}),
287-
...(clearedSessionCheckpointKeys !== undefined ? { clearedSessionCheckpointKeys } : {}),
288-
};
289-
}
290-
291288
const sessionCorpusDestination = await archivePathIfPresent(
292289
path.join(workspaceDir, SESSION_CORPUS_RELATIVE_DIR),
293290
);
@@ -307,7 +304,6 @@ export async function repairDreamingArtifacts(params: {
307304
if (sessionCorpusDestination || sessionIngestionDestination) {
308305
try {
309306
await clearSessionIngestionState(workspaceDir);
310-
clearedSessionCheckpointKeys = 2;
311307
} catch (err) {
312308
warnings.push(
313309
`Failed clearing dreaming session-ingestion SQLite state: ${
@@ -328,20 +324,14 @@ export async function repairDreamingArtifacts(params: {
328324
}
329325
}
330326

331-
const changed =
332-
archivedDreamsDiary ||
333-
archivedSessionCorpus ||
334-
archivedSessionIngestion ||
335-
removedHeartbeatDerivedLines > 0;
327+
const changed = archivedDreamsDiary || archivedSessionCorpus || archivedSessionIngestion;
336328
return {
337329
changed,
338330
...(archiveDir ? { archiveDir } : {}),
339331
archivedDreamsDiary,
340332
archivedSessionCorpus,
341333
archivedSessionIngestion,
342334
archivedPaths,
343-
...(removedHeartbeatDerivedLines > 0 ? { removedHeartbeatDerivedLines } : {}),
344-
...(clearedSessionCheckpointKeys !== undefined ? { clearedSessionCheckpointKeys } : {}),
345335
warnings,
346336
};
347337
}

0 commit comments

Comments
 (0)