dsh-agora-plugin 0.6.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 (79) hide show
  1. package/README.md +520 -0
  2. package/client/index.js +492 -0
  3. package/cordis.patch.yml +14 -0
  4. package/dsh.plugin.json +7 -0
  5. package/lib/agora-client.d.ts +73 -0
  6. package/lib/agora-client.d.ts.map +1 -0
  7. package/lib/agora-client.js +207 -0
  8. package/lib/agora-client.js.map +1 -0
  9. package/lib/client.js +492 -0
  10. package/lib/command-adapter.d.ts +44 -0
  11. package/lib/command-adapter.d.ts.map +1 -0
  12. package/lib/command-adapter.js +70 -0
  13. package/lib/command-adapter.js.map +1 -0
  14. package/lib/command.d.ts +53 -0
  15. package/lib/command.d.ts.map +1 -0
  16. package/lib/command.js +375 -0
  17. package/lib/command.js.map +1 -0
  18. package/lib/context-types.d.ts +51 -0
  19. package/lib/context-types.d.ts.map +1 -0
  20. package/lib/context-types.js +2 -0
  21. package/lib/context-types.js.map +1 -0
  22. package/lib/contracts.d.ts +402 -0
  23. package/lib/contracts.d.ts.map +1 -0
  24. package/lib/contracts.js +2 -0
  25. package/lib/contracts.js.map +1 -0
  26. package/lib/extension-sdk.d.ts +74 -0
  27. package/lib/extension-sdk.d.ts.map +1 -0
  28. package/lib/extension-sdk.js +160 -0
  29. package/lib/extension-sdk.js.map +1 -0
  30. package/lib/harness-runtime.d.ts +29 -0
  31. package/lib/harness-runtime.d.ts.map +1 -0
  32. package/lib/harness-runtime.js +422 -0
  33. package/lib/harness-runtime.js.map +1 -0
  34. package/lib/http-api.d.ts +10 -0
  35. package/lib/http-api.d.ts.map +1 -0
  36. package/lib/http-api.js +282 -0
  37. package/lib/http-api.js.map +1 -0
  38. package/lib/im-bridge-v1.d.ts +37 -0
  39. package/lib/im-bridge-v1.d.ts.map +1 -0
  40. package/lib/im-bridge-v1.js +43 -0
  41. package/lib/im-bridge-v1.js.map +1 -0
  42. package/lib/im-gateway.d.ts +24 -0
  43. package/lib/im-gateway.d.ts.map +1 -0
  44. package/lib/im-gateway.js +53 -0
  45. package/lib/im-gateway.js.map +1 -0
  46. package/lib/index.d.ts +46 -0
  47. package/lib/index.d.ts.map +1 -0
  48. package/lib/index.js +210 -0
  49. package/lib/index.js.map +1 -0
  50. package/lib/node-worker.d.ts +52 -0
  51. package/lib/node-worker.d.ts.map +1 -0
  52. package/lib/node-worker.js +372 -0
  53. package/lib/node-worker.js.map +1 -0
  54. package/lib/service.d.ts +51 -0
  55. package/lib/service.d.ts.map +1 -0
  56. package/lib/service.js +196 -0
  57. package/lib/service.js.map +1 -0
  58. package/lib/tool.d.ts +72 -0
  59. package/lib/tool.d.ts.map +1 -0
  60. package/lib/tool.js +206 -0
  61. package/lib/tool.js.map +1 -0
  62. package/package.json +74 -0
  63. package/patches/dsh-im/@xmanrui__dsh-im@2.1.0.patch +920 -0
  64. package/patches/dsh-im/@xmanrui__dsh-im@2.3.0.patch +984 -0
  65. package/patches/dsh-im/README.md +82 -0
  66. package/src/agora-client.ts +333 -0
  67. package/src/command-adapter.ts +106 -0
  68. package/src/command.ts +424 -0
  69. package/src/context-types.ts +54 -0
  70. package/src/contracts.ts +413 -0
  71. package/src/extension-sdk.ts +225 -0
  72. package/src/harness-runtime.ts +518 -0
  73. package/src/http-api.ts +269 -0
  74. package/src/im-bridge-v1.ts +73 -0
  75. package/src/im-gateway.ts +82 -0
  76. package/src/index.ts +260 -0
  77. package/src/node-worker.ts +442 -0
  78. package/src/service.ts +262 -0
  79. package/src/tool.ts +269 -0
@@ -0,0 +1,82 @@
1
+ # Versioned dsh-im Agora bridge patches
2
+
3
+ This directory contains reviewed pnpm patches for exact versions of the third-party `@xmanrui/dsh-im` package.
4
+
5
+ | Package | Patch |
6
+ | --- | --- |
7
+ | `@xmanrui/dsh-im@2.1.0` | `@xmanrui__dsh-im@2.1.0.patch` |
8
+ | `@xmanrui/dsh-im@2.3.0` | `@xmanrui__dsh-im@2.3.0.patch` |
9
+
10
+ Each patch adds the public, token-free `dsh-im.bridge/v1` Cordis service used by `dsh-agora`:
11
+
12
+ - safe Bot identity, connectivity, and capability discovery;
13
+ - DSH Session to IM conversation/thread route observation;
14
+ - idempotent proactive delivery through a selected Bot;
15
+ - Discord outbound delivery without accepting Bot-authored inbound messages.
16
+
17
+ The patches include a rebuilt upstream `lib/index.js`, so a target host does not need dsh-im development dependencies.
18
+
19
+ ## Version rule
20
+
21
+ Apply a patch only to the exact package version in its filename. A dsh-im upgrade requires regenerating, reviewing, and testing a new patch. pnpm intentionally rejects a version mismatch.
22
+
23
+ ## Installation
24
+
25
+ Pin dsh-im first, for example:
26
+
27
+ ```bash
28
+ dsh plugin --profile web add @xmanrui/dsh-im@2.3.0
29
+ ```
30
+
31
+ Copy the matching patch to the DSH profile:
32
+
33
+ ```bash
34
+ PROFILE="${DSH_HOME:-$HOME/.dsh}/profiles/web"
35
+ mkdir -p "$PROFILE/patches"
36
+ cp @xmanrui__dsh-im@2.3.0.patch "$PROFILE/patches/"
37
+ ```
38
+
39
+ Merge the matching declaration into `$PROFILE/pnpm-workspace.yaml` without removing existing `allowBuilds` or other patches:
40
+
41
+ ```yaml
42
+ patchedDependencies:
43
+ '@xmanrui/dsh-im@2.3.0': patches/@xmanrui__dsh-im@2.3.0.patch
44
+ ```
45
+
46
+ Apply it and make the lockfile reproducible:
47
+
48
+ ```bash
49
+ pnpm --dir "$PROFILE" install
50
+ pnpm --dir "$PROFILE" install --frozen-lockfile
51
+ ```
52
+
53
+ Restart DSH, then verify the local snapshot:
54
+
55
+ ```bash
56
+ curl -fsS -X POST http://127.0.0.1:3080/dsh-agora/api/snapshot \
57
+ -H 'content-type: application/json' \
58
+ -d '{}'
59
+ ```
60
+
61
+ Expected bridge state:
62
+
63
+ ```text
64
+ imBridge.state: connected
65
+ imBridge.protocol: dsh-im.bridge/v1
66
+ ```
67
+
68
+ `dsh-im.command-gateway/v1` is a separate optional interface and is not added by these patches. Seeing `im.state: unavailable` together with a connected bridge is expected.
69
+
70
+ ## Rebuilding a patch for a new dsh-im version
71
+
72
+ Use pnpm's patch workflow in an isolated profile or test project:
73
+
74
+ ```bash
75
+ pnpm patch @xmanrui/dsh-im@<version>
76
+ # edit and rebuild the extracted package
77
+ pnpm patch-commit <path-printed-by-pnpm>
78
+ ```
79
+
80
+ Review the resulting source and bundled diff, run the dsh-im build/package checks, apply it to a clean exact-version install, and run a real bridge smoke test before adding the artifact here.
81
+
82
+ Do not disable pnpm's build-script policy globally. If a reviewed dependency needs an install script, add only the exact package key under `allowBuilds`.
@@ -0,0 +1,333 @@
1
+ import type {
2
+ AgoraHealth,
3
+ AgoraTask,
4
+ AgoraTaskStatus,
5
+ AgentScorecard,
6
+ CoordinationRun,
7
+ CoordinationRunStatus,
8
+ CreateCoordinationRunInput,
9
+ CreateAgoraTaskInput,
10
+ CreateRuntimeDispatchInput,
11
+ RuntimeDispatch,
12
+ RecordRuntimeDispatchProgressInput,
13
+ RuntimeDispatchProgress,
14
+ RuntimeResultEnvelope,
15
+ RuntimeDelivery,
16
+ RuntimeNode,
17
+ RuntimeNodeHeartbeatInput,
18
+ RuntimeSessionBinding,
19
+ RuntimeTarget,
20
+ } from './contracts.js'
21
+
22
+ export interface AgoraClientOptions {
23
+ readonly serverUrl: string
24
+ readonly apiToken?: string | undefined
25
+ readonly timeoutMs?: number | undefined
26
+ readonly fetch?: typeof globalThis.fetch | undefined
27
+ }
28
+
29
+ export class AgoraApiError extends Error {
30
+ constructor(
31
+ message: string,
32
+ readonly status: number,
33
+ readonly path: string,
34
+ readonly detail: unknown,
35
+ ) {
36
+ super(message)
37
+ this.name = 'AgoraApiError'
38
+ }
39
+ }
40
+
41
+ export class AgoraClient {
42
+ readonly serverUrl: string
43
+ private readonly apiToken: string | undefined
44
+ private readonly timeoutMs: number
45
+ private readonly fetchImpl: typeof globalThis.fetch
46
+
47
+ constructor(options: AgoraClientOptions) {
48
+ const url = new URL(options.serverUrl)
49
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
50
+ throw new TypeError('Agora serverUrl must use http or https')
51
+ }
52
+ this.serverUrl = url.toString().replace(/\/$/u, '')
53
+ this.apiToken = nonEmpty(options.apiToken)
54
+ this.timeoutMs = normalizeTimeout(options.timeoutMs)
55
+ this.fetchImpl = options.fetch ?? globalThis.fetch
56
+ }
57
+
58
+ health(signal?: AbortSignal): Promise<AgoraHealth> {
59
+ return this.request('/api/health', { signal })
60
+ }
61
+
62
+ listTasks(state?: string, projectId?: string, signal?: AbortSignal): Promise<AgoraTask[]> {
63
+ const query = new URLSearchParams()
64
+ if (nonEmpty(state)) query.set('state', state!.trim())
65
+ if (nonEmpty(projectId)) query.set('project_id', projectId!.trim())
66
+ const suffix = query.size === 0 ? '' : `?${query.toString()}`
67
+ return this.request(`/api/tasks${suffix}`, { signal })
68
+ }
69
+
70
+ getTask(taskId: string, signal?: AbortSignal): Promise<AgoraTask> {
71
+ return this.request(`/api/tasks/${encodeURIComponent(requireValue(taskId, 'task id'))}`, { signal })
72
+ }
73
+
74
+ taskStatus(taskId: string, signal?: AbortSignal): Promise<AgoraTaskStatus> {
75
+ return this.request(`/api/tasks/${encodeURIComponent(requireValue(taskId, 'task id'))}/status`, { signal })
76
+ }
77
+
78
+ createTask(input: CreateAgoraTaskInput, signal?: AbortSignal): Promise<AgoraTask> {
79
+ const title = requireValue(input.title, 'task title')
80
+ const body = {
81
+ title,
82
+ type: nonEmpty(input.type) ?? 'general',
83
+ creator: nonEmpty(input.creator) ?? 'dsh',
84
+ description: input.description ?? '',
85
+ priority: input.priority ?? 'normal',
86
+ locale: input.locale ?? 'zh-CN',
87
+ ...(nonEmpty(input.projectId) ? { project_id: input.projectId!.trim() } : {}),
88
+ ...(input.imTarget === undefined ? {} : { im_target: compactImTarget(input.imTarget) }),
89
+ }
90
+ return this.request('/api/tasks', { method: 'POST', body, signal })
91
+ }
92
+
93
+ heartbeatRuntimeNode(nodeId: string, input: RuntimeNodeHeartbeatInput, signal?: AbortSignal): Promise<RuntimeNode> {
94
+ return this.request(`/api/runtime-nodes/${encodeURIComponent(requireValue(nodeId, 'node id'))}/heartbeat`, {
95
+ method: 'PUT', body: input, signal,
96
+ })
97
+ }
98
+
99
+ listRuntimeNodes(signal?: AbortSignal): Promise<RuntimeNode[]> {
100
+ return this.request<{ nodes: RuntimeNode[] }>('/api/runtime-nodes', { signal }).then(value => value.nodes)
101
+ }
102
+
103
+ listRuntimeTargets(signal?: AbortSignal): Promise<RuntimeTarget[]> {
104
+ return this.request<{ runtime_targets: RuntimeTarget[] }>('/api/runtime-targets', { signal }).then(value => value.runtime_targets)
105
+ }
106
+
107
+ createRuntimeDispatch(nodeId: string, input: CreateRuntimeDispatchInput, signal?: AbortSignal): Promise<RuntimeDispatch> {
108
+ return this.request(`/api/runtime-nodes/${encodeURIComponent(requireValue(nodeId, 'node id'))}/dispatches`, {
109
+ method: 'POST', body: input, signal,
110
+ })
111
+ }
112
+
113
+ getRuntimeDispatch(dispatchId: string, signal?: AbortSignal): Promise<RuntimeDispatch> {
114
+ return this.request(`/api/runtime-dispatches/${encodeURIComponent(requireValue(dispatchId, 'dispatch id'))}`, { signal })
115
+ }
116
+
117
+ listRuntimeDispatchProgress(dispatchId: string, signal?: AbortSignal): Promise<RuntimeDispatchProgress[]> {
118
+ return this.request<{ events: RuntimeDispatchProgress[] }>(
119
+ `/api/runtime-dispatches/${encodeURIComponent(requireValue(dispatchId, 'dispatch id'))}/progress`,
120
+ { signal },
121
+ ).then(value => value.events)
122
+ }
123
+
124
+ createCoordinationRun(input: CreateCoordinationRunInput, signal?: AbortSignal): Promise<CoordinationRun> {
125
+ return this.request('/api/coordination-runs', { method: 'POST', body: input, signal })
126
+ }
127
+
128
+ getCoordinationRun(runId: string, signal?: AbortSignal): Promise<CoordinationRun> {
129
+ return this.request(`/api/coordination-runs/${encodeURIComponent(requireValue(runId, 'coordination run id'))}`, { signal })
130
+ }
131
+
132
+ listCoordinationRuns(status?: CoordinationRunStatus, signal?: AbortSignal): Promise<CoordinationRun[]> {
133
+ const suffix = status ? `?status=${encodeURIComponent(status)}` : ''
134
+ return this.request<{ runs: CoordinationRun[] }>(`/api/coordination-runs${suffix}`, { signal }).then(value => value.runs)
135
+ }
136
+
137
+ listAgentScorecards(taskType?: string, signal?: AbortSignal): Promise<AgentScorecard[]> {
138
+ const suffix = nonEmpty(taskType) ? `?task_type=${encodeURIComponent(taskType!.trim())}` : ''
139
+ return this.request<{ scorecards: AgentScorecard[] }>(`/api/agent-scorecards${suffix}`, { signal }).then(value => value.scorecards)
140
+ }
141
+
142
+ claimRuntimeDispatch(nodeId: string, instanceId: string, leaseSeconds: number, signal?: AbortSignal): Promise<RuntimeDispatch | null> {
143
+ return this.request<{ dispatch: RuntimeDispatch | null }>(
144
+ `/api/runtime-nodes/${encodeURIComponent(requireValue(nodeId, 'node id'))}/dispatches/claim`,
145
+ { method: 'POST', body: { instance_id: instanceId, lease_seconds: leaseSeconds }, signal },
146
+ ).then(value => value.dispatch)
147
+ }
148
+
149
+ renewRuntimeDispatch(
150
+ nodeId: string,
151
+ dispatchId: string,
152
+ instanceId: string,
153
+ claimToken: string,
154
+ leaseSeconds: number,
155
+ signal?: AbortSignal,
156
+ ): Promise<RuntimeDispatch> {
157
+ return this.request(
158
+ `/api/runtime-nodes/${encodeURIComponent(requireValue(nodeId, 'node id'))}/dispatches/${encodeURIComponent(requireValue(dispatchId, 'dispatch id'))}/renew`,
159
+ {
160
+ method: 'POST',
161
+ body: {
162
+ instance_id: requireValue(instanceId, 'instance id'),
163
+ claim_token: requireValue(claimToken, 'claim token'),
164
+ lease_seconds: leaseSeconds,
165
+ },
166
+ signal,
167
+ },
168
+ )
169
+ }
170
+
171
+ recordRuntimeDispatchProgress(
172
+ nodeId: string,
173
+ dispatchId: string,
174
+ input: RecordRuntimeDispatchProgressInput,
175
+ signal?: AbortSignal,
176
+ ): Promise<RuntimeDispatchProgress> {
177
+ return this.request(
178
+ `/api/runtime-nodes/${encodeURIComponent(requireValue(nodeId, 'node id'))}/dispatches/${encodeURIComponent(requireValue(dispatchId, 'dispatch id'))}/progress`,
179
+ { method: 'POST', body: input, signal },
180
+ )
181
+ }
182
+
183
+ completeRuntimeDispatch(
184
+ nodeId: string,
185
+ dispatchId: string,
186
+ input: {
187
+ readonly instance_id: string
188
+ readonly claim_token: string
189
+ readonly status: 'completed' | 'failed'
190
+ readonly session_id?: string | null
191
+ readonly result?: Readonly<Record<string, unknown>> | null
192
+ readonly result_envelope?: RuntimeResultEnvelope | null
193
+ readonly error?: string | null
194
+ readonly delivery_payload?: Readonly<Record<string, unknown>> | null
195
+ },
196
+ signal?: AbortSignal,
197
+ ): Promise<RuntimeDispatch> {
198
+ return this.request(
199
+ `/api/runtime-nodes/${encodeURIComponent(requireValue(nodeId, 'node id'))}/dispatches/${encodeURIComponent(requireValue(dispatchId, 'dispatch id'))}/complete`,
200
+ { method: 'POST', body: input, signal },
201
+ )
202
+ }
203
+
204
+ claimRuntimeDelivery(
205
+ nodeId: string,
206
+ instanceId: string,
207
+ leaseSeconds: number,
208
+ signal?: AbortSignal,
209
+ ): Promise<RuntimeDelivery | null> {
210
+ return this.request<{ delivery: RuntimeDelivery | null }>(
211
+ `/api/runtime-nodes/${encodeURIComponent(requireValue(nodeId, 'node id'))}/deliveries/claim`,
212
+ { method: 'POST', body: { instance_id: instanceId, lease_seconds: leaseSeconds }, signal },
213
+ ).then(value => value.delivery)
214
+ }
215
+
216
+ completeRuntimeDelivery(
217
+ nodeId: string,
218
+ deliveryId: string,
219
+ input:
220
+ | { readonly instance_id: string; readonly claim_token: string; readonly status: 'delivered'; readonly receipt?: Readonly<Record<string, unknown>> | null }
221
+ | { readonly instance_id: string; readonly claim_token: string; readonly status: 'retry'; readonly error: string; readonly retry_delay_seconds: number }
222
+ | { readonly instance_id: string; readonly claim_token: string; readonly status: 'failed'; readonly error: string },
223
+ signal?: AbortSignal,
224
+ ): Promise<RuntimeDelivery> {
225
+ return this.request(
226
+ `/api/runtime-nodes/${encodeURIComponent(requireValue(nodeId, 'node id'))}/deliveries/${encodeURIComponent(requireValue(deliveryId, 'delivery id'))}/complete`,
227
+ { method: 'POST', body: input, signal },
228
+ )
229
+ }
230
+
231
+ bindRuntimeSession(
232
+ taskId: string,
233
+ participantBindingId: string,
234
+ sessionId: string,
235
+ agentRef?: string,
236
+ signal?: AbortSignal,
237
+ ): Promise<RuntimeSessionBinding> {
238
+ return this.request(
239
+ `/api/tasks/${encodeURIComponent(requireValue(taskId, 'task id'))}/runtime-session-bindings/${encodeURIComponent(requireValue(participantBindingId, 'participant binding id'))}`,
240
+ {
241
+ method: 'PUT',
242
+ body: {
243
+ runtime_provider: 'dsh',
244
+ runtime_session_ref: requireValue(sessionId, 'session id'),
245
+ runtime_actor_ref: nonEmpty(agentRef) ?? null,
246
+ continuity_ref: `dsh:${requireValue(sessionId, 'session id')}`,
247
+ presence_state: 'active',
248
+ binding_reason: 'runtime_node_dispatch',
249
+ },
250
+ signal,
251
+ },
252
+ )
253
+ }
254
+
255
+ async request<T>(
256
+ path: string,
257
+ options: {
258
+ readonly method?: string | undefined
259
+ readonly body?: unknown
260
+ readonly signal?: AbortSignal | undefined
261
+ } = {},
262
+ ): Promise<T> {
263
+ const timeoutSignal = AbortSignal.timeout(this.timeoutMs)
264
+ const signal = options.signal === undefined
265
+ ? timeoutSignal
266
+ : AbortSignal.any([options.signal, timeoutSignal])
267
+ const headers: Record<string, string> = { Accept: 'application/json' }
268
+ if (options.body !== undefined) headers['Content-Type'] = 'application/json'
269
+ if (this.apiToken !== undefined) headers.Authorization = `Bearer ${this.apiToken}`
270
+
271
+ const response = await this.fetchImpl(`${this.serverUrl}${path}`, {
272
+ method: options.method ?? 'GET',
273
+ headers,
274
+ ...(options.body === undefined ? {} : { body: JSON.stringify(options.body) }),
275
+ signal,
276
+ })
277
+ const text = await response.text()
278
+ const payload = parsePayload(text)
279
+ if (!response.ok) {
280
+ const detail = errorDetail(payload, text)
281
+ throw new AgoraApiError(`Agora API ${response.status} at ${path}: ${detail}`, response.status, path, payload)
282
+ }
283
+ return payload as T
284
+ }
285
+ }
286
+
287
+ function compactImTarget(target: NonNullable<CreateAgoraTaskInput['imTarget']>): Record<string, unknown> {
288
+ return {
289
+ ...(nonEmpty(target.provider) ? { provider: target.provider!.trim() } : {}),
290
+ ...(nonEmpty(target.conversation_ref) ? { conversation_ref: target.conversation_ref!.trim() } : {}),
291
+ ...(nonEmpty(target.thread_ref) ? { thread_ref: target.thread_ref!.trim() } : {}),
292
+ ...(target.visibility === undefined ? {} : { visibility: target.visibility }),
293
+ ...(target.participant_refs === undefined ? {} : { participant_refs: [...target.participant_refs] }),
294
+ }
295
+ }
296
+
297
+ function normalizeTimeout(value: number | undefined): number {
298
+ const timeout = value ?? 10_000
299
+ if (!Number.isSafeInteger(timeout) || timeout < 100 || timeout > 300_000) {
300
+ throw new TypeError('requestTimeoutMs must be an integer between 100 and 300000')
301
+ }
302
+ return timeout
303
+ }
304
+
305
+ function requireValue(value: string, label: string): string {
306
+ const normalized = nonEmpty(value)
307
+ if (normalized === undefined) throw new TypeError(`${label} is required`)
308
+ return normalized
309
+ }
310
+
311
+ function nonEmpty(value: string | undefined): string | undefined {
312
+ const normalized = value?.trim()
313
+ return normalized === '' ? undefined : normalized
314
+ }
315
+
316
+ function parsePayload(text: string): unknown {
317
+ if (text.trim() === '') return {}
318
+ try {
319
+ return JSON.parse(text) as unknown
320
+ } catch (error) {
321
+ throw new SyntaxError(`Agora returned invalid JSON: ${error instanceof Error ? error.message : String(error)}`)
322
+ }
323
+ }
324
+
325
+ function errorDetail(payload: unknown, fallback: string): string {
326
+ if (typeof payload === 'object' && payload !== null) {
327
+ const record = payload as Record<string, unknown>
328
+ for (const key of ['detail', 'message', 'error']) {
329
+ if (typeof record[key] === 'string' && record[key].trim() !== '') return record[key]
330
+ }
331
+ }
332
+ return fallback.trim() || 'request failed'
333
+ }
@@ -0,0 +1,106 @@
1
+ import type { AgoraCommandResult, AgoraRequestContext } from './contracts.js'
2
+ import type { DshImBridgeV1 } from './im-bridge-v1.js'
3
+
4
+ export const DSH_AGORA_COMMAND_ADAPTER_PROTOCOL = 'dsh-agora.command-adapter/v1' as const
5
+
6
+ export interface DshAgoraCommandEventV1 {
7
+ readonly protocol: typeof DSH_AGORA_COMMAND_ADAPTER_PROTOCOL
8
+ readonly idempotency_key: string
9
+ readonly input: string
10
+ readonly actor_ref: string
11
+ readonly provider?: string | null
12
+ readonly conversation_ref?: string | null
13
+ readonly thread_ref?: string | null
14
+ readonly reply?: {
15
+ readonly enabled: boolean
16
+ readonly bot_ref?: string | null
17
+ } | null
18
+ readonly metadata?: Readonly<Record<string, unknown>> | null
19
+ }
20
+
21
+ export interface DshAgoraCommandEventResultV1 {
22
+ readonly protocol: typeof DSH_AGORA_COMMAND_ADAPTER_PROTOCOL
23
+ readonly idempotency_key: string
24
+ readonly result: AgoraCommandResult
25
+ readonly delivery: {
26
+ readonly sent: boolean
27
+ readonly provider_message_refs: readonly string[]
28
+ readonly reason?: string
29
+ }
30
+ }
31
+
32
+ export interface DshAgoraCommandAdapterV1 {
33
+ readonly protocol: typeof DSH_AGORA_COMMAND_ADAPTER_PROTOCOL
34
+ ingest(event: DshAgoraCommandEventV1, signal?: AbortSignal): Promise<DshAgoraCommandEventResultV1>
35
+ }
36
+
37
+ export class DshAgoraCommandAdapter implements DshAgoraCommandAdapterV1 {
38
+ readonly protocol = DSH_AGORA_COMMAND_ADAPTER_PROTOCOL
39
+ private readonly receipts = new Map<string, Promise<DshAgoraCommandEventResultV1>>()
40
+
41
+ constructor(private readonly options: {
42
+ execute(input: string, context: AgoraRequestContext, signal?: AbortSignal): Promise<AgoraCommandResult>
43
+ bridge(): DshImBridgeV1 | null
44
+ receiptLimit?: number
45
+ }) {}
46
+
47
+ ingest(event: DshAgoraCommandEventV1, signal?: AbortSignal): Promise<DshAgoraCommandEventResultV1> {
48
+ validateEvent(event)
49
+ const existing = this.receipts.get(event.idempotency_key)
50
+ if (existing) return existing
51
+ const pending = this.execute(event, signal)
52
+ this.receipts.set(event.idempotency_key, pending)
53
+ while (this.receipts.size > (this.options.receiptLimit ?? 2_048)) this.receipts.delete(this.receipts.keys().next().value!)
54
+ pending.catch(() => {
55
+ if (this.receipts.get(event.idempotency_key) === pending) this.receipts.delete(event.idempotency_key)
56
+ })
57
+ return pending
58
+ }
59
+
60
+ private async execute(event: DshAgoraCommandEventV1, signal?: AbortSignal): Promise<DshAgoraCommandEventResultV1> {
61
+ const result = await this.options.execute(event.input, {
62
+ actorId: event.actor_ref,
63
+ ...(event.provider ? { provider: event.provider } : {}),
64
+ ...(event.conversation_ref ? { conversationRef: event.conversation_ref } : {}),
65
+ ...(event.thread_ref ? { threadRef: event.thread_ref } : {}),
66
+ }, signal)
67
+ const bridge = this.options.bridge()
68
+ if (!event.reply?.enabled) return envelope(event, result, { sent: false, provider_message_refs: [], reason: 'reply disabled' })
69
+ if (!bridge || !event.provider || !event.conversation_ref) {
70
+ return envelope(event, result, { sent: false, provider_message_refs: [], reason: 'IM bridge or destination unavailable' })
71
+ }
72
+ try {
73
+ const receipt = await bridge.send({
74
+ provider: event.provider,
75
+ conversation_ref: event.conversation_ref,
76
+ ...(event.reply.bot_ref === undefined ? {} : { bot_ref: event.reply.bot_ref }),
77
+ ...(event.thread_ref === undefined ? {} : { thread_ref: event.thread_ref }),
78
+ text: result.text ?? (result.kind === 'success' ? 'Agora command completed.' : 'Agora command failed.'),
79
+ idempotency_key: `command:${event.idempotency_key}`,
80
+ })
81
+ return envelope(event, result, { sent: true, provider_message_refs: receipt.provider_message_refs })
82
+ } catch (error) {
83
+ return envelope(event, result, {
84
+ sent: false,
85
+ provider_message_refs: [],
86
+ reason: `IM delivery failed: ${error instanceof Error ? error.message : String(error)}`,
87
+ })
88
+ }
89
+ }
90
+ }
91
+
92
+ function envelope(
93
+ event: DshAgoraCommandEventV1,
94
+ result: AgoraCommandResult,
95
+ delivery: DshAgoraCommandEventResultV1['delivery'],
96
+ ): DshAgoraCommandEventResultV1 {
97
+ return { protocol: DSH_AGORA_COMMAND_ADAPTER_PROTOCOL, idempotency_key: event.idempotency_key, result, delivery }
98
+ }
99
+
100
+ function validateEvent(event: DshAgoraCommandEventV1): void {
101
+ if (event.protocol !== DSH_AGORA_COMMAND_ADAPTER_PROTOCOL) throw new TypeError(`command event protocol must be ${DSH_AGORA_COMMAND_ADAPTER_PROTOCOL}`)
102
+ for (const [name, value] of [['idempotency_key', event.idempotency_key], ['input', event.input], ['actor_ref', event.actor_ref]] as const) {
103
+ if (typeof value !== 'string' || !value.trim()) throw new TypeError(`${name} is required`)
104
+ }
105
+ if (event.reply?.enabled && (!event.provider || !event.conversation_ref)) throw new TypeError('reply requires provider and conversation_ref')
106
+ }