lark-remote 0.5.15 → 0.5.16

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/CHANGELOG.md CHANGED
@@ -5,6 +5,22 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.5.16] - 2026-11-01
9
+
10
+ ### 修复
11
+
12
+ - **Claude / Pi 的「本 run」用量在多步回合下不再低报**:卡片「本 run」与 `/resume`、自动恢复卡、完成通知卡的 Input / Output 由会话文件兜底读取,此前这两个 agent 只取**最后一条 assistant 消息**,而一条消息等于一次模型请求——带工具往返的回合里显示的是最后一次请求的量,整轮消耗被系统性低估。现在汇总末个真人回合内的所有 assistant 消息;`Context` 仍是末次请求的窗口水位、`累计` 仍是会话总量,口径不变。没有真人输入的旧数据或截断文件退回末条采样,不会把整份会话当成一轮
13
+ - **DSH 回合进行中的读数不再显示上一轮**:进程重启后的自动恢复与 `/resume`、没有活动用量时的中途读取、完成通知卡,此前优先取**上一个已结算回合**的数字(取值顺序与它自己的注释相反),当前回合已消耗的量被显示成上一轮。现在在途回合优先
14
+ - **DSH 的 `Context` 不再虚高**:窗口水位此前把整回合的输出一路相加,而前几步的输出早已计入后续请求的输入,属重复计数;`Context` 始终以会话文件为准,于是终态卡的上下文占用行随回合变长而虚高、百分比一起失真。现在只取末次请求的输出
15
+ - **Kimi / opencode 新回合刚开始时显示 0,不再显示上一轮残留**:回合已发出、模型还没产生任何用量采样的时刻,两个 reader 此前退回上一轮的最后一条记录,卡片把「刚开始的空回合」画成「刚结束那一轮」的消耗。现在按「末个回合」契约显示 0(`Context` 保留上一轮水位,因为窗口占用确实延续);整份记录里没有回合标记的旧数据沿用原有语义
16
+ - **`/compact` 收尾不再做无用功**:opencode 与 DSH 的会话读取给不出压缩次数,此前的重试把「这个 agent 没有该字段」当成「还没落盘」,每次压缩固定多跑两次完整导出 / 读取且永远等不到。现在 reader 显式声明能否产出压缩计数,声明不能的不再重试
17
+
18
+ ### 维护
19
+
20
+ - **用量来源与 result 事件语义拆成两个字段**:原先一个字段同时承担「Input/Output 来自 live 还是会话文件」和「result 事件是否按 live 语义处理(启动前的 result 丢弃、会话指针写回)」,而 Kimi / opencode 的 live 通知只带上下文占用、没有输入输出拆分,却被迫声明成 live 来源。现在前者单独声明为会话文件,result 事件语义走新的声明位。无行为变化
21
+ - **跨 agent 的用量口径断言集中到一处**:本 run / `Context` / 累计三条不变量原本每个 reader 各写一套期望值,容易把错误语义固化成测试基线;现在 Claude / Pi / DSH / Kimi / opencode 的回归用例共用同一组断言。无行为变化
22
+ - **新增 token 用量口径文档**(中英文):三个 scope 的硬不变量、live 与会话文件两条生产路径、同一低报问题反复复发的原因、六个 reader 各自的回合边界与 `Context` 公式,以及尚未落地的共享聚合器与子 agent 用量过滤缺口
23
+
8
24
  ## [0.5.15] - 2026-10-31
9
25
 
10
26
  ### 修复
@@ -1220,7 +1220,7 @@ export class Bridge {
1220
1220
  * @param dropFromCard 该 result 是陈旧重放,不得进卡片(调用方 continue 跳过 push)
1221
1221
  */
1222
1222
  observeResultEvent(event, opts) {
1223
- const { sawInit, usageAuthority, usage, writeback, runId } = opts;
1223
+ const { sawInit, liveResultEvents, usage, writeback, runId } = opts;
1224
1224
  const preInitResult = !sawInit;
1225
1225
  const preInitTerminal = preInitResult && (event.subtype === 'error' || event.subtype === 'interrupted')
1226
1226
  ? {
@@ -1228,7 +1228,7 @@ export class Bridge {
1228
1228
  errorMessage: event.errorMessage,
1229
1229
  }
1230
1230
  : undefined;
1231
- if (preInitResult && usageAuthority !== 'live') {
1231
+ if (preInitResult && !liveResultEvents) {
1232
1232
  getLogger().info(`[lark-remote] pre-init result ignored (resume replay) runId=${runId}`);
1233
1233
  return { sawResult: false, dropFromCard: true, preInitTerminal };
1234
1234
  }
@@ -1236,11 +1236,11 @@ export class Bridge {
1236
1236
  getLogger().info(preInitResult
1237
1237
  ? `[lark-remote] pre-init result from live runner (app-server setup failure) runId=${runId}`
1238
1238
  : `[lark-remote] result event received runId=${runId}`);
1239
- // app-server 模式 setup 失败路径(thread/start 成功后 turn/start 失败)不会有
1240
- // turn_started 事件,store 不会写回新线程 id;runner 已在 result 里兜底上报。
1241
- // 仅当 runner 声明 live usage(app-server)且会话指针未动时写回,避免下条消息
1242
- // 再开一个孤儿线程。其余 agent 不在此列(各自有 system.init / turn_started 写回)。
1243
- if (usageAuthority === 'live')
1239
+ // 连接型 runner 的 setup 失败路径(thread/start 成功后 turn/start 失败)
1240
+ // 不会有 turn_started 事件,store 不会写回新线程 id;runner 已在 result 里
1241
+ // 兜底上报。仅当 runner 声明 live result 语义且会话指针未动时写回,避免
1242
+ // 下条消息再开一个孤儿线程。其余 agent 不在此列(各自有 init/turn_started 写回)。
1243
+ if (liveResultEvents)
1244
1244
  writeBackResultSession(writeback, event.session_id);
1245
1245
  return { sawResult: !preInitResult, dropFromCard: false, preInitTerminal };
1246
1246
  }
@@ -1353,9 +1353,12 @@ export class Bridge {
1353
1353
  * Handles idle watchdog, event processing, session sync, and state transitions.
1354
1354
  */
1355
1355
  async runAgentStreamToEnd(runner, cardSession, activeRun, sessionId, sessionEpochAtStart, ctx, cwd, runId, message, agentKind = this.config.defaultAgent) {
1356
- // Runner 声明的 usage 权威来源:codex app-server = 'live'
1357
- // (turn/started 的 tokenUsage.last 是本 turn 增量)。其余 runner 未声明。
1356
+ // Runner 声明的 usage 权威来源:codex app-server / pi RPC = 'live'
1357
+ // (live 带 input/output);opencode/kimi ACP live 只有 context,声明
1358
+ // 'jsonl'。result 事件的 live 语义(pre-init/写回)单独声明,不能和
1359
+ // 用量来源共用一个字段。
1358
1360
  const usageAuthority = runner.getUsageAuthority?.();
1361
+ const liveResultEvents = runner.usesLiveResultEvents?.() ?? usageAuthority === 'live';
1359
1362
  // §9.12 空闲看门狗:判定口径见 startIdleWatchdog;武装点在 cardSession.start() 之后。
1360
1363
  let watchdog = null;
1361
1364
  let sawResult = false;
@@ -1453,7 +1456,7 @@ export class Bridge {
1453
1456
  if (event.type === 'result') {
1454
1457
  const outcome = this.observeResultEvent(event, {
1455
1458
  sawInit,
1456
- usageAuthority,
1459
+ liveResultEvents,
1457
1460
  usage,
1458
1461
  writeback,
1459
1462
  runId,
@@ -1975,8 +1978,11 @@ export class Bridge {
1975
1978
  // 压缩结束后从会话 jsonl 读权威统计(压缩后上下文、压缩次数、本次压缩
1976
1979
  // token 消耗与会话累计)。thread/compacted 通知与 jsonl 落盘几乎同时
1977
1980
  // (实测差 ~9ms),compactCount 未读到则短重试,仍失败优雅降级(无统计)。
1981
+ // 不支持 compactCount 的 reader(opencode/dsh)不重试——否则每次 compact
1982
+ // 都会固定多跑两次完整 export/读取,且永远等不到该字段。
1983
+ const compactCountSupported = this.sessionReaderRegistry.tryGet(agentKind)?.supportsCompactCount !== false;
1978
1984
  let finalUsage = resolveFinalUsage(this.sessionReaderRegistry, this.config.defaultAgent, sessionId, cwd, agentKind);
1979
- for (let attempt = 0; !finalUsage?.compactCount && attempt < 2; attempt++) {
1985
+ for (let attempt = 0; compactCountSupported && !finalUsage?.compactCount && attempt < 2; attempt++) {
1980
1986
  await sleep(150);
1981
1987
  finalUsage = resolveFinalUsage(this.sessionReaderRegistry, this.config.defaultAgent, sessionId, cwd, agentKind);
1982
1988
  }
@@ -122,7 +122,9 @@ export declare abstract class ConnectionBasedRunner<TClient, TEvent = AgentEvent
122
122
  * One-way by design: an evicted instance never returns to the cache.
123
123
  */
124
124
  markOrphaned(): void;
125
- getUsageAuthority(): 'live';
125
+ getUsageAuthority(): 'live' | 'jsonl';
126
+ /** 连接型 runner 的 result 事件都是 runner 自己产出的 live 语义。 */
127
+ usesLiveResultEvents(): boolean;
126
128
  killOrphan(): void;
127
129
  registerExitHandlers(): void;
128
130
  unregisterExitHandlers(): void;
@@ -221,6 +221,10 @@ export class ConnectionBasedRunner {
221
221
  getUsageAuthority() {
222
222
  return 'live';
223
223
  }
224
+ /** 连接型 runner 的 result 事件都是 runner 自己产出的 live 语义。 */
225
+ usesLiveResultEvents() {
226
+ return true;
227
+ }
224
228
  killOrphan() {
225
229
  // No-op: the connection is managed by ConnectionManager.
226
230
  }
@@ -56,6 +56,8 @@ export interface KimiAcpRunnerOptions {
56
56
  permissionMode?: 'manual' | 'auto' | 'yolo';
57
57
  }
58
58
  export declare class KimiAcpRunner extends BaseAcpRunner<KimiAcpTranslator> {
59
+ /** ACP usage_update 只有 context,没有 input/output:flow 走 jsonl。 */
60
+ getUsageAuthority(): 'jsonl';
59
61
  private permissionMode;
60
62
  private thinkingEffort?;
61
63
  private readonly compactIdleTimeoutMs;
@@ -134,6 +134,10 @@ function buildQuestionResponse(action, pending, response) {
134
134
  // Runner
135
135
  // =============================================================================
136
136
  export class KimiAcpRunner extends BaseAcpRunner {
137
+ /** ACP usage_update 只有 context,没有 input/output:flow 走 jsonl。 */
138
+ getUsageAuthority() {
139
+ return 'jsonl';
140
+ }
137
141
  permissionMode;
138
142
  thinkingEffort;
139
143
  compactIdleTimeoutMs;
@@ -53,6 +53,8 @@ export interface OpencodeAcpRunnerOptions {
53
53
  errorMonitorPollIntervalMs?: number;
54
54
  }
55
55
  export declare class OpencodeAcpRunner extends BaseAcpRunner<OpencodeAcpTranslator> {
56
+ /** ACP usage_update 只有 context,没有 input/output:flow 走 jsonl。 */
57
+ getUsageAuthority(): 'jsonl';
56
58
  /** Configured session mode applied via session/set_mode. */
57
59
  private configuredMode;
58
60
  /** Current session mode id, parsed from session/new|resume configOptions. */
@@ -30,6 +30,10 @@ import { OpencodeLogErrorMonitor, resolveOpencodeLogPath } from './error-monitor
30
30
  // Runner
31
31
  // =============================================================================
32
32
  export class OpencodeAcpRunner extends BaseAcpRunner {
33
+ /** ACP usage_update 只有 context,没有 input/output:flow 走 jsonl。 */
34
+ getUsageAuthority() {
35
+ return 'jsonl';
36
+ }
33
37
  /** Configured session mode applied via session/set_mode. */
34
38
  configuredMode;
35
39
  /** Current session mode id, parsed from session/new|resume configOptions. */
@@ -581,8 +581,15 @@ export interface AgentRunner extends Runner {
581
581
  readonly sessionReader: AgentSessionReader;
582
582
  /** Return current status info for /status display. */
583
583
  getStatusInfo(): AgentStatusInfo;
584
- /** Whether this runner provides live usage data (vs. file-based). */
584
+ /** flow 字段的权威来源:live 有 input/output 用 live,否则用 jsonl。 */
585
585
  getUsageAuthority?(): 'live' | 'jsonl';
586
+ /**
587
+ * 该 runner 的 result 事件是否具备 live 语义(pre-init result 处理与
588
+ * session 写回)。与用量来源分开:ACP live 只有 context,用量来源是
589
+ * jsonl,但 result 事件仍需按 live 语义处理。
590
+ * 未实现 = 按 getUsageAuthority() === 'live' 兼容旧行为。
591
+ */
592
+ usesLiveResultEvents?(): boolean;
586
593
  /**
587
594
  * 回传审批决策(codex thread/permissions、ACP elicitation、DSH approval
588
595
  * resolve)。未实现的 agent 由协调器按「无人应答」超时收尾。
@@ -246,6 +246,32 @@ export function isClaudeSessionActive(sessionId, cwd, opts = {}) {
246
246
  }
247
247
  }
248
248
  }
249
+ /**
250
+ * 是否是“真人发起新 turn”的 user 行。
251
+ *
252
+ * 只用于 turn 边界,不用于展示标题。排除:tool_result、compact summary、
253
+ * isMeta(skill/命令注入)、task notification、sidechain(子 agent prompt)。
254
+ * 不要求有可展示文本——attachment-only 的输入同样是一次真人 turn。
255
+ */
256
+ function isRealUserPrompt(obj) {
257
+ if (obj.type !== 'user' || obj.isCompactSummary === true || obj.isMeta === true) {
258
+ return false;
259
+ }
260
+ if (obj.isSidechain === true)
261
+ return false;
262
+ const msg = obj.message;
263
+ if (msg?.role !== 'user' || msg.content === undefined)
264
+ return false;
265
+ const content = msg.content;
266
+ if (Array.isArray(content) &&
267
+ content.some((block) => block?.type === 'tool_result')) {
268
+ return false;
269
+ }
270
+ const text = extractText(content);
271
+ if (text && isTaskNotificationText(text))
272
+ return false;
273
+ return true;
274
+ }
249
275
  /**
250
276
  * first-pass scan: stream the file once via `scanJsonlLines`
251
277
  * (no `string[]` materialized), parse each line exactly once, and collect
@@ -268,6 +294,8 @@ function scalarScan(filePath) {
268
294
  const usageByMessageId = new Map();
269
295
  let lastPostTokens = 0;
270
296
  let tailOffset = -1;
297
+ /** 真人 prompt 序号;末个 turn = 当前值。sidechain/注入/工具结果不递增。 */
298
+ let turnSeq = 0;
271
299
  let lastAiTitle;
272
300
  let lastRecap;
273
301
  let lastUserMessage;
@@ -285,6 +313,7 @@ function scalarScan(filePath) {
285
313
  output: Math.max(prev?.output ?? 0, usage.output_tokens ?? 0),
286
314
  cacheRead: Math.max(prev?.cacheRead ?? 0, usage.cache_read_input_tokens ?? 0),
287
315
  cacheCreation: Math.max(prev?.cacheCreation ?? 0, usage.cache_creation_input_tokens ?? 0),
316
+ turn: prev?.turn ?? turnSeq,
288
317
  });
289
318
  }
290
319
  }
@@ -337,16 +366,39 @@ function scalarScan(filePath) {
337
366
  }
338
367
  }
339
368
  }
369
+ // --- turn boundary for per-run usage ---
370
+ // 真人 prompt 开启新 turn;注入/工具结果/sidechain 不递增。usage 记录在
371
+ // 解析到 assistant 行时已经带上当前 turnSeq,末尾只汇总末个 turn。
372
+ if (isRealUserPrompt(obj))
373
+ turnSeq++;
340
374
  });
341
375
  // Feed one best record per message id, in first-appearance order (Map
342
376
  // preserves insertion order), so acc.last is the LAST message's full usage
343
- // and totals sum each API response exactly once.
377
+ // and totals sum each API response exactly once. Per-run flow is only the
378
+ // records belonging to the last real-user turn — a tool-looping turn has
379
+ // several assistant messages (one per model request), so `last` alone
380
+ // under-reports it.
381
+ const turn = { input: 0, output: 0, cacheRead: 0, cacheCreation: 0 };
344
382
  for (const record of usageByMessageId.values()) {
345
383
  acc.add(record);
384
+ if (turnSeq > 0 && record.turn === turnSeq) {
385
+ turn.input += record.input;
386
+ turn.output += record.output;
387
+ turn.cacheRead += record.cacheRead;
388
+ turn.cacheCreation += record.cacheCreation;
389
+ }
346
390
  }
347
391
  // Build usage result (same logic as old aggregateSessionUsage)
348
392
  const t = acc.totals;
349
393
  const l = acc.last;
394
+ // 没有真人 user prompt(旧数据/截断文件)→ 无法划分 turn,回退末条采样,
395
+ // 与 dsh/codex「无 turn 标记退回末条」一致;不能把整份文件当成一轮。
396
+ if (turnSeq === 0 && l) {
397
+ turn.input = l.input;
398
+ turn.output = l.output;
399
+ turn.cacheRead = l.cacheRead;
400
+ turn.cacheCreation = l.cacheCreation;
401
+ }
350
402
  // contextLength = current context-window occupancy. Prefer compact_boundary
351
403
  // postTokens (the precise post-compact size); otherwise fall back to the
352
404
  // last turn's input + cacheRead + cacheCreation (unified
@@ -362,14 +414,14 @@ function scalarScan(filePath) {
362
414
  // 对齐 codex reader 语义(§9.21:`last_token_usage`=单 turn 增量、
363
415
  // `total_token_usage`=session 累计)——否则 jsonl 兜底路径会把 session 累计
364
416
  // 当成"本 run"显示,与卡片"本 run ≤ 累计"不变量冲突(累计反而更小)。
365
- const lastTotal = l ? l.input + l.output + l.cacheRead + l.cacheCreation : 0;
417
+ const lastTotal = turn.input + turn.output + turn.cacheRead + turn.cacheCreation;
366
418
  const usage = {
367
- inputTokens: l ? l.input : 0,
368
- outputTokens: l ? l.output : 0,
419
+ inputTokens: turn.input,
420
+ outputTokens: turn.output,
369
421
  contextLength,
370
422
  compactCount: acc.compactCount,
371
- cacheReadTokens: l && l.cacheRead > 0 ? l.cacheRead : undefined,
372
- cacheCreationTokens: l && l.cacheCreation > 0 ? l.cacheCreation : undefined,
423
+ cacheReadTokens: turn.cacheRead > 0 ? turn.cacheRead : undefined,
424
+ cacheCreationTokens: turn.cacheCreation > 0 ? turn.cacheCreation : undefined,
373
425
  totalTokens: lastTotal,
374
426
  ...cumulativeUsageFields(t),
375
427
  };
@@ -41,6 +41,8 @@ interface DshSessionReaderOptions {
41
41
  syncConnect?: (baseUrl: string, token: string) => string;
42
42
  }
43
43
  export declare class DshSessionReader implements AgentSessionReader {
44
+ /** DSH session/page 没有 compactCount;compact 收尾不应因此重试读取。 */
45
+ readonly supportsCompactCount = false;
44
46
  private readonly hostProvider;
45
47
  private readonly dshHomeProvider;
46
48
  private readonly tokenProvider;
@@ -98,6 +98,8 @@ function isAuthFailure(err) {
98
98
  return /\b401\b|\b403\b|unauthori[sz]ed|forbidden|invalid[ -]?cookie/i.test(msg);
99
99
  }
100
100
  export class DshSessionReader {
101
+ /** DSH session/page 没有 compactCount;compact 收尾不应因此重试读取。 */
102
+ supportsCompactCount = false;
101
103
  hostProvider;
102
104
  dshHomeProvider;
103
105
  tokenProvider;
@@ -315,8 +317,8 @@ export class DshSessionReader {
315
317
  // 在途 turn(turn/start 无 turn/end,run 进行中读历史)的采样优先于
316
318
  // 上一轮:旧实现只认已结算的 lastTurnUsage,mid-run 读取会把当前 turn
317
319
  // 已消耗的量显示成上一个完整 turn 的数字。
318
- const flow = lastTurnUsage ??
319
- turnUsage ?? {
320
+ const flow = turnUsage ??
321
+ lastTurnUsage ?? {
320
322
  input,
321
323
  output: lastUsage.outputTokens ?? 0,
322
324
  cacheRead,
@@ -325,8 +327,9 @@ export class DshSessionReader {
325
327
  sessionUsage = {
326
328
  inputTokens: flow.input,
327
329
  outputTokens: flow.output,
328
- // §9.21 口径:含末轮 output(下一 prompt 全量携带,与 postTokens 水位同语义)。
329
- contextLength: input + flow.output + cacheRead + cacheWrite,
330
+ // §9.21 口径:context 是末次采样的窗口水位(input + 末次 output +
331
+ // cache),不把整 turn 的 output 求和(前几步 output 已进入后续 input)。
332
+ contextLength: input + (lastUsage.outputTokens ?? 0) + cacheRead + cacheWrite,
330
333
  cacheReadTokens: flow.cacheRead,
331
334
  cacheCreationTokens: flow.cacheWrite,
332
335
  totalTokens: flow.input + flow.output + flow.cacheRead + flow.cacheWrite,
@@ -353,8 +353,9 @@ export class KimiSessionReader {
353
353
  // 末条会低报,取会话总量(旧实现)又会把历史所有 run 的消耗算进这一轮。
354
354
  // 子 agent 的 wire 没有 turn 标记,只能进会话累计(无法归属到某一轮);
355
355
  // 整份 wire 都没有 turn.prompt(旧数据)时沿用「整份文件 = 一个 turn」。
356
+ // 末个 turn 尚无 usage.record 时 flow = 0(当前契约是「末个 turn」,不是
357
+ // 「末个非空 turn」)——不能退回上一轮的记录。
356
358
  let turnAcc = null;
357
- let lastMainUsage;
358
359
  const accumulateUsage = (u, scope) => {
359
360
  hasUsageRecord = true;
360
361
  // Malformed records (missing usage object or missing fields) must
@@ -372,7 +373,6 @@ export class KimiSessionReader {
372
373
  acc.add(record);
373
374
  if (scope !== 'main')
374
375
  return;
375
- lastMainUsage = record;
376
376
  turnAcc?.add(record);
377
377
  // unified contract (§9.21): input + output + cacheRead + cacheCreation.
378
378
  // 末轮 output 已进入历史、下一 prompt 全量携带——与 compact postTokens
@@ -498,10 +498,10 @@ export class KimiSessionReader {
498
498
  // Build the final AgentSessionUsage from the last turn's flow + session
499
499
  // cumulative + main contextLength.
500
500
  const t = acc.totals;
501
- // 有 turn.prompt 时本 run = 末个 turn 的 flow(末 turn 还没有记录时退回
502
- // 末条主 wire 记录);整份 wire 都没有 turn.prompt(旧数据/截断)时无从
503
- // 分段,沿用「整份文件 = 一个 turn」的旧口径(含子 agent wire)。
504
- const flow = turnAcc ? (turnAcc.count > 0 ? turnAcc.totals : (lastMainUsage ?? t)) : t;
501
+ // 有 turn.prompt 时本 run = 末个 turn 的 flow;末 turn 还没有记录时
502
+ // turnAcc.count = 0 → flow = 0。整份 wire 都没有 turn.prompt(旧数据/
503
+ // 截断)时无从分段,沿用「整份文件 = 一个 turn」的旧口径(含子 agent wire)。
504
+ const flow = turnAcc ? turnAcc.totals : t;
505
505
  // Kimi semantics: preserve 0 values (not undefined) for cache fields to match original
506
506
  const totalUsage = {
507
507
  inputTokens: flow.input,
@@ -31,6 +31,8 @@ interface OpencodeSessionReaderOptions {
31
31
  export declare class OpencodeSessionReader implements AgentSessionReader {
32
32
  private readonly binary;
33
33
  private readonly cacheTtlMs;
34
+ /** opencode export 没有 compactCount;compact 收尾不应因此重试 export。 */
35
+ readonly supportsCompactCount = false;
34
36
  /** Optional raw-export capture override (testing / alt transport). */
35
37
  private readonly captureExportOverride?;
36
38
  /** cwd → 列表缓存(TTL 由 cacheTtlMs 决定)。 */
@@ -119,6 +119,12 @@ function lastTurnStartIndex(messages) {
119
119
  }
120
120
  if (lastSample < 0)
121
121
  return -1;
122
+ // 末条采样之后又出现 user:这是新 turn 的起点,但还没有采样(空 turn)。
123
+ // 返回该 user 下标,让 flow 汇总范围为空 → 本 run = 0,而不是上一轮。
124
+ for (let i = lastSample + 1; i < messages.length; i++) {
125
+ if (messages[i]?.type === 'user')
126
+ return i;
127
+ }
122
128
  for (let i = lastSample - 1; i >= 0; i--) {
123
129
  if (messages[i]?.type === 'idle')
124
130
  return i;
@@ -162,6 +168,8 @@ function withCumulativeUsage(usage, cum) {
162
168
  export class OpencodeSessionReader {
163
169
  binary;
164
170
  cacheTtlMs;
171
+ /** opencode export 没有 compactCount;compact 收尾不应因此重试 export。 */
172
+ supportsCompactCount = false;
165
173
  /** Optional raw-export capture override (testing / alt transport). */
166
174
  captureExportOverride;
167
175
  /** cwd → 列表缓存(TTL 由 cacheTtlMs 决定)。 */
@@ -557,8 +565,9 @@ export class OpencodeSessionReader {
557
565
  totalTokens: sample.total,
558
566
  };
559
567
  }
560
- // turnStart 严格小于最后一个采样下标,所以 latest 存在时 turn 至少含一条
561
- // 采样:flow 覆盖末条值,只有 contextLength 保留末条。
568
+ // turnStart 可能落在末条采样之后(新 turn 尚无采样)→ turn 全 0;latest
569
+ // 仍是上一轮末条采样,contextLength 保留水位。turnStart 小于最后一个采样
570
+ // 下标时,flow 覆盖末条值,只有 contextLength 保留末条。
562
571
  if (latest) {
563
572
  latest.inputTokens = turn.input;
564
573
  latest.outputTokens = turn.output;
@@ -157,6 +157,11 @@ function findSessionFile(sessionId, cwd, sessionsDir) {
157
157
  */
158
158
  function piScalarScan(filePath) {
159
159
  const acc = new UsageAccumulator();
160
+ // 末个真人 turn 的 flow:user 消息开启新 turn,assistant 用量同时进两个
161
+ // accumulator。带工具往返的 turn 有多个 assistant 消息(一次模型请求一条),
162
+ // 只取末条会低报整轮消耗。
163
+ let turnAcc = new UsageAccumulator();
164
+ let sawUser = false;
160
165
  let tailOffset = -1;
161
166
  let lastUserText;
162
167
  scanJsonlOnce(filePath, (rawObj, line, offset) => {
@@ -168,18 +173,22 @@ function piScalarScan(filePath) {
168
173
  // usage aggregation (assistant only)
169
174
  if (msg.role === 'assistant' && msg.usage) {
170
175
  const u = msg.usage;
171
- acc.add({
176
+ const record = {
172
177
  input: u.input ?? 0,
173
178
  output: u.output ?? 0,
174
179
  cacheRead: u.cacheRead ?? 0,
175
180
  cacheCreation: u.cacheWrite ?? 0,
176
181
  total: u.totalTokens ?? 0,
177
- });
182
+ };
183
+ acc.add(record);
184
+ turnAcc.add(record);
178
185
  }
179
186
  // last user message offset + displayTitle. Record the byte offset
180
187
  // where the tail should start: the beginning of the NEXT line after
181
188
  // this user message (see tailOffsetAfter).
182
189
  if (msg.role === 'user') {
190
+ sawUser = true;
191
+ turnAcc = new UsageAccumulator();
183
192
  tailOffset = tailOffsetAfter(line, offset);
184
193
  const text = extractPiText(msg.content);
185
194
  // Compress skill-injection bodies so the "最近输入" label shows what
@@ -195,7 +204,7 @@ function piScalarScan(filePath) {
195
204
  });
196
205
  return {
197
206
  tailOffset,
198
- usage: buildPiUsage(acc),
207
+ usage: buildPiUsage(acc, turnAcc, sawUser),
199
208
  // Whitespace-normalized single-line display title (no length truncation —
200
209
  // the consumer truncates). Normalization collapses the skill-injection
201
210
  // newlines so the title stays on one line.
@@ -203,27 +212,38 @@ function piScalarScan(filePath) {
203
212
  };
204
213
  }
205
214
  /**
206
- * Build the AgentSessionUsage from an accumulator, preserving pi's
207
- * "last turn" semantics: inputTokens/outputTokens use the LAST turn's
208
- * values, contextLength = last turn's context-window occupancy
209
- * (input + cacheRead + cacheCreation, excludes output —unified
210
- * contract across all five readers), cumulative = session-wide sum.
215
+ * Build the AgentSessionUsage: flow (input/output/cache/total) is the sum of
216
+ * every assistant message in the LAST real-user turn; contextLength is the
217
+ * last assistant message's window occupancy; cumulative is the whole session.
218
+ * With no real-user turn (legacy/truncated file), flow falls back to the last
219
+ * assistant sample, matching dsh/codex's no-turn-marker fallback.
211
220
  * Returns undefined when no usage data was observed.
212
221
  */
213
- function buildPiUsage(acc) {
222
+ function buildPiUsage(acc, turnAcc, sawUser) {
214
223
  const t = acc.totals;
215
- if (t.input + t.output === 0) {
224
+ if (t.input + t.output + t.cacheRead + t.cacheCreation === 0) {
216
225
  return undefined;
217
226
  }
218
227
  const l = acc.last;
228
+ // 没有真人 user prompt(旧数据/截断文件)→ 无法划分 turn,回退末条采样,
229
+ // 与 dsh/codex「无 turn 标记退回末条」一致;不能把整份文件当成一轮。
230
+ const f = sawUser
231
+ ? turnAcc.totals
232
+ : {
233
+ input: l.input,
234
+ output: l.output,
235
+ cacheRead: l.cacheRead,
236
+ cacheCreation: l.cacheCreation,
237
+ total: l.total,
238
+ };
219
239
  return {
220
- inputTokens: l.input,
221
- outputTokens: l.output,
240
+ inputTokens: f.input,
241
+ outputTokens: f.output,
222
242
  contextLength: contextWindowOccupancy(l),
223
243
  compactCount: acc.compactCount > 0 ? acc.compactCount : undefined,
224
- cacheReadTokens: l.cacheRead > 0 ? l.cacheRead : 0,
225
- cacheCreationTokens: l.cacheCreation > 0 ? l.cacheCreation : 0,
226
- totalTokens: l.total > 0 ? l.total : undefined,
244
+ cacheReadTokens: f.cacheRead > 0 ? f.cacheRead : 0,
245
+ cacheCreationTokens: f.cacheCreation > 0 ? f.cacheCreation : 0,
246
+ totalTokens: f.total > 0 ? f.total : undefined,
227
247
  // Cumulative (session-wide): sum of ALL runs in the jsonl file.
228
248
  ...cumulativeUsageFields(t),
229
249
  };
@@ -10,4 +10,6 @@ export declare class SessionReaderRegistry {
10
10
  private readonly readers;
11
11
  register(kind: AgentKind, reader: AgentSessionReader): void;
12
12
  get(kind: AgentKind): AgentSessionReader;
13
+ /** 可选能力探测用:不抛错,交给调用方决定降级行为。 */
14
+ tryGet(kind: AgentKind): AgentSessionReader | undefined;
13
15
  }
@@ -16,5 +16,9 @@ export class SessionReaderRegistry {
16
16
  }
17
17
  return reader;
18
18
  }
19
+ /** 可选能力探测用:不抛错,交给调用方决定降级行为。 */
20
+ tryGet(kind) {
21
+ return this.readers.get(kind);
22
+ }
19
23
  }
20
24
  //# sourceMappingURL=registry.js.map
@@ -175,4 +175,10 @@ export interface AgentSessionReader {
175
175
  * 未实现 = 该 agent 没有这个概念,调用方按「不在途」处理。
176
176
  */
177
177
  readCompactionState?(sessionId: string, cwd: string): CompactionState;
178
+ /**
179
+ * `readSessionContent` 是否产出 `compactCount`。opencode 的 export 没有
180
+ * 压缩计数;compact 收尾的重试不能把「不支持」误判成「还没落盘」。
181
+ * 未声明 = 支持(保持既有 reader 行为)。
182
+ */
183
+ supportsCompactCount?: boolean;
178
184
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lark-remote",
3
- "version": "0.5.15",
3
+ "version": "0.5.16",
4
4
  "description": "Feishu (Lark) private-chat bridge to local coding agents — Claude Code / Codex / opencode / pi / kimi, with live-streaming CardKit 2.0 cards",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",