lightclawbot 1.2.5 → 1.2.6
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/dist/src/history/message-parser.js +20 -0
- package/dist/src/history/session-reader.js +17 -2
- package/dist/src/history/usage-aggregator.js +53 -0
- package/dist/src/inbound.js +3 -0
- package/dist/src/socket/agent-soul.js +41 -0
- package/dist/src/socket/handlers.js +55 -4
- package/dist/src/streaming/stream-reply-sink.js +55 -3
- package/dist/src/usage/index.js +4 -0
- package/dist/src/usage/normalize.js +47 -0
- package/dist/src/usage/types.js +1 -0
- package/dist/src/utils/common.js +8 -5
- package/node_modules/ws/lib/receiver.js +54 -0
- package/node_modules/ws/lib/websocket-server.js +8 -0
- package/node_modules/ws/lib/websocket.js +14 -0
- package/node_modules/ws/package.json +1 -1
- package/package.json +1 -1
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* - 消息标准化
|
|
12
12
|
*/
|
|
13
13
|
import { stripTransportMetadata, extractFileAttachments, deduplicateFiles, } from "./text-processing.js";
|
|
14
|
+
import { normalizeUsage } from "../usage/index.js";
|
|
14
15
|
// ============================================================
|
|
15
16
|
// 系统注入消息检测
|
|
16
17
|
// ============================================================
|
|
@@ -227,6 +228,24 @@ export function normalizeMessage(msg) {
|
|
|
227
228
|
|| undefined;
|
|
228
229
|
toolResult = { toolCallId, name, output };
|
|
229
230
|
}
|
|
231
|
+
// assistant 角色:归一 usage 字段(缺失时 normalizeUsage 返回 null)。
|
|
232
|
+
// OpenClaw jsonl 在 message 节点上挂 provider/model/api 三个上下文字段,
|
|
233
|
+
// 传给 normalizeUsage 用于回填到 UnifiedUsage 顶层。
|
|
234
|
+
const usage = normalizedRole === "assistant"
|
|
235
|
+
? normalizeUsage(msg.usage, {
|
|
236
|
+
provider: typeof msg.provider === "string" ? msg.provider : undefined,
|
|
237
|
+
model: typeof msg.model === "string" ? msg.model : undefined,
|
|
238
|
+
api: typeof msg.api === "string" ? msg.api : undefined,
|
|
239
|
+
})
|
|
240
|
+
: null;
|
|
241
|
+
// 过滤全零 usage(来自 LLM 调用失败的场景,如 401 / 404 / 网络异常等):
|
|
242
|
+
// OpenClaw 对错误的 assistant 消息也会落盘 usage 字段,但所有 token 字段全是 0。
|
|
243
|
+
// 这种数据没有任何信息量,透出反而会误导用户在历史里看到「0 token」当作"无消耗"展示。
|
|
244
|
+
// 过滤后 HistoryMessage.usage 缺失 → 与"未知/未上报"语义一致,前端按 undefined 渲染即可。
|
|
245
|
+
const usageIsEmpty = usage
|
|
246
|
+
&& !usage.inputTokens && !usage.outputTokens && !usage.totalTokens
|
|
247
|
+
&& !usage.cachedInputTokens && !usage.cacheWriteTokens && !usage.reasoningTokens;
|
|
248
|
+
const effectiveUsage = usageIsEmpty ? null : usage;
|
|
230
249
|
// 跳过完全空的消息
|
|
231
250
|
if (!text && !toolCalls && !toolResult && !thinking && !files?.length)
|
|
232
251
|
return null;
|
|
@@ -238,5 +257,6 @@ export function normalizeMessage(msg) {
|
|
|
238
257
|
...(toolResult && { toolResult }),
|
|
239
258
|
...(thinking && { thinking }),
|
|
240
259
|
...(files && files.length > 0 && { files }),
|
|
260
|
+
...(effectiveUsage && { usage: effectiveUsage }),
|
|
241
261
|
};
|
|
242
262
|
}
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import fs from "node:fs";
|
|
12
12
|
import { resolveTranscriptPath, loadSessionStore, resolveTranscriptPathBySessionId } from "./session-store.js";
|
|
13
13
|
import { isSystemInjectedUserMessage, normalizeMessage } from "./message-parser.js";
|
|
14
|
+
import { aggregateUsageByTurn } from "./usage-aggregator.js";
|
|
14
15
|
import { listCronSessions, classifySessionKey, extractCronJobId, extractCronJobIdsFromTranscript, findCronSessionsByJobIds, } from "./cron-utils.js";
|
|
15
16
|
// ============================================================
|
|
16
17
|
// 核心读取:单个 Session
|
|
@@ -30,7 +31,10 @@ export function readSessionHistory(sessionKey, opts) {
|
|
|
30
31
|
return [];
|
|
31
32
|
try {
|
|
32
33
|
const raw = fs.readFileSync(filePath, "utf-8");
|
|
33
|
-
|
|
34
|
+
const messages = parseTranscriptLines(raw, { limit, chatOnly });
|
|
35
|
+
// 按"轮次"聚合 usage(详见 aggregateUsageByTurn 注释)
|
|
36
|
+
aggregateUsageByTurn(messages);
|
|
37
|
+
return messages;
|
|
34
38
|
}
|
|
35
39
|
catch {
|
|
36
40
|
return [];
|
|
@@ -72,7 +76,10 @@ export function readSessionHistoryTail(sessionKey, opts) {
|
|
|
72
76
|
if (readStart > 0 && lines.length > 0) {
|
|
73
77
|
lines.shift();
|
|
74
78
|
}
|
|
75
|
-
|
|
79
|
+
const messages = parseLines(lines, { limit, chatOnly });
|
|
80
|
+
// 按"轮次"聚合 usage(详见 aggregateUsageByTurn 注释)
|
|
81
|
+
aggregateUsageByTurn(messages);
|
|
82
|
+
return messages;
|
|
76
83
|
}
|
|
77
84
|
catch {
|
|
78
85
|
return [];
|
|
@@ -198,6 +205,10 @@ export function readSessionHistoryWithCron(sessionKey, opts) {
|
|
|
198
205
|
// 6. 按时间合并
|
|
199
206
|
const merged = [...mainMessages, ...cronMessages]
|
|
200
207
|
.sort((a, b) => (a.timestamp ?? 0) - (b.timestamp ?? 0));
|
|
208
|
+
// 主会话与 cron 消息合并后再聚合一次:cron 触发的"system: ⏰ ..."类消息
|
|
209
|
+
// 通常被识别为 user 角色(系统注入),会自然分隔轮次,因此聚合不会把
|
|
210
|
+
// 主会话和 cron 提醒的 usage 错误合并。
|
|
211
|
+
aggregateUsageByTurn(merged);
|
|
201
212
|
return merged.length > limit ? merged.slice(-limit) : merged;
|
|
202
213
|
}
|
|
203
214
|
// ============================================================
|
|
@@ -278,6 +289,10 @@ export function readSessionHistoriesByIds(sessionIds, opts) {
|
|
|
278
289
|
return [];
|
|
279
290
|
// 多段合并:按 timestamp 升序;无 timestamp 的消息按相对顺序兜底
|
|
280
291
|
all.sort((a, b) => (a.timestamp ?? 0) - (b.timestamp ?? 0));
|
|
292
|
+
// 合并后再做 usage 轮次聚合:跨 sessionId 的同一对话回合也能被识别
|
|
293
|
+
// (注意:每个 sessionId 内部的消息已经在 readSessionHistory 中聚合过一次,
|
|
294
|
+
// 幂等,再聚合一次结果不变;此处主要解决跨 reset 的轮次合并场景)
|
|
295
|
+
aggregateUsageByTurn(all);
|
|
281
296
|
return all.length > limit ? all.slice(-limit) : all;
|
|
282
297
|
}
|
|
283
298
|
// ============================================================
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LightClaw — Usage 轮次聚合、一次问答只展示一次消耗。
|
|
3
|
+
*/
|
|
4
|
+
/**
|
|
5
|
+
* 把每一轮中除"最后一条" assistant 之外的所有 assistant 消息的 usage 字段删掉。
|
|
6
|
+
* - **就地修改** 传入的 messages 数组中的 assistant 消息(不创建新数组);
|
|
7
|
+
* - 同轮内中间 assistant 消息:删掉 usage 字段;
|
|
8
|
+
* - 同轮内最后一条 assistant 消息:保持原 usage 不变;
|
|
9
|
+
* - 整轮内没有 usage 的消息不做任何修改。
|
|
10
|
+
*
|
|
11
|
+
* 调用时机:在 4 个公共读取入口(readSessionHistory / Tail / ByIds / WithCron)
|
|
12
|
+
* 的最后一步统一调用,确保聚合基于完整对话流。
|
|
13
|
+
*
|
|
14
|
+
* @param messages 已 normalize 完成的历史消息数组(按时间正序)
|
|
15
|
+
*/
|
|
16
|
+
export function aggregateUsageByTurn(messages) {
|
|
17
|
+
// 当前轮内最后一条 assistant 的下标(轮次结束时它的 usage 保留)
|
|
18
|
+
let lastAssistantIdx = -1;
|
|
19
|
+
// 当前轮内除"最后一条"之外的 assistant 下标(这些节点的 usage 要被清空)
|
|
20
|
+
let intermediateAssistantIdxs = [];
|
|
21
|
+
/** 把当前轮的中间 assistant 节点 usage 全部清空,重置状态准备进入下一轮。 */
|
|
22
|
+
const flushTurn = () => {
|
|
23
|
+
for (const idx of intermediateAssistantIdxs) {
|
|
24
|
+
if (messages[idx]?.usage) {
|
|
25
|
+
delete messages[idx].usage;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
lastAssistantIdx = -1;
|
|
29
|
+
intermediateAssistantIdxs = [];
|
|
30
|
+
};
|
|
31
|
+
for (let i = 0; i < messages.length; i += 1) {
|
|
32
|
+
const msg = messages[i];
|
|
33
|
+
// cron 提醒类消息(带 cronInfo)属于独立的提醒上下文,不参与主会话轮次聚合,
|
|
34
|
+
// 保留它们各自的 usage 原样展示。
|
|
35
|
+
if (msg.cronInfo)
|
|
36
|
+
continue;
|
|
37
|
+
if (msg.role === "user") {
|
|
38
|
+
// 新一轮开始前,先把上一轮的中间 assistant usage 清空
|
|
39
|
+
flushTurn();
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
if (msg.role !== "assistant")
|
|
43
|
+
continue; // tool / system 节点不参与
|
|
44
|
+
// 把上一个"最后一条"挪到中间列表(它的 usage 不再代表轮末,要删)
|
|
45
|
+
if (lastAssistantIdx >= 0) {
|
|
46
|
+
intermediateAssistantIdxs.push(lastAssistantIdx);
|
|
47
|
+
}
|
|
48
|
+
// 当前节点暂定为"最后一条"
|
|
49
|
+
lastAssistantIdx = i;
|
|
50
|
+
}
|
|
51
|
+
// 处理最后一轮(可能没有后续 user 消息触发 flushTurn)
|
|
52
|
+
flushTurn();
|
|
53
|
+
}
|
package/dist/src/inbound.js
CHANGED
|
@@ -246,6 +246,9 @@ export function createInboundHandler(account, emitter, log) {
|
|
|
246
246
|
log,
|
|
247
247
|
effectiveApiKey,
|
|
248
248
|
typingAlreadyStarted: true,
|
|
249
|
+
// 透传 sessionKey + agentId 给 sink,markComplete 时用来从 transcript 兜底读 usage
|
|
250
|
+
sessionKey: route?.sessionKey,
|
|
251
|
+
agentId: resolvedAgentId,
|
|
249
252
|
}, { ...prefixOptions, onModelSelected }, signalCtx);
|
|
250
253
|
// 8. 分发给 AI 引擎(真流式)。abort 由 openclaw fast-abort 处理,sink 层
|
|
251
254
|
// 把英文回执汉化;此处仅兜底自身抛错。
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
export const LIGHTSOUL_CONFIG_PATH = path.join(process.env.HOME || '', '.config/lightsoul/config.json');
|
|
4
|
+
export function parseSoulIdMap(raw) {
|
|
5
|
+
const map = {};
|
|
6
|
+
let parsed;
|
|
7
|
+
try {
|
|
8
|
+
parsed = JSON.parse(raw);
|
|
9
|
+
}
|
|
10
|
+
catch (error) {
|
|
11
|
+
console.error('Failed to parse lightsoul config:', error);
|
|
12
|
+
return map;
|
|
13
|
+
}
|
|
14
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
15
|
+
return map;
|
|
16
|
+
}
|
|
17
|
+
for (const [agentId, value] of Object.entries(parsed)) {
|
|
18
|
+
if (typeof value === 'string' && value.trim()) {
|
|
19
|
+
map[agentId] = value.trim();
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
23
|
+
const soulId = value.soulId;
|
|
24
|
+
if (typeof soulId === 'string' && soulId.trim()) {
|
|
25
|
+
map[agentId] = soulId.trim();
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return map;
|
|
30
|
+
}
|
|
31
|
+
export function readSoulIdMap(configPath = LIGHTSOUL_CONFIG_PATH) {
|
|
32
|
+
return parseSoulIdMap(fs.readFileSync(configPath, 'utf8'));
|
|
33
|
+
}
|
|
34
|
+
export function attachSoulIdsToAgents(agents, soulMap) {
|
|
35
|
+
return agents.map((agent) => {
|
|
36
|
+
const id = typeof agent.id === 'string' ? agent.id.trim() : '';
|
|
37
|
+
const name = typeof agent.name === 'string' ? agent.name.trim() : '';
|
|
38
|
+
const soulId = (id && soulMap[id]) || (name && soulMap[name]) || undefined;
|
|
39
|
+
return soulId ? { ...agent, soulId } : agent;
|
|
40
|
+
});
|
|
41
|
+
}
|
|
@@ -20,8 +20,52 @@ import { handleChatList, handleChatCreate, handleChatUpdate, handleChatDelete }
|
|
|
20
20
|
import { uploadFileToServer } from '../file-storage.js';
|
|
21
21
|
import { guessMimeByExt } from '../media.js';
|
|
22
22
|
import { ensureSessionInHistory, readChatsFile, resolveChatsFilePath } from '../utils/common.js';
|
|
23
|
+
import { attachSoulIdsToAgents, readSoulIdMap, LIGHTSOUL_CONFIG_PATH } from './agent-soul.js';
|
|
23
24
|
import * as fs from 'node:fs';
|
|
24
25
|
import * as path from 'node:path';
|
|
26
|
+
const getNonEmptyString = (value) => {
|
|
27
|
+
if (typeof value !== 'string')
|
|
28
|
+
return undefined;
|
|
29
|
+
const trimmed = value.trim();
|
|
30
|
+
return trimmed || undefined;
|
|
31
|
+
};
|
|
32
|
+
const isMainAgent = (agent) => {
|
|
33
|
+
const id = getNonEmptyString(agent.id);
|
|
34
|
+
const name = getNonEmptyString(agent.name);
|
|
35
|
+
return id === DEFAULT_AGENT_ID || name === DEFAULT_AGENT_ID;
|
|
36
|
+
};
|
|
37
|
+
const withDefaultWorkspace = (agent, defaultWorkspace) => {
|
|
38
|
+
if (!defaultWorkspace || getNonEmptyString(agent.workspace))
|
|
39
|
+
return agent;
|
|
40
|
+
return { ...agent, workspace: defaultWorkspace };
|
|
41
|
+
};
|
|
42
|
+
/**
|
|
43
|
+
* 组装 agents:request 响应列表,确保单 agent 场景也返回默认 main agent。
|
|
44
|
+
*/
|
|
45
|
+
export const normalizeAgentsWithMainAgent = (currentCfg) => {
|
|
46
|
+
const agentsConfig = currentCfg.agents;
|
|
47
|
+
const agentsList = Array.isArray(agentsConfig?.list) ? agentsConfig.list : [];
|
|
48
|
+
const defaultWorkspace = getNonEmptyString(agentsConfig?.defaults?.workspace);
|
|
49
|
+
let hasMainAgent = false;
|
|
50
|
+
const normalizedAgents = agentsList.map((agent) => {
|
|
51
|
+
if (!isMainAgent(agent))
|
|
52
|
+
return agent;
|
|
53
|
+
hasMainAgent = true;
|
|
54
|
+
return withDefaultWorkspace(agent, defaultWorkspace);
|
|
55
|
+
});
|
|
56
|
+
if (hasMainAgent)
|
|
57
|
+
return normalizedAgents;
|
|
58
|
+
const mainAgent = {
|
|
59
|
+
id: DEFAULT_AGENT_ID,
|
|
60
|
+
name: DEFAULT_AGENT_ID,
|
|
61
|
+
displayName: DEFAULT_AGENT_ID,
|
|
62
|
+
isDefault: true,
|
|
63
|
+
};
|
|
64
|
+
if (defaultWorkspace) {
|
|
65
|
+
mainAgent.workspace = defaultWorkspace;
|
|
66
|
+
}
|
|
67
|
+
return [mainAgent, ...normalizedAgents];
|
|
68
|
+
};
|
|
25
69
|
/**
|
|
26
70
|
* 绑定所有 Socket.IO 事件监听器到指定 socket 实例。
|
|
27
71
|
*
|
|
@@ -267,7 +311,7 @@ export function bindSocketHandlers(socket, deps) {
|
|
|
267
311
|
});
|
|
268
312
|
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
269
313
|
// 事件:Agents 列表请求(agents:request)
|
|
270
|
-
// 职责:读取 openclaw.json 中 agents.list
|
|
314
|
+
// 职责:读取 openclaw.json 中 agents.list,并在单 agent 场景补齐默认 main(支持热更新,不缓存)
|
|
271
315
|
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
272
316
|
socket.on(EVENT_AGENTS_REQUEST, (data, ack) => {
|
|
273
317
|
// 立即回复 ACK,告知服务端请求已收到
|
|
@@ -280,14 +324,21 @@ export function bindSocketHandlers(socket, deps) {
|
|
|
280
324
|
// 获取插件运行时,读取最新配置
|
|
281
325
|
const pluginRuntime = getLightclawRuntime();
|
|
282
326
|
const currentCfg = pluginRuntime.config.loadConfig();
|
|
283
|
-
const agentsList = currentCfg
|
|
284
|
-
|
|
327
|
+
const agentsList = normalizeAgentsWithMainAgent(currentCfg);
|
|
328
|
+
let agentsWithSoulIds = agentsList;
|
|
329
|
+
try {
|
|
330
|
+
agentsWithSoulIds = attachSoulIdsToAgents(agentsList, readSoulIdMap());
|
|
331
|
+
}
|
|
332
|
+
catch (soulErr) {
|
|
333
|
+
log?.warn(`[${CHANNEL_KEY}] Failed to read soul config ${LIGHTSOUL_CONFIG_PATH}: ${soulErr instanceof Error ? soulErr.message : String(soulErr)}`);
|
|
334
|
+
}
|
|
335
|
+
log?.info(`[${CHANNEL_KEY}] Agents request: count=${agentsWithSoulIds.length}`);
|
|
285
336
|
const agentsMsgId = generateMsgId();
|
|
286
337
|
reliableEmitter.emitWithAck(EVENT_AGENTS_RESPONSE, {
|
|
287
338
|
msgId: agentsMsgId,
|
|
288
339
|
to: data.from,
|
|
289
340
|
from: botClientId,
|
|
290
|
-
agents:
|
|
341
|
+
agents: agentsWithSoulIds,
|
|
291
342
|
timestamp: Date.now(),
|
|
292
343
|
}, agentsMsgId);
|
|
293
344
|
}
|
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
* - onPartialReply → 计算增量 → emit stream_chunk
|
|
6
6
|
* - onToolStart → emit tool_start(前端决定如何渲染)
|
|
7
7
|
* - sendFinalReply → 跨轮次去重后发送剩余增量(或丢弃)
|
|
8
|
-
* - markComplete → 发 typing_stop;
|
|
8
|
+
* - markComplete → 从 transcript 读 usage 并 emit usage 帧 → 发 typing_stop;
|
|
9
|
+
* NO_REPLY 场景兜底一条提示
|
|
9
10
|
*
|
|
10
11
|
* 关键状态:
|
|
11
12
|
* streamedText 当前 assistant 轮已流式推送的文本
|
|
@@ -17,6 +18,7 @@ import { uploadFileToServer } from "../file-storage.js";
|
|
|
17
18
|
import { mediaUrlsToFiles } from "../media.js";
|
|
18
19
|
import { createDeltaTrackerState, toStreamDeltaText } from "./delta-tracker.js";
|
|
19
20
|
import { emitSignal } from "../utils/common.js";
|
|
21
|
+
import { readSessionHistoryTail } from "../history/index.js";
|
|
20
22
|
/** 派生/管理 subagent 的工具名;前端可据此特化渲染 tool_start。 */
|
|
21
23
|
const SUBAGENT_TOOL_NAMES = new Set([
|
|
22
24
|
"sessions_spawn",
|
|
@@ -30,7 +32,7 @@ function localizeAbortReplyText(text) {
|
|
|
30
32
|
return OPENCLAW_ABORT_REPLY_RE.test(text.trim()) ? LOCALIZED_ABORT_REPLY : text;
|
|
31
33
|
}
|
|
32
34
|
export function createStreamReplyConfig(opts, prefixOptions, signalCtx) {
|
|
33
|
-
const { emitter, targetId, originalMsgId, log, effectiveApiKey, typingAlreadyStarted } = opts;
|
|
35
|
+
const { emitter, targetId, originalMsgId, log, effectiveApiKey, typingAlreadyStarted, sessionKey, agentId } = opts;
|
|
34
36
|
// ── 增量追踪 & 已推送文本 ──
|
|
35
37
|
let partialReplyState = createDeltaTrackerState();
|
|
36
38
|
let streamedText = "";
|
|
@@ -54,6 +56,10 @@ export function createStreamReplyConfig(opts, prefixOptions, signalCtx) {
|
|
|
54
56
|
let completed = false;
|
|
55
57
|
/** openclaw abort 检测:sendFinalReply 收到 abort 文案时置 true,markComplete 据此跳过 NO_REPLY。 */
|
|
56
58
|
let abortDetected = false;
|
|
59
|
+
// ── Token 用量 ──
|
|
60
|
+
// 把每一轮的 usage 写入 transcript jsonl(与 history 模块同源),
|
|
61
|
+
// 因此在 markComplete 时从 transcript 末尾读最近一条 assistant 消息的 usage,
|
|
62
|
+
// 然后 emit `kind='usage'` 帧给前端。
|
|
57
63
|
// ── 串行化回调队列:保证 COS 上传等异步操作按顺序执行 ──
|
|
58
64
|
let queuePromise = Promise.resolve();
|
|
59
65
|
const enqueue = (fn) => {
|
|
@@ -223,13 +229,18 @@ export function createStreamReplyConfig(opts, prefixOptions, signalCtx) {
|
|
|
223
229
|
getQueuedCounts: () => ({ ...counts }),
|
|
224
230
|
getFailedCounts: () => ({ tool: 0, block: 0, final: 0 }),
|
|
225
231
|
/**
|
|
226
|
-
* 标记本次 dispatch 完成 → 发 typing_stop。
|
|
232
|
+
* 标记本次 dispatch 完成 → 发 usage 帧(如有)→ 发 typing_stop。
|
|
227
233
|
*
|
|
228
234
|
* NO_REPLY 仅在"LLM 正常完成一次完整 run 但没产出可见文本"时才触发。
|
|
229
235
|
* 以下场景均不发:
|
|
230
236
|
* - isAborted / abortDetected:/stop 或 abort 触发
|
|
231
237
|
* - hadLLMActivity 但 hadDispatch=false:LLM 启动但被中途打断(abort 没来得及 sendFinalReply)
|
|
232
238
|
* - !hadLLMActivity:followup 被 collect 合并 / plugin 拦截
|
|
239
|
+
*
|
|
240
|
+
* Token 用量帧的 emit 时机:
|
|
241
|
+
* 在 typing_stop **之前** 从 transcript 读 usage 并 emit 一次。前端依次收到:
|
|
242
|
+
* ...stream_chunk → tool_start/end → ... → usage(可选) → typing_stop
|
|
243
|
+
* 这样保证"一次问答一次消耗",且消耗信息在结束态前到达。
|
|
233
244
|
*/
|
|
234
245
|
markComplete: (markOpts) => {
|
|
235
246
|
completed = true;
|
|
@@ -263,6 +274,47 @@ export function createStreamReplyConfig(opts, prefixOptions, signalCtx) {
|
|
|
263
274
|
`toolStart=${toolStartCount} assistantMsg=${assistantMessageCount} ` +
|
|
264
275
|
`streamedLen=${streamedText.length} historyRounds=${streamedHistory.length}`);
|
|
265
276
|
}
|
|
277
|
+
// 在 typing_stop 之前 emit 一帧 usage(如有)。
|
|
278
|
+
// 数据源:从 transcript jsonl 读最近一条 assistant 消息的 usage(与 history 模块同源)。
|
|
279
|
+
// abort / 静默 dispatch / NO_REPLY 等异常路径下 transcript 可能没有可读 usage,
|
|
280
|
+
// 此时不 emit,前端按"无用量数据"渲染。
|
|
281
|
+
let usageToEmit;
|
|
282
|
+
// 非 abort + sessionKey 已知 → 从 transcript 读 usage
|
|
283
|
+
if (!isAborted && sessionKey) {
|
|
284
|
+
try {
|
|
285
|
+
// 找到本轮最后一条 assistant
|
|
286
|
+
const tailMessages = readSessionHistoryTail(sessionKey, {
|
|
287
|
+
limit: 10,
|
|
288
|
+
chatOnly: true,
|
|
289
|
+
agentId,
|
|
290
|
+
});
|
|
291
|
+
// 找到最后一条 assistant 消息(一轮的最后一条 = 本次问答的累计 usage)
|
|
292
|
+
let foundUsage;
|
|
293
|
+
for (let i = tailMessages.length - 1; i >= 0; i -= 1) {
|
|
294
|
+
const m = tailMessages[i];
|
|
295
|
+
if (m.role === "assistant" && m.usage) {
|
|
296
|
+
foundUsage = m.usage;
|
|
297
|
+
break;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
if (foundUsage) {
|
|
301
|
+
usageToEmit = foundUsage;
|
|
302
|
+
}
|
|
303
|
+
else {
|
|
304
|
+
log?.warn(`[${CHANNEL_KEY}] [stream] no assistant.usage found in transcript tail ` +
|
|
305
|
+
`(messages=${tailMessages.length})`);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
catch (err) {
|
|
309
|
+
log?.warn(`[${CHANNEL_KEY}] [stream] read transcript failed: ` +
|
|
310
|
+
`${err instanceof Error ? err.message : String(err)}`);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
if (usageToEmit) {
|
|
314
|
+
log?.info(`[${CHANNEL_KEY}] [stream] emit final usage: ` +
|
|
315
|
+
`input=${usageToEmit.inputTokens} output=${usageToEmit.outputTokens} total=${usageToEmit.totalTokens}`);
|
|
316
|
+
emitSignal(signalCtx, "usage", "", undefined, { usage: usageToEmit });
|
|
317
|
+
}
|
|
266
318
|
sendTypingStop();
|
|
267
319
|
},
|
|
268
320
|
};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LightClaw — Usage 归一函数
|
|
3
|
+
*
|
|
4
|
+
* 当前阶段直接读取 OpenClaw 的字段(input/output/...),
|
|
5
|
+
* 未来如果遇到和当前 OpenClaw 不一致的agent或者其他模型需要兼容
|
|
6
|
+
* 可以把本函数升级为「多适配器」,对外 API保持不变,调用方 0 改动。
|
|
7
|
+
*/
|
|
8
|
+
export function normalizeUsage(raw, ctx = {}) {
|
|
9
|
+
if (!raw || typeof raw !== 'object')
|
|
10
|
+
return null;
|
|
11
|
+
const r = raw;
|
|
12
|
+
// OpenClaw 落盘格式的字段直接取(v1:input/output;v2:inputTokens/outputTokens 双兼容)
|
|
13
|
+
const inputTokens = pickNumber(r, ['inputTokens', 'input']);
|
|
14
|
+
const outputTokens = pickNumber(r, ['outputTokens', 'output']);
|
|
15
|
+
// 三者全无则视为无效数据
|
|
16
|
+
if (inputTokens === undefined && outputTokens === undefined) {
|
|
17
|
+
const totalOnly = pickNumber(r, ['totalTokens', 'total']);
|
|
18
|
+
if (totalOnly === undefined)
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
const totalTokensRaw = pickNumber(r, ['totalTokens', 'total']);
|
|
22
|
+
const totalTokens = totalTokensRaw ?? (inputTokens ?? 0) + (outputTokens ?? 0);
|
|
23
|
+
return {
|
|
24
|
+
inputTokens,
|
|
25
|
+
outputTokens,
|
|
26
|
+
totalTokens,
|
|
27
|
+
cachedInputTokens: pickNumber(r, ['cachedInputTokens', 'cacheRead']),
|
|
28
|
+
cacheWriteTokens: pickNumber(r, ['cacheWriteTokens', 'cacheWrite']),
|
|
29
|
+
reasoningTokens: pickNumber(r, ['reasoningTokens']),
|
|
30
|
+
model: ctx.model,
|
|
31
|
+
provider: ctx.provider,
|
|
32
|
+
raw: r,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* 在对象中按候选键名顺序查找第一个 finite number 字段。
|
|
37
|
+
* 抽出此函数是为了让字段命名兼容(v1 短名 / v2 长名)逻辑保持一处,
|
|
38
|
+
* 后续如果 OpenClaw 又新增字段命名(比如 inputTokenCount),只需在候选列表里追加。
|
|
39
|
+
*/
|
|
40
|
+
function pickNumber(obj, keys) {
|
|
41
|
+
for (const key of keys) {
|
|
42
|
+
const v = obj[key];
|
|
43
|
+
if (typeof v === 'number' && Number.isFinite(v))
|
|
44
|
+
return v;
|
|
45
|
+
}
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/src/utils/common.js
CHANGED
|
@@ -50,12 +50,14 @@ export function buildAuthHeaders(apiKey) {
|
|
|
50
50
|
};
|
|
51
51
|
}
|
|
52
52
|
/**
|
|
53
|
-
* 统一的信号发送出口,收敛 typing / stream / tool 控制帧的构造逻辑。
|
|
53
|
+
* 统一的信号发送出口,收敛 typing / stream / tool / usage 控制帧的构造逻辑。
|
|
54
54
|
*
|
|
55
55
|
* - typing_start 不带 replyToMsgId(协议要求);其余帧都携带。
|
|
56
|
-
* -
|
|
56
|
+
* - topLevelExtra 用于透传顶层 kind 相关字段(如 tool_start 的 toolName/toolPhase、tool_end 的 toolIsError)。
|
|
57
|
+
* - innerExtra 用于在 PrivateMessageData.extra 内追加自定义字段(如 usage 帧的 usage 对象)。
|
|
58
|
+
* chatId 始终由本函数管理,调用方无需也不能在 innerExtra 中传 chatId(会被覆盖)。
|
|
57
59
|
*/
|
|
58
|
-
export function emitSignal(ctx, kind, content = '',
|
|
60
|
+
export function emitSignal(ctx, kind, content = '', topLevelExtra, innerExtra) {
|
|
59
61
|
const { emitter, targetId, replyMsgId, originalMsgId, agentId, chatId } = ctx;
|
|
60
62
|
return emitter.emit({
|
|
61
63
|
msgId: replyMsgId,
|
|
@@ -65,9 +67,10 @@ export function emitSignal(ctx, kind, content = '', extra) {
|
|
|
65
67
|
timestamp: Date.now(),
|
|
66
68
|
kind,
|
|
67
69
|
...(kind !== 'typing_start' ? { replyToMsgId: originalMsgId } : {}),
|
|
68
|
-
...
|
|
70
|
+
...topLevelExtra,
|
|
69
71
|
agentId,
|
|
70
|
-
|
|
72
|
+
// chatId 始终位于 extra.chatId;innerExtra 在前展开,确保 chatId 不被覆盖
|
|
73
|
+
extra: { ...(innerExtra ?? {}), chatId: chatId ?? '' },
|
|
71
74
|
});
|
|
72
75
|
}
|
|
73
76
|
/**
|
|
@@ -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
|
|
|
@@ -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
|
});
|