🚀 Socket Launch Week Day 5:Introducing Repository Access Permissions and Custom Roles.Learn more
Sign In

lightclawbot

Package Overview
Dependencies
Maintainers
1
Versions
30
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

lightclawbot - npm Package Compare versions

Comparing version
1.2.5
to
1.2.6-beta.0
+53
dist/src/history/usage-aggregator.js
/**
* 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;
}
+20
-0

@@ -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 }),
};
}
+17
-2

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

+8
-5

@@ -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": [

{
"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",