@modesto_ma/dsh-channels 0.6.0 → 0.8.0-beta.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@modesto_ma/dsh-channels",
3
- "version": "0.6.0",
3
+ "version": "0.8.0-beta.1",
4
4
  "description": "DeepSeek Harness (DSH) 飞书渠道插件:一个 dsh 实例对应一个飞书机器人,私聊/群聊/话题群 @ 触发会话,话题绑定 session,卡片 2.0 流式回复;Web 左侧栏提供「飞书渠道」状态与常用配置面板",
5
5
  "repository": {
6
6
  "type": "git",
@@ -19,6 +19,9 @@
19
19
  "types": "./src/client/index.ts",
20
20
  "default": "./lib/client.js"
21
21
  },
22
+ "./topics": {
23
+ "types": "./src/topics/contract.ts"
24
+ },
22
25
  "./package.json": "./package.json"
23
26
  },
24
27
  "files": [
@@ -185,12 +185,12 @@ export class AccessController {
185
185
  }
186
186
 
187
187
  /**
188
- * Whether the analyze API may post into `chatId`: an authorized group, or
189
- * any group in `open` access mode.
188
+ * Whether a `feishuTopics` launch may post into `chatId`: an authorized
189
+ * group, or any group in `open` access mode.
190
190
  * @param chatId - target group chat id.
191
191
  * @returns true when the chat is a permitted target.
192
192
  */
193
- mayAnalyzeIn(chatId: string): boolean {
193
+ mayLaunchIn(chatId: string): boolean {
194
194
  if (this.config().accessMode === 'open') return true
195
195
  return this.deps.store.snapshot().groups.has(chatId)
196
196
  }
@@ -11,4 +11,4 @@ export * from './onboarding.ts'
11
11
  export * from './approval.ts'
12
12
  export * from './question.ts'
13
13
  export * from './picker.ts'
14
- export * from './analyze.ts'
14
+ export * from './topic.ts'
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Root card of a topic started through the `feishuTopics` service: the
3
+ * caller's title, an @-mention of the requester followed by the caller's lead
4
+ * sentence, an optional link table, and a status tag (进行中 → 已完成 /
5
+ * 已停止 / 失败 / 已中断) whose wording uses the caller's task name. The work
6
+ * itself streams in the topic below the card through the ordinary streaming
7
+ * card.
8
+ *
9
+ * @module dsh-channels/cards/topic
10
+ */
11
+
12
+ import type { TopicRootCard } from '../topics/contract.ts'
13
+ import { CARD_CONFIG, CARD_SCHEMA, markdown, note } from './primitives.ts'
14
+
15
+ /**
16
+ * Root card status: `running` until the first turn settles (`done`,
17
+ * `stopped`, `error`), `failed` when the topic could not start, or
18
+ * `interrupted` when DSH restarted before the first turn settled.
19
+ */
20
+ export type TopicStatus =
21
+ | { readonly kind: 'running' }
22
+ | { readonly kind: 'done' }
23
+ | { readonly kind: 'stopped' }
24
+ | { readonly kind: 'error' }
25
+ | { readonly kind: 'failed'; readonly reason: string }
26
+ | { readonly kind: 'interrupted' }
27
+
28
+ /** Rows per table page (Feishu caps `page_size` at 10). */
29
+ const TABLE_PAGE_SIZE = 10
30
+
31
+ /** Escape markdown emphasis characters in caller-supplied text. */
32
+ function plain(text: string): string {
33
+ return text.replace(/([*_~`[\]\\])/gu, '\\$1')
34
+ }
35
+
36
+ /** Percent-encode the characters that would end a markdown link target early. */
37
+ function linkTarget(url: string): string {
38
+ return url.replace(/[()\s]/gu, char => `%${char.charCodeAt(0).toString(16).toUpperCase().padStart(2, '0')}`)
39
+ }
40
+
41
+ /** Header tag text, tag color, and header template of each status. */
42
+ function statusLook(status: TopicStatus, task: string): { tag: string; color: string; template: string } {
43
+ switch (status.kind) {
44
+ case 'running': return { tag: `${task}中`, color: 'blue', template: 'blue' }
45
+ case 'done': return { tag: '已完成', color: 'green', template: 'green' }
46
+ case 'stopped': return { tag: '已停止', color: 'neutral', template: 'grey' }
47
+ case 'error':
48
+ case 'failed': return { tag: '失败', color: 'red', template: 'red' }
49
+ case 'interrupted': return { tag: '已中断', color: 'orange', template: 'orange' }
50
+ }
51
+ }
52
+
53
+ /** Footer line of each status. */
54
+ function statusNote(status: TopicStatus, task: string): string {
55
+ switch (status.kind) {
56
+ case 'running': return `${task}过程在本卡片下的话题内实时输出;群内已授权的成员可以在话题内直接回复追问。`
57
+ case 'done': return `${task}已完成,结果见话题;在话题内回复即可继续追问。`
58
+ case 'stopped': return `${task}已停止;在话题内回复即可继续。`
59
+ case 'error': return `${task}回合失败,原因见话题;在话题内回复即可重试或追问。`
60
+ case 'failed': return `${task}未能开始:${plain(status.reason)}`
61
+ case 'interrupted': return `DSH 重启导致${task}中断;在话题内回复即可继续。`
62
+ }
63
+ }
64
+
65
+ /**
66
+ * The topic root card.
67
+ * @param requesterUserId - `user_id` @-mentioned before the lead sentence.
68
+ * @param card - what the caller asked the card to show.
69
+ * @param status - the status the card shows.
70
+ * @returns card JSON.
71
+ */
72
+ export function buildTopicRootCard(requesterUserId: string, card: TopicRootCard, status: TopicStatus): object {
73
+ const task = plain(card.taskName)
74
+ const look = statusLook(status, card.taskName)
75
+ const lead = card.lead.map(run => (run.bold === true ? `**${plain(run.text)}**` : plain(run.text))).join('')
76
+ const elements: object[] = [markdown(`<at id=${requesterUserId}></at> ${lead}`)]
77
+ if (card.links !== undefined && card.links.rows.length > 0) {
78
+ elements.push({
79
+ tag: 'table',
80
+ page_size: TABLE_PAGE_SIZE,
81
+ row_height: 'low',
82
+ header_style: { background_style: 'grey', bold: true, lines: 1 },
83
+ columns: [
84
+ { name: 'name', display_name: card.links.nameHeader, data_type: 'text', width: 'auto' },
85
+ { name: 'link', display_name: card.links.linkHeader, data_type: 'markdown', width: 'auto' },
86
+ ],
87
+ rows: card.links.rows.map(row => ({
88
+ name: row.name,
89
+ link: `[查看](${linkTarget(row.url)})`,
90
+ })),
91
+ })
92
+ }
93
+ elements.push(note(statusNote(status, task)))
94
+ return {
95
+ schema: CARD_SCHEMA,
96
+ config: CARD_CONFIG,
97
+ header: {
98
+ title: { tag: 'plain_text', content: card.title },
99
+ template: look.template,
100
+ text_tag_list: [{ tag: 'text_tag', text: { tag: 'plain_text', content: look.tag }, color: look.color }],
101
+ },
102
+ body: { elements },
103
+ }
104
+ }
@@ -171,7 +171,7 @@ function OriginDetails({ origin, onRefresh }: { origin: SessionOriginWire; onRef
171
171
  </span>
172
172
  )])
173
173
  const heading = originHeading(origin)
174
- if (origin.shared) rows.push(['说明', '日志平台分析话题,群内成员都可以继续'])
174
+ if (origin.shared) rows.push(['说明', '群共享话题,群内成员都可以继续'])
175
175
  rows.push(['开始于', formatTime(origin.createdAt)])
176
176
  rows.push(origin.bound && origin.lastActivityAt !== undefined
177
177
  ? ['最近活跃', formatTime(origin.lastActivityAt)]
@@ -1,48 +1,24 @@
1
1
  /**
2
- * 「高级」tab:日志分析接口(开关 + 访问令牌凭据行)、SDK 日志级别
3
- * 与 Web 覆盖文件(「恢复 profile 配置」删除整个覆盖层,不走 draft)。
2
+ * 「高级」tab:SDK 日志级别与 Web 覆盖文件(「恢复 profile 配置」删除整个
3
+ * 覆盖层,不走 draft)。
4
4
  *
5
5
  * @module dsh-channels/client/panel/advanced-tab
6
6
  */
7
7
 
8
8
  import { fillCopy, PANEL_COPY, TAB_FIELD_GROUPS } from '../../settings/contract.ts'
9
- import { CredentialRow } from './credentials-card.tsx'
10
9
  import { FieldSections, Row, Section } from './shared.tsx'
11
- import type { AnalyzeTokenView, FormView } from './shared.tsx'
10
+ import type { FormView } from './shared.tsx'
12
11
 
13
12
  export function AdvancedTab(props: {
14
13
  form: FormView
15
- analyzeToken: AnalyzeTokenView
16
14
  onClear: () => void
17
15
  }): JSX.Element {
18
- const { form, analyzeToken, onClear } = props
16
+ const { form, onClear } = props
19
17
  const { panel, busy, locked } = form
20
- const tokenState = panel.config.credentials.analyzeToken
21
18
  const overridden = panel.config.overriddenKeys.length
22
19
  return (
23
20
  <div className="dsh-channels-cfg__tabBody">
24
- <FieldSections
25
- form={form}
26
- groups={TAB_FIELD_GROUPS.advanced}
27
- extra={{
28
- analyze: (
29
- <CredentialRow
30
- label={PANEL_COPY.analyzeTokenLabel}
31
- summary={PANEL_COPY.analyzeTokenSummary}
32
- state={tokenState}
33
- value={analyzeToken.value}
34
- secret
35
- placeholder={PANEL_COPY.analyzeTokenPlaceholder}
36
- issue={analyzeToken.issues.find(item => item.key === 'analyzeToken')}
37
- busy={analyzeToken.busy}
38
- disabled={analyzeToken.busy !== null || analyzeToken.locked || !tokenState.writable}
39
- onChange={analyzeToken.onChange}
40
- onSave={analyzeToken.onSave}
41
- onClear={analyzeToken.onClear}
42
- />
43
- ),
44
- }}
45
- />
21
+ <FieldSections form={form} groups={TAB_FIELD_GROUPS.advanced} />
46
22
  <Section title={PANEL_COPY.overlayTitle}>
47
23
  <Row
48
24
  label={PANEL_COPY.overlayPath}
@@ -60,7 +60,6 @@ import {
60
60
  validateDraft,
61
61
  } from './shared.tsx'
62
62
  import type {
63
- AnalyzeTokenView,
64
63
  BusyAction,
65
64
  CredentialsView,
66
65
  FormView,
@@ -95,7 +94,6 @@ function FeishuChannelsPanel(): JSX.Element | null {
95
94
  const [pollError, setPollError] = useState<string | null>(null)
96
95
  const [appIdInput, setAppIdInput] = useState('')
97
96
  const [appSecretInput, setAppSecretInput] = useState('')
98
- const [analyzeTokenInput, setAnalyzeTokenInput] = useState('')
99
97
  const [catalog, setCatalog] = useState<CatalogWire | null>(null)
100
98
  const [catalogBusy, setCatalogBusy] = useState(false)
101
99
  const [accessNotice, setAccessNotice] = useState<string | null>(null)
@@ -122,7 +120,6 @@ function FeishuChannelsPanel(): JSX.Element | null {
122
120
  setDraft({})
123
121
  setAppIdInput('')
124
122
  setAppSecretInput('')
125
- setAnalyzeTokenInput('')
126
123
  } catch (error) {
127
124
  setLoad({ phase: 'error', message: messageOf(error) })
128
125
  }
@@ -573,28 +570,6 @@ function FeishuChannelsPanel(): JSX.Element | null {
573
570
  }
574
571
  }, [appIdInput, appSecretInput])
575
572
 
576
- /** Save the analyze API token; it applies to the next request, no reconnect. */
577
- const onSaveAnalyzeToken = useCallback(async (): Promise<void> => {
578
- if (analyzeTokenInput.trim() === '') return
579
- setBusy('credentials')
580
- setNotice(null)
581
- try {
582
- const result = await saveCredentials({ analyzeToken: analyzeTokenInput })
583
- if (!result.ok) {
584
- setNotice({ kind: 'error', text: result.error, issues: result.issues })
585
- return
586
- }
587
- setLoad({ phase: 'ready', panel: result.panel })
588
- setStatus(result.panel.status)
589
- setAnalyzeTokenInput('')
590
- setNotice({ kind: 'ok', text: PANEL_COPY.analyzeTokenSaved })
591
- } catch (error) {
592
- setNotice({ kind: 'error', text: messageOf(error) })
593
- } finally {
594
- setBusy(null)
595
- }
596
- }, [analyzeTokenInput])
597
-
598
573
  const onClearCredential = useCallback(async (field: CredentialField): Promise<void> => {
599
574
  const confirmed = typeof window.confirm === 'function'
600
575
  ? window.confirm(PANEL_COPY.credentialRemoveConfirm)
@@ -612,9 +587,7 @@ function FeishuChannelsPanel(): JSX.Element | null {
612
587
  setStatus(result.panel.status)
613
588
  setNotice({
614
589
  kind: 'ok',
615
- text: field === 'analyzeToken'
616
- ? PANEL_COPY.analyzeTokenRemoved
617
- : result.reconnect === 'pending' ? PANEL_COPY.credentialRemovedPending : PANEL_COPY.credentialRemoved,
590
+ text: result.reconnect === 'pending' ? PANEL_COPY.credentialRemovedPending : PANEL_COPY.credentialRemoved,
618
591
  })
619
592
  } catch (error) {
620
593
  setNotice({ kind: 'error', text: messageOf(error) })
@@ -676,16 +649,6 @@ function FeishuChannelsPanel(): JSX.Element | null {
676
649
  onClear: (field: CredentialField) => { void onClearCredential(field) },
677
650
  }), [appIdInput, appSecretInput, busy, hostBusy, issues, onClearCredential, onSaveCredentials])
678
651
 
679
- const analyzeToken = useMemo<AnalyzeTokenView>(() => ({
680
- value: analyzeTokenInput,
681
- issues,
682
- busy,
683
- locked: hostBusy,
684
- onChange: setAnalyzeTokenInput,
685
- onSave: () => { void onSaveAnalyzeToken() },
686
- onClear: () => { void onClearCredential('analyzeToken') },
687
- }), [analyzeTokenInput, busy, hostBusy, issues, onClearCredential, onSaveAnalyzeToken])
688
-
689
652
  if (load.phase === 'loading') {
690
653
  return (
691
654
  <div className="dsh-channels-cfg">
@@ -777,7 +740,7 @@ function FeishuChannelsPanel(): JSX.Element | null {
777
740
  )
778
741
  : null}
779
742
  {activeTab === 'advanced'
780
- ? <AdvancedTab form={form} analyzeToken={analyzeToken} onClear={() => { void onClear() }} />
743
+ ? <AdvancedTab form={form} onClear={() => { void onClear() }} />
781
744
  : null}
782
745
  </div>
783
746
  <SaveBar
@@ -162,18 +162,6 @@ export interface CredentialsView {
162
162
  onClear: (field: CredentialField) => void
163
163
  }
164
164
 
165
- /** Analyze API token editor data owned by the page shell, rendered by the advanced tab. */
166
- export interface AnalyzeTokenView {
167
- value: string
168
- issues: FieldIssueWire[]
169
- busy: BusyAction | null
170
- /** Host-side lifecycle busy (connecting/restarting): block writes. */
171
- locked: boolean
172
- onChange: (value: string) => void
173
- onSave: () => void
174
- onClear: () => void
175
- }
176
-
177
165
  /** Everything a field-bearing tab needs from the page shell's single draft. */
178
166
  export interface FormView {
179
167
  panel: PanelWire
package/src/config.ts CHANGED
@@ -80,8 +80,26 @@ export const DEFAULT_TOPIC_MESSAGES_TIMEOUT_MS = 20_000
80
80
  */
81
81
  export const DEFAULT_PROMPT_CONTEXT_TIMEOUT_MS = 3_000
82
82
 
83
- /** Conventional credential reference holding the log-platform analyze API bearer token. */
84
- export const DEFAULT_ANALYZE_TOKEN_REF = 'DSH_CHANNELS_ANALYZE_TOKEN'
83
+ /**
84
+ * Keys of the log-platform analyze API, which moved to 观象
85
+ * (`@modesto_ma/dsh-guanxiang`). A value still set in `cordis.yml` or the Web
86
+ * overlay is ignored; the loader schema passes them through and
87
+ * {@link movedAnalyzeKeysOf} finds them so the deployment is told.
88
+ */
89
+ export const MOVED_ANALYZE_KEYS = ['analyzeEnabled', 'analyzeToken'] as const
90
+
91
+ /** Log line naming where the moved analyze API settings went. */
92
+ export const MOVED_ANALYZE_NOTICE = 'dsh-channels: 日志分析接口已迁到观象(@modesto_ma/dsh-guanxiang),忽略 %s:请在观象面板「分析接口」中开启并保存令牌(GUANXIANG_ANALYZE_TOKEN)'
93
+
94
+ /**
95
+ * The moved analyze API keys one raw config or overlay object still sets.
96
+ * @param raw - undecoded `config` or overlay JSON.
97
+ * @returns the keys present, in {@link MOVED_ANALYZE_KEYS} order.
98
+ */
99
+ export function movedAnalyzeKeysOf(raw: unknown): string[] {
100
+ if (typeof raw !== 'object' || raw === null) return []
101
+ return MOVED_ANALYZE_KEYS.filter(key => Object.hasOwn(raw, key))
102
+ }
85
103
 
86
104
  /** A credential reference: `FEISHU_APP_ID`-style name resolved through ctx.credentials. */
87
105
  const credentialRefSchema = z.string().regex(
@@ -421,18 +439,6 @@ export const configSchema = z.object({
421
439
  * its own deadline and is left to the host signal instead.
422
440
  */
423
441
  questionTimeoutMs: z.number().int().min(10_000).max(3_600_000).default(DEFAULT_QUESTION_TIMEOUT_MS),
424
- /**
425
- * Switch for the log-platform analyze API (`POST /api/dsh-channels.logalyze`
426
- * on the DSH Web server). Off (default) answers 404; read per request, so
427
- * toggling it needs no reconnect. Needs a host with a Web server.
428
- */
429
- analyzeEnabled: z.boolean().default(false),
430
- /**
431
- * Credential reference holding the analyze API bearer token (defaults to
432
- * `DSH_CHANNELS_ANALYZE_TOKEN`). Resolved per request, so rotating the value
433
- * applies to the next request; an unresolvable token refuses every request.
434
- */
435
- analyzeToken: credentialRefSchema.default(DEFAULT_ANALYZE_TOKEN_REF),
436
442
  })
437
443
 
438
444
  export type FeishuConfig = z.infer<typeof configSchema>
@@ -442,9 +448,13 @@ export type FeishuConfig = z.infer<typeof configSchema>
442
448
  * `config` object; the loader validates that row with `undefined` before the
443
449
  * user's patch layer supplies values. Accept that absence and fill the same
444
450
  * defaults as {@link resolveConfig}, so enabling the plugin from the Web UI
445
- * cannot fail with "expected object, received undefined".
451
+ * cannot fail with "expected object, received undefined". The moved analyze
452
+ * keys pass through unchanged so `apply()` can warn about them.
446
453
  */
447
- export const pluginConfigSchema = configSchema.prefault({})
454
+ export const pluginConfigSchema = configSchema.extend({
455
+ analyzeEnabled: z.unknown().optional(),
456
+ analyzeToken: z.unknown().optional(),
457
+ }).prefault({})
448
458
 
449
459
  /**
450
460
  * Validate the raw loader config. Values stay credential *references*; the
package/src/domain.ts CHANGED
@@ -9,6 +9,7 @@
9
9
  */
10
10
 
11
11
  import { z } from 'zod'
12
+ import type { TopicRootCard } from './topics/contract.ts'
12
13
 
13
14
  /**
14
15
  * Route key kinds. `thread` = real Feishu topic; `chat` = degraded chat-scoped
@@ -62,7 +63,7 @@ export const routeRecordSchema = z.object({
62
63
  /** Sessions this topic has pointed at, oldest first. Optional for v1 compatibility. */
63
64
  sessionHistory: z.array(sessionHistoryEntrySchema).optional(),
64
65
  /**
65
- * Group-shared route (log-platform analysis topics): every member admitted
66
+ * Group-shared route (topics started through `feishuTopics`): every member admitted
66
67
  * in `chatId` may continue the Session and decide its cards, not only
67
68
  * `senderUserId` (who then names the requester). Absent = owner-only.
68
69
  */
@@ -287,44 +288,100 @@ export const processedRecordSchema = z.object({
287
288
 
288
289
  export type ProcessedRecord = z.infer<typeof processedRecordSchema>
289
290
 
291
+ /** {@link TopicRootCard} as stored in a launch record. */
292
+ export const topicRootCardSchema = z.object({
293
+ title: z.string(),
294
+ taskName: z.string(),
295
+ lead: z.array(z.object({ text: z.string(), bold: z.boolean().optional() })),
296
+ links: z.object({
297
+ nameHeader: z.string(),
298
+ linkHeader: z.string(),
299
+ rows: z.array(z.object({ name: z.string(), url: z.string() })),
300
+ }).optional(),
301
+ })
302
+
290
303
  /**
291
- * One accepted analyze API request, keyed by its `Idempotency-Key`. Kept
292
- * forever: a retried key returns the stored response instead of posting a
293
- * second analysis, and a reused key with a different body is refused.
304
+ * Root card content written by plugin versions before the `feishuTopics`
305
+ * service (the built-in log-platform analyze API). Read only; converted by
306
+ * {@link rootCardOf}.
294
307
  */
295
- export const analyzeRequestRecordSchema = z.object({
296
- /** The request's `Idempotency-Key` (also the table key). */
308
+ const legacyAnalyzeSubjectSchema = z.object({
309
+ requesterUserId: z.string(),
310
+ triggeredAt: z.string(),
311
+ app: z.string(),
312
+ errorCode: z.string(),
313
+ logDetails: z.array(z.object({ name: z.string(), url: z.string() })),
314
+ })
315
+
316
+ /**
317
+ * One accepted `feishuTopics` launch, keyed by its idempotency key. Kept
318
+ * forever: a retried key answers the stored root card instead of posting a
319
+ * second topic, and a reused key with another fingerprint is refused. The
320
+ * records live in the `analyze_requests` table, named after the analyze API
321
+ * that wrote the first of them; those older records carry `subject` instead
322
+ * of `requesterUserId` + `card`, and their keys are the analyze API's
323
+ * `Idempotency-Key`s.
324
+ */
325
+ export const topicLaunchRecordSchema = z.object({
326
+ /** The launch's idempotency key (also the table key). */
297
327
  key: z.string().min(1),
298
- /** SHA-256 (hex) of the canonical request body. */
328
+ /** The launch's fingerprint (older records: SHA-256 of the analyze request). */
299
329
  bodyHash: z.string().min(1),
300
330
  /** Target group chat. */
301
331
  chatId: z.string().min(1),
302
- /** Root card message id (the analysis topic's root). */
332
+ /** Root card message id (the topic's root). */
303
333
  messageId: z.string().min(1),
304
- /** Epoch ms when the request was accepted. */
334
+ /** Epoch ms when the launch was accepted. */
305
335
  acceptedAt: z.number().int().nonnegative(),
306
- /** What the root card shows, kept so the card can be redrawn after a restart. */
307
- subject: z.object({
308
- requesterUserId: z.string(),
309
- triggeredAt: z.string(),
310
- app: z.string(),
311
- errorCode: z.string(),
312
- logDetails: z.array(z.object({ name: z.string(), url: z.string() })),
313
- }),
336
+ /** Requester @-mentioned on the root card; absent on older records. */
337
+ requesterUserId: z.string().optional(),
338
+ /** What the root card shows, kept so the card can be redrawn after a restart; absent on older records. */
339
+ card: topicRootCardSchema.optional(),
340
+ /** Root card content of older records. */
341
+ subject: legacyAnalyzeSubjectSchema.optional(),
314
342
  /**
315
343
  * Root card status. `running` until the first turn settles; a record still
316
- * `running` when the channel starts belongs to an analysis the previous
344
+ * `running` when the channel starts belongs to a topic the previous
317
345
  * instance never finished, and becomes `interrupted`.
318
346
  */
319
347
  status: z.enum(['running', 'done', 'stopped', 'error', 'failed', 'interrupted']),
320
348
  })
321
349
 
322
- export type AnalyzeRequestRecord = z.infer<typeof analyzeRequestRecordSchema>
350
+ export type TopicLaunchRecord = z.infer<typeof topicLaunchRecordSchema>
351
+
352
+ /**
353
+ * The requester and root card content of one launch record, converting the
354
+ * analyze API's older records to the card that API drew.
355
+ * @param record - stored launch record.
356
+ * @returns what to redraw, or undefined for a record carrying neither form.
357
+ */
358
+ export function rootCardOf(record: TopicLaunchRecord): { requesterUserId: string; card: TopicRootCard } | undefined {
359
+ if (record.card !== undefined && record.requesterUserId !== undefined) {
360
+ return { requesterUserId: record.requesterUserId, card: record.card }
361
+ }
362
+ const subject = record.subject
363
+ if (subject === undefined) return undefined
364
+ return {
365
+ requesterUserId: subject.requesterUserId,
366
+ card: {
367
+ title: '日志分析',
368
+ taskName: '分析',
369
+ lead: [
370
+ { text: `于 ${subject.triggeredAt} 发起了对 ` },
371
+ { text: subject.app, bold: true },
372
+ { text: ' 应用 ' },
373
+ { text: subject.errorCode, bold: true },
374
+ { text: ' 错误码不良日志的分析。' },
375
+ ],
376
+ links: { nameHeader: '日志', linkHeader: '详情页', rows: subject.logDetails },
377
+ },
378
+ }
379
+ }
323
380
 
324
381
  /**
325
382
  * Where one Feishu-routed Session came from, keyed by its DSH session id.
326
383
  * Written the first time a route record names the Session (new topic,
327
- * `/renew`, analyze API) and kept after the route retires, so the Web
384
+ * `/renew`, `feishuTopics` launch) and kept after the route retires, so the Web
328
385
  * client can still show the origin of an old Session. Names are resolved
329
386
  * live when read, never stored.
330
387
  */
@@ -339,11 +396,11 @@ export const sessionOriginRecordSchema = z.object({
339
396
  chatId: z.string().min(1),
340
397
  /** `p2p` or `group`. */
341
398
  chatType: z.enum(['p2p', 'group']),
342
- /** `user_id` of the user who started the Session (the requester for analyze topics). */
399
+ /** `user_id` of the user who started the Session (the requester for launched topics). */
343
400
  senderUserId: z.string().min(1),
344
401
  /** Epoch ms when the Session was bound to the route. */
345
402
  createdAt: z.number().int().nonnegative(),
346
- /** Group-shared route (log-platform analysis topic). */
403
+ /** Group-shared route (topic started through `feishuTopics`). */
347
404
  shared: z.literal(true).optional(),
348
405
  })
349
406
 
@@ -390,7 +447,7 @@ export interface FeishuDomainSpecShape {
390
447
  __key?: string
391
448
  }
392
449
  analyze_requests: {
393
- valueSchema: typeof analyzeRequestRecordSchema
450
+ valueSchema: typeof topicLaunchRecordSchema
394
451
  __key?: string
395
452
  }
396
453
  session_origins: {
@@ -437,8 +494,8 @@ export const feishuDomain: FeishuDomainSpecShape = {
437
494
  // independent grant. Added without a version bump like the other access
438
495
  // tables: a missing table loads empty.
439
496
  access_wiki_users: { valueSchema: accessUserRecordSchema },
440
- // Analyze API idempotency records; a missing table loads empty.
441
- analyze_requests: { valueSchema: analyzeRequestRecordSchema },
497
+ // `feishuTopics` launch records (historical name); a missing table loads empty.
498
+ analyze_requests: { valueSchema: topicLaunchRecordSchema },
442
499
  // Session id → Feishu origin; a missing table loads empty.
443
500
  session_origins: { valueSchema: sessionOriginRecordSchema },
444
501
  },
@@ -507,11 +564,11 @@ export interface AccessRequestsTable {
507
564
  entries(): IterableIterator<[string, AccessRequestRecord]>
508
565
  }
509
566
 
510
- /** Normalized table handle for analyze API requests (key = `Idempotency-Key`). */
511
- export interface AnalyzeRequestsTable {
512
- get(key: string): AnalyzeRequestRecord | undefined
513
- put(key: string, value: AnalyzeRequestRecord): Promise<unknown> | void
514
- entries(): IterableIterator<[string, AnalyzeRequestRecord]>
567
+ /** Normalized table handle for `feishuTopics` launches (key = idempotency key). */
568
+ export interface TopicLaunchesTable {
569
+ get(key: string): TopicLaunchRecord | undefined
570
+ put(key: string, value: TopicLaunchRecord): Promise<unknown> | void
571
+ entries(): IterableIterator<[string, TopicLaunchRecord]>
515
572
  }
516
573
 
517
574
  /** Normalized table handle for the session-origin table (key = DSH session id). */
@@ -531,8 +588,8 @@ export interface FeishuDomainHandle {
531
588
  accessRequests: AccessRequestsTable
532
589
  /** Wiki whitelist (key = `user_id`); same records as {@link AccessUsersTable}. */
533
590
  accessWikiUsers: AccessUsersTable
534
- /** Analyze API idempotency records. */
535
- analyzeRequests: AnalyzeRequestsTable
591
+ /** `feishuTopics` launch records. */
592
+ topicLaunches: TopicLaunchesTable
536
593
  /** Feishu origin of every Session a route ever named. */
537
594
  sessionOrigins: SessionOriginsTable
538
595
  getGlobal(): FeishuDomainGlobal
package/src/host/core.ts CHANGED
@@ -317,10 +317,39 @@ export interface AgentPresetSelectedEvent extends SessionEventEnvelope {
317
317
  data: { agentPreset: string }
318
318
  }
319
319
 
320
+ /**
321
+ * Durable record of one model-request retry scheduled by `dsh-llm-retry`
322
+ * (DSH `packages/llm/llm-retry/src/types.ts`), appended before the backoff
323
+ * wait. The retry re-runs the failed step inside the SAME open turn: only
324
+ * `turn/end` ends the turn.
325
+ */
326
+ export interface LlmRetryEvent extends SessionEventEnvelope {
327
+ type: 'llm/retry'
328
+ data: {
329
+ turn: number
330
+ step: number
331
+ provider: string
332
+ mode: 'normal' | 'always'
333
+ /** 1-based retry number. */
334
+ retry: number
335
+ /** Retry budget in `normal` mode; absent in `always` mode (unbounded). */
336
+ maxRetries?: number
337
+ delayMs: number
338
+ failure: { message: string; code: string; [key: string]: unknown }
339
+ [key: string]: unknown
340
+ }
341
+ }
342
+
343
+ /** Appended when a retry's backoff wait ends, right before the retried request starts. */
344
+ export interface LlmRetryStartedEvent extends SessionEventEnvelope {
345
+ type: 'llm/retry-started'
346
+ data: { turn: number; step: number; retry: number; [key: string]: unknown }
347
+ }
348
+
320
349
  export interface OtherSessionEvent extends SessionEventEnvelope {
321
350
  /** Unknown/extension event types are ignored by the folds. */
322
351
  type: Exclude<string, 'assistant/message' | 'step/start' | 'turn/end' | 'request/header'
323
- | 'tool/call' | 'tool/result' | 'subagent/descriptor'
352
+ | 'tool/call' | 'tool/result' | 'subagent/descriptor' | 'llm/retry' | 'llm/retry-started'
324
353
  | 'command/run' | 'command/done'
325
354
  | 'tool-workflow/run-start' | 'tool-workflow/agent-start' | 'tool-workflow/agent-end' | 'tool-workflow/run-end'>
326
355
  data: unknown
@@ -342,4 +371,6 @@ export type SessionEvent =
342
371
  | ToolWorkflowAgentStartEvent
343
372
  | ToolWorkflowAgentEndEvent
344
373
  | ToolWorkflowRunEndEvent
374
+ | LlmRetryEvent
375
+ | LlmRetryStartedEvent
345
376
 
@@ -288,6 +288,11 @@ export interface HostContext {
288
288
  logger?: HostLogger
289
289
  /** Safe service lookup (returns undefined for absent services). */
290
290
  get?(name: string): unknown
291
+ /**
292
+ * Provide a service owned by this plugin's fiber; it is withdrawn when the
293
+ * plugin unloads. Optional so test contexts may omit it.
294
+ */
295
+ provide?(name: string, value: unknown): () => void
291
296
  /** Host events this plugin subscribes to (overloads per event). */
292
297
  on(name: 'session/event', listener: (session: Session, event: SessionEvent) => void): () => boolean
293
298
  /** Live (transient) model stream for every agent; replaces the removed `assistant/chunk` events. */