@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,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
+ }