@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,95 @@
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
+ import { splitMessage } from './splitter.js';
10
+ const FENCE_PATTERN = /^\s*(```[a-zA-Z0-9_-]*)\s*$/u;
11
+ /** Append a closing fence when `text` ends inside an open block. */
12
+ export function closeOpenFences(text) {
13
+ const lines = text.split('\n');
14
+ let open = false;
15
+ for (const line of lines) {
16
+ if (FENCE_PATTERN.test(line))
17
+ open = !open;
18
+ }
19
+ return open ? `${text}\n\`\`\`` : text;
20
+ }
21
+ function rebalanceChunk(chunk, state) {
22
+ const lines = chunk.split('\n');
23
+ const out = [];
24
+ if (state.open !== '') {
25
+ out.push(state.open);
26
+ }
27
+ for (const line of lines) {
28
+ if (state.open !== '') {
29
+ out.push(line);
30
+ if (line.trim() === '```') {
31
+ state.open = '';
32
+ }
33
+ continue;
34
+ }
35
+ const match = FENCE_PATTERN.exec(line);
36
+ if (match !== null) {
37
+ state.open = match[1] ?? '```';
38
+ }
39
+ out.push(line);
40
+ }
41
+ if (state.open !== '') {
42
+ out.push('```');
43
+ }
44
+ return out.join('\n');
45
+ }
46
+ /**
47
+ * Split like the base splitter, then rebalance fences across chunks: each
48
+ * chunk renders as well-formed Markdown on its own, and fence language
49
+ * markers survive the boundary.
50
+ */
51
+ export function splitMarkdownAware(text, limit) {
52
+ const base = splitMessage(text, limit);
53
+ const state = { open: '' };
54
+ const rebalanced = base.map(chunk => rebalanceChunk(chunk, state));
55
+ return rebalanced.filter(chunk => chunk.trim() !== '');
56
+ }
57
+ const TABLE_DELIMITER_ROW = /^\s*\|(\s*:?-{3,}:?\s*\|)+\s*$/u;
58
+ function isTableRow(line) {
59
+ const trimmed = line.trim();
60
+ return trimmed.startsWith('|') && trimmed.endsWith('|');
61
+ }
62
+ /**
63
+ * Discord does not render GFM tables: raw pipe rows collapse into ragged
64
+ * text. A table region outside any fence — a header row, a delimiter row,
65
+ * then body rows — is wrapped in a fence so Discord renders it as aligned
66
+ * monospace. Regions inside fences are left untouched, and the resulting
67
+ * fences are ordinary balanced fences to the splitter.
68
+ */
69
+ export function wrapGfmTables(text) {
70
+ const lines = text.split('\n');
71
+ const out = [];
72
+ let inFence = false;
73
+ let index = 0;
74
+ while (index < lines.length) {
75
+ const line = lines[index] ?? '';
76
+ if (FENCE_PATTERN.test(line)) {
77
+ inFence = !inFence;
78
+ out.push(line);
79
+ index += 1;
80
+ continue;
81
+ }
82
+ if (!inFence && isTableRow(line)
83
+ && TABLE_DELIMITER_ROW.test(lines[index + 1] ?? '')) {
84
+ let end = index;
85
+ while (end < lines.length && isTableRow(lines[end] ?? ''))
86
+ end += 1;
87
+ out.push('```', ...lines.slice(index, end), '```');
88
+ index = end;
89
+ continue;
90
+ }
91
+ out.push(line);
92
+ index += 1;
93
+ }
94
+ return out.join('\n');
95
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * The single outbound message builder (design.md §8, task 11.7). Every
3
+ * message path — assistant answers, tool views, titles, errors — constructs
4
+ * its payload here, where mention prevention is layered three ways: the
5
+ * `allowed_mentions: { parse: [] }` request field (the API-level guarantee),
6
+ * silent-delivery flags, and byte-wise syntax neutralization, so a renderer
7
+ * that bypassed one layer still could not resolve a ping.
8
+ */
9
+ /** Discord message flags the adapter always sets (silent delivery). */
10
+ export declare const OUTBOUND_MESSAGE_FLAGS: number;
11
+ export type OutboundContentKind = 'assistant' | 'tool' | 'title' | 'error';
12
+ export interface OutboundMessage {
13
+ content: string;
14
+ flags: number;
15
+ /** No parse category allowed: nothing in the content can ping. */
16
+ allowed_mentions: {
17
+ parse: string[];
18
+ };
19
+ }
20
+ /**
21
+ * Build one outbound payload. Titles are additionally length-capped (they
22
+ * render into headers and badges); all content is mention-neutralized.
23
+ */
24
+ export declare function buildOutboundMessage(input: {
25
+ kind: OutboundContentKind;
26
+ content: string;
27
+ }): OutboundMessage;
@@ -0,0 +1,25 @@
1
+ /**
2
+ * The single outbound message builder (design.md §8, task 11.7). Every
3
+ * message path — assistant answers, tool views, titles, errors — constructs
4
+ * its payload here, where mention prevention is layered three ways: the
5
+ * `allowed_mentions: { parse: [] }` request field (the API-level guarantee),
6
+ * silent-delivery flags, and byte-wise syntax neutralization, so a renderer
7
+ * that bypassed one layer still could not resolve a ping.
8
+ */
9
+ import { ALLOWED_MENTIONS_NONE, DISCORD_SUPPRESS_NOTIFICATIONS_FLAG, safeTitle } from '../policy/disclosure.js';
10
+ import { wrapGfmTables } from './markdown.js';
11
+ import { suppressMentionSyntax } from '../policy/suppress.js';
12
+ /** Discord message flags the adapter always sets (silent delivery). */
13
+ export const OUTBOUND_MESSAGE_FLAGS = DISCORD_SUPPRESS_NOTIFICATIONS_FLAG;
14
+ /**
15
+ * Build one outbound payload. Titles are additionally length-capped (they
16
+ * render into headers and badges); all content is mention-neutralized.
17
+ */
18
+ export function buildOutboundMessage(input) {
19
+ const content = input.kind === 'title'
20
+ ? safeTitle(input.content)
21
+ : input.kind === 'assistant'
22
+ ? suppressMentionSyntax(wrapGfmTables(input.content))
23
+ : suppressMentionSyntax(input.content);
24
+ return { content, flags: OUTBOUND_MESSAGE_FLAGS, allowed_mentions: ALLOWED_MENTIONS_NONE };
25
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Render generation and terminal fences (design.md §8, task 11.4). Every
3
+ * async edit belongs to a generation; a late chunk from a superseded
4
+ * generation is dropped, never overwriting newer output. `finalize` is the
5
+ * terminal fence: it freezes the final content, and after it nothing —
6
+ * stale or current — mutates what Discord saw.
7
+ */
8
+ export interface PublishResult {
9
+ published: boolean;
10
+ visible: string;
11
+ }
12
+ export type FinalizeResult = {
13
+ ok: true;
14
+ } | {
15
+ ok: false;
16
+ error: 'stale-generation' | 'already-final';
17
+ };
18
+ export interface RenderFence {
19
+ /** Open a new generation and return its id. */
20
+ beginGeneration(): number;
21
+ current(): number;
22
+ visible(): string;
23
+ isFinal(): boolean;
24
+ publish(content: string, generation: number): PublishResult;
25
+ finalize(generation: number, finalContent: string): FinalizeResult;
26
+ }
27
+ export declare function createRenderFence(): RenderFence;
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Render generation and terminal fences (design.md §8, task 11.4). Every
3
+ * async edit belongs to a generation; a late chunk from a superseded
4
+ * generation is dropped, never overwriting newer output. `finalize` is the
5
+ * terminal fence: it freezes the final content, and after it nothing —
6
+ * stale or current — mutates what Discord saw.
7
+ */
8
+ export function createRenderFence() {
9
+ let generation = 0;
10
+ let visible = '';
11
+ let final = false;
12
+ return {
13
+ beginGeneration() {
14
+ generation += 1;
15
+ return generation;
16
+ },
17
+ current: () => generation,
18
+ visible: () => visible,
19
+ isFinal: () => final,
20
+ publish(content, gen) {
21
+ if (final || gen !== generation) {
22
+ return { published: false, visible };
23
+ }
24
+ visible = content;
25
+ return { published: true, visible };
26
+ },
27
+ finalize(gen, finalContent) {
28
+ if (final)
29
+ return { ok: false, error: 'already-final' };
30
+ if (gen !== generation)
31
+ return { ok: false, error: 'stale-generation' };
32
+ visible = finalContent;
33
+ final = true;
34
+ return { ok: true };
35
+ },
36
+ };
37
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * The per-thread render model (design.md §8, tasks 11.1 + 11.2). Each DSH
3
+ * assistant message owns ONE logical Discord answer: preceding text deltas
4
+ * coalesce into it, the authoritative `assistant/message` supersedes them, an
5
+ * interruption keeps the visible prefix with a marker, and a later Step opens
6
+ * another logical answer rather than overwriting an earlier one. Events for
7
+ * unknown turns are dropped, never fatal — the reconciler owns recovery.
8
+ */
9
+ export interface RenderAnswer {
10
+ turnId: string;
11
+ stepId: string;
12
+ text: string;
13
+ authoritative: boolean;
14
+ interrupted: boolean;
15
+ }
16
+ export interface RenderSnapshot {
17
+ turnOpen: boolean;
18
+ turnId: string | undefined;
19
+ answers: RenderAnswer[];
20
+ }
21
+ export interface ThreadRenderModel {
22
+ beginTurn(input: {
23
+ turnId: string;
24
+ }): void;
25
+ endTurn(input: {
26
+ turnId: string;
27
+ }): void;
28
+ beginStep(input: {
29
+ turnId: string;
30
+ stepId: string;
31
+ }): void;
32
+ appendDelta(input: {
33
+ turnId: string;
34
+ stepId: string;
35
+ text: string;
36
+ }): void;
37
+ setAuthoritative(input: {
38
+ turnId: string;
39
+ stepId: string;
40
+ text: string;
41
+ }): void;
42
+ interrupt(input: {
43
+ turnId: string;
44
+ stepId: string;
45
+ }): void;
46
+ snapshot(): RenderSnapshot;
47
+ }
48
+ export declare function createThreadRenderModel(): ThreadRenderModel;
@@ -0,0 +1,68 @@
1
+ /**
2
+ * The per-thread render model (design.md §8, tasks 11.1 + 11.2). Each DSH
3
+ * assistant message owns ONE logical Discord answer: preceding text deltas
4
+ * coalesce into it, the authoritative `assistant/message` supersedes them, an
5
+ * interruption keeps the visible prefix with a marker, and a later Step opens
6
+ * another logical answer rather than overwriting an earlier one. Events for
7
+ * unknown turns are dropped, never fatal — the reconciler owns recovery.
8
+ */
9
+ export function createThreadRenderModel() {
10
+ let turnId;
11
+ let turnOpen = false;
12
+ const answers = new Map();
13
+ function answerKey(stepId) {
14
+ return `${turnId ?? ''}:${stepId}`;
15
+ }
16
+ function ensureAnswer(stepId) {
17
+ const key = answerKey(stepId);
18
+ let answer = answers.get(key);
19
+ if (answer === undefined) {
20
+ answer = { turnId: turnId ?? '', stepId, text: '', authoritative: false, interrupted: false };
21
+ answers.set(key, answer);
22
+ }
23
+ return answer;
24
+ }
25
+ return {
26
+ beginTurn(input) {
27
+ if (input.turnId === turnId && turnOpen)
28
+ return;
29
+ turnId = input.turnId;
30
+ turnOpen = true;
31
+ // Answers belong to their turn: a new turn opens a fresh list.
32
+ answers.clear();
33
+ },
34
+ endTurn(input) {
35
+ if (input.turnId === turnId)
36
+ turnOpen = false;
37
+ },
38
+ beginStep(input) {
39
+ if (input.turnId !== turnId)
40
+ return;
41
+ ensureAnswer(input.stepId);
42
+ },
43
+ appendDelta(input) {
44
+ if (input.turnId !== turnId)
45
+ return;
46
+ const answer = ensureAnswer(input.stepId);
47
+ if (answer.authoritative || answer.interrupted)
48
+ return;
49
+ answer.text += input.text;
50
+ },
51
+ setAuthoritative(input) {
52
+ if (input.turnId !== turnId)
53
+ return;
54
+ const answer = ensureAnswer(input.stepId);
55
+ answer.text = input.text;
56
+ answer.authoritative = true;
57
+ },
58
+ interrupt(input) {
59
+ if (input.turnId !== turnId)
60
+ return;
61
+ const answer = ensureAnswer(input.stepId);
62
+ answer.interrupted = true;
63
+ },
64
+ snapshot() {
65
+ return { turnOpen, turnId, answers: [...answers.values()] };
66
+ },
67
+ };
68
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * The base message splitter (design.md §8, task 11.5). Breaks prefer line
3
+ * boundaries, then word boundaries, then a hard cut — iterating by code
4
+ * points so surrogate pairs (emoji) never split mid-pair. Every chunk is
5
+ * non-empty and within Discord's message limit.
6
+ */
7
+ export declare const DISCORD_MESSAGE_LIMIT = 2000;
8
+ export declare function splitMessage(text: string, limit?: number): string[];
@@ -0,0 +1,58 @@
1
+ /**
2
+ * The base message splitter (design.md §8, task 11.5). Breaks prefer line
3
+ * boundaries, then word boundaries, then a hard cut — iterating by code
4
+ * points so surrogate pairs (emoji) never split mid-pair. Every chunk is
5
+ * non-empty and within Discord's message limit.
6
+ */
7
+ export const DISCORD_MESSAGE_LIMIT = 2_000;
8
+ /** Code-point-safe slice: never splits a surrogate pair. */
9
+ function safeSlice(text, start, end) {
10
+ let end2 = Math.min(end, text.length);
11
+ if (end2 < text.length) {
12
+ const code = text.charCodeAt(end2 - 1);
13
+ const next = text.charCodeAt(end2);
14
+ // High surrogate at the last position followed by a low surrogate: pull back.
15
+ if (code >= 0xD800 && code <= 0xDBFF && next >= 0xDC00 && next <= 0xDFFF) {
16
+ end2 -= 1;
17
+ }
18
+ }
19
+ return text.slice(start, end2);
20
+ }
21
+ export function splitMessage(text, limit = DISCORD_MESSAGE_LIMIT) {
22
+ const trimmed = text.trim();
23
+ if (trimmed === '')
24
+ return [];
25
+ if (trimmed.length <= limit)
26
+ return [trimmed];
27
+ const chunks = [];
28
+ let rest = trimmed;
29
+ while (rest.length > limit) {
30
+ const window = rest.slice(0, limit + 1);
31
+ // Prefer the last line break inside the window.
32
+ let cut = window.lastIndexOf('\n');
33
+ if (cut <= 0) {
34
+ // Then the last word boundary.
35
+ cut = window.lastIndexOf(' ');
36
+ }
37
+ if (cut <= 0) {
38
+ // Hard cut at a code-point boundary.
39
+ cut = limit;
40
+ }
41
+ const chunk = safeSlice(rest, 0, cut).trim();
42
+ if (chunk !== '')
43
+ chunks.push(chunk);
44
+ let advance = cut === limit ? limit : cut + 1;
45
+ // safeSlice may have pulled the chunk boundary back one code unit to
46
+ // avoid splitting a surrogate pair; the next chunk must start at that
47
+ // pulled-back position, never at the dangling low surrogate.
48
+ const hi = rest.charCodeAt(advance - 1);
49
+ const lo = rest.charCodeAt(advance);
50
+ if (hi >= 0xD800 && hi <= 0xDBFF && lo >= 0xDC00 && lo <= 0xDFFF)
51
+ advance -= 1;
52
+ rest = rest.slice(advance);
53
+ }
54
+ const tail = rest.trim();
55
+ if (tail !== '')
56
+ chunks.push(tail);
57
+ return chunks;
58
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * The bounded tool activity surface (design.md §8, task 11.8). Parallel tool
3
+ * calls each own one status row keyed by `callId`. Labels come from a safe
4
+ * allowlist (generic fallback otherwise — an unknown tool name never reaches
5
+ * Discord). Raw arguments and output are accepted for correlation but are
6
+ * structurally excluded from every render. Verbosity gates visibility:
7
+ * `text-only` renders nothing; `essential-tools` and `full-tools` render the
8
+ * same bounded rows in Milestone 1.
9
+ */
10
+ import type { DiscordVerbosity } from '../settings.js';
11
+ export type ToolState = 'running' | 'succeeded' | 'failed' | 'interrupted';
12
+ export interface ToolRow {
13
+ callId: string;
14
+ label: string;
15
+ state: ToolState;
16
+ /** Host-presented title (command / call title); falls back to the label. */
17
+ title: string | undefined;
18
+ }
19
+ /** The safe label for a tool name: allowlisted categories, generic fallback. */
20
+ export declare function toolLabel(toolName: string): string;
21
+ export interface ToolRecordInput {
22
+ callId: string;
23
+ toolName: string;
24
+ state: ToolState;
25
+ /** Accepted for correlation only; never rendered. */
26
+ rawArguments?: string | undefined;
27
+ rawOutput?: string | undefined;
28
+ /**
29
+ * The Host presentation view's title (a terminal call's command, or the
30
+ * call title) — Host-curated disclosure, rendered sanitized + truncated.
31
+ */
32
+ title?: string | undefined;
33
+ }
34
+ export interface ToolActivitySurface {
35
+ record(input: ToolRecordInput): void;
36
+ render(): ToolRow[];
37
+ }
38
+ export declare function createToolActivitySurface(options: {
39
+ verbosity: DiscordVerbosity;
40
+ }): ToolActivitySurface;
@@ -0,0 +1,39 @@
1
+ /**
2
+ * The bounded tool activity surface (design.md §8, task 11.8). Parallel tool
3
+ * calls each own one status row keyed by `callId`. Labels come from a safe
4
+ * allowlist (generic fallback otherwise — an unknown tool name never reaches
5
+ * Discord). Raw arguments and output are accepted for correlation but are
6
+ * structurally excluded from every render. Verbosity gates visibility:
7
+ * `text-only` renders nothing; `essential-tools` and `full-tools` render the
8
+ * same bounded rows in Milestone 1.
9
+ */
10
+ /** Safe, category-level labels for allowlisted tools. */
11
+ const TOOL_LABELS = {
12
+ bash: 'Shell',
13
+ read: 'Read file',
14
+ write: 'Write file',
15
+ edit: 'Edit file',
16
+ grep: 'Search',
17
+ glob: 'Find files',
18
+ web: 'Web',
19
+ };
20
+ /** The safe label for a tool name: allowlisted categories, generic fallback. */
21
+ export function toolLabel(toolName) {
22
+ return TOOL_LABELS[toolName] ?? 'Tool';
23
+ }
24
+ export function createToolActivitySurface(options) {
25
+ const rows = new Map();
26
+ return {
27
+ record(input) {
28
+ if (options.verbosity === 'text-only')
29
+ return;
30
+ rows.set(input.callId, {
31
+ callId: input.callId,
32
+ label: toolLabel(input.toolName),
33
+ state: input.state,
34
+ title: input.title,
35
+ });
36
+ },
37
+ render: () => [...rows.values()],
38
+ };
39
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * The typing keepalive lifecycle (design.md §8, task 11.9). Typing starts
3
+ * when a Turn produces work and refreshes on an interval; an owning
4
+ * interaction pauses it (the interaction owns attention); completion,
5
+ * cancellation, and failure all stop it; disposal is terminal — no trigger
6
+ * can fire after the plugin tears the surface down.
7
+ */
8
+ export type TypingStopReason = 'completed' | 'cancelled' | 'failed' | 'disposed';
9
+ export type TypingPauseReason = 'interaction';
10
+ export interface TypingLifecycle {
11
+ start(): void;
12
+ pause(reason: TypingPauseReason): void;
13
+ resume(): void;
14
+ stop(reason?: TypingStopReason): void;
15
+ dispose(): void;
16
+ }
17
+ export declare function createTypingLifecycle(options: {
18
+ trigger: () => void | Promise<void>;
19
+ intervalMs: number;
20
+ /** Observes a failed trigger; the interval keeps the indicator honest. */
21
+ onFailure?: (cause: unknown) => void;
22
+ }): TypingLifecycle;
@@ -0,0 +1,58 @@
1
+ /**
2
+ * The typing keepalive lifecycle (design.md §8, task 11.9). Typing starts
3
+ * when a Turn produces work and refreshes on an interval; an owning
4
+ * interaction pauses it (the interaction owns attention); completion,
5
+ * cancellation, and failure all stop it; disposal is terminal — no trigger
6
+ * can fire after the plugin tears the surface down.
7
+ */
8
+ export function createTypingLifecycle(options) {
9
+ let state = 'idle';
10
+ let timer;
11
+ const fire = () => {
12
+ // The trigger runs detached (fire-and-forget by contract); without this
13
+ // catch a rejecting trigger (e.g. a credential resolution failure) would
14
+ // be an unhandled rejection and kill the process.
15
+ void Promise.resolve(options.trigger()).catch((cause) => {
16
+ options.onFailure?.(cause);
17
+ });
18
+ };
19
+ function clearTimer() {
20
+ if (timer !== undefined) {
21
+ clearInterval(timer);
22
+ timer = undefined;
23
+ }
24
+ }
25
+ return {
26
+ start() {
27
+ if (state !== 'idle')
28
+ return;
29
+ state = 'running';
30
+ fire();
31
+ timer = setInterval(fire, options.intervalMs);
32
+ },
33
+ pause(reason) {
34
+ void reason;
35
+ if (state !== 'running')
36
+ return;
37
+ state = 'paused';
38
+ clearTimer();
39
+ },
40
+ resume() {
41
+ if (state !== 'paused')
42
+ return;
43
+ state = 'running';
44
+ fire();
45
+ timer = setInterval(fire, options.intervalMs);
46
+ },
47
+ stop(reason = 'completed') {
48
+ void reason;
49
+ if (state === 'stopped')
50
+ return;
51
+ state = 'stopped';
52
+ clearTimer();
53
+ },
54
+ dispose() {
55
+ this.stop('disposed');
56
+ },
57
+ };
58
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * The streaming update scheduler (design.md §8, task 11.3). Rapid chunks
3
+ * coalesce: content is remembered as "latest" and one flush per interval
4
+ * carries it to Discord — and because a flush may be an in-flight REST edit,
5
+ * the scheduler serializes: while a flush runs, new content only waits; the
6
+ * next flush fires only when both the interval elapsed AND the content
7
+ * changed. A failed flush is observed HERE (the scheduler runs detached, so
8
+ * its rejection would otherwise be unhandled), reported through
9
+ * `onFlushError`, and retried on the next schedule.
10
+ */
11
+ export interface UpdateScheduler {
12
+ schedule(content: string): void;
13
+ dispose(): void;
14
+ }
15
+ export declare function createUpdateScheduler(options: {
16
+ minIntervalMs: number;
17
+ onFlush(content: string): Promise<void>;
18
+ /** Observes a failed flush; unset, failures are still contained silently. */
19
+ onFlushError?: (cause: unknown) => void;
20
+ }): UpdateScheduler;
@@ -0,0 +1,65 @@
1
+ /**
2
+ * The streaming update scheduler (design.md §8, task 11.3). Rapid chunks
3
+ * coalesce: content is remembered as "latest" and one flush per interval
4
+ * carries it to Discord — and because a flush may be an in-flight REST edit,
5
+ * the scheduler serializes: while a flush runs, new content only waits; the
6
+ * next flush fires only when both the interval elapsed AND the content
7
+ * changed. A failed flush is observed HERE (the scheduler runs detached, so
8
+ * its rejection would otherwise be unhandled), reported through
9
+ * `onFlushError`, and retried on the next schedule.
10
+ */
11
+ export function createUpdateScheduler(options) {
12
+ let disposed = false;
13
+ let latest;
14
+ let lastFlushed;
15
+ let timer;
16
+ let flushing = false;
17
+ function scheduleNext(now) {
18
+ if (disposed || now === undefined || now === lastFlushed)
19
+ return;
20
+ if (timer !== undefined)
21
+ return;
22
+ timer = setTimeout(() => {
23
+ timer = undefined;
24
+ void runFlush();
25
+ }, options.minIntervalMs);
26
+ }
27
+ async function runFlush() {
28
+ if (disposed || flushing)
29
+ return;
30
+ const content = latest;
31
+ if (content === undefined || content === lastFlushed)
32
+ return;
33
+ flushing = true;
34
+ try {
35
+ await options.onFlush(content);
36
+ lastFlushed = content;
37
+ }
38
+ catch (cause) {
39
+ // The flush runs detached (void runFlush()), so this catch is the only
40
+ // thing standing between a failing delivery and a process-killing
41
+ // unhandled rejection. lastFlushed stays put: the next schedule retries.
42
+ options.onFlushError?.(cause);
43
+ }
44
+ finally {
45
+ flushing = false;
46
+ // Content that arrived during the flush gets its own interval now.
47
+ scheduleNext(latest);
48
+ }
49
+ }
50
+ return {
51
+ schedule(content) {
52
+ if (disposed)
53
+ return;
54
+ latest = content;
55
+ scheduleNext(content);
56
+ },
57
+ dispose() {
58
+ disposed = true;
59
+ if (timer !== undefined) {
60
+ clearTimeout(timer);
61
+ timer = undefined;
62
+ }
63
+ },
64
+ };
65
+ }