lightclawbot 1.2.3 → 1.2.6-beta.0

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.
@@ -1,39 +1,80 @@
1
1
  /**
2
- * LightClaw — ChannelPlugin 主定义
2
+ * ============================================================================
3
+ * channel.ts —— Lightclaw Channel Plugin 主入口
4
+ * ============================================================================
3
5
  *
4
- * 使用 SDK 标准的 createChatChannelPlugin 构建器,
5
- * 参照官方 Discord/Slack channel 实现。
6
+ * 本文件通过 OpenClaw 的 Plugin SDK 创建并导出一个「聊天类 Channel 插件」
7
+ * (`lightclawPlugin`),用于把 Lightclaw(AI 助手 Server)接入到 OpenClaw
8
+ * 网关体系中,作为一个标准的消息通道(Channel)使用。
6
9
  *
7
- * 结构:
8
- * base: 来自 shared.ts createLightclawPluginBase + 扩展字段
9
- * security: DM 安全策略
10
- * outbound: 出站消息适配器
10
+ * 该插件主要负责以下几件事:
11
+ * 1. messaging:目标字符串(user:xxx / channel:xxx)的规范化与识别;
12
+ * 2. status :暴露插件/账户运行时状态,供 OpenClaw 状态面板展示;
13
+ * 3. gateway :账户级别的长连接生命周期管理(启动/停止/登出);
14
+ * 4. outbound :出站消息(文本 / 媒体)的投递策略与发送实现。
15
+ *
16
+ * 架构概览:
17
+ * OpenClaw ──启动账户──▶ startAccount() ──▶ startGateway() ──▶ Socket.IO WS
18
+ * ◀──状态回写── ctx.setStatus() ◀── onReady/onEvent/onDisconnect/onError
19
+ *
20
+ * ============================================================================
11
21
  */
12
22
  import { createChatChannelPlugin } from 'openclaw/plugin-sdk/core';
23
+ // ---- 通用工具:文本分片、默认账户 ID 解析 ------------------------------------
13
24
  import { chunkText, defaultAccountId } from './utils/index.js';
25
+ // ---- 常量:通道 Key、默认账户 ID、文本分片上限 -------------------------------
14
26
  import { CHANNEL_KEY, DEFAULT_ACCOUNT_ID, TEXT_CHUNK_LIMIT } from './config.js';
27
+ // ---- 出站发送:文本 / 媒体两类消息的实际发送实现 -----------------------------
15
28
  import { sendText, sendMedia } from './outbound.js';
29
+ // ---- Gateway:账户级别的 Socket.IO 长连接控制器 ------------------------------
16
30
  import { startGateway } from './gateway.js';
31
+ // ---- 共享 base:抽取出的通用插件基础配置(避免与其他插件重复) ---------------
17
32
  import { createLightclawPluginBase } from './shared.js';
33
+ // ---- Setup 适配器:首次配置 / 登录流程 ---------------------------------------
18
34
  import { lightclawSetupAdapter } from './setup-core.js';
35
+ // ---- 目标解析:对 target 字符串做 normalize 和 "是否像 ID" 的预判 -------------
19
36
  import { normalizeTarget, looksLikeId } from './messaging.js';
20
- // ============================================================
21
- // ChannelPlugin 定义(使用 createChatChannelPlugin)
22
- // ============================================================
37
+ /**
38
+ * Lightclaw 聊天通道插件。
39
+ *
40
+ * 通过 `createChatChannelPlugin` 工厂创建;泛型 `ResolvedAssistantAccount`
41
+ * 指定该通道使用的账户类型(OpenClaw 会据此在各回调中注入强类型 account)。
42
+ *
43
+ * 导出后由 OpenClaw 主程序在启动阶段加载并注册到 Channel 管理器中。
44
+ */
23
45
  export const lightclawPlugin = createChatChannelPlugin({
46
+ // ========================================================================
47
+ // base:插件的核心能力声明(messaging / status / gateway ...)
48
+ // ========================================================================
24
49
  base: {
25
- // ---- 来自 shared.ts 的基础定义 ----
50
+ // 展开共享的 base:包含 setup、channelKey、日志前缀等公用配置
26
51
  ...createLightclawPluginBase({
27
52
  setup: lightclawSetupAdapter,
28
53
  }),
29
- // ---- 消息目标解析 ----
54
+ // ----------------------------------------------------------------------
55
+ // messaging:消息目标(target)的解析与规范化
56
+ // ----------------------------------------------------------------------
30
57
  messaging: {
31
- // 对原始目标字符串进行标准化处理,如去除空格、统一格式、转换编码等,返回标准化后的字符串
58
+ /**
59
+ * 对原始目标字符串进行标准化处理。
60
+ *
61
+ * 例如:去除前后空格、统一大小写、把 `@xxx` 转为 `user:xxx` 等,
62
+ * 返回规范化后的字符串,供后续路由匹配使用。
63
+ */
32
64
  normalizeTarget: (target) => {
33
65
  return normalizeTarget(target);
34
66
  },
67
+ /**
68
+ * 目标解析器:在消息路由阶段帮助框架判断 target 的"形态"。
69
+ */
35
70
  targetResolver: {
36
- // 判断一个原始字符串是否看起来像一个 ID,用于在解析前快速分流处理逻辑
71
+ /**
72
+ * 判断一个原始字符串是否看起来像一个 ID(而不是昵称/关键词等),
73
+ * 用于在解析前快速分流处理逻辑,避免不必要的远端查询。
74
+ *
75
+ * @param id 用户输入的原始字符串
76
+ * @param normalized 经过 normalizeTarget 规范化后的字符串(可选)
77
+ */
37
78
  looksLikeId: (id, normalized) => {
38
79
  return looksLikeId(id, normalized);
39
80
  },
@@ -41,8 +82,14 @@ export const lightclawPlugin = createChatChannelPlugin({
41
82
  hint: `user:<userId> or channel:<groupId>`,
42
83
  },
43
84
  },
44
- // ---- 状态面板 ----
85
+ // ----------------------------------------------------------------------
86
+ // status:运行时状态快照的构建与默认值
87
+ // - defaultRuntime :账户 runtime 的初始值(未启动时回显)
88
+ // - buildChannelSummary:整个通道层级的概要状态(所有账户汇总之上的顶层)
89
+ // - buildAccountSnapshot:单个账户的详细快照(CLI/面板展示的主体数据)
90
+ // ----------------------------------------------------------------------
45
91
  status: {
92
+ /** 账户 runtime 的初始状态:未连接、未运行、无错误 */
46
93
  defaultRuntime: {
47
94
  accountId: DEFAULT_ACCOUNT_ID,
48
95
  running: false,
@@ -50,6 +97,10 @@ export const lightclawPlugin = createChatChannelPlugin({
50
97
  lastConnectedAt: null,
51
98
  lastError: null,
52
99
  },
100
+ /**
101
+ * 构建通道级别的汇总状态。
102
+ * 由 OpenClaw 在查询 `status` 命令时调用。
103
+ */
53
104
  buildChannelSummary: ({ snapshot }) => ({
54
105
  configured: snapshot.configured ?? false,
55
106
  running: snapshot.running ?? false,
@@ -57,10 +108,18 @@ export const lightclawPlugin = createChatChannelPlugin({
57
108
  lastConnectedAt: snapshot.lastConnectedAt ?? null,
58
109
  lastError: snapshot.lastError ?? null,
59
110
  }),
111
+ /**
112
+ * 构建单个账户的快照。
113
+ *
114
+ * @param account 账户元数据(accountId / name / apiKey / enabled 等)
115
+ * @param runtime 账户运行时状态(由 gateway 回调实时更新)
116
+ * @param cfg 当前 OpenClaw 完整配置,用于在 account 缺失时回退取默认
117
+ */
60
118
  buildAccountSnapshot: ({ account, runtime, cfg }) => ({
61
119
  accountId: account?.accountId ?? defaultAccountId(cfg),
62
120
  name: account?.name,
63
121
  enabled: account?.enabled ?? false,
122
+ // configured 表示"是否已配置 apiKey",是前端判断"是否可启动"的依据
64
123
  configured: Boolean(account?.apiKey),
65
124
  running: runtime?.running ?? false,
66
125
  connected: runtime?.connected ?? false,
@@ -68,24 +127,41 @@ export const lightclawPlugin = createChatChannelPlugin({
68
127
  lastError: runtime?.lastError ?? null,
69
128
  }),
70
129
  },
71
- // ---- Gateway 生命周期(核心!) ----
130
+ // ----------------------------------------------------------------------
131
+ // gateway:账户级生命周期(启动 WS 长连接 / 登出清理凭证)
132
+ // ----------------------------------------------------------------------
72
133
  gateway: {
73
134
  /**
74
- * 启动账户:建立 WS 长连接到 AI 助手 Server
75
- * OpenClaw 会在加载配置后为每个 enabled 的账户调用此方法
135
+ * 启动账户:建立 WS 长连接到 AI 助手 Server
136
+ *
137
+ * OpenClaw 会在加载配置后,为每个 `enabled` 的账户调用此方法。
138
+ * 方法内部通过 `startGateway` 维持一个带自动重连的 Socket.IO 实例,
139
+ * 并通过一组回调把连接状态反向写回 OpenClaw 的 runtime 状态。
140
+ *
141
+ * @param ctx 插件上下文:
142
+ * - account 当前账户解析结果
143
+ * - abortSignal 外部取消信号(用户执行 stop 时触发)
144
+ * - log 插件作用域的 logger
145
+ * - cfg OpenClaw 完整配置
146
+ * - getStatus/setStatus 读写当前 runtime 快照
76
147
  */
77
148
  startAccount: async (ctx) => {
78
149
  const { account, abortSignal, log, cfg } = ctx;
79
- log?.info(`[${CHANNEL_KEY}:${account.accountId}] Starting gateway...`);
150
+ const preLogFix = `[${CHANNEL_KEY}:${account.accountId}]`;
151
+ log?.info(`${preLogFix} Starting gateway...`);
80
152
  // 启动并维持一个 Socket.IO Gateway 实例
81
153
  await startGateway({
82
154
  account,
83
155
  abortSignal,
84
156
  cfg,
85
157
  log,
86
- /** WS 连接就绪回调 */
158
+ /**
159
+ * WS 连接就绪回调:
160
+ * 标记 running=true / connected=true,并用当前时间戳初始化
161
+ * lastConnectedAt 与 lastEventAt(后者是 health-monitor 的检测基准)。
162
+ */
87
163
  onReady: () => {
88
- log?.info(`[${CHANNEL_KEY}:${account.accountId}] Gateway ready: WS connected`);
164
+ log?.info(`${preLogFix} Gateway ready: WS connected`);
89
165
  const now = Date.now();
90
166
  ctx.setStatus({
91
167
  ...ctx.getStatus(),
@@ -96,17 +172,24 @@ export const lightclawPlugin = createChatChannelPlugin({
96
172
  lastEventAt: now,
97
173
  });
98
174
  },
99
- /** WS 连接断开回调 */
175
+ /**
176
+ * WS 连接断开回调:
177
+ * 仅把 connected 置为 false;running 保持 true,表示 gateway
178
+ * 仍在尝试重连。真正的停止由 abortSignal 触发。
179
+ */
100
180
  onDisconnect: () => {
101
- log?.info(`[${CHANNEL_KEY}:${account.accountId}] Gateway ready: WS disconnect`);
181
+ log?.info(`${preLogFix} Gateway ready: WS disconnect`);
102
182
  ctx.setStatus({
103
183
  ...ctx.getStatus(),
104
184
  connected: false,
105
185
  });
106
186
  },
107
- /** 错误回调:记录错误信息,不中断重连逻辑(由 gateway 内部处理) */
187
+ /**
188
+ * 错误回调:记录错误信息到 runtime.lastError。
189
+ * 不中断重连逻辑(重连由 gateway 内部自愈机制处理)。
190
+ */
108
191
  onError: (error) => {
109
- log?.error(`[${CHANNEL_KEY}:${account.accountId}] Gateway error: ${error.message}`);
192
+ log?.error(`${preLogFix} Gateway error: ${error.message}`);
110
193
  ctx.setStatus({
111
194
  ...ctx.getStatus(),
112
195
  lastError: error.message,
@@ -114,11 +197,12 @@ export const lightclawPlugin = createChatChannelPlugin({
114
197
  },
115
198
  /**
116
199
  * 入站事件回调:每次收到消息时调用。
117
- * 刷新 lastEventAt 和 lastInboundAt,
118
- * 通知框架 health-monitor 该连接仍处于活跃状态。
200
+ *
201
+ * 通过刷新 `lastEventAt` 和 `lastInboundAt`,通知框架
202
+ * health-monitor 该连接仍处于活跃状态,避免被判定为 stale 而重连。
119
203
  */
120
204
  onEvent: () => {
121
- log?.info(`[${CHANNEL_KEY}:${account.accountId}] Gateway Ready: WS message enter`);
205
+ log?.info(`${preLogFix} Gateway Ready: WS message enter`);
122
206
  ctx.setStatus({
123
207
  ...ctx.getStatus(),
124
208
  lastEventAt: Date.now(),
@@ -127,19 +211,24 @@ export const lightclawPlugin = createChatChannelPlugin({
127
211
  },
128
212
  });
129
213
  },
130
- /** 登出账户:清除凭证 */
214
+ /**
215
+ * 登出账户:从配置中清除该账户的凭证(apiKey)。
216
+ * 清理 `lightclawbot.accounts[accountId].apiKey`
217
+ *
218
+ * 注意:本方法只负责清除凭证字段,不停止 gateway(停止由框架调度)。
219
+ *
220
+ * @returns { ok, cleared } cleared=true 表示确实清理了字段
221
+ */
131
222
  logoutAccount: async ({ accountId, cfg }) => {
223
+ // 拷贝 cfg,避免直接改引用导致上游感知不到变化
132
224
  const nextCfg = { ...cfg };
133
- const section = nextCfg.channels?.[CHANNEL_KEY];
225
+ const lightclawbotConfig = nextCfg?.channels?.[CHANNEL_KEY];
134
226
  let cleared = false;
135
- if (section) {
136
- if (accountId === DEFAULT_ACCOUNT_ID && section.apiKeys) {
137
- delete section.apiKeys;
138
- cleared = true;
139
- }
140
- const accounts = section.accounts;
141
- if (accounts?.[accountId]?.apiKeys) {
142
- delete accounts[accountId].apiKeys;
227
+ if (lightclawbotConfig) {
228
+ // 命名账户:凭证挂在 accounts[accountId]
229
+ const accounts = lightclawbotConfig.accounts;
230
+ if (accounts?.[accountId]?.apiKey) {
231
+ delete accounts[accountId].apiKey;
143
232
  cleared = true;
144
233
  }
145
234
  }
@@ -147,13 +236,30 @@ export const lightclawPlugin = createChatChannelPlugin({
147
236
  },
148
237
  },
149
238
  },
150
- // ---- 出站消息适配器 ----
239
+ // ========================================================================
240
+ // outbound:出站消息投递配置
241
+ // - base :投递策略(模式、分片器、目标解析)
242
+ // - attachedResults :实际发送函数(sendText / sendMedia)
243
+ // ========================================================================
151
244
  outbound: {
152
245
  base: {
246
+ /** 投递模式:direct = 直接发送,不走队列/合并 */
153
247
  deliveryMode: 'direct',
248
+ /** 文本分片器:按 Markdown 语义边界做智能切分 */
154
249
  chunker: chunkText,
250
+ /** 分片模式:markdown 模式会保留代码块、列表等结构完整性 */
155
251
  chunkerMode: 'markdown',
252
+ /** 单条消息文本长度上限(超过会被 chunker 切成多条发送) */
156
253
  textChunkLimit: TEXT_CHUNK_LIMIT,
254
+ /**
255
+ * 解析投递目标(resolveTarget):
256
+ *
257
+ * 决定一次 outbound 消息最终要发送给谁(to)。
258
+ * 优先级:
259
+ * 1. 显式 `to` 参数(用户 / 上层明确指定);
260
+ * 2. implicit 模式下,从 `allowFrom` 列表中挑第一个非通配符条目;
261
+ * 3. 都没有则返回错误,提示用户用 `--to` 指定或配置 allowFrom。
262
+ */
157
263
  resolveTarget: ({ to, allowFrom, mode }) => {
158
264
  // 优先使用显式 to;implicit 模式下回退到 allowFrom 第一个非通配符条目
159
265
  const effectiveTo = to?.trim();
@@ -172,11 +278,26 @@ export const lightclawPlugin = createChatChannelPlugin({
172
278
  };
173
279
  },
174
280
  },
281
+ /**
282
+ * attachedResults:绑定到"工具调用结果投递"这条链路的发送实现。
283
+ *
284
+ * OpenClaw 在 assistant 产出工具结果需要推送给外部时,会根据
285
+ * `channel` 字段路由到这里,调用 sendText / sendMedia 完成实际投递。
286
+ */
175
287
  attachedResults: {
288
+ /** 通道标识,需与 CHANNEL_KEY 一致,供 OpenClaw 路由匹配 */
176
289
  channel: CHANNEL_KEY,
290
+ /**
291
+ * 发送纯文本消息。
292
+ * 参数由框架透传:to 目标、text 内容、accountId 账户、replyToId 回复锚点、cfg 完整配置。
293
+ */
177
294
  sendText: async ({ to, text, accountId, replyToId, cfg }) => {
178
295
  return sendText({ to, text, accountId, replyToId, cfg });
179
296
  },
297
+ /**
298
+ * 发送带媒体附件的消息(图片 / 文件等)。
299
+ * text 为附带的文本说明,mediaUrl 为媒体资源 URL。
300
+ */
180
301
  sendMedia: async ({ to, text, mediaUrl, accountId, replyToId, cfg }) => {
181
302
  return sendMedia({ to, text, mediaUrl, accountId, replyToId, cfg });
182
303
  },
@@ -147,6 +147,10 @@ export const EVENT_HISTORY_REQUEST = 'message:history:request';
147
147
  export const EVENT_HISTORY_RESPONSE = 'message:history:response';
148
148
  export const EVENT_SESSIONS_REQUEST = 'sessions:request';
149
149
  export const EVENT_SESSIONS_RESPONSE = 'sessions:response';
150
+ export const EVENT_AGENTS_REQUEST = 'agents:request';
151
+ export const EVENT_AGENTS_RESPONSE = 'agents:response';
152
+ export const EVENT_CHAT_REQUEST = 'chat:request';
153
+ export const EVENT_CHAT_RESPONSE = 'chat:response';
150
154
  // ============================================================
151
155
  // 文件下载信令(kind 字段值)
152
156
  // ============================================================
@@ -133,10 +133,19 @@ export async function startGateway(ctx) {
133
133
  * - 所有消息都走可靠发送队列,入队即视为"发送成功",实际 ACK 由 ReliableEmitter 保证
134
134
  */
135
135
  const emit = (data) => {
136
+ // 统一保证 chatId 字段存在:全部 EVENT_MESSAGE_PRIVATE 出站消息都通过
137
+ // `extra.chatId` 携带,调用方未传入时默认为 ''(默认会话)。
138
+ // 同时保留调用方传入的其他 extra 字段(如 transferData)。
139
+ const existingExtra = data.extra ?? {};
140
+ const chatId = typeof existingExtra.chatId === 'string' ? existingExtra.chatId : '';
141
+ const payload = {
142
+ ...data,
143
+ extra: { ...existingExtra, chatId },
144
+ };
136
145
  // 完整发送日志由 ReliableEmitter 打印(含 idempotencyKey)
137
- reliableEmitter.emitWithAck(EVENT_MESSAGE_PRIVATE, data, data.msgId).then((ok) => {
146
+ reliableEmitter.emitWithAck(EVENT_MESSAGE_PRIVATE, payload, payload.msgId).then((ok) => {
138
147
  if (!ok)
139
- log?.error(`[${CHANNEL_KEY}] Message delivery failed after retries: msgId=${data.msgId}`);
148
+ log?.error(`[${CHANNEL_KEY}] Message delivery failed after retries: msgId=${payload.msgId}`);
140
149
  });
141
150
  return true;
142
151
  };
@@ -149,7 +158,7 @@ export async function startGateway(ctx) {
149
158
  * @param replyToMsgId - 可选,回复对应的原始消息 ID(用于前端展示引用关系)
150
159
  * @param agentId - 可选,目标 Agent ID
151
160
  */
152
- const sendReply = (targetId, text, replyToMsgId, agentId) => {
161
+ const sendReply = (targetId, text, replyToMsgId, chatId, agentId) => {
153
162
  log?.info(`[${CHANNEL_KEY}] sendReply: ${text} to ${targetId} (replyTo: ${replyToMsgId || 'none'})`);
154
163
  return emit({
155
164
  msgId: generateMsgId(),
@@ -159,6 +168,7 @@ export async function startGateway(ctx) {
159
168
  timestamp: Date.now(),
160
169
  replyToMsgId,
161
170
  agentId,
171
+ extra: { chatId: chatId ?? '' },
162
172
  });
163
173
  };
164
174
  /**
@@ -171,7 +181,7 @@ export async function startGateway(ctx) {
171
181
  * @param replyToMsgId - 可选,回复对应的原始消息 ID
172
182
  * @param agentId - 可选,目标 Agent ID
173
183
  */
174
- const sendFiles = (targetId, text, files, replyToMsgId, agentId) => {
184
+ const sendFiles = (targetId, text, files, replyToMsgId, chatId, agentId) => {
175
185
  return emit({
176
186
  msgId: generateMsgId(),
177
187
  from: botClientId,
@@ -181,6 +191,7 @@ export async function startGateway(ctx) {
181
191
  files,
182
192
  replyToMsgId,
183
193
  agentId,
194
+ extra: { chatId: chatId ?? '' },
184
195
  });
185
196
  };
186
197
  /** SocketEmitter 抽象:将 emit / sendReply / sendFiles 和 botClientId 打包传给 inbound 处理器 */
@@ -201,7 +212,7 @@ export async function startGateway(ctx) {
201
212
  catch (err) {
202
213
  log?.error(`[${CHANNEL_KEY}] Message handler error: ${err}`);
203
214
  try {
204
- emitter.sendReply(msg.senderId, "消息处理异常,请稍后重试。", msg.messageId);
215
+ emitter.sendReply(msg.senderId, "消息处理异常,请稍后重试。", msg.messageId, msg.chatId);
205
216
  emitter.emit({
206
217
  msgId: generateMsgId(),
207
218
  from: emitter.botClientId,
@@ -211,6 +222,7 @@ export async function startGateway(ctx) {
211
222
  kind: "typing_stop",
212
223
  replyToMsgId: msg.messageId,
213
224
  agentId: msg.agentId,
225
+ extra: { chatId: msg.chatId ?? '' },
214
226
  });
215
227
  }
216
228
  catch (notifyErr) {
@@ -20,4 +20,4 @@ export { isSystemInjectedUserMessage, extractText, extractRawText, extractThinki
20
20
  // ── Cron Utilities ──
21
21
  export { isCronSessionKey, isCronRunKey, isCronBaseKey, extractCronJobId, extractCronRunId, classifySessionKey, listCronSessions, groupCronSessionsByJob, extractCronInfoFromText, cleanCronUserMessage, extractCronJobIdsFromTranscript, findCronSessionsByJobIds, } from "./cron-utils.js";
22
22
  // ── Session Reader (核心 API) ──
23
- export { readSessionHistory, readSessionHistoryTail, readCronHistory, readSessionHistoryWithCron, listSessions, } from "./session-reader.js";
23
+ export { readSessionHistory, readSessionHistoryTail, readCronHistory, readSessionHistoryWithCron, readSessionHistoriesByIds, listSessions, } from "./session-reader.js";
@@ -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
  }
@@ -9,10 +9,10 @@
9
9
  * - listSessions() — 列出所有 session(含 cron 类型标识)
10
10
  */
11
11
  import fs from "node:fs";
12
- import { resolveTranscriptPath } from "./session-store.js";
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
- import { loadSessionStore } from "./session-store.js";
16
16
  // ============================================================
17
17
  // 核心读取:单个 Session
18
18
  // ============================================================
@@ -31,7 +31,10 @@ export function readSessionHistory(sessionKey, opts) {
31
31
  return [];
32
32
  try {
33
33
  const raw = fs.readFileSync(filePath, "utf-8");
34
- return parseTranscriptLines(raw, { limit, chatOnly });
34
+ const messages = parseTranscriptLines(raw, { limit, chatOnly });
35
+ // 按"轮次"聚合 usage(详见 aggregateUsageByTurn 注释)
36
+ aggregateUsageByTurn(messages);
37
+ return messages;
35
38
  }
36
39
  catch {
37
40
  return [];
@@ -73,7 +76,10 @@ export function readSessionHistoryTail(sessionKey, opts) {
73
76
  if (readStart > 0 && lines.length > 0) {
74
77
  lines.shift();
75
78
  }
76
- return parseLines(lines, { limit, chatOnly });
79
+ const messages = parseLines(lines, { limit, chatOnly });
80
+ // 按"轮次"聚合 usage(详见 aggregateUsageByTurn 注释)
81
+ aggregateUsageByTurn(messages);
82
+ return messages;
77
83
  }
78
84
  catch {
79
85
  return [];
@@ -199,6 +205,10 @@ export function readSessionHistoryWithCron(sessionKey, opts) {
199
205
  // 6. 按时间合并
200
206
  const merged = [...mainMessages, ...cronMessages]
201
207
  .sort((a, b) => (a.timestamp ?? 0) - (b.timestamp ?? 0));
208
+ // 主会话与 cron 消息合并后再聚合一次:cron 触发的"system: ⏰ ..."类消息
209
+ // 通常被识别为 user 角色(系统注入),会自然分隔轮次,因此聚合不会把
210
+ // 主会话和 cron 提醒的 usage 错误合并。
211
+ aggregateUsageByTurn(merged);
202
212
  return merged.length > limit ? merged.slice(-limit) : merged;
203
213
  }
204
214
  // ============================================================
@@ -226,6 +236,66 @@ export function listSessions(agentId) {
226
236
  .sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0));
227
237
  }
228
238
  // ============================================================
239
+ // 多 SessionId 合并读取(跨 reset 历史回看)
240
+ // ============================================================
241
+ /**
242
+ * 给定一组 sessionId(按时间序),合并读取它们各自 jsonl 中的消息。
243
+ *
244
+ * 适用场景:
245
+ * chats.json 的 sessionIdHistory 保存了某 chat 历史上用过的所有 sessionId
246
+ * (含已被 reset 归档的旧 ID 和当前在用的 ID)。本函数据此把多份 jsonl
247
+ * 合并为一份按时间正序的消息流,让前端能跨 reset 看到完整对话历史。
248
+ *
249
+ * 实现细节:
250
+ * - 通过 resolveTranscriptPathBySessionId 直接按 sessionId 定位文件,
251
+ * 既能命中活跃文件 <sessionId>.jsonl,也能命中归档文件
252
+ * <sessionId>.jsonl.reset.* / .deleted.*;
253
+ * - 单份 jsonl 内已按写入顺序自然有序,不同 sessionId 之间通过 timestamp
254
+ * 做最终归并排序;
255
+ * - 仅返回最后 limit 条(保持与 readSessionHistory 一致的行为)。
256
+ *
257
+ * @param sessionIds - 该 chat 用过的所有 sessionId(顺序由调用方保证:
258
+ * 通常 = chats.json[chatId].sessionIdHistory)
259
+ * @param opts - 与 readSessionHistory 一致;agentId 用于路径解析
260
+ * @returns 按 timestamp 升序合并的消息列表(最多 limit 条)
261
+ */
262
+ export function readSessionHistoriesByIds(sessionIds, opts) {
263
+ const limit = opts?.limit ?? 200;
264
+ const chatOnly = opts?.chatOnly ?? false;
265
+ if (!Array.isArray(sessionIds) || sessionIds.length === 0)
266
+ return [];
267
+ const all = [];
268
+ // 去重 + 保持顺序:同一 sessionId 出现多次只读一次
269
+ const seen = new Set();
270
+ for (const sid of sessionIds) {
271
+ if (!sid || seen.has(sid))
272
+ continue;
273
+ seen.add(sid);
274
+ const filePath = resolveTranscriptPathBySessionId(sid, opts?.agentId);
275
+ if (!filePath)
276
+ continue;
277
+ try {
278
+ const raw = fs.readFileSync(filePath, "utf-8");
279
+ // 单文件先读到上限:避免单段过大撑爆内存;最终还要按 limit 截断
280
+ const msgs = parseTranscriptLines(raw, { limit, chatOnly });
281
+ for (const m of msgs)
282
+ all.push(m);
283
+ }
284
+ catch {
285
+ // 单段读取失败不阻断其他段
286
+ }
287
+ }
288
+ if (all.length === 0)
289
+ return [];
290
+ // 多段合并:按 timestamp 升序;无 timestamp 的消息按相对顺序兜底
291
+ all.sort((a, b) => (a.timestamp ?? 0) - (b.timestamp ?? 0));
292
+ // 合并后再做 usage 轮次聚合:跨 sessionId 的同一对话回合也能被识别
293
+ // (注意:每个 sessionId 内部的消息已经在 readSessionHistory 中聚合过一次,
294
+ // 幂等,再聚合一次结果不变;此处主要解决跨 reset 的轮次合并场景)
295
+ aggregateUsageByTurn(all);
296
+ return all.length > limit ? all.slice(-limit) : all;
297
+ }
298
+ // ============================================================
229
299
  // 内部工具函数
230
300
  // ============================================================
231
301
  /**
@@ -29,6 +29,7 @@ export function resolveOpenClawHome() {
29
29
  export function resolveSessionsDir(agentId) {
30
30
  const home = resolveOpenClawHome();
31
31
  const id = agentId?.trim() || "main";
32
+ // 例如:~/.openclaw/agents/main/sessions
32
33
  return path.join(home, "agents", id, "sessions");
33
34
  }
34
35
  // ============================================================
@@ -45,6 +46,7 @@ let _lastStoreFilePath = null;
45
46
  * 加载 sessions.json 索引,获取 sessionKey → sessionEntry 映射
46
47
  */
47
48
  export function loadSessionStore(agentId) {
49
+ // ~/.openclaw/agents/main/sessions/sessions.json
48
50
  const storePath = path.join(resolveSessionsDir(agentId), "sessions.json");
49
51
  // const storePath = path.join(currentDir, "../../sessions/sessions.json").replace("file:", "");
50
52
  const loaded = _tryLoadStore(storePath);
@@ -169,3 +171,44 @@ function _findArchivedTranscript(dir, jsonlFileName) {
169
171
  }
170
172
  return null;
171
173
  }
174
+ /**
175
+ * 直接根据 sessionId 解析 transcript 文件路径(不依赖 sessions.json 索引)。
176
+ *
177
+ * 适用场景:
178
+ * chats.json 中的 sessionIdHistory 保存了某 chat 历史上用过的所有 sessionId,
179
+ * 这些 sessionId 在 reset 之后会被框架从 sessions.json 索引中移除(旧条目失效),
180
+ * 因此无法再通过 sessionKey 反查;只能直接按 sessionId 在磁盘上定位 jsonl。
181
+ *
182
+ * 解析顺序:
183
+ * 1. <sessionsDir>/<sessionId>.jsonl —— 当前在用的 transcript
184
+ * 2. <sessionsDir>/<sessionId>.jsonl.reset.* / .deleted.* —— 归档 transcript
185
+ * 3. storeDir 兜底(同 resolveTranscriptPath,离线分析场景)
186
+ *
187
+ * 找不到则返回 null。
188
+ */
189
+ export function resolveTranscriptPathBySessionId(sessionId, agentId) {
190
+ if (!sessionId || !sessionId.trim())
191
+ return null;
192
+ const sessionsDir = resolveSessionsDir(agentId);
193
+ const jsonlFileName = `${sessionId}.jsonl`;
194
+ // 1. 标准目录下的活跃 transcript
195
+ const defaultPath = path.join(sessionsDir, jsonlFileName);
196
+ if (fs.existsSync(defaultPath))
197
+ return defaultPath;
198
+ // 2. 同目录归档文件
199
+ const archivedPath = _findArchivedTranscript(sessionsDir, jsonlFileName);
200
+ if (archivedPath)
201
+ return archivedPath;
202
+ // 3. storeDir 兜底(离线分析场景)
203
+ // 注意:getStoreDir() 仅在 loadSessionStore() 成功后才有值,调用方需确保前置条件
204
+ const storeDir = getStoreDir();
205
+ if (storeDir && storeDir !== sessionsDir) {
206
+ const siblingPath = path.join(storeDir, jsonlFileName);
207
+ if (fs.existsSync(siblingPath))
208
+ return siblingPath;
209
+ const archivedInStore = _findArchivedTranscript(storeDir, jsonlFileName);
210
+ if (archivedInStore)
211
+ return archivedInStore;
212
+ }
213
+ return null;
214
+ }
@@ -71,6 +71,13 @@ export function stripResidualMetadata(text) {
71
71
  result = result.replace(/<file\b[^>]*>.*?<\/file>/gs, "");
72
72
  // 移除 "用户发送了文件: filename (size)" 描述文本
73
73
  result = result.replace(/用户发送了文件:\s*.+?\s*\([^)]+\)\s*/g, "");
74
+ // 移除 OpenClaw 媒体消息的结构化包装块:
75
+ // [Image] / [Video] / [Audio] / [Document] / [Image 1] 等媒体类型标记行
76
+ result = result.replace(/^\[[A-Za-z][\w ]*\]\s*$/gm, "");
77
+ // "User text:" 单独一行的 label(其后紧跟用户真实输入,需保留输入本身)
78
+ result = result.replace(/^User text:\s*$/gm, "");
79
+ // "Description:\n<AI 对媒体的自动描述>" — 位于消息尾部,直接吃到文本末尾
80
+ result = result.replace(/^Description:[\s\S]*$/m, "");
74
81
  return result.trim();
75
82
  }
76
83
  // ============================================================