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" ;
34import path from "node:path" ;
5+ import { extractErrorCode } from "openclaw/plugin-sdk/error-runtime" ;
46import {
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
36147export 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