lightclawbot 1.2.6 → 1.2.8

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.
Files changed (102) hide show
  1. package/dist/src/config.js +30 -3
  2. package/dist/src/gateway.js +58 -12
  3. package/dist/src/group/constants/index.js +20 -0
  4. package/dist/src/group/inbound/index.js +254 -0
  5. package/dist/src/group/index.js +15 -0
  6. package/dist/src/group/orchestrator/execution/agent-runner.js +299 -0
  7. package/dist/src/group/orchestrator/execution/index.js +7 -0
  8. package/dist/src/group/orchestrator/execution/prompt-builder.js +288 -0
  9. package/dist/src/group/orchestrator/execution/soul-resolver.js +38 -0
  10. package/dist/src/group/orchestrator/execution/types.js +7 -0
  11. package/dist/src/group/orchestrator/index.js +14 -0
  12. package/dist/src/group/orchestrator/lifecycle/conversation-state.js +162 -0
  13. package/dist/src/group/orchestrator/lifecycle/index.js +7 -0
  14. package/dist/src/group/orchestrator/lifecycle/ledger-writer.js +96 -0
  15. package/dist/src/group/orchestrator/lifecycle/run-registry.js +174 -0
  16. package/dist/src/group/orchestrator/orchestrator.js +265 -0
  17. package/dist/src/group/orchestrator/planning/index.js +13 -0
  18. package/dist/src/group/orchestrator/planning/plan-validator.js +233 -0
  19. package/dist/src/group/orchestrator/planning/planning-parser.js +207 -0
  20. package/dist/src/group/orchestrator/planning/subtask-executor.js +345 -0
  21. package/dist/src/group/orchestrator/planning/summarizer-runner.js +224 -0
  22. package/dist/src/group/orchestrator/routes/index.js +9 -0
  23. package/dist/src/group/orchestrator/routes/leader-dispatch.js +229 -0
  24. package/dist/src/group/orchestrator/routes/leader-orchestration-route.js +179 -0
  25. package/dist/src/group/orchestrator/routes/leader-planning.js +92 -0
  26. package/dist/src/group/orchestrator/routes/leader-self-answer.js +223 -0
  27. package/dist/src/group/orchestrator/routes/mention-concurrent-route.js +226 -0
  28. package/dist/src/group/orchestrator/routes/route-helpers.js +186 -0
  29. package/dist/src/group/orchestrator/routes/types.js +8 -0
  30. package/dist/src/group/services/group-cleanup-service.js +183 -0
  31. package/dist/src/group/services/group-creation-service.js +122 -0
  32. package/dist/src/group/services/group-deletion-service.js +111 -0
  33. package/dist/src/group/services/group-history-service.js +73 -0
  34. package/dist/src/group/services/group-member-service.js +169 -0
  35. package/dist/src/group/services/group-query-service.js +133 -0
  36. package/dist/src/group/services/group-update-service.js +144 -0
  37. package/dist/src/group/services/index.js +20 -0
  38. package/dist/src/group/storage/concurrency-manager.js +119 -0
  39. package/dist/src/group/storage/group-storage-core.js +227 -0
  40. package/dist/src/group/storage/index.js +12 -0
  41. package/dist/src/group/storage/message-reader.js +213 -0
  42. package/dist/src/group/storage/message-writer.js +229 -0
  43. package/dist/src/group/storage/slice-manager.js +165 -0
  44. package/dist/src/group/types/common.js +5 -0
  45. package/dist/src/group/types/index.js +5 -0
  46. package/dist/src/group/types/message.js +5 -0
  47. package/dist/src/group/types/orchestrator.js +5 -0
  48. package/dist/src/group/types/storage.js +5 -0
  49. package/dist/src/group/utils/id-generator.js +15 -0
  50. package/dist/src/group/utils/index.js +12 -0
  51. package/dist/src/group/utils/mime.js +36 -0
  52. package/dist/src/group/utils/normalize.js +32 -0
  53. package/dist/src/group/utils/run-helpers.js +36 -0
  54. package/dist/src/history/session-reader.js +8 -2
  55. package/dist/src/outbound.js +12 -19
  56. package/dist/src/shared.js +4 -3
  57. package/dist/src/socket/events/agents-request.js +147 -0
  58. package/dist/src/socket/events/chat-request.js +67 -0
  59. package/dist/src/socket/events/file-download.js +121 -0
  60. package/dist/src/socket/events/group-abort.js +59 -0
  61. package/dist/src/socket/events/group-history.js +59 -0
  62. package/dist/src/socket/events/group-member.js +83 -0
  63. package/dist/src/socket/events/group-request.js +91 -0
  64. package/dist/src/socket/events/history-request.js +95 -0
  65. package/dist/src/socket/events/index.js +39 -0
  66. package/dist/src/socket/events/message-private.js +82 -0
  67. package/dist/src/socket/handlers.js +53 -568
  68. package/dist/src/socket/native-socket.js +21 -20
  69. package/dist/src/socket/registry.js +6 -3
  70. package/dist/src/socket/reliable-emitter.js +16 -13
  71. package/dist/src/socket/service/chat-common.js +36 -0
  72. package/dist/src/socket/service/chat-create.js +75 -0
  73. package/dist/src/socket/service/chat-delete.js +94 -0
  74. package/dist/src/socket/service/chat-list.js +82 -0
  75. package/dist/src/socket/service/chat-update.js +83 -0
  76. package/dist/src/socket/service/group-abort.js +104 -0
  77. package/dist/src/socket/service/group-history.js +140 -0
  78. package/dist/src/socket/service/group-member.js +209 -0
  79. package/dist/src/socket/service/group.js +233 -0
  80. package/dist/src/socket/service/history.js +102 -0
  81. package/dist/src/socket/service/index.js +14 -0
  82. package/dist/src/socket/types/index.js +7 -0
  83. package/dist/src/socket/types/request.js +8 -0
  84. package/dist/src/socket/types/service.js +8 -0
  85. package/dist/src/socket/utils/agent-soul.js +95 -0
  86. package/dist/src/socket/utils/index.js +8 -0
  87. package/dist/src/socket/utils/message.js +83 -0
  88. package/dist/src/socket/utils/validate.js +42 -0
  89. package/dist/src/streaming/index.js +1 -0
  90. package/dist/src/streaming/stream-reply-sink.js +367 -20
  91. package/dist/src/streaming/thinking-formatter.js +325 -0
  92. package/dist/src/streaming/types.js +20 -1
  93. package/dist/src/{download-tool.js → tools/download-tool.js} +41 -35
  94. package/dist/src/tools/group-history-tool.js +172 -0
  95. package/dist/src/{upload-tool.js → tools/upload-tool.js} +2 -2
  96. package/dist/src/tools.js +4 -3
  97. package/dist/src/utils/index.js +1 -0
  98. package/dist/src/utils/logger.js +38 -0
  99. package/openclaw.plugin.json +2 -1
  100. package/package.json +1 -1
  101. package/dist/src/socket/agent-soul.js +0 -41
  102. package/dist/src/socket/chat.js +0 -257
@@ -0,0 +1,325 @@
1
+ /**
2
+ * LightClaw — 思考过程帧(thinking_step)格式化模块
3
+ *
4
+ * 职责:把 openclaw 的工具回调(onToolStart / onItemEvent)翻译成
5
+ * 协议契约里的 `extra.step` 载荷。前端据此渲染:
6
+ *
7
+ * 💭 接下来将先执行端口检查,确认浏览器启动状态。 ← stream_chunk(旁白)
8
+ * 🔧 执行了 exec: lsof -i :9222 ← thinking_step running
9
+ * 💭 已找到 9222 端口 PID = 12345 ← thinking_step done
10
+ *
11
+ * 协议见 docs/thinking-step-protocol.md。
12
+ */
13
+ // ── 工具名 → 类型/中文短句映射 ──
14
+ //
15
+ // 命中规则按"前缀"匹配,避免 sessions_spawn / sessions.spawn 这种命名差异。
16
+ const TOOL_TYPE_RULES = [
17
+ { test: (n) => n === "exec" || n.startsWith("exec_"), type: "cmd", verb: "执行命令" },
18
+ { test: (n) => n.startsWith("browser"), type: "browser", verb: "操作浏览器" },
19
+ { test: (n) => n === "apply_patch" || n.startsWith("str_replace") || n === "edit_file" || n === "write_file", type: "patch", verb: "编辑文件" },
20
+ { test: (n) => n === "read" || n === "read_file" || n === "view_code_item", type: "tool", verb: "查看文件" },
21
+ { test: (n) => n.startsWith("web_") || n === "fetch", type: "tool", verb: "访问网页" },
22
+ { test: (n) => n.includes("session") && n.includes("spawn"), type: "subagent", verb: "派发子任务" },
23
+ { test: (n) => n === "subagents", type: "subagent", verb: "派发子任务" },
24
+ { test: (n) => n === "update_plan" || n === "plan", type: "plan", verb: "更新计划" },
25
+ ];
26
+ /** 把工具名映射成行级类型 + 默认中文动作短句。 */
27
+ function classifyTool(toolName) {
28
+ for (const rule of TOOL_TYPE_RULES) {
29
+ if (rule.test(toolName))
30
+ return { type: rule.type, verb: rule.verb };
31
+ }
32
+ return { type: "tool", verb: `调用 ${toolName}` };
33
+ }
34
+ /** stepId 生成器:调用方应在每次 onToolStart 时调用一次,把返回值缓存到 lastToolStepId 上。 */
35
+ export function genStepId(seed, toolName) {
36
+ return `step-${seed}-${toolName.replace(/[^a-zA-Z0-9_]/g, "_")}`;
37
+ }
38
+ /**
39
+ * 把 args 摘要成一行人类可读的字符串。优先级:
40
+ * command/cmd/shell > path/file_path/target_file > url/targetUrl > query/keyword > name/title > action > 任意非空字符串
41
+ *
42
+ * 注意 `action` 这种枚举型动词字段(值通常是 "open" / "click" 等单词)被刻意降权,
43
+ * 否则会把更有信息量的 url/targetUrl 压住,导致 running 文案退化为 "执行了 操作浏览器: open"。
44
+ *
45
+ * 超长内容会被截断到 160 字符(后接 …),避免占满前端时间线一行。
46
+ */
47
+ const ARG_SUMMARY_MAX_LEN = 160;
48
+ const ARG_PRIORITY_KEYS = [
49
+ // 命令/路径类(最有信息量)
50
+ "command",
51
+ "cmd",
52
+ "shell",
53
+ "path",
54
+ "file_path",
55
+ "filepath",
56
+ "filePath",
57
+ "target_file",
58
+ // URL 类(browser 工具常用 targetUrl)
59
+ "url",
60
+ "targetUrl",
61
+ "target_url",
62
+ "href",
63
+ // 检索/关键词类
64
+ "query",
65
+ "keyword",
66
+ "text",
67
+ // 标识符类
68
+ "name",
69
+ "title",
70
+ // 元素定位类(browser 点击/输入常用 targetId / selector)
71
+ "targetId",
72
+ "target_id",
73
+ "selector",
74
+ // 枚举动词(信息量最低,放最后兜底)
75
+ "action",
76
+ ];
77
+ function truncate(text) {
78
+ if (text.length <= ARG_SUMMARY_MAX_LEN)
79
+ return text;
80
+ return `${text.slice(0, ARG_SUMMARY_MAX_LEN)}…`;
81
+ }
82
+ function summarizeArgs(args) {
83
+ if (!args || typeof args !== "object")
84
+ return "";
85
+ // 1. 优先级 key 命中
86
+ for (const key of ARG_PRIORITY_KEYS) {
87
+ const val = args[key];
88
+ if (typeof val === "string" && val.trim()) {
89
+ return truncate(val.trim());
90
+ }
91
+ }
92
+ // 2. 任何非空字符串字段
93
+ for (const [, val] of Object.entries(args)) {
94
+ if (typeof val === "string" && val.trim()) {
95
+ return truncate(val.trim());
96
+ }
97
+ }
98
+ // 3. 兜底:序列化整个对象
99
+ try {
100
+ const json = JSON.stringify(args);
101
+ if (json && json !== "{}")
102
+ return truncate(json);
103
+ }
104
+ catch {
105
+ // 循环引用等场景,忽略
106
+ }
107
+ return "";
108
+ }
109
+ // ── browser 工具特化文案 ──
110
+ //
111
+ // 根因:browser 工具下不同 action 对应的有效字段差异很大——
112
+ // - open 用 targetUrl(用户能识别的 URL)
113
+ // - click 用 targetId(DOM 元素内部句柄,如 "t1",无语义信息)
114
+ // - type 用 text(输入内容)
115
+ // - scroll/back/forward/screenshot 等几乎没有可读字段
116
+ //
117
+ // 通用 ARG_PRIORITY_KEYS 把这些字段放同一优先级,导致 click 文案退化为
118
+ // "执行了 操作浏览器: t1" 这类用户完全不知所云的内容。这里按 action 分支
119
+ // 显式映射 verb 与可见字段,避免把内部句柄搬到时间线上。
120
+ const BROWSER_ACTION_VERBS = {
121
+ open: "打开网页",
122
+ navigate: "打开网页",
123
+ goto: "打开网页",
124
+ click: "点击页面元素",
125
+ tap: "点击页面元素",
126
+ type: "输入文本",
127
+ fill: "输入文本",
128
+ input: "输入文本",
129
+ scroll: "滚动页面",
130
+ back: "返回上一页",
131
+ forward: "前进",
132
+ reload: "刷新页面",
133
+ refresh: "刷新页面",
134
+ screenshot: "页面截图",
135
+ snapshot: "页面截图",
136
+ close: "关闭页面",
137
+ };
138
+ /**
139
+ * 按 browser action 生成 {verb, summary}。
140
+ * - 命中已知 action:返回特化 verb,且 summary 仅取真正有用户语义的字段(targetId 等内部句柄不进文案)。
141
+ * - 未命中(含 args 缺失 / action 不在白名单):返回 null,由调用方回退到通用 summarizeArgs。
142
+ */
143
+ function summarizeBrowserArgs(args) {
144
+ if (!args || typeof args !== "object")
145
+ return null;
146
+ const action = args.action;
147
+ if (typeof action !== "string" || !action.trim())
148
+ return null;
149
+ const verb = BROWSER_ACTION_VERBS[action.toLowerCase()];
150
+ if (!verb)
151
+ return null;
152
+ const pickString = (...keys) => {
153
+ for (const k of keys) {
154
+ const v = args[k];
155
+ if (typeof v === "string" && v.trim())
156
+ return truncate(v.trim());
157
+ }
158
+ return "";
159
+ };
160
+ let summary = "";
161
+ switch (action.toLowerCase()) {
162
+ case "open":
163
+ case "navigate":
164
+ case "goto":
165
+ summary = pickString("targetUrl", "url", "target_url", "href");
166
+ break;
167
+ case "click":
168
+ case "tap":
169
+ // targetId 是 DOM 内部句柄(如 "t1"),毫无可读性,刻意不进文案。
170
+ // 优先用 agent 自填的 description / label / name;都没有就只显示 verb。
171
+ summary = pickString("description", "label", "name", "selector");
172
+ break;
173
+ case "type":
174
+ case "fill":
175
+ case "input":
176
+ summary = pickString("text", "content", "value");
177
+ break;
178
+ // scroll / back / forward / reload / screenshot / close 一般无需附带 summary
179
+ default:
180
+ summary = "";
181
+ }
182
+ return { verb, summary };
183
+ }
184
+ /**
185
+ * 把 SDK onItemEvent 给的 title 清掉常见的 toolName 前缀,避免 "browser browser https://..." 这种重复。
186
+ * 例如 title="browser https://www.baidu.com/..."、toolName="browser" → 返回 "https://www.baidu.com/..."。
187
+ */
188
+ function stripToolNamePrefix(title, toolName) {
189
+ if (!title || !toolName)
190
+ return title;
191
+ const trimmed = title.trim();
192
+ if (trimmed.toLowerCase().startsWith(`${toolName.toLowerCase()} `)) {
193
+ return trimmed.slice(toolName.length + 1).trim();
194
+ }
195
+ return trimmed;
196
+ }
197
+ /**
198
+ * T1:工具开始 → running 帧。
199
+ *
200
+ * 文案优先级(自上而下,命中即返回):
201
+ * 1. args 摘要(command/path/url/targetUrl/... 见 ARG_PRIORITY_KEYS)
202
+ * 2. fallbackMeta(来自 SDK onItemEvent 的 payload.meta,通常是裸 URL/路径)
203
+ * 3. fallbackTitle(去掉 toolName 前缀后的剩余部分)
204
+ * 4. 兜底:仅 verb,不带摘要
205
+ *
206
+ * 设计目的:让 running 帧一次性带上有信息量的内容(如 URL),避免后续 done 帧
207
+ * 出现 "覆盖还是不覆盖" 的两难。done 帧仍按原策略:summary/title/meta 全空 → text 留空。
208
+ *
209
+ * @param stepId 调用方维护的步骤 ID(onToolStart 用 genStepId 生成)
210
+ * @param seq 时间线位置序号(由 sink 维护的全局自增计数器)
211
+ * @param toolName 原始工具名
212
+ * @param args onToolStart 透传的工具参数(SDK 已提供)
213
+ * @param fallbackMeta onItemEvent(start) 透传的 meta,args 摸不出内容时兜底
214
+ * @param fallbackTitle onItemEvent(start) 透传的 title,args/meta 都摸不出内容时兜底
215
+ */
216
+ export function buildRunningStep(stepId, seq, toolName, args, fallbackMeta, fallbackTitle) {
217
+ const { type, verb: defaultVerb } = classifyTool(toolName);
218
+ // browser 工具特化:按 action 选择 verb / summary,避免 targetId 这类内部句柄进入文案。
219
+ // 未命中(无 args / action 不在白名单)时返回 null,回退到通用路径。
220
+ let verb = defaultVerb;
221
+ let summary = "";
222
+ let browserHit = false;
223
+ if (type === "browser") {
224
+ const browserSummary = summarizeBrowserArgs(args);
225
+ if (browserSummary) {
226
+ verb = browserSummary.verb;
227
+ summary = browserSummary.summary;
228
+ browserHit = true;
229
+ }
230
+ }
231
+ // 1. 通用 args 摘要(browser 已特化命中则跳过,避免被通用规则覆盖回 targetId 等无语义内容)
232
+ if (!browserHit) {
233
+ summary = summarizeArgs(args);
234
+ }
235
+ // 2. args 没摸出内容 → meta 兜底
236
+ // 注意:browser 特化已命中但 summary 为空(如 screenshot / scroll / back 等无副词 action)时,
237
+ // 不再回退到 meta / title。原因是 SDK 在这些场景的 meta 多为内部句柄(如 "5" / "7" / "1_124"),
238
+ // 一旦兜底就会出现 "执行了 页面截图: 5" 这类噪音。命中即视为"verb 已经讲清楚了"。
239
+ if (!summary && !browserHit && fallbackMeta && fallbackMeta.trim()) {
240
+ summary = truncate(fallbackMeta.trim());
241
+ }
242
+ // 3. meta 也没有 → title 去前缀后兜底(同上,browser 特化命中后不再走 title)
243
+ if (!summary && !browserHit && fallbackTitle && fallbackTitle.trim()) {
244
+ const cleaned = stripToolNamePrefix(fallbackTitle, toolName);
245
+ if (cleaned)
246
+ summary = truncate(cleaned);
247
+ }
248
+ const text = summary ? `执行了 ${verb}: ${summary}` : `执行了 ${verb}`;
249
+ return {
250
+ stepId,
251
+ seq,
252
+ type,
253
+ text,
254
+ status: "running",
255
+ toolName,
256
+ detail: summary || undefined,
257
+ };
258
+ }
259
+ /**
260
+ * T3:工具结束 → done/error 帧。
261
+ *
262
+ * 入参来自 openclaw `onItemEvent` 的 payload,重点字段:
263
+ * - itemId 备用 stepId(实测 SDK 随机生成,与 running 时的 stepId 对不上,
264
+ * 所以本函数优先用 fallbackStepId,由调用方传入 running 时缓存的 lastToolStepId)
265
+ * - status "completed" / "failed" / "error" / ...
266
+ * - summary 一行式结果摘要("已进入腾讯云轻量应用服务器产品页")
267
+ * - title SDK 给的成品文案("browser https://www.baidu.com/..."),多数场景是 running 时 URL 的回声
268
+ * - meta SDK 内部分类(多数场景与 title 同源,是 running 文案的回声)
269
+ * - progressText 中间态进度文案(不适合做最终态摘要)
270
+ *
271
+ * 文案规则(见 docs/thinking-step-protocol.md,v7 收紧):
272
+ * - 成功:**只用 `summary`**(agent 真正写给用户看的结果摘要)。
273
+ * summary 为空时 text 留空,前端识别为"无新文案",仅切状态/图标 spinner→✓,
274
+ * 保留 running 行的原文本(避免 SDK title/meta 是 running URL 回声,覆盖后出现
275
+ * "browser https://www.baidu.com/..." 这类劣化文案)。
276
+ * - 失败:summary > `${verb}失败`。**不再**裸取 title / meta / progressText 作为
277
+ * 主文案,因为实测 SDK 在 onItemEvent(end, status=error) 的 title 常常是
278
+ * `"browser"` / `"browser target t1, ref 1_124"` 这类内部句柄回声,对用户无意义。
279
+ * verb 已按工具类型分类(如 "操作浏览器" / "执行命令"),保证最低有可读语义。
280
+ * title/meta/progressText 改为 detail 折叠展示,方便排障但不污染主文案。
281
+ */
282
+ export function buildDoneStep(payload, fallbackStepId, seq, fallbackToolName) {
283
+ // toolName 优先用 fallbackToolName(来自 running 时缓存的 lastToolName,已是正确值),
284
+ // payload.name 在 onItemEvent 里经常为空 / 取值奇怪,避免把 verb 分类带偏。
285
+ const toolName = fallbackToolName ?? payload.name ?? "unknown";
286
+ const { type, verb } = classifyTool(toolName);
287
+ const isError = payload.status === "failed" ||
288
+ payload.status === "error" ||
289
+ payload.status === "cancelled";
290
+ const summary = (payload.summary ?? "").trim();
291
+ const title = (payload.title ?? "").trim();
292
+ const meta = (payload.meta ?? "").trim();
293
+ const progress = (payload.progressText ?? "").trim();
294
+ // 成功路径:只采纳 summary(agent 总结的真摘要)。title/meta 实测多为 running 文案回声,
295
+ // 不再作为成功 done 的 text 来源;progress 是中间态,也不适合做最终态。
296
+ // 失败路径:v7 收紧 —— 不再裸取 title / meta / progressText(实测多为 SDK 内部句柄回声,
297
+ // 如 `"browser"` / `"browser target t1, ref 1_124"` 这类对用户无意义的内容)。
298
+ // 只采纳 summary(agent 写的失败原因);缺失则用 `${verb}失败`,verb 已按工具
299
+ // 类型分类(如 "操作浏览器" / "执行命令"),保证有可读语义。
300
+ // title/meta/progressText 改为 detail 折叠展示,方便排障但不出现在主文案。
301
+ const text = isError
302
+ ? summary || `${verb}失败`
303
+ : summary;
304
+ // detail 优先级:失败时把 SDK 的原始 title / meta / progress 拼起来供折叠查看(排障用);
305
+ // 成功时仅把进度文案塞进去(与 v5 一致)。
306
+ let detail;
307
+ if (isError) {
308
+ const debugParts = [title, meta, progress].filter((s) => s && s !== text);
309
+ detail = debugParts.length > 0 ? debugParts.join(" · ") : undefined;
310
+ }
311
+ else {
312
+ detail = progress && progress !== text ? progress : undefined;
313
+ }
314
+ return {
315
+ // stepId 优先用 fallbackStepId(调用方传入的 lastToolStepId),保证前端按 stepId
316
+ // 合并到 running 那一行;payload.itemId 因 SDK 随机分配,仅作最后兜底。
317
+ stepId: fallbackStepId || payload.itemId || "unknown",
318
+ seq,
319
+ type,
320
+ text,
321
+ status: isError ? "error" : "done",
322
+ toolName,
323
+ detail,
324
+ };
325
+ }
@@ -1,4 +1,23 @@
1
1
  /**
2
2
  * LightClaw — 流式输出类型定义
3
3
  */
4
- export {};
4
+ /**
5
+ * 默认聚合参数(详见 ADR-017 数据评估)。
6
+ *
7
+ * 调优记录:
8
+ * - v1(minChars=24, idleMs=120):合并率 ~95%(500 字 200+ 帧 → 约 8-9 帧),
9
+ * 但视觉上"打字一卡一卡",每 1.2-2 秒才更新一次,且尾部有 ~350ms 停顿感。
10
+ * - v2(当前,minChars=24, idleMs=120):兼顾流畅度与节流效率
11
+ * · minChars=24 ≈ 1 句中文/半行英文,承载 ~10 个模型 token / 帧
12
+ * · idleMs=120 远低于人眼感知卡顿阈值(约 400-500ms),尾部无停顿感
13
+ * · 预估合并率仍 >80%(200+ token → 约 25-30 帧),更新频率 ~1.3-2 帧/秒,
14
+ * 与主流 Chat 产品(ChatGPT / Claude)网页端体感一致
15
+ *
16
+ * 实际合并比可通过 `[coalesce-summary]` 日志中的 `avgTokensPerFrame` 字段观测,
17
+ * 据此进一步调参。
18
+ */
19
+ export const DEFAULT_PARTIAL_COALESCE = {
20
+ minChars: 24,
21
+ idleMs: 120,
22
+ flushFirstFrame: true,
23
+ };
@@ -7,38 +7,38 @@
7
7
  *
8
8
  * 工具名: lightclaw_get_file_url
9
9
  */
10
- import * as fs from "node:fs";
11
- import * as path from "node:path";
12
- import { getFileDownloadUrl, downloadFileFromServer, uploadFileToServer, } from "./file-storage.js";
13
- import { formatFileSize } from "./media.js";
14
- import { resolveEffectiveApiKey } from "./config.js";
10
+ import * as fs from 'node:fs';
11
+ import * as path from 'node:path';
12
+ import { getFileDownloadUrl, downloadFileFromServer, uploadFileToServer } from '../file-storage.js';
13
+ import { formatFileSize } from '../media.js';
14
+ import { resolveEffectiveApiKey } from '../config.js';
15
15
  // ============================================================
16
16
  // 工具参数 schema
17
17
  // ============================================================
18
- export const DOWNLOAD_TOOL_NAME = "lightclaw_get_file_url";
18
+ export const DOWNLOAD_TOOL_NAME = 'lightclaw_get_file_url';
19
19
  export const downloadToolSchema = {
20
- type: "object",
20
+ type: 'object',
21
21
  properties: {
22
22
  action: {
23
- type: "string",
24
- enum: ["get_url", "download_to_local", "upload_and_get_url"],
25
- description: "Action to perform: " +
23
+ type: 'string',
24
+ enum: ['get_url', 'download_to_local', 'upload_and_get_url'],
25
+ description: 'Action to perform: ' +
26
26
  "'get_url' — get the public download URL for a previously uploaded file (by filePath); " +
27
27
  "'download_to_local' — download a cloud file to local directory; " +
28
28
  "'upload_and_get_url' — upload a local file and return its public download URL.",
29
29
  },
30
30
  filePath: {
31
- type: "string",
31
+ type: 'string',
32
32
  description: "For 'get_url' and 'download_to_local': the cloud file path (e.g. '2026-03-06/report.pdf'). " +
33
33
  "For 'upload_and_get_url': the local file path to upload.",
34
34
  },
35
35
  localDir: {
36
- type: "string",
36
+ type: 'string',
37
37
  description: "For 'download_to_local' only: the local directory to save the downloaded file. " +
38
- "Defaults to the current working directory if not specified.",
38
+ 'Defaults to the current working directory if not specified.',
39
39
  },
40
40
  },
41
- required: ["action", "filePath"],
41
+ required: ['action', 'filePath'],
42
42
  };
43
43
  // ============================================================
44
44
  // 工具注册(飞书工厂函数模式)
@@ -51,34 +51,34 @@ export function registerDownloadTool(api) {
51
51
  const sessionKey = ctx.sessionKey;
52
52
  return {
53
53
  name: DOWNLOAD_TOOL_NAME,
54
- description: "Manage files on cloud storage. " +
55
- "Actions: " +
54
+ description: 'Manage files on cloud storage. ' +
55
+ 'Actions: ' +
56
56
  "(1) 'get_url' — get a public download URL for a previously uploaded file (by its filePath); " +
57
57
  "(2) 'download_to_local' — download a cloud file back to the local filesystem for further processing; " +
58
58
  "(3) 'upload_and_get_url' — upload a single local file and return its public download URL. " +
59
- "Note: For batch uploading files to share with users, prefer lightclaw_upload_file instead.",
59
+ 'Note: For batch uploading files to share with users, prefer lightclaw_upload_file instead.',
60
60
  parameters: downloadToolSchema,
61
61
  async execute(_toolCallId, params) {
62
62
  // 每次 execute 时动态解析 apiKey(多 key 模式下通过 sessionKey 直接获取)
63
63
  const apiKey = resolveEffectiveApiKey({ sessionKey });
64
64
  // log.warn(`[lightclaw_get_file_url] resolved apiKey="${apiKey?.slice(0, 8)}..."`);
65
65
  const { action, filePath, localDir } = params;
66
- if (!filePath || typeof filePath !== "string") {
66
+ if (!filePath || typeof filePath !== 'string') {
67
67
  return {
68
- content: [{ type: "text", text: "Error: filePath is required." }],
68
+ content: [{ type: 'text', text: 'Error: filePath is required.' }],
69
69
  };
70
70
  }
71
71
  try {
72
72
  switch (action) {
73
- case "get_url": {
73
+ case 'get_url': {
74
74
  const url = getFileDownloadUrl(filePath);
75
- const fileName = filePath.split("/").pop() || filePath;
75
+ const fileName = filePath.split('/').pop() || filePath;
76
76
  return {
77
- content: [{ type: "text", text: `[${fileName}](${url})` }],
77
+ content: [{ type: 'text', text: `[${fileName}](${url})` }],
78
78
  details: { action, filePath, url },
79
79
  };
80
80
  }
81
- case "download_to_local": {
81
+ case 'download_to_local': {
82
82
  const result = await downloadFileFromServer(filePath, { apiKey });
83
83
  const targetDir = localDir || process.cwd();
84
84
  // 确保目录存在
@@ -88,10 +88,12 @@ export function registerDownloadTool(api) {
88
88
  const targetPath = path.join(targetDir, result.fileName);
89
89
  fs.writeFileSync(targetPath, result.buffer);
90
90
  return {
91
- content: [{
92
- type: "text",
91
+ content: [
92
+ {
93
+ type: 'text',
93
94
  text: `File downloaded to: ${targetPath} (${formatFileSize(result.buffer.length)}, ${result.contentType})`,
94
- }],
95
+ },
96
+ ],
95
97
  details: {
96
98
  action,
97
99
  filePath,
@@ -101,19 +103,21 @@ export function registerDownloadTool(api) {
101
103
  },
102
104
  };
103
105
  }
104
- case "upload_and_get_url": {
106
+ case 'upload_and_get_url': {
105
107
  if (!fs.existsSync(filePath)) {
106
108
  return {
107
- content: [{ type: "text", text: `Error: local file not found: ${filePath}` }],
109
+ content: [{ type: 'text', text: `Error: local file not found: ${filePath}` }],
108
110
  };
109
111
  }
110
112
  const uploadResult = await uploadFileToServer(filePath, { apiKey });
111
113
  const fileName = path.basename(filePath);
112
114
  return {
113
- content: [{
114
- type: "text",
115
+ content: [
116
+ {
117
+ type: 'text',
115
118
  text: `File uploaded.\n\n[${fileName}](${uploadResult.url})`,
116
- }],
119
+ },
120
+ ],
117
121
  details: {
118
122
  action,
119
123
  localPath: filePath,
@@ -124,17 +128,19 @@ export function registerDownloadTool(api) {
124
128
  }
125
129
  default:
126
130
  return {
127
- content: [{
128
- type: "text",
131
+ content: [
132
+ {
133
+ type: 'text',
129
134
  text: `Error: unknown action '${action}'. Use 'get_url', 'download_to_local', or 'upload_and_get_url'.`,
130
- }],
135
+ },
136
+ ],
131
137
  };
132
138
  }
133
139
  }
134
140
  catch (err) {
135
141
  const errMsg = err instanceof Error ? err.message : String(err);
136
142
  return {
137
- content: [{ type: "text", text: `Error: ${errMsg}` }],
143
+ content: [{ type: 'text', text: `Error: ${errMsg}` }],
138
144
  };
139
145
  }
140
146
  },