@modesto_ma/dsh-channels 0.0.0-stage → 0.1.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 (73) hide show
  1. package/README.md +302 -2
  2. package/cordis.patch.yml +3 -0
  3. package/docs/access-control-design.md +550 -0
  4. package/docs/assets/web-settings-panel.png +0 -0
  5. package/docs/assets/web-settings-restart-pending.png +0 -0
  6. package/docs/feasibility-and-design.md +213 -0
  7. package/docs/feishu-onboarding.md +268 -0
  8. package/docs/persistence.md +199 -0
  9. package/docs/refactor-plan.md +56 -0
  10. package/docs/review-dsh-0.2.0.md +389 -0
  11. package/docs/slash-commands-implementation-plan.md +410 -0
  12. package/docs/web-display-only-surface-roadmap.md +363 -0
  13. package/lib/client.js +2226 -0
  14. package/lib/client.js.map +1 -0
  15. package/lib/index.js +10217 -0
  16. package/lib/index.js.map +1 -0
  17. package/package.json +98 -4
  18. package/src/access/cards.ts +188 -0
  19. package/src/access/controller.ts +201 -0
  20. package/src/access/index.ts +13 -0
  21. package/src/access/policy.ts +103 -0
  22. package/src/access/requests.ts +512 -0
  23. package/src/access/store.ts +220 -0
  24. package/src/approvals.ts +8 -0
  25. package/src/cards/approval.ts +114 -0
  26. package/src/cards/index.ts +11 -0
  27. package/src/cards/primitives.ts +82 -0
  28. package/src/cards/question.ts +141 -0
  29. package/src/cards/streaming.ts +563 -0
  30. package/src/cards.ts +8 -0
  31. package/src/cleanup.ts +398 -0
  32. package/src/client/api.ts +262 -0
  33. package/src/client/catalog.ts +92 -0
  34. package/src/client/index.ts +61 -0
  35. package/src/client/panel.tsx +1475 -0
  36. package/src/client/styles.ts +98 -0
  37. package/src/commands/registry.ts +155 -0
  38. package/src/commands.ts +335 -0
  39. package/src/config.ts +206 -0
  40. package/src/domain.ts +428 -0
  41. package/src/dsh-types.ts +11 -0
  42. package/src/feishu/agent-context.ts +62 -0
  43. package/src/feishu/delivery.ts +188 -0
  44. package/src/feishu/inbound-attachments.ts +436 -0
  45. package/src/feishu-channel.ts +339 -0
  46. package/src/health.ts +233 -0
  47. package/src/host/agent.ts +181 -0
  48. package/src/host/commands.ts +63 -0
  49. package/src/host/core.ts +345 -0
  50. package/src/host/index.ts +12 -0
  51. package/src/host/interaction.ts +101 -0
  52. package/src/host/services.ts +320 -0
  53. package/src/index.ts +122 -0
  54. package/src/interactions/approvals.ts +154 -0
  55. package/src/interactions/pending.ts +162 -0
  56. package/src/interactions/questions.ts +274 -0
  57. package/src/questions.ts +8 -0
  58. package/src/routing/inbound.ts +524 -0
  59. package/src/service.ts +2138 -0
  60. package/src/session/manager.ts +352 -0
  61. package/src/session/repair.ts +157 -0
  62. package/src/session/request-policy.ts +192 -0
  63. package/src/session/routes.ts +165 -0
  64. package/src/session-commands.ts +310 -0
  65. package/src/session-export.ts +112 -0
  66. package/src/settings/catalog.ts +309 -0
  67. package/src/settings/contract.ts +508 -0
  68. package/src/settings/overlay.ts +164 -0
  69. package/src/settings/routes.ts +409 -0
  70. package/src/settings/surface.ts +773 -0
  71. package/src/stats.ts +485 -0
  72. package/src/streamer.ts +1108 -0
  73. package/src/workspaces.ts +199 -0
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Shared lifecycle of a pending interaction card.
3
+ *
4
+ * Both HITL surfaces (approvals and `ask_user_question`) need the same
5
+ * mechanics: a token-keyed registry, owner-gated card-action lookup, delivery
6
+ * with a withdrawn-race guard, a local timeout, settle-once cleanup, and
7
+ * shutdown withdrawal. Only the rendered card and the resolution semantics
8
+ * differ, so those stay in the surface modules and everything else lives here.
9
+ *
10
+ * @module dsh-channels/interactions/pending
11
+ */
12
+
13
+ import type { CardActionEvent, FeishuChannel } from '../feishu-channel.ts'
14
+
15
+ /** Where one session's interaction cards are placed on Feishu. */
16
+ export interface InteractionRoute {
17
+ /** Feishu chat the cards live in. */
18
+ chatId: string
19
+ /** Reply anchor: the session's latest streaming card message id. */
20
+ cardMessageId: string
21
+ /** Whether the anchor lives in a real topic (replyInThread). */
22
+ inThread: boolean
23
+ /** The session owner (open_id) — the only operator allowed to decide. */
24
+ senderOpenId: string
25
+ }
26
+
27
+ /** Fields every pending interaction card carries. */
28
+ export interface PendingCardEntry {
29
+ /** Opaque token binding the card to one pending interaction. */
30
+ readonly token: string
31
+ /** DSH session the interaction belongs to (routing + error logs). */
32
+ readonly sessionId: string
33
+ /** Feishu placement of this card. */
34
+ readonly route: InteractionRoute
35
+ /** Card message id once it lands; undefined while sending. */
36
+ messageId: string | undefined
37
+ /** Settle-once guard: a late click/signal/timeout is discarded. */
38
+ settled: boolean
39
+ /** Local timeout handle (absent for host-owned deadlines). */
40
+ timer: ReturnType<typeof setTimeout> | undefined
41
+ /** The ask's own abort signal, when it has one. */
42
+ readonly signal: AbortSignal | undefined
43
+ /** Listener registered for that signal, removed on close. */
44
+ onAbort: (() => void) | undefined
45
+ /** The owner-only notice was already patched onto the card. */
46
+ unauthorizedNoted: boolean
47
+ }
48
+
49
+ export interface DeliverHandlers<E extends PendingCardEntry> {
50
+ /** The pending card to send as a topic/thread reply. */
51
+ card: object
52
+ /** The card landed and its message id is recorded (arm timeouts here). */
53
+ onDelivered?(entry: E): void
54
+ /** The request withdrew while the card was in flight. */
55
+ onWithdrawn(entry: E): void
56
+ /** Delivery failed: fail the request closed. */
57
+ onFailure(entry: E, error: unknown): void
58
+ }
59
+
60
+ /**
61
+ * The pending-card table: registry, delivery, owner gating, and cleanup.
62
+ * Concrete surfaces supply rendering and the terminal action.
63
+ */
64
+ export class PendingCardTable<E extends PendingCardEntry> {
65
+ private readonly entries = new Map<string, E>()
66
+
67
+ constructor(
68
+ private readonly channel: FeishuChannel,
69
+ private readonly onError: (sessionId: string, error: unknown) => void,
70
+ ) {}
71
+
72
+ add(entry: E): void {
73
+ this.entries.set(entry.token, entry)
74
+ }
75
+
76
+ count(): number {
77
+ return this.entries.size
78
+ }
79
+
80
+ /** Every pending entry (diagnostics and inbound text routing). */
81
+ values(): IterableIterator<E> {
82
+ return this.entries.values()
83
+ }
84
+
85
+ /**
86
+ * Owner-gated lookup for one card action, containing the owner-only notice.
87
+ * Returns `undefined` for unknown cards, settled entries, and non-owner
88
+ * clicks (which call `onUnauthorized` once).
89
+ */
90
+ resolveAction(event: CardActionEvent, onUnauthorized: (entry: E) => void): E | undefined {
91
+ let entry: E | undefined
92
+ for (const candidate of this.entries.values()) {
93
+ if (candidate.messageId === event.messageId) {
94
+ entry = candidate
95
+ break
96
+ }
97
+ }
98
+ if (entry === undefined || entry.settled) return undefined
99
+ if (event.operator.openId !== entry.route.senderOpenId) {
100
+ onUnauthorized(entry)
101
+ return undefined
102
+ }
103
+ return entry
104
+ }
105
+
106
+ /** Send the pending card and contain every outcome. */
107
+ async deliver(entry: E, handlers: DeliverHandlers<E>): Promise<void> {
108
+ try {
109
+ const res = await this.channel.send(
110
+ entry.route.chatId,
111
+ { card: handlers.card },
112
+ { replyTo: entry.route.cardMessageId, replyInThread: entry.route.inThread },
113
+ )
114
+ // Record the id before the withdrawn check: a request that settled
115
+ // while the card was in flight still needs its card patched terminal.
116
+ entry.messageId = res.messageId
117
+ if (entry.settled) {
118
+ handlers.onWithdrawn(entry)
119
+ return
120
+ }
121
+ handlers.onDelivered?.(entry)
122
+ } catch (error) {
123
+ this.onError(entry.sessionId, error)
124
+ handlers.onFailure(entry, error)
125
+ }
126
+ }
127
+
128
+ /** Full-replacement patch of one delivered card; failures stay contained. */
129
+ patch(entry: E, card: object): void {
130
+ if (entry.messageId === undefined) return
131
+ void this.channel.updateCard(entry.messageId, card)
132
+ .catch((error: unknown) => { this.onError(entry.sessionId, error) })
133
+ }
134
+
135
+ /** Arm the local deadline for a request the host does not time itself. */
136
+ armTimeout(entry: E, ms: number, onTimeout: () => void): void {
137
+ entry.timer = setTimeout(onTimeout, ms)
138
+ entry.timer.unref?.()
139
+ }
140
+
141
+ /**
142
+ * Close one entry exactly once: clear the timer and abort listener, drop it
143
+ * from the registry, then run the terminal action (usually a card patch).
144
+ * @returns false when it was already settled.
145
+ */
146
+ close(entry: E, terminal: () => void): boolean {
147
+ if (entry.settled) return false
148
+ entry.settled = true
149
+ if (entry.timer !== undefined) clearTimeout(entry.timer)
150
+ if (entry.signal !== undefined && entry.onAbort !== undefined) {
151
+ entry.signal.removeEventListener('abort', entry.onAbort)
152
+ }
153
+ this.entries.delete(entry.token)
154
+ terminal()
155
+ return true
156
+ }
157
+
158
+ /** Withdraw every pending entry (plugin shutdown). */
159
+ withdrawAll(withdraw: (entry: E) => void): void {
160
+ for (const entry of [...this.entries.values()]) withdraw(entry)
161
+ }
162
+ }
@@ -0,0 +1,274 @@
1
+ /**
2
+ * HITL structured-question answerer: turns the host's
3
+ * `user-questions/request` waterfall events (the model's `ask_user_question`
4
+ * tool) into standalone Feishu cards.
5
+ *
6
+ * The host's only shipped answerer is the Web client, so a Feishu-only
7
+ * session otherwise fails every `ask_user_question` call with `NO_PROVIDER`.
8
+ * This answerer is registered with `prepend: true` on the plugin context:
9
+ * requests for a session this channel routes are claimed here, everything else
10
+ * delegates through `next()`.
11
+ *
12
+ * Rendering lives in `cards/question.ts`; the shared pending-card lifecycle
13
+ * lives in `interactions/pending.ts`.
14
+ *
15
+ * Deliberately unsupported in v1: questions that declare no options (a card
16
+ * cannot ask for them); such a request fails with an explicit error instead of
17
+ * hanging.
18
+ *
19
+ * @module dsh-channels/interactions/questions
20
+ */
21
+
22
+ import { randomUUID } from 'node:crypto'
23
+ import { buildQuestionCard, buildQuestionSettledCard } from '../cards/question.ts'
24
+ import type { QuestionCardInput, QuestionCardItem, QuestionCardState } from '../cards/question.ts'
25
+ import type { AskUserQuestionAnswer, AskUserQuestionItem, AskUserQuestionRequestEvent } from '../dsh-types.ts'
26
+ import type { CardActionEvent, FeishuChannel } from '../feishu-channel.ts'
27
+ import { PendingCardTable } from './pending.ts'
28
+ import type { InteractionRoute, PendingCardEntry } from './pending.ts'
29
+
30
+ export interface QuestionHooks {
31
+ /**
32
+ * Resolve the Feishu placement for a session. `undefined` means the session
33
+ * is not owned by this channel — the answerer delegates through `next()`.
34
+ */
35
+ routeFor(sessionId: string): InteractionRoute | undefined
36
+ /** Log a non-fatal error (delivery/update failures). */
37
+ onError(sessionId: string, error: unknown): void
38
+ }
39
+
40
+ /** One in-flight question set: card content, partial answers, resolver. */
41
+ interface PendingQuestions extends PendingCardEntry {
42
+ questions: QuestionCardItem[]
43
+ /** Multi-select toggles not yet submitted, by question id. */
44
+ selected: Map<string, string[]>
45
+ /** Committed answers, by question id. */
46
+ answered: Map<string, string[]>
47
+ /** Free-text answers supplied by a plain reply, by question id. */
48
+ custom: Map<string, string>
49
+ /** Host owns the deadline (timed wait): no local timer is armed. */
50
+ hostTimed: boolean
51
+ resolve(answer: AskUserQuestionAnswer): void
52
+ reject(error: unknown): void
53
+ }
54
+
55
+ /** Normalize one host question into its renderable form. */
56
+ function normalizeQuestion(item: AskUserQuestionItem): QuestionCardItem {
57
+ const intent = item.intent
58
+ return {
59
+ id: item.id,
60
+ question: item.question,
61
+ ...(item.detail === undefined ? {} : { detail: item.detail }),
62
+ ...(item.header === undefined ? {} : { header: item.header }),
63
+ options: (item.options ?? []).map(option => ({
64
+ label: option.label,
65
+ ...(option.description === undefined ? {} : { description: option.description }),
66
+ })),
67
+ multiSelect: item.multiSelect === true,
68
+ ...(intent?.kind === 'plan-review' ? { intentApproved: intent.approve } : {}),
69
+ }
70
+ }
71
+
72
+ export class QuestionAnswerer {
73
+ private readonly table: PendingCardTable<PendingQuestions>
74
+
75
+ constructor(
76
+ channel: FeishuChannel,
77
+ private readonly hooks: QuestionHooks,
78
+ private readonly timeoutMs: number,
79
+ ) {
80
+ this.table = new PendingCardTable(channel, hooks.onError)
81
+ }
82
+
83
+ /** In-flight question-set count (diagnostics/tests). */
84
+ pendingCount(): number {
85
+ return this.table.count()
86
+ }
87
+
88
+ /**
89
+ * The `user-questions/request` waterfall listener. Non-owned sessions and
90
+ * questions without options delegate through `next()`.
91
+ */
92
+ onRequest(
93
+ request: AskUserQuestionRequestEvent,
94
+ next: () => Promise<AskUserQuestionAnswer>,
95
+ ): Promise<AskUserQuestionAnswer> {
96
+ const agent = request.agent
97
+ if (agent === undefined) return next()
98
+ const route = this.hooks.routeFor(agent.session.id)
99
+ if (route === undefined) return next()
100
+
101
+ const questions = request.questions.map(normalizeQuestion)
102
+ if (questions.length === 0) return next()
103
+
104
+ const token = randomUUID()
105
+ return new Promise<AskUserQuestionAnswer>((resolve, reject) => {
106
+ const entry: PendingQuestions = {
107
+ token,
108
+ sessionId: agent.session.id,
109
+ route,
110
+ messageId: undefined,
111
+ settled: false,
112
+ timer: undefined,
113
+ signal: request.signal,
114
+ onAbort: undefined,
115
+ unauthorizedNoted: false,
116
+ questions,
117
+ selected: new Map(),
118
+ answered: new Map(),
119
+ custom: new Map(),
120
+ hostTimed: request.wait?.timed === true,
121
+ resolve,
122
+ reject,
123
+ }
124
+ const onAbort = (): void => {
125
+ const reason = request.signal?.reason
126
+ entry.reject(reason instanceof Error ? reason : new Error('dsh-channels: 提问已被取消。'))
127
+ this.close(entry, 'cancelled')
128
+ }
129
+ entry.onAbort = onAbort
130
+ if (request.signal?.aborted === true) {
131
+ reject(new Error('dsh-channels: 提问已被取消。'))
132
+ return
133
+ }
134
+ request.signal?.addEventListener('abort', onAbort, { once: true })
135
+ this.table.add(entry)
136
+ void this.table.deliver(entry, {
137
+ card: buildQuestionCard(this.cardInput(entry)),
138
+ onWithdrawn: e => this.table.patch(e, buildQuestionSettledCard(this.cardInput(e), 'cancelled')),
139
+ // A timed wait is owned by the host: its signal settles the request at
140
+ // the deadline (rejection with ASK_TIMED_OUT becomes a continued
141
+ // question). Only blocking asks need a local timer.
142
+ onDelivered: e => {
143
+ if (e.hostTimed) return
144
+ this.table.armTimeout(e, this.timeoutMs, () => {
145
+ e.reject(new Error('dsh-channels: ask_user_question 等待超时,未收到回答。'))
146
+ this.close(e, 'expired')
147
+ })
148
+ },
149
+ onFailure: (e, error) => {
150
+ this.table.close(e, () => {})
151
+ e.reject(error instanceof Error ? error : new Error(String(error)))
152
+ },
153
+ })
154
+ })
155
+ }
156
+
157
+ /** Snapshot the current selections for rendering. */
158
+ private cardInput(entry: PendingQuestions): QuestionCardInput {
159
+ const selected: Record<string, string[]> = {}
160
+ for (const [id, labels] of entry.selected) selected[id] = [...labels]
161
+ return {
162
+ token: entry.token,
163
+ questions: entry.questions,
164
+ timeoutMs: this.timeoutMs,
165
+ sessionId: entry.sessionId,
166
+ selected,
167
+ answered: [...entry.answered.keys()],
168
+ ...(entry.unauthorizedNoted ? { unauthorizedNote: true } : {}),
169
+ }
170
+ }
171
+
172
+ /**
173
+ * Route one card button-callback event to its pending question set.
174
+ * Unknown cards and non-owner operators are ignored (a non-owner click adds
175
+ * the owner-only notice once); owner decisions settle the request.
176
+ */
177
+ onCardAction(event: CardActionEvent): void {
178
+ const entry = this.table.resolveAction(event, candidate => {
179
+ if (candidate.unauthorizedNoted) return
180
+ candidate.unauthorizedNoted = true
181
+ this.table.patch(candidate, buildQuestionCard(this.cardInput(candidate)))
182
+ })
183
+ if (entry === undefined) return
184
+ const value = event.action.value as
185
+ | { decision?: unknown; id?: unknown; value?: unknown }
186
+ | undefined
187
+ const decision = value?.decision
188
+ const id = typeof value?.id === 'string' ? value.id : undefined
189
+ const option = typeof value?.value === 'string' ? value.value : undefined
190
+ if (id === undefined) return
191
+ const question = entry.questions.find(item => item.id === id)
192
+ if (question === undefined || entry.answered.has(id)) return
193
+
194
+ if (decision === 'question-answer' && option !== undefined) {
195
+ entry.answered.set(id, [option])
196
+ this.table.patch(entry, buildQuestionCard(this.cardInput(entry)))
197
+ this.resolveIfComplete(entry)
198
+ return
199
+ }
200
+ if (decision === 'question-toggle' && option !== undefined) {
201
+ const current = new Set(entry.selected.get(id) ?? [])
202
+ if (current.has(option)) current.delete(option)
203
+ else current.add(option)
204
+ entry.selected.set(id, [...current])
205
+ this.table.patch(entry, buildQuestionCard(this.cardInput(entry)))
206
+ return
207
+ }
208
+ if (decision === 'question-submit') {
209
+ const chosen = entry.selected.get(id) ?? []
210
+ if (chosen.length === 0) return
211
+ entry.answered.set(id, [...chosen])
212
+ this.table.patch(entry, buildQuestionCard(this.cardInput(entry)))
213
+ this.resolveIfComplete(entry)
214
+ }
215
+ }
216
+
217
+ /** Complete the waterfall once every question has a committed answer. */
218
+ private resolveIfComplete(entry: PendingQuestions): void {
219
+ if (entry.questions.some(question => !entry.answered.has(question.id))) return
220
+ const answer: AskUserQuestionAnswer = {
221
+ answers: entry.questions.map(question => ({
222
+ id: question.id,
223
+ selected: entry.answered.get(question.id) ?? [],
224
+ ...(entry.custom.has(question.id) ? { custom: entry.custom.get(question.id) } : {}),
225
+ })),
226
+ }
227
+ const closed = this.table.close(entry, () => {
228
+ this.table.patch(entry, buildQuestionSettledCard(this.cardInput(entry), 'answered'))
229
+ })
230
+ if (closed) entry.resolve(answer)
231
+ }
232
+
233
+ /**
234
+ * Feed one plain reply as a free-text answer.
235
+ *
236
+ * A session blocked on `ask_user_question` has no other use for an inbound
237
+ * text message, so the next unanswered question consumes it as its "Other"
238
+ * answer. Returns false when the session has no pending question.
239
+ */
240
+ submitText(sessionId: string, text: string): boolean {
241
+ const trimmed = text.trim()
242
+ if (trimmed === '') return false
243
+ const entry = [...this.table.values()].find(candidate =>
244
+ candidate.sessionId === sessionId && !candidate.settled)
245
+ if (entry === undefined) return false
246
+ const question = entry.questions.find(candidate => !entry.answered.has(candidate.id))
247
+ if (question === undefined) return false
248
+ entry.answered.set(question.id, [])
249
+ entry.custom.set(question.id, trimmed)
250
+ this.table.patch(entry, buildQuestionCard(this.cardInput(entry)))
251
+ this.resolveIfComplete(entry)
252
+ return true
253
+ }
254
+
255
+ /** Whether a session currently has an unanswered question set. */
256
+ hasPendingFor(sessionId: string): boolean {
257
+ return [...this.table.values()].some(candidate => candidate.sessionId === sessionId && !candidate.settled)
258
+ }
259
+
260
+ /** Withdraw every in-flight question (plugin shutdown). */
261
+ dispose(): void {
262
+ this.table.withdrawAll(entry => {
263
+ entry.reject(new Error('dsh-channels: 插件已关闭,提问未完成。'))
264
+ this.close(entry, 'cancelled')
265
+ })
266
+ }
267
+
268
+ /** Settle once and patch the card to its terminal state. */
269
+ private close(entry: PendingQuestions, state: QuestionCardState): void {
270
+ this.table.close(entry, () => {
271
+ this.table.patch(entry, buildQuestionSettledCard(this.cardInput(entry), state))
272
+ })
273
+ }
274
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Public barrel for the structured-question answerer (implementation lives in
3
+ * `src/interactions/questions.ts`).
4
+ *
5
+ * @module dsh-channels/questions
6
+ */
7
+
8
+ export * from './interactions/questions.ts'