pi-roundtable 0.27.0 → 0.28.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 (49) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +2 -0
  3. package/README.zh-TW.md +2 -0
  4. package/docs/plugins.md +29 -13
  5. package/package.json +1 -1
  6. package/src/characters/compose.ts +11 -0
  7. package/src/characters/parts-face.ts +23 -2
  8. package/src/core/agents/agent-messages.ts +32 -85
  9. package/src/core/agents/agent-ports.ts +3 -0
  10. package/src/core/agents/agent-prompt.ts +2 -16
  11. package/src/core/agents/agent-tools.ts +1 -1
  12. package/src/core/agents/helper-create.ts +3 -24
  13. package/src/core/agents/helper-modes.ts +64 -0
  14. package/src/core/agents/team-turn-types.ts +7 -13
  15. package/src/core/agents/team-turns.ts +7 -2
  16. package/src/core/builtin/agent-server.ts +3 -4
  17. package/src/core/builtin/discord.ts +35 -8
  18. package/src/core/config/adapters.ts +2 -1
  19. package/src/core/config/limits.ts +35 -2
  20. package/src/core/discord/agent-discord.ts +12 -1
  21. package/src/core/discord/card-copies.ts +7 -2
  22. package/src/core/discord/discord-surface.ts +23 -38
  23. package/src/core/discord/intercom-cards.ts +121 -0
  24. package/src/core/discord/owner-card-asks.ts +9 -0
  25. package/src/core/discord/owner-card-types.ts +2 -0
  26. package/src/core/discord/owner-cards.ts +2 -1
  27. package/src/core/discord/stop-button.ts +35 -0
  28. package/src/core/domain/conversation.ts +18 -0
  29. package/src/core/domain/interim.ts +7 -0
  30. package/src/core/domain/turn-timeout.ts +5 -0
  31. package/src/core/i18n/conversation.ts +16 -4
  32. package/src/core/i18n/turn-timeout.ts +10 -2
  33. package/src/core/interactions/prompts.ts +5 -0
  34. package/src/core/modes/autonomy.ts +22 -1
  35. package/src/core/modes/conversation-modes.ts +135 -23
  36. package/src/core/modes/mode-inheritance.ts +78 -0
  37. package/src/core/modes/modes-plugin.ts +1 -1
  38. package/src/core/modules/skills/skill-contract.ts +2 -0
  39. package/src/core/presentation/intercom.ts +44 -0
  40. package/src/core/runtime/ask-questions.ts +21 -4
  41. package/src/core/runtime/extensions/ask-user-text.ts +36 -3
  42. package/src/core/runtime/extensions/ask-user.ts +21 -1
  43. package/src/core/runtime/interim-text.ts +7 -4
  44. package/src/core/runtime/pi-agent-runtime.ts +2 -2
  45. package/src/core/runtime/prompt-slot.ts +11 -1
  46. package/src/core/runtime/session-factory.ts +11 -6
  47. package/src/core/runtime/turn-budget.ts +34 -3
  48. package/src/core/runtime/turn-limits.ts +13 -3
  49. package/src/index.ts +1 -0
@@ -2,12 +2,11 @@ import type { CharacterInput } from "../../characters/index.ts";
2
2
  import { helpersOf } from "../config/helpers.ts";
3
3
  import { AgentError } from "../domain/errors.ts";
4
4
  import type { AgentTurnScope } from "../domain/ports.ts";
5
- import { SYSTEM_PRINCIPAL } from "../identity/principal-store.ts";
6
5
  import type { Speaker } from "../speakers.ts";
7
6
  import { toolTiers } from "../tool-tiers.ts";
8
7
  import { isHelper } from "./agent-rules.ts";
9
8
  import type { Agent } from "./agent-store.ts";
10
- import { keysOf } from "./team-keys.ts";
9
+ import { followOrigin } from "./helper-modes.ts";
11
10
  import type { TeamContext } from "./team-options.ts";
12
11
 
13
12
  /** The team's way of making an agent, which `createHelper` uses to make a temp knight. */
@@ -98,10 +97,10 @@ export async function createHelper(
98
97
  );
99
98
  const helper = made.agent;
100
99
  try {
101
- await inheritLongRunning(ctx, caller, speaker, helper);
100
+ await followOrigin(ctx, caller, speaker, helper);
102
101
  ctx.turns.message(caller, helper.name, task);
103
102
  } catch (error) {
104
- // The long-running mode or the task could not be handed over (a chain at its cap): the temp
103
+ // The modes or the task could not be handed over (a chain at its cap): the temp
105
104
  // knight is not left idle, and its thread is closed.
106
105
  await ctx.archiveHelpers([helper.name]);
107
106
  throw error;
@@ -158,23 +157,3 @@ async function makeRoom(
158
157
  await ctx.archiveHelpers([oldest.name]);
159
158
  return ` To make room, "${oldest.displayName}" (${oldest.name}), your oldest finished temp knight, was archived.`;
160
159
  }
161
-
162
- /**
163
- * Turns long-running on for the temp knight's conversations when the conversation that asked for
164
- * it has it on, so it works as long as its parent may. This adds no permission: an owner turned
165
- * it on there. Only a turn of an owner can carry that over, as for any change of modes.
166
- */
167
- async function inheritLongRunning(
168
- ctx: TeamContext,
169
- caller: AgentTurnScope,
170
- speaker: Speaker,
171
- helper: Agent,
172
- ): Promise<void> {
173
- const { store, surfaces } = ctx.options;
174
- const modes = ctx.options.modes?.();
175
- if (!modes?.peek(ctx.turns.turnChannel(caller)).longRunning) return;
176
- if (speaker.tier !== "owner" || speaker.principalId === SYSTEM_PRINCIPAL)
177
- return;
178
- for (const key of keysOf(store, helper.name, surfaces?.roomSurfaces() ?? []))
179
- await modes.set(key, { longRunning: true }, speaker);
180
- }
@@ -0,0 +1,64 @@
1
+ import type { AgentTurnScope } from "../domain/ports.ts";
2
+ import { SYSTEM_PRINCIPAL } from "../identity/principal-store.ts";
3
+ import type { ModeOrigins } from "../modes/mode-inheritance.ts";
4
+ import type { AgentDirectory } from "../services.ts";
5
+ import type { Speaker } from "../speakers.ts";
6
+ import { isHelper } from "./agent-rules.ts";
7
+ import type { Agent } from "./agent-store.ts";
8
+ import { agentOfChannel, groupOfChannel, keysOf } from "./team-keys.ts";
9
+ import type { TeamContext } from "./team-options.ts";
10
+
11
+ /**
12
+ * Makes the temp knight's conversations follow the modes of the conversation that asked for it,
13
+ * live, so turning yolo or long-running on or off there applies to it at once. This adds no
14
+ * permission: an owner turns the modes on there. Only a turn of an owner can carry them over, as
15
+ * for any change of modes; any other turn's temp knight starts with its own, both off, whatever
16
+ * an earlier agent of the same name followed.
17
+ */
18
+ export async function followOrigin(
19
+ ctx: TeamContext,
20
+ caller: AgentTurnScope,
21
+ speaker: Speaker,
22
+ helper: Agent,
23
+ ): Promise<void> {
24
+ const { store, surfaces } = ctx.options;
25
+ const modes = ctx.options.modes?.();
26
+ if (!modes) return;
27
+ const keys = keysOf(store, helper.name, surfaces?.roomSurfaces() ?? []);
28
+ if (speaker.tier !== "owner" || speaker.principalId === SYSTEM_PRINCIPAL) {
29
+ for (const key of keys) await modes.forget(key);
30
+ return;
31
+ }
32
+ await modes.follow(keys, ctx.turns.turnChannel(caller), speaker);
33
+ }
34
+
35
+ /**
36
+ * What the agent server says of a conversation that follows another's modes: it still does while
37
+ * it is a temp knight's conversation made from that origin, named after the temp knight's parent;
38
+ * the origin is gone once the agent or group whose conversation it is (else the parent) is
39
+ * archived or deleted.
40
+ */
41
+ export function modeOrigins(
42
+ store: Pick<
43
+ AgentDirectory,
44
+ "agent" | "agentByChannel" | "group" | "groupByChannel"
45
+ >,
46
+ hosts: { roomSurfaces(): readonly string[] },
47
+ ): ModeOrigins {
48
+ return {
49
+ originOf: (channel, origin) => {
50
+ const helper = agentOfChannel(store, hosts, channel);
51
+ if (!helper || !isHelper(helper) || helper.origin !== origin)
52
+ return undefined;
53
+ const parent = store.agent(helper.parent as string);
54
+ const owner =
55
+ agentOfChannel(store, hosts, origin) ??
56
+ groupOfChannel(store, hosts, origin) ??
57
+ parent;
58
+ return {
59
+ name: parent?.displayName ?? (helper.parent as string),
60
+ alive: owner?.status === "active",
61
+ };
62
+ },
63
+ };
64
+ }
@@ -3,6 +3,7 @@ import type { SurfacePort } from "../contract/surface.ts";
3
3
  import type { TurnAttachments } from "../domain/attachment.ts";
4
4
  import type {
5
5
  ChannelKey,
6
+ IntercomCard,
6
7
  PendingConfirmation,
7
8
  TurnResult,
8
9
  } from "../domain/conversation.ts";
@@ -14,10 +15,6 @@ import type { EventSink } from "../plugin.ts";
14
15
  import type { AvatarStudio } from "../services.ts";
15
16
  import type { TurnSelection } from "../sessions.ts";
16
17
  import type { Speaker } from "../speakers.ts";
17
- import type {
18
- DispatchThread,
19
- DispatchThreads,
20
- } from "../threads/dispatch-threads.ts";
21
18
  import type { ToolTiers } from "../tool-tiers.ts";
22
19
  import type { AgentChannels, AgentTurnRunner } from "./agent-ports.ts";
23
20
  import type { Agent, PgAgentStore } from "./agent-store.ts";
@@ -36,12 +33,6 @@ export interface Chain {
36
33
  speaker: Speaker;
37
34
  }
38
35
 
39
- /** A message's thread in the sender's turn channel, which holds the request and the answer. */
40
- export interface Exchange {
41
- parent: ChannelKey;
42
- thread: DispatchThread;
43
- }
44
-
45
36
  export interface TeamTurnsOptions {
46
37
  /** The agent server's entry channel, where the coordinator lives; absent on a host without Discord. */
47
38
  entryChannelId?: string;
@@ -99,8 +90,6 @@ export interface TeamTurnsOptions {
99
90
  * stored speaker is kept.
100
91
  */
101
92
  principals?: () => Pick<IdentityService, "speakerFor"> | undefined;
102
- /** A thread for each message between agents; without it both go in the sender's channel. */
103
- threads?: Pick<DispatchThreads, "open">;
104
93
  }
105
94
 
106
95
  /** How a turn runs, beyond its text. */
@@ -150,7 +139,12 @@ export interface TurnHost {
150
139
  post(
151
140
  channel: ChannelKey,
152
141
  as: Agent,
153
- body: { thinking?: string; chunks: string[]; threadId?: string },
142
+ body: {
143
+ thinking?: string;
144
+ chunks: string[];
145
+ threadId?: string;
146
+ intercom?: IntercomCard;
147
+ },
154
148
  ): Promise<void>;
155
149
  /** The agent's latest row. */
156
150
  current(agent: Agent): Agent;
@@ -2,6 +2,7 @@ import { channelKey, parseChannelKey } from "../contract/surface.ts";
2
2
  import type { TurnAttachments } from "../domain/attachment.ts";
3
3
  import type {
4
4
  ChannelKey,
5
+ IntercomCard,
5
6
  PendingConfirmation,
6
7
  PostAuthor,
7
8
  ReplyFile,
@@ -321,8 +322,10 @@ export class TeamTurns {
321
322
  const { store, channels, studio, surfaces } = this.#options;
322
323
  if (discordIdOf(channel) === undefined) {
323
324
  // A room's interim posts are its surface's, under the agent as author.
324
- if (!surfaces?.interim(channel, this.#author(agent))) return undefined;
325
+ const first = surfaces?.interim(channel, this.#author(agent));
326
+ if (!surfaces || !first) return undefined;
325
327
  return {
328
+ ...(first.tools === false ? { tools: false } : {}),
326
329
  post: async (text) => {
327
330
  if (!channelOwner(store, channel, this.#rooms(channel)))
328
331
  throw new AgentError(`${channel} is archived`);
@@ -363,6 +366,7 @@ export class TeamTurns {
363
366
  files?: ReplyFile[];
364
367
  threadId?: string;
365
368
  failed?: true;
369
+ intercom?: IntercomCard;
366
370
  },
367
371
  ): Promise<void> {
368
372
  const { store, channels, studio, surfaces, logger } = this.#options;
@@ -378,7 +382,7 @@ export class TeamTurns {
378
382
  if (discordIdOf(channel) === undefined) {
379
383
  if (!surfaces)
380
384
  throw new AgentError(`no surface serves the room ${channel}`);
381
- const { thinking, chunks, files, threadId } = rest;
385
+ const { thinking, chunks, files, threadId, intercom } = rest;
382
386
  // A thread of the room's surface is a channel of that surface, and speaks there.
383
387
  return surfaces.sendReply(
384
388
  threadId === undefined
@@ -390,6 +394,7 @@ export class TeamTurns {
390
394
  ...(files?.length ? { files } : {}),
391
395
  author: this.#author(as),
392
396
  ...(failed ? { failed } : {}),
397
+ ...(intercom ? { intercom } : {}),
393
398
  },
394
399
  );
395
400
  }
@@ -9,6 +9,7 @@ import { DiscordAgentTeam } from "../agents/agent-team.ts";
9
9
  import { AGENT_TOOLS } from "../agents/agent-tools.ts";
10
10
  import { FileAvatarStudio } from "../agents/avatar-studio.ts";
11
11
  import { RelevanceScorer } from "../agents/group-round.ts";
12
+ import { modeOrigins } from "../agents/helper-modes.ts";
12
13
  import { Routes } from "../agents/routes.ts";
13
14
  import { agentOfChannel, discordKey } from "../agents/team-keys.ts";
14
15
  import { ownerAttachmentDir } from "../attachments/attachment-dir.ts";
@@ -35,7 +36,6 @@ import {
35
36
  RUNTIME,
36
37
  SCHEDULES,
37
38
  SKILLS,
38
- THREADS,
39
39
  } from "../services.ts";
40
40
  import type { SessionTool } from "../sessions.ts";
41
41
  import type { Tier } from "../speakers.ts";
@@ -217,8 +217,6 @@ export function agentServerPlugin(
217
217
  setup: async (context) => {
218
218
  // Absent on a host without Discord: then the team has no channels or dashboard.
219
219
  const discord = context.services.find(DISCORD);
220
- // Dispatch threads on any surface that hosts them, Discord's or a room surface's.
221
- const threads = context.services.find(THREADS);
222
220
  const schedules = context.services.get(SCHEDULES);
223
221
  const connection = discord?.connection;
224
222
  const { logger, providers, surfaces } = context;
@@ -245,6 +243,8 @@ export function agentServerPlugin(
245
243
  // Absent when the skills addon is off: the agents then carry none.
246
244
  const skills = context.services.find(SKILLS);
247
245
  const { agents: store } = await openStores(context, teamId);
246
+ // A temp knight's conversations follow the modes of the one that made it.
247
+ context.services.find(MODES)?.trackOrigins(modeOrigins(store, surfaces));
248
248
  const running = context.services.get(RUNTIME);
249
249
  const judge = providers.judge;
250
250
  const confirmations = new ConfirmationJudge({
@@ -348,7 +348,6 @@ export function agentServerPlugin(
348
348
  modes: () => context.services.find(MODES),
349
349
  ...(options.helpers !== undefined ? { helpers: options.helpers } : {}),
350
350
  principals: () => context.services.find(IDENTITY),
351
- ...(threads ? { threads } : {}),
352
351
  logger,
353
352
  });
354
353
  connection?.onChannelDeleted((channelId) => {
@@ -5,6 +5,7 @@ import type {
5
5
  ChannelContextOptions,
6
6
  } from "../contract/channel-context.ts";
7
7
  import { type ServiceKey, serviceKey } from "../contract/services.ts";
8
+ import { CardCopies } from "../discord/card-copies.ts";
8
9
  import { ChannelContextReader } from "../discord/channel-context.ts";
9
10
  import { CommandCollection } from "../discord/command-collection.ts";
10
11
  import type { DiscordConnection } from "../discord/connection.ts";
@@ -15,6 +16,7 @@ import type {
15
16
  CommandGuard,
16
17
  CommandRegistrar,
17
18
  } from "../discord/interaction-module.ts";
19
+ import { intercomButtonModule } from "../discord/intercom-cards.ts";
18
20
  import { OwnerCards } from "../discord/owner-cards.ts";
19
21
  import { commandGuard, ownerRootCommand } from "../discord/owner-command.ts";
20
22
  import type { DiscordPoster } from "../discord/poster/poster.ts";
@@ -88,11 +90,15 @@ export interface DiscordOptions {
88
90
  * How the texts behind approval cards' copy buttons are kept: the newest `keep` (default 100)
89
91
  * for `keepDays` (default 30), in `card-copies.json` under `dataDir`, so the buttons work after
90
92
  * a restart. `false` keeps them in memory only, with the same limits: they are gone when the
91
- * process restarts and the button then says so.
93
+ * process restarts and the button then says so. The whole texts behind the agents' message
94
+ * cards follow the same choice and `keepDays`, the newest 300, in `intercom-texts.json`.
92
95
  */
93
96
  cardCopies?: { keepDays?: number; keep?: number } | false;
94
97
  }
95
98
 
99
+ /** How many agent message cards keep their whole text for the open button, the newest. */
100
+ const INTERCOM_KEEP = 300;
101
+
96
102
  /**
97
103
  * The Discord connection and what stands on it: the owner's cards, the command guard, and the
98
104
  * threads that carry background reports. It contributes the Discord surface, collects the
@@ -114,11 +120,24 @@ export function discordPlugin(options: DiscordOptions): RoundtablePlugin {
114
120
  setup: async ({ conversations, services, logger }) => {
115
121
  const identity = services.find(IDENTITY);
116
122
  // A host that keeps no copy texts on disk drops what an earlier run left.
117
- if (options.cardCopies === false) {
118
- const file = join(options.dataDir, "card-copies.json");
119
- rmSync(file, { force: true });
120
- rmSync(`${file}.tmp`, { force: true });
121
- }
123
+ if (options.cardCopies === false)
124
+ for (const name of ["card-copies.json", "intercom-texts.json"]) {
125
+ const file = join(options.dataDir, name);
126
+ rmSync(file, { force: true });
127
+ rmSync(`${file}.tmp`, { force: true });
128
+ }
129
+ // The whole texts behind the agents' message cards, kept as the copy texts are.
130
+ const intercomTexts = new CardCopies({
131
+ ...(options.cardCopies === false
132
+ ? {}
133
+ : { path: join(options.dataDir, "intercom-texts.json") }),
134
+ keep: INTERCOM_KEEP,
135
+ ...(options.cardCopies
136
+ ? { keepDays: options.cardCopies.keepDays }
137
+ : {}),
138
+ logger,
139
+ what: "agent message texts",
140
+ });
122
141
  // The surface exists by the time a turn posts a card.
123
142
  const cards: OwnerCards = new OwnerCards({
124
143
  ownerId: options.ownerId,
@@ -142,6 +161,7 @@ export function discordPlugin(options: DiscordOptions): RoundtablePlugin {
142
161
  ownerId: options.ownerId,
143
162
  ownerName: options.ownerName,
144
163
  prompts: (channel, scope) => cards.prompts(channel, scope),
164
+ intercomTexts,
145
165
  // Built below, once the surface can read messages; called only after setup.
146
166
  channelContext: (message, given) => context.of(message, given),
147
167
  logger,
@@ -171,6 +191,7 @@ export function discordPlugin(options: DiscordOptions): RoundtablePlugin {
171
191
  collection.registrar.add({
172
192
  module: stopButtonModule({ guard, conversations }),
173
193
  });
194
+ collection.registrar.add({ module: intercomButtonModule(intercomTexts) });
174
195
  // Marks the owners in channel context: the primary owner, and every other owner's Discord identities.
175
196
  const owners = new DiscordOwners({
176
197
  ownerId: options.ownerId,
@@ -210,8 +231,14 @@ export function discordPlugin(options: DiscordOptions): RoundtablePlugin {
210
231
  // The copy texts of the last cards are on disk before the host exits.
211
232
  {
212
233
  name: "card-copies",
213
- start: () => cards.startCopies(),
214
- stop: () => cards.stopCopies(),
234
+ start: () => {
235
+ cards.startCopies();
236
+ intercomTexts.start();
237
+ },
238
+ stop: async () => {
239
+ await cards.stopCopies();
240
+ await intercomTexts.stop();
241
+ },
215
242
  },
216
243
  ],
217
244
  };
@@ -84,7 +84,8 @@ export interface DiscordConfig {
84
84
  * for `keepDays` (default 30), in `card-copies.json` under the data directory, so the buttons
85
85
  * work after a restart. `false` keeps them in memory only, with the same limits: they are gone
86
86
  * when the process restarts, and the button then says so. Discarding a conversation does not
87
- * remove them; they age out.
87
+ * remove them; they age out. The whole texts behind the agents' message cards follow the same
88
+ * choice and `keepDays`, the newest 300, in `intercom-texts.json`.
88
89
  */
89
90
  cardCopies?: { keepDays?: number; keep?: number } | false;
90
91
  /**
@@ -1,5 +1,10 @@
1
1
  import { ConfigError } from "../domain/errors.ts";
2
- import { type Autonomy, DEFAULT_LONG_RUNNING_MS } from "../modes/autonomy.ts";
2
+ import {
3
+ type Autonomy,
4
+ DEFAULT_LONG_RUNNING_MS,
5
+ DEFAULT_YOLO_QUESTION_GRACE_MS,
6
+ DEFAULT_YOLO_TIMEOUT_CARD_GRACE_MS,
7
+ } from "../modes/autonomy.ts";
3
8
  import { integer, list, optional, shape, text } from "./schema.ts";
4
9
 
5
10
  /** The configuration's `turnLimits` and `autonomy`: how long a turn works, and what runs without a person. */
@@ -27,6 +32,16 @@ export interface LimitsConfig {
27
32
  allow?: readonly string[];
28
33
  /** Hold classes or rule names yolo never approves, such as `shell.push` or `tool:repo_push`. */
29
34
  card?: readonly string[];
35
+ /**
36
+ * How long an `ask_user` card holds a yolo turn before it goes on with its own best choice
37
+ * (the card stays open, and a later answer starts a new turn); 5 to 600, default 30.
38
+ */
39
+ questionGraceSeconds?: number;
40
+ /**
41
+ * How long the timeout card waits in a yolo conversation before the turn goes on as if
42
+ * Continue were pressed, up to `longRunning.maxSeconds` of work; 10 to 3600, default 60.
43
+ */
44
+ timeoutCardSeconds?: number;
30
45
  };
31
46
  /** Long-running mode: a turn that reaches its time limit goes on by itself, up to this. */
32
47
  longRunning?: {
@@ -48,7 +63,12 @@ export const limitsShape = {
48
63
  autonomy: optional(
49
64
  shape({
50
65
  yolo: optional(
51
- shape({ allow: optional(list(text)), card: optional(list(text)) }),
66
+ shape({
67
+ allow: optional(list(text)),
68
+ card: optional(list(text)),
69
+ questionGraceSeconds: optional(integer(5, 600)),
70
+ timeoutCardSeconds: optional(integer(10, 3_600)),
71
+ }),
52
72
  ),
53
73
  longRunning: optional(
54
74
  shape({ maxSeconds: optional(integer(600, 604_800)) }),
@@ -83,6 +103,14 @@ export function limitsOf(config: LimitsConfig): ResolvedLimits {
83
103
  yolo: {
84
104
  allow: autonomy?.yolo?.allow ?? [],
85
105
  card: autonomy?.yolo?.card ?? [],
106
+ questionGraceMs: secondsOr(
107
+ autonomy?.yolo?.questionGraceSeconds,
108
+ DEFAULT_YOLO_QUESTION_GRACE_MS,
109
+ ),
110
+ timeoutCardGraceMs: secondsOr(
111
+ autonomy?.yolo?.timeoutCardSeconds,
112
+ DEFAULT_YOLO_TIMEOUT_CARD_GRACE_MS,
113
+ ),
86
114
  },
87
115
  longRunningMaxMs:
88
116
  longRunning === undefined
@@ -91,3 +119,8 @@ export function limitsOf(config: LimitsConfig): ResolvedLimits {
91
119
  },
92
120
  };
93
121
  }
122
+
123
+ /** Seconds as ms, or the default in ms when unset. */
124
+ function secondsOr(seconds: number | undefined, defaultMs: number): number {
125
+ return seconds === undefined ? defaultMs : seconds * 1_000;
126
+ }
@@ -28,6 +28,7 @@ import { AgentError } from "../domain/errors.ts";
28
28
  import type { InterimPosts } from "../domain/interim.ts";
29
29
  import { messages } from "../i18n/index.ts";
30
30
  import type { Logger } from "../log.ts";
31
+ import { type IntercomTexts, intercomPost } from "./intercom-cards.ts";
31
32
 
32
33
  const DASHBOARD_NAME = "dashboard";
33
34
 
@@ -42,10 +43,13 @@ export class DiscordAgentChannels implements AgentChannels {
42
43
  readonly #webhooks = new Map<string, Promise<Webhook>>();
43
44
  /** The parent channel of each thread the agents live in, and null for a channel that is none. */
44
45
  readonly #parents = new Map<string, string | null>();
46
+ /** Where the cards' whole texts are kept for their open buttons; without it a card posts as text. */
47
+ readonly #texts: IntercomTexts | undefined;
45
48
 
46
- constructor(client: Client, guildId: string) {
49
+ constructor(client: Client, guildId: string, texts?: IntercomTexts) {
47
50
  this.#client = client;
48
51
  this.#guildId = guildId;
52
+ this.#texts = texts;
49
53
  }
50
54
 
51
55
  /**
@@ -77,6 +81,13 @@ export class DiscordAgentChannels implements AgentChannels {
77
81
  ...(route.threadId ? { threadId: route.threadId } : {}),
78
82
  };
79
83
  try {
84
+ if (post.intercom && this.#texts) {
85
+ await webhook.send({
86
+ ...as,
87
+ ...intercomPost(post.intercom, this.#texts),
88
+ });
89
+ return;
90
+ }
80
91
  if (post.thinking) await webhook.send({ ...as, content: post.thinking });
81
92
  for (const chunk of post.chunks)
82
93
  await webhook.send({ ...as, content: chunk });
@@ -35,6 +35,8 @@ export interface CardCopiesOptions {
35
35
  setInterval?: typeof setInterval;
36
36
  /** Replaceable in tests, with `setInterval`. */
37
37
  clearInterval?: typeof clearInterval;
38
+ /** What the texts are, as the log names them; default "card copy texts". */
39
+ what?: string;
38
40
  }
39
41
 
40
42
  /**
@@ -69,7 +71,7 @@ export class CardCopies {
69
71
  } catch (error) {
70
72
  logger.warn(
71
73
  { path, err: error },
72
- "card copy texts not read; copy buttons of earlier cards will say they are gone",
74
+ `${options.what ?? "card copy texts"} not read; buttons of earlier cards will say they are gone`,
73
75
  );
74
76
  }
75
77
  }
@@ -172,7 +174,10 @@ export class CardCopies {
172
174
  await chmod(temporary, 0o600);
173
175
  await rename(temporary, path);
174
176
  } catch (error) {
175
- logger.warn({ path, err: error }, "card copy texts not saved");
177
+ logger.warn(
178
+ { path, err: error },
179
+ `${this.#options.what ?? "card copy texts"} not saved`,
180
+ );
176
181
  }
177
182
  }
178
183
  }
@@ -6,7 +6,6 @@ import {
6
6
  Events,
7
7
  GatewayIntentBits,
8
8
  type Interaction,
9
- type Message,
10
9
  MessageFlags,
11
10
  Partials,
12
11
  type REST,
@@ -47,18 +46,17 @@ import { DiscordThreadHost } from "./dispatch-thread-host.ts";
47
46
  import { DISCORD_FORMATTING, pickFormatting } from "./formatting.ts";
48
47
  import { DEFAULT_FRESH_MARKER, postFreshMarker } from "./fresh-marker.ts";
49
48
  import { lateAnswerMessage } from "./inbound-message.ts";
49
+ import type { IntercomTexts } from "./intercom-cards.ts";
50
50
  import { listenForMessages, reportChanges } from "./message-changes.ts";
51
51
  import type { CardChannel, LateTurn } from "./owner-cards.ts";
52
52
  import { DiscordOwnerOps } from "./owner-discord.ts";
53
- import { stopPanel } from "./stop-button.ts";
53
+ import { showStopPanel } from "./stop-button.ts";
54
54
 
55
55
  const PREFIX = "discord:";
56
56
  /** Discord's error code for a channel that does not exist or cannot be seen. */
57
57
  const UNKNOWN_CHANNEL = 10003;
58
58
 
59
59
  const TYPING_REFRESH_MS = 8_000;
60
- /** A turn shorter than this shows no stop button, so quick answers do not flicker one. */
61
- const STOP_PANEL_DELAY_MS = 5_000;
62
60
 
63
61
  export interface DiscordSurfaceOptions {
64
62
  token: string;
@@ -73,6 +71,8 @@ export interface DiscordSurfaceOptions {
73
71
  freshMarker?: string | false;
74
72
  /** What the model is told about how replies render here; `false` tells it nothing. Default: `DISCORD_FORMATTING`. */
75
73
  formatting?: string | false;
74
+ /** Where the agents' message cards keep their whole texts for the open button; without it a card posts as text. */
75
+ intercomTexts?: IntercomTexts;
76
76
  /** Reads the channel context of an addressed message; without it the surface has none. */
77
77
  channelContext?: (
78
78
  message: InboundMessage,
@@ -157,7 +157,11 @@ export class DiscordSurface
157
157
 
158
158
  /** The agent server's channels and webhooks. */
159
159
  agentChannels(guildId: string): AgentChannels {
160
- return new DiscordAgentChannels(this.#client, guildId);
160
+ return new DiscordAgentChannels(
161
+ this.#client,
162
+ guildId,
163
+ this.#options.intercomTexts,
164
+ );
161
165
  }
162
166
 
163
167
  /** Threads for background dispatches, in the channel that started each. */
@@ -297,8 +301,8 @@ export class DiscordSurface
297
301
 
298
302
  /**
299
303
  * Discord draws attachments below a message's text, so the card goes out as its own message
300
- * first and produced files last, each on its own. Replies may mention people but never everyone or a role.
301
- */
304
+ * first and produced files last, each on its own. Replies may mention people but never everyone or a role. A card
305
+ * Discord refuses (a channel without Attach Files) is logged, and the text still goes out. */
302
306
  async sendReply(channel: ChannelKey, reply: OutboundReply): Promise<void> {
303
307
  const target = await this.#sendable(channel);
304
308
  const allowedMentions = { parse: ["users" as const] };
@@ -306,12 +310,14 @@ export class DiscordSurface
306
310
  await target.send({ content: reply.thinking, allowedMentions });
307
311
  }
308
312
  if (reply.card) {
309
- await target.send({
310
- files: [
311
- new AttachmentBuilder(Buffer.from(reply.card), {
312
- name: "card.png",
313
- }),
314
- ],
313
+ const card = new AttachmentBuilder(Buffer.from(reply.card), {
314
+ name: "card.png",
315
+ });
316
+ await target.send({ files: [card] }).catch((err: unknown) => {
317
+ this.#options.logger.warn(
318
+ { channel, err },
319
+ "reply card not posted; sending the text alone",
320
+ );
315
321
  });
316
322
  }
317
323
  for (const chunk of reply.chunks) {
@@ -363,33 +369,12 @@ export class DiscordSurface
363
369
  }
364
370
 
365
371
  showStop(channel: ChannelKey): () => void {
366
- const { logger } = this.#options;
367
372
  // An outside agent's conversation has no Discord channel to show a button in.
368
373
  if (!channel.startsWith(PREFIX)) return () => undefined;
369
- let hidden = false;
370
- let panel: Message | undefined;
371
- const remove = (message: Message) =>
372
- message
373
- .delete()
374
- .catch((error: unknown) =>
375
- logger.warn({ channel, err: error }, "stop panel not removed"),
376
- );
377
- const timer = setTimeout(() => {
378
- this.#sendable(channel)
379
- .then((target): Promise<Message> => target.send(stopPanel()))
380
- .then((message) => {
381
- if (hidden) void remove(message);
382
- else panel = message;
383
- })
384
- .catch((error: unknown) =>
385
- logger.warn({ channel, err: error }, "stop panel not shown"),
386
- );
387
- }, STOP_PANEL_DELAY_MS);
388
- return () => {
389
- hidden = true;
390
- clearTimeout(timer);
391
- if (panel) void remove(panel);
392
- };
374
+ return showStopPanel(
375
+ () => this.#sendable(channel),
376
+ (error, what) => this.#options.logger.warn({ channel, err: error }, what),
377
+ );
393
378
  }
394
379
 
395
380
  async react(