@addozhang/dsh-discord 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 (183) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +68 -0
  3. package/cordis.patch.yml +14 -0
  4. package/lib/client/DiscordSettingsCard.d.ts +6 -0
  5. package/lib/client/PluginCard.d.ts +43 -0
  6. package/lib/client/card-controller.d.ts +22 -0
  7. package/lib/client/card-form.d.ts +111 -0
  8. package/lib/client/fields.d.ts +50 -0
  9. package/lib/client/index.d.ts +12 -0
  10. package/lib/client/nav-icon.d.ts +21 -0
  11. package/lib/client/settings-model.d.ts +49 -0
  12. package/lib/client/slot-contract.d.ts +57 -0
  13. package/lib/client/snapshot-store.d.ts +9 -0
  14. package/lib/client.js +1622 -0
  15. package/lib/compose.d.ts +158 -0
  16. package/lib/compose.js +227 -0
  17. package/lib/credential.d.ts +20 -0
  18. package/lib/credential.js +24 -0
  19. package/lib/discord/commands.d.ts +45 -0
  20. package/lib/discord/commands.js +87 -0
  21. package/lib/discord/components.d.ts +29 -0
  22. package/lib/discord/components.js +57 -0
  23. package/lib/discord/delivery.d.ts +21 -0
  24. package/lib/discord/delivery.js +31 -0
  25. package/lib/discord/interaction-lifecycle.d.ts +42 -0
  26. package/lib/discord/interaction-lifecycle.js +78 -0
  27. package/lib/discord/rest.d.ts +74 -0
  28. package/lib/discord/rest.js +149 -0
  29. package/lib/discord/selector.d.ts +39 -0
  30. package/lib/discord/selector.js +70 -0
  31. package/lib/discord/thread-port.d.ts +37 -0
  32. package/lib/discord/thread-port.js +58 -0
  33. package/lib/dsh/api-proxy-face.d.ts +253 -0
  34. package/lib/dsh/api-proxy-face.js +370 -0
  35. package/lib/features/adapter-status.d.ts +111 -0
  36. package/lib/features/adapter-status.js +103 -0
  37. package/lib/features/approval-expiry.d.ts +27 -0
  38. package/lib/features/approval-expiry.js +36 -0
  39. package/lib/features/approval-routing.d.ts +50 -0
  40. package/lib/features/approval-routing.js +57 -0
  41. package/lib/features/approval-store.d.ts +86 -0
  42. package/lib/features/approval-store.js +119 -0
  43. package/lib/features/approval-view.d.ts +47 -0
  44. package/lib/features/approval-view.js +48 -0
  45. package/lib/features/ask-wiring.d.ts +74 -0
  46. package/lib/features/ask-wiring.js +151 -0
  47. package/lib/features/host-status.d.ts +41 -0
  48. package/lib/features/host-status.js +24 -0
  49. package/lib/features/image-collection.d.ts +56 -0
  50. package/lib/features/image-collection.js +79 -0
  51. package/lib/features/image-download.d.ts +48 -0
  52. package/lib/features/image-download.js +77 -0
  53. package/lib/features/image-submission.d.ts +67 -0
  54. package/lib/features/image-submission.js +57 -0
  55. package/lib/features/interaction-router.d.ts +84 -0
  56. package/lib/features/interaction-router.js +655 -0
  57. package/lib/features/model-control.d.ts +79 -0
  58. package/lib/features/model-control.js +51 -0
  59. package/lib/features/preset-control.d.ts +84 -0
  60. package/lib/features/preset-control.js +43 -0
  61. package/lib/features/project-bind.d.ts +69 -0
  62. package/lib/features/project-bind.js +57 -0
  63. package/lib/features/project-info.d.ts +25 -0
  64. package/lib/features/project-info.js +22 -0
  65. package/lib/features/project-list.d.ts +79 -0
  66. package/lib/features/project-list.js +93 -0
  67. package/lib/features/prompt-submission.d.ts +53 -0
  68. package/lib/features/prompt-submission.js +47 -0
  69. package/lib/features/question-expiry.d.ts +53 -0
  70. package/lib/features/question-expiry.js +56 -0
  71. package/lib/features/question-routing.d.ts +108 -0
  72. package/lib/features/question-routing.js +162 -0
  73. package/lib/features/question-store.d.ts +124 -0
  74. package/lib/features/question-store.js +186 -0
  75. package/lib/features/question-view.d.ts +58 -0
  76. package/lib/features/question-view.js +81 -0
  77. package/lib/features/queue-control.d.ts +63 -0
  78. package/lib/features/queue-control.js +31 -0
  79. package/lib/features/reconcile-bindings.d.ts +75 -0
  80. package/lib/features/reconcile-bindings.js +54 -0
  81. package/lib/features/reconcile-deliveries.d.ts +29 -0
  82. package/lib/features/reconcile-deliveries.js +28 -0
  83. package/lib/features/reconcile-events.d.ts +49 -0
  84. package/lib/features/reconcile-events.js +50 -0
  85. package/lib/features/reconcile-intents.d.ts +32 -0
  86. package/lib/features/reconcile-intents.js +32 -0
  87. package/lib/features/reconcile-interactions.d.ts +59 -0
  88. package/lib/features/reconcile-interactions.js +54 -0
  89. package/lib/features/session-adopt.d.ts +89 -0
  90. package/lib/features/session-adopt.js +77 -0
  91. package/lib/features/session-creation.d.ts +49 -0
  92. package/lib/features/session-creation.js +43 -0
  93. package/lib/features/session-mainline.d.ts +75 -0
  94. package/lib/features/session-mainline.js +96 -0
  95. package/lib/features/session-resume.d.ts +44 -0
  96. package/lib/features/session-resume.js +40 -0
  97. package/lib/features/skill-run.d.ts +48 -0
  98. package/lib/features/skill-run.js +25 -0
  99. package/lib/features/steer-control.d.ts +37 -0
  100. package/lib/features/steer-control.js +28 -0
  101. package/lib/features/stop-control.d.ts +38 -0
  102. package/lib/features/stop-control.js +31 -0
  103. package/lib/features/task-admission.d.ts +29 -0
  104. package/lib/features/task-admission.js +21 -0
  105. package/lib/features/thread-continuation.d.ts +36 -0
  106. package/lib/features/thread-continuation.js +18 -0
  107. package/lib/features/thread-creation.d.ts +81 -0
  108. package/lib/features/thread-creation.js +81 -0
  109. package/lib/features/thread-routing.d.ts +49 -0
  110. package/lib/features/thread-routing.js +34 -0
  111. package/lib/features/turn-ownership.d.ts +39 -0
  112. package/lib/features/turn-ownership.js +40 -0
  113. package/lib/features/unbound-mention.d.ts +49 -0
  114. package/lib/features/unbound-mention.js +31 -0
  115. package/lib/features/workspace-channel.d.ts +41 -0
  116. package/lib/features/workspace-channel.js +47 -0
  117. package/lib/gateway/gateway.d.ts +50 -0
  118. package/lib/gateway/gateway.js +229 -0
  119. package/lib/gateway/inbound.d.ts +85 -0
  120. package/lib/gateway/inbound.js +186 -0
  121. package/lib/gateway/ingress.d.ts +15 -0
  122. package/lib/gateway/ingress.js +22 -0
  123. package/lib/i18n.d.ts +92 -0
  124. package/lib/i18n.js +180 -0
  125. package/lib/index.d.ts +17 -0
  126. package/lib/index.js +869 -0
  127. package/lib/lifecycle.d.ts +44 -0
  128. package/lib/lifecycle.js +84 -0
  129. package/lib/policy/authorization.d.ts +41 -0
  130. package/lib/policy/authorization.js +64 -0
  131. package/lib/policy/disclosure.d.ts +93 -0
  132. package/lib/policy/disclosure.js +153 -0
  133. package/lib/policy/guard.d.ts +31 -0
  134. package/lib/policy/guard.js +46 -0
  135. package/lib/policy/suppress.d.ts +13 -0
  136. package/lib/policy/suppress.js +20 -0
  137. package/lib/settings-namespace.d.ts +7 -0
  138. package/lib/settings-namespace.js +6 -0
  139. package/lib/settings.d.ts +37 -0
  140. package/lib/settings.js +102 -0
  141. package/lib/startup.d.ts +16 -0
  142. package/lib/startup.js +60 -0
  143. package/lib/state/bindings.d.ts +46 -0
  144. package/lib/state/bindings.js +55 -0
  145. package/lib/state/channel-bindings.d.ts +37 -0
  146. package/lib/state/channel-bindings.js +43 -0
  147. package/lib/state/domain.d.ts +82 -0
  148. package/lib/state/domain.js +86 -0
  149. package/lib/state/effect-machine.d.ts +37 -0
  150. package/lib/state/effect-machine.js +52 -0
  151. package/lib/state/fail-closed.d.ts +44 -0
  152. package/lib/state/fail-closed.js +57 -0
  153. package/lib/state/intents.d.ts +51 -0
  154. package/lib/state/intents.js +73 -0
  155. package/lib/state/records.d.ts +37 -0
  156. package/lib/state/records.js +30 -0
  157. package/lib/state/retention.d.ts +74 -0
  158. package/lib/state/retention.js +68 -0
  159. package/lib/state/session-owners.d.ts +50 -0
  160. package/lib/state/session-owners.js +50 -0
  161. package/lib/stream/finalizer.d.ts +49 -0
  162. package/lib/stream/finalizer.js +67 -0
  163. package/lib/stream/icons.d.ts +15 -0
  164. package/lib/stream/icons.js +36 -0
  165. package/lib/stream/live.d.ts +140 -0
  166. package/lib/stream/live.js +515 -0
  167. package/lib/stream/markdown.d.ts +24 -0
  168. package/lib/stream/markdown.js +95 -0
  169. package/lib/stream/outbound.d.ts +27 -0
  170. package/lib/stream/outbound.js +25 -0
  171. package/lib/stream/render-fence.d.ts +27 -0
  172. package/lib/stream/render-fence.js +37 -0
  173. package/lib/stream/render-model.d.ts +48 -0
  174. package/lib/stream/render-model.js +68 -0
  175. package/lib/stream/splitter.d.ts +8 -0
  176. package/lib/stream/splitter.js +58 -0
  177. package/lib/stream/tool-view.d.ts +40 -0
  178. package/lib/stream/tool-view.js +39 -0
  179. package/lib/stream/typing.d.ts +22 -0
  180. package/lib/stream/typing.js +58 -0
  181. package/lib/stream/update-scheduler.d.ts +20 -0
  182. package/lib/stream/update-scheduler.js +65 -0
  183. package/package.json +86 -0
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Live render wiring (Phase 1): the DSH events.mux stream drives per-thread
3
+ * render state into Discord delivery. Each bound session thread owns one
4
+ * runtime — render model, update scheduler, typing lifecycle, tool activity
5
+ * surface, and the turn's head message. Frames for sessions with no bound
6
+ * thread are dropped (reconciliation owns recovery, not the live path);
7
+ * `assistant/message` finalizes exactly once with ordered continuations;
8
+ * `turn/end` stops typing, releases the adapter-owned turn, and deletes the
9
+ * turn's tool activity message.
10
+ */
11
+ import type { DiscordVerbosity } from '../settings.js';
12
+ /** The mux frames the live path consumes (narrow, defensive shape). */
13
+ export type LiveFrame = {
14
+ type: 'session/event';
15
+ sessionId: string;
16
+ event: {
17
+ type: string;
18
+ data: Record<string, unknown>;
19
+ };
20
+ view?: unknown;
21
+ } | {
22
+ type: 'session/subscribed';
23
+ sessionId: string;
24
+ } | {
25
+ type: 'session/queue';
26
+ sessionId: string;
27
+ items: Array<{
28
+ id: string;
29
+ summary: string;
30
+ }>;
31
+ } | {
32
+ type: string;
33
+ };
34
+ /**
35
+ * The Discord delivery face the live renderer needs. `unknown` on a send
36
+ * means the request's application on Discord's side is unobservable (rest.ts
37
+ * contract); the caller must never blind-resent such a send — the live path
38
+ * pauses instead and lets the finalizer's single fresh send carry the text.
39
+ */
40
+ export interface LiveDeliveryPort {
41
+ send(request: {
42
+ channelId: string;
43
+ content: string;
44
+ }): Promise<{
45
+ outcome: 'completed';
46
+ messageId: string;
47
+ } | {
48
+ outcome: 'unknown';
49
+ } | {
50
+ outcome: 'failed';
51
+ }>;
52
+ edit(request: {
53
+ channelId: string;
54
+ messageId: string;
55
+ content: string;
56
+ }): Promise<{
57
+ outcome: 'completed';
58
+ } | {
59
+ outcome: 'failed';
60
+ }>;
61
+ delete(request: {
62
+ channelId: string;
63
+ messageId: string;
64
+ }): Promise<{
65
+ outcome: 'completed';
66
+ } | {
67
+ outcome: 'failed';
68
+ }>;
69
+ typing(channelId: string): Promise<void>;
70
+ renameThread(request: {
71
+ channelId: string;
72
+ name: string;
73
+ }): Promise<{
74
+ outcome: 'completed';
75
+ } | {
76
+ outcome: 'failed';
77
+ }>;
78
+ }
79
+ export interface LiveRenderDeps {
80
+ frames: (signal: AbortSignal) => AsyncIterable<unknown>;
81
+ threadForSession: (sessionId: string) => string | undefined;
82
+ delivery: LiveDeliveryPort;
83
+ /** Wire name of a thread, when resolvable; lets a restart skip no-op renames. */
84
+ threadName?: (channelId: string) => Promise<string | undefined>;
85
+ updateIntervalMs: number;
86
+ typingIntervalMs: number;
87
+ /** Approval ask deadline (approvalTimeoutMs setting). */
88
+ approvalTimeoutMs: number;
89
+ /** Question ask deadline (questionTimeoutMs setting). */
90
+ questionTimeoutMs: number;
91
+ /** Coalescing budget for tool-activity edits (default 1s). */
92
+ activityCoalesceMs?: number;
93
+ verbosity?: DiscordVerbosity;
94
+ log?: (event: string, detail?: unknown) => void;
95
+ /** Queue snapshot cache (the /queue surface's data source). */
96
+ onQueueSnapshot?: (sessionId: string, items: Array<{
97
+ id: string;
98
+ summary: string;
99
+ }>) => void;
100
+ /** Localized interruption suffix, resolved live (language can change). */
101
+ interruptedMarker?: () => string;
102
+ /** Turn ownership release on turn/end. */
103
+ onTurnEnded?: (sessionId: string) => void;
104
+ /**
105
+ * Answerable server-request frames (interaction-routing spec): approvals
106
+ * and questions arrive as mux frames with an envelope rpcId the response
107
+ * must echo. The composition owns rendering, ownership, and expiry.
108
+ */
109
+ requests?: {
110
+ onApprovalRequested(input: {
111
+ sessionId: string;
112
+ threadId: string;
113
+ rpcId: string;
114
+ approvalId: string;
115
+ toolName: string;
116
+ reason?: string | undefined;
117
+ expiresAtMs: number;
118
+ }): void;
119
+ onApprovalResolved(input: {
120
+ sessionId: string;
121
+ approvalId: string;
122
+ outcome?: string | undefined;
123
+ }): void;
124
+ onQuestionRequested(input: {
125
+ sessionId: string;
126
+ threadId: string;
127
+ rpcId: string;
128
+ questions: Array<Record<string, unknown>>;
129
+ expiresAtMs: number;
130
+ }): void;
131
+ onQuestionResolved(input: {
132
+ sessionId: string;
133
+ questionRpcId: string;
134
+ outcome: 'answered' | 'cancelled';
135
+ }): void;
136
+ };
137
+ }
138
+ export declare function startLiveRender(deps: LiveRenderDeps): {
139
+ dispose(): void;
140
+ };
@@ -0,0 +1,515 @@
1
+ /**
2
+ * Live render wiring (Phase 1): the DSH events.mux stream drives per-thread
3
+ * render state into Discord delivery. Each bound session thread owns one
4
+ * runtime — render model, update scheduler, typing lifecycle, tool activity
5
+ * surface, and the turn's head message. Frames for sessions with no bound
6
+ * thread are dropped (reconciliation owns recovery, not the live path);
7
+ * `assistant/message` finalizes exactly once with ordered continuations;
8
+ * `turn/end` stops typing, releases the adapter-owned turn, and deletes the
9
+ * turn's tool activity message.
10
+ */
11
+ import { createThreadRenderModel } from './render-model.js';
12
+ import { createUpdateScheduler } from './update-scheduler.js';
13
+ import { createTypingLifecycle } from './typing.js';
14
+ import { createToolActivitySurface } from './tool-view.js';
15
+ import { createAnswerFinalizer } from './finalizer.js';
16
+ import { buildOutboundMessage } from './outbound.js';
17
+ import { toolCategoryIcon } from './icons.js';
18
+ import { discordChannelNameKey, safeTitle } from '../policy/disclosure.js';
19
+ /** Coalescing budget for activity-message edits under parallel tools. */
20
+ const DEFAULT_ACTIVITY_COALESCE_MS = 1_000;
21
+ /** Row budget: a presentation title is truncated before it reaches Discord. */
22
+ const ACTIVITY_TITLE_MAX = 80;
23
+ const ANSWER_MARKER = (interrupted, marker) => interrupted ? `\n\n${marker}` : '';
24
+ /** Pair-safe truncation: never split a surrogate pair at the boundary. */
25
+ function truncateText(text, max) {
26
+ if (text.length <= max)
27
+ return text;
28
+ const cut = text.slice(0, max);
29
+ const last = cut.charCodeAt(cut.length - 1);
30
+ return last >= 0xd800 && last <= 0xdbff ? cut.slice(0, -1) : cut;
31
+ }
32
+ /** Extract the visible text of one assistant message (text blocks only). */
33
+ function assistantText(message) {
34
+ if (typeof message !== 'object' || message === null)
35
+ return '';
36
+ const content = message.content;
37
+ if (!Array.isArray(content))
38
+ return '';
39
+ return content
40
+ .filter((block) => typeof block === 'object' && block !== null && block.type === 'text')
41
+ .map(block => block.text)
42
+ .join('');
43
+ }
44
+ /** The callId of a tool/result event (block-carried, defensive). */
45
+ function resultCallId(data) {
46
+ if (typeof data['callId'] === 'string')
47
+ return data['callId'];
48
+ const message = data['message'];
49
+ const block = Array.isArray(message?.content) ? message.content[0] : undefined;
50
+ return typeof block?.toolCallId === 'string' ? block.toolCallId : undefined;
51
+ }
52
+ /**
53
+ * The Host presentation view's title for one tool event: a terminal call's
54
+ * title IS the command; generic/diff cards title the call. Host-curated
55
+ * disclosure — never raw arguments.
56
+ */
57
+ function presentationTitle(frameView) {
58
+ if (typeof frameView !== 'object' || frameView === null)
59
+ return undefined;
60
+ const view = frameView.view;
61
+ if (typeof view !== 'object' || view === null)
62
+ return undefined;
63
+ const title = view.title;
64
+ return typeof title === 'string' && title !== '' ? title : undefined;
65
+ }
66
+ export function startLiveRender(deps) {
67
+ const verbosity = deps.verbosity ?? 'essential-tools';
68
+ const runtimes = new Map();
69
+ const state = { disposed: false };
70
+ const controller = new AbortController();
71
+ const runtimeFor = (threadId) => {
72
+ let runtime = runtimes.get(threadId);
73
+ if (runtime !== undefined)
74
+ return runtime;
75
+ runtime = {
76
+ render: createThreadRenderModel(),
77
+ tools: createToolActivitySurface({ verbosity }),
78
+ typing: createTypingLifecycle({
79
+ trigger: () => deps.delivery.typing(threadId),
80
+ intervalMs: deps.typingIntervalMs,
81
+ onFailure: (cause) => { deps.log?.('discord_live_typing_threw', { threadId, cause: String(cause) }); },
82
+ }),
83
+ scheduler: undefined,
84
+ activityScheduler: undefined,
85
+ finalizer: undefined,
86
+ headMessageId: undefined,
87
+ activityMessageId: undefined,
88
+ stepSeq: 0,
89
+ headAttempted: false,
90
+ activityAttempted: false,
91
+ turnId: undefined,
92
+ toolNames: new Map(),
93
+ toolTitles: new Map(),
94
+ lastTitle: undefined,
95
+ };
96
+ runtimes.set(threadId, runtime);
97
+ return runtime;
98
+ };
99
+ /** Flush the current answer text: create the head once, then edit it. */
100
+ function flushAnswer(threadId, runtime) {
101
+ const stepSeq = runtime.stepSeq;
102
+ return async (content) => {
103
+ if (runtime.stepSeq !== stepSeq)
104
+ return;
105
+ const payload = buildOutboundMessage({ kind: 'assistant', content });
106
+ if (runtime.headMessageId === undefined) {
107
+ // An earlier send whose application was unobservable must never be
108
+ // blind-resent (rest.ts contract): the stream pauses here and the
109
+ // finalizer's single fresh send carries the answer instead.
110
+ if (runtime.headAttempted)
111
+ return;
112
+ const sent = await deps.delivery.send({ channelId: threadId, content: payload.content });
113
+ if (sent.outcome === 'completed')
114
+ runtime.headMessageId = sent.messageId;
115
+ else if (sent.outcome === 'unknown') {
116
+ runtime.headAttempted = true;
117
+ deps.log?.('discord_live_head_send_unknown', { threadId });
118
+ }
119
+ return;
120
+ }
121
+ await deps.delivery.edit({ channelId: threadId, messageId: runtime.headMessageId, content: payload.content });
122
+ };
123
+ }
124
+ function beginTurn(threadId, runtime, turnId) {
125
+ runtime.render.beginTurn({ turnId });
126
+ runtime.turnId = turnId;
127
+ runtime.headMessageId = undefined;
128
+ runtime.activityMessageId = undefined;
129
+ runtime.headAttempted = false;
130
+ runtime.activityAttempted = false;
131
+ runtime.stepSeq += 1;
132
+ runtime.tools = createToolActivitySurface({ verbosity });
133
+ runtime.toolNames = new Map();
134
+ runtime.toolTitles = new Map();
135
+ runtime.scheduler?.dispose();
136
+ runtime.scheduler = createUpdateScheduler({
137
+ minIntervalMs: deps.updateIntervalMs,
138
+ onFlush: flushAnswer(threadId, runtime),
139
+ onFlushError: (cause) => { deps.log?.('discord_live_flush_failed', { threadId, cause: String(cause) }); },
140
+ });
141
+ // The activity message's own coalescer: row changes share one edit per
142
+ // interval, so parallel tools cannot exceed the channel's edit budget.
143
+ runtime.activityScheduler?.dispose();
144
+ runtime.activityScheduler = createUpdateScheduler({
145
+ minIntervalMs: deps.activityCoalesceMs ?? DEFAULT_ACTIVITY_COALESCE_MS,
146
+ onFlush: renderActivity(threadId, runtime),
147
+ onFlushError: (cause) => { deps.log?.('discord_live_activity_flush_failed', { threadId, cause: String(cause) }); },
148
+ });
149
+ runtime.finalizer = undefined;
150
+ // A fresh lifecycle per turn: start() no-ops on a stopped one, so a
151
+ // second turn in the same thread would otherwise never type again.
152
+ runtime.typing.dispose();
153
+ runtime.typing = createTypingLifecycle({
154
+ trigger: () => deps.delivery.typing(threadId),
155
+ intervalMs: deps.typingIntervalMs,
156
+ onFailure: (cause) => { deps.log?.('discord_live_typing_threw', { threadId, cause: String(cause) }); },
157
+ });
158
+ runtime.typing.start();
159
+ }
160
+ /** The activity message body: one icon + presentation title per call row. */
161
+ function renderActivityContent(runtime) {
162
+ const rows = runtime.tools.render();
163
+ return rows.map(row => {
164
+ const title = truncateText(row.title ?? row.label, ACTIVITY_TITLE_MAX);
165
+ return `> ${toolCategoryIcon(row.label)} ${title}`;
166
+ }).join('\n');
167
+ }
168
+ /** Render the tool rows into one bounded activity message (create once, edit after). */
169
+ function renderActivity(threadId, runtime) {
170
+ return async () => {
171
+ if (runtime.tools.render().length === 0)
172
+ return;
173
+ // Tool titles are Host-presented free text (terminal commands): they
174
+ // go through the same outbound builder as every other message path.
175
+ const payload = buildOutboundMessage({ kind: 'tool', content: renderActivityContent(runtime) });
176
+ if (runtime.activityMessageId === undefined) {
177
+ if (runtime.activityAttempted)
178
+ return;
179
+ const sent = await deps.delivery.send({ channelId: threadId, content: payload.content });
180
+ if (sent.outcome === 'completed')
181
+ runtime.activityMessageId = sent.messageId;
182
+ else if (sent.outcome === 'unknown') {
183
+ runtime.activityAttempted = true;
184
+ deps.log?.('discord_live_activity_send_unknown', { threadId });
185
+ }
186
+ return;
187
+ }
188
+ await deps.delivery.edit({ channelId: threadId, messageId: runtime.activityMessageId, content: payload.content });
189
+ };
190
+ }
191
+ function handleSessionEvent(sessionId, threadId, runtime, event, frameView) {
192
+ const data = event.data;
193
+ const turnId = typeof data['turn'] === 'number' ? String(data['turn']) : undefined;
194
+ const stepId = typeof data['step'] === 'number' ? String(data['step']) : undefined;
195
+ switch (event.type) {
196
+ case 'turn/start': {
197
+ if (typeof turnId !== 'string')
198
+ return;
199
+ beginTurn(threadId, runtime, turnId);
200
+ return;
201
+ }
202
+ case 'step/start': {
203
+ if (turnId !== undefined && stepId !== undefined)
204
+ runtime.render.beginStep({ turnId, stepId });
205
+ // A new step opens a NEW logical answer message: the previous
206
+ // step's completed head is never overwritten (stream-renderer spec).
207
+ runtime.headMessageId = undefined;
208
+ runtime.headAttempted = false;
209
+ runtime.stepSeq += 1;
210
+ // The previous step's finalize disposed the scheduler; a fresh one
211
+ // carries the new step's chunk coalescing.
212
+ runtime.scheduler?.dispose();
213
+ runtime.scheduler = createUpdateScheduler({
214
+ minIntervalMs: deps.updateIntervalMs,
215
+ onFlush: flushAnswer(threadId, runtime),
216
+ onFlushError: (cause) => { deps.log?.('discord_live_flush_failed', { threadId, cause: String(cause) }); },
217
+ });
218
+ return;
219
+ }
220
+ case 'assistant/chunk': {
221
+ if (runtime.scheduler === undefined || turnId === undefined || stepId === undefined)
222
+ return;
223
+ const chunk = data['chunk'];
224
+ if (chunk?.type !== 'text-delta' || typeof chunk.text !== 'string')
225
+ return;
226
+ runtime.render.appendDelta({ turnId, stepId, text: chunk.text });
227
+ const snapshot = runtime.render.snapshot();
228
+ const current = snapshot.answers.find(answer => answer.stepId === stepId);
229
+ if (current !== undefined)
230
+ runtime.scheduler.schedule(current.text);
231
+ return;
232
+ }
233
+ case 'assistant/message': {
234
+ if (turnId === undefined || stepId === undefined)
235
+ return;
236
+ const interrupted = data['interrupted'] === true;
237
+ const text = assistantText(data['message']);
238
+ runtime.render.setAuthoritative({ turnId, stepId, text });
239
+ if (interrupted)
240
+ runtime.render.interrupt({ turnId, stepId });
241
+ runtime.scheduler?.dispose();
242
+ runtime.scheduler = undefined;
243
+ if (text === '')
244
+ return;
245
+ // The authoritative finalize sends exactly once per turn answer. It
246
+ // runs detached, so it captures the step generation here: a finalize
247
+ // whose REST is still in flight when the next step starts must not
248
+ // re-point the live head at its own (older) message.
249
+ const finalizedStepSeq = runtime.stepSeq;
250
+ runtime.finalizer = createAnswerFinalizer({
251
+ delivery: {
252
+ editHead: async ({ messageId, content }) => {
253
+ const payload = buildOutboundMessage({ kind: 'assistant', content });
254
+ // No head exists (text arrived without flushed chunks): the
255
+ // first finalize send IS the head, recorded for continuations.
256
+ if (messageId === '') {
257
+ const sent = await deps.delivery.send({ channelId: threadId, content: payload.content });
258
+ if (sent.outcome === 'completed') {
259
+ if (runtime.stepSeq === finalizedStepSeq && runtime.headMessageId === undefined) {
260
+ runtime.headMessageId = sent.messageId;
261
+ }
262
+ return { outcome: 'completed' };
263
+ }
264
+ return { outcome: 'failed' };
265
+ }
266
+ const edited = await deps.delivery.edit({ channelId: threadId, messageId, content: payload.content });
267
+ return edited.outcome === 'completed' ? { outcome: 'completed' } : { outcome: 'failed' };
268
+ },
269
+ sendContinuation: async ({ content }) => {
270
+ const payload = buildOutboundMessage({ kind: 'assistant', content });
271
+ const sent = await deps.delivery.send({ channelId: threadId, content: payload.content });
272
+ return sent.outcome === 'completed' ? { outcome: 'completed' } : { outcome: 'failed' };
273
+ },
274
+ },
275
+ headMessageId: runtime.headMessageId ?? '',
276
+ });
277
+ const finalText = text + ANSWER_MARKER(interrupted, deps.interruptedMarker?.() ?? '*(已被中断)*');
278
+ void runtime.finalizer.finalize(finalText).catch((cause) => {
279
+ deps.log?.('discord_live_finalize_threw', { threadId, cause: String(cause) });
280
+ });
281
+ return;
282
+ }
283
+ case 'tool/call': {
284
+ if (typeof data['callId'] !== 'string' || typeof data['name'] !== 'string')
285
+ return;
286
+ const title = presentationTitle(frameView);
287
+ runtime.toolNames.set(data['callId'], data['name']);
288
+ if (title !== undefined)
289
+ runtime.toolTitles.set(data['callId'], title);
290
+ runtime.tools.record({
291
+ callId: data['callId'],
292
+ toolName: data['name'],
293
+ state: 'running',
294
+ title,
295
+ rawArguments: typeof data['arguments'] === 'string' ? data['arguments'] : undefined,
296
+ });
297
+ runtime.activityScheduler?.schedule(renderActivityContent(runtime));
298
+ return;
299
+ }
300
+ case 'tool/result': {
301
+ const callId = resultCallId(data);
302
+ if (callId === undefined)
303
+ return;
304
+ const failed = data['error'] !== undefined;
305
+ runtime.tools.record({
306
+ callId,
307
+ toolName: runtime.toolNames.get(callId) ?? 'tool',
308
+ state: failed ? 'failed' : 'succeeded',
309
+ title: runtime.toolTitles.get(callId),
310
+ });
311
+ runtime.activityScheduler?.schedule(renderActivityContent(runtime));
312
+ return;
313
+ }
314
+ case 'turn/end': {
315
+ runtime.typing.stop('completed');
316
+ runtime.scheduler?.dispose();
317
+ runtime.scheduler = undefined;
318
+ runtime.activityScheduler?.dispose();
319
+ runtime.activityScheduler = undefined;
320
+ // The activity message is the live "what's happening" surface only:
321
+ // at turn end it is deleted — the durable record is the Session log
322
+ // and the assistant's answer.
323
+ const activityMessageId = runtime.activityMessageId;
324
+ runtime.activityMessageId = undefined;
325
+ if (activityMessageId !== undefined) {
326
+ void deps.delivery.delete({ channelId: threadId, messageId: activityMessageId }).catch((cause) => {
327
+ deps.log?.('discord_live_activity_delete_threw', { threadId, cause: String(cause) });
328
+ });
329
+ }
330
+ deps.onTurnEnded?.(sessionId);
331
+ return;
332
+ }
333
+ default:
334
+ return;
335
+ }
336
+ }
337
+ /** A bounded one-line summary of a queued message: text blocks only. */
338
+ function queueSummary(item) {
339
+ const record = (typeof item === 'object' && item !== null ? item : {});
340
+ const id = typeof record.id === 'string' ? record.id : '';
341
+ const content = Array.isArray(record.message?.content) ? record.message.content : [];
342
+ const text = truncateText(content
343
+ .filter((block) => typeof block === 'object' && block !== null && block.type === 'text')
344
+ .map(block => block.text)
345
+ .join(' '), 120);
346
+ return { id, summary: text === '' ? '(非文本消息)' : text };
347
+ }
348
+ function handleFrame(raw, envelopeRpcId) {
349
+ const frame = (typeof raw === 'object' && raw !== null ? raw : {});
350
+ const type = frame['type'];
351
+ const sessionId = typeof frame['sessionId'] === 'string' ? frame['sessionId'] : undefined;
352
+ if (typeof type !== 'string' || sessionId === undefined)
353
+ return;
354
+ if (type === 'session/queue') {
355
+ const items = Array.isArray(frame['items']) ? frame['items'] : [];
356
+ deps.onQueueSnapshot?.(sessionId, items.map(item => queueSummary(item)));
357
+ // Admission-time typing: typing starts when the prompt is
358
+ // ENQUEUED — covering the queue wait, agent startup, and first-token
359
+ // latency before the first turn event — and stops when the queue
360
+ // drains with no turn open (never wedges the indicator on).
361
+ const threadId = deps.threadForSession(sessionId);
362
+ if (threadId === undefined)
363
+ return;
364
+ const runtime = runtimeFor(threadId);
365
+ if (items.length > 0) {
366
+ runtime.typing.start();
367
+ return;
368
+ }
369
+ if (!runtime.render.snapshot().turnOpen)
370
+ runtime.typing.stop();
371
+ return;
372
+ }
373
+ if (type === 'session/projection') {
374
+ // DSH's model-generated session title (from the user's first input):
375
+ // rename the thread once per distinct title.
376
+ if (frame['key'] !== 'title')
377
+ return;
378
+ const title = frame['value'];
379
+ if (typeof title !== 'string' || title === '')
380
+ return;
381
+ const threadId = deps.threadForSession(sessionId);
382
+ if (threadId === undefined)
383
+ return;
384
+ const runtime = runtimeFor(threadId);
385
+ const name = safeTitle(title);
386
+ if (name === '' || name === runtime.lastTitle)
387
+ return;
388
+ runtime.lastTitle = name;
389
+ // Cold dedupe state after a restart: confirm the thread's wire name
390
+ // before PATCHing — Discord throttles renames hard (~2 per 10 min),
391
+ // and an unchanged session must not burn one on a no-op. Both sides
392
+ // are slugified: Discord stores names lowercased and dashed, so a
393
+ // raw comparison would report a false difference on every restart.
394
+ const renameNeeded = deps.threadName === undefined ? Promise.resolve(true)
395
+ : deps.threadName(threadId).then((current) => current === undefined || discordChannelNameKey(current) !== discordChannelNameKey(name)).catch(() => true);
396
+ void renameNeeded.then((needed) => {
397
+ if (!needed)
398
+ return;
399
+ return deps.delivery.renameThread({ channelId: threadId, name }).then((result) => {
400
+ if (result.outcome !== 'completed') {
401
+ deps.log?.('discord_live_rename_failed', { threadId, name });
402
+ }
403
+ });
404
+ }).catch((cause) => {
405
+ deps.log?.('discord_live_rename_threw', { threadId, cause: String(cause) });
406
+ });
407
+ return;
408
+ }
409
+ if (type === 'approval/requested') {
410
+ const threadId = deps.threadForSession(sessionId);
411
+ if (threadId === undefined || deps.requests === undefined)
412
+ return;
413
+ deps.requests.onApprovalRequested({
414
+ sessionId,
415
+ threadId,
416
+ rpcId: envelopeRpcId ?? '',
417
+ approvalId: typeof frame['approvalId'] === 'string' ? frame['approvalId'] : '',
418
+ toolName: typeof frame['toolName'] === 'string' ? frame['toolName'] : 'tool',
419
+ reason: typeof frame['reason'] === 'string' ? frame['reason'] : undefined,
420
+ expiresAtMs: Date.now() + deps.approvalTimeoutMs,
421
+ });
422
+ return;
423
+ }
424
+ if (type === 'approval/resolved') {
425
+ deps.requests?.onApprovalResolved({
426
+ sessionId,
427
+ approvalId: typeof frame['approvalId'] === 'string' ? frame['approvalId'] : '',
428
+ outcome: typeof frame['outcome'] === 'string' ? frame['outcome'] : undefined,
429
+ });
430
+ return;
431
+ }
432
+ if (type === 'question/requested') {
433
+ const threadId = deps.threadForSession(sessionId);
434
+ if (threadId === undefined || deps.requests === undefined)
435
+ return;
436
+ const questions = Array.isArray(frame['questions']) ? frame['questions'] : [];
437
+ deps.requests.onQuestionRequested({
438
+ sessionId,
439
+ threadId,
440
+ rpcId: envelopeRpcId ?? '',
441
+ questions,
442
+ expiresAtMs: Date.now() + deps.questionTimeoutMs,
443
+ });
444
+ return;
445
+ }
446
+ if (type === 'question/resolved') {
447
+ const outcome = frame['outcome'] === 'cancelled' ? 'cancelled' : 'answered';
448
+ deps.requests?.onQuestionResolved({
449
+ sessionId,
450
+ questionRpcId: typeof frame['questionRpcId'] === 'string' ? frame['questionRpcId'] : '',
451
+ outcome,
452
+ });
453
+ return;
454
+ }
455
+ if (type !== 'session/event')
456
+ return;
457
+ const threadId = deps.threadForSession(sessionId);
458
+ if (threadId === undefined)
459
+ return;
460
+ const eventWrapper = frame['event'];
461
+ if (eventWrapper === undefined || typeof eventWrapper.type !== 'string')
462
+ return;
463
+ handleSessionEvent(sessionId, threadId, runtimeFor(threadId), { type: eventWrapper.type, data: eventWrapper.data ?? {} }, frame['view']);
464
+ }
465
+ async function runLoop() {
466
+ // Bounded reopen loop: the mux stream is the live accelerator; a dropped
467
+ // stream reopens after backoff while reconciliation covers the gap.
468
+ // The flag is read through a call: dispose() mutates it asynchronously.
469
+ let backoffMs = 1_000;
470
+ const isDisposed = () => state.disposed;
471
+ while (!isDisposed()) {
472
+ try {
473
+ for await (const frame of deps.frames(controller.signal)) {
474
+ if (isDisposed())
475
+ return;
476
+ try {
477
+ const envelopeRpcId = (typeof frame === 'object' && frame !== null && 'rpcId' in frame)
478
+ ? frame.rpcId
479
+ : undefined;
480
+ const payload = (typeof frame === 'object' && frame !== null && 'payload' in frame)
481
+ ? frame.payload
482
+ : frame;
483
+ handleFrame(payload, typeof envelopeRpcId === 'string' ? envelopeRpcId : undefined);
484
+ }
485
+ catch (cause) {
486
+ deps.log?.('discord_live_frame_threw', { cause: String(cause) });
487
+ }
488
+ }
489
+ backoffMs = 1_000;
490
+ }
491
+ catch (cause) {
492
+ if (isDisposed())
493
+ return;
494
+ deps.log?.('discord_live_stream_error', { cause: String(cause) });
495
+ }
496
+ if (isDisposed())
497
+ return;
498
+ await new Promise(resolve => { setTimeout(resolve, backoffMs); });
499
+ backoffMs = Math.min(backoffMs * 2, 30_000);
500
+ }
501
+ }
502
+ void runLoop();
503
+ return {
504
+ dispose() {
505
+ state.disposed = true;
506
+ controller.abort();
507
+ for (const runtime of runtimes.values()) {
508
+ runtime.scheduler?.dispose();
509
+ runtime.activityScheduler?.dispose();
510
+ runtime.typing.dispose();
511
+ }
512
+ runtimes.clear();
513
+ },
514
+ };
515
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Markdown-aware splitting (design.md §8, task 11.6). Fenced code blocks stay
3
+ * balanced across chunk boundaries: a cut inside a fence closes it at the end
4
+ * of the earlier chunk and reopens it — same language — at the top of the
5
+ * next. Output ending inside a fence gets the fence closed. GFM tables are
6
+ * wrapped into fences (wrapGfmTables) before splitting, so they ride the
7
+ * same balance guarantee.
8
+ */
9
+ /** Append a closing fence when `text` ends inside an open block. */
10
+ export declare function closeOpenFences(text: string): string;
11
+ /**
12
+ * Split like the base splitter, then rebalance fences across chunks: each
13
+ * chunk renders as well-formed Markdown on its own, and fence language
14
+ * markers survive the boundary.
15
+ */
16
+ export declare function splitMarkdownAware(text: string, limit: number): string[];
17
+ /**
18
+ * Discord does not render GFM tables: raw pipe rows collapse into ragged
19
+ * text. A table region outside any fence — a header row, a delimiter row,
20
+ * then body rows — is wrapped in a fence so Discord renders it as aligned
21
+ * monospace. Regions inside fences are left untouched, and the resulting
22
+ * fences are ordinary balanced fences to the splitter.
23
+ */
24
+ export declare function wrapGfmTables(text: string): string;