lark-remote 0.1.12 → 0.1.13

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.
Files changed (62) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/bridge/inbound-media.d.ts +0 -3
  3. package/dist/bridge/inbound-media.js +0 -1
  4. package/dist/bridge/index.d.ts +7 -0
  5. package/dist/bridge/index.js +79 -70
  6. package/dist/card/card-shared.d.ts +1 -4
  7. package/dist/card/card-shared.js +2 -4
  8. package/dist/common/truncate.d.ts +0 -6
  9. package/dist/common/truncate.js +1 -1
  10. package/dist/common/ttl-cache.d.ts +24 -0
  11. package/dist/common/ttl-cache.js +53 -0
  12. package/dist/config/codex-config.js +12 -11
  13. package/dist/config/index.d.ts +0 -3
  14. package/dist/config/index.js +0 -5
  15. package/dist/config/kimi-config.d.ts +1 -1
  16. package/dist/config/kimi-config.js +1 -1
  17. package/dist/config/opencode-config.d.ts +1 -1
  18. package/dist/config/opencode-config.js +3 -8
  19. package/dist/router/index.d.ts +7 -0
  20. package/dist/router/index.js +217 -279
  21. package/dist/runner/claude/session.d.ts +0 -3
  22. package/dist/runner/claude/session.js +0 -5
  23. package/dist/runner/codex/app-server/runner.d.ts +1 -16
  24. package/dist/runner/codex/app-server/runner.js +2 -2
  25. package/dist/runner/common/acp/protocol-helpers.d.ts +31 -0
  26. package/dist/runner/common/acp/protocol-helpers.js +48 -0
  27. package/dist/runner/common/connection-based-runner.d.ts +0 -2
  28. package/dist/runner/common/connection-based-runner.js +1 -1
  29. package/dist/runner/dsh/index.d.ts +0 -2
  30. package/dist/runner/dsh/index.js +0 -2
  31. package/dist/runner/dsh/runner.d.ts +0 -2
  32. package/dist/runner/dsh/runner.js +1 -1
  33. package/dist/runner/kimi/acp/runner.d.ts +18 -11
  34. package/dist/runner/kimi/acp/runner.js +95 -87
  35. package/dist/runner/kimi/acp/translator.d.ts +0 -1
  36. package/dist/runner/kimi/acp/translator.js +5 -13
  37. package/dist/runner/opencode/acp/runner.js +2 -42
  38. package/dist/runner/opencode/acp/translator.d.ts +0 -1
  39. package/dist/runner/opencode/acp/translator.js +3 -13
  40. package/dist/runner/pi/rpc/index.d.ts +0 -2
  41. package/dist/runner/pi/rpc/index.js +0 -2
  42. package/dist/runner/resolve-agent-choices.js +6 -2
  43. package/dist/runner/sync-agent-choices.js +1 -1
  44. package/dist/session/claude/session-index.js +3 -1
  45. package/dist/session/claude/sessions.js +3 -7
  46. package/dist/session/codex/rollout-reader.d.ts +0 -14
  47. package/dist/session/codex/rollout-reader.js +3 -7
  48. package/dist/session/common/pagination.d.ts +0 -2
  49. package/dist/session/common/pagination.js +1 -1
  50. package/dist/session/common/recency.d.ts +7 -0
  51. package/dist/session/common/recency.js +10 -0
  52. package/dist/session/common/usage-accumulator.d.ts +14 -0
  53. package/dist/session/common/usage-accumulator.js +15 -0
  54. package/dist/session/dsh/sessions.js +9 -6
  55. package/dist/session/kimi/sessions.d.ts +18 -4
  56. package/dist/session/kimi/sessions.js +49 -30
  57. package/dist/session/opencode/sessions.d.ts +2 -14
  58. package/dist/session/opencode/sessions.js +9 -7
  59. package/dist/session/pi/sessions.js +10 -22
  60. package/dist/update/index.d.ts +2 -2
  61. package/dist/update/index.js +1 -1
  62. package/package.json +1 -2
@@ -22,50 +22,10 @@
22
22
  import { ConnectionManager, } from '../../common/jsonrpc/connection-manager.js';
23
23
  import { OpencodeAcpTranslator } from './translator.js';
24
24
  import { RpcErrorCode, } from '../../common/acp/protocol-types.js';
25
- import { findOptionIdByKind } from '../../common/acp/protocol-helpers.js';
25
+ import { OPENCODE_APPROVAL_KINDS, buildAcpPermissionOutcome, } from '../../common/acp/protocol-helpers.js';
26
26
  import { getLogger } from '../../../logger/index.js';
27
27
  import { ConnectionBasedRunner } from '../../common/connection-based-runner.js';
28
28
  import { OpencodeLogErrorMonitor, resolveOpencodeLogPath } from './error-monitor.js';
29
- /**
30
- * Build the ACP protocol response for a permission approval decision.
31
- *
32
- * accept → {outcome:{outcome:'selected', optionId:<allow_once kind>}}
33
- * accept_for_session → {outcome:{outcome:'selected', optionId:<allow_always kind>}}
34
- * decline → {outcome:{outcome:'selected', optionId:<reject kind>}}
35
- * cancel → {outcome:{outcome:'cancelled'}}
36
- *
37
- * optionId is opaque and echoed back as-is (opencode permission.ts:20-24
38
- * offers once/always/reject with kinds allow_once/allow_always/reject_once;
39
- * the server maps optionId 'once'/'always' → approve, anything else →
40
- * reject — permission.ts:219-223). If no matching option is found, fall
41
- * back to cancelled (safe universal default).
42
- */
43
- function buildApprovalResponse(action, pending) {
44
- if (action === 'cancel') {
45
- return { outcome: { outcome: 'cancelled' } };
46
- }
47
- if (action === 'accept') {
48
- const optionId = findOptionIdByKind(pending.options, ['allow_once', 'allow_always']);
49
- if (optionId) {
50
- return { outcome: { outcome: 'selected', optionId } };
51
- }
52
- return { outcome: { outcome: 'cancelled' } };
53
- }
54
- if (action === 'accept_for_session') {
55
- // §P4: 卡片「允许本次会话」→ always 类 optionId(opencode 'always')
56
- const optionId = findOptionIdByKind(pending.options, ['allow_always', 'approve_always']);
57
- if (optionId) {
58
- return { outcome: { outcome: 'selected', optionId } };
59
- }
60
- return { outcome: { outcome: 'cancelled' } };
61
- }
62
- // decline
63
- const optionId = findOptionIdByKind(pending.options, ['reject_once', 'reject']);
64
- if (optionId) {
65
- return { outcome: { outcome: 'selected', optionId } };
66
- }
67
- return { outcome: { outcome: 'cancelled' } };
68
- }
69
29
  // =============================================================================
70
30
  // Runner
71
31
  // =============================================================================
@@ -252,7 +212,7 @@ export class OpencodeAcpRunner extends ConnectionBasedRunner {
252
212
  if (!client || !pending)
253
213
  return;
254
214
  const action = response?.action ?? 'decline';
255
- const acpResponse = buildApprovalResponse(action, pending);
215
+ const acpResponse = buildAcpPermissionOutcome(action, pending.options, OPENCODE_APPROVAL_KINDS);
256
216
  client.respond(requestId, acpResponse);
257
217
  this.pendingApprovals.delete(requestId);
258
218
  getLogger().info(`[${this.logTag}] approval responded requestId=${requestId} action=${action}`);
@@ -109,7 +109,6 @@ export declare class OpencodeAcpTranslator {
109
109
  * accept/decline/cancel are universal; acceptForSession requires an
110
110
  * always-class option (opencode `allow_always`, kimi `approve_always`).
111
111
  */
112
- private deriveAvailableDecisions;
113
112
  /**
114
113
  * ResultEvent.usage nominally requires input_tokens/output_tokens, but the
115
114
  * ACP wire has no such split — the live snapshot is context occupancy only.
@@ -29,6 +29,7 @@
29
29
  * session/request_permission → approval_requested (optionId echo)
30
30
  */
31
31
  import { NotificationMethod, ServerRequestMethod, SessionEventType, } from '../../common/acp/protocol-types.js';
32
+ import { deriveAcpAvailableDecisions, truncateWithEllipsis, } from '../../common/acp/protocol-helpers.js';
32
33
  import { getLogger } from '../../../logger/index.js';
33
34
  // =============================================================================
34
35
  // Translator
@@ -254,10 +255,10 @@ export class OpencodeAcpTranslator {
254
255
  requestId,
255
256
  kind: 'command',
256
257
  command: params.toolCall.title ?? undefined,
257
- reason: rawInput ? truncate(stringify(rawInput), 200) : undefined,
258
+ reason: rawInput ? truncateWithEllipsis(stringify(rawInput), 200) : undefined,
258
259
  // §P4: 从服务端 options kind 派生——带 allow_always(或 approve_always)
259
260
  // 才提供「本会话总是允许」(acceptForSession)。
260
- availableDecisions: this.deriveAvailableDecisions(params),
261
+ availableDecisions: deriveAcpAvailableDecisions(params.options ?? []),
261
262
  };
262
263
  return [
263
264
  {
@@ -277,14 +278,6 @@ export class OpencodeAcpTranslator {
277
278
  * accept/decline/cancel are universal; acceptForSession requires an
278
279
  * always-class option (opencode `allow_always`, kimi `approve_always`).
279
280
  */
280
- deriveAvailableDecisions(params) {
281
- const decisions = ['accept', 'decline', 'cancel'];
282
- const hasAlwaysOption = (params.options ?? []).some((opt) => opt.kind === 'allow_always' || opt.kind === 'approve_always');
283
- if (hasAlwaysOption) {
284
- decisions.push('acceptForSession');
285
- }
286
- return decisions;
287
- }
288
281
  // =========================================================================
289
282
  // Private helpers
290
283
  // =========================================================================
@@ -329,9 +322,6 @@ function extractToolResultContent(event) {
329
322
  }
330
323
  return '';
331
324
  }
332
- function truncate(text, maxLen) {
333
- return text.length <= maxLen ? text : text.slice(0, maxLen) + '…';
334
- }
335
325
  function stringify(value) {
336
326
  if (typeof value === 'string')
337
327
  return value;
@@ -1,4 +1,2 @@
1
1
  export { PiRpcRunner } from './runner.js';
2
2
  export type { PiRpcRunnerOptions } from './runner.js';
3
- export { PiRpcClient } from './client.js';
4
- export { PiRpcTranslator } from './translator.js';
@@ -1,4 +1,2 @@
1
1
  export { PiRpcRunner } from './runner.js';
2
- export { PiRpcClient } from './client.js';
3
- export { PiRpcTranslator } from './translator.js';
4
2
  //# sourceMappingURL=index.js.map
@@ -9,9 +9,13 @@ export function resolveAgentChoices(config) {
9
9
  if (agent === 'claude') {
10
10
  return config;
11
11
  }
12
- const agentChoices = choices[agent];
13
12
  const fields = choiceFieldsFor(agent);
14
- if (!agentChoices || !fields) {
13
+ // dsh 的 choices 恒为空(host 是连接配置而非 per-run choice),无需恢复
14
+ if (!fields || fields.length === 0) {
15
+ return config;
16
+ }
17
+ const agentChoices = choices[agent];
18
+ if (!agentChoices) {
15
19
  return config;
16
20
  }
17
21
  // Clone to avoid mutation
@@ -2,7 +2,7 @@ import { choiceFieldsFor } from './agent-choices-common.js';
2
2
  export function syncAgentChoices(config, agent) {
3
3
  // claude 的配置在顶层 config.claude,不在 agents 下,无需同步
4
4
  const fields = choiceFieldsFor(agent);
5
- if (!fields)
5
+ if (!fields || fields.length === 0)
6
6
  return config;
7
7
  const agents = config.agents;
8
8
  const agentCfg = agents?.[agent];
@@ -1,6 +1,7 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { scanJsonlLines } from '../common/jsonl.js';
4
+ import { sortByRecencyDesc } from '../common/recency.js';
4
5
  import { extractContentBlocks } from '../common/content-blocks.js';
5
6
  import { getLogger } from '../../logger/index.js';
6
7
  // ─── Claude content block mapping (shared with sessions.ts) ─
@@ -175,7 +176,8 @@ export class SessionIndex {
175
176
  byId.set(entry.sessionId, entry);
176
177
  }
177
178
  }
178
- const sorted = [...byId.values()].sort((a, b) => b.mtimeMs - a.mtimeMs || a.sessionId.localeCompare(b.sessionId));
179
+ const sorted = [...byId.values()];
180
+ sortByRecencyDesc(sorted, (s) => s.mtimeMs, (s) => s.sessionId);
179
181
  const offset = opts.offset ?? 0;
180
182
  const limit = opts.limit;
181
183
  const sliced = limit === undefined ? sorted.slice(offset) : sorted.slice(offset, offset + limit);
@@ -5,7 +5,7 @@ import { findJsonlLine, readLastJsonlLine, scanJsonlLines, readJsonlLinesFromOff
5
5
  import { STALE_MS } from '../common/constants.js';
6
6
  import { capEvents } from '../common/pagination.js';
7
7
  import { extractContentBlocks } from '../common/content-blocks.js';
8
- import { UsageAccumulator } from '../common/usage-accumulator.js';
8
+ import { UsageAccumulator, contextWindowOccupancy, cumulativeUsageFields, } from '../common/usage-accumulator.js';
9
9
  import { SessionIndex, extractText, isTaskNotificationText, CLAUDE_MAPPING, } from './session-index.js';
10
10
  /**
11
11
  * Directory Claude Code uses to store session transcripts for a given cwd.
@@ -329,7 +329,7 @@ function scalarScan(filePath) {
329
329
  // contract — excludes output/reasoning, which are generated, not part of
330
330
  // the input window). Take the max so a pre-compact turn larger than the
331
331
  // post-compact size is not under-reported.
332
- const lastWindow = l ? l.input + l.cacheRead + l.cacheCreation : 0;
332
+ const lastWindow = l ? contextWindowOccupancy(l) : 0;
333
333
  let contextLength = lastPostTokens;
334
334
  if (contextLength === 0 || lastWindow > contextLength) {
335
335
  contextLength = lastWindow;
@@ -347,11 +347,7 @@ function scalarScan(filePath) {
347
347
  cacheReadTokens: l && l.cacheRead > 0 ? l.cacheRead : undefined,
348
348
  cacheCreationTokens: l && l.cacheCreation > 0 ? l.cacheCreation : undefined,
349
349
  totalTokens: lastTotal,
350
- cumulativeTotalTokens: t.input + t.output + t.cacheRead + t.cacheCreation,
351
- cumulativeInputTokens: t.input,
352
- cumulativeOutputTokens: t.output,
353
- cumulativeCacheReadTokens: t.cacheRead,
354
- cumulativeCacheCreationTokens: t.cacheCreation,
350
+ ...cumulativeUsageFields(t),
355
351
  };
356
352
  return {
357
353
  tailOffset,
@@ -21,7 +21,6 @@ interface CodexRolloutEntry {
21
21
  firstUserMessage: string;
22
22
  /** Last real (human-typed) user message, for the "最近输入" card label. */
23
23
  lastRealUserMessage: string;
24
- createdAtMs: number;
25
24
  updatedAtMs: number;
26
25
  events: AgentSessionContentEvent[];
27
26
  /** Last-turn usage extracted from token_count events (ccusage-aligned). */
@@ -38,19 +37,6 @@ interface ListCodexRolloutsOptions {
38
37
  offset?: number;
39
38
  }
40
39
  export declare function readCodexRollout(filePath: string): CodexRolloutEntry | null;
41
- /**
42
- * Lightweight single-file scan that extracts only the display-relevant fields
43
- * (last real user message + usage) — **without** constructing the `events`
44
- * array. Backs `readCodexSessionSummary` so the `/resume` list prefetch does
45
- * not pay the cost of JSON.parse-ing every response_item and building events
46
- * that are immediately discarded.
47
- *
48
- * Returns null if the file doesn't exist, is corrupted, or has no session_meta.
49
- */
50
- export declare function readCodexRolloutSummary(filePath: string): {
51
- lastRealUserMessage: string;
52
- usage?: AgentSessionUsage;
53
- } | null;
54
40
  /**
55
41
  * List Codex sessions from rollout files.
56
42
  *
@@ -18,6 +18,7 @@ import path from 'node:path';
18
18
  import { readJsonlLines, findJsonlLine } from '../common/jsonl.js';
19
19
  import { STALE_MS } from '../common/constants.js';
20
20
  import { paginate, capEvents } from '../common/pagination.js';
21
+ import { sortByRecencyDesc } from '../common/recency.js';
21
22
  import { getLogger } from '../../logger/index.js';
22
23
  import { isRecord, stringValue } from '../../common/guards.js';
23
24
  import { resolveCodexHome } from '../../config/codex-config.js';
@@ -45,11 +46,7 @@ export function readCodexRollout(filePath) {
45
46
  if (!sessionId) {
46
47
  return null;
47
48
  }
48
- // Use file mtime as updatedAtMs, or parse timestamp from session_meta
49
49
  const stats = fs.statSync(filePath);
50
- const createdAtMs = sessionMeta.timestamp
51
- ? new Date(String(sessionMeta.timestamp)).getTime()
52
- : stats.birthtimeMs;
53
50
  const updatedAtMs = stats.mtimeMs;
54
51
  const firstUserMessage = scan.realUserMessages[0] ?? '';
55
52
  const lastRealUserMessage = scan.realUserMessages[scan.realUserMessages.length - 1] ?? '';
@@ -59,7 +56,6 @@ export function readCodexRollout(filePath) {
59
56
  cwd,
60
57
  firstUserMessage: firstUserMessage || '(no user message)',
61
58
  lastRealUserMessage,
62
- createdAtMs,
63
59
  updatedAtMs,
64
60
  events: scan.events,
65
61
  usage,
@@ -79,7 +75,7 @@ export function readCodexRollout(filePath) {
79
75
  *
80
76
  * Returns null if the file doesn't exist, is corrupted, or has no session_meta.
81
77
  */
82
- export function readCodexRolloutSummary(filePath) {
78
+ function readCodexRolloutSummary(filePath) {
83
79
  if (!fs.existsSync(filePath)) {
84
80
  return null;
85
81
  }
@@ -316,7 +312,7 @@ export function listCodexRollouts(opts = {}) {
316
312
  // Establish the global order by mtime desc, then slice the page.
317
313
  // Same-mtime ties use filePath as a deterministic secondary key so index
318
314
  // rebuilds / walk order changes never reorder the page.
319
- matched.sort((a, b) => b.mtimeMs - a.mtimeMs || a.filePath.localeCompare(b.filePath));
315
+ sortByRecencyDesc(matched, (e) => e.mtimeMs, (e) => e.filePath);
320
316
  const { items: page, total } = paginate(matched, { limit: opts.limit, offset: opts.offset });
321
317
  // Full-parse only the page being returned (summary/usage).
322
318
  const entries = [];
@@ -7,8 +7,6 @@
7
7
  * drifted on the default limit). Centralized here so the contract is defined
8
8
  * once: default page limit is 20, matching the majority of readers.
9
9
  */
10
- /** Default page size for listSessions. */
11
- export declare const DEFAULT_LIST_LIMIT = 20;
12
10
  /**
13
11
  * Paginate a (already-ordered) full item list by offset + limit.
14
12
  * Clamps negative offset to 0; caps limit to the remaining items.
@@ -8,7 +8,7 @@
8
8
  * once: default page limit is 20, matching the majority of readers.
9
9
  */
10
10
  /** Default page size for listSessions. */
11
- export const DEFAULT_LIST_LIMIT = 20;
11
+ const DEFAULT_LIST_LIMIT = 20;
12
12
  /**
13
13
  * Paginate a (already-ordered) full item list by offset + limit.
14
14
  * Clamps negative offset to 0; caps limit to the remaining items.
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Session-list ordering invariant shared by every reader: newest first,
3
+ * with a deterministic id tie-break so index rebuilds / walk-order changes
4
+ * never reorder a page (codex red line: never break out of a partial order).
5
+ */
6
+ /** 「时间降序 + id 字典序 tie-break」原地排序(原先 6 处内联拷贝)。 */
7
+ export declare function sortByRecencyDesc<T>(items: T[], time: (item: T) => number, id: (item: T) => string): void;
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Session-list ordering invariant shared by every reader: newest first,
3
+ * with a deterministic id tie-break so index rebuilds / walk-order changes
4
+ * never reorder a page (codex red line: never break out of a partial order).
5
+ */
6
+ /** 「时间降序 + id 字典序 tie-break」原地排序(原先 6 处内联拷贝)。 */
7
+ export function sortByRecencyDesc(items, time, id) {
8
+ items.sort((a, b) => time(b) - time(a) || id(a).localeCompare(id(b)));
9
+ }
10
+ //# sourceMappingURL=recency.js.map
@@ -55,4 +55,18 @@ export declare class UsageAccumulator {
55
55
  */
56
56
  bumpCompact(): void;
57
57
  }
58
+ /** P2-8 统一契约:上下文窗口占用 = 末轮 input + cacheRead + cacheCreation(不含 output/reasoning)。 */
59
+ export declare function contextWindowOccupancy(l: {
60
+ input: number;
61
+ cacheRead: number;
62
+ cacheCreation: number;
63
+ }): number;
64
+ /** P2-8 统一契约:session 全量累计字段组装(4 个 reader 共用:claude/dsh/kimi/pi;
65
+ * codex 走 last_token_usage 口径、opencode 保留自己的条件赋值,原为逐字段复制的 4 连拷贝)。 */
66
+ export declare function cumulativeUsageFields(t: {
67
+ input: number;
68
+ output: number;
69
+ cacheRead: number;
70
+ cacheCreation: number;
71
+ }): Pick<import('../../runner/types.js').AgentSessionUsage, 'cumulativeTotalTokens' | 'cumulativeInputTokens' | 'cumulativeOutputTokens' | 'cumulativeCacheReadTokens' | 'cumulativeCacheCreationTokens'>;
58
72
  export {};
@@ -69,4 +69,19 @@ export class UsageAccumulator {
69
69
  this._compactCount++;
70
70
  }
71
71
  }
72
+ /** P2-8 统一契约:上下文窗口占用 = 末轮 input + cacheRead + cacheCreation(不含 output/reasoning)。 */
73
+ export function contextWindowOccupancy(l) {
74
+ return l.input + l.cacheRead + l.cacheCreation;
75
+ }
76
+ /** P2-8 统一契约:session 全量累计字段组装(4 个 reader 共用:claude/dsh/kimi/pi;
77
+ * codex 走 last_token_usage 口径、opencode 保留自己的条件赋值,原为逐字段复制的 4 连拷贝)。 */
78
+ export function cumulativeUsageFields(t) {
79
+ return {
80
+ cumulativeTotalTokens: t.input + t.output + t.cacheRead + t.cacheCreation,
81
+ cumulativeInputTokens: t.input,
82
+ cumulativeOutputTokens: t.output,
83
+ cumulativeCacheReadTokens: t.cacheRead,
84
+ cumulativeCacheCreationTokens: t.cacheCreation,
85
+ };
86
+ }
72
87
  //# sourceMappingURL=usage-accumulator.js.map
@@ -13,6 +13,8 @@
13
13
  * session-wide usage accumulation (assistant/message.usage).
14
14
  */
15
15
  import { spawnSync } from 'node:child_process';
16
+ import { cumulativeUsageFields } from '../common/usage-accumulator.js';
17
+ import { sortByRecencyDesc } from '../common/recency.js';
16
18
  import { getLogger } from '../../logger/index.js';
17
19
  import { DEFAULT_DSH_HOST } from '../../config/index.js';
18
20
  import { capEvents, paginate } from '../common/pagination.js';
@@ -81,13 +83,13 @@ export class DshSessionReader {
81
83
  const items = value.items ?? [];
82
84
  const filtered = items
83
85
  .filter((i) => i.cwd === cwd)
84
- .sort((a, b) => b.updatedAt - a.updatedAt || String(a.sessionId).localeCompare(String(b.sessionId)))
85
86
  .map((i) => ({
86
87
  sessionId: String(i.sessionId),
87
88
  summary: '',
88
89
  mtime: i.updatedAt,
89
90
  ...(i.agentPreset ? { agentPreset: i.agentPreset } : {}),
90
91
  }));
92
+ sortByRecencyDesc(filtered, (i) => i.mtime, (i) => i.sessionId);
91
93
  const { items: page, total } = paginate(filtered, opts ?? {});
92
94
  return { sessions: page, total };
93
95
  }
@@ -147,11 +149,12 @@ export class DshSessionReader {
147
149
  cacheReadTokens: cacheRead,
148
150
  cacheCreationTokens: cacheWrite,
149
151
  totalTokens: input + output + cacheRead + cacheWrite,
150
- cumulativeTotalTokens: cumInput + cumOutput + cumCacheRead + cumCacheWrite,
151
- cumulativeInputTokens: cumInput,
152
- cumulativeOutputTokens: cumOutput,
153
- cumulativeCacheReadTokens: cumCacheRead,
154
- cumulativeCacheCreationTokens: cumCacheWrite,
152
+ ...cumulativeUsageFields({
153
+ input: cumInput,
154
+ output: cumOutput,
155
+ cacheRead: cumCacheRead,
156
+ cacheCreation: cumCacheWrite,
157
+ }),
155
158
  };
156
159
  }
157
160
  return {
@@ -19,7 +19,9 @@ export interface KimiSessionState {
19
19
  * `messagesCompacted` 是死字段,真实 record 里不存在——禁止再写。
20
20
  */
21
21
  export interface KimiCompactionRecord {
22
- type: 'full_compaction.complete' | 'context.apply_compaction';
22
+ type: 'full_compaction.begin' | 'full_compaction.complete' | 'full_compaction.cancel' | 'context.apply_compaction';
23
+ /** 压缩来源(仅 full_compaction.begin 携带):'manual' | 'auto'。 */
24
+ source?: string;
23
25
  /** 压缩掉的消息条数(仅 context.apply_compaction 携带)。 */
24
26
  compactedCount?: number;
25
27
  /** 压缩前上下文 token 水位(仅 context.apply_compaction 携带)。 */
@@ -96,13 +98,25 @@ export declare class KimiSessionReader implements AgentSessionReader {
96
98
  maxEvents?: number;
97
99
  }): SessionContent;
98
100
  /**
99
- * Read the compaction records (context.apply_compaction /
100
- * full_compaction.complete) from a session's wire.jsonl.
101
+ * Read the compaction records (full_compaction.begin / complete / cancel /
102
+ * context.apply_compaction) from a session's wire.jsonl.
101
103
  *
102
104
  * Same cwd guard as readSessionContent. Used by KimiAcpRunner.runCompact
103
- * (R2) to wait for the background compaction to land before ending the run.
105
+ * to wait for the background compaction terminal before ending the run, and
106
+ * by readCompactionState to detect an in-flight compaction.
104
107
  */
105
108
  readCompactionRecords(sessionId: string, cwd: string): KimiCompactionRecord[];
109
+ /**
110
+ * 由 begin/terminal 记录重建压缩状态机:最后一次 begin 无更新的 terminal
111
+ * 记录 = 在途(kimi-compact-wait-redesign §5.1)。崩溃墓碑由 kimi resume
112
+ * 时补写 full_compaction.cancel,天然闭合。
113
+ */
114
+ readCompactionState(sessionId: string, cwd: string): {
115
+ inFlight: boolean;
116
+ /** 在途 begin 的 time(epoch ms)。 */
117
+ inFlightSince?: number;
118
+ records: KimiCompactionRecord[];
119
+ };
106
120
  /**
107
121
  * Resolve the main wire.jsonl path for a session after passing the cwd
108
122
  * guard (state.json + session index fallback). Returns null when the
@@ -2,10 +2,12 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import os from 'node:os';
4
4
  import { readJsonlLines as readJsonlLinesShared, readLastNJsonlLines } from '../common/jsonl.js';
5
- import { UsageAccumulator } from '../common/usage-accumulator.js';
5
+ import { UsageAccumulator, cumulativeUsageFields } from '../common/usage-accumulator.js';
6
6
  import { getLogger } from '../../logger/index.js';
7
7
  import { STALE_MS } from '../common/constants.js';
8
8
  import { capEvents, paginate } from '../common/pagination.js';
9
+ import { sortByRecencyDesc } from '../common/recency.js';
10
+ import { TtlCache } from '../../common/ttl-cache.js';
9
11
  import { truncateUtf8, TOOL_RESULT_MAX_BYTES } from '../../common/truncate.js';
10
12
  import { truncateToolInput } from '../common/content-blocks.js';
11
13
  /** Default Kimi config directory */
@@ -25,6 +27,16 @@ function isTurnPrompt(entry) {
25
27
  function isCompactionComplete(entry) {
26
28
  return entry.type === 'full_compaction.complete' || entry.type === 'context.apply_compaction';
27
29
  }
30
+ /** full_compaction.begin:压缩开始(手动 /compact 或自动触发都写)。 */
31
+ function isCompactionBegin(entry) {
32
+ return entry.type === 'full_compaction.begin';
33
+ }
34
+ /** 压缩终态:完成(complete/apply)或失败/取消(cancel)共用 cancel 记录。 */
35
+ function isCompactionTerminal(entry) {
36
+ return (entry.type === 'full_compaction.complete' ||
37
+ entry.type === 'context.apply_compaction' ||
38
+ entry.type === 'full_compaction.cancel');
39
+ }
28
40
  /**
29
41
  * Max lines scanned backwards when looking for the last loop event.
30
42
  * Bounds the tail scan so huge wire files are not fully parsed.
@@ -55,10 +67,7 @@ function lastLoopEventIsStepEnd(lines) {
55
67
  return false;
56
68
  }
57
69
  /** Cache for JSONL file contents to avoid repeated full reads */
58
- const jsonlCache = new Map();
59
- const CACHE_TTL_MS = 5000; // 5 second cache TTL
60
- /** Upper bound on cached files; evict the least-recently-used entry past this. */
61
- const CACHE_MAX_ENTRIES = 32;
70
+ const jsonlCache = new TtlCache(5_000, 32);
62
71
  /**
63
72
  * Read lines from a JSONL file with caching.
64
73
  * Delegates to the shared readJsonlLines for the actual file read,
@@ -68,25 +77,14 @@ function readJsonlLines(filePath) {
68
77
  try {
69
78
  const stat = fs.statSync(filePath);
70
79
  const mtime = stat.mtimeMs;
80
+ // P1-17: TTL 由 TtlCache 按缓存写入时间判定(不是文件 mtime),命中即 LRU 刷新;
81
+ // mtime 变化视为失效(文件被改写后不得返回旧行)。
71
82
  const cached = jsonlCache.get(filePath);
72
- // P1-17: TTL must be measured from the cache-write time (cachedAt), not
73
- // the FILE mtime — a file modified an hour ago would otherwise never hit
74
- // the cache, making every call re-read and re-write the same entry.
75
- if (cached && mtime === cached.mtime && Date.now() - cached.cachedAt < CACHE_TTL_MS) {
76
- // LRU refresh on hit: re-insert so recently-used entries survive eviction.
77
- jsonlCache.delete(filePath);
78
- jsonlCache.set(filePath, cached);
83
+ if (cached && mtime === cached.mtime) {
79
84
  return cached.lines;
80
85
  }
81
86
  const lines = readJsonlLinesShared(filePath);
82
- jsonlCache.set(filePath, { mtime, cachedAt: Date.now(), lines });
83
- // P1-17: bound the cache. Map iteration is insertion order; after the LRU
84
- // refresh above the least-recently-used entry is the first key.
85
- if (jsonlCache.size > CACHE_MAX_ENTRIES) {
86
- const oldest = jsonlCache.keys().next().value;
87
- if (oldest !== undefined)
88
- jsonlCache.delete(oldest);
89
- }
87
+ jsonlCache.set(filePath, { mtime, lines });
90
88
  return lines;
91
89
  }
92
90
  catch {
@@ -271,7 +269,7 @@ export class KimiSessionReader {
271
269
  }
272
270
  // Sort by mtime descending; same-mtime ties use a deterministic
273
271
  // secondary key so ordering is stable across scans.
274
- sessions.sort((a, b) => b.mtime - a.mtime || a.sessionId.localeCompare(b.sessionId));
272
+ sortByRecencyDesc(sessions, (s) => s.mtime, (s) => s.sessionId);
275
273
  const { items, total } = paginate(sessions, opts ?? {});
276
274
  return {
277
275
  sessions: items,
@@ -469,11 +467,7 @@ export class KimiSessionReader {
469
467
  totalTokens: t.input + t.output + t.cacheRead + t.cacheCreation,
470
468
  // Cumulative (session-wide): kimi sums all usage.records; mirrors
471
469
  // inputTokens/outputTokens which are already session totals.
472
- cumulativeTotalTokens: t.input + t.output + t.cacheRead + t.cacheCreation,
473
- cumulativeInputTokens: t.input,
474
- cumulativeOutputTokens: t.output,
475
- cumulativeCacheReadTokens: t.cacheRead > 0 ? t.cacheRead : 0,
476
- cumulativeCacheCreationTokens: t.cacheCreation > 0 ? t.cacheCreation : 0,
470
+ ...cumulativeUsageFields(t),
477
471
  // §6.3: compaction stats from wire.jsonl
478
472
  compactCount,
479
473
  ...(compactPreContextLength !== undefined ? { compactPreContextLength } : {}),
@@ -490,11 +484,12 @@ export class KimiSessionReader {
490
484
  }
491
485
  }
492
486
  /**
493
- * Read the compaction records (context.apply_compaction /
494
- * full_compaction.complete) from a session's wire.jsonl.
487
+ * Read the compaction records (full_compaction.begin / complete / cancel /
488
+ * context.apply_compaction) from a session's wire.jsonl.
495
489
  *
496
490
  * Same cwd guard as readSessionContent. Used by KimiAcpRunner.runCompact
497
- * (R2) to wait for the background compaction to land before ending the run.
491
+ * to wait for the background compaction terminal before ending the run, and
492
+ * by readCompactionState to detect an in-flight compaction.
498
493
  */
499
494
  readCompactionRecords(sessionId, cwd) {
500
495
  const wirePath = this.resolveSessionWirePath(sessionId, cwd);
@@ -506,9 +501,10 @@ export class KimiSessionReader {
506
501
  for (const line of readJsonlLines(wirePath)) {
507
502
  try {
508
503
  const entry = JSON.parse(line);
509
- if (isCompactionComplete(entry)) {
504
+ if (isCompactionBegin(entry) || isCompactionTerminal(entry)) {
510
505
  records.push({
511
506
  type: entry.type,
507
+ ...(entry.source !== undefined ? { source: entry.source } : {}),
512
508
  ...(entry.compactedCount !== undefined
513
509
  ? { compactedCount: entry.compactedCount }
514
510
  : {}),
@@ -529,6 +525,29 @@ export class KimiSessionReader {
529
525
  }
530
526
  return records;
531
527
  }
528
+ /**
529
+ * 由 begin/terminal 记录重建压缩状态机:最后一次 begin 无更新的 terminal
530
+ * 记录 = 在途(kimi-compact-wait-redesign §5.1)。崩溃墓碑由 kimi resume
531
+ * 时补写 full_compaction.cancel,天然闭合。
532
+ */
533
+ readCompactionState(sessionId, cwd) {
534
+ const records = [...this.readCompactionRecords(sessionId, cwd)].sort((a, b) => a.time - b.time);
535
+ let pendingBeginAt;
536
+ for (const record of records) {
537
+ if (isCompactionBegin(record)) {
538
+ pendingBeginAt = record.time;
539
+ }
540
+ else {
541
+ // 终态(complete/apply/cancel)闭合当前在途压缩。
542
+ pendingBeginAt = undefined;
543
+ }
544
+ }
545
+ return {
546
+ inFlight: pendingBeginAt !== undefined,
547
+ ...(pendingBeginAt !== undefined ? { inFlightSince: pendingBeginAt } : {}),
548
+ records,
549
+ };
550
+ }
532
551
  /**
533
552
  * Resolve the main wire.jsonl path for a session after passing the cwd
534
553
  * guard (state.json + session index fallback). Returns null when the
@@ -24,25 +24,13 @@ interface OpencodeSessionReaderOptions {
24
24
  */
25
25
  captureExport?: (sessionId: string) => string;
26
26
  }
27
- interface OpencodeSessionListEntry {
28
- id: string;
29
- title: string;
30
- updated: number;
31
- created: number;
32
- projectId: string;
33
- directory: string;
34
- }
35
27
  export declare class OpencodeSessionReader implements AgentSessionReader {
36
28
  private readonly binary;
37
29
  private readonly cacheTtlMs;
38
30
  /** Optional raw-export capture override (testing / alt transport). */
39
31
  private readonly captureExportOverride?;
40
- /** Cache keyed by cwd - each cwd has its own cache entry */
41
- /** cwd → 列表缓存(public:测试直接断言缓存形状,替代 as unknown as)。 */
42
- listCache: Map<string, {
43
- ts: number;
44
- data: OpencodeSessionListEntry[];
45
- }>;
32
+ /** cwd → 列表缓存(TTL 由 cacheTtlMs 决定)。 */
33
+ private listCache;
46
34
  constructor(opts?: OpencodeSessionReaderOptions);
47
35
  /**
48
36
  * List sessions in a given cwd, using directory field for matching.