🚀 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.7
to
1.2.8
+325
dist/src/streaming/thinking-formatter.js
/**
* LightClaw — 思考过程帧(thinking_step)格式化模块
*
* 职责:把 openclaw 的工具回调(onToolStart / onItemEvent)翻译成
* 协议契约里的 `extra.step` 载荷。前端据此渲染:
*
* 💭 接下来将先执行端口检查,确认浏览器启动状态。 ← stream_chunk(旁白)
* 🔧 执行了 exec: lsof -i :9222 ← thinking_step running
* 💭 已找到 9222 端口 PID = 12345 ← thinking_step done
*
* 协议见 docs/thinking-step-protocol.md。
*/
// ── 工具名 → 类型/中文短句映射 ──
//
// 命中规则按"前缀"匹配,避免 sessions_spawn / sessions.spawn 这种命名差异。
const TOOL_TYPE_RULES = [
{ test: (n) => n === "exec" || n.startsWith("exec_"), type: "cmd", verb: "执行命令" },
{ test: (n) => n.startsWith("browser"), type: "browser", verb: "操作浏览器" },
{ test: (n) => n === "apply_patch" || n.startsWith("str_replace") || n === "edit_file" || n === "write_file", type: "patch", verb: "编辑文件" },
{ test: (n) => n === "read" || n === "read_file" || n === "view_code_item", type: "tool", verb: "查看文件" },
{ test: (n) => n.startsWith("web_") || n === "fetch", type: "tool", verb: "访问网页" },
{ test: (n) => n.includes("session") && n.includes("spawn"), type: "subagent", verb: "派发子任务" },
{ test: (n) => n === "subagents", type: "subagent", verb: "派发子任务" },
{ test: (n) => n === "update_plan" || n === "plan", type: "plan", verb: "更新计划" },
];
/** 把工具名映射成行级类型 + 默认中文动作短句。 */
function classifyTool(toolName) {
for (const rule of TOOL_TYPE_RULES) {
if (rule.test(toolName))
return { type: rule.type, verb: rule.verb };
}
return { type: "tool", verb: `调用 ${toolName}` };
}
/** stepId 生成器:调用方应在每次 onToolStart 时调用一次,把返回值缓存到 lastToolStepId 上。 */
export function genStepId(seed, toolName) {
return `step-${seed}-${toolName.replace(/[^a-zA-Z0-9_]/g, "_")}`;
}
/**
* 把 args 摘要成一行人类可读的字符串。优先级:
* command/cmd/shell > path/file_path/target_file > url/targetUrl > query/keyword > name/title > action > 任意非空字符串
*
* 注意 `action` 这种枚举型动词字段(值通常是 "open" / "click" 等单词)被刻意降权,
* 否则会把更有信息量的 url/targetUrl 压住,导致 running 文案退化为 "执行了 操作浏览器: open"。
*
* 超长内容会被截断到 160 字符(后接 …),避免占满前端时间线一行。
*/
const ARG_SUMMARY_MAX_LEN = 160;
const ARG_PRIORITY_KEYS = [
// 命令/路径类(最有信息量)
"command",
"cmd",
"shell",
"path",
"file_path",
"filepath",
"filePath",
"target_file",
// URL 类(browser 工具常用 targetUrl)
"url",
"targetUrl",
"target_url",
"href",
// 检索/关键词类
"query",
"keyword",
"text",
// 标识符类
"name",
"title",
// 元素定位类(browser 点击/输入常用 targetId / selector)
"targetId",
"target_id",
"selector",
// 枚举动词(信息量最低,放最后兜底)
"action",
];
function truncate(text) {
if (text.length <= ARG_SUMMARY_MAX_LEN)
return text;
return `${text.slice(0, ARG_SUMMARY_MAX_LEN)}…`;
}
function summarizeArgs(args) {
if (!args || typeof args !== "object")
return "";
// 1. 优先级 key 命中
for (const key of ARG_PRIORITY_KEYS) {
const val = args[key];
if (typeof val === "string" && val.trim()) {
return truncate(val.trim());
}
}
// 2. 任何非空字符串字段
for (const [, val] of Object.entries(args)) {
if (typeof val === "string" && val.trim()) {
return truncate(val.trim());
}
}
// 3. 兜底:序列化整个对象
try {
const json = JSON.stringify(args);
if (json && json !== "{}")
return truncate(json);
}
catch {
// 循环引用等场景,忽略
}
return "";
}
// ── browser 工具特化文案 ──
//
// 根因:browser 工具下不同 action 对应的有效字段差异很大——
// - open 用 targetUrl(用户能识别的 URL)
// - click 用 targetId(DOM 元素内部句柄,如 "t1",无语义信息)
// - type 用 text(输入内容)
// - scroll/back/forward/screenshot 等几乎没有可读字段
//
// 通用 ARG_PRIORITY_KEYS 把这些字段放同一优先级,导致 click 文案退化为
// "执行了 操作浏览器: t1" 这类用户完全不知所云的内容。这里按 action 分支
// 显式映射 verb 与可见字段,避免把内部句柄搬到时间线上。
const BROWSER_ACTION_VERBS = {
open: "打开网页",
navigate: "打开网页",
goto: "打开网页",
click: "点击页面元素",
tap: "点击页面元素",
type: "输入文本",
fill: "输入文本",
input: "输入文本",
scroll: "滚动页面",
back: "返回上一页",
forward: "前进",
reload: "刷新页面",
refresh: "刷新页面",
screenshot: "页面截图",
snapshot: "页面截图",
close: "关闭页面",
};
/**
* 按 browser action 生成 {verb, summary}。
* - 命中已知 action:返回特化 verb,且 summary 仅取真正有用户语义的字段(targetId 等内部句柄不进文案)。
* - 未命中(含 args 缺失 / action 不在白名单):返回 null,由调用方回退到通用 summarizeArgs。
*/
function summarizeBrowserArgs(args) {
if (!args || typeof args !== "object")
return null;
const action = args.action;
if (typeof action !== "string" || !action.trim())
return null;
const verb = BROWSER_ACTION_VERBS[action.toLowerCase()];
if (!verb)
return null;
const pickString = (...keys) => {
for (const k of keys) {
const v = args[k];
if (typeof v === "string" && v.trim())
return truncate(v.trim());
}
return "";
};
let summary = "";
switch (action.toLowerCase()) {
case "open":
case "navigate":
case "goto":
summary = pickString("targetUrl", "url", "target_url", "href");
break;
case "click":
case "tap":
// targetId 是 DOM 内部句柄(如 "t1"),毫无可读性,刻意不进文案。
// 优先用 agent 自填的 description / label / name;都没有就只显示 verb。
summary = pickString("description", "label", "name", "selector");
break;
case "type":
case "fill":
case "input":
summary = pickString("text", "content", "value");
break;
// scroll / back / forward / reload / screenshot / close 一般无需附带 summary
default:
summary = "";
}
return { verb, summary };
}
/**
* 把 SDK onItemEvent 给的 title 清掉常见的 toolName 前缀,避免 "browser browser https://..." 这种重复。
* 例如 title="browser https://www.baidu.com/..."、toolName="browser" → 返回 "https://www.baidu.com/..."。
*/
function stripToolNamePrefix(title, toolName) {
if (!title || !toolName)
return title;
const trimmed = title.trim();
if (trimmed.toLowerCase().startsWith(`${toolName.toLowerCase()} `)) {
return trimmed.slice(toolName.length + 1).trim();
}
return trimmed;
}
/**
* T1:工具开始 → running 帧。
*
* 文案优先级(自上而下,命中即返回):
* 1. args 摘要(command/path/url/targetUrl/... 见 ARG_PRIORITY_KEYS)
* 2. fallbackMeta(来自 SDK onItemEvent 的 payload.meta,通常是裸 URL/路径)
* 3. fallbackTitle(去掉 toolName 前缀后的剩余部分)
* 4. 兜底:仅 verb,不带摘要
*
* 设计目的:让 running 帧一次性带上有信息量的内容(如 URL),避免后续 done 帧
* 出现 "覆盖还是不覆盖" 的两难。done 帧仍按原策略:summary/title/meta 全空 → text 留空。
*
* @param stepId 调用方维护的步骤 ID(onToolStart 用 genStepId 生成)
* @param seq 时间线位置序号(由 sink 维护的全局自增计数器)
* @param toolName 原始工具名
* @param args onToolStart 透传的工具参数(SDK 已提供)
* @param fallbackMeta onItemEvent(start) 透传的 meta,args 摸不出内容时兜底
* @param fallbackTitle onItemEvent(start) 透传的 title,args/meta 都摸不出内容时兜底
*/
export function buildRunningStep(stepId, seq, toolName, args, fallbackMeta, fallbackTitle) {
const { type, verb: defaultVerb } = classifyTool(toolName);
// browser 工具特化:按 action 选择 verb / summary,避免 targetId 这类内部句柄进入文案。
// 未命中(无 args / action 不在白名单)时返回 null,回退到通用路径。
let verb = defaultVerb;
let summary = "";
let browserHit = false;
if (type === "browser") {
const browserSummary = summarizeBrowserArgs(args);
if (browserSummary) {
verb = browserSummary.verb;
summary = browserSummary.summary;
browserHit = true;
}
}
// 1. 通用 args 摘要(browser 已特化命中则跳过,避免被通用规则覆盖回 targetId 等无语义内容)
if (!browserHit) {
summary = summarizeArgs(args);
}
// 2. args 没摸出内容 → meta 兜底
// 注意:browser 特化已命中但 summary 为空(如 screenshot / scroll / back 等无副词 action)时,
// 不再回退到 meta / title。原因是 SDK 在这些场景的 meta 多为内部句柄(如 "5" / "7" / "1_124"),
// 一旦兜底就会出现 "执行了 页面截图: 5" 这类噪音。命中即视为"verb 已经讲清楚了"。
if (!summary && !browserHit && fallbackMeta && fallbackMeta.trim()) {
summary = truncate(fallbackMeta.trim());
}
// 3. meta 也没有 → title 去前缀后兜底(同上,browser 特化命中后不再走 title)
if (!summary && !browserHit && fallbackTitle && fallbackTitle.trim()) {
const cleaned = stripToolNamePrefix(fallbackTitle, toolName);
if (cleaned)
summary = truncate(cleaned);
}
const text = summary ? `执行了 ${verb}: ${summary}` : `执行了 ${verb}`;
return {
stepId,
seq,
type,
text,
status: "running",
toolName,
detail: summary || undefined,
};
}
/**
* T3:工具结束 → done/error 帧。
*
* 入参来自 openclaw `onItemEvent` 的 payload,重点字段:
* - itemId 备用 stepId(实测 SDK 随机生成,与 running 时的 stepId 对不上,
* 所以本函数优先用 fallbackStepId,由调用方传入 running 时缓存的 lastToolStepId)
* - status "completed" / "failed" / "error" / ...
* - summary 一行式结果摘要("已进入腾讯云轻量应用服务器产品页")
* - title SDK 给的成品文案("browser https://www.baidu.com/..."),多数场景是 running 时 URL 的回声
* - meta SDK 内部分类(多数场景与 title 同源,是 running 文案的回声)
* - progressText 中间态进度文案(不适合做最终态摘要)
*
* 文案规则(见 docs/thinking-step-protocol.md,v7 收紧):
* - 成功:**只用 `summary`**(agent 真正写给用户看的结果摘要)。
* summary 为空时 text 留空,前端识别为"无新文案",仅切状态/图标 spinner→✓,
* 保留 running 行的原文本(避免 SDK title/meta 是 running URL 回声,覆盖后出现
* "browser https://www.baidu.com/..." 这类劣化文案)。
* - 失败:summary > `${verb}失败`。**不再**裸取 title / meta / progressText 作为
* 主文案,因为实测 SDK 在 onItemEvent(end, status=error) 的 title 常常是
* `"browser"` / `"browser target t1, ref 1_124"` 这类内部句柄回声,对用户无意义。
* verb 已按工具类型分类(如 "操作浏览器" / "执行命令"),保证最低有可读语义。
* title/meta/progressText 改为 detail 折叠展示,方便排障但不污染主文案。
*/
export function buildDoneStep(payload, fallbackStepId, seq, fallbackToolName) {
// toolName 优先用 fallbackToolName(来自 running 时缓存的 lastToolName,已是正确值),
// payload.name 在 onItemEvent 里经常为空 / 取值奇怪,避免把 verb 分类带偏。
const toolName = fallbackToolName ?? payload.name ?? "unknown";
const { type, verb } = classifyTool(toolName);
const isError = payload.status === "failed" ||
payload.status === "error" ||
payload.status === "cancelled";
const summary = (payload.summary ?? "").trim();
const title = (payload.title ?? "").trim();
const meta = (payload.meta ?? "").trim();
const progress = (payload.progressText ?? "").trim();
// 成功路径:只采纳 summary(agent 总结的真摘要)。title/meta 实测多为 running 文案回声,
// 不再作为成功 done 的 text 来源;progress 是中间态,也不适合做最终态。
// 失败路径:v7 收紧 —— 不再裸取 title / meta / progressText(实测多为 SDK 内部句柄回声,
// 如 `"browser"` / `"browser target t1, ref 1_124"` 这类对用户无意义的内容)。
// 只采纳 summary(agent 写的失败原因);缺失则用 `${verb}失败`,verb 已按工具
// 类型分类(如 "操作浏览器" / "执行命令"),保证有可读语义。
// title/meta/progressText 改为 detail 折叠展示,方便排障但不出现在主文案。
const text = isError
? summary || `${verb}失败`
: summary;
// detail 优先级:失败时把 SDK 的原始 title / meta / progress 拼起来供折叠查看(排障用);
// 成功时仅把进度文案塞进去(与 v5 一致)。
let detail;
if (isError) {
const debugParts = [title, meta, progress].filter((s) => s && s !== text);
detail = debugParts.length > 0 ? debugParts.join(" · ") : undefined;
}
else {
detail = progress && progress !== text ? progress : undefined;
}
return {
// stepId 优先用 fallbackStepId(调用方传入的 lastToolStepId),保证前端按 stepId
// 合并到 running 那一行;payload.itemId 因 SDK 随机分配,仅作最后兜底。
stepId: fallbackStepId || payload.itemId || "unknown",
seq,
type,
text,
status: isError ? "error" : "done",
toolName,
detail,
};
}
+30
-3

@@ -58,5 +58,32 @@ /**

const sessionKeyToApiKey = new Map();
/** 设置 apiKeyMap(gateway 启动时调用) */
export function setApiKeyMap(map, defaultApiKey) {
globalApiKeyMap = map;
/** 合并单条 account 的 apiKey 到全局映射(gateway 启动时调用) */
export function addApiKeyToMap(accountId, apiKey) {
globalApiKeyMap.set(accountId, apiKey);
// 仅在尚未设置 defaultApiKey 时设置(取第一个 account 的 key 作为默认)
if (!globalDefaultApiKey) {
globalDefaultApiKey = apiKey;
}
}
/**
* 一次性构建全局 apiKeyMap(插件初始化时调用)。
*
* 遍历配置中所有 accounts,将 accountId→apiKey 映射一次性写入全局 Map,
* 避免每个 account 启动 gateway 时覆盖其他 account 的映射。
*
* @param cfg - OpenClaw 全局配置对象
*/
export function buildGlobalApiKeyMap(cfg) {
const channels = cfg?.channels;
const channelConfig = channels?.[CHANNEL_KEY];
if (!channelConfig?.accounts)
return;
const accounts = channelConfig.accounts;
let defaultApiKey = globalDefaultApiKey; // 保留已有的 default
for (const [accountId, accountConfig] of Object.entries(accounts)) {
if (accountConfig?.apiKey) {
globalApiKeyMap.set(accountId, accountConfig.apiKey);
if (!defaultApiKey)
defaultApiKey = accountConfig.apiKey;
}
}
globalDefaultApiKey = defaultApiKey;

@@ -63,0 +90,0 @@ }

+8
-6

@@ -16,3 +16,3 @@ /**

import { NativeSocketClient } from './socket/native-socket.js';
import { CHANNEL_KEY, WS_URL, API_BASE_URL, SOCKET_PATH, API_PATH_USER_CURRENT, EVENT_MESSAGE_PRIVATE, HEALTH_HEARTBEAT_INTERVAL, setApiKeyMap, } from './config.js';
import { CHANNEL_KEY, WS_URL, API_BASE_URL, SOCKET_PATH, API_PATH_USER_CURRENT, EVENT_MESSAGE_PRIVATE, HEALTH_HEARTBEAT_INTERVAL, addApiKeyToMap, buildGlobalApiKeyMap, } from './config.js';
import { generateMsgId } from './dedup.js';

@@ -99,3 +99,3 @@ import { createInboundHandler } from './inbound.js';

export async function startGateway(ctx) {
const { account, abortSignal, onReady, onDisconnect, onEvent, log } = ctx;
const { account, abortSignal, cfg, onReady, onDisconnect, onEvent, log } = ctx;
const prefix = `[${CHANNEL_KEY}:${account.accountId}]`;

@@ -118,7 +118,9 @@ // 判断是否存在有效的apikey

const { botClientId, ticket } = await resolveBotClientId(account.apiKey, log);
// 构建 uin→apiKey 映射表(新格式下每个 account 只有一条记录)
const apiKeyMap = new Map([[account.accountId, account.apiKey]]);
// 一次性构建全局 uin→apiKey 映射(遍历所有 accounts,幂等操作)
// 解决多账号场景下每个 gateway 启动时覆盖全局 Map 的问题
buildGlobalApiKeyMap(cfg);
// 将当前 account 的 uin→apiKey 映射合并到全局 config 模块
// 确保 gateway 重启/reconnect 时映射仍然完整
addApiKeyToMap(account.accountId, account.apiKey);
log?.info(`${prefix} Bot clientId: ${botClientId}, uin=${account.accountId} → apiKey=***${account.apiKey.slice(-4)}`);
// 将映射表写入全局 config 模块,供 inbound 处理时按 uin 查找对应 apiKey
setApiKeyMap(apiKeyMap, account.apiKey);
/** Gateway 是否已被 abort(用于终止消息处理循环) */

@@ -125,0 +127,0 @@ let isAborted = false;

@@ -341,4 +341,10 @@ /**

// 过滤 openclaw delivery-mirror 消息(镜像投递,非真实消息)
if (msg.model === "delivery-mirror")
continue;
// 但对 cron 直接投递的消息放行(用户需要看到定时提醒内容)
if (msg.model === "delivery-mirror") {
const idempotencyKey = msg.idempotencyKey
?? parsed.idempotencyKey;
if (!idempotencyKey || !idempotencyKey.startsWith("cron-direct-delivery:")) {
continue;
}
}
// 过滤传输层伪装为 user 的系统注入消息

@@ -345,0 +351,0 @@ if (isSystemInjectedUserMessage(msg))

@@ -20,2 +20,3 @@ /**

import { createDeltaTrackerState, toStreamDeltaText } from "./delta-tracker.js";
import { buildRunningStep, buildDoneStep, genStepId } from "./thinking-formatter.js";
import { DEFAULT_PARTIAL_COALESCE } from "./types.js";

@@ -84,2 +85,17 @@ import { emitSignal } from "../utils/common.js";

let assistantMessageCount = 0;
// ── thinking_step 关联:onToolStart 生成 stepId,缓存到这里;
// onItemEvent 拿不到 itemId 时回退到这个 stepId,保证 running ↔ done 能配对。
let lastToolStepId = null;
let lastToolName = null;
// ── onItemEvent(start) 预热信息:
// 实测 SDK 时序是 onItemEvent(start) 先于 onToolStart(毫秒级),而工具调用串行,
// 所以用一对全局变量缓存最近一次 start 的 meta/title,onToolStart 取走后由 sink 使用。
// args 摸不出内容时(如 browser 工具 args.action="open" 没信息量),running 用这些兜底。
let pendingItemMeta = null;
let pendingItemTitle = null;
// ── thinking_step 时间线序号 ──
// seqCounter: 全局自增,每次 running 帧 +1
// stepIdToSeq: running 时记录 stepId → seq;done 帧复用此 seq,保证合并行的位置稳定
let thinkingSeqCounter = 0;
const thinkingStepSeqMap = new Map();
// ── thinking/reasoning 观测(不面向用户,仅为首字延迟归因) ──

@@ -490,7 +506,15 @@ let reasoningChars = 0;

// 数据源:从 transcript jsonl 读最近一条 assistant 消息的 usage(与 history 模块同源)。
// abort / 静默 dispatch / NO_REPLY 等异常路径下 transcript 可能没有可读 usage,
// 此时不 emit,前端按"无用量数据"渲染。
//
// 守卫条件(缺一不可):
// 1. 非 abort
// 2. sessionKey 已知(否则无 transcript 可读)
// 3. 本 sink 真正承载了一次完整的 LLM 输出:hadLLMActivity && (hadStreamedText || hadDispatch)
//
// 第 3 条尤为关键:当用户连发消息被 openclaw 合并为 followup 时,被合并那条的 sink
// 不会有任何 LLM 活动(hadLLMActivity=false),但 transcript 里有上一轮的 usage —— 若
// 不加守卫就会把上一轮的 usage 误透给这条消息,前端把它当作"该消息已结束"的终态信号,
// 导致后续真正的回复内容不再绑定到这条消息上。
let usageToEmit;
// 非 abort + sessionKey 已知 → 从 transcript 读 usage
if (!isAborted && sessionKey) {
const sinkProducedOutput = hadLLMActivity && (hadStreamedText || hadDispatch);
if (!isAborted && sessionKey && sinkProducedOutput) {
try {

@@ -585,2 +609,12 @@ // 找到本轮最后一条 assistant

return;
// SDK 在 phase=start 和 phase=update 都会回调;只在 start 时认为是"新一次工具调用",
// update 仅作为同一 stepId 的中间态,不重新计 seq、不发 running 帧(避免重复)。
const toolPhase = payload.phase ?? "";
if (toolPhase !== "start") {
// 仍然透传 tool_start 帧(向下兼容旧前端的 update 渲染),但不动 thinking_step。
const toolNameForUpdate = payload.name ?? "unknown";
log?.info(`[${CHANNEL_KEY}] [stream] onToolStart: name=${toolNameForUpdate} phase=${toolPhase} (skip thinking_step)`);
emitSignal(signalCtx, "tool_start", "\n\n", { toolName: toolNameForUpdate, toolPhase, ...groupSignalExtra }, buildGroupDataExtra('delta'));
return;
}
toolStartCount++;

@@ -590,7 +624,25 @@ // 强制 flush:前端按"先文本→后工具卡"渲染,buffer 残留若不先发出去会被工具卡插队。

const toolName = payload.name ?? "unknown";
const toolPhase = payload.phase ?? "";
const args = payload.args;
const isSubagentSpawn = SUBAGENT_TOOL_NAMES.has(toolName);
log?.info(`[${CHANNEL_KEY}] [stream] onToolStart: name=${toolName} phase=${toolPhase}` +
const argsKeys = args ? Object.keys(args).join(",") : "(none)";
log?.info(`[${CHANNEL_KEY}] [stream] onToolStart: name=${toolName} phase=${toolPhase} argsKeys=${argsKeys}` +
(isSubagentSpawn ? " (subagent-spawn)" : ""));
emitSignal(signalCtx, "tool_start", "\n\n", { toolName, toolPhase, ...groupSignalExtra }, buildGroupDataExtra('delta'));
// 追加 thinking_step running 帧:和 tool_start 同步发出,前端可二选一渲染。
// stepId 缓存到 lastToolStepId,onItemEvent(completed) 没拿到 itemId 时回退使用。
const stepId = genStepId(toolStartCount, toolName);
lastToolStepId = stepId;
lastToolName = toolName;
// 分配并记录 seq;done 帧复用此 seq 保证合并行的时间线位置稳定。
thinkingSeqCounter += 1;
const seq = thinkingSeqCounter;
thinkingStepSeqMap.set(stepId, seq);
// 取走上一次 onItemEvent(start) 预热的 meta/title 作为 running 文案兜底,
// 取后立即清空,避免被下一次调用误用。
const fallbackMeta = pendingItemMeta ?? undefined;
const fallbackTitle = pendingItemTitle ?? undefined;
pendingItemMeta = null;
pendingItemTitle = null;
const runningStep = buildRunningStep(stepId, seq, toolName, args, fallbackMeta, fallbackTitle);
emitSignal(signalCtx, "thinking_step", "", groupSignalExtra, { ...buildGroupDataExtra('delta'), step: runningStep });
},

@@ -675,2 +727,41 @@ onReplyStart: () => {

log?.info(`[${CHANNEL_KEY}] [stream] onItemEvent: ${JSON.stringify(payload)}`);
// SDK 在 exec 工具上会同时发 kind=tool 和 kind=command 两份事件,载荷高度重复。
// 只采 kind=tool(缺省视为 tool),过滤掉 kind=command 避免时间线重复行。
const kind = payload.kind ?? "tool";
if (kind === "command")
return;
// start 帧:预热缓存 meta/title,供紧随其后的 onToolStart 拼 running 文案。
// (实测时序:onItemEvent(start) → onToolStart,间隔毫秒级;工具调用串行不会交叉。)
const phase = payload.phase ?? "";
if (phase === "start") {
pendingItemMeta = payload.meta?.trim() || null;
pendingItemTitle = payload.title?.trim() || null;
return;
}
// 仅终态事件触发 thinking_step done/error 帧;中间态(running / progress)暂不映射,
// 避免协议面被 SDK 内部 phase 噪音污染。
const status = payload.status ?? "";
const isTerminal = status === "completed" ||
status === "done" ||
status === "failed" ||
status === "error" ||
status === "cancelled";
if (!isTerminal)
return;
// stepId 关联策略(重要):
// 实测 openclaw `onItemEvent` 里的 `payload.itemId` 与 `onToolStart` 时生成的
// stepId 完全对不上(SDK 内部随机分配),如果直接把 itemId 当 stepId 透传出去,
// 前端按 stepId 合并行就永远落空,结果就是"running 一行 + done 又起一行"。
// 因此优先复用 running 时缓存的 lastToolStepId,保证 done 帧能就地覆盖到 running 那一行。
// 仅当不存在 lastToolStepId(极端兜底)时才退回到 itemId / "item-unknown"。
const fallbackStepId = `item-${payload.itemId ?? "unknown"}`;
const stepId = lastToolStepId ?? payload.itemId ?? fallbackStepId;
// 复用 running 时分配的 seq,保证合并行不漂移。
let seq = thinkingStepSeqMap.get(stepId);
if (seq === undefined) {
thinkingSeqCounter += 1;
seq = thinkingSeqCounter;
}
const doneStep = buildDoneStep(payload, stepId, seq, lastToolName ?? undefined);
emitSignal(signalCtx, "thinking_step", "", groupSignalExtra, { ...buildGroupDataExtra('delta'), step: doneStep });
},

@@ -677,0 +768,0 @@ onTypingCleanup: () => {

{
"name": "lightclawbot",
"version": "1.2.7",
"version": "1.2.8",
"description": "LightClawBot channel plugin with message support, cron jobs, and proactive messaging",

@@ -5,0 +5,0 @@ "type": "module",