@openbmb/clawxrouter 1.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,403 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import type { PrivacyConfig } from "./types.js";
4
+ import { desensitizeWithLocalModel } from "./local-model.js";
5
+ import { redactSensitiveInfo } from "./utils.js";
6
+
7
+ export const GUARD_SECTION_BEGIN = "<!-- clawxrouter:guard-begin -->";
8
+ export const GUARD_SECTION_END = "<!-- clawxrouter:guard-end -->";
9
+
10
+ export class MemoryIsolationManager {
11
+ private workspaceDir: string;
12
+
13
+ constructor(workspaceDir: string = "~/.openclaw/workspace") {
14
+ // Expand ~ to home directory
15
+ this.workspaceDir = workspaceDir.startsWith("~")
16
+ ? path.join(process.env.HOME || process.env.USERPROFILE || "~", workspaceDir.slice(2))
17
+ : workspaceDir;
18
+ }
19
+
20
+ /**
21
+ * Get memory directory path based on model type and content type
22
+ */
23
+ getMemoryDir(isCloudModel: boolean): string {
24
+ const memoryType = isCloudModel ? "memory" : "memory-full";
25
+ return path.join(this.workspaceDir, memoryType);
26
+ }
27
+
28
+ /**
29
+ * Get MEMORY.md path based on model type
30
+ */
31
+ getMemoryFilePath(isCloudModel: boolean): string {
32
+ if (isCloudModel) {
33
+ // Cloud models use the standard MEMORY.md
34
+ return path.join(this.workspaceDir, "MEMORY.md");
35
+ } else {
36
+ // Local models can access the full memory
37
+ return path.join(this.workspaceDir, "MEMORY-FULL.md");
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Get daily memory file path
43
+ */
44
+ getDailyMemoryPath(isCloudModel: boolean, date?: Date): string {
45
+ const memoryDir = this.getMemoryDir(isCloudModel);
46
+ const today = date ?? new Date();
47
+ const dateStr = today.toISOString().split("T")[0]; // YYYY-MM-DD
48
+ return path.join(memoryDir, `${dateStr}.md`);
49
+ }
50
+
51
+ /**
52
+ * Write to memory file
53
+ */
54
+ async writeMemory(
55
+ content: string,
56
+ isCloudModel: boolean,
57
+ options?: { append?: boolean; daily?: boolean },
58
+ ): Promise<void> {
59
+ try {
60
+ const filePath = options?.daily
61
+ ? this.getDailyMemoryPath(isCloudModel)
62
+ : this.getMemoryFilePath(isCloudModel);
63
+
64
+ // Ensure directory exists
65
+ const dir = path.dirname(filePath);
66
+ await fs.promises.mkdir(dir, { recursive: true });
67
+
68
+ // Write or append
69
+ if (options?.append) {
70
+ await fs.promises.appendFile(filePath, content, "utf-8");
71
+ } else {
72
+ await fs.promises.writeFile(filePath, content, "utf-8");
73
+ }
74
+ } catch (err) {
75
+ console.error(`[ClawXrouter] Failed to write memory (cloud=${isCloudModel}):`, err);
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Read from memory file
81
+ */
82
+ async readMemory(
83
+ isCloudModel: boolean,
84
+ options?: { daily?: boolean; date?: Date },
85
+ ): Promise<string> {
86
+ try {
87
+ const filePath = options?.daily
88
+ ? this.getDailyMemoryPath(isCloudModel, options.date)
89
+ : this.getMemoryFilePath(isCloudModel);
90
+
91
+ if (!fs.existsSync(filePath)) {
92
+ return "";
93
+ }
94
+
95
+ return await fs.promises.readFile(filePath, "utf-8");
96
+ } catch (err) {
97
+ console.error(`[ClawXrouter] Failed to read memory (cloud=${isCloudModel}):`, err);
98
+ return "";
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Merge clean memory content into full memory so FULL is always the superset.
104
+ * Cloud models write to MEMORY.md; this step captures those additions into
105
+ * MEMORY-FULL.md before we sanitize back. Lines already present in FULL
106
+ * (by trimmed content) are skipped to avoid duplicates.
107
+ */
108
+ async mergeCleanIntoFull(options?: { daily?: boolean; date?: Date }): Promise<number> {
109
+ try {
110
+ const cleanContent = await this.readMemory(true, options);
111
+ const fullContent = await this.readMemory(false, options);
112
+
113
+ if (!cleanContent.trim()) {
114
+ return 0;
115
+ }
116
+
117
+ // Build a set of trimmed lines already in FULL for dedup
118
+ const fullLines = new Set(
119
+ fullContent
120
+ .split("\n")
121
+ .map((l) => l.trim())
122
+ .filter(Boolean),
123
+ );
124
+
125
+ // Find lines in CLEAN that don't exist in FULL
126
+ // Skip [REDACTED:...] tags — those are artifacts of previous sync, not real content
127
+ const newLines: string[] = [];
128
+ for (const line of cleanContent.split("\n")) {
129
+ const trimmed = line.trim();
130
+ if (trimmed && !fullLines.has(trimmed) && !trimmed.includes("[REDACTED:")) {
131
+ newLines.push(line);
132
+ }
133
+ }
134
+
135
+ if (newLines.length === 0) {
136
+ return 0;
137
+ }
138
+
139
+ // Append new lines to FULL under a merged section
140
+ const appendBlock = `\n\n## Cloud Session Additions\n${newLines.join("\n")}\n`;
141
+ await this.writeMemory(fullContent + appendBlock, false, options);
142
+ console.log(`[ClawXrouter] Merged ${newLines.length} line(s) from clean → full`);
143
+ return newLines.length;
144
+ } catch (err) {
145
+ console.error("[ClawXrouter] Failed to merge clean into full:", err);
146
+ return 0;
147
+ }
148
+ }
149
+
150
+ /**
151
+ * Sync memory from full to clean (removing guard agent content + redacting PII).
152
+ *
153
+ * Flow:
154
+ * 1. Merge MEMORY.md → MEMORY-FULL.md (capture cloud model additions)
155
+ * 2. Filter guard agent sections from MEMORY-FULL.md
156
+ * 3. Redact PII (local model → regex fallback)
157
+ * 4. Write result to MEMORY.md
158
+ *
159
+ * @param privacyConfig - When provided, PII redaction uses the local model
160
+ * (same `desensitizeWithLocalModel` pipeline as real-time S2 messages).
161
+ * If the model is unavailable or config is omitted, falls back to
162
+ * rule-based `redactSensitiveInfo()`.
163
+ */
164
+ async syncMemoryToClean(privacyConfig?: PrivacyConfig): Promise<void> {
165
+ try {
166
+ // Step 0: Merge cloud additions into FULL so nothing is lost
167
+ await this.mergeCleanIntoFull();
168
+
169
+ // Read full memory (now includes any cloud additions)
170
+ const fullMemory = await this.readMemory(false);
171
+
172
+ if (!fullMemory) {
173
+ return;
174
+ }
175
+
176
+ // Phase 1: Filter out guard agent related sections
177
+ const guardStripped = this.filterGuardContent(fullMemory);
178
+
179
+ // Phase 2: Redact PII — prefer local model, fall back to regex rules
180
+ const cleanMemory = await this.redactContent(guardStripped, privacyConfig);
181
+
182
+ // Write to clean memory
183
+ await this.writeMemory(cleanMemory, true);
184
+
185
+ console.log("[ClawXrouter] MEMORY-FULL.md synced to MEMORY.md");
186
+ } catch (err) {
187
+ console.error("[ClawXrouter] Failed to sync memory:", err);
188
+ }
189
+ }
190
+
191
+ /**
192
+ * Sync ALL daily memory files from memory-full/ → memory/
193
+ * Each file goes through: merge clean→full, guard-strip, PII-redact.
194
+ */
195
+ async syncDailyMemoryToClean(privacyConfig?: PrivacyConfig): Promise<number> {
196
+ let synced = 0;
197
+ try {
198
+ const fullDir = this.getMemoryDir(false); // memory-full/
199
+ const cleanDir = this.getMemoryDir(true); // memory/
200
+
201
+ if (!fs.existsSync(fullDir)) {
202
+ return 0;
203
+ }
204
+
205
+ await fs.promises.mkdir(cleanDir, { recursive: true });
206
+
207
+ // Collect all daily .md files from BOTH directories (cloud may have files full doesn't)
208
+ const fullFiles = fs.existsSync(fullDir)
209
+ ? (await fs.promises.readdir(fullDir)).filter((f) => f.endsWith(".md"))
210
+ : [];
211
+ const cleanFiles = fs.existsSync(cleanDir)
212
+ ? (await fs.promises.readdir(cleanDir)).filter((f) => f.endsWith(".md"))
213
+ : [];
214
+ const allFiles = [...new Set([...fullFiles, ...cleanFiles])];
215
+
216
+ for (const file of allFiles) {
217
+ try {
218
+ const fullPath = path.join(fullDir, file);
219
+ const cleanPath = path.join(cleanDir, file);
220
+
221
+ // Step 0: Merge cloud daily additions into full daily (by filepath, no Date needed)
222
+ await this.mergeDailyFile(fullPath, cleanPath);
223
+
224
+ // Re-read full content (now includes merged cloud additions)
225
+ const fullContent = fs.existsSync(fullPath)
226
+ ? await fs.promises.readFile(fullPath, "utf-8")
227
+ : "";
228
+
229
+ if (!fullContent.trim()) {
230
+ continue;
231
+ }
232
+
233
+ // Phase 1: strip guard content
234
+ const guardStripped = this.filterGuardContent(fullContent);
235
+
236
+ // Phase 2: PII redaction
237
+ const cleanContent = await this.redactContent(guardStripped, privacyConfig);
238
+
239
+ await fs.promises.writeFile(cleanPath, cleanContent, "utf-8");
240
+ synced++;
241
+ } catch (fileErr) {
242
+ console.error(`[ClawXrouter] Failed to sync daily file ${file}:`, fileErr);
243
+ }
244
+ }
245
+
246
+ if (synced > 0) {
247
+ console.log(
248
+ `[ClawXrouter] Synced ${synced} daily memory file(s) from memory-full/ → memory/`,
249
+ );
250
+ }
251
+ } catch (err) {
252
+ console.error("[ClawXrouter] Failed to sync daily memory:", err);
253
+ }
254
+ return synced;
255
+ }
256
+
257
+ /**
258
+ * Sync everything: long-term memory + all daily files
259
+ */
260
+ async syncAllMemoryToClean(privacyConfig?: PrivacyConfig): Promise<void> {
261
+ await this.syncMemoryToClean(privacyConfig);
262
+ await this.syncDailyMemoryToClean(privacyConfig);
263
+ }
264
+
265
+ /**
266
+ * PII redaction: prefer local model, fall back to regex.
267
+ * Public alias for use by hooks that need redaction outside the sync flow.
268
+ */
269
+ async redactContentPublic(text: string, privacyConfig?: PrivacyConfig): Promise<string> {
270
+ return this.redactContent(text, privacyConfig);
271
+ }
272
+
273
+ /**
274
+ * Shared PII redaction: prefer local model, fall back to regex.
275
+ */
276
+ private async redactContent(text: string, privacyConfig?: PrivacyConfig): Promise<string> {
277
+ const redactionOpts = privacyConfig?.redaction;
278
+ if (privacyConfig) {
279
+ const { desensitized, wasModelUsed } = await desensitizeWithLocalModel(text, privacyConfig);
280
+
281
+ if (wasModelUsed && desensitized !== text) {
282
+ console.log("[ClawXrouter] PII redacted via local model");
283
+ return desensitized;
284
+ }
285
+
286
+ // Model unavailable or returned unchanged — fall back to regex
287
+ console.log(
288
+ `[ClawXrouter] PII redacted via rules (model ${wasModelUsed ? "returned unchanged" : "unavailable"})`,
289
+ );
290
+ return redactSensitiveInfo(text, redactionOpts);
291
+ }
292
+
293
+ console.log("[ClawXrouter] PII redacted via rules (no config)");
294
+ return redactSensitiveInfo(text, redactionOpts);
295
+ }
296
+
297
+ /**
298
+ * Merge a single daily clean file's unique lines into the corresponding full file.
299
+ * Operates directly on file paths — no Date conversion needed, avoids timezone issues.
300
+ */
301
+ private async mergeDailyFile(fullPath: string, cleanPath: string): Promise<void> {
302
+ try {
303
+ if (!fs.existsSync(cleanPath)) {
304
+ return;
305
+ }
306
+
307
+ const cleanContent = await fs.promises.readFile(cleanPath, "utf-8");
308
+ if (!cleanContent.trim()) {
309
+ return;
310
+ }
311
+
312
+ const fullContent = fs.existsSync(fullPath)
313
+ ? await fs.promises.readFile(fullPath, "utf-8")
314
+ : "";
315
+
316
+ const fullLines = new Set(
317
+ fullContent
318
+ .split("\n")
319
+ .map((l) => l.trim())
320
+ .filter(Boolean),
321
+ );
322
+
323
+ const newLines: string[] = [];
324
+ for (const line of cleanContent.split("\n")) {
325
+ const trimmed = line.trim();
326
+ if (trimmed && !fullLines.has(trimmed) && !trimmed.includes("[REDACTED:")) {
327
+ newLines.push(line);
328
+ }
329
+ }
330
+
331
+ if (newLines.length === 0) {
332
+ return;
333
+ }
334
+
335
+ // Ensure parent dir exists (in case full file doesn't exist yet)
336
+ await fs.promises.mkdir(path.dirname(fullPath), { recursive: true });
337
+
338
+ const appendBlock = `\n\n## Cloud Session Additions\n${newLines.join("\n")}\n`;
339
+ await fs.promises.writeFile(fullPath, fullContent + appendBlock, "utf-8");
340
+ console.log(
341
+ `[ClawXrouter] Merged ${newLines.length} daily line(s) from clean → full (${path.basename(fullPath)})`,
342
+ );
343
+ } catch (err) {
344
+ console.error(`[ClawXrouter] Failed to merge daily file:`, err);
345
+ }
346
+ }
347
+
348
+ /**
349
+ * Filter guard agent content from memory text.
350
+ * Uses explicit `GUARD_SECTION_BEGIN` / `GUARD_SECTION_END` HTML comment
351
+ * markers to delimit guard-originated sections. Falls back to the legacy
352
+ * heuristic for content written before markers were introduced.
353
+ */
354
+ private filterGuardContent(content: string): string {
355
+ const lines = content.split("\n");
356
+ const filtered: string[] = [];
357
+ let inGuardSection = false;
358
+
359
+ for (const line of lines) {
360
+ if (line.trim() === GUARD_SECTION_BEGIN) {
361
+ inGuardSection = true;
362
+ continue;
363
+ }
364
+ if (line.trim() === GUARD_SECTION_END) {
365
+ inGuardSection = false;
366
+ continue;
367
+ }
368
+ if (!inGuardSection) {
369
+ filtered.push(line);
370
+ }
371
+ }
372
+
373
+ return filtered.join("\n");
374
+ }
375
+
376
+ /**
377
+ * Ensure both memory directories exist
378
+ */
379
+ async initializeDirectories(): Promise<void> {
380
+ try {
381
+ const fullDir = this.getMemoryDir(false);
382
+ const cleanDir = this.getMemoryDir(true);
383
+
384
+ await fs.promises.mkdir(fullDir, { recursive: true });
385
+ await fs.promises.mkdir(cleanDir, { recursive: true });
386
+
387
+ console.log("[ClawXrouter] Memory directories initialized");
388
+ } catch (err) {
389
+ console.error("[ClawXrouter] Failed to initialize memory directories:", err);
390
+ }
391
+ }
392
+
393
+ }
394
+
395
+ // Export a singleton instance
396
+ let defaultMemoryManager: MemoryIsolationManager | null = null;
397
+
398
+ export function getDefaultMemoryManager(workspaceDir?: string): MemoryIsolationManager {
399
+ if (!defaultMemoryManager || workspaceDir) {
400
+ defaultMemoryManager = new MemoryIsolationManager(workspaceDir);
401
+ }
402
+ return defaultMemoryManager;
403
+ }