pi-roundtable 0.9.3 → 0.9.6

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 (42) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +4 -0
  3. package/README.zh-TW.md +4 -0
  4. package/docs/plugins.md +32 -6
  5. package/package.json +1 -1
  6. package/src/core/agents/agent-prompt.ts +1 -1
  7. package/src/core/builtin/discord-admin.ts +6 -2
  8. package/src/core/builtin/discord.ts +10 -0
  9. package/src/core/config/adapters.ts +47 -0
  10. package/src/core/config/config.ts +9 -34
  11. package/src/core/define-roundtable.ts +2 -0
  12. package/src/core/define.ts +8 -0
  13. package/src/core/discord/channel-files.ts +107 -0
  14. package/src/core/discord/channel-operations.ts +100 -33
  15. package/src/core/discord/discord-surface.ts +21 -4
  16. package/src/core/discord/dispatch-thread-host.ts +11 -0
  17. package/src/core/discord/dispatch-threads.ts +14 -0
  18. package/src/core/discord/inbound-message.ts +34 -0
  19. package/src/core/discord/owner-card-rows.ts +98 -0
  20. package/src/core/discord/owner-cards-fixture.ts +3 -0
  21. package/src/core/discord/owner-cards.ts +137 -105
  22. package/src/core/host.ts +9 -1
  23. package/src/core/i18n/cards.ts +6 -8
  24. package/src/core/i18n/conversation.ts +15 -0
  25. package/src/core/interactions/prompts.ts +36 -7
  26. package/src/core/modules/discord-admin/discord-admin.ts +16 -2
  27. package/src/core/plugin.ts +5 -0
  28. package/src/core/runtime/compaction-tiers.ts +61 -8
  29. package/src/core/runtime/conversation-sessions.ts +2 -0
  30. package/src/core/runtime/extensions/ask-user.ts +21 -4
  31. package/src/core/runtime/extensions/confirmation-gate.ts +73 -11
  32. package/src/core/runtime/prompt-slot.ts +6 -4
  33. package/src/core/runtime/runtime-types.ts +5 -0
  34. package/src/core/runtime/session-factory.ts +22 -2
  35. package/src/core/sessions.ts +6 -0
  36. package/src/core/shared/attach-file-tool.ts +72 -0
  37. package/src/core/shared/workspace-files.ts +76 -0
  38. package/src/core/testing/thread-host.ts +14 -0
  39. package/src/index.ts +2 -0
  40. package/src/kit/index.ts +2 -0
  41. package/src/kit/worker.ts +2 -0
  42. package/src/testing.ts +7 -1
@@ -38,7 +38,7 @@ const message = { channelId: id, messageId: id };
38
38
  const strict = { additionalProperties: false } as const;
39
39
 
40
40
  /** One upload may carry at most this much, in all files together. */
41
- const CHANNEL_UPLOAD_MAX_BYTES = 8 * 1024 * 1024;
41
+ export const CHANNEL_UPLOAD_MAX_BYTES = 8 * 1024 * 1024;
42
42
 
43
43
  const file = Type.Object(
44
44
  {
@@ -70,12 +70,90 @@ const files = Type.Optional(
70
70
  }),
71
71
  );
72
72
 
73
+ /** A file a session sends: one on its disk by path, or small generated data inline. */
74
+ const localFile = Type.Object(
75
+ {
76
+ path: Type.Optional(
77
+ Type.String({
78
+ minLength: 1,
79
+ maxLength: 4096,
80
+ description:
81
+ "A file on disk inside the workspace or the scratch dir; prefer this for any file you have",
82
+ }),
83
+ ),
84
+ filename: Type.Optional(
85
+ Type.String({
86
+ ...file.properties.filename,
87
+ description:
88
+ "The attachment file name, without a path; defaults to the path's base name, required with dataBase64",
89
+ }),
90
+ ),
91
+ dataBase64: Type.Optional(
92
+ Type.String({
93
+ ...file.properties.dataBase64,
94
+ description:
95
+ "Small generated content as standard base64, without a data URL prefix; never base64 a file you have on disk, give its path",
96
+ }),
97
+ ),
98
+ description: file.properties.description,
99
+ },
100
+ strict,
101
+ );
102
+ const localFiles = Type.Optional(
103
+ Type.Array(localFile, {
104
+ minItems: 1,
105
+ maxItems: 10,
106
+ description:
107
+ "Images or files to add, each with exactly one of path or dataBase64; at most 8 MiB in total for the whole call",
108
+ }),
109
+ );
110
+
73
111
  export interface ChannelTool {
74
112
  readonly operation: ChannelOperation;
75
113
  readonly schema: Readonly<TSchema>;
76
114
  readonly description: string;
77
115
  }
78
116
 
117
+ const sendMessage = (attached: typeof files | typeof localFiles) => ({
118
+ operation: "send" as const,
119
+ schema: Type.Object(
120
+ {
121
+ ...channel,
122
+ content: Type.Optional(Type.String({ maxLength: 2000 })),
123
+ files: attached,
124
+ },
125
+ strict,
126
+ ),
127
+ description:
128
+ "Send a message, optionally with images or files; it triggers no @ mentions.",
129
+ });
130
+
131
+ const editMessage = (attached: typeof files | typeof localFiles) => ({
132
+ operation: "edit" as const,
133
+ schema: Type.Object(
134
+ {
135
+ ...message,
136
+ content: Type.Optional(Type.String({ maxLength: 2000 })),
137
+ files: attached,
138
+ keepAttachmentIds: Type.Optional(
139
+ Type.Array(id, {
140
+ maxItems: 10,
141
+ description:
142
+ "Omit to keep every old attachment; an empty array removes them all; listed ids keep only those attachments",
143
+ }),
144
+ ),
145
+ },
146
+ strict,
147
+ ),
148
+ description: "Edit a message the bot itself sent.",
149
+ });
150
+
151
+ /** The tools that upload files; a session's files may name a path, which it reads before the call. */
152
+ export const FILE_CHANNEL_TOOLS: readonly string[] = Object.freeze([
153
+ "discord_send_message",
154
+ "discord_edit_message",
155
+ ]);
156
+
79
157
  /** Discord tool descriptions and schemas, each gated by one channel operation. */
80
158
  export const CHANNEL_TOOLS: Readonly<Record<string, ChannelTool>> = freeze<
81
159
  Record<string, ChannelTool>
@@ -164,38 +242,8 @@ export const CHANNEL_TOOLS: Readonly<Record<string, ChannelTool>> = freeze<
164
242
  schema: Type.Object(channel, strict),
165
243
  description: "Read the channel's pinned messages.",
166
244
  },
167
- discord_send_message: {
168
- operation: "send",
169
- schema: Type.Object(
170
- {
171
- ...channel,
172
- content: Type.Optional(Type.String({ maxLength: 2000 })),
173
- files,
174
- },
175
- strict,
176
- ),
177
- description:
178
- "Send a message, optionally with images or files; it triggers no @ mentions.",
179
- },
180
- discord_edit_message: {
181
- operation: "edit",
182
- schema: Type.Object(
183
- {
184
- ...message,
185
- content: Type.Optional(Type.String({ maxLength: 2000 })),
186
- files,
187
- keepAttachmentIds: Type.Optional(
188
- Type.Array(id, {
189
- maxItems: 10,
190
- description:
191
- "Omit to keep every old attachment; an empty array removes them all; listed ids keep only those attachments",
192
- }),
193
- ),
194
- },
195
- strict,
196
- ),
197
- description: "Edit a message the bot itself sent.",
198
- },
245
+ discord_send_message: sendMessage(files),
246
+ discord_edit_message: editMessage(files),
199
247
  discord_delete_message: {
200
248
  operation: "delete",
201
249
  schema: Type.Object(message, strict),
@@ -251,6 +299,25 @@ export const CHANNEL_TOOLS: Readonly<Record<string, ChannelTool>> = freeze<
251
299
  },
252
300
  });
253
301
 
302
+ /**
303
+ * The channel tools as a session offers them: a file may name a path on the session's disk, which
304
+ * `resolveChannelFiles` reads into `CHANNEL_TOOLS`' inline form before the call runs.
305
+ */
306
+ export const SESSION_CHANNEL_TOOLS: Readonly<Record<string, ChannelTool>> =
307
+ freeze<Record<string, ChannelTool>>({
308
+ ...CHANNEL_TOOLS,
309
+ discord_send_message: {
310
+ ...sendMessage(localFiles),
311
+ description:
312
+ "Send a message, optionally with images or files; it triggers no @ mentions. To send a file you have on disk, give its path; base64 is for small generated data only.",
313
+ },
314
+ discord_edit_message: {
315
+ ...editMessage(localFiles),
316
+ description:
317
+ "Edit a message the bot itself sent. To add a file you have on disk, give its path; base64 is for small generated data only.",
318
+ },
319
+ });
320
+
254
321
  /** A tool call that cannot be run as asked; the code is safe to show the caller. */
255
322
  export class ChannelToolError extends Error {
256
323
  constructor(readonly code: string) {
@@ -34,10 +34,9 @@ import type { ChannelExecutor } from "./channel-operations.ts";
34
34
  import type { ComposedCommands } from "./compose-commands.ts";
35
35
  import type { ChannelInfo, DiscordConnection } from "./connection.ts";
36
36
  import { DiscordThreadHost } from "./dispatch-thread-host.ts";
37
- import type { ThreadHost } from "./dispatch-threads.ts";
38
37
  import { DEFAULT_FRESH_MARKER, postFreshMarker } from "./fresh-marker.ts";
39
- import { toInbound } from "./inbound-message.ts";
40
- import type { CardChannel } from "./owner-cards.ts";
38
+ import { lateAnswerMessage, toInbound } from "./inbound-message.ts";
39
+ import type { CardChannel, LateTurn } from "./owner-cards.ts";
41
40
  import { DiscordOwnerOps } from "./owner-discord.ts";
42
41
  import { stopPanel } from "./stop-button.ts";
43
42
 
@@ -86,6 +85,7 @@ export class DiscordSurface
86
85
  });
87
86
 
88
87
  #interactions: ComposedCommands = { commands: [], modules: [] };
88
+ #onMessage: ((message: InboundMessage) => void) | undefined;
89
89
 
90
90
  constructor(options: DiscordSurfaceOptions) {
91
91
  this.#options = options;
@@ -118,7 +118,7 @@ export class DiscordSurface
118
118
  }
119
119
 
120
120
  /** Threads for background dispatches, in the channel that started each. */
121
- threadHost(): ThreadHost {
121
+ threadHost(): DiscordThreadHost {
122
122
  return new DiscordThreadHost(this.#client);
123
123
  }
124
124
 
@@ -209,8 +209,25 @@ export class DiscordSurface
209
209
  };
210
210
  }
211
211
 
212
+ /**
213
+ * Hands the router a card's late answer as a message from whoever answered, in the card's
214
+ * channel, so the conversation's claim answers it as theirs; dropped (logged) before start.
215
+ */
216
+ resume(turn: LateTurn): void {
217
+ const onMessage = this.#onMessage;
218
+ if (!onMessage) {
219
+ this.#options.logger.warn(
220
+ { channelId: turn.channelId },
221
+ "late card answer before the surface started; dropped",
222
+ );
223
+ return;
224
+ }
225
+ onMessage(lateAnswerMessage(turn));
226
+ }
227
+
212
228
  async start(onMessage: (message: InboundMessage) => void): Promise<void> {
213
229
  const { logger, token } = this.#options;
230
+ this.#onMessage = onMessage;
214
231
  this.#client.on(Events.MessageCreate, (message) => {
215
232
  toInbound(message, {
216
233
  botId: this.#client.user?.id,
@@ -4,6 +4,7 @@ import {
4
4
  type Message,
5
5
  ThreadAutoArchiveDuration,
6
6
  } from "discord.js";
7
+ import type { InterimMessage } from "../domain/interim.ts";
7
8
  import { splitReply } from "../presentation/reply-splitter.ts";
8
9
  import type { ThreadHost } from "./dispatch-threads.ts";
9
10
 
@@ -53,6 +54,16 @@ export class DiscordThreadHost implements ThreadHost {
53
54
  for (const chunk of splitReply(text)) await thread.send({ content: chunk });
54
55
  }
55
56
 
57
+ async send(threadId: string, text: string): Promise<InterimMessage> {
58
+ const thread = await this.#thread(threadId);
59
+ const message = await thread.send({ content: text });
60
+ return {
61
+ edit: async (change) => {
62
+ await message.edit({ content: change });
63
+ },
64
+ };
65
+ }
66
+
56
67
  async close(threadId: string): Promise<void> {
57
68
  const thread = await this.#thread(threadId);
58
69
  // Locking needs Manage Threads; without it the thread is only archived.
@@ -7,6 +7,7 @@ import {
7
7
  } from "node:fs";
8
8
  import { dirname } from "node:path";
9
9
  import type { ChannelKey } from "../domain/conversation.ts";
10
+ import type { InterimMessage, InterimPosts } from "../domain/interim.ts";
10
11
  import { messages } from "../i18n/index.ts";
11
12
  import type { Logger } from "../log.ts";
12
13
 
@@ -27,6 +28,11 @@ export interface ThreadHost {
27
28
  line: (thread?: string) => string,
28
29
  ): Promise<string | undefined>;
29
30
  post(threadId: string, text: string): Promise<void>;
31
+ /**
32
+ * Posts one message of at most Discord's 2000 characters and resolves to it, so it can be
33
+ * edited in place; without it a thread has no `interim` posts.
34
+ */
35
+ send?(threadId: string, text: string): Promise<InterimMessage>;
30
36
  /** Archives the thread, and locks it when allowed. */
31
37
  close(threadId: string): Promise<void>;
32
38
  }
@@ -40,6 +46,12 @@ export interface DispatchThread {
40
46
  readonly mention: string;
41
47
  /** Posts in the thread; a failure is logged. */
42
48
  post(text: string): Promise<void>;
49
+ /**
50
+ * Where the dispatch posts its progress as it goes, under the same name as its report, such
51
+ * as a coding worker's interim text; absent when the host cannot edit a message it posted.
52
+ * Unlike `post`, a failure rejects.
53
+ */
54
+ readonly interim?: InterimPosts;
43
55
  /** Posts the final report, if any, then archives the thread. Runs once; never rejects. */
44
56
  close(report?: string): Promise<void>;
45
57
  }
@@ -132,6 +144,7 @@ export class DispatchThreads {
132
144
  #thread(id: string): DispatchThread {
133
145
  const { host, logger } = this.#options;
134
146
  let closed = false;
147
+ const send = host.send?.bind(host);
135
148
  const post = async (text: string) => {
136
149
  try {
137
150
  await host.post(id, text);
@@ -144,6 +157,7 @@ export class DispatchThreads {
144
157
  channel: `${PREFIX}${id}`,
145
158
  mention: `<#${id}>`,
146
159
  post,
160
+ ...(send ? { interim: { post: (text: string) => send(id, text) } } : {}),
147
161
  close: async (report) => {
148
162
  if (closed) return;
149
163
  closed = true;
@@ -2,6 +2,7 @@ import { type Message, MessageReferenceType } from "discord.js";
2
2
  import type { InboundMessage } from "../contract/channels.ts";
3
3
  import { channelKey } from "../contract/surface.ts";
4
4
  import type { AttachmentRef } from "../domain/attachment.ts";
5
+ import type { LateTurn } from "./owner-cards.ts";
5
6
 
6
7
  /** Who the bot is on Discord, as far as the client knows it. */
7
8
  export interface BotIdentity {
@@ -113,3 +114,36 @@ export async function toInbound(
113
114
  ...(referenced ? { reference: referenceOf(referenced, mention) } : {}),
114
115
  };
115
116
  }
117
+
118
+ /**
119
+ * A card's late answer as a message from whoever answered, in the card's channel, standing in
120
+ * for the card's message: the router resolves its author as any other's, and the claim answers it.
121
+ */
122
+ export function lateAnswerMessage(turn: LateTurn): InboundMessage {
123
+ const { user } = turn;
124
+ const roles = user.roleIds;
125
+ return {
126
+ channel: channelKey("discord", turn.channelId),
127
+ messageId: turn.messageId,
128
+ actor: {
129
+ provider: "discord",
130
+ subject: user.id,
131
+ name: user.name,
132
+ surface: "discord",
133
+ ...(roles ? { roles: roles.map((role) => `discord:role:${role}`) } : {}),
134
+ ...(turn.guildId ? { space: turn.guildId } : {}),
135
+ legacyId: user.id,
136
+ },
137
+ authorId: user.id,
138
+ authorName: user.name,
139
+ authorIsBot: false,
140
+ ...(roles ? { authorRoleIds: roles } : {}),
141
+ ...(turn.guildId ? { space: turn.guildId } : {}),
142
+ isDirect: turn.isDirect,
143
+ // Pressing the assistant's card speaks to it, as a mention does.
144
+ mentionsBot: true,
145
+ repliesToBot: true,
146
+ text: turn.text,
147
+ attachments: [],
148
+ };
149
+ }
@@ -0,0 +1,98 @@
1
+ import {
2
+ ActionRowBuilder,
3
+ ButtonBuilder,
4
+ ButtonStyle,
5
+ LabelBuilder,
6
+ ModalBuilder,
7
+ StringSelectMenuBuilder,
8
+ TextInputBuilder,
9
+ TextInputStyle,
10
+ } from "discord.js";
11
+ import { messages } from "../i18n/index.ts";
12
+ import type { OwnerAnswer, OwnerQuestion } from "../interactions/prompts.ts";
13
+ import { type ownerPanel, plain } from "./owner-panel.ts";
14
+
15
+ /** The prefix of every card control's custom id. */
16
+ export const CARD_PREFIX = "roundtable:card:";
17
+ /** The menu value of "Other…". */
18
+ export const OTHER = "other";
19
+ /** The answer form's text field. */
20
+ export const TEXT_FIELD = "text";
21
+
22
+ export type Rows = NonNullable<Parameters<typeof ownerPanel>[0]["rows"]>;
23
+
24
+ /** A question's controls: a menu of its options, or a button that opens the answer form. */
25
+ export function askRows(
26
+ id: string,
27
+ question: OwnerQuestion,
28
+ disabled: boolean,
29
+ ): Rows {
30
+ if (question.options.length === 0)
31
+ return [
32
+ new ActionRowBuilder<ButtonBuilder>().addComponents(
33
+ new ButtonBuilder()
34
+ .setCustomId(`${CARD_PREFIX}${id}:write`)
35
+ .setLabel(messages().cardAnswerLabel)
36
+ .setStyle(ButtonStyle.Primary)
37
+ .setDisabled(disabled),
38
+ ),
39
+ ];
40
+ // Discord menus take 25 options; "Other…" takes the last place when the options fill it.
41
+ const options = question.options
42
+ .slice(0, question.allowOther ? 24 : 25)
43
+ .map((option, i) => ({
44
+ label: option.label.slice(0, 100),
45
+ value: String(i),
46
+ ...(option.description
47
+ ? { description: option.description.slice(0, 100) }
48
+ : {}),
49
+ }));
50
+ if (question.allowOther)
51
+ options.push({
52
+ label: messages().cardOtherLabel,
53
+ value: OTHER,
54
+ description: messages().cardOtherDescription,
55
+ });
56
+ return [
57
+ new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(
58
+ new StringSelectMenuBuilder()
59
+ .setCustomId(`${CARD_PREFIX}${id}:pick`)
60
+ .setPlaceholder(
61
+ question.multi
62
+ ? messages().cardPickManyPlaceholder
63
+ : messages().cardPickOnePlaceholder,
64
+ )
65
+ .setMinValues(1)
66
+ .setMaxValues(question.multi ? options.length : 1)
67
+ .addOptions(options)
68
+ .setDisabled(disabled),
69
+ ),
70
+ ];
71
+ }
72
+
73
+ export function answerModal(id: string): ModalBuilder {
74
+ return new ModalBuilder()
75
+ .setCustomId(`${CARD_PREFIX}${id}:text`)
76
+ .setTitle(messages().cardModalTitle)
77
+ .addLabelComponents(
78
+ new LabelBuilder()
79
+ .setLabel(messages().cardModalField)
80
+ .setTextInputComponent(
81
+ new TextInputBuilder()
82
+ .setCustomId(TEXT_FIELD)
83
+ .setStyle(TextInputStyle.Paragraph)
84
+ .setRequired(true)
85
+ .setMaxLength(1000),
86
+ ),
87
+ );
88
+ }
89
+
90
+ export function answeredLine(answer: OwnerAnswer): string {
91
+ const parts = [
92
+ ...answer.choices.map(plain),
93
+ ...(answer.text !== undefined
94
+ ? [messages().cardQuote(plain(answer.text))]
95
+ : []),
96
+ ];
97
+ return messages().cardAnswered(parts);
98
+ }
@@ -56,6 +56,9 @@ export function press(
56
56
  isModalSubmit: () => kind === "modal",
57
57
  isFromMessage: () => true,
58
58
  customId,
59
+ inGuild: () => true,
60
+ guildId: "300000000000000003",
61
+ message: { id: "400000000000000004" },
59
62
  user: { id: options.user ?? OWNER },
60
63
  member: {
61
64
  roles: { cache: new Map((options.roles ?? []).map((r) => [r, r])) },