@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.
- package/config.example.json +204 -0
- package/index.ts +398 -0
- package/openclaw.plugin.json +97 -0
- package/package.json +48 -0
- package/prompts/detection-system.md +50 -0
- package/prompts/token-saver-judge.md +25 -0
- package/src/config-schema.ts +210 -0
- package/src/dashboard-config-io.ts +25 -0
- package/src/detector.ts +230 -0
- package/src/guard-agent.ts +86 -0
- package/src/hooks.ts +1428 -0
- package/src/live-config.ts +75 -0
- package/src/llm-desensitize-worker.ts +7 -0
- package/src/llm-detect-worker.ts +7 -0
- package/src/local-model.ts +723 -0
- package/src/memory-isolation.ts +403 -0
- package/src/privacy-proxy.ts +683 -0
- package/src/prompt-loader.ts +101 -0
- package/src/provider.ts +268 -0
- package/src/router-pipeline.ts +380 -0
- package/src/routers/configurable.ts +208 -0
- package/src/routers/privacy.ts +102 -0
- package/src/routers/token-saver.ts +273 -0
- package/src/rules.ts +320 -0
- package/src/session-manager.ts +377 -0
- package/src/session-state.ts +471 -0
- package/src/stats-dashboard.ts +3402 -0
- package/src/sync-desensitize.ts +48 -0
- package/src/sync-detect.ts +49 -0
- package/src/token-stats.ts +358 -0
- package/src/types.ts +269 -0
- package/src/utils.ts +283 -0
- package/src/worker-loader.mjs +25 -0
|
@@ -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
|
+
}
|