dsh-caduceus 0.7.0 → 0.10.5

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/dist/index.js CHANGED
@@ -16,9 +16,11 @@ import { dirname, join } from 'node:path';
16
16
  import { backfillSessionOwnership, scanSessionDirs } from './backfill.js';
17
17
  import { Bridge } from './bridge/bridge.js';
18
18
  import { runHarnessCommand } from './commands/harness.js';
19
+ import { cleanupExportCache, parseSessionEvents } from './export.js';
20
+ import { resolveLang } from './i18n.js';
19
21
  import { Config, accessControlWarning, resolveGatewayConfig, } from './config.js';
20
22
  import { FileLogger } from './file-logger.js';
21
- import { defaultLogPath } from './home.js';
23
+ import { defaultExportCacheDir, defaultLogPath } from './home.js';
22
24
  import { adaptLogger, teeLoggers } from './logger.js';
23
25
  import { formatStartupLine } from './observability.js';
24
26
  import { autoSyncPersona } from './persona/auto-sync.js';
@@ -140,6 +142,9 @@ export function apply(ctx, config = {}) {
140
142
  busyMode: resolved.bridge.busyMode,
141
143
  }));
142
144
  logger.info(`agent preset=${resolved.agentPreset}`);
145
+ if (resolved.approvalPolicy !== undefined) {
146
+ logger.info(`approval policy override=${resolved.approvalPolicy}`);
147
+ }
143
148
  const accessWarning = accessControlWarning(resolved.bridge.admins ?? []);
144
149
  if (accessWarning !== undefined)
145
150
  logger.warn(accessWarning);
@@ -177,6 +182,10 @@ export function apply(ctx, config = {}) {
177
182
  },
178
183
  logger,
179
184
  sendCard: (sessionId, card) => bridge.sendInteractiveCard(sessionId, card),
185
+ permissionSwitch: (action, name) => {
186
+ void bridge.handlePermissionCardAction(action, name);
187
+ },
188
+ turnStop: (action) => bridge.handleStopCardAction(action),
180
189
  updateCard: (messageId, card) => transport.updateCard(messageId, card),
181
190
  });
182
191
  transport.onCardAction?.((action) => {
@@ -197,6 +206,31 @@ export function apply(ctx, config = {}) {
197
206
  rootCtx: ctx,
198
207
  approvalProvider: () => ctx.get('approval'),
199
208
  attachmentProvider: () => ctx.get('attachments'),
209
+ permissionPresetsProvider: () => ctx.get('permissionPresets'),
210
+ usageProvider: async (sessionId) => {
211
+ const query = ctx.get('sessionQuery');
212
+ if (query?.readSession === undefined)
213
+ return undefined;
214
+ const snapshot = await query.readSession(sessionId);
215
+ return snapshot.events ?? [];
216
+ },
217
+ languageProvider: () => resolveLang(resolved.lang, () => ctx.get('settings')),
218
+ exportProvider: async (sessionId) => {
219
+ const query = ctx.get('sessionQuery');
220
+ if (query?.readSession === undefined)
221
+ return undefined;
222
+ try {
223
+ const snapshot = await query.readSession(sessionId);
224
+ const events = snapshot.events ?? [];
225
+ return {
226
+ messages: parseSessionEvents(events),
227
+ rawJsonl: events.map((event) => JSON.stringify(event)).join('\n') + '\n',
228
+ };
229
+ }
230
+ catch {
231
+ return undefined;
232
+ }
233
+ },
200
234
  approvalPolicy: resolved.approvalPolicy,
201
235
  sessionPresetLookup: (sessionId) => ctx.get('sessions')?.get(sessionId)?.header
202
236
  ?.agentPreset,
@@ -262,6 +296,19 @@ export function apply(ctx, config = {}) {
262
296
  logger.error(`inbound message failed: ${errorText(error)}`);
263
297
  });
264
298
  });
299
+ const exportCacheDir = defaultExportCacheDir();
300
+ try {
301
+ mkdirSync(exportCacheDir, { recursive: true });
302
+ }
303
+ catch (error) {
304
+ logger.warn(`export cache dir unavailable (${exportCacheDir}): ${errorText(error)}`);
305
+ }
306
+ try {
307
+ cleanupExportCache(exportCacheDir, resolved.exportCacheMaxAgeHours, Date.now(), logger);
308
+ }
309
+ catch (error) {
310
+ logger.warn(`export cache cleanup failed: ${errorText(error)}`);
311
+ }
265
312
  ctx.on('session/event', (session, event) => {
266
313
  const sessionId = String(session.id);
267
314
  if (!bridge.ownsSession(sessionId))
@@ -39,7 +39,7 @@ export function createApprovalAnswerer(host, contextFor) {
39
39
  meta: { toolName: request.toolName, message: request.reason },
40
40
  });
41
41
  host.logger.info(`interactive: approval request id=${entry.id} questions=0 scope=${context.scopeKey}`);
42
- const card = approvalCard(entry.id, context.sessionId, request.toolName, request.reason);
42
+ const card = approvalCard(entry.id, context.sessionId, request.toolName, request.reason, context.permissionLine);
43
43
  let timer;
44
44
  const onAbort = () => {
45
45
  if (host.pending.delete(entry.id) === undefined)
@@ -2,13 +2,14 @@ import type { FeishuCard, FeishuCardElement } from '../transport/types.js';
2
2
  export interface CardButton {
3
3
  readonly label: string;
4
4
  readonly type?: 'primary' | 'danger' | 'default';
5
+ readonly disabled?: boolean;
5
6
  readonly value: Record<string, unknown>;
6
7
  }
7
8
  /** A card with optional buttons. */
8
9
  export declare function interactiveCard(title: string, lines: readonly string[], buttons?: readonly CardButton[], template?: string): FeishuCard;
9
10
  /** Card from pre-built elements (forms, inputs, action rows). */
10
11
  export declare function interactiveElementsCard(title: string, elements: readonly FeishuCardElement[], template?: string): FeishuCard;
11
- export declare function approvalCard(requestId: string, sessionId: string, toolName: string, reason: string | undefined): FeishuCard;
12
+ export declare function approvalCard(requestId: string, sessionId: string, toolName: string, reason: string | undefined, permissionLine?: string): FeishuCard;
12
13
  export declare function approvalTerminalCard(toolName: string, outcome: string): FeishuCard;
13
14
  export interface QuestionCardInput {
14
15
  readonly requestId: string;
@@ -14,6 +14,7 @@ function actionRow(buttons) {
14
14
  tag: 'button',
15
15
  text: { tag: 'plain_text', content: button.label },
16
16
  type: button.type ?? 'default',
17
+ ...(button.disabled === true ? { disabled: true } : {}),
17
18
  value: button.value,
18
19
  })),
19
20
  };
@@ -29,8 +30,9 @@ export function interactiveCard(title, lines, buttons = [], template = 'orange')
29
30
  export function interactiveElementsCard(title, elements, template = 'orange') {
30
31
  return buildCard({ title, template, elements });
31
32
  }
32
- export function approvalCard(requestId, sessionId, toolName, reason) {
33
+ export function approvalCard(requestId, sessionId, toolName, reason, permissionLine) {
33
34
  return interactiveCard(`审批请求 · ${toolName}`, [
35
+ ...(permissionLine === undefined ? [] : [permissionLine]),
34
36
  reason === undefined || reason.trim().length === 0 ? '(无说明)' : reason,
35
37
  ], [
36
38
  {
@@ -18,6 +18,10 @@ export interface InteractiveRuntimeOptions {
18
18
  readonly threadId?: string;
19
19
  }>;
20
20
  updateCard(messageId: string, card: FeishuCard): Promise<void>;
21
+ /** Button callback for the permission card (`caduceus.permission.switch`). */
22
+ permissionSwitch?(action: CardAction, name: string): void | Promise<void>;
23
+ /** Button callback for the process-card stop button. */
24
+ turnStop?(action: CardAction): void | Promise<void>;
21
25
  }
22
26
  export declare class InteractiveRuntime {
23
27
  #private;
@@ -12,12 +12,16 @@ export class InteractiveRuntime {
12
12
  pending = new PendingRegistry();
13
13
  #sendCard;
14
14
  #updateCard;
15
+ #permissionSwitch;
16
+ #turnStop;
15
17
  constructor(options) {
16
18
  this.config = options.config;
17
19
  this.logger = options.logger ?? silentLogger();
18
20
  this.clock = options.clock ?? systemClock;
19
21
  this.#sendCard = options.sendCard;
20
22
  this.#updateCard = options.updateCard;
23
+ this.#permissionSwitch = options.permissionSwitch;
24
+ this.#turnStop = options.turnStop;
21
25
  }
22
26
  /**
23
27
  * Register both waterfall answerers ONCE on the plugin root context, as dsh's
@@ -58,6 +62,17 @@ export class InteractiveRuntime {
58
62
  * a warning and do not resolve or patch anything.
59
63
  */
60
64
  async handleCardAction(action) {
65
+ if (action.value['action'] === 'caduceus.turn.stop') {
66
+ this.logger.info(`card action stop operator=${logPrefix(action.operatorOpenId)}`);
67
+ await this.#turnStop?.(action);
68
+ return;
69
+ }
70
+ if (action.value['action'] === 'caduceus.permission.switch' &&
71
+ typeof action.value['name'] === 'string') {
72
+ this.logger.info(`card action permission switch name=${action.value['name']} operator=${logPrefix(action.operatorOpenId)}`);
73
+ await this.#permissionSwitch?.(action, action.value['name']);
74
+ return;
75
+ }
61
76
  const id = typeof action.value['id'] === 'string' ? action.value['id'] : undefined;
62
77
  if (id === undefined) {
63
78
  this.logger.info('card action ignored (unknown id)');
@@ -26,6 +26,8 @@ export interface InteractiveContext {
26
26
  readonly chatId: string;
27
27
  readonly ownerOpenId: string;
28
28
  readonly sessionId: string;
29
+ /** Optional permission summary line for approval cards. */
30
+ readonly permissionLine?: string;
29
31
  }
30
32
  /** A Cordis context (the plugin root or an agent scope); only `on` is needed. */
31
33
  export interface EventScopeLike {
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Card/`/usage` metrics aggregated from durable session events:
3
+ * rounds, steps, tok/s, cumulative tokens, cache hit rate, context usage.
4
+ *
5
+ * These are deliberately derived from the event log (not the in-memory status
6
+ * tracker), so a restarted gateway can still show the same numbers.
7
+ *
8
+ * @module dsh-caduceus/metrics
9
+ */
10
+ export type MetricsMode = 'off' | 'compact' | 'detailed';
11
+ export interface Metrics {
12
+ readonly rounds: number;
13
+ readonly steps: number;
14
+ readonly tokensPerSecond?: number;
15
+ readonly totalTokens: number;
16
+ /** Raw sums (used when merging an in-flight turn with the full log). */
17
+ readonly inputTokens: number;
18
+ readonly outputTokens: number;
19
+ readonly cacheReadTokens: number;
20
+ readonly cacheHitPercent?: number;
21
+ readonly contextPercent?: number;
22
+ }
23
+ /** Aggregate metrics from one session event sequence. */
24
+ export declare function aggregateMetrics(events: readonly unknown[]): Metrics;
25
+ /** Compact number format: 999, 1.6k, 7.9M. */
26
+ export declare function formatTokenCount(value: number): string;
27
+ /** One-line metrics summary; empty when mode is off or nothing to show. */
28
+ export declare function formatMetricsLine(metrics: Metrics, mode: MetricsMode): string;
@@ -0,0 +1,135 @@
1
+ function asRecord(value) {
2
+ return typeof value === 'object' && value !== null
3
+ ? value
4
+ : undefined;
5
+ }
6
+ function num(record, key) {
7
+ const value = record?.[key];
8
+ return typeof value === 'number' ? value : 0;
9
+ }
10
+ function usageDelta(value) {
11
+ const record = asRecord(value);
12
+ if (record === undefined)
13
+ return undefined;
14
+ const input = num(record, 'inputTokens');
15
+ const output = num(record, 'outputTokens');
16
+ const cacheRead = num(record, 'cacheReadTokens');
17
+ const cacheWrite = num(record, 'cacheWriteTokens');
18
+ const providerTotal = typeof record['totalTokens'] === 'number' ? record['totalTokens'] : 0;
19
+ if (input === 0 && output === 0 && cacheRead === 0 && cacheWrite === 0 && providerTotal === 0) {
20
+ return undefined;
21
+ }
22
+ return {
23
+ input,
24
+ output,
25
+ cacheRead,
26
+ cacheWrite,
27
+ total: providerTotal > 0 ? providerTotal : input + output + cacheRead + cacheWrite,
28
+ };
29
+ }
30
+ /** Aggregate metrics from one session event sequence. */
31
+ export function aggregateMetrics(events) {
32
+ let rounds = 0;
33
+ let steps = 0;
34
+ let totalTokens = 0;
35
+ let cacheRead = 0;
36
+ let input = 0;
37
+ let outputTotal = 0;
38
+ const outputByTurn = new Map();
39
+ const promptByTurn = new Map();
40
+ const startByTurn = new Map();
41
+ const endByTurn = new Map();
42
+ let lastEventAt;
43
+ let contextWindow;
44
+ for (const event of events) {
45
+ const row = asRecord(event);
46
+ if (row === undefined)
47
+ continue;
48
+ const data = asRecord(row.data);
49
+ const time = typeof row.time === 'number' ? row.time : undefined;
50
+ if (time !== undefined)
51
+ lastEventAt = time;
52
+ const turn = typeof data?.['turn'] === 'number' ? data['turn'] : undefined;
53
+ if (turn !== undefined)
54
+ rounds = Math.max(rounds, turn);
55
+ if (row.type === 'turn/start' && turn !== undefined && time !== undefined) {
56
+ startByTurn.set(turn, time);
57
+ }
58
+ if (row.type === 'turn/end' && turn !== undefined && time !== undefined) {
59
+ endByTurn.set(turn, time);
60
+ }
61
+ if (row.type === 'step/start')
62
+ steps += 1;
63
+ if (row.type === 'request/context') {
64
+ const window = num(data, 'contextWindow');
65
+ if (window > 0)
66
+ contextWindow = window;
67
+ }
68
+ if (row.type !== 'assistant/message')
69
+ continue;
70
+ const delta = usageDelta(data?.usage);
71
+ if (delta === undefined)
72
+ continue;
73
+ totalTokens += delta.total;
74
+ cacheRead += delta.cacheRead;
75
+ input += delta.input;
76
+ outputTotal += delta.output;
77
+ if (turn !== undefined) {
78
+ outputByTurn.set(turn, (outputByTurn.get(turn) ?? 0) + delta.output);
79
+ promptByTurn.set(turn, (promptByTurn.get(turn) ?? 0) + delta.input + delta.cacheRead + delta.cacheWrite);
80
+ }
81
+ }
82
+ let tokensPerSecond;
83
+ const start = startByTurn.get(rounds);
84
+ const end = endByTurn.get(rounds) ?? lastEventAt;
85
+ const output = outputByTurn.get(rounds) ?? 0;
86
+ if (start !== undefined && end !== undefined && end - start >= 1000 && output > 0) {
87
+ tokensPerSecond = output / ((end - start) / 1000);
88
+ }
89
+ const promptTokens = promptByTurn.get(rounds - 1);
90
+ const contextPercent = contextWindow !== undefined && promptTokens !== undefined && contextWindow > 0
91
+ ? Math.round((promptTokens / contextWindow) * 100)
92
+ : undefined;
93
+ const denominator = cacheRead + input;
94
+ const cacheHitPercent = denominator > 0 ? Math.round((cacheRead / denominator) * 100) : undefined;
95
+ return {
96
+ rounds,
97
+ steps,
98
+ totalTokens,
99
+ inputTokens: input,
100
+ outputTokens: outputTotal,
101
+ cacheReadTokens: cacheRead,
102
+ ...(tokensPerSecond === undefined ? {} : { tokensPerSecond }),
103
+ ...(cacheHitPercent === undefined ? {} : { cacheHitPercent }),
104
+ ...(contextPercent === undefined ? {} : { contextPercent }),
105
+ };
106
+ }
107
+ /** Compact number format: 999, 1.6k, 7.9M. */
108
+ export function formatTokenCount(value) {
109
+ if (value >= 1_000_000)
110
+ return `${(value / 1_000_000).toFixed(1)}M`;
111
+ if (value >= 1_000)
112
+ return `${(value / 1_000).toFixed(1)}k`;
113
+ return String(Math.round(value));
114
+ }
115
+ /** One-line metrics summary; empty when mode is off or nothing to show. */
116
+ export function formatMetricsLine(metrics, mode) {
117
+ if (mode === 'off')
118
+ return '';
119
+ const parts = [];
120
+ if (metrics.rounds > 0)
121
+ parts.push(`${metrics.rounds} 轮`);
122
+ if (metrics.steps > 0)
123
+ parts.push(`${metrics.steps} 步`);
124
+ if (metrics.tokensPerSecond !== undefined) {
125
+ parts.push(`${Math.round(metrics.tokensPerSecond)} tok/s`);
126
+ }
127
+ if (metrics.totalTokens > 0)
128
+ parts.push(`${formatTokenCount(metrics.totalTokens)} tok`);
129
+ if (metrics.cacheHitPercent !== undefined)
130
+ parts.push(`缓存 ${metrics.cacheHitPercent}%`);
131
+ if (mode === 'detailed' && metrics.contextPercent !== undefined) {
132
+ parts.push(`上下文 ${metrics.contextPercent}%`);
133
+ }
134
+ return parts.join(' · ');
135
+ }
@@ -26,6 +26,10 @@ export declare function buildCard(input: CardInput): FeishuCard;
26
26
  export declare function tailText(text: string, maxBytes?: number): string;
27
27
  export interface ProgressCardInput {
28
28
  readonly title: string;
29
+ /** Extra status lines (compaction, teammate, stop requested). */
30
+ readonly extraLines?: readonly string[];
31
+ /** Show the stop button (running turns only). */
32
+ readonly stopButton?: boolean;
29
33
  readonly stateLabel: string;
30
34
  readonly steps: number;
31
35
  readonly tools: number;
@@ -64,9 +64,26 @@ export function progressCard(input) {
64
64
  div(stateLine.join(' · ')),
65
65
  div(`最近:${clipLabel(input.recentEvent ?? '(无)')}`),
66
66
  ];
67
+ for (const line of input.extraLines ?? []) {
68
+ if (line.trim().length > 0)
69
+ elements.push(div(line));
70
+ }
67
71
  const tail = input.tail === undefined ? '' : tailText(input.tail);
68
72
  if (tail.trim().length > 0)
69
73
  elements.push(div(tail));
74
+ if (input.stopButton === true) {
75
+ elements.push({
76
+ tag: 'action',
77
+ actions: [
78
+ {
79
+ tag: 'button',
80
+ text: { tag: 'plain_text', content: '停止' },
81
+ type: 'danger',
82
+ value: { action: 'caduceus.turn.stop' },
83
+ },
84
+ ],
85
+ });
86
+ }
70
87
  elements.push(div('_过程消息;收尾会把终稿更新到本卡。_'));
71
88
  return buildCard({ title: input.title, template: 'blue', elements });
72
89
  }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Permission UI rendered once as a Feishu card and once as plain text (the
3
+ * send-failure fallback). Both consume the same data and the same i18n dict.
4
+ *
5
+ * @module dsh-caduceus/render/permission-card
6
+ */
7
+ import type { FeishuCard } from '../transport/types.js';
8
+ import type { Lang } from '../i18n.js';
9
+ export interface PermissionPresetView {
10
+ readonly name: string;
11
+ readonly sandbox?: string;
12
+ readonly approval?: string;
13
+ /** False/absent when resolve failed: render the bare name only. */
14
+ readonly resolved?: boolean;
15
+ }
16
+ export interface PermissionView {
17
+ readonly lang: Lang;
18
+ readonly current: string;
19
+ readonly presets: readonly PermissionPresetView[];
20
+ }
21
+ export declare const PERMISSION_SWITCH_ACTION = "caduceus.permission.switch";
22
+ export declare function buildPermissionCard(view: PermissionView): FeishuCard;
23
+ export declare function renderPermissionText(view: PermissionView): string;
@@ -0,0 +1,22 @@
1
+ import { presetLabel, t } from '../i18n.js';
2
+ import { interactiveCard } from '../interactive/cards.js';
3
+ export const PERMISSION_SWITCH_ACTION = 'caduceus.permission.switch';
4
+ function title(view, dict) {
5
+ const label = view.current === 'custom' ? dict.customLabel : presetLabel(view.lang, view.current);
6
+ return dict.permissionTitle(label);
7
+ }
8
+ function buttonLabels(view) {
9
+ return view.presets.map((preset) => presetLabel(view.lang, preset.name));
10
+ }
11
+ export function buildPermissionCard(view) {
12
+ const dict = t(view.lang);
13
+ return interactiveCard(title(view, dict), [dict.usage], view.presets.map((preset) => ({
14
+ label: presetLabel(view.lang, preset.name),
15
+ disabled: preset.name === view.current,
16
+ value: { action: PERMISSION_SWITCH_ACTION, name: preset.name },
17
+ })));
18
+ }
19
+ export function renderPermissionText(view) {
20
+ const dict = t(view.lang);
21
+ return [title(view, dict), dict.usage, buttonLabels(view).join(' / ')].join('\n');
22
+ }
package/dist/status.d.ts CHANGED
@@ -21,6 +21,14 @@
21
21
  */
22
22
  /** Derived session states. */
23
23
  export type StatusState = 'idle' | 'waiting-input' | 'running' | 'completed' | 'failed' | 'waiting-approval' | 'interrupted';
24
+ /** Disjoint token totals for one or more model calls. */
25
+ export interface UsageTotals {
26
+ readonly input: number;
27
+ readonly output: number;
28
+ readonly cacheRead: number;
29
+ readonly cacheWrite: number;
30
+ readonly total: number;
31
+ }
24
32
  /** Immutable status snapshot. */
25
33
  export interface StatusSnapshot {
26
34
  readonly state: StatusState;
@@ -41,6 +49,20 @@ export interface StatusSnapshot {
41
49
  readonly terminalKind?: string;
42
50
  /** Provider failure text for `turn/end error`. */
43
51
  readonly errorMessage?: string;
52
+ /** Token usage for the current turn (`assistant/message.usage`). */
53
+ readonly usageTurn?: UsageTotals;
54
+ /** Usage of the previous turn. */
55
+ readonly usagePrevious?: UsageTotals;
56
+ /** Cumulative usage of the session. */
57
+ readonly usageTotal?: UsageTotals;
58
+ /** True once at least one usage-bearing assistant message was seen. */
59
+ readonly usageSeen?: boolean;
60
+ /** Context compaction lifecycle. */
61
+ readonly compaction?: 'running' | 'done' | 'failed';
62
+ /** Messages retained after compaction when the event reports it. */
63
+ readonly compactionKept?: number;
64
+ /** Latest teammate/task line (only one, later overwrites earlier). */
65
+ readonly teamLine?: string;
44
66
  }
45
67
  /** Structural subset of a dsh `SessionEvent` used by the reducer. */
46
68
  export interface StatusEventLike {
package/dist/status.js CHANGED
@@ -19,6 +19,42 @@
19
19
  *
20
20
  * @module dsh-caduceus/status
21
21
  */
22
+ const ZERO_USAGE = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 };
23
+ function usageFromEvent(data) {
24
+ const usage = data?.['usage'];
25
+ if (typeof usage !== 'object' || usage === null)
26
+ return undefined;
27
+ const record = usage;
28
+ const number = (key) => typeof record[key] === 'number' ? record[key] : 0;
29
+ const input = number('inputTokens');
30
+ const output = number('outputTokens');
31
+ const cacheRead = number('cacheReadTokens');
32
+ const cacheWrite = number('cacheWriteTokens');
33
+ if (input === 0 && output === 0 && cacheRead === 0 && cacheWrite === 0)
34
+ return undefined;
35
+ const total = typeof record['totalTokens'] === 'number'
36
+ ? record['totalTokens']
37
+ : input + output + cacheRead + cacheWrite;
38
+ return { input, output, cacheRead, cacheWrite, total };
39
+ }
40
+ function addUsage(base, delta) {
41
+ const left = base ?? ZERO_USAGE;
42
+ return {
43
+ input: left.input + delta.input,
44
+ output: left.output + delta.output,
45
+ cacheRead: left.cacheRead + delta.cacheRead,
46
+ cacheWrite: left.cacheWrite + delta.cacheWrite,
47
+ total: left.total + delta.total,
48
+ };
49
+ }
50
+ function keptFromEvent(data) {
51
+ for (const key of ['kept', 'retained', 'messages', 'messageCount']) {
52
+ const value = data?.[key];
53
+ if (typeof value === 'number')
54
+ return value;
55
+ }
56
+ return undefined;
57
+ }
22
58
  const INITIAL = { state: 'idle', steps: 0, tools: 0, turn: 0 };
23
59
  function asRecord(value) {
24
60
  return typeof value === 'object' && value !== null ? value : undefined;
@@ -39,11 +75,14 @@ export class StatusTracker {
39
75
  case 'turn/start': {
40
76
  const turn = typeof data?.['turn'] === 'number' ? data['turn'] : base.turn + 1;
41
77
  this.snapshot = {
78
+ ...base,
42
79
  state: 'running',
43
80
  steps: 0,
44
81
  tools: 0,
45
82
  turn,
46
83
  ...(time === undefined ? {} : { startedAt: time }),
84
+ ...(base.usageTurn === undefined ? {} : { usagePrevious: base.usageTurn }),
85
+ usageTurn: ZERO_USAGE,
47
86
  ...common,
48
87
  };
49
88
  return this.snapshot;
@@ -60,6 +99,46 @@ export class StatusTracker {
60
99
  this.snapshot = { ...base, tools: base.tools + 1, ...common };
61
100
  return this.snapshot;
62
101
  }
102
+ case 'assistant/message': {
103
+ const usage = usageFromEvent(data);
104
+ if (usage === undefined) {
105
+ this.snapshot = { ...base, ...common };
106
+ return this.snapshot;
107
+ }
108
+ this.snapshot = {
109
+ ...base,
110
+ usageSeen: true,
111
+ usageTurn: addUsage(base.usageTurn, usage),
112
+ usageTotal: addUsage(base.usageTotal, usage),
113
+ ...common,
114
+ };
115
+ return this.snapshot;
116
+ }
117
+ case 'compaction/start': {
118
+ this.snapshot = { ...base, compaction: 'running', ...common };
119
+ return this.snapshot;
120
+ }
121
+ case 'compaction/end': {
122
+ const failed = typeof data?.['error'] === 'string';
123
+ const kept = keptFromEvent(data);
124
+ this.snapshot = {
125
+ ...base,
126
+ compaction: failed ? 'failed' : 'done',
127
+ ...(kept === undefined ? {} : { compactionKept: kept }),
128
+ ...common,
129
+ };
130
+ return this.snapshot;
131
+ }
132
+ case 'team/member':
133
+ case 'team/task': {
134
+ const line = teamLineFromEvent(event.type, data);
135
+ this.snapshot = {
136
+ ...base,
137
+ ...(line === undefined ? {} : { teamLine: line }),
138
+ ...common,
139
+ };
140
+ return this.snapshot;
141
+ }
63
142
  case 'turn/end': {
64
143
  const turnData = (data ?? {});
65
144
  const kind = turnData.reason?.kind;
@@ -90,6 +169,22 @@ export class StatusTracker {
90
169
  this.snapshot = INITIAL;
91
170
  }
92
171
  }
172
+ function teamLineFromEvent(type, data) {
173
+ if (type === 'team/member') {
174
+ const member = asRecord(data?.['member']);
175
+ const name = typeof member?.['name'] === 'string' ? member['name'] : undefined;
176
+ const phase = typeof member?.['phase'] === 'string' ? member['phase'] : undefined;
177
+ if (name === undefined)
178
+ return undefined;
179
+ return `队友 ${name}:${phase ?? '更新'}`;
180
+ }
181
+ const task = asRecord(data?.['task']);
182
+ const subject = typeof task?.['subject'] === 'string' ? task['subject'] : undefined;
183
+ const status = typeof task?.['status'] === 'string' ? task['status'] : undefined;
184
+ if (subject === undefined)
185
+ return undefined;
186
+ return `队友 ${subject}:${status ?? '更新'}`;
187
+ }
93
188
  /** Map a `turn/end` reason kind to the derived state. */
94
189
  export function stateForTurnEnd(kind) {
95
190
  switch (kind) {
@@ -0,0 +1 @@
1
+ export declare function unwrapFeishuData<T extends object>(response: unknown, operation: string): T;
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Some Feishu SDK methods return a `{code,msg,data}` envelope, while the
3
+ * file/image upload endpoints return the bare `data` object. Normalize both.
4
+ *
5
+ * @module dsh-caduceus/transport/feishu-response
6
+ */
7
+ import { FeishuApiError } from './types.js';
8
+ export function unwrapFeishuData(response, operation) {
9
+ if (typeof response !== 'object' || response === null) {
10
+ throw new FeishuApiError(`feishu ${operation} returned no data`, -1);
11
+ }
12
+ const record = response;
13
+ if (typeof record.code === 'number') {
14
+ if (record.code !== 0) {
15
+ throw new FeishuApiError(`feishu ${operation} failed (code ${record.code}): ${typeof record.msg === 'string' ? record.msg : 'unknown error'}`, record.code);
16
+ }
17
+ const data = record.data;
18
+ if (typeof data !== 'object' || data === null) {
19
+ throw new FeishuApiError(`feishu ${operation} returned no data`, -1);
20
+ }
21
+ return data;
22
+ }
23
+ // Bare data shape (file/image upload endpoints in the installed SDK).
24
+ return record;
25
+ }
@@ -13,6 +13,7 @@ import { silentLogger } from '../logger.js';
13
13
  import { normalizeCardAction, normalizeMessageEvent, } from './normalize.js';
14
14
  import { formatGateLine } from '../gate.js';
15
15
  import { FeishuApiError } from './types.js';
16
+ import { unwrapFeishuData } from './feishu-response.js';
16
17
  /** Long-connection Feishu implementation of {@link FeishuApi}. */
17
18
  export class LarkFeishuApi {
18
19
  client;
@@ -174,8 +175,8 @@ export class LarkFeishuApi {
174
175
  file: Buffer.from(bytes),
175
176
  },
176
177
  }));
177
- this.assertOk(response, 'im.v1.file.create');
178
- const fileKey = response.data?.file_key;
178
+ const data = unwrapFeishuData(response, 'im.v1.file.create');
179
+ const fileKey = data.file_key;
179
180
  if (fileKey === undefined || fileKey.length === 0) {
180
181
  throw new FeishuApiError('feishu im.v1.file.create returned no file_key', -1);
181
182
  }
@@ -188,8 +189,8 @@ export class LarkFeishuApi {
188
189
  image: Buffer.from(bytes),
189
190
  },
190
191
  }));
191
- this.assertOk(response, 'im.v1.image.create');
192
- const imageKey = response.data?.image_key;
192
+ const data = unwrapFeishuData(response, 'im.v1.image.create');
193
+ const imageKey = data.image_key;
193
194
  if (imageKey === undefined || imageKey.length === 0) {
194
195
  throw new FeishuApiError('feishu im.v1.image.create returned no image_key', -1);
195
196
  }