lightclawbot
Advanced tools
| /** | ||
| * LightClaw — Usage 轮次聚合、一次问答只展示一次消耗。 | ||
| */ | ||
| /** | ||
| * 把每一轮中除"最后一条" assistant 之外的所有 assistant 消息的 usage 字段删掉。 | ||
| * - **就地修改** 传入的 messages 数组中的 assistant 消息(不创建新数组); | ||
| * - 同轮内中间 assistant 消息:删掉 usage 字段; | ||
| * - 同轮内最后一条 assistant 消息:保持原 usage 不变; | ||
| * - 整轮内没有 usage 的消息不做任何修改。 | ||
| * | ||
| * 调用时机:在 4 个公共读取入口(readSessionHistory / Tail / ByIds / WithCron) | ||
| * 的最后一步统一调用,确保聚合基于完整对话流。 | ||
| * | ||
| * @param messages 已 normalize 完成的历史消息数组(按时间正序) | ||
| */ | ||
| export function aggregateUsageByTurn(messages) { | ||
| // 当前轮内最后一条 assistant 的下标(轮次结束时它的 usage 保留) | ||
| let lastAssistantIdx = -1; | ||
| // 当前轮内除"最后一条"之外的 assistant 下标(这些节点的 usage 要被清空) | ||
| let intermediateAssistantIdxs = []; | ||
| /** 把当前轮的中间 assistant 节点 usage 全部清空,重置状态准备进入下一轮。 */ | ||
| const flushTurn = () => { | ||
| for (const idx of intermediateAssistantIdxs) { | ||
| if (messages[idx]?.usage) { | ||
| delete messages[idx].usage; | ||
| } | ||
| } | ||
| lastAssistantIdx = -1; | ||
| intermediateAssistantIdxs = []; | ||
| }; | ||
| for (let i = 0; i < messages.length; i += 1) { | ||
| const msg = messages[i]; | ||
| // cron 提醒类消息(带 cronInfo)属于独立的提醒上下文,不参与主会话轮次聚合, | ||
| // 保留它们各自的 usage 原样展示。 | ||
| if (msg.cronInfo) | ||
| continue; | ||
| if (msg.role === "user") { | ||
| // 新一轮开始前,先把上一轮的中间 assistant usage 清空 | ||
| flushTurn(); | ||
| continue; | ||
| } | ||
| if (msg.role !== "assistant") | ||
| continue; // tool / system 节点不参与 | ||
| // 把上一个"最后一条"挪到中间列表(它的 usage 不再代表轮末,要删) | ||
| if (lastAssistantIdx >= 0) { | ||
| intermediateAssistantIdxs.push(lastAssistantIdx); | ||
| } | ||
| // 当前节点暂定为"最后一条" | ||
| lastAssistantIdx = i; | ||
| } | ||
| // 处理最后一轮(可能没有后续 user 消息触发 flushTurn) | ||
| flushTurn(); | ||
| } |
| /** | ||
| * LightClaw — Usage 模块 | ||
| */ | ||
| export { normalizeUsage } from './normalize.js'; |
| /** | ||
| * LightClaw — Usage 归一函数 | ||
| * | ||
| * 当前阶段直接读取 OpenClaw 的字段(input/output/...), | ||
| * 未来如果遇到和当前 OpenClaw 不一致的agent或者其他模型需要兼容 | ||
| * 可以把本函数升级为「多适配器」,对外 API保持不变,调用方 0 改动。 | ||
| */ | ||
| export function normalizeUsage(raw, ctx = {}) { | ||
| if (!raw || typeof raw !== 'object') | ||
| return null; | ||
| const r = raw; | ||
| // OpenClaw 落盘格式的字段直接取(v1:input/output;v2:inputTokens/outputTokens 双兼容) | ||
| const inputTokens = pickNumber(r, ['inputTokens', 'input']); | ||
| const outputTokens = pickNumber(r, ['outputTokens', 'output']); | ||
| // 三者全无则视为无效数据 | ||
| if (inputTokens === undefined && outputTokens === undefined) { | ||
| const totalOnly = pickNumber(r, ['totalTokens', 'total']); | ||
| if (totalOnly === undefined) | ||
| return null; | ||
| } | ||
| const totalTokensRaw = pickNumber(r, ['totalTokens', 'total']); | ||
| const totalTokens = totalTokensRaw ?? (inputTokens ?? 0) + (outputTokens ?? 0); | ||
| return { | ||
| inputTokens, | ||
| outputTokens, | ||
| totalTokens, | ||
| cachedInputTokens: pickNumber(r, ['cachedInputTokens', 'cacheRead']), | ||
| cacheWriteTokens: pickNumber(r, ['cacheWriteTokens', 'cacheWrite']), | ||
| reasoningTokens: pickNumber(r, ['reasoningTokens']), | ||
| model: ctx.model, | ||
| provider: ctx.provider, | ||
| raw: r, | ||
| }; | ||
| } | ||
| /** | ||
| * 在对象中按候选键名顺序查找第一个 finite number 字段。 | ||
| * 抽出此函数是为了让字段命名兼容(v1 短名 / v2 长名)逻辑保持一处, | ||
| * 后续如果 OpenClaw 又新增字段命名(比如 inputTokenCount),只需在候选列表里追加。 | ||
| */ | ||
| function pickNumber(obj, keys) { | ||
| for (const key of keys) { | ||
| const v = obj[key]; | ||
| if (typeof v === 'number' && Number.isFinite(v)) | ||
| return v; | ||
| } | ||
| return undefined; | ||
| } |
| export {}; |
@@ -14,2 +14,3 @@ /** | ||
| import { stripTransportMetadata, extractFileAttachments, deduplicateFiles, } from "./text-processing.js"; | ||
| import { normalizeUsage } from "../usage/index.js"; | ||
| // ============================================================ | ||
@@ -231,2 +232,20 @@ // 系统注入消息检测 | ||
| } | ||
| // assistant 角色:归一 usage 字段(缺失时 normalizeUsage 返回 null)。 | ||
| // OpenClaw jsonl 在 message 节点上挂 provider/model/api 三个上下文字段, | ||
| // 传给 normalizeUsage 用于回填到 UnifiedUsage 顶层。 | ||
| const usage = normalizedRole === "assistant" | ||
| ? normalizeUsage(msg.usage, { | ||
| provider: typeof msg.provider === "string" ? msg.provider : undefined, | ||
| model: typeof msg.model === "string" ? msg.model : undefined, | ||
| api: typeof msg.api === "string" ? msg.api : undefined, | ||
| }) | ||
| : null; | ||
| // 过滤全零 usage(来自 LLM 调用失败的场景,如 401 / 404 / 网络异常等): | ||
| // OpenClaw 对错误的 assistant 消息也会落盘 usage 字段,但所有 token 字段全是 0。 | ||
| // 这种数据没有任何信息量,透出反而会误导用户在历史里看到「0 token」当作"无消耗"展示。 | ||
| // 过滤后 HistoryMessage.usage 缺失 → 与"未知/未上报"语义一致,前端按 undefined 渲染即可。 | ||
| const usageIsEmpty = usage | ||
| && !usage.inputTokens && !usage.outputTokens && !usage.totalTokens | ||
| && !usage.cachedInputTokens && !usage.cacheWriteTokens && !usage.reasoningTokens; | ||
| const effectiveUsage = usageIsEmpty ? null : usage; | ||
| // 跳过完全空的消息 | ||
@@ -243,3 +262,4 @@ if (!text && !toolCalls && !toolResult && !thinking && !files?.length) | ||
| ...(files && files.length > 0 && { files }), | ||
| ...(effectiveUsage && { usage: effectiveUsage }), | ||
| }; | ||
| } |
@@ -14,2 +14,3 @@ /** | ||
| import { isSystemInjectedUserMessage, normalizeMessage } from "./message-parser.js"; | ||
| import { aggregateUsageByTurn } from "./usage-aggregator.js"; | ||
| import { listCronSessions, classifySessionKey, extractCronJobId, extractCronJobIdsFromTranscript, findCronSessionsByJobIds, } from "./cron-utils.js"; | ||
@@ -34,3 +35,6 @@ // ============================================================ | ||
| const raw = fs.readFileSync(filePath, "utf-8"); | ||
| return parseTranscriptLines(raw, { limit, chatOnly }); | ||
| const messages = parseTranscriptLines(raw, { limit, chatOnly }); | ||
| // 按"轮次"聚合 usage(详见 aggregateUsageByTurn 注释) | ||
| aggregateUsageByTurn(messages); | ||
| return messages; | ||
| } | ||
@@ -77,3 +81,6 @@ catch { | ||
| } | ||
| return parseLines(lines, { limit, chatOnly }); | ||
| const messages = parseLines(lines, { limit, chatOnly }); | ||
| // 按"轮次"聚合 usage(详见 aggregateUsageByTurn 注释) | ||
| aggregateUsageByTurn(messages); | ||
| return messages; | ||
| } | ||
@@ -204,2 +211,6 @@ catch { | ||
| .sort((a, b) => (a.timestamp ?? 0) - (b.timestamp ?? 0)); | ||
| // 主会话与 cron 消息合并后再聚合一次:cron 触发的"system: ⏰ ..."类消息 | ||
| // 通常被识别为 user 角色(系统注入),会自然分隔轮次,因此聚合不会把 | ||
| // 主会话和 cron 提醒的 usage 错误合并。 | ||
| aggregateUsageByTurn(merged); | ||
| return merged.length > limit ? merged.slice(-limit) : merged; | ||
@@ -285,2 +296,6 @@ } | ||
| all.sort((a, b) => (a.timestamp ?? 0) - (b.timestamp ?? 0)); | ||
| // 合并后再做 usage 轮次聚合:跨 sessionId 的同一对话回合也能被识别 | ||
| // (注意:每个 sessionId 内部的消息已经在 readSessionHistory 中聚合过一次, | ||
| // 幂等,再聚合一次结果不变;此处主要解决跨 reset 的轮次合并场景) | ||
| aggregateUsageByTurn(all); | ||
| return all.length > limit ? all.slice(-limit) : all; | ||
@@ -287,0 +302,0 @@ } |
@@ -249,2 +249,5 @@ /** | ||
| typingAlreadyStarted: true, | ||
| // 透传 sessionKey + agentId 给 sink,markComplete 时用来从 transcript 兜底读 usage | ||
| sessionKey: route?.sessionKey, | ||
| agentId: resolvedAgentId, | ||
| }, { ...prefixOptions, onModelSelected }, signalCtx); | ||
@@ -251,0 +254,0 @@ // 8. 分发给 AI 引擎(真流式)。abort 由 openclaw fast-abort 处理,sink 层 |
@@ -8,3 +8,4 @@ /** | ||
| * - sendFinalReply → 跨轮次去重后发送剩余增量(或丢弃) | ||
| * - markComplete → 发 typing_stop;NO_REPLY 场景兜底一条提示 | ||
| * - markComplete → 从 transcript 读 usage 并 emit usage 帧 → 发 typing_stop; | ||
| * NO_REPLY 场景兜底一条提示 | ||
| * | ||
@@ -21,2 +22,3 @@ * 关键状态: | ||
| import { emitSignal } from "../utils/common.js"; | ||
| import { readSessionHistoryTail } from "../history/index.js"; | ||
| /** 派生/管理 subagent 的工具名;前端可据此特化渲染 tool_start。 */ | ||
@@ -35,3 +37,3 @@ const SUBAGENT_TOOL_NAMES = new Set([ | ||
| export function createStreamReplyConfig(opts, prefixOptions, signalCtx) { | ||
| const { emitter, targetId, originalMsgId, log, effectiveApiKey, typingAlreadyStarted } = opts; | ||
| const { emitter, targetId, originalMsgId, log, effectiveApiKey, typingAlreadyStarted, sessionKey, agentId } = opts; | ||
| // ── 增量追踪 & 已推送文本 ── | ||
@@ -60,2 +62,6 @@ let partialReplyState = createDeltaTrackerState(); | ||
| let abortDetected = false; | ||
| // ── Token 用量 ── | ||
| // 把每一轮的 usage 写入 transcript jsonl(与 history 模块同源), | ||
| // 因此在 markComplete 时从 transcript 末尾读最近一条 assistant 消息的 usage, | ||
| // 然后 emit `kind='usage'` 帧给前端。 | ||
| // ── 串行化回调队列:保证 COS 上传等异步操作按顺序执行 ── | ||
@@ -230,3 +236,3 @@ let queuePromise = Promise.resolve(); | ||
| /** | ||
| * 标记本次 dispatch 完成 → 发 typing_stop。 | ||
| * 标记本次 dispatch 完成 → 发 usage 帧(如有)→ 发 typing_stop。 | ||
| * | ||
@@ -238,2 +244,7 @@ * NO_REPLY 仅在"LLM 正常完成一次完整 run 但没产出可见文本"时才触发。 | ||
| * - !hadLLMActivity:followup 被 collect 合并 / plugin 拦截 | ||
| * | ||
| * Token 用量帧的 emit 时机: | ||
| * 在 typing_stop **之前** 从 transcript 读 usage 并 emit 一次。前端依次收到: | ||
| * ...stream_chunk → tool_start/end → ... → usage(可选) → typing_stop | ||
| * 这样保证"一次问答一次消耗",且消耗信息在结束态前到达。 | ||
| */ | ||
@@ -272,2 +283,43 @@ markComplete: (markOpts) => { | ||
| } | ||
| // 在 typing_stop 之前 emit 一帧 usage(如有)。 | ||
| // 数据源:从 transcript jsonl 读最近一条 assistant 消息的 usage(与 history 模块同源)。 | ||
| // abort / 静默 dispatch / NO_REPLY 等异常路径下 transcript 可能没有可读 usage, | ||
| // 此时不 emit,前端按"无用量数据"渲染。 | ||
| let usageToEmit; | ||
| // 非 abort + sessionKey 已知 → 从 transcript 读 usage | ||
| if (!isAborted && sessionKey) { | ||
| try { | ||
| // 找到本轮最后一条 assistant | ||
| const tailMessages = readSessionHistoryTail(sessionKey, { | ||
| limit: 10, | ||
| chatOnly: true, | ||
| agentId, | ||
| }); | ||
| // 找到最后一条 assistant 消息(一轮的最后一条 = 本次问答的累计 usage) | ||
| let foundUsage; | ||
| for (let i = tailMessages.length - 1; i >= 0; i -= 1) { | ||
| const m = tailMessages[i]; | ||
| if (m.role === "assistant" && m.usage) { | ||
| foundUsage = m.usage; | ||
| break; | ||
| } | ||
| } | ||
| if (foundUsage) { | ||
| usageToEmit = foundUsage; | ||
| } | ||
| else { | ||
| log?.warn(`[${CHANNEL_KEY}] [stream] no assistant.usage found in transcript tail ` + | ||
| `(messages=${tailMessages.length})`); | ||
| } | ||
| } | ||
| catch (err) { | ||
| log?.warn(`[${CHANNEL_KEY}] [stream] read transcript failed: ` + | ||
| `${err instanceof Error ? err.message : String(err)}`); | ||
| } | ||
| } | ||
| if (usageToEmit) { | ||
| log?.info(`[${CHANNEL_KEY}] [stream] emit final usage: ` + | ||
| `input=${usageToEmit.inputTokens} output=${usageToEmit.outputTokens} total=${usageToEmit.totalTokens}`); | ||
| emitSignal(signalCtx, "usage", "", undefined, { usage: usageToEmit }); | ||
| } | ||
| sendTypingStop(); | ||
@@ -274,0 +326,0 @@ }, |
@@ -53,8 +53,10 @@ import * as fs from 'node:fs'; | ||
| /** | ||
| * 统一的信号发送出口,收敛 typing / stream / tool 控制帧的构造逻辑。 | ||
| * 统一的信号发送出口,收敛 typing / stream / tool / usage 控制帧的构造逻辑。 | ||
| * | ||
| * - typing_start 不带 replyToMsgId(协议要求);其余帧都携带。 | ||
| * - extra 用于透传 kind 相关字段(如 tool_start 的 toolName/toolPhase、tool_end 的 toolIsError)。 | ||
| * - topLevelExtra 用于透传顶层 kind 相关字段(如 tool_start 的 toolName/toolPhase、tool_end 的 toolIsError)。 | ||
| * - innerExtra 用于在 PrivateMessageData.extra 内追加自定义字段(如 usage 帧的 usage 对象)。 | ||
| * chatId 始终由本函数管理,调用方无需也不能在 innerExtra 中传 chatId(会被覆盖)。 | ||
| */ | ||
| export function emitSignal(ctx, kind, content = '', extra) { | ||
| export function emitSignal(ctx, kind, content = '', topLevelExtra, innerExtra) { | ||
| const { emitter, targetId, replyMsgId, originalMsgId, agentId, chatId } = ctx; | ||
@@ -69,5 +71,6 @@ return emitter.emit({ | ||
| ...(kind !== 'typing_start' ? { replyToMsgId: originalMsgId } : {}), | ||
| ...extra, | ||
| ...topLevelExtra, | ||
| agentId, | ||
| extra: { chatId: chatId ?? '' }, | ||
| // chatId 始终位于 extra.chatId;innerExtra 在前展开,确保 chatId 不被覆盖 | ||
| extra: { ...(innerExtra ?? {}), chatId: chatId ?? '' }, | ||
| }); | ||
@@ -74,0 +77,0 @@ } |
@@ -43,2 +43,6 @@ 'use strict'; | ||
| * client or server mode | ||
| * @param {Number} [options.maxBufferedChunks=0] The maximum number of | ||
| * buffered data chunks | ||
| * @param {Number} [options.maxFragments=0] The maximum number of message | ||
| * fragments | ||
| * @param {Number} [options.maxPayload=0] The maximum allowed message length | ||
@@ -58,2 +62,4 @@ * @param {Boolean} [options.skipUTF8Validation=false] Specifies whether or | ||
| this._isServer = !!options.isServer; | ||
| this._maxBufferedChunks = options.maxBufferedChunks | 0; | ||
| this._maxFragments = options.maxFragments | 0; | ||
| this._maxPayload = options.maxPayload | 0; | ||
@@ -94,2 +100,18 @@ this._skipUTF8Validation = !!options.skipUTF8Validation; | ||
| if ( | ||
| this._maxBufferedChunks > 0 && | ||
| this._buffers.length >= this._maxBufferedChunks | ||
| ) { | ||
| cb( | ||
| this.createError( | ||
| RangeError, | ||
| 'Too many buffered chunks', | ||
| false, | ||
| 1008, | ||
| 'WS_ERR_TOO_MANY_BUFFERED_PARTS' | ||
| ) | ||
| ); | ||
| return; | ||
| } | ||
| this._bufferedBytes += chunk.length; | ||
@@ -491,2 +513,18 @@ this._buffers.push(chunk); | ||
| if (data.length) { | ||
| if ( | ||
| this._maxFragments > 0 && | ||
| this._fragments.length >= this._maxFragments | ||
| ) { | ||
| const error = this.createError( | ||
| RangeError, | ||
| 'Too many message fragments', | ||
| false, | ||
| 1008, | ||
| 'WS_ERR_TOO_MANY_BUFFERED_PARTS' | ||
| ); | ||
| cb(error); | ||
| return; | ||
| } | ||
| // | ||
@@ -531,2 +569,18 @@ // This message is not compressed so its length is the sum of the payload | ||
| if ( | ||
| this._maxFragments > 0 && | ||
| this._fragments.length >= this._maxFragments | ||
| ) { | ||
| const error = this.createError( | ||
| RangeError, | ||
| 'Too many message fragments', | ||
| false, | ||
| 1008, | ||
| 'WS_ERR_TOO_MANY_BUFFERED_PARTS' | ||
| ); | ||
| cb(error); | ||
| return; | ||
| } | ||
| this._fragments.push(buf); | ||
@@ -533,0 +587,0 @@ } |
@@ -46,2 +46,6 @@ /* eslint no-unused-vars: ["error", { "varsIgnorePattern": "^Duplex$", "caughtErrors": "none" }] */ | ||
| * @param {String} [options.host] The hostname where to bind the server | ||
| * @param {Number} [options.maxBufferedChunks=1048576] The maximum number of | ||
| * buffered data chunks | ||
| * @param {Number} [options.maxFragments=131072] The maximum number of message | ||
| * fragments | ||
| * @param {Number} [options.maxPayload=104857600] The maximum allowed message | ||
@@ -69,2 +73,4 @@ * size | ||
| autoPong: true, | ||
| maxBufferedChunks: 1024 * 1024, | ||
| maxFragments: 128 * 1024, | ||
| maxPayload: 100 * 1024 * 1024, | ||
@@ -429,2 +435,4 @@ skipUTF8Validation: false, | ||
| allowSynchronousEvents: this.options.allowSynchronousEvents, | ||
| maxBufferedChunks: this.options.maxBufferedChunks, | ||
| maxFragments: this.options.maxFragments, | ||
| maxPayload: this.options.maxPayload, | ||
@@ -431,0 +439,0 @@ skipUTF8Validation: this.options.skipUTF8Validation |
@@ -204,2 +204,6 @@ /* eslint no-unused-vars: ["error", { "varsIgnorePattern": "^Duplex|Readable$", "caughtErrors": "none" }] */ | ||
| * masking key | ||
| * @param {Number} [options.maxBufferedChunks=0] The maximum number of | ||
| * buffered data chunks | ||
| * @param {Number} [options.maxFragments=0] The maximum number of message | ||
| * fragments | ||
| * @param {Number} [options.maxPayload=0] The maximum allowed message size | ||
@@ -216,2 +220,4 @@ * @param {Boolean} [options.skipUTF8Validation=false] Specifies whether or | ||
| isServer: this._isServer, | ||
| maxBufferedChunks: options.maxBufferedChunks, | ||
| maxFragments: options.maxFragments, | ||
| maxPayload: options.maxPayload, | ||
@@ -645,2 +651,6 @@ skipUTF8Validation: options.skipUTF8Validation | ||
| * handshake request | ||
| * @param {Number} [options.maxBufferedChunks=1048576] The maximum number of | ||
| * buffered data chunks | ||
| * @param {Number} [options.maxFragments=131072] The maximum number of message | ||
| * fragments | ||
| * @param {Number} [options.maxPayload=104857600] The maximum allowed message | ||
@@ -666,2 +676,4 @@ * size | ||
| protocolVersion: protocolVersions[1], | ||
| maxBufferedChunks: 1024 * 1024, | ||
| maxFragments: 128 * 1024, | ||
| maxPayload: 100 * 1024 * 1024, | ||
@@ -1024,2 +1036,4 @@ skipUTF8Validation: false, | ||
| generateMask: opts.generateMask, | ||
| maxBufferedChunks: opts.maxBufferedChunks, | ||
| maxFragments: opts.maxFragments, | ||
| maxPayload: opts.maxPayload, | ||
@@ -1026,0 +1040,0 @@ skipUTF8Validation: opts.skipUTF8Validation |
| { | ||
| "name": "ws", | ||
| "version": "8.20.1", | ||
| "version": "8.21.0", | ||
| "description": "Simple to use, blazing fast and thoroughly tested websocket client and server for Node.js", | ||
@@ -5,0 +5,0 @@ "keywords": [ |
+1
-1
| { | ||
| "name": "lightclawbot", | ||
| "version": "1.2.5", | ||
| "version": "1.2.6-beta.0", | ||
| "description": "LightClawBot channel plugin with message support, cron jobs, and proactive messaging", | ||
@@ -5,0 +5,0 @@ "type": "module", |
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
No v1
QualityPackage is not semver >=1. This means it is not stable and does not support ^ ranges.
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
Found 2 instances
463860
2.84%66
6.45%10826
2.55%6
-14.29%2
100%