@modesto_ma/dsh-channels 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +115 -39
  2. package/docs/access-control-design.md +1 -1
  3. package/docs/attachment-pipeline-v2.md +318 -0
  4. package/docs/feasibility-and-design.md +54 -26
  5. package/docs/feishu-onboarding.md +123 -15
  6. package/docs/persistence.md +4 -4
  7. package/docs/refactor-plan.md +7 -6
  8. package/docs/workspace-isolation-design.md +81 -0
  9. package/lib/client.js +30 -15
  10. package/lib/client.js.map +1 -1
  11. package/lib/index.js +11008 -5132
  12. package/lib/index.js.map +1 -1
  13. package/package.json +1 -1
  14. package/src/cards/index.ts +1 -0
  15. package/src/cards/onboarding.ts +23 -0
  16. package/src/cards/primitives.ts +20 -0
  17. package/src/cards/streaming.ts +310 -291
  18. package/src/cleanup.ts +267 -119
  19. package/src/client/panel/cleanup-card.tsx +4 -2
  20. package/src/client/panel/workspace-tab.tsx +1 -1
  21. package/src/config.ts +221 -13
  22. package/src/domain.ts +141 -3
  23. package/src/feishu/agent-context.ts +66 -16
  24. package/src/feishu/inbound-attachments.ts +440 -3
  25. package/src/feishu/manifest.ts +270 -0
  26. package/src/feishu/message-read.ts +1333 -0
  27. package/src/feishu/native-attachments.ts +998 -0
  28. package/src/feishu/pending-attachments.ts +562 -0
  29. package/src/feishu/tools.ts +2345 -0
  30. package/src/feishu-channel.ts +55 -0
  31. package/src/host/agent.ts +55 -0
  32. package/src/host/attachments.ts +210 -0
  33. package/src/host/index.ts +1 -0
  34. package/src/index.ts +8 -6
  35. package/src/routing/inbound.ts +730 -81
  36. package/src/service.ts +536 -50
  37. package/src/session/isolation.ts +277 -0
  38. package/src/session/manager.ts +120 -23
  39. package/src/session/repair.ts +2 -3
  40. package/src/session/routes.ts +2 -0
  41. package/src/session-commands.ts +41 -14
  42. package/src/settings/contract.ts +43 -21
  43. package/src/settings/overlay.ts +124 -13
  44. package/src/settings/surface.ts +147 -14
  45. package/src/streamer.ts +70 -30
  46. package/src/workspaces.ts +74 -31
@@ -1,16 +1,46 @@
1
1
  /**
2
- * Streaming reply card: reasoning panel, activity lines, body segmentation
3
- * and the byte/table budgets that keep one card inside Feishu's limits.
2
+ * Streaming reply card: the reasoning fold panel, the transient activity
3
+ * lines, the reply body and the settled footer — plus the byte/table budgets
4
+ * that keep one card inside Feishu's limits.
4
5
  *
5
- * Two independent budgets: the reply body segments across cards at
6
- * `CARD_TARGET_BYTES` (never splitting reasoning), while the reasoning fold
7
- * panel is a fixed `REASONING_MAX_BYTES` (5 KB) window — clipped from the tail
8
- * with a truncation marker and never traded against the body in either
9
- * direction.
6
+ * Composition, main card:
7
+ *
8
+ * [fold panel] [activity status] [activity tool] [body]
9
+ * [hr] [model · session · reasoning time]
10
+ * [context share · input · output · cache hit]
11
+ *
12
+ * - The panel shows the reasoning behind its own window. While the turn is
13
+ * still thinking it doubles as the phase indicator; once the turn settles
14
+ * without any reasoning content (non-reasoning models) it is omitted
15
+ * entirely instead of leaving an empty bordered box.
16
+ * - The two activity lines are transient: the streamer only passes them for
17
+ * the card that is currently streaming, so frozen segment cards never repeat
18
+ * a stale "正在调用工具" line.
19
+ * - The settled footer is a divider plus two notation lines and renders
20
+ * exactly once: on the main card, only after the turn settles. It carries
21
+ * the model/effort, session id and the turn's accumulated reasoning time
22
+ * (line 1), then the context pressure with its window share plus the
23
+ * input/output token counts and cache hit ratio (line 2). First frames
24
+ * therefore never show a row of zeroed counters.
25
+ *
26
+ * Continuation cards (`options.segment`) carry a segment header and the body;
27
+ * they have no panel and no footer, so a long answer is not repeated chrome.
28
+ *
29
+ * Budgets, two independent ones:
30
+ * - Each card is measured as a whole: segmentation targets `CARD_TARGET_BYTES`
31
+ * (main cards probe the panel at its maximum window, so reasoning that keeps
32
+ * growing after the split cannot push the card over), while `CARD_MAX_BYTES`
33
+ * is the platform hard cap used only by `buildCard` as an absolute guard.
34
+ * - The reasoning panel is a fixed `REASONING_MAX_BYTES` window, clipped from
35
+ * the tail with a truncation marker. It neither competes with nor is squeezed
36
+ * by the reply body: the visible reasoning window is identical whether the
37
+ * answer is one line or a full segment.
10
38
  *
11
39
  * @module dsh-channels/cards/streaming
12
40
  */
13
41
 
42
+ import { card, divider, foldPanel, markdown, note } from './primitives.ts'
43
+
14
44
  export type PanelPhase = 'thinking' | 'replying' | 'done' | 'error'
15
45
 
16
46
  export const PANEL_TITLES: Record<PanelPhase, string> = {
@@ -25,27 +55,17 @@ export const PANEL_CHEVRON_TOKEN = 'down-small-ccm_outlined'
25
55
 
26
56
  /** Feishu hard cap for one card message body. */
27
57
  export const CARD_MAX_BYTES = 30_000
28
- /** Target budget per card — 2 KB below the hard cap as headroom. */
58
+ /** Target budget per card — 2 KB below the hard cap as headroom for the
59
+ * figures that keep growing between a segment split and the final patch. */
29
60
  export const CARD_TARGET_BYTES = 28_000
30
61
  /**
31
- * Hard byte cap for the reasoning fold panel's markdown (5 KB, JSON-escaped
32
- * UTF-8, including the truncation marker). The panel is clipped against this
33
- * alone — never against the card — so it neither competes with nor is squeezed
34
- * by the reply body: the visible reasoning window is identical whether the
35
- * answer is one line or a full 28 KB segment. Measured at the cap: ≈4.96 K
36
- * ASCII chars, ≈1.65 K CJK chars, ≈2.5 K emoji. The panel element's own
37
- * wrapper (border, header, chevron) is charged separately — see
38
- * `CARD_PANEL_RESERVE_BYTES`.
62
+ * Cap for the reasoning fold panel's markdown (5 KB, JSON-escaped UTF-8,
63
+ * including the truncation marker). The panel is clipped against this alone —
64
+ * never against the card — so the visible reasoning window is the same whether
65
+ * the reply is one line or a full segment. Visible window at the cap: ≈4.96 K
66
+ * ASCII chars, ≈1.65 K CJK chars, ≈1.24 K emoji.
39
67
  */
40
68
  export const REASONING_MAX_BYTES = 5_000
41
- /**
42
- * Bytes held back from the card target for the fold panel as a whole, so a
43
- * segmented card's body plus its panel stay inside `CARD_TARGET_BYTES`.
44
- * `REASONING_MAX_BYTES` caps the panel's markdown; the element wrapper around
45
- * it (border, header, chevron icon, `elements` array) measures a further 315 B
46
- * at the worst phase, and this reserve covers that with headroom.
47
- */
48
- export const CARD_PANEL_RESERVE_BYTES = 5_500
49
69
  /**
50
70
  * Feishu hard-limits the number of markdown tables a single card may contain
51
71
  * (API error 11310 "card table number over limit"; verified on device: the
@@ -56,7 +76,7 @@ export const MAX_TABLES_PER_CARD = 5
56
76
  /** Appended to clipped reasoning (and, as an absolute last resort, clipped body). */
57
77
  export const TRUNCATION_SUFFIX = '\n\n…(内容过长,已截断)'
58
78
 
59
- /** Session-scoped figures rendered on the info lines. */
79
+ /** Session-scoped figures rendered on the settled card's footer. */
60
80
  export interface CardStats {
61
81
  /** DSH session id. */
62
82
  sessionId: string
@@ -74,6 +94,13 @@ export interface CardStats {
74
94
  model?: string
75
95
  /** Reasoning effort (current turn's latest request header). */
76
96
  effort?: string
97
+ /**
98
+ * Accumulated wall-clock time the turn spent in reasoning phases, in ms.
99
+ * Each step's phase opens at its first reasoning delta and closes when that
100
+ * step's output ends (first answer text or step end), so tool execution time
101
+ * is never counted. Undefined when the turn produced no reasoning → `—`.
102
+ */
103
+ reasoningMs?: number
77
104
  }
78
105
 
79
106
  export interface CardState {
@@ -85,25 +112,27 @@ export interface CardState {
85
112
  /**
86
113
  * Transient delegation status (subagent/workflow progress, settled workflow
87
114
  * outcome, heartbeat elapsed) rendered as a notation line between the panel
88
- * and the body — the FIRST of the two activity lines. Empty/undefined →
89
- * omitted. Callers cap its length so it stays inside the budget headroom.
115
+ * and the body. The streamer only passes it for the card that is currently
116
+ * streaming. Empty/undefined → omitted. Callers cap its length so it stays
117
+ * inside the budget headroom.
90
118
  */
91
119
  status?: string
92
120
  /**
93
121
  * The parent agent's in-flight tool call, rendered as a notation line below
94
- * the status line — the SECOND activity line. Independent of `status`:
95
- * neither ever supersedes the other. Empty/undefined → omitted.
122
+ * the status line. Independent of `status`: neither ever supersedes the
123
+ * other. Same rule as `status` — only the actively streaming card renders it.
124
+ * Empty/undefined → omitted.
96
125
  */
97
126
  tool?: string
98
127
  stats: CardStats
99
128
  }
100
129
 
101
- /** Rendering variants. */
130
+ /**
131
+ * Rendering variant. Omitted → the main card (reasoning panel + settled
132
+ * footer); a 1-based `segment` → a continuation card (segment header only).
133
+ */
102
134
  export interface CardOptions {
103
- /** Render the reasoning fold panel (default `true`). Continuation segment
104
- * cards pass `false` and get a segment header instead. */
105
- panel?: boolean
106
- /** 1-based segment number shown in the continuation header. */
135
+ /** 1-based continuation segment number. */
107
136
  segment?: number
108
137
  }
109
138
 
@@ -116,11 +145,20 @@ export interface BodySplit {
116
145
  tail: string
117
146
  }
118
147
 
148
+ const encoder = new TextEncoder()
149
+
119
150
  /** UTF-8 byte size of a card's JSON — the metric behind Feishu's 30 KB cap. */
120
- export function cardJsonBytes(card: object): number {
121
- return new TextEncoder().encode(JSON.stringify(card)).length
151
+ export function cardJsonBytes(value: object): number {
152
+ return encoder.encode(JSON.stringify(value)).length
153
+ }
154
+
155
+ /** JSON-escaped UTF-8 size of a string, as embedded in a card's JSON. */
156
+ function jsonBytes(text: string): number {
157
+ return encoder.encode(JSON.stringify(text)).length
122
158
  }
123
159
 
160
+ // ---- body primitives ------------------------------------------------------
161
+
124
162
  /** A contiguous run of markdown table lines; `end` is exclusive. */
125
163
  export interface TableBlock {
126
164
  start: number
@@ -159,6 +197,41 @@ export function tableBlocks(body: string): TableBlock[] {
159
197
  return blocks
160
198
  }
161
199
 
200
+ /** Fenced code block ranges (opening fence line through the closing line). An
201
+ * unclosed fence extends to the end of the body. */
202
+ function codeFenceBlocks(body: string): TableBlock[] {
203
+ const blocks: TableBlock[] = []
204
+ let start = -1
205
+ let offset = 0
206
+ for (const line of body.split('\n')) {
207
+ if (/^[ \t]*```/.test(line)) {
208
+ if (start < 0) {
209
+ start = offset
210
+ } else {
211
+ blocks.push({ start, end: offset + line.length })
212
+ start = -1
213
+ }
214
+ }
215
+ offset += line.length + 1
216
+ }
217
+ if (start >= 0) blocks.push({ start, end: body.length })
218
+ return blocks
219
+ }
220
+
221
+ /**
222
+ * Move a cut position out of any protected block (code fence or table) it
223
+ * lands inside: the block is kept whole on the following card. A block that
224
+ * starts at position 0 cannot be moved back (that would produce an empty
225
+ * head), so that degenerate case is left to the hard-cut escape hatch.
226
+ */
227
+ function avoidProtected(blocks: TableBlock[], at: number): number {
228
+ for (const block of blocks) {
229
+ if (block.start >= at) break
230
+ if (at < block.end) return block.start > 0 ? block.start : at
231
+ }
232
+ return at
233
+ }
234
+
162
235
  /**
163
236
  * Replace markdown table blocks with a placeholder. Used for the reasoning
164
237
  * panel, whose content is auxiliary (truncation has no impact) and is never
@@ -179,30 +252,58 @@ export function stripTables(text: string): string {
179
252
  return out
180
253
  }
181
254
 
255
+ // ---- card content ---------------------------------------------------------
256
+
182
257
  function fmt(n: number): string {
183
258
  return n.toLocaleString('en-US')
184
259
  }
185
260
 
261
+ /** Context-window share: one decimal below 10%, a whole number from 10% up. */
262
+ function fmtContextPct(tokens: number, window: number): string {
263
+ const pct = (tokens / window) * 100
264
+ // Round first, then decide the precision, so 9.96% renders as `10%` (not
265
+ // `10.0%`) and agrees with the integer branch.
266
+ return `${Number(pct.toFixed(1)) >= 10 ? pct.toFixed(0) : pct.toFixed(1)}%`
267
+ }
268
+
269
+ /** `上下文 3,618/1,000,000 (0.4%)`; without a usable window the share is omitted. */
186
270
  function fmtContext(tokens?: number, window?: number): string {
187
271
  if (tokens === undefined) return '—'
188
- if (window === undefined) return `${fmt(tokens)} tokens`
189
- return `${fmt(tokens)} / ${fmt(window)}`
272
+ if (window === undefined || window <= 0) return `${fmt(tokens)} tokens`
273
+ return `${fmt(tokens)}/${fmt(window)} (${fmtContextPct(tokens, window)})`
190
274
  }
191
275
 
192
276
  function fmtCacheHit(pct?: number): string {
193
277
  return pct === undefined ? '—' : `${(pct * 100).toFixed(1)}%`
194
278
  }
195
279
 
196
- /** Info line 2: `icon - 模型 {model}({effort}) · icon - 缓存命中 {cache} · icon - 上下文 {context}` */
280
+ /** `推理 3.2s` / `推理 1分5s`; `—` when the turn had no reasoning phase. */
281
+ function fmtReasoningTime(ms?: number): string {
282
+ if (ms === undefined) return '—'
283
+ const safe = Math.max(0, ms)
284
+ if (safe < 60_000) return `${(safe / 1000).toFixed(1)}s`
285
+ const total = Math.round(safe / 1000)
286
+ return `${Math.floor(total / 60)}分${total % 60}s`
287
+ }
288
+
289
+ /**
290
+ * Settled footer, line 1: 🧠 model/effort · 🔑 session id · ⏱ reasoning time.
291
+ * Rendered once, after the turn settles, so live cards never flash a row of
292
+ * zeroed counters.
293
+ */
197
294
  function buildModelLine(stats: CardStats): string {
198
295
  const model = stats.model ?? '—'
199
296
  const effort = stats.effort !== undefined && stats.effort !== '' ? `(${stats.effort})` : ''
200
- return `🧠 模型 ${model}${effort} · ⚡ 缓存命中 ${fmtCacheHit(stats.cacheHitPct)} · 📊 上下文 ${fmtContext(stats.contextTokens, stats.contextWindow)}`
297
+ return `🧠 模型 ${model}${effort} · 🔑 会话 ${stats.sessionId} · ⏱ 推理 ${fmtReasoningTime(stats.reasoningMs)}`
201
298
  }
202
299
 
203
- /** Info line 1: `icon - {session id} · icon - input token {input_token} · icon - output token {output_token}` */
204
- function buildSessionLine(stats: CardStats): string {
205
- return `🔑 ${stats.sessionId} · ⬆ input token ${fmt(stats.inputTokens)} · ⬇ output token ${fmt(stats.outputTokens)}`
300
+ /**
301
+ * Settled footer, line 2: 📊 context pressure (counts + share) · 📩 input
302
+ * tokens · 📨 output tokens · ⚡ cache hit ratio.
303
+ */
304
+ function buildUsageLine(stats: CardStats): string {
305
+ return `📊 上下文 ${fmtContext(stats.contextTokens, stats.contextWindow)}`
306
+ + ` · 📩 输入 ${fmt(stats.inputTokens)} · 📨 输出 ${fmt(stats.outputTokens)} · ⚡ 缓存命中 ${fmtCacheHit(stats.cacheHitPct)}`
206
307
  }
207
308
 
208
309
  function reasoningPlaceholder(phase: PanelPhase): string {
@@ -213,142 +314,187 @@ function bodyPlaceholder(phase: PanelPhase): string {
213
314
  return phase === 'done' || phase === 'error' ? '*(本次无正文输出)*' : '*正在生成…*'
214
315
  }
215
316
 
216
- /** Continuation header: `📄 回复分段 {n} · {phase}` (or 续上一条卡片 without a number). */
217
- function segmentHeader(options: CardOptions, phase: PanelPhase): string {
218
- const seg = options.segment
219
- const title = seg === undefined ? '续上一条卡片' : `回复分段 ${seg}`
220
- return `📄 ${title} · ${PANEL_TITLES[phase]}`
317
+ /** The panel's markdown: stripped of tables, with a placeholder when empty. */
318
+ function panelContent(phase: PanelPhase, reasoning: string): string {
319
+ const stripped = stripTables(reasoning)
320
+ return stripped.trim() === '' ? reasoningPlaceholder(phase) : stripped
221
321
  }
222
322
 
223
- /** Raw card assembly — no clipping of any kind (the budget machinery wraps this). */
224
- function assemble(
225
- phase: PanelPhase,
323
+ /**
324
+ * Whether the fold panel renders. `thinking` always does (it carries the
325
+ * phase indicator while the card is otherwise empty); every later phase only
326
+ * when there is actual reasoning to fold away — a settled non-reasoning turn
327
+ * gets no empty bordered box.
328
+ */
329
+ function panelShown(phase: PanelPhase, reasoning: string): boolean {
330
+ return phase === 'thinking' || stripTables(reasoning).trim() !== ''
331
+ }
332
+
333
+ /** Continuation header: `📄 回复分段 {n}`. */
334
+ function segmentHeader(segment: number): string {
335
+ return `📄 回复分段 ${segment}`
336
+ }
337
+
338
+ /** Interruption notice header for a repaired continuation card. */
339
+ function interruptionHeader(segment: number): string {
340
+ return `📄 回复分段 ${segment} · 回复中断`
341
+ }
342
+
343
+ function isSettled(phase: PanelPhase): boolean {
344
+ return phase === 'done' || phase === 'error'
345
+ }
346
+
347
+ /**
348
+ * Defensive cap on caller-supplied activity lines. The streamer already clips
349
+ * them to 64 chars; this bound keeps `buildCard`'s "always ≤ CARD_MAX_BYTES"
350
+ * guarantee even for a direct caller that passes an unbounded line.
351
+ */
352
+ const ACTIVITY_LINE_MAX_CHARS = 512
353
+
354
+ /** One caller-supplied notation line, defensively capped; undefined when empty. */
355
+ function activityNote(text: string | undefined): object | undefined {
356
+ if (text === undefined || text === '') return undefined
357
+ const clipped = text.length <= ACTIVITY_LINE_MAX_CHARS
358
+ ? text
359
+ : `${text.slice(0, ACTIVITY_LINE_MAX_CHARS - 1)}…`
360
+ return note(clipped)
361
+ }
362
+
363
+ // ---- rendering ------------------------------------------------------------
364
+
365
+ /** Render one card. `reasoning`/`body` are the already-budgeted texts. */
366
+ function renderCard(
367
+ state: CardState,
368
+ options: CardOptions,
226
369
  reasoning: string,
227
370
  body: string,
228
- stats: CardStats,
229
- options: CardOptions,
230
- status?: string,
231
- tool?: string,
232
371
  ): object {
372
+ const segment = options.segment
373
+ const main = segment === undefined
233
374
  const elements: object[] = []
234
375
 
235
- if (options.panel ?? true) {
236
- const panelReasoning = stripTables(reasoning)
237
- elements.push({
238
- tag: 'collapsible_panel',
239
- expanded: false,
240
- border: { color: 'grey', corner_radius: '8px' },
241
- header: {
242
- title: { tag: 'plain_text', content: PANEL_TITLES[phase] },
243
- icon: { tag: 'standard_icon', token: PANEL_CHEVRON_TOKEN },
244
- icon_position: 'right',
245
- icon_expanded_angle: 180,
246
- },
247
- elements: [
248
- { tag: 'markdown', content: panelReasoning.trim() === '' ? reasoningPlaceholder(phase) : panelReasoning },
249
- ],
250
- })
376
+ if (main) {
377
+ if (panelShown(state.phase, reasoning)) {
378
+ elements.push(foldPanel(
379
+ PANEL_TITLES[state.phase],
380
+ panelContent(state.phase, reasoning),
381
+ PANEL_CHEVRON_TOKEN,
382
+ ))
383
+ }
251
384
  } else {
252
- elements.push({ tag: 'markdown', content: segmentHeader(options, phase), text_size: 'notation' })
385
+ elements.push(note(segmentHeader(segment)))
253
386
  }
254
387
 
255
- if (status !== undefined && status !== '') {
256
- elements.push({ tag: 'markdown', content: status, text_size: 'notation' })
257
- }
258
- if (tool !== undefined && tool !== '') {
259
- elements.push({ tag: 'markdown', content: tool, text_size: 'notation' })
260
- }
388
+ const statusNote = activityNote(state.status)
389
+ if (statusNote !== undefined) elements.push(statusNote)
390
+ const toolNote = activityNote(state.tool)
391
+ if (toolNote !== undefined) elements.push(toolNote)
261
392
 
262
- elements.push({ tag: 'hr' })
263
- elements.push({ tag: 'markdown', content: body.trim() === '' ? bodyPlaceholder(phase) : body })
264
- elements.push({ tag: 'hr' })
265
- elements.push({ tag: 'markdown', content: buildSessionLine(stats), text_size: 'notation' })
266
- elements.push({ tag: 'markdown', content: buildModelLine(stats), text_size: 'notation' })
393
+ elements.push(markdown(body.trim() === '' ? bodyPlaceholder(state.phase) : body))
267
394
 
268
- return {
269
- schema: '2.0',
270
- config: { width_mode: 'fill' },
271
- body: { elements },
395
+ if (main && isSettled(state.phase)) {
396
+ // The divider separates the answer from the turn's metadata; the two
397
+ // notation lines below it are the only place the settled figures appear.
398
+ elements.push(divider())
399
+ elements.push(note(buildModelLine(state.stats)))
400
+ elements.push(note(buildUsageLine(state.stats)))
272
401
  }
402
+
403
+ return card(elements)
273
404
  }
274
405
 
406
+ // ---- budgeting ------------------------------------------------------------
407
+
275
408
  /**
276
- * Fit `text` into a byte budget measured by `render`: return `text` unchanged
277
- * when it already fits; otherwise binary-search the longest prefix that still
278
- * fits once `suffix` is appended. The result is guaranteed to satisfy the
279
- * same measurement within `budget`.
409
+ * Longest prefix of `text` that fits `budget` under `measure`.
410
+ *
411
+ * Returns `text` unchanged when it already fits; otherwise the longest prefix
412
+ * with `suffix` appended still fits, so a clipped result is guaranteed to stay
413
+ * within the same budget.
280
414
  */
281
- function clipText(text: string, suffix: string, render: (content: string) => number, budget: number): string {
282
- if (render(text) <= budget) return text
415
+ function fitPrefix(
416
+ text: string,
417
+ budget: number,
418
+ measure: (candidate: string) => number,
419
+ suffix = '',
420
+ ): string {
421
+ if (measure(text) <= budget) return text
283
422
  let lo = 0
284
423
  let hi = text.length
285
424
  while (lo < hi) {
286
425
  const mid = Math.ceil((lo + hi) / 2)
287
- if (render(text.slice(0, mid) + suffix) <= budget) {
288
- lo = mid
289
- } else {
290
- hi = mid - 1
291
- }
426
+ if (measure(text.slice(0, mid) + suffix) <= budget) lo = mid
427
+ else hi = mid - 1
292
428
  }
293
429
  return text.slice(0, lo) + suffix
294
430
  }
295
431
 
296
- /** JSON-escaped UTF-8 size of a string — the metric Feishu's card cap counts. */
297
- function jsonBytes(text: string): number {
298
- return new TextEncoder().encode(JSON.stringify(text)).length
432
+ /** Reasoning as rendered: clipped against the panel's own fixed window. */
433
+ function budgetReasoning(state: CardState): string {
434
+ return fitPrefix(
435
+ state.reasoning,
436
+ REASONING_MAX_BYTES,
437
+ content => jsonBytes(panelContent(state.phase, content)),
438
+ TRUNCATION_SUFFIX,
439
+ )
299
440
  }
300
441
 
301
442
  /**
302
- * JSON-escaped UTF-8 size of the fold panel's markdown, as rendered by one
303
- * assembled card. Reasoning is budgeted against THIS rather than against the
304
- * whole card: a card-wide budget makes the panel collapse to the bare
305
- * truncation marker as soon as the reply body grows, so the visible reasoning
306
- * window would depend on how long the answer is.
443
+ * Build one card.
444
+ *
445
+ * The reasoning window is clipped against `REASONING_MAX_BYTES` alone, and the
446
+ * body is then clipped against `CARD_MAX_BYTES` measured on the card that is
447
+ * actually rendered — the guard exists only for direct callers and for the
448
+ * turn-failure notice appended to a settled body; the streamer pre-splits with
449
+ * `segmentCardContent`.
450
+ *
451
+ * Guarantee: the returned card is ≤ `CARD_MAX_BYTES`, and its panel markdown
452
+ * is ≤ `REASONING_MAX_BYTES`.
307
453
  */
308
- function panelMarkdownBytes(render: (reasoning: string) => object): (content: string) => number {
309
- return content => {
310
- const elements = (render(content) as { body?: { elements?: Array<{ elements?: Array<{ content?: string }> }> } })
311
- .body?.elements
312
- return jsonBytes(elements?.[0]?.elements?.[0]?.content ?? '')
313
- }
454
+ export function buildCard(state: CardState, options: CardOptions = {}): object {
455
+ const reasoning = options.segment === undefined ? budgetReasoning(state) : state.reasoning
456
+ const body = fitPrefix(
457
+ state.body,
458
+ CARD_MAX_BYTES,
459
+ content => cardJsonBytes(renderCard(state, options, reasoning, content)),
460
+ TRUNCATION_SUFFIX,
461
+ )
462
+ return renderCard(state, options, reasoning, body)
314
463
  }
315
464
 
316
- /** JSON-escaped UTF-8 size of the truncation marker — reserved so clips can
317
- * never push a card past its budget. */
318
- const SUFFIX_JSON_BYTES = jsonBytes(TRUNCATION_SUFFIX)
465
+ /**
466
+ * Worst-case panel markdown: exactly `REASONING_MAX_BYTES` JSON bytes of
467
+ * ASCII text (the surrounding quotes cost 2 B).
468
+ */
469
+ const PANEL_PROBE_MARKDOWN = 'r'.repeat(REASONING_MAX_BYTES - 2)
319
470
 
320
471
  /**
321
- * Build one card.
322
- *
323
- * - Reasoning is clipped so its markdown stays within `REASONING_MAX_BYTES`
324
- * (including the truncation marker): a fixed, self-contained budget that
325
- * neither grants space to nor borrows space from the body, so the visible
326
- * reasoning window is the same whether the reply is one line or a 28 KB
327
- * segment.
328
- * - The body gets whatever is left of `CARD_MAX_BYTES` once the clipped
329
- * reasoning is accounted for, which keeps the total under Feishu's hard cap.
330
- * The streamer pre-splits with `segmentCardContent` (probed at the
331
- * worst-case phase), so a live stream never reaches this clip: it is a pure
332
- * guard for direct callers.
472
+ * Split a body so one card fits: `head` re-renders within `CARD_TARGET_BYTES`
473
+ * and `tail` flows into the next segment card ('' when everything fits).
333
474
  *
334
- * Guarantee: the returned card is always ≤ `CARD_MAX_BYTES`, and its panel is
335
- * always ≤ `REASONING_MAX_BYTES`.
475
+ * The probe renders the worst card this segment can ever become: the main card
476
+ * always budgets the panel's full window (reasoning may keep growing after the
477
+ * split) and its settled footer, while continuation cards have neither. The
478
+ * `error` phase is probed because it has the longest panel title.
336
479
  */
337
- export function buildCard(state: CardState, options: CardOptions = {}): object {
338
- const render = (reasoning: string, body: string) =>
339
- assemble(state.phase, reasoning, body, state.stats, options, state.status, state.tool)
340
-
341
- // 1. The panel, against its own budget. The panel markdown (and therefore the
342
- // visible reasoning window) does not depend on the body, the phase title,
343
- // or the info lines — only on the reasoning text and this cap.
344
- const panelBytes = panelMarkdownBytes(content => render(content, ''))
345
- const reasoning = clipText(state.reasoning, TRUNCATION_SUFFIX, panelBytes, REASONING_MAX_BYTES)
480
+ export function segmentCardContent(state: CardState, options: CardOptions = {}): BodySplit {
481
+ const main = options.segment === undefined
482
+ const probe: CardState = {
483
+ ...state,
484
+ phase: 'error',
485
+ reasoning: main ? PANEL_PROBE_MARKDOWN : '',
486
+ }
487
+ const limit = fitPrefix(
488
+ state.body,
489
+ CARD_TARGET_BYTES,
490
+ content => cardJsonBytes(renderCard(probe, options, probe.reasoning, content)),
491
+ ).length
492
+ return splitBodyForCard(state.body, limit)
493
+ }
346
494
 
347
- // 2. The body, against what is left of the hard cap once the panel's real
348
- // bytes are counted. The suffix reserve keeps a clipped body sendable.
349
- const body = clipText(state.body, TRUNCATION_SUFFIX, content => cardJsonBytes(render('', content)),
350
- CARD_MAX_BYTES - panelBytes(reasoning) - SUFFIX_JSON_BYTES)
351
- return render(reasoning, body)
495
+ /** Card-variant options for one segment (`0` is the main card). */
496
+ export function segmentCardOptions(index: number): CardOptions {
497
+ return index === 0 ? {} : { segment: index + 1 }
352
498
  }
353
499
 
354
500
  /** Interruption notice body for cards repaired after a restart mid-turn. */
@@ -365,60 +511,19 @@ export const INTERRUPTION_NOTICE = '⚠️ 服务已重启,本次回复中断
365
511
  */
366
512
  export function buildInterruptionCard(options: CardOptions = {}): object {
367
513
  const elements: object[] = []
368
- if (options.panel === false) {
369
- elements.push({
370
- tag: 'markdown',
371
- content: `📄 ${options.segment === undefined ? '续上一条卡片' : `回复分段 ${options.segment}`} · 回复中断`,
372
- text_size: 'notation',
373
- })
374
- }
375
- elements.push({ tag: 'markdown', content: INTERRUPTION_NOTICE })
376
- return {
377
- schema: '2.0',
378
- config: { width_mode: 'fill' },
379
- body: { elements },
380
- }
381
- }
382
-
383
- /**
384
- * Largest body prefix (char count) whose card renders within `CARD_TARGET_BYTES`
385
- * minus `CARD_PANEL_RESERVE_BYTES`, reserving the panel's whole footprint for
386
- * the reasoning that travels with the segment. `segmentCardContent` only ever
387
- * segments bodies, so reasoning is never a segment budget input by construction
388
- * — but the panel still occupies the card, so its cap is held back. The probe renders
389
- * at phase `error`, the worst case (its segment header title is the longest),
390
- * so a frozen segment can never outgrow the budget merely by the phase flipping
391
- * 回复中 → 回复失败; stats growth between the split and the final patch consumes
392
- * the 2 KB redundancy (`CARD_MAX_BYTES` − `CARD_TARGET_BYTES`).
393
- */
394
- function largestBodyPrefix(state: CardState, options: CardOptions): number {
395
- const text = state.body
396
- const budget = CARD_TARGET_BYTES - CARD_PANEL_RESERVE_BYTES
397
- // `panel: false` keeps the reserved panel cap out of the measurement itself.
398
- const render = (content: string) =>
399
- assemble('error', '', content, state.stats, { ...options, panel: false }, state.status, state.tool)
400
- if (cardJsonBytes(render(text)) <= budget) return text.length
401
- let lo = 0
402
- let hi = text.length
403
- while (lo < hi) {
404
- const mid = Math.ceil((lo + hi) / 2)
405
- if (cardJsonBytes(render(text.slice(0, mid))) <= budget) {
406
- lo = mid
407
- } else {
408
- hi = mid - 1
409
- }
410
- }
411
- return lo
514
+ if (options.segment !== undefined) elements.push(note(interruptionHeader(options.segment)))
515
+ elements.push(markdown(INTERRUPTION_NOTICE))
516
+ return card(elements)
412
517
  }
413
518
 
414
519
  /**
415
520
  * Split a body at a clean breakpoint so `head` fits one card:
416
521
  * 1. the head holds at most `maxTables` whole markdown tables — the boundary
417
522
  * is capped at the first excess table (Feishu rejects cards with more);
418
- * 2. never inside a fenced code block (pull the boundary back before the fence);
419
- * 3. never inside a table block (a breakpoint that would land inside one is
420
- * moved to its start);
421
- * 4. then the clean-breakpoint tiers: paragraph blank line → `#` heading line
523
+ * 2. never inside a fenced code block or a table block — the boundary is
524
+ * pulled back to the block's start (a block beginning the body is left to
525
+ * the hard-cut escape hatch);
526
+ * 3. then the clean-breakpoint tiers: paragraph blank line → `#` heading line
422
527
  * → line break → hard cut as the escape hatch.
423
528
  */
424
529
  export function splitBodyForCard(body: string, maxChars: number, maxTables: number = MAX_TABLES_PER_CARD): BodySplit {
@@ -427,89 +532,38 @@ export function splitBodyForCard(body: string, maxChars: number, maxTables: numb
427
532
  return { head: body, tail: '' }
428
533
  }
429
534
 
430
- let limit = maxChars
431
-
432
- // 1. Table-count ceiling: the head may hold at most `maxTables` tables, so
433
- // never extend past the boundary right before the first excess table.
434
- const firstExcess = tables[maxTables]
435
- if (firstExcess !== undefined) {
436
- limit = Math.min(limit, firstExcess.start)
437
- }
535
+ // Every range a cut may never land inside: fenced code blocks and tables.
536
+ const blocks = [...codeFenceBlocks(body), ...tables].sort((a, b) => a.start - b.start)
438
537
 
439
- // 2. Never cut inside a fenced code block straddling the ceiling.
440
- const fencePattern = /^[ \t]*```[^\n]*$/gm
441
- let fenceStart = -1
442
- let inside = false
443
- for (const match of body.matchAll(fencePattern)) {
444
- const index = match.index ?? 0
445
- if (!inside) {
446
- if (index >= limit) break
447
- inside = true
448
- fenceStart = index
449
- } else if (index >= limit) {
450
- // The block opened before the ceiling and closes after it: move the
451
- // boundary to just before the opening fence (unless it starts at 0).
452
- if (fenceStart > 0) limit = fenceStart
453
- break
454
- } else {
455
- inside = false
456
- }
457
- }
458
- if (inside && fenceStart > 0 && fenceStart < limit) limit = fenceStart
459
-
460
- // 3. Never cut inside a table block straddling the ceiling (pull the
461
- // boundary back to its start; a table beginning the body is left to the
462
- // hard-cut escape hatch).
463
- for (const block of tables) {
464
- if (block.start >= limit) break
465
- if (block.end > limit) {
466
- if (block.start > 0) limit = block.start
467
- break
468
- }
469
- }
470
-
471
- /** Move a cut position out of any table block (to just before it). */
472
- const avoidTables = (at: number): number => {
473
- for (const block of tables) {
474
- if (block.start >= at) break
475
- if (at <= block.end && block.start > 0) return block.start
476
- }
477
- return at
478
- }
538
+ // 1. Table-count ceiling, then 2. pull the ceiling out of any block that
539
+ // straddles it.
540
+ const limit = avoidProtected(blocks, Math.min(maxChars, tables[maxTables]?.start ?? body.length))
479
541
 
480
542
  const window = body.slice(0, limit)
543
+ // Preferred breakpoints scanning back from the ceiling; only accept one that
544
+ // keeps the card reasonably full, else fall through to the next tier.
545
+ const minFill = Math.floor(limit * 0.7)
546
+
481
547
  const cut = (at: number): BodySplit => ({
482
548
  head: body.slice(0, at).replace(/\s+$/u, ''),
483
549
  tail: body.slice(at).replace(/^\n+/u, ''),
484
550
  })
485
551
 
486
- // Preferred breakpoints scanning back from the ceiling; only accept one that
487
- // keeps the card reasonably full, else fall through to the next tier.
488
- const minFill = Math.floor(limit * 0.7)
489
-
552
+ // 3. Clean-breakpoint tiers, each moved out of a protected block.
490
553
  const paragraph = window.lastIndexOf('\n\n')
491
- if (paragraph >= minFill) return cut(avoidTables(paragraph + 2))
554
+ if (paragraph >= minFill) return cut(avoidProtected(blocks, paragraph + 2))
492
555
 
493
556
  let heading = -1
494
557
  for (const match of window.matchAll(/^#{1,6} .*$/gm)) {
495
558
  const start = match.index ?? 0
496
559
  heading = start === 0 ? 0 : start - 1
497
560
  }
498
- if (heading >= minFill) return cut(avoidTables(heading))
561
+ if (heading >= minFill) return cut(avoidProtected(blocks, heading))
499
562
 
500
563
  const line = window.lastIndexOf('\n')
501
- if (line >= minFill) return cut(avoidTables(line + 1))
564
+ if (line >= minFill) return cut(avoidProtected(blocks, line + 1))
502
565
 
503
- return { head: body.slice(0, avoidTables(limit)), tail: body.slice(avoidTables(limit)) }
504
- }
505
-
506
- /**
507
- * How the accumulated body should be laid out for one card: everything that
508
- * fits (clean breakpoint) plus the remainder for the next segment card.
509
- * `head` re-renders within `CARD_TARGET_BYTES`; the body is never dropped.
510
- */
511
- export function segmentCardContent(state: CardState, options: CardOptions = {}): BodySplit {
512
- return splitBodyForCard(state.body, largestBodyPrefix(state, options))
566
+ return cut(limit)
513
567
  }
514
568
 
515
569
  /**
@@ -526,38 +580,3 @@ export function initialStreamingCard(sessionId: string): object {
526
580
  }
527
581
  return buildCard(state)
528
582
  }
529
-
530
- /**
531
- * One-time onboarding card: what the bot does, how workspaces are scoped, and
532
- * the command cheat sheet. Sent on the first private message of a chat.
533
- */
534
- export function buildOnboardingCard(): object {
535
- return {
536
- schema: '2.0',
537
- config: { width_mode: 'fill' },
538
- body: {
539
- elements: [
540
- { tag: 'markdown', content: '**👋 欢迎使用飞书 DSH**' },
541
- {
542
- tag: 'markdown',
543
- content: '直接发消息即可开始一个会话,我会在话题里流式回复;话题内继续发消息无需再 @ 我。',
544
- },
545
- {
546
- tag: 'markdown',
547
- content: '**工作区**:私聊一人一个;群里每位成员各自一个,互不影响。',
548
- },
549
- {
550
- tag: 'markdown',
551
- content: '**常用命令**:`/help` 命令列表 · `/status` 会话状态 · `/workspace` 工作目录\n'
552
- + '`/effort` 推理强度 · `/model` 模型 · `/permissions` 权限预设\n'
553
- + '`/new` 新建会话 · `/stop` 中断当前回合 · `/history` 历史 · `/export` 导出日志',
554
- },
555
- {
556
- tag: 'markdown',
557
- content: '需要授权或补充信息时,我会发一张卡片,点按钮或直接回复即可。',
558
- text_size: 'notation',
559
- },
560
- ],
561
- },
562
- }
563
- }