@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,377 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { isGuardSessionKey } from "./guard-agent.js";
|
|
4
|
+
|
|
5
|
+
export type SessionMessage = {
|
|
6
|
+
role: "user" | "assistant" | "system" | "tool";
|
|
7
|
+
content: string;
|
|
8
|
+
timestamp?: number;
|
|
9
|
+
toolCallId?: string;
|
|
10
|
+
toolName?: string;
|
|
11
|
+
sessionKey?: string;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Dual session manager that maintains separate full and clean histories
|
|
16
|
+
*/
|
|
17
|
+
export class DualSessionManager {
|
|
18
|
+
private baseDir: string;
|
|
19
|
+
private writeLocks = new Map<string, Promise<void>>();
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Serialize writes to the same file to prevent interleaved JSONL lines
|
|
23
|
+
* when multiple fire-and-forget writes race from sync hooks.
|
|
24
|
+
*/
|
|
25
|
+
private async withWriteLock(lockKey: string, fn: () => Promise<void>): Promise<void> {
|
|
26
|
+
const prev = this.writeLocks.get(lockKey) ?? Promise.resolve();
|
|
27
|
+
const next = prev.then(fn, fn);
|
|
28
|
+
this.writeLocks.set(lockKey, next);
|
|
29
|
+
await next;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
constructor(baseDir: string = "~/.openclaw") {
|
|
33
|
+
// Expand ~ to home directory
|
|
34
|
+
this.baseDir = baseDir.startsWith("~")
|
|
35
|
+
? path.join(process.env.HOME || process.env.USERPROFILE || "~", baseDir.slice(2))
|
|
36
|
+
: baseDir;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Persist a message to session history
|
|
41
|
+
* - Full history: includes all messages (including guard agent interactions)
|
|
42
|
+
* - Clean history: excludes guard agent interactions (for cloud models)
|
|
43
|
+
*/
|
|
44
|
+
async persistMessage(
|
|
45
|
+
sessionKey: string,
|
|
46
|
+
message: SessionMessage,
|
|
47
|
+
agentId: string = "main"
|
|
48
|
+
): Promise<void> {
|
|
49
|
+
// Always write to full history
|
|
50
|
+
await this.writeToHistory(sessionKey, message, agentId, "full");
|
|
51
|
+
|
|
52
|
+
// Write to clean history only if not a guard agent message
|
|
53
|
+
if (!this.isGuardAgentMessage(message)) {
|
|
54
|
+
await this.writeToHistory(sessionKey, message, agentId, "clean");
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Seed the full track with existing clean track content (if any) so that
|
|
60
|
+
* the full track is a complete history from the start of the session.
|
|
61
|
+
* No-op if the full track already exists. Mirrors the memory-isolation
|
|
62
|
+
* pattern of mergeCleanIntoFull.
|
|
63
|
+
*/
|
|
64
|
+
private seededSessions = new Set<string>();
|
|
65
|
+
|
|
66
|
+
private async ensureFullTrackSeeded(
|
|
67
|
+
sessionKey: string,
|
|
68
|
+
agentId: string,
|
|
69
|
+
): Promise<void> {
|
|
70
|
+
const key = `${sessionKey}:${agentId}`;
|
|
71
|
+
if (this.seededSessions.has(key)) return;
|
|
72
|
+
|
|
73
|
+
const fullPath = this.getHistoryPath(sessionKey, agentId, "full");
|
|
74
|
+
if (fs.existsSync(fullPath)) {
|
|
75
|
+
this.seededSessions.add(key);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const cleanPath = this.getHistoryPath(sessionKey, agentId, "clean");
|
|
80
|
+
if (!fs.existsSync(cleanPath)) {
|
|
81
|
+
this.seededSessions.add(key);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
const dir = path.dirname(fullPath);
|
|
87
|
+
await fs.promises.mkdir(dir, { recursive: true });
|
|
88
|
+
await fs.promises.copyFile(cleanPath, fullPath);
|
|
89
|
+
console.log(`[ClawXrouter] Seeded full track from clean track for ${sessionKey}`);
|
|
90
|
+
} catch (err) {
|
|
91
|
+
console.error(`[ClawXrouter] Failed to seed full track for ${sessionKey}:`, err);
|
|
92
|
+
}
|
|
93
|
+
this.seededSessions.add(key);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Write a message to the full history only.
|
|
98
|
+
* On first write, seeds the full track with existing clean track content
|
|
99
|
+
* so it contains the complete conversation history.
|
|
100
|
+
*/
|
|
101
|
+
async writeToFull(
|
|
102
|
+
sessionKey: string,
|
|
103
|
+
message: SessionMessage,
|
|
104
|
+
agentId: string = "main"
|
|
105
|
+
): Promise<void> {
|
|
106
|
+
await this.ensureFullTrackSeeded(sessionKey, agentId);
|
|
107
|
+
await this.writeToHistory(sessionKey, message, agentId, "full");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Write a message to the clean history only.
|
|
112
|
+
*/
|
|
113
|
+
async writeToClean(
|
|
114
|
+
sessionKey: string,
|
|
115
|
+
message: SessionMessage,
|
|
116
|
+
agentId: string = "main"
|
|
117
|
+
): Promise<void> {
|
|
118
|
+
await this.writeToHistory(sessionKey, message, agentId, "clean");
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Load session history based on model type
|
|
123
|
+
* - Cloud models: get clean history only
|
|
124
|
+
* - Local models: get full history
|
|
125
|
+
*/
|
|
126
|
+
async loadHistory(
|
|
127
|
+
sessionKey: string,
|
|
128
|
+
isCloudModel: boolean,
|
|
129
|
+
agentId: string = "main",
|
|
130
|
+
limit?: number
|
|
131
|
+
): Promise<SessionMessage[]> {
|
|
132
|
+
const historyType = isCloudModel ? "clean" : "full";
|
|
133
|
+
return await this.readHistory(sessionKey, agentId, historyType, limit);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Check if a message is from guard agent interactions
|
|
138
|
+
*/
|
|
139
|
+
private isGuardAgentMessage(message: SessionMessage): boolean {
|
|
140
|
+
if (message.sessionKey && isGuardSessionKey(message.sessionKey)) {
|
|
141
|
+
return true;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const content = message.content;
|
|
145
|
+
if (
|
|
146
|
+
content.includes("[clawxrouter:guard]") ||
|
|
147
|
+
content.includes("[guard agent]")
|
|
148
|
+
) {
|
|
149
|
+
return true;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Write message to history file.
|
|
157
|
+
* Uses a per-file write lock to serialize concurrent appends
|
|
158
|
+
* (e.g. from fire-and-forget calls in sync hooks).
|
|
159
|
+
*/
|
|
160
|
+
private async writeToHistory(
|
|
161
|
+
sessionKey: string,
|
|
162
|
+
message: SessionMessage,
|
|
163
|
+
agentId: string,
|
|
164
|
+
historyType: "full" | "clean"
|
|
165
|
+
): Promise<void> {
|
|
166
|
+
const historyPath = this.getHistoryPath(sessionKey, agentId, historyType);
|
|
167
|
+
|
|
168
|
+
await this.withWriteLock(historyPath, async () => {
|
|
169
|
+
try {
|
|
170
|
+
const dir = path.dirname(historyPath);
|
|
171
|
+
await fs.promises.mkdir(dir, { recursive: true });
|
|
172
|
+
|
|
173
|
+
const line = JSON.stringify({
|
|
174
|
+
...message,
|
|
175
|
+
timestamp: message.timestamp ?? Date.now(),
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
await fs.promises.appendFile(historyPath, line + "\n", "utf-8");
|
|
179
|
+
} catch (err) {
|
|
180
|
+
console.error(
|
|
181
|
+
`[ClawXrouter] Failed to write to ${historyType} history for ${sessionKey}:`,
|
|
182
|
+
err
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Read messages from history file
|
|
190
|
+
*/
|
|
191
|
+
private async readHistory(
|
|
192
|
+
sessionKey: string,
|
|
193
|
+
agentId: string,
|
|
194
|
+
historyType: "full" | "clean",
|
|
195
|
+
limit?: number
|
|
196
|
+
): Promise<SessionMessage[]> {
|
|
197
|
+
try {
|
|
198
|
+
const historyPath = this.getHistoryPath(sessionKey, agentId, historyType);
|
|
199
|
+
|
|
200
|
+
// Check if file exists
|
|
201
|
+
if (!fs.existsSync(historyPath)) {
|
|
202
|
+
return [];
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Read file and parse JSONL
|
|
206
|
+
const content = await fs.promises.readFile(historyPath, "utf-8");
|
|
207
|
+
const lines = content.trim().split("\n").filter(Boolean);
|
|
208
|
+
|
|
209
|
+
const messages = lines
|
|
210
|
+
.map((line) => {
|
|
211
|
+
try {
|
|
212
|
+
return JSON.parse(line) as SessionMessage;
|
|
213
|
+
} catch {
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
})
|
|
217
|
+
.filter((msg): msg is SessionMessage => msg !== null);
|
|
218
|
+
|
|
219
|
+
// Apply limit if specified
|
|
220
|
+
if (limit && messages.length > limit) {
|
|
221
|
+
return messages.slice(-limit);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return messages;
|
|
225
|
+
} catch (err) {
|
|
226
|
+
console.error(
|
|
227
|
+
`[ClawXrouter] Failed to read ${historyType} history for ${sessionKey}:`,
|
|
228
|
+
err
|
|
229
|
+
);
|
|
230
|
+
return [];
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Get history file path
|
|
236
|
+
*/
|
|
237
|
+
private getHistoryPath(
|
|
238
|
+
sessionKey: string,
|
|
239
|
+
agentId: string,
|
|
240
|
+
historyType: "full" | "clean"
|
|
241
|
+
): string {
|
|
242
|
+
// Sanitize session key for file name
|
|
243
|
+
const safeSessionKey = sessionKey.replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
244
|
+
|
|
245
|
+
const fileName = `${safeSessionKey}.jsonl`;
|
|
246
|
+
|
|
247
|
+
return path.join(
|
|
248
|
+
this.baseDir,
|
|
249
|
+
"agents",
|
|
250
|
+
agentId,
|
|
251
|
+
"sessions",
|
|
252
|
+
historyType,
|
|
253
|
+
fileName
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Clear history for a session
|
|
259
|
+
*/
|
|
260
|
+
async clearHistory(
|
|
261
|
+
sessionKey: string,
|
|
262
|
+
agentId: string = "main",
|
|
263
|
+
historyType?: "full" | "clean"
|
|
264
|
+
): Promise<void> {
|
|
265
|
+
const types: Array<"full" | "clean"> = historyType ? [historyType] : ["full", "clean"];
|
|
266
|
+
|
|
267
|
+
for (const type of types) {
|
|
268
|
+
try {
|
|
269
|
+
const historyPath = this.getHistoryPath(sessionKey, agentId, type);
|
|
270
|
+
|
|
271
|
+
if (fs.existsSync(historyPath)) {
|
|
272
|
+
await fs.promises.unlink(historyPath);
|
|
273
|
+
}
|
|
274
|
+
} catch (err) {
|
|
275
|
+
console.error(
|
|
276
|
+
`[ClawXrouter] Failed to clear ${type} history for ${sessionKey}:`,
|
|
277
|
+
err
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Load messages that exist in the full track but not in the clean track.
|
|
285
|
+
* These are Guard Agent interactions and original S3 content that were
|
|
286
|
+
* stripped from the sanitized transcript — exactly the context a local
|
|
287
|
+
* model needs to reconstruct the full conversation.
|
|
288
|
+
*/
|
|
289
|
+
async loadHistoryDelta(
|
|
290
|
+
sessionKey: string,
|
|
291
|
+
agentId: string = "main",
|
|
292
|
+
limit?: number
|
|
293
|
+
): Promise<SessionMessage[]> {
|
|
294
|
+
const full = await this.readHistory(sessionKey, agentId, "full");
|
|
295
|
+
const clean = await this.readHistory(sessionKey, agentId, "clean");
|
|
296
|
+
|
|
297
|
+
if (full.length === 0) return [];
|
|
298
|
+
if (clean.length === 0) return limit ? full.slice(-limit) : full;
|
|
299
|
+
|
|
300
|
+
const cleanSet = new Set(
|
|
301
|
+
clean.map((m) => `${m.role}:${m.timestamp ?? ""}:${m.content.slice(0, 80)}`)
|
|
302
|
+
);
|
|
303
|
+
|
|
304
|
+
const delta = full.filter(
|
|
305
|
+
(m) => !cleanSet.has(`${m.role}:${m.timestamp ?? ""}:${m.content.slice(0, 80)}`)
|
|
306
|
+
);
|
|
307
|
+
|
|
308
|
+
return limit && delta.length > limit ? delta.slice(-limit) : delta;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Format session messages as a readable conversation context block
|
|
313
|
+
* suitable for injection via prependContext.
|
|
314
|
+
*/
|
|
315
|
+
static formatAsContext(messages: SessionMessage[], label?: string): string {
|
|
316
|
+
if (messages.length === 0) return "";
|
|
317
|
+
|
|
318
|
+
const header = label ?? "Full conversation history (original, authoritative)";
|
|
319
|
+
const lines = [
|
|
320
|
+
`[${header}]`,
|
|
321
|
+
`[NOTE: The conversation above may contain "🔒 [Private message]" placeholders or redacted text. This is the complete original history — use it as the authoritative source.]`,
|
|
322
|
+
];
|
|
323
|
+
|
|
324
|
+
for (const msg of messages) {
|
|
325
|
+
const roleLabel =
|
|
326
|
+
msg.role === "user" ? "User" :
|
|
327
|
+
msg.role === "assistant" ? "Assistant" :
|
|
328
|
+
msg.role === "tool" ? `Tool${msg.toolName ? `(${msg.toolName})` : ""}` :
|
|
329
|
+
"System";
|
|
330
|
+
|
|
331
|
+
const ts = msg.timestamp
|
|
332
|
+
? ` [ts=${new Date(msg.timestamp).toISOString()}]`
|
|
333
|
+
: "";
|
|
334
|
+
|
|
335
|
+
const truncated =
|
|
336
|
+
msg.content.length > 2000
|
|
337
|
+
? msg.content.slice(0, 2000) + "…(truncated)"
|
|
338
|
+
: msg.content;
|
|
339
|
+
|
|
340
|
+
lines.push(`${roleLabel}${ts}: ${truncated}`);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
lines.push("[End of private context]");
|
|
344
|
+
return lines.join("\n");
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Get history statistics
|
|
349
|
+
*/
|
|
350
|
+
async getHistoryStats(
|
|
351
|
+
sessionKey: string,
|
|
352
|
+
agentId: string = "main"
|
|
353
|
+
): Promise<{
|
|
354
|
+
fullCount: number;
|
|
355
|
+
cleanCount: number;
|
|
356
|
+
difference: number;
|
|
357
|
+
}> {
|
|
358
|
+
const full = await this.readHistory(sessionKey, agentId, "full");
|
|
359
|
+
const clean = await this.readHistory(sessionKey, agentId, "clean");
|
|
360
|
+
|
|
361
|
+
return {
|
|
362
|
+
fullCount: full.length,
|
|
363
|
+
cleanCount: clean.length,
|
|
364
|
+
difference: full.length - clean.length,
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// Export a singleton instance
|
|
370
|
+
let defaultManager: DualSessionManager | null = null;
|
|
371
|
+
|
|
372
|
+
export function getDefaultSessionManager(baseDir?: string): DualSessionManager {
|
|
373
|
+
if (!defaultManager || baseDir) {
|
|
374
|
+
defaultManager = new DualSessionManager(baseDir);
|
|
375
|
+
}
|
|
376
|
+
return defaultManager;
|
|
377
|
+
}
|