@copilotkit/channels-discord 0.0.2

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 (123) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +308 -0
  3. package/dist/__tests__/ephemeral.test.d.ts +2 -0
  4. package/dist/__tests__/ephemeral.test.d.ts.map +1 -0
  5. package/dist/__tests__/ephemeral.test.js +26 -0
  6. package/dist/__tests__/intents.test.d.ts +2 -0
  7. package/dist/__tests__/intents.test.d.ts.map +1 -0
  8. package/dist/__tests__/intents.test.js +8 -0
  9. package/dist/__tests__/modal-submit.test.d.ts +2 -0
  10. package/dist/__tests__/modal-submit.test.d.ts.map +1 -0
  11. package/dist/__tests__/modal-submit.test.js +26 -0
  12. package/dist/__tests__/reactions.test.d.ts +2 -0
  13. package/dist/__tests__/reactions.test.d.ts.map +1 -0
  14. package/dist/__tests__/reactions.test.js +87 -0
  15. package/dist/adapter.d.ts +118 -0
  16. package/dist/adapter.d.ts.map +1 -0
  17. package/dist/adapter.js +567 -0
  18. package/dist/adapter.test.d.ts +2 -0
  19. package/dist/adapter.test.d.ts.map +1 -0
  20. package/dist/adapter.test.js +343 -0
  21. package/dist/auto-close-streaming.d.ts +61 -0
  22. package/dist/auto-close-streaming.d.ts.map +1 -0
  23. package/dist/auto-close-streaming.js +276 -0
  24. package/dist/auto-close-streaming.test.d.ts +2 -0
  25. package/dist/auto-close-streaming.test.d.ts.map +1 -0
  26. package/dist/auto-close-streaming.test.js +149 -0
  27. package/dist/built-in-context.d.ts +20 -0
  28. package/dist/built-in-context.d.ts.map +1 -0
  29. package/dist/built-in-context.js +30 -0
  30. package/dist/built-in-context.test.d.ts +2 -0
  31. package/dist/built-in-context.test.d.ts.map +1 -0
  32. package/dist/built-in-context.test.js +21 -0
  33. package/dist/built-in-tools.d.ts +4 -0
  34. package/dist/built-in-tools.d.ts.map +1 -0
  35. package/dist/built-in-tools.js +27 -0
  36. package/dist/built-in-tools.test.d.ts +2 -0
  37. package/dist/built-in-tools.test.d.ts.map +1 -0
  38. package/dist/built-in-tools.test.js +30 -0
  39. package/dist/chunked-message-stream.d.ts +94 -0
  40. package/dist/chunked-message-stream.d.ts.map +1 -0
  41. package/dist/chunked-message-stream.js +203 -0
  42. package/dist/chunked-message-stream.test.d.ts +2 -0
  43. package/dist/chunked-message-stream.test.d.ts.map +1 -0
  44. package/dist/chunked-message-stream.test.js +384 -0
  45. package/dist/commands.d.ts +29 -0
  46. package/dist/commands.d.ts.map +1 -0
  47. package/dist/commands.js +193 -0
  48. package/dist/commands.test.d.ts +2 -0
  49. package/dist/commands.test.d.ts.map +1 -0
  50. package/dist/commands.test.js +281 -0
  51. package/dist/conversation-store.d.ts +45 -0
  52. package/dist/conversation-store.d.ts.map +1 -0
  53. package/dist/conversation-store.js +78 -0
  54. package/dist/conversation-store.test.d.ts +2 -0
  55. package/dist/conversation-store.test.d.ts.map +1 -0
  56. package/dist/conversation-store.test.js +106 -0
  57. package/dist/discord-listener.d.ts +88 -0
  58. package/dist/discord-listener.d.ts.map +1 -0
  59. package/dist/discord-listener.js +118 -0
  60. package/dist/discord-listener.test.d.ts +2 -0
  61. package/dist/discord-listener.test.d.ts.map +1 -0
  62. package/dist/discord-listener.test.js +361 -0
  63. package/dist/download-files.d.ts +94 -0
  64. package/dist/download-files.d.ts.map +1 -0
  65. package/dist/download-files.js +145 -0
  66. package/dist/download-files.test.d.ts +2 -0
  67. package/dist/download-files.test.d.ts.map +1 -0
  68. package/dist/download-files.test.js +181 -0
  69. package/dist/event-renderer.d.ts +36 -0
  70. package/dist/event-renderer.d.ts.map +1 -0
  71. package/dist/event-renderer.js +270 -0
  72. package/dist/event-renderer.test.d.ts +2 -0
  73. package/dist/event-renderer.test.d.ts.map +1 -0
  74. package/dist/event-renderer.test.js +160 -0
  75. package/dist/index.d.ts +24 -0
  76. package/dist/index.d.ts.map +1 -0
  77. package/dist/index.js +17 -0
  78. package/dist/interaction.d.ts +11 -0
  79. package/dist/interaction.d.ts.map +1 -0
  80. package/dist/interaction.js +138 -0
  81. package/dist/interaction.test.d.ts +2 -0
  82. package/dist/interaction.test.d.ts.map +1 -0
  83. package/dist/interaction.test.js +120 -0
  84. package/dist/markdown.d.ts +7 -0
  85. package/dist/markdown.d.ts.map +1 -0
  86. package/dist/markdown.js +66 -0
  87. package/dist/markdown.test.d.ts +2 -0
  88. package/dist/markdown.test.d.ts.map +1 -0
  89. package/dist/markdown.test.js +47 -0
  90. package/dist/message-stream.d.ts +45 -0
  91. package/dist/message-stream.d.ts.map +1 -0
  92. package/dist/message-stream.js +76 -0
  93. package/dist/message-stream.test.d.ts +2 -0
  94. package/dist/message-stream.test.d.ts.map +1 -0
  95. package/dist/message-stream.test.js +162 -0
  96. package/dist/pending-interactions.d.ts +26 -0
  97. package/dist/pending-interactions.d.ts.map +1 -0
  98. package/dist/pending-interactions.js +50 -0
  99. package/dist/pending-interactions.test.d.ts +2 -0
  100. package/dist/pending-interactions.test.d.ts.map +1 -0
  101. package/dist/pending-interactions.test.js +73 -0
  102. package/dist/render/budget.d.ts +28 -0
  103. package/dist/render/budget.d.ts.map +1 -0
  104. package/dist/render/budget.js +49 -0
  105. package/dist/render/budget.test.d.ts +2 -0
  106. package/dist/render/budget.test.d.ts.map +1 -0
  107. package/dist/render/budget.test.js +59 -0
  108. package/dist/render/components-v2.d.ts +16 -0
  109. package/dist/render/components-v2.d.ts.map +1 -0
  110. package/dist/render/components-v2.js +396 -0
  111. package/dist/render/components-v2.test.d.ts +2 -0
  112. package/dist/render/components-v2.test.d.ts.map +1 -0
  113. package/dist/render/components-v2.test.js +395 -0
  114. package/dist/render/modal.d.ts +12 -0
  115. package/dist/render/modal.d.ts.map +1 -0
  116. package/dist/render/modal.js +44 -0
  117. package/dist/types.d.ts +16 -0
  118. package/dist/types.d.ts.map +1 -0
  119. package/dist/types.js +4 -0
  120. package/dist/types.test.d.ts +2 -0
  121. package/dist/types.test.d.ts.map +1 -0
  122. package/dist/types.test.js +12 -0
  123. package/package.json +56 -0
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=auto-close-streaming.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"auto-close-streaming.test.d.ts","sourceRoot":"","sources":["../src/auto-close-streaming.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,149 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { autoCloseOpenMarkdown } from "./auto-close-streaming.js";
3
+ describe("autoCloseOpenMarkdown", () => {
4
+ // ── No-op cases ──────────────────────────────────────────────────
5
+ it("returns empty string unchanged", () => {
6
+ expect(autoCloseOpenMarkdown("")).toBe("");
7
+ });
8
+ it("returns plain text unchanged", () => {
9
+ expect(autoCloseOpenMarkdown("just plain text 123")).toBe("just plain text 123");
10
+ });
11
+ it("does not modify already-balanced markdown", () => {
12
+ expect(autoCloseOpenMarkdown("**bold** and *italic* and ~~strike~~")).toBe("**bold** and *italic* and ~~strike~~");
13
+ });
14
+ // ── Bold ──────────────────────────────────────────────────────────
15
+ it("closes an unbalanced **opening — `**hello` → `**hello**`", () => {
16
+ expect(autoCloseOpenMarkdown("**hello")).toBe("**hello**");
17
+ });
18
+ it("does NOT close a `**` with nothing after it", () => {
19
+ // Just opened — closing would render as `****`, which looks worse.
20
+ expect(autoCloseOpenMarkdown("hi **")).toBe("hi **");
21
+ });
22
+ it("closes `__bold` as `__bold__`", () => {
23
+ expect(autoCloseOpenMarkdown("__bold")).toBe("__bold__");
24
+ });
25
+ // ── Italic ───────────────────────────────────────────────────────
26
+ it("closes `*italic` → `*italic*`", () => {
27
+ expect(autoCloseOpenMarkdown("*italic")).toBe("*italic*");
28
+ });
29
+ it("closes `_italic` → `_italic_`", () => {
30
+ expect(autoCloseOpenMarkdown("_italic")).toBe("_italic_");
31
+ });
32
+ it("does NOT close a `*` with nothing after it", () => {
33
+ expect(autoCloseOpenMarkdown("text *")).toBe("text *");
34
+ });
35
+ it("closes an open italic `*` that precedes an empty trailing `**` — `*ab**`", () => {
36
+ // The trailing `**` is empty (just opened) and is dropped, but the
37
+ // leading `*ab` italic has real content and must still be closed.
38
+ // Regression: lastIndexOf("*") used to match the `*` inside `**`,
39
+ // wrongly treating the italic as empty and leaving it unbalanced.
40
+ expect(autoCloseOpenMarkdown("*ab**")).toBe("*ab***");
41
+ });
42
+ it("closes an open underscore-italic `_` that precedes an empty trailing `__` — `_ab__`", () => {
43
+ expect(autoCloseOpenMarkdown("_ab__")).toBe("_ab___");
44
+ });
45
+ // ── Strike ───────────────────────────────────────────────────────
46
+ it("closes `~~strike` → `~~strike~~`", () => {
47
+ expect(autoCloseOpenMarkdown("~~strike")).toBe("~~strike~~");
48
+ });
49
+ // ── Inline code ──────────────────────────────────────────────────
50
+ it("closes `` `code `` → `` `code` ``", () => {
51
+ expect(autoCloseOpenMarkdown("`code")).toBe("`code`");
52
+ });
53
+ it("doesn't touch content inside paired inline code", () => {
54
+ // `code with *stars*` — the stars are inside code, so no italic close.
55
+ expect(autoCloseOpenMarkdown("`code *stars*` and more")).toBe("`code *stars*` and more");
56
+ });
57
+ it("treats unbalanced markers INSIDE inline code as opaque", () => {
58
+ // `**foo` is inside an unclosed inline → we close the backtick, but
59
+ // don't try to close the `**` inside code.
60
+ expect(autoCloseOpenMarkdown("see `**foo")).toBe("see `**foo`");
61
+ });
62
+ // ── Fenced code ──────────────────────────────────────────────────
63
+ it("closes an open fence — adds `\\n```` after the content", () => {
64
+ const input = "```py\ndef foo():";
65
+ const out = autoCloseOpenMarkdown(input);
66
+ expect(out.startsWith(input)).toBe(true);
67
+ expect(out.trim().endsWith("```")).toBe(true);
68
+ });
69
+ it("does NOT close a bare ``` with nothing after it (just opened)", () => {
70
+ expect(autoCloseOpenMarkdown("```")).toBe("```");
71
+ expect(autoCloseOpenMarkdown("```py")).toBe("```py"); // language tag alone
72
+ });
73
+ it("balanced fences left untouched", () => {
74
+ const input = "before\n```\ncode\n```\nafter";
75
+ expect(autoCloseOpenMarkdown(input)).toBe(input);
76
+ });
77
+ it("balanced fence + unbalanced markers in surrounding text close correctly", () => {
78
+ // The fence is paired; the trailing `**hel` outside the fence needs close.
79
+ const input = "**hel ```code``` more text";
80
+ const out = autoCloseOpenMarkdown(input);
81
+ expect(out).toContain("**hel");
82
+ expect(out).toContain("```code```");
83
+ expect(out).toContain("**"); // close
84
+ });
85
+ it("markers inside paired fences don't count toward balance", () => {
86
+ // Inside the fence we have `**oops`, but the markdown text is balanced.
87
+ const input = "```\n**oops never closes\n``` after";
88
+ expect(autoCloseOpenMarkdown(input)).toBe(input);
89
+ });
90
+ // ── Nesting ──────────────────────────────────────────────────────
91
+ it("closes innermost first — `**bold _italic` → `**bold _italic_**`", () => {
92
+ expect(autoCloseOpenMarkdown("**bold _italic")).toBe("**bold _italic_**");
93
+ });
94
+ it("doesn't double-close already-balanced inner markers", () => {
95
+ expect(autoCloseOpenMarkdown("**bold _italic_ tail")).toBe("**bold _italic_ tail**");
96
+ });
97
+ // ── Stream-evolution: simulate consecutive deltas; the agent's eventual
98
+ // close should NOT cause double-closes in the produced text. ──
99
+ it("stream-evolution: agent eventually closes; auto-close adds nothing at that point", () => {
100
+ const states = [
101
+ "**h",
102
+ "**he",
103
+ "**hel",
104
+ "**hell",
105
+ "**hello",
106
+ "**hello*",
107
+ "**hello**",
108
+ ];
109
+ const out = states.map(autoCloseOpenMarkdown);
110
+ // Intermediate states are closed
111
+ expect(out[0]).toBe("**h**");
112
+ expect(out[4]).toBe("**hello**");
113
+ // Final state is unchanged (already balanced)
114
+ expect(out[out.length - 1]).toBe("**hello**");
115
+ });
116
+ it("stream-evolution for fenced code: closes mid-stream; goes silent on real close", () => {
117
+ const states = [
118
+ "```py\n",
119
+ "```py\ndef ",
120
+ "```py\ndef foo():",
121
+ "```py\ndef foo():\n pass\n```",
122
+ ];
123
+ const out = states.map(autoCloseOpenMarkdown);
124
+ // First state: just opened — no content yet, don't auto-close
125
+ expect(out[0]).toBe("```py\n");
126
+ // Middle states: closed
127
+ expect(out[1].trim().endsWith("```")).toBe(true);
128
+ expect(out[2].trim().endsWith("```")).toBe(true);
129
+ // Final: agent emitted the close, balanced, no change
130
+ expect(out[3]).toBe(states[3]);
131
+ });
132
+ // ── Combined ────────────────────────────────────────────────────
133
+ it("closes bold before an unclosed fence opener, and closes the fence too", () => {
134
+ // `**bold ` is open bold then a space; `\`\`\`code\n` opens a fence with
135
+ // content. We close bold (with closer inserted before the trailing
136
+ // space, so the space stays adjacent to the fence) and close the fence.
137
+ const input = "**bold ```py\ndef foo():";
138
+ const out = autoCloseOpenMarkdown(input);
139
+ expect(out).toContain("**bold**"); // bold closed before fence
140
+ expect(out).toContain("def foo():"); // fence content preserved
141
+ expect(out.trim().endsWith("```")).toBe(true); // fence closed
142
+ });
143
+ // ── Sanity: long input ─────────────────────────────────────────
144
+ it("handles a long buffer with mixed structures", () => {
145
+ const lots = "**Bold opener** and `code` and *italic* and `unclosed inline";
146
+ const out = autoCloseOpenMarkdown(lots);
147
+ expect(out).toBe("**Bold opener** and `code` and *italic* and `unclosed inline`");
148
+ });
149
+ });
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Discord-platform-universal context entries — knowledge the LLM needs
3
+ * about Discord itself (tagging procedure, formatting, conversation
4
+ * model). Apps spread `defaultDiscordContext` into the `context:` config
5
+ * they pass to `createBot`.
6
+ *
7
+ * Each entry is exported individually too so apps can cherry-pick.
8
+ */
9
+ import type { ContextEntry } from "@copilotkit/channels";
10
+ export declare const discordTaggingContext: ContextEntry;
11
+ export declare const discordFormattingContext: ContextEntry;
12
+ export declare const discordConversationModelContext: ContextEntry;
13
+ /**
14
+ * The default context entries the SDK ships. Spread into your
15
+ * `createBot({context: …})`:
16
+ *
17
+ * context: [...defaultDiscordContext, ...myAppContext],
18
+ */
19
+ export declare const defaultDiscordContext: ContextEntry[];
20
+ //# sourceMappingURL=built-in-context.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"built-in-context.d.ts","sourceRoot":"","sources":["../src/built-in-context.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,sBAAsB,CAAC;AAEzD,eAAO,MAAM,qBAAqB,EAAE,YAMnC,CAAC;AAEF,eAAO,MAAM,wBAAwB,EAAE,YAOtC,CAAC;AAEF,eAAO,MAAM,+BAA+B,EAAE,YAM7C,CAAC;AAEF;;;;;GAKG;AACH,eAAO,MAAM,qBAAqB,EAAE,YAAY,EAI/C,CAAC"}
@@ -0,0 +1,30 @@
1
+ export const discordTaggingContext = {
2
+ description: "How to tag/mention people in Discord",
3
+ value: "To mention a user, use <@USER_ID> (numeric id). If you only know a name or " +
4
+ "handle, call lookup_discord_user to resolve it to an id first, then mention " +
5
+ "with <@id>. Mention a channel with <#CHANNEL_ID>.",
6
+ };
7
+ export const discordFormattingContext = {
8
+ description: "Discord message formatting",
9
+ value: "Use standard Markdown: **bold**, *italic*, `code`, ```fenced``` blocks, > quotes, " +
10
+ "and # / ## / ### headings. Discord renders NO tables — never output a pipe table; " +
11
+ "put tabular data in a fenced code block or call the appropriate render tool. " +
12
+ "Mentions use <@USER_ID> and channels <#CHANNEL_ID>.",
13
+ };
14
+ export const discordConversationModelContext = {
15
+ description: "The Discord conversation model",
16
+ value: "Each channel, thread, and DM is one conversation. You reply in the same channel " +
17
+ "the message arrived in. Use the read-history tool to see prior messages when you " +
18
+ "need context from earlier in the conversation.",
19
+ };
20
+ /**
21
+ * The default context entries the SDK ships. Spread into your
22
+ * `createBot({context: …})`:
23
+ *
24
+ * context: [...defaultDiscordContext, ...myAppContext],
25
+ */
26
+ export const defaultDiscordContext = [
27
+ discordTaggingContext,
28
+ discordFormattingContext,
29
+ discordConversationModelContext,
30
+ ];
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=built-in-context.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"built-in-context.test.d.ts","sourceRoot":"","sources":["../src/built-in-context.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,21 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { discordFormattingContext, discordConversationModelContext, discordTaggingContext, defaultDiscordContext, } from "./built-in-context.js";
3
+ describe("defaultDiscordContext", () => {
4
+ it("bundles the three context entries", () => {
5
+ expect(defaultDiscordContext).toEqual([
6
+ discordTaggingContext,
7
+ discordFormattingContext,
8
+ discordConversationModelContext,
9
+ ]);
10
+ });
11
+ it("each entry has a description and value", () => {
12
+ for (const e of defaultDiscordContext) {
13
+ expect(typeof e.description).toBe("string");
14
+ expect(e.value.length).toBeGreaterThan(0);
15
+ }
16
+ });
17
+ it("formatting guidance warns there are no tables and gives mention syntax", () => {
18
+ expect(discordFormattingContext.value).toMatch(/table/i);
19
+ expect(discordFormattingContext.value).toContain("<@");
20
+ });
21
+ });
@@ -0,0 +1,4 @@
1
+ import type { BotTool } from "@copilotkit/channels";
2
+ export declare const lookupDiscordUserTool: BotTool;
3
+ export declare const defaultDiscordTools: BotTool[];
4
+ //# sourceMappingURL=built-in-tools.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"built-in-tools.d.ts","sourceRoot":"","sources":["../src/built-in-tools.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,sBAAsB,CAAC;AAEpD,eAAO,MAAM,qBAAqB,EAAE,OAsBlC,CAAC;AAEH,eAAO,MAAM,mBAAmB,EAAE,OAAO,EAA4B,CAAC"}
@@ -0,0 +1,27 @@
1
+ import { z } from "zod";
2
+ import { defineBotTool } from "@copilotkit/channels";
3
+ export const lookupDiscordUserTool = defineBotTool({
4
+ name: "lookup_discord_user",
5
+ description: "Resolve a person's name, display name, or handle to a Discord user id and a " +
6
+ "ready-to-use <@id> mention.",
7
+ parameters: z.object({
8
+ query: z
9
+ .string()
10
+ .min(1)
11
+ .describe("A name, display name, or handle to resolve."),
12
+ }),
13
+ async handler({ query }, { thread }) {
14
+ let user;
15
+ try {
16
+ user = await thread.lookupUser(query);
17
+ }
18
+ catch (error) {
19
+ console.error(`lookup_discord_user failed for "${query}":`, error);
20
+ return `Couldn't resolve a Discord user for "${query}" (lookup unavailable).`;
21
+ }
22
+ if (!user)
23
+ return `No Discord user found matching "${query}".`;
24
+ return { id: user.id, name: user.name, mention: `<@${user.id}>` };
25
+ },
26
+ });
27
+ export const defaultDiscordTools = [lookupDiscordUserTool];
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=built-in-tools.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"built-in-tools.test.d.ts","sourceRoot":"","sources":["../src/built-in-tools.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,30 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { lookupDiscordUserTool, defaultDiscordTools, } from "./built-in-tools.js";
3
+ describe("lookupDiscordUserTool", () => {
4
+ it("resolves a query via thread.lookupUser and returns a mention", async () => {
5
+ const thread = {
6
+ lookupUser: vi.fn(async () => ({ id: "u9", name: "Ann", handle: "ann" })),
7
+ };
8
+ const res = await lookupDiscordUserTool.handler({ query: "ann" }, {
9
+ thread,
10
+ });
11
+ expect(thread.lookupUser).toHaveBeenCalledWith("ann");
12
+ expect(JSON.stringify(res)).toContain("<@u9>");
13
+ });
14
+ it("returns a graceful string when thread.lookupUser rejects", async () => {
15
+ const thread = {
16
+ lookupUser: vi.fn(async () => {
17
+ throw new Error("intent missing");
18
+ }),
19
+ };
20
+ const res = await lookupDiscordUserTool.handler({ query: "ann" }, {
21
+ thread,
22
+ });
23
+ expect(thread.lookupUser).toHaveBeenCalledWith("ann");
24
+ expect(typeof res).toBe("string");
25
+ expect(res).toContain("lookup unavailable");
26
+ });
27
+ it("is included in defaultDiscordTools", () => {
28
+ expect(defaultDiscordTools).toContain(lookupDiscordUserTool);
29
+ });
30
+ });
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Placeholder texts posted for streaming messages: the first chunk shows
3
+ * "_thinking…_" and later (continuation) chunks show "_…(continued)_" until
4
+ * real content lands. Exported as a shared const so the producer here and the
5
+ * history filter in adapter.ts (which excludes the bot's own placeholders from
6
+ * `read_thread`) never drift apart. The `…` is a real U+2026 ellipsis.
7
+ */
8
+ export declare const STREAM_PLACEHOLDERS: readonly ["_thinking…_", "_…(continued)_"];
9
+ /**
10
+ * Discord's HARD per-message content limit is 2000 chars. For longer agent
11
+ * responses we spread the text over several Discord messages — but the
12
+ * *chunk boundaries* must be stable once committed (we can't reflow a
13
+ * sentence into a previous chunk after that chunk has already been posted
14
+ * to Discord).
15
+ *
16
+ * ChunkedMessageStream owns N underlying `MessageStream`s (one per Discord
17
+ * message). It accepts the full accumulated text on `append()`, decides
18
+ * where the chunk boundaries are, mints new Discord messages as needed,
19
+ * and dispatches the right slice to each chunk's stream.
20
+ *
21
+ * Boundaries are chosen greedily: once an active chunk exceeds the soft
22
+ * limit, freeze the boundary at the last newline (or last space) within
23
+ * the limit; remaining text becomes the next chunk. Boundaries don't
24
+ * move once frozen, so an already-posted chunk's text never shrinks.
25
+ */
26
+ export interface ChunkedMessageStreamConfig {
27
+ /**
28
+ * Soft per-message char limit used to choose chunk boundaries. Defaults to
29
+ * 1900 — deliberately below Discord's 2000 hard limit so the per-chunk
30
+ * `transform` has headroom to grow the text without overflowing. The
31
+ * transform appends a fence/marker closer (autoCloseOpenMarkdown) and, for
32
+ * continuation chunks, prepends a re-opener (e.g. "```longlangname\n"); a
33
+ * chunk sliced to exactly 2000 raw chars would exceed 2000 once transformed
34
+ * and Discord would reject the edit with BASE_TYPE_MAX_LENGTH.
35
+ */
36
+ limit?: number;
37
+ /** Throttle floor for each underlying stream's message edit. */
38
+ minIntervalMs?: number;
39
+ /** Posts a new Discord message with placeholder text; resolves with its id. */
40
+ postPlaceholder: (text: string) => Promise<string>;
41
+ /** Updates the Discord message at `id` with `text`. */
42
+ updateAt: (id: string, text: string) => Promise<void>;
43
+ /**
44
+ * Optional transformer for the text *just before* it hits the edit call —
45
+ * e.g. markdown translation. Applied per-chunk.
46
+ * Defaults to `discordMarkdown`.
47
+ */
48
+ transform?: (text: string) => string;
49
+ }
50
+ export declare class ChunkedMessageStream {
51
+ private buffer;
52
+ /** Sorted positions where a chunk ends (= where the next chunk begins). */
53
+ private boundaries;
54
+ private streams;
55
+ /** Serialises new-chunk creation so async postPlaceholder calls don't race. */
56
+ private setupPromise;
57
+ /**
58
+ * Records the first setup-chain failure (e.g. a rejecting `postPlaceholder`).
59
+ * The chain itself is kept rejection-free via a `.catch` so no interim
60
+ * unhandled promise rejection is emitted; the stored error is rethrown
61
+ * deterministically at the next `append`/`finish`.
62
+ */
63
+ private setupError;
64
+ private finished;
65
+ private readonly limit;
66
+ private readonly minIntervalMs;
67
+ private readonly postPlaceholder;
68
+ private readonly updateAt;
69
+ private readonly transform;
70
+ constructor(config: ChunkedMessageStreamConfig);
71
+ append(fullText: string): void;
72
+ finish(): Promise<void>;
73
+ /** Wraps the recorded setup failure with context for surfacing to callers. */
74
+ private asSetupError;
75
+ /** Returns the number of Discord messages this stream has posted so far. */
76
+ get chunkCount(): number;
77
+ /**
78
+ * Walk forward from the last frozen boundary, freezing new ones whenever
79
+ * the active chunk's length exceeds the soft limit. Once frozen, a
80
+ * boundary doesn't move.
81
+ *
82
+ * Special case (block-keeps-whole): if the chosen boundary lands INSIDE
83
+ * an open fenced code block, we try to move the boundary BACK to the
84
+ * position right before the fence opener, so the *whole* block lives in
85
+ * the next Discord message rather than being split. The previous chunk
86
+ * gets shortened (message edit will update it down). Fallback when the
87
+ * block is too large to fit in one chunk: keep the inside-fence
88
+ * boundary; the dispatcher will prepend the fence opener to the next
89
+ * chunk (re-opener path).
90
+ */
91
+ private refreezeBoundaries;
92
+ private ensureStreamsAndDispatch;
93
+ }
94
+ //# sourceMappingURL=chunked-message-stream.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"chunked-message-stream.d.ts","sourceRoot":"","sources":["../src/chunked-message-stream.ts"],"names":[],"mappings":"AAUA;;;;;;GAMG;AACH,eAAO,MAAM,mBAAmB,4CAA6C,CAAC;AAyC9E;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,WAAW,0BAA0B;IACzC;;;;;;;;OAQG;IACH,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,gEAAgE;IAChE,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,+EAA+E;IAC/E,eAAe,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,CAAC;IACnD,uDAAuD;IACvD,QAAQ,EAAE,CAAC,EAAE,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IACtD;;;;OAIG;IACH,SAAS,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,CAAC;CACtC;AAED,qBAAa,oBAAoB;IAC/B,OAAO,CAAC,MAAM,CAAM;IACpB,2EAA2E;IAC3E,OAAO,CAAC,UAAU,CAAgB;IAClC,OAAO,CAAC,OAAO,CAAuB;IACtC,+EAA+E;IAC/E,OAAO,CAAC,YAAY,CAAoC;IACxD;;;;;OAKG;IACH,OAAO,CAAC,UAAU,CAAsB;IACxC,OAAO,CAAC,QAAQ,CAAS;IAEzB,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAS;IAC/B,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAqB;IACnD,OAAO,CAAC,QAAQ,CAAC,eAAe,CAAoC;IACpE,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAA8C;IACvE,OAAO,CAAC,QAAQ,CAAC,SAAS,CAA2B;gBAEzC,MAAM,EAAE,0BAA0B;IAQ9C,MAAM,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI;IAmBxB,MAAM,IAAI,OAAO,CAAC,IAAI,CAAC;IAe7B,8EAA8E;IAC9E,OAAO,CAAC,YAAY;IAKpB,4EAA4E;IAC5E,IAAI,UAAU,IAAI,MAAM,CAEvB;IAED;;;;;;;;;;;;;OAaG;IACH,OAAO,CAAC,kBAAkB;YA0BZ,wBAAwB;CAwCvC"}
@@ -0,0 +1,203 @@
1
+ import { MessageStream } from "./message-stream.js";
2
+ import { detectOpenContext, renderContextOpener, } from "./auto-close-streaming.js";
3
+ import { discordMarkdown } from "./markdown.js";
4
+ /** Discord's hard per-message content limit. Edits above this are rejected. */
5
+ const DISCORD_HARD_LIMIT = 2000;
6
+ /**
7
+ * Placeholder texts posted for streaming messages: the first chunk shows
8
+ * "_thinking…_" and later (continuation) chunks show "_…(continued)_" until
9
+ * real content lands. Exported as a shared const so the producer here and the
10
+ * history filter in adapter.ts (which excludes the bot's own placeholders from
11
+ * `read_thread`) never drift apart. The `…` is a real U+2026 ellipsis.
12
+ */
13
+ export const STREAM_PLACEHOLDERS = ["_thinking…_", "_…(continued)_"];
14
+ /**
15
+ * Soft chunk limit. Sits ~100 chars below the hard limit to leave headroom
16
+ * for the per-chunk transform, which can both append a closer ("\n```") and
17
+ * prepend a continuation re-opener ("```longlangname\n").
18
+ */
19
+ const DEFAULT_LIMIT = 1900;
20
+ /**
21
+ * Last-resort safety net: guarantee a transformed chunk never exceeds
22
+ * Discord's hard limit. The 1900-char soft limit is the primary defence;
23
+ * this clamp only fires in pathological cases (e.g. an enormous re-opener
24
+ * language tag). If truncation severs an open code fence, re-append a
25
+ * closing fence so the message still renders as a balanced block — at the
26
+ * cost of dropping the overflow characters, which is preferable to a
27
+ * rejected edit that freezes the chunk on its placeholder.
28
+ */
29
+ function clampToHardLimit(text) {
30
+ if (text.length <= DISCORD_HARD_LIMIT)
31
+ return text;
32
+ let clamped = text.slice(0, DISCORD_HARD_LIMIT);
33
+ // Odd number of triple-backticks → an open fence was severed. Reserve room
34
+ // for a "\n```" closer (4 chars) and re-append it to keep the fence balanced.
35
+ if ((clamped.match(/```/g) || []).length % 2 === 1) {
36
+ clamped = clamped.slice(0, DISCORD_HARD_LIMIT - 4) + "\n```";
37
+ }
38
+ return clamped;
39
+ }
40
+ /**
41
+ * Position (0-based) of the unpaired opening ``` in `buffer.slice(0, end)`,
42
+ * or null if all triple-backticks before `end` are paired.
43
+ */
44
+ function findUnpairedFenceStart(buffer, end) {
45
+ const before = buffer.slice(0, end);
46
+ const parts = before.split("```");
47
+ // length is odd → balanced; even → one unpaired opener exists
48
+ if (parts.length % 2 !== 0)
49
+ return null;
50
+ return before.lastIndexOf("```");
51
+ }
52
+ export class ChunkedMessageStream {
53
+ buffer = "";
54
+ /** Sorted positions where a chunk ends (= where the next chunk begins). */
55
+ boundaries = [];
56
+ streams = [];
57
+ /** Serialises new-chunk creation so async postPlaceholder calls don't race. */
58
+ setupPromise = Promise.resolve();
59
+ /**
60
+ * Records the first setup-chain failure (e.g. a rejecting `postPlaceholder`).
61
+ * The chain itself is kept rejection-free via a `.catch` so no interim
62
+ * unhandled promise rejection is emitted; the stored error is rethrown
63
+ * deterministically at the next `append`/`finish`.
64
+ */
65
+ setupError = undefined;
66
+ finished = false;
67
+ limit;
68
+ minIntervalMs;
69
+ postPlaceholder;
70
+ updateAt;
71
+ transform;
72
+ constructor(config) {
73
+ this.limit = config.limit ?? DEFAULT_LIMIT;
74
+ this.minIntervalMs = config.minIntervalMs;
75
+ this.postPlaceholder = config.postPlaceholder;
76
+ this.updateAt = config.updateAt;
77
+ this.transform = config.transform ?? discordMarkdown;
78
+ }
79
+ append(fullText) {
80
+ if (this.finished)
81
+ return;
82
+ // A prior setup-chain failure is fatal for the stream — surface it
83
+ // synchronously here rather than chaining more work onto a doomed promise.
84
+ if (this.setupError !== undefined)
85
+ throw this.asSetupError();
86
+ if (fullText === this.buffer)
87
+ return;
88
+ this.buffer = fullText;
89
+ this.refreezeBoundaries();
90
+ // Make sure we have one Discord message per chunk, then dispatch. The
91
+ // `.catch` keeps `setupPromise` itself rejection-free (no unhandled
92
+ // rejection between this append and the next surfacing point); the failure
93
+ // is recorded once and rethrown at the next append/finish.
94
+ this.setupPromise = this.setupPromise
95
+ .then(() => this.ensureStreamsAndDispatch())
96
+ .catch((err) => {
97
+ if (this.setupError === undefined)
98
+ this.setupError = err;
99
+ });
100
+ }
101
+ async finish() {
102
+ this.finished = true;
103
+ // Drain any pending setup, then a final dispatch, then finish each stream.
104
+ this.setupPromise = this.setupPromise
105
+ .then(() => this.ensureStreamsAndDispatch())
106
+ .catch((err) => {
107
+ if (this.setupError === undefined)
108
+ this.setupError = err;
109
+ });
110
+ await this.setupPromise;
111
+ // Surface a setup failure (e.g. a rejecting postPlaceholder) here so the
112
+ // caller still sees it — just without an interim unhandled rejection.
113
+ if (this.setupError !== undefined)
114
+ throw this.asSetupError();
115
+ for (const s of this.streams)
116
+ await s.finish();
117
+ }
118
+ /** Wraps the recorded setup failure with context for surfacing to callers. */
119
+ asSetupError() {
120
+ const cause = this.setupError;
121
+ return new Error("ChunkedMessageStream setup failed", { cause });
122
+ }
123
+ /** Returns the number of Discord messages this stream has posted so far. */
124
+ get chunkCount() {
125
+ return this.streams.length;
126
+ }
127
+ /**
128
+ * Walk forward from the last frozen boundary, freezing new ones whenever
129
+ * the active chunk's length exceeds the soft limit. Once frozen, a
130
+ * boundary doesn't move.
131
+ *
132
+ * Special case (block-keeps-whole): if the chosen boundary lands INSIDE
133
+ * an open fenced code block, we try to move the boundary BACK to the
134
+ * position right before the fence opener, so the *whole* block lives in
135
+ * the next Discord message rather than being split. The previous chunk
136
+ * gets shortened (message edit will update it down). Fallback when the
137
+ * block is too large to fit in one chunk: keep the inside-fence
138
+ * boundary; the dispatcher will prepend the fence opener to the next
139
+ * chunk (re-opener path).
140
+ */
141
+ refreezeBoundaries() {
142
+ let lastFrozen = this.boundaries.at(-1) ?? 0;
143
+ while (this.buffer.length - lastFrozen > this.limit) {
144
+ const window = this.buffer.slice(lastFrozen, lastFrozen + this.limit);
145
+ let breakAt = window.lastIndexOf("\n");
146
+ if (breakAt < this.limit / 4)
147
+ breakAt = window.lastIndexOf(" ");
148
+ if (breakAt < 0)
149
+ breakAt = this.limit - 1;
150
+ let candidate = lastFrozen + breakAt + 1;
151
+ // Block-keeps-whole: move boundary back if it lands inside an open
152
+ // fence. Only adjust if the fence opener is in the latter ~70% of
153
+ // the active chunk — otherwise shrinking would lose too much
154
+ // content (chunk N would be tiny) and the re-opener path is better.
155
+ const fenceStart = findUnpairedFenceStart(this.buffer, candidate);
156
+ if (fenceStart !== null) {
157
+ const minAcceptable = lastFrozen + Math.floor(this.limit * 0.3);
158
+ if (fenceStart > minAcceptable) {
159
+ candidate = fenceStart;
160
+ }
161
+ }
162
+ this.boundaries.push(candidate);
163
+ lastFrozen = candidate;
164
+ }
165
+ }
166
+ async ensureStreamsAndDispatch() {
167
+ // If we've never received any content, don't post a placeholder. This
168
+ // matters for the empty-response case: a TEXT_MESSAGE_START + END
169
+ // pair with no content events should produce no Discord message.
170
+ if (this.buffer.length === 0)
171
+ return;
172
+ const chunkCount = this.boundaries.length + 1;
173
+ while (this.streams.length < chunkCount) {
174
+ const i = this.streams.length;
175
+ const placeholder = i === 0 ? STREAM_PLACEHOLDERS[0] : STREAM_PLACEHOLDERS[1];
176
+ const id = await this.postPlaceholder(placeholder);
177
+ this.streams.push(new MessageStream({
178
+ update: (text) => this.updateAt(id, clampToHardLimit(this.transform(text)) || " "),
179
+ minIntervalMs: this.minIntervalMs,
180
+ }));
181
+ }
182
+ // Dispatch slices.
183
+ //
184
+ // For continuation chunks (i > 0), if the boundary fell inside an open
185
+ // markdown construct (e.g. inside a ```python block), prepend the
186
+ // opener for that construct to the slice. Otherwise the second Discord
187
+ // message would begin with raw code that Discord renders as plain text.
188
+ // The closer at the end of the chunk is added by `transform`
189
+ // (autoCloseOpenMarkdown), which runs per chunk.
190
+ for (let i = 0; i < chunkCount; i++) {
191
+ const start = i === 0 ? 0 : this.boundaries[i - 1];
192
+ const end = i < this.boundaries.length ? this.boundaries[i] : this.buffer.length;
193
+ let slice = this.buffer.slice(start, end);
194
+ if (i > 0) {
195
+ const ctx = detectOpenContext(this.buffer.slice(0, start));
196
+ const opener = renderContextOpener(ctx);
197
+ if (opener)
198
+ slice = opener + slice;
199
+ }
200
+ this.streams[i].append(slice);
201
+ }
202
+ }
203
+ }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=chunked-message-stream.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"chunked-message-stream.test.d.ts","sourceRoot":"","sources":["../src/chunked-message-stream.test.ts"],"names":[],"mappings":""}