lightclawbot 1.2.3 → 1.2.6-beta.0

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.
@@ -1,4 +1,7 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
1
3
  import { X_PRODUCT } from '../config.js';
4
+ import { resolveOpenClawHome } from '../history/session-store.js';
2
5
  /**
3
6
  * 将长文本按指定长度拆分为多个文本块。
4
7
  *
@@ -47,13 +50,15 @@ export function buildAuthHeaders(apiKey) {
47
50
  };
48
51
  }
49
52
  /**
50
- * 统一的信号发送出口,收敛 typing / stream / tool 控制帧的构造逻辑。
53
+ * 统一的信号发送出口,收敛 typing / stream / tool / usage 控制帧的构造逻辑。
51
54
  *
52
55
  * - typing_start 不带 replyToMsgId(协议要求);其余帧都携带。
53
- * - extra 用于透传 kind 相关字段(如 tool_start 的 toolName/toolPhase、tool_end 的 toolIsError)。
56
+ * - topLevelExtra 用于透传顶层 kind 相关字段(如 tool_start 的 toolName/toolPhase、tool_end 的 toolIsError)。
57
+ * - innerExtra 用于在 PrivateMessageData.extra 内追加自定义字段(如 usage 帧的 usage 对象)。
58
+ * chatId 始终由本函数管理,调用方无需也不能在 innerExtra 中传 chatId(会被覆盖)。
54
59
  */
55
- export function emitSignal(ctx, kind, content = '', extra) {
56
- const { emitter, targetId, replyMsgId, originalMsgId, agentId } = ctx;
60
+ export function emitSignal(ctx, kind, content = '', topLevelExtra, innerExtra) {
61
+ const { emitter, targetId, replyMsgId, originalMsgId, agentId, chatId } = ctx;
57
62
  return emitter.emit({
58
63
  msgId: replyMsgId,
59
64
  from: emitter.botClientId,
@@ -62,7 +67,157 @@ export function emitSignal(ctx, kind, content = '', extra) {
62
67
  timestamp: Date.now(),
63
68
  kind,
64
69
  ...(kind !== 'typing_start' ? { replyToMsgId: originalMsgId } : {}),
65
- ...extra,
70
+ ...topLevelExtra,
66
71
  agentId,
72
+ // chatId 始终位于 extra.chatId;innerExtra 在前展开,确保 chatId 不被覆盖
73
+ extra: { ...(innerExtra ?? {}), chatId: chatId ?? '' },
67
74
  });
68
75
  }
76
+ /**
77
+ * 构建 chats.json 的绝对路径:
78
+ * `~/.openclaw/agents/<agentId>/chats/<userId>/chats.json`
79
+ */
80
+ export function resolveChatsFilePath(agentId, userId) {
81
+ const home = resolveOpenClawHome();
82
+ return path.join(home, 'agents', agentId, 'chats', userId, 'chats.json');
83
+ }
84
+ /**
85
+ * 读取 chats.json 并返回 chats 列表(**纯只读**,无任何文件落盘副作用)。
86
+ *
87
+ * - 文件不存在 → 返回空数组
88
+ * - JSON 解析失败或结构不合法 → 返回空数组
89
+ * - 合法记录按 `updatedAt` 倒序排序,未提供则按 `createdAt` 兜底
90
+ *
91
+ */
92
+ export function readChatsFile(filePath) {
93
+ if (!fs.existsSync(filePath))
94
+ return [];
95
+ const raw = fs.readFileSync(filePath, 'utf-8');
96
+ if (!raw.trim())
97
+ return [];
98
+ const parsed = JSON.parse(raw);
99
+ if (!parsed || !Array.isArray(parsed.chats))
100
+ return [];
101
+ // 过滤出字段合法的记录
102
+ const valid = parsed.chats.filter((c) => !!c &&
103
+ typeof c.chatId === 'string' &&
104
+ typeof c.title === 'string' &&
105
+ typeof c.createdAt === 'number' &&
106
+ typeof c.updatedAt === 'number');
107
+ // 按 updatedAt 倒序,便于前端直接渲染
108
+ return valid.sort((a, b) => (b.updatedAt ?? b.createdAt) - (a.updatedAt ?? a.createdAt));
109
+ }
110
+ /**
111
+ * 读取 chats.json;若文件不存在则**兜底创建一条 chatId='' 的默认对话**并落盘。
112
+ *
113
+ * 与 {@link readChatsFile} 的差异:本函数有写盘副作用,专供"必须保证存在默认对话
114
+ * 兜底条目"的调用方(典型场景:sessionId 登记入口 ensureSessionInHistory)。
115
+ *
116
+ * 拆分原因:原 readChatsFile 兼任只读与兜底创建两种语义,导致 history:request 等
117
+ * 纯查询路径在用户尚未发任何消息前就把 chats.json 写到磁盘上,违反单一职责。
118
+ *
119
+ * 行为:
120
+ * - 文件不存在 → 落盘 [{ chatId: '', title: '默认会话', ... }] 并返回;
121
+ * - 文件存在但解析失败 / 结构不合法 → 沿用 readChatsFile 的容错(返回 [],不创建);
122
+ * - 文件存在但内容合法 → 等价于 readChatsFile。
123
+ */
124
+ export function readChatsFileOrInitDefault(filePath) {
125
+ if (!fs.existsSync(filePath)) {
126
+ const now = Date.now();
127
+ const defaultChat = {
128
+ chatId: '',
129
+ title: '默认会话',
130
+ createdAt: now,
131
+ updatedAt: now,
132
+ titleLocked: false,
133
+ pinned: false,
134
+ sessionIdHistory: [],
135
+ };
136
+ writeChatsFile(filePath, [defaultChat]);
137
+ return [defaultChat];
138
+ }
139
+ return readChatsFile(filePath);
140
+ }
141
+ /**
142
+ * 将 chats 列表原子写入 `chats.json`。
143
+ *
144
+ * 写入策略:
145
+ * 1. 目录不存在时逐级创建(recursive: true)
146
+ * 2. 先写入同目录下的 `.tmp` 文件,再 rename 覆盖目标,
147
+ * 避免写入过程中进程崩溃导致 json 文件损坏
148
+ * 3. schema 固定 `version: 1`,为未来平滑升级预留字段
149
+ *
150
+ * @param filePath 目标 chats.json 绝对路径
151
+ * @param chats 完整的 ChatMeta 列表(调用方自行保证顺序与合法性)
152
+ */
153
+ export function writeChatsFile(filePath, chats) {
154
+ const dir = path.dirname(filePath);
155
+ fs.mkdirSync(dir, { recursive: true });
156
+ const payload = { version: 1, chats };
157
+ const tmpPath = `${filePath}.tmp`;
158
+ fs.writeFileSync(tmpPath, JSON.stringify(payload, null, 2), 'utf-8');
159
+ fs.renameSync(tmpPath, filePath);
160
+ }
161
+ /**
162
+ * 把当前生效的 sessionId 幂等地登记到 `chats.json` 中对应 chatId 的 `sessionIdHistory` 数组。
163
+ *
164
+ * 语义说明:
165
+ * - sessionIdHistory 记录该 chat 见过的所有 sessionId(含当前在用),按时间序末尾追加;
166
+ * - 同一 sessionId 若已存在则跳过(幂等,不会重复 push);
167
+ * - 列表最后一项 ≈ 该 chat 当前在用的 sessionId(前提是调用方在每次消息进入时都调用本函数)。
168
+ *
169
+ * 关于 chatId 为空('')的处理:
170
+ * - 空 chatId 是**合法**的"默认对话"会话标识——`readChatsFile` 在 chats.json
171
+ * 不存在时会主动写入一条 `chatId: ''` 的兜底记录;
172
+ * - 因此本函数对空 chatId 同样登记,目标条目就是 `chatId === ''` 那一条;
173
+ * - 若 chats.json 文件不存在 → 通过 readChatsFile 自动创建兜底记录;
174
+ * - 若 chats.json 已存在但缺失目标 chatId 条目 → 跳过登记并告警(避免污染他人数据)。
175
+ *
176
+ * 容错策略:
177
+ * - chatId 为 undefined/null → 跳过;空字符串 '' 视为合法(默认会话);
178
+ * - sessionId 为空 → 跳过;
179
+ * - 任意 IO/解析异常 → 静默吞错 + 日志,绝不抛出影响主流程。
180
+ *
181
+ * @param agentId - Agent ID(chats/<agentId> 目录段)
182
+ * @param userId - 用户 ID(chats/<agentId>/chats/<userId> 目录段)
183
+ * @param chatId - 会话 ID(ChatMeta.chatId);undefined/null 时跳过;空字符串视为默认会话
184
+ * @param sessionId - 当前生效的 sessionId(来自 sessions.json 索引);为空时直接 return
185
+ * @returns 是否实际写盘(true=本次新增了一个 sessionId;false=跳过 / 已存在 / 出错)
186
+ */
187
+ export function ensureSessionInHistory(agentId, userId, chatId, sessionId) {
188
+ // chatId 必须是 string(含空字符串 '' —— 默认会话的合法标识);undefined/null 跳过
189
+ if (typeof chatId !== 'string')
190
+ return false;
191
+ if (!sessionId || !sessionId.trim())
192
+ return false;
193
+ const chatsPath = resolveChatsFilePath(agentId, userId);
194
+ try {
195
+ // 登记入口需要兜底:默认对话首次消息时 chats.json 尚不存在,
196
+ // 走 readChatsFileOrInitDefault 让其落盘一条 chatId='' 的默认对话,
197
+ // 然后正常走"查找+追加"流程。
198
+ const chats = readChatsFileOrInitDefault(chatsPath);
199
+ const idx = chats.findIndex((c) => c.chatId === chatId);
200
+ if (idx < 0) {
201
+ return false;
202
+ }
203
+ const target = chats[idx];
204
+ const history = Array.isArray(target.sessionIdHistory) ? target.sessionIdHistory : [];
205
+ if (history.includes(sessionId)) {
206
+ // 已存在,幂等跳过,不写盘
207
+ return false;
208
+ }
209
+ // 末尾追加,写回。
210
+ // 把默认对话/具名对话无端"置顶",扰乱前端按 updatedAt 倒序的列表排序。
211
+ const updated = {
212
+ ...target,
213
+ sessionIdHistory: [...history, sessionId],
214
+ };
215
+ const nextChats = [...chats];
216
+ nextChats[idx] = updated;
217
+ writeChatsFile(chatsPath, nextChats);
218
+ return true;
219
+ }
220
+ catch (err) {
221
+ return false;
222
+ }
223
+ }
@@ -40,6 +40,10 @@ class Receiver extends Writable {
40
40
  * extensions
41
41
  * @param {Boolean} [options.isServer=false] Specifies whether to operate in
42
42
  * client or server mode
43
+ * @param {Number} [options.maxBufferedChunks=0] The maximum number of
44
+ * buffered data chunks
45
+ * @param {Number} [options.maxFragments=0] The maximum number of message
46
+ * fragments
43
47
  * @param {Number} [options.maxPayload=0] The maximum allowed message length
44
48
  * @param {Boolean} [options.skipUTF8Validation=false] Specifies whether or
45
49
  * not to skip UTF-8 validation for text and close messages
@@ -54,6 +58,8 @@ class Receiver extends Writable {
54
58
  this._binaryType = options.binaryType || BINARY_TYPES[0];
55
59
  this._extensions = options.extensions || {};
56
60
  this._isServer = !!options.isServer;
61
+ this._maxBufferedChunks = options.maxBufferedChunks | 0;
62
+ this._maxFragments = options.maxFragments | 0;
57
63
  this._maxPayload = options.maxPayload | 0;
58
64
  this._skipUTF8Validation = !!options.skipUTF8Validation;
59
65
  this[kWebSocket] = undefined;
@@ -89,6 +95,22 @@ class Receiver extends Writable {
89
95
  _write(chunk, encoding, cb) {
90
96
  if (this._opcode === 0x08 && this._state == GET_INFO) return cb();
91
97
 
98
+ if (
99
+ this._maxBufferedChunks > 0 &&
100
+ this._buffers.length >= this._maxBufferedChunks
101
+ ) {
102
+ cb(
103
+ this.createError(
104
+ RangeError,
105
+ 'Too many buffered chunks',
106
+ false,
107
+ 1008,
108
+ 'WS_ERR_TOO_MANY_BUFFERED_PARTS'
109
+ )
110
+ );
111
+ return;
112
+ }
113
+
92
114
  this._bufferedBytes += chunk.length;
93
115
  this._buffers.push(chunk);
94
116
  this.startLoop(cb);
@@ -485,6 +507,22 @@ class Receiver extends Writable {
485
507
  }
486
508
 
487
509
  if (data.length) {
510
+ if (
511
+ this._maxFragments > 0 &&
512
+ this._fragments.length >= this._maxFragments
513
+ ) {
514
+ const error = this.createError(
515
+ RangeError,
516
+ 'Too many message fragments',
517
+ false,
518
+ 1008,
519
+ 'WS_ERR_TOO_MANY_BUFFERED_PARTS'
520
+ );
521
+
522
+ cb(error);
523
+ return;
524
+ }
525
+
488
526
  //
489
527
  // This message is not compressed so its length is the sum of the payload
490
528
  // length of all fragments.
@@ -524,6 +562,22 @@ class Receiver extends Writable {
524
562
  return;
525
563
  }
526
564
 
565
+ if (
566
+ this._maxFragments > 0 &&
567
+ this._fragments.length >= this._maxFragments
568
+ ) {
569
+ const error = this.createError(
570
+ RangeError,
571
+ 'Too many message fragments',
572
+ false,
573
+ 1008,
574
+ 'WS_ERR_TOO_MANY_BUFFERED_PARTS'
575
+ );
576
+
577
+ cb(error);
578
+ return;
579
+ }
580
+
527
581
  this._fragments.push(buf);
528
582
  }
529
583
 
@@ -4,6 +4,9 @@
4
4
 
5
5
  const { Duplex } = require('stream');
6
6
  const { randomFillSync } = require('crypto');
7
+ const {
8
+ types: { isUint8Array }
9
+ } = require('util');
7
10
 
8
11
  const PerMessageDeflate = require('./permessage-deflate');
9
12
  const { EMPTY_BUFFER, kWebSocket, NOOP } = require('./constants');
@@ -200,8 +203,10 @@ class Sender {
200
203
 
201
204
  if (typeof data === 'string') {
202
205
  buf.write(data, 2);
203
- } else {
206
+ } else if (isUint8Array(data)) {
204
207
  buf.set(data, 2);
208
+ } else {
209
+ throw new TypeError('Second argument must be a string or a Uint8Array');
205
210
  }
206
211
  }
207
212
 
@@ -43,6 +43,10 @@ class WebSocketServer extends EventEmitter {
43
43
  * called
44
44
  * @param {Function} [options.handleProtocols] A hook to handle protocols
45
45
  * @param {String} [options.host] The hostname where to bind the server
46
+ * @param {Number} [options.maxBufferedChunks=1048576] The maximum number of
47
+ * buffered data chunks
48
+ * @param {Number} [options.maxFragments=131072] The maximum number of message
49
+ * fragments
46
50
  * @param {Number} [options.maxPayload=104857600] The maximum allowed message
47
51
  * size
48
52
  * @param {Boolean} [options.noServer=false] Enable no server mode
@@ -65,6 +69,8 @@ class WebSocketServer extends EventEmitter {
65
69
  options = {
66
70
  allowSynchronousEvents: true,
67
71
  autoPong: true,
72
+ maxBufferedChunks: 1024 * 1024,
73
+ maxFragments: 128 * 1024,
68
74
  maxPayload: 100 * 1024 * 1024,
69
75
  skipUTF8Validation: false,
70
76
  perMessageDeflate: false,
@@ -424,6 +430,8 @@ class WebSocketServer extends EventEmitter {
424
430
 
425
431
  ws.setSocket(socket, head, {
426
432
  allowSynchronousEvents: this.options.allowSynchronousEvents,
433
+ maxBufferedChunks: this.options.maxBufferedChunks,
434
+ maxFragments: this.options.maxFragments,
427
435
  maxPayload: this.options.maxPayload,
428
436
  skipUTF8Validation: this.options.skipUTF8Validation
429
437
  });
@@ -201,6 +201,10 @@ class WebSocket extends EventEmitter {
201
201
  * multiple times in the same tick
202
202
  * @param {Function} [options.generateMask] The function used to generate the
203
203
  * masking key
204
+ * @param {Number} [options.maxBufferedChunks=0] The maximum number of
205
+ * buffered data chunks
206
+ * @param {Number} [options.maxFragments=0] The maximum number of message
207
+ * fragments
204
208
  * @param {Number} [options.maxPayload=0] The maximum allowed message size
205
209
  * @param {Boolean} [options.skipUTF8Validation=false] Specifies whether or
206
210
  * not to skip UTF-8 validation for text and close messages
@@ -212,6 +216,8 @@ class WebSocket extends EventEmitter {
212
216
  binaryType: this.binaryType,
213
217
  extensions: this._extensions,
214
218
  isServer: this._isServer,
219
+ maxBufferedChunks: options.maxBufferedChunks,
220
+ maxFragments: options.maxFragments,
215
221
  maxPayload: options.maxPayload,
216
222
  skipUTF8Validation: options.skipUTF8Validation
217
223
  });
@@ -640,6 +646,10 @@ module.exports = WebSocket;
640
646
  * masking key
641
647
  * @param {Number} [options.handshakeTimeout] Timeout in milliseconds for the
642
648
  * handshake request
649
+ * @param {Number} [options.maxBufferedChunks=1048576] The maximum number of
650
+ * buffered data chunks
651
+ * @param {Number} [options.maxFragments=131072] The maximum number of message
652
+ * fragments
643
653
  * @param {Number} [options.maxPayload=104857600] The maximum allowed message
644
654
  * size
645
655
  * @param {Number} [options.maxRedirects=10] The maximum number of redirects
@@ -660,6 +670,8 @@ function initAsClient(websocket, address, protocols, options) {
660
670
  autoPong: true,
661
671
  closeTimeout: CLOSE_TIMEOUT,
662
672
  protocolVersion: protocolVersions[1],
673
+ maxBufferedChunks: 1024 * 1024,
674
+ maxFragments: 128 * 1024,
663
675
  maxPayload: 100 * 1024 * 1024,
664
676
  skipUTF8Validation: false,
665
677
  perMessageDeflate: true,
@@ -1017,6 +1029,8 @@ function initAsClient(websocket, address, protocols, options) {
1017
1029
  websocket.setSocket(socket, head, {
1018
1030
  allowSynchronousEvents: opts.allowSynchronousEvents,
1019
1031
  generateMask: opts.generateMask,
1032
+ maxBufferedChunks: opts.maxBufferedChunks,
1033
+ maxFragments: opts.maxFragments,
1020
1034
  maxPayload: opts.maxPayload,
1021
1035
  skipUTF8Validation: opts.skipUTF8Validation
1022
1036
  });
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ws",
3
- "version": "8.20.0",
3
+ "version": "8.21.0",
4
4
  "description": "Simple to use, blazing fast and thoroughly tested websocket client and server for Node.js",
5
5
  "keywords": [
6
6
  "HyBi",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lightclawbot",
3
- "version": "1.2.3",
3
+ "version": "1.2.6-beta.0",
4
4
  "description": "LightClawBot channel plugin with message support, cron jobs, and proactive messaging",
5
5
  "type": "module",
6
6
  "files": [