dsh-caduceus 0.12.1 → 0.12.3

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.
@@ -35,4 +35,4 @@ export declare class Bridge {
35
35
  }>;
36
36
  }
37
37
  /** Build the process card for one snapshot (exported for render tests). */
38
- export declare function progressCardForSnapshot(snapshot: StatusSnapshot, sessionTitle: string | undefined, recentEvent: string, tail: string, now: number, presetMissing?: boolean, extraLines?: readonly string[], stopButton?: boolean): FeishuCard;
38
+ export declare function progressCardForSnapshot(snapshot: StatusSnapshot, sessionTitle: string | undefined, recentEvent: string, tail: string, now: number, presetMissing?: boolean, extraLines?: readonly string[], stopButton?: boolean, stepLines?: readonly string[], stepNote?: string): FeishuCard;
@@ -29,11 +29,12 @@ import { writeOwnership } from '../ownership.js';
29
29
  import { defaultExportCacheDir } from '../home.js';
30
30
  import { extractMediaMarkers, resolveMedia } from '../media.js';
31
31
  import { aggregateUsage, renderUsageReport } from '../usage.js';
32
- import { aggregateMetrics, formatMetricsLine } from '../metrics.js';
32
+ import { aggregateMetrics, formatMetricsLine, formatPerformanceLines } from '../metrics.js';
33
+ import { DEFAULT_CARD_POLICY, foldsAtTurnEnd, formatStepLines, showsStepElapsed, stepLimit, stepLinesFromEvents, } from '../card-policy.js';
33
34
  import { presetLabel, t } from '../i18n.js';
34
35
  import { buildPermissionCard, renderPermissionText, } from '../render/permission-card.js';
35
36
  import { readSessionLogFile, renderExportJsonl, renderExportMarkdown, truncateExport, writeExportFile, } from '../export.js';
36
- import { finalCard, finalCardPlainText, progressCard, } from '../render/cards.js';
37
+ import { clipLabel, collapsedProcessCard, finalCard, finalCardPlainText, progressCard, } from '../render/cards.js';
37
38
  import { CardStream, systemClock } from '../render/stream.js';
38
39
  import { mintSessionId, scopeKey, SCOPE_KEY_SEPARATOR } from '../session-key.js';
39
40
  import { StatusTracker, shouldNotifyTerminal, stateForTurnEnd, recentEventText, statusLabel, statusTemplate, statusText, terminalNotice, } from '../status.js';
@@ -230,6 +231,7 @@ export class Bridge {
230
231
  state.running = true;
231
232
  state.stopRequested = false;
232
233
  state.lastOutput = '';
234
+ state.replies = [];
233
235
  state.progress?.dispose();
234
236
  state.progress = this.#makeCardStream(state);
235
237
  await this.#renderProgress(state);
@@ -262,8 +264,15 @@ export class Bridge {
262
264
  }
263
265
  case 'assistant/message': {
264
266
  const text = assistantTextFromEvent(event.data);
265
- if (text.length > 0)
267
+ if (text.length > 0) {
266
268
  state.lastOutput = text;
269
+ const trimmed = text.trim();
270
+ if (trimmed.length > 0 && state.replies[state.replies.length - 1] !== trimmed) {
271
+ state.replies.push(trimmed);
272
+ if (state.replies.length > 3)
273
+ state.replies.shift();
274
+ }
275
+ }
267
276
  await this.#renderProgress(state);
268
277
  break;
269
278
  }
@@ -295,6 +304,16 @@ export class Bridge {
295
304
  }
296
305
  async #processInbound(message) {
297
306
  const state = this.#stateFor(message);
307
+ const pending = state.pendingCollapse;
308
+ if (pending !== undefined) {
309
+ state.pendingCollapse = undefined;
310
+ try {
311
+ await this.#transport.updateCard(pending.messageId, pending.card);
312
+ }
313
+ catch (error) {
314
+ this.#logger.warn(`deferred collapse failed: ${errorMessage(error)}`);
315
+ }
316
+ }
298
317
  state.lastInboundMessageId = message.messageId;
299
318
  state.ownerOpenId = message.senderOpenId;
300
319
  // Startup-stage failures have no session to notify yet; the first message
@@ -860,21 +879,30 @@ export class Bridge {
860
879
  state.progress = undefined;
861
880
  }
862
881
  const card = finalCard(finalInput);
882
+ // 过程卡按 dsh 的「工作步骤收起时机」处理:completion 立刻置灰收起;
883
+ // next-input 留到下一次输入再收,正文始终另发一张摘要卡。
884
+ const policy = this.#policy();
863
885
  if (messageId !== undefined) {
864
- try {
865
- await this.#transport.updateCard(messageId, card);
886
+ const collapsed = this.#collapsedCard(state, snapshot, kind);
887
+ if (foldsAtTurnEnd(policy)) {
888
+ try {
889
+ if (process.env.CADUCEUS_DEBUG === '1')
890
+ console.error('[dbg] collapse', messageId);
891
+ await this.#transport.updateCard(messageId, collapsed);
892
+ }
893
+ catch (error) {
894
+ this.#logger.warn(`collapse patch failed: ${errorMessage(error)}`);
895
+ }
866
896
  }
867
- catch (error) {
868
- await this.#finalFallback(state, finalInput, error);
897
+ else {
898
+ state.pendingCollapse = { messageId, card: collapsed };
869
899
  }
870
900
  }
871
- else {
872
- try {
873
- await this.#sendNewCard(state, card);
874
- }
875
- catch (error) {
876
- await this.#finalFallback(state, finalInput, error);
877
- }
901
+ try {
902
+ await this.#sendNewCard(state, card);
903
+ }
904
+ catch (error) {
905
+ await this.#finalFallback(state, finalInput, error);
878
906
  }
879
907
  await this.#sendOutboundMedia(state, outbound.entries);
880
908
  if (shouldNotifyTerminal(kind)) {
@@ -944,7 +972,7 @@ export class Bridge {
944
972
  #finalCardInput(state, snapshot, body, finalState, finalStateLabel) {
945
973
  const sessionTitle = this.#titleOf(state)?.trim() ?? state.title;
946
974
  return {
947
- title: '飞书会话 · 过程',
975
+ title: '飞书会话 · 回答',
948
976
  // Event-derived, never the (possibly stale) tracker snapshot.
949
977
  stateLabel: finalStateLabel,
950
978
  steps: snapshot.steps,
@@ -1714,6 +1742,7 @@ export class Bridge {
1714
1742
  running: false,
1715
1743
  status: new StatusTracker(),
1716
1744
  lastOutput: '',
1745
+ replies: [],
1717
1746
  reactionQueue: [],
1718
1747
  reactionStarts: new Map(),
1719
1748
  logEvents: [],
@@ -1729,6 +1758,8 @@ export class Bridge {
1729
1758
  return sent;
1730
1759
  },
1731
1760
  update: async (messageId, card) => {
1761
+ if (process.env.CADUCEUS_DEBUG === '1')
1762
+ console.error('[dbg] stream.update', messageId, card.header?.title.content);
1732
1763
  await this.#transport.updateCard(messageId, card);
1733
1764
  },
1734
1765
  };
@@ -1854,16 +1885,56 @@ export class Bridge {
1854
1885
  extraLines.push('上下文压缩失败');
1855
1886
  if (snapshot.teamLine !== undefined)
1856
1887
  extraLines.push(snapshot.teamLine);
1857
- const metricsMode = this.#config.metrics ?? 'compact';
1858
- if (metricsMode !== 'off') {
1859
- const metricsLine = formatMetricsLine(await this.#metricsFor(state, snapshot), metricsMode);
1860
- if (metricsLine.length > 0)
1861
- extraLines.push(metricsLine);
1888
+ // 性能与用量:dsh 的 ui-chat.performanceUsage 决定密度;caduceus 的
1889
+ // `metrics: off` 仅作排障用的整体关闭开关。
1890
+ const policy = this.#policy();
1891
+ if (this.#config.metrics !== 'off') {
1892
+ const metrics = await this.#metricsFor(state, snapshot);
1893
+ for (const line of formatPerformanceLines(metrics, policy.performance)) {
1894
+ extraLines.push(line);
1895
+ }
1862
1896
  }
1897
+ const steps = this.#stepLines(state, snapshot, policy);
1863
1898
  if (state.stopRequested === true) {
1864
1899
  extraLines.push('已停止当前回合。后台任务仍在运行,完成后结果会回来。');
1865
1900
  }
1866
- return progressCardForSnapshot(snapshot, state.title, recentEventText(snapshot), state.lastOutput, this.#now(), state.sessionId !== undefined && this.#presetFailures.has(state.sessionId), extraLines, state.running && state.stopRequested !== true);
1901
+ return progressCardForSnapshot(snapshot, state.title, recentEventText(snapshot), state.lastOutput, this.#now(), state.sessionId !== undefined && this.#presetFailures.has(state.sessionId), extraLines, state.running && state.stopRequested !== true, steps.lines, steps.note);
1902
+ }
1903
+ /** Card policy from dsh's live settings; defaults only when unavailable. */
1904
+ #policy() {
1905
+ try {
1906
+ return this.#options.cardPolicyProvider?.() ?? DEFAULT_CARD_POLICY;
1907
+ }
1908
+ catch {
1909
+ return DEFAULT_CARD_POLICY;
1910
+ }
1911
+ }
1912
+ /** Step rows for the current turn, limited by the transcript level. */
1913
+ #stepLines(state, snapshot, policy) {
1914
+ const all = stepLinesFromEvents(state.logEvents, Number.POSITIVE_INFINITY, {
1915
+ turn: snapshot.turn,
1916
+ elapsed: showsStepElapsed(policy.transcript),
1917
+ });
1918
+ const limit = stepLimit(policy.transcript);
1919
+ const shown = Number.isFinite(limit) ? all.slice(Math.max(0, all.length - limit)) : all;
1920
+ const lines = formatStepLines(shown);
1921
+ const note = all.length > shown.length ? `共 ${all.length} 次调用,显示最近 ${shown.length} 条` : undefined;
1922
+ return { lines, ...(note === undefined ? {} : { note }) };
1923
+ }
1924
+ /** Greyed, collapsed record of the finished turn's process card. */
1925
+ #collapsedCard(state, snapshot, kind) {
1926
+ const policy = this.#policy();
1927
+ const steps = this.#stepLines(state, snapshot, policy);
1928
+ const card = progressCardForSnapshot(snapshot, state.title, recentEventText(snapshot), '', this.#now(), state.sessionId !== undefined && this.#presetFailures.has(state.sessionId), [], false, steps.lines, steps.note);
1929
+ const noteLines = [`已收起 · ${snapshot.steps} 步`];
1930
+ if (kind !== undefined && kind !== 'completed')
1931
+ noteLines[0] += ` · ${statusLabel(stateForTurnEnd(kind))}`;
1932
+ if (policy.foldedReplies === 'all' && state.replies.length > 0) {
1933
+ for (const [index, reply] of state.replies.slice(-3).entries()) {
1934
+ noteLines.push(`过程回复 ${index + 1}:${clipLabel(reply.replace(/\s+/g, ' '), 60)}`);
1935
+ }
1936
+ }
1937
+ return collapsedProcessCard(card, noteLines.join('\n'));
1867
1938
  }
1868
1939
  async #replyText(state, text) {
1869
1940
  if (text.length === 0)
@@ -1883,10 +1954,12 @@ export class Bridge {
1883
1954
  }
1884
1955
  }
1885
1956
  /** Build the process card for one snapshot (exported for render tests). */
1886
- export function progressCardForSnapshot(snapshot, sessionTitle, recentEvent, tail, now, presetMissing = false, extraLines = [], stopButton = false) {
1957
+ export function progressCardForSnapshot(snapshot, sessionTitle, recentEvent, tail, now, presetMissing = false, extraLines = [], stopButton = false, stepLines = [], stepNote) {
1887
1958
  return progressCard({
1888
1959
  title: '飞书会话 · 过程',
1889
1960
  ...(extraLines.length === 0 ? {} : { extraLines }),
1961
+ ...(stepLines.length === 0 ? {} : { stepLines }),
1962
+ ...(stepNote === undefined || stepNote.length === 0 ? {} : { stepNote }),
1890
1963
  ...(stopButton ? { stopButton: true } : {}),
1891
1964
  stateLabel: statusLabel(snapshot.state),
1892
1965
  steps: snapshot.steps,
@@ -171,6 +171,12 @@ export interface BridgeOptions {
171
171
  readonly permissionPresetsProvider?: () => PermissionPresetsLike | undefined;
172
172
  /** Session log reader for `/usage` (`sessionQuery.readSession().events`). */
173
173
  readonly usageProvider?: (sessionId: string) => Promise<readonly unknown[] | undefined> | readonly unknown[] | undefined;
174
+ /**
175
+ * Card presentation level, read live from dsh's own settings
176
+ * (`ui-chat` / `ui-settings-general`). Resolved lazily per render so a
177
+ * settings change applies without a restart.
178
+ */
179
+ readonly cardPolicyProvider?: () => import('../card-policy.js').CardPolicy;
174
180
  /** Locale source (dsh settings/env); default zh. */
175
181
  readonly languageProvider?: () => import('../i18n.js').Lang;
176
182
  /** Lazily resolve dsh's approval service (agent-scoped setPolicy). */
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Card presentation policy — mirrors dsh's own Chat/General settings so the
3
+ * Feishu card shows the same amount of process detail as the Web UI instead of
4
+ * inventing a second set of knobs.
5
+ *
6
+ * Read live from the `settings` service (`describe()`), which returns the
7
+ * active profile's entry values:
8
+ *
9
+ * - `ui-chat.transcriptView` 工作步骤展示
10
+ * - `ui-chat.collapseTiming` 工作步骤收起时机
11
+ * - `ui-chat.foldedReplies` 收起后显示的回复
12
+ * - `ui-chat.performanceUsage` 性能与用量
13
+ * - `ui-settings-general.developerTools` 显示代码工作视图
14
+ *
15
+ * @module dsh-caduceus/card-policy
16
+ */
17
+ /** Transcript detail levels accepted by dsh's Chat settings. */
18
+ export declare const TRANSCRIPT_MODES: readonly ["compact", "standard", "detailed", "verbose"];
19
+ export type TranscriptMode = (typeof TRANSCRIPT_MODES)[number];
20
+ /** When the completed turn's process folds away. */
21
+ export declare const COLLAPSE_TIMINGS: readonly ["completion", "next-input"];
22
+ export type CollapseTiming = (typeof COLLAPSE_TIMINGS)[number];
23
+ /** Which assistant replies stay visible once the process folds. */
24
+ export declare const FOLDED_REPLIES: readonly ["all", "final"];
25
+ export type FoldedReplies = (typeof FOLDED_REPLIES)[number];
26
+ /** Performance/usage line density. */
27
+ export declare const PERFORMANCE_MODES: readonly ["compact", "detailed"];
28
+ export type PerformanceMode = (typeof PERFORMANCE_MODES)[number];
29
+ /** Everything the card renderer needs to know about the user's detail level. */
30
+ export interface CardPolicy {
31
+ readonly transcript: TranscriptMode;
32
+ readonly collapseTiming: CollapseTiming;
33
+ readonly foldedReplies: FoldedReplies;
34
+ readonly developerTools: boolean;
35
+ readonly performance: PerformanceMode;
36
+ }
37
+ /**
38
+ * dsh's shipped defaults. Used only when the settings service is unavailable
39
+ * (headless boot, older runtime) — never as a user-facing default override.
40
+ */
41
+ export declare const DEFAULT_CARD_POLICY: CardPolicy;
42
+ /** One `settings.describe()` entry, narrowed to what the policy reads. */
43
+ export interface SettingsEntryLike {
44
+ readonly ns?: unknown;
45
+ readonly id?: unknown;
46
+ readonly value?: unknown;
47
+ }
48
+ /**
49
+ * Map live settings entries onto the card policy. Unknown or malformed values
50
+ * fall back to dsh's defaults per field, so one bad field never blanks the card.
51
+ */
52
+ export declare function cardPolicyFromSettings(entries: readonly SettingsEntryLike[] | undefined): CardPolicy;
53
+ /** How many step lines a transcript mode shows; `Infinity` = the whole turn. */
54
+ export declare function stepLimit(mode: TranscriptMode): number;
55
+ /** Per-step elapsed time is part of the `detailed` and `verbose` levels. */
56
+ export declare function showsStepElapsed(mode: TranscriptMode): boolean;
57
+ /** Tool invocation total belongs to `detailed` and up. */
58
+ export declare function showsToolCount(mode: TranscriptMode): boolean;
59
+ /** Fold the process card as soon as the turn ends (`next-input` defers it). */
60
+ export declare function foldsAtTurnEnd(policy: CardPolicy): boolean;
61
+ /** One rendered step line. */
62
+ export interface StepLine {
63
+ readonly label: string;
64
+ readonly done: boolean;
65
+ readonly durationMs?: number;
66
+ }
67
+ /**
68
+ * Reduce the in-memory event window to step lines for the current turn.
69
+ *
70
+ * `tool/call` carries the name and `tool/result` carries the matching
71
+ * `message.source.callId`, so a call is `done` once its result arrives and the
72
+ * pair yields a duration. Only the last `limit` lines are returned; callers
73
+ * surface the total separately.
74
+ */
75
+ export declare function stepLinesFromEvents(events: readonly unknown[], limit: number, options?: {
76
+ readonly turn?: number;
77
+ readonly elapsed?: boolean;
78
+ }): StepLine[];
79
+ /** Render step lines the way the card shows them: one tool per row. */
80
+ export declare function formatStepLines(lines: readonly StepLine[]): string[];
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Card presentation policy — mirrors dsh's own Chat/General settings so the
3
+ * Feishu card shows the same amount of process detail as the Web UI instead of
4
+ * inventing a second set of knobs.
5
+ *
6
+ * Read live from the `settings` service (`describe()`), which returns the
7
+ * active profile's entry values:
8
+ *
9
+ * - `ui-chat.transcriptView` 工作步骤展示
10
+ * - `ui-chat.collapseTiming` 工作步骤收起时机
11
+ * - `ui-chat.foldedReplies` 收起后显示的回复
12
+ * - `ui-chat.performanceUsage` 性能与用量
13
+ * - `ui-settings-general.developerTools` 显示代码工作视图
14
+ *
15
+ * @module dsh-caduceus/card-policy
16
+ */
17
+ /** Transcript detail levels accepted by dsh's Chat settings. */
18
+ export const TRANSCRIPT_MODES = ['compact', 'standard', 'detailed', 'verbose'];
19
+ /** When the completed turn's process folds away. */
20
+ export const COLLAPSE_TIMINGS = ['completion', 'next-input'];
21
+ /** Which assistant replies stay visible once the process folds. */
22
+ export const FOLDED_REPLIES = ['all', 'final'];
23
+ /** Performance/usage line density. */
24
+ export const PERFORMANCE_MODES = ['compact', 'detailed'];
25
+ /**
26
+ * dsh's shipped defaults. Used only when the settings service is unavailable
27
+ * (headless boot, older runtime) — never as a user-facing default override.
28
+ */
29
+ export const DEFAULT_CARD_POLICY = {
30
+ transcript: 'compact',
31
+ collapseTiming: 'completion',
32
+ foldedReplies: 'final',
33
+ developerTools: false,
34
+ performance: 'compact',
35
+ };
36
+ function asRecord(value) {
37
+ return typeof value === 'object' && value !== null
38
+ ? value
39
+ : undefined;
40
+ }
41
+ function entryValue(entries, namespace) {
42
+ for (const entry of entries) {
43
+ const key = typeof entry.ns === 'string' ? entry.ns : entry.id;
44
+ if (key === namespace)
45
+ return asRecord(entry.value);
46
+ }
47
+ return undefined;
48
+ }
49
+ function pick(record, field, allowed, fallback) {
50
+ const raw = record?.[field];
51
+ return typeof raw === 'string' && allowed.includes(raw)
52
+ ? raw
53
+ : fallback;
54
+ }
55
+ /**
56
+ * Map live settings entries onto the card policy. Unknown or malformed values
57
+ * fall back to dsh's defaults per field, so one bad field never blanks the card.
58
+ */
59
+ export function cardPolicyFromSettings(entries) {
60
+ if (entries === undefined)
61
+ return DEFAULT_CARD_POLICY;
62
+ const chat = entryValue(entries, 'ui-chat');
63
+ const general = entryValue(entries, 'ui-settings-general');
64
+ const developerTools = general?.['developerTools'];
65
+ return {
66
+ transcript: pick(chat, 'transcriptView', TRANSCRIPT_MODES, DEFAULT_CARD_POLICY.transcript),
67
+ collapseTiming: pick(chat, 'collapseTiming', COLLAPSE_TIMINGS, DEFAULT_CARD_POLICY.collapseTiming),
68
+ foldedReplies: pick(chat, 'foldedReplies', FOLDED_REPLIES, DEFAULT_CARD_POLICY.foldedReplies),
69
+ developerTools: typeof developerTools === 'boolean' ? developerTools : DEFAULT_CARD_POLICY.developerTools,
70
+ performance: pick(chat, 'performanceUsage', PERFORMANCE_MODES, DEFAULT_CARD_POLICY.performance),
71
+ };
72
+ }
73
+ /** How many step lines a transcript mode shows; `Infinity` = the whole turn. */
74
+ export function stepLimit(mode) {
75
+ switch (mode) {
76
+ case 'compact':
77
+ return 1;
78
+ case 'standard':
79
+ return 3;
80
+ case 'detailed':
81
+ return 5;
82
+ case 'verbose':
83
+ return Number.POSITIVE_INFINITY;
84
+ }
85
+ }
86
+ /** Per-step elapsed time is part of the `detailed` and `verbose` levels. */
87
+ export function showsStepElapsed(mode) {
88
+ return mode === 'detailed' || mode === 'verbose';
89
+ }
90
+ /** Tool invocation total belongs to `detailed` and up. */
91
+ export function showsToolCount(mode) {
92
+ return mode !== 'compact';
93
+ }
94
+ /** Fold the process card as soon as the turn ends (`next-input` defers it). */
95
+ export function foldsAtTurnEnd(policy) {
96
+ return policy.collapseTiming === 'completion';
97
+ }
98
+ function seconds(ms) {
99
+ return `${(ms / 1000).toFixed(1)}s`;
100
+ }
101
+ /**
102
+ * Reduce the in-memory event window to step lines for the current turn.
103
+ *
104
+ * `tool/call` carries the name and `tool/result` carries the matching
105
+ * `message.source.callId`, so a call is `done` once its result arrives and the
106
+ * pair yields a duration. Only the last `limit` lines are returned; callers
107
+ * surface the total separately.
108
+ */
109
+ export function stepLinesFromEvents(events, limit, options = {}) {
110
+ const calls = new Map();
111
+ const order = [];
112
+ for (const raw of events) {
113
+ const event = asRecord(raw);
114
+ if (event === undefined)
115
+ continue;
116
+ const data = asRecord(event.data);
117
+ if (data === undefined)
118
+ continue;
119
+ if (options.turn !== undefined) {
120
+ const turn = data['turn'];
121
+ if (typeof turn === 'number' && turn !== options.turn)
122
+ continue;
123
+ }
124
+ const time = typeof event.time === 'number' ? event.time : undefined;
125
+ if (event.type === 'tool/call') {
126
+ const callId = typeof data['callId'] === 'string' ? data['callId'] : undefined;
127
+ const name = typeof data['name'] === 'string' ? data['name'] : '调用';
128
+ if (callId === undefined)
129
+ continue;
130
+ calls.set(callId, { label: name, at: time ?? 0, done: false });
131
+ order.push(callId);
132
+ continue;
133
+ }
134
+ if (event.type === 'tool/result') {
135
+ const message = asRecord(data['message']);
136
+ const source = asRecord(message?.['source']);
137
+ const callId = typeof source?.['callId'] === 'string' ? source['callId'] : undefined;
138
+ if (callId === undefined)
139
+ continue;
140
+ const entry = calls.get(callId);
141
+ if (entry === undefined)
142
+ continue;
143
+ entry.done = true;
144
+ if (time !== undefined && entry.at > 0)
145
+ entry.durationMs = Math.max(0, time - entry.at);
146
+ }
147
+ }
148
+ const lines = order
149
+ .map((callId) => calls.get(callId))
150
+ .filter((entry) => entry !== undefined)
151
+ .map((entry) => ({
152
+ label: entry.label,
153
+ done: entry.done,
154
+ ...(options.elapsed === true && entry.durationMs !== undefined
155
+ ? { durationMs: entry.durationMs }
156
+ : {}),
157
+ }));
158
+ if (!Number.isFinite(limit))
159
+ return lines;
160
+ return lines.slice(Math.max(0, lines.length - limit));
161
+ }
162
+ /** Render step lines the way the card shows them: one tool per row. */
163
+ export function formatStepLines(lines) {
164
+ return lines.map((line) => {
165
+ const mark = line.done ? '✓' : '▸';
166
+ const duration = line.durationMs === undefined ? '' : ` · ${seconds(line.durationMs)}`;
167
+ return `${mark} ${line.label}${duration}`;
168
+ });
169
+ }
package/dist/config.d.ts CHANGED
@@ -92,7 +92,7 @@ export declare const Config: z<Schemastery.ObjectS<NoInfer<{
92
92
  admins: z<string[], string[], "plain">;
93
93
  lang: z<"zh" | "en" | "auto", "zh" | "en" | "auto", "plain">;
94
94
  exportCacheMaxAgeHours: z<number, number, "plain">;
95
- metrics: z<"off" | "compact" | "detailed", "off" | "compact" | "detailed", "plain">;
95
+ metrics: z<"compact" | "detailed" | "off", "compact" | "detailed" | "off", "plain">;
96
96
  }>>, Schemastery.ObjectT<NoInfer<{
97
97
  appId: z<string, string, "plain">;
98
98
  appSecret: z<string, string, "plain">;
@@ -124,7 +124,7 @@ export declare const Config: z<Schemastery.ObjectS<NoInfer<{
124
124
  admins: z<string[], string[], "plain">;
125
125
  lang: z<"zh" | "en" | "auto", "zh" | "en" | "auto", "plain">;
126
126
  exportCacheMaxAgeHours: z<number, number, "plain">;
127
- metrics: z<"off" | "compact" | "detailed", "off" | "compact" | "detailed", "plain">;
127
+ metrics: z<"compact" | "detailed" | "off", "compact" | "detailed" | "off", "plain">;
128
128
  }>>, "plain">;
129
129
  /** Default card patch throttle: half of Feishu's 5 QPS budget. */
130
130
  export declare const DEFAULT_CARD_THROTTLE_MS = 400;
package/dist/index.js CHANGED
@@ -15,6 +15,7 @@ import { createRequire } from 'node:module';
15
15
  import { dirname, join } from 'node:path';
16
16
  import { backfillSessionOwnership, scanSessionDirs } from './backfill.js';
17
17
  import { Bridge } from './bridge/bridge.js';
18
+ import { cardPolicyFromSettings } from './card-policy.js';
18
19
  import { runHarnessCommand } from './commands/harness.js';
19
20
  import { cleanupExportCache, parseSessionEvents } from './export.js';
20
21
  import { resolveLang } from './i18n.js';
@@ -54,6 +55,20 @@ function agentOptions(ctx) {
54
55
  return {};
55
56
  }
56
57
  }
58
+ /** Read dsh's live Chat/General settings as a card policy (lazy each render). */
59
+ function makeCardPolicy(ctx) {
60
+ const read = () => {
61
+ try {
62
+ const settings = ctx.get('settings');
63
+ const entries = settings?.describe?.();
64
+ return cardPolicyFromSettings(entries);
65
+ }
66
+ catch {
67
+ return cardPolicyFromSettings(undefined);
68
+ }
69
+ };
70
+ return read;
71
+ }
57
72
  /** Adapt `ctx.get('agents')` to the bridge's structural seam. */
58
73
  function makeAgents(ctx) {
59
74
  const service = () => ctx.get('agents');
@@ -217,6 +232,8 @@ export function apply(ctx, config = {}) {
217
232
  sessionIdsProvider: () => scanSessionDirs().map((entry) => entry.sessionId),
218
233
  // Lazy per use: the preset registry can register AFTER this plugin mounts.
219
234
  agentPresetsProvider: () => ctx.get('agentPresets'),
235
+ // Card detail follows dsh's own Chat/General settings (live, no restart).
236
+ cardPolicyProvider: makeCardPolicy(ctx),
220
237
  selfCheck,
221
238
  interactive,
222
239
  rootCtx: ctx,
package/dist/metrics.d.ts CHANGED
@@ -7,6 +7,7 @@
7
7
  *
8
8
  * @module dsh-caduceus/metrics
9
9
  */
10
+ import type { PerformanceMode } from './card-policy.js';
10
11
  export type MetricsMode = 'off' | 'compact' | 'detailed';
11
12
  export interface Metrics {
12
13
  readonly rounds: number;
@@ -26,3 +27,9 @@ export declare function aggregateMetrics(events: readonly unknown[]): Metrics;
26
27
  export declare function formatTokenCount(value: number): string;
27
28
  /** One-line metrics summary; empty when mode is off or nothing to show. */
28
29
  export declare function formatMetricsLine(metrics: Metrics, mode: MetricsMode): string;
30
+ /**
31
+ * Performance/usage block, laid out per the user's dsh `性能与用量` level:
32
+ * `compact` is one dense line, `detailed` splits rate from volume so neither
33
+ * line has to be read across a long `·` chain.
34
+ */
35
+ export declare function formatPerformanceLines(metrics: Metrics, performance: PerformanceMode): string[];
package/dist/metrics.js CHANGED
@@ -133,3 +133,36 @@ export function formatMetricsLine(metrics, mode) {
133
133
  }
134
134
  return parts.join(' · ');
135
135
  }
136
+ /**
137
+ * Performance/usage block, laid out per the user's dsh `性能与用量` level:
138
+ * `compact` is one dense line, `detailed` splits rate from volume so neither
139
+ * line has to be read across a long `·` chain.
140
+ */
141
+ export function formatPerformanceLines(metrics, performance) {
142
+ const rate = [];
143
+ if (metrics.tokensPerSecond !== undefined) {
144
+ rate.push(`${Math.round(metrics.tokensPerSecond)} tok/s`);
145
+ }
146
+ if (metrics.cacheHitPercent !== undefined)
147
+ rate.push(`缓存 ${metrics.cacheHitPercent}%`);
148
+ if (metrics.contextPercent !== undefined)
149
+ rate.push(`上下文 ${metrics.contextPercent}%`);
150
+ const volume = [];
151
+ if (metrics.rounds > 0)
152
+ volume.push(`${metrics.rounds} 轮`);
153
+ if (metrics.steps > 0)
154
+ volume.push(`${metrics.steps} 步`);
155
+ if (metrics.totalTokens > 0)
156
+ volume.push(`${formatTokenCount(metrics.totalTokens)} tok`);
157
+ if (performance === 'compact') {
158
+ // 简洁档:一行放完,但把速度、总量、缓存分开读得懂的顺序。
159
+ const merged = [...volume, ...rate];
160
+ return merged.length === 0 ? [] : [merged.join(' · ')];
161
+ }
162
+ const lines = [];
163
+ if (rate.length > 0)
164
+ lines.push(`性能:${rate.join(' · ')}`);
165
+ if (volume.length > 0)
166
+ lines.push(`用量:${volume.join(' · ')}`);
167
+ return lines;
168
+ }
@@ -36,24 +36,44 @@ export interface ProgressCardInput {
36
36
  /** Unix epoch ms when the turn started; used for the elapsed line. */
37
37
  readonly startedAt?: number;
38
38
  readonly now?: number;
39
- /** Session title shown on the state line when known. */
39
+ /** Session title shown above the state line when known. */
40
40
  readonly sessionTitle?: string;
41
- /** Latest tool/event label (clipped to 40 characters). */
41
+ /** Latest tool/event label (kept for callers that have no step log). */
42
42
  readonly recentEvent?: string;
43
43
  /** Assistant output tail shown while the turn runs (≤8 KB). */
44
44
  readonly tail?: string;
45
45
  /** Append `(preset 未挂载)` to the state line. */
46
46
  readonly presetMissing?: boolean;
47
+ /** 性能与用量 block, already density-adjusted for the user's level. */
48
+ readonly metricLines?: readonly string[];
49
+ /** Step log rows (one tool per row), already limited for the level. */
50
+ readonly stepLines?: readonly string[];
51
+ /** `共 N 次调用,显示最近 M 条` when the log was truncated. */
52
+ readonly stepNote?: string;
47
53
  }
48
54
  /** Clip by Unicode code point so a label never exceeds the budget. */
49
55
  export declare function clipLabel(text: string, maxChars?: number): string;
50
56
  /**
51
- * The single per-turn card: state line (title/state/steps/tools/elapsed) and a
52
- * one-line recent event. It deliberately carries NO assistant body — the final
53
- * message is the only place an answer is rendered, so a short answer can never
54
- * appear twice.
57
+ * The single per-turn card, laid out in stacked blocks so nothing is crammed
58
+ * onto one `·` chain:
59
+ *
60
+ * 会话标题
61
+ * 状态行(状态 + 已运行)
62
+ * 性能与用量(按档位一行或两行)
63
+ * 附加状态(压缩 / 协作 / 已请求停止)
64
+ * ───
65
+ * 步骤流水(一行一个工具,按档位限量)
66
+ * ───
67
+ * 正文尾部
68
+ * [停止]
69
+ * 脚注
70
+ *
71
+ * It deliberately carries NO assistant body beyond the tail — the final card is
72
+ * the only place a complete answer is rendered.
55
73
  */
56
74
  export declare function progressCard(input: ProgressCardInput): FeishuCard;
75
+ /** Turn the live process card into the greyed, collapsed record of the turn. */
76
+ export declare function collapsedProcessCard(card: FeishuCard, note: string): FeishuCard;
57
77
  export interface FinalCardInput {
58
78
  readonly title: string;
59
79
  readonly stateLabel: string;
@@ -41,18 +41,30 @@ export function clipLabel(text, maxChars = 40) {
41
41
  return Array.from(text).slice(0, maxChars).join('');
42
42
  }
43
43
  /**
44
- * The single per-turn card: state line (title/state/steps/tools/elapsed) and a
45
- * one-line recent event. It deliberately carries NO assistant body — the final
46
- * message is the only place an answer is rendered, so a short answer can never
47
- * appear twice.
44
+ * The single per-turn card, laid out in stacked blocks so nothing is crammed
45
+ * onto one `·` chain:
46
+ *
47
+ * 会话标题
48
+ * 状态行(状态 + 已运行)
49
+ * 性能与用量(按档位一行或两行)
50
+ * 附加状态(压缩 / 协作 / 已请求停止)
51
+ * ───
52
+ * 步骤流水(一行一个工具,按档位限量)
53
+ * ───
54
+ * 正文尾部
55
+ * [停止]
56
+ * 脚注
57
+ *
58
+ * It deliberately carries NO assistant body beyond the tail — the final card is
59
+ * the only place a complete answer is rendered.
48
60
  */
49
61
  export function progressCard(input) {
50
- const stateLine = [];
62
+ const elements = [];
51
63
  const sessionTitle = input.sessionTitle?.trim();
52
64
  if (sessionTitle !== undefined && sessionTitle.length > 0) {
53
- stateLine.push(`会话:${clipLabel(sessionTitle)}`);
65
+ elements.push(div(`会话:${clipLabel(sessionTitle)}`));
54
66
  }
55
- stateLine.push(`**${input.stateLabel}**`, `步数 ${input.steps}`, `工具 ${input.tools}`);
67
+ const stateLine = [`**${input.stateLabel}**`];
56
68
  if (input.startedAt !== undefined) {
57
69
  const now = input.now ?? Date.now();
58
70
  const seconds = Math.max(0, Math.floor((now - input.startedAt) / 1000));
@@ -60,17 +72,34 @@ export function progressCard(input) {
60
72
  }
61
73
  if (input.presetMissing === true)
62
74
  stateLine.push('(preset 未挂载)');
63
- const elements = [
64
- div(stateLine.join(' · ')),
65
- div(`最近:${clipLabel(input.recentEvent ?? '(无)')}`),
66
- ];
75
+ elements.push(div(stateLine.join(' · ')));
76
+ for (const line of input.metricLines ?? []) {
77
+ if (line.trim().length > 0)
78
+ elements.push(div(line));
79
+ }
67
80
  for (const line of input.extraLines ?? []) {
68
81
  if (line.trim().length > 0)
69
82
  elements.push(div(line));
70
83
  }
84
+ const stepRows = (input.stepLines ?? []).filter((line) => line.trim().length > 0);
85
+ const rows = stepRows.length > 0
86
+ ? stepRows
87
+ : input.recentEvent === undefined
88
+ ? []
89
+ : [`最近:${clipLabel(input.recentEvent)}`];
90
+ if (rows.length > 0) {
91
+ elements.push({ tag: 'hr' });
92
+ for (const row of rows)
93
+ elements.push(div(row));
94
+ const note = input.stepNote?.trim();
95
+ if (note !== undefined && note.length > 0)
96
+ elements.push(div(`_${note}_`));
97
+ }
71
98
  const tail = input.tail === undefined ? '' : tailText(input.tail);
72
- if (tail.trim().length > 0)
99
+ if (tail.trim().length > 0) {
100
+ elements.push({ tag: 'hr' });
73
101
  elements.push(div(tail));
102
+ }
74
103
  if (input.stopButton === true) {
75
104
  elements.push({
76
105
  tag: 'action',
@@ -84,9 +113,28 @@ export function progressCard(input) {
84
113
  ],
85
114
  });
86
115
  }
87
- elements.push(div('_过程消息;收尾会把终稿更新到本卡。_'));
116
+ elements.push(div('_过程消息;结束后会置灰收起,正文另发。_'));
88
117
  return buildCard({ title: input.title, template: 'blue', elements });
89
118
  }
119
+ /** Turn the live process card into the greyed, collapsed record of the turn. */
120
+ export function collapsedProcessCard(card, note) {
121
+ // Drop the buttons and the running-time footnote: both would lie on a card
122
+ // that has already ended.
123
+ const elements = card.elements.filter((element) => element.tag !== 'action' &&
124
+ !element.text?.content?.startsWith('_过程消息'));
125
+ const text = note.trim();
126
+ if (text.length > 0)
127
+ elements.push(div(`_${text}_`));
128
+ const grey = {
129
+ config: card.config,
130
+ ...(card.header === undefined
131
+ ? {}
132
+ : { header: { ...card.header, template: 'grey' } }),
133
+ elements,
134
+ };
135
+ assertCardFits(grey);
136
+ return grey;
137
+ }
90
138
  /** State-line parts shared by the card markdown and the text fallback. */
91
139
  function finalStateLine(input, markdown) {
92
140
  const parts = [];
package/lib/client.js CHANGED
@@ -85,7 +85,7 @@ function openDrawer(props) {
85
85
  fontSize: "18px"
86
86
  });
87
87
  const version = document.createElement("p");
88
- version.textContent = `${t("panel.version")} 0.12.1`;
88
+ version.textContent = `${t("panel.version")} 0.12.3`;
89
89
  const session = document.createElement("p");
90
90
  session.textContent = `${t("drawer.session")}: ${props.sessionId ?? t("drawer.none")}`;
91
91
  const loaded = document.createElement("p");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-caduceus",
3
- "version": "0.12.1",
3
+ "version": "0.12.3",
4
4
  "description": "Feishu/Lark surface plugin for DeepSeek Harness (dsh): one topic per session, SOUL persona sync, streaming process cards, /status and /doctor.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",