pi-roundtable 0.27.1 → 0.29.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 (65) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +2 -0
  3. package/README.zh-TW.md +2 -0
  4. package/docs/plugins.md +54 -22
  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 +37 -92
  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-rules.ts +27 -0
  12. package/src/core/agents/agent-team.ts +5 -0
  13. package/src/core/agents/agent-tools.ts +18 -10
  14. package/src/core/agents/helper-create.ts +3 -24
  15. package/src/core/agents/helper-modes.ts +64 -0
  16. package/src/core/agents/message-target.ts +20 -18
  17. package/src/core/agents/routes.ts +42 -24
  18. package/src/core/agents/team-editing.ts +5 -5
  19. package/src/core/agents/team-helpers.ts +76 -5
  20. package/src/core/agents/team-keys.ts +14 -0
  21. package/src/core/agents/team-turn-types.ts +7 -13
  22. package/src/core/agents/team-turns.ts +9 -6
  23. package/src/core/builtin/agent-server.ts +3 -4
  24. package/src/core/builtin/discord.ts +35 -8
  25. package/src/core/builtin/modules.ts +15 -15
  26. package/src/core/config/adapters.ts +2 -1
  27. package/src/core/config/limits.ts +35 -2
  28. package/src/core/config/routing.ts +5 -4
  29. package/src/core/discord/agent-discord.ts +12 -1
  30. package/src/core/discord/card-copies.ts +7 -2
  31. package/src/core/discord/direct-channel.ts +1 -0
  32. package/src/core/discord/discord-surface.ts +13 -30
  33. package/src/core/discord/intercom-cards.ts +121 -0
  34. package/src/core/discord/owner-card-asks.ts +9 -0
  35. package/src/core/discord/owner-card-types.ts +2 -0
  36. package/src/core/discord/owner-cards.ts +2 -1
  37. package/src/core/discord/stop-button.ts +35 -0
  38. package/src/core/domain/conversation.ts +18 -0
  39. package/src/core/domain/interim.ts +7 -0
  40. package/src/core/domain/turn-timeout.ts +5 -0
  41. package/src/core/i18n/conversation.ts +16 -4
  42. package/src/core/i18n/turn-timeout.ts +10 -2
  43. package/src/core/interactions/prompts.ts +5 -0
  44. package/src/core/modes/autonomy.ts +22 -1
  45. package/src/core/modes/conversation-modes.ts +135 -23
  46. package/src/core/modes/mode-inheritance.ts +78 -0
  47. package/src/core/modes/modes-plugin.ts +1 -1
  48. package/src/core/modules/notify/notify.ts +3 -2
  49. package/src/core/modules/notify/session-notify.ts +21 -4
  50. package/src/core/modules/skills/skill-contract.ts +13 -0
  51. package/src/core/modules/skills/skill-registry.ts +3 -1
  52. package/src/core/modules/skills/skill-store.ts +4 -3
  53. package/src/core/presence/delivery-router.ts +165 -0
  54. package/src/core/presence/direct-channels.ts +39 -68
  55. package/src/core/presentation/intercom.ts +44 -0
  56. package/src/core/runtime/ask-questions.ts +21 -4
  57. package/src/core/runtime/extensions/ask-user-text.ts +36 -3
  58. package/src/core/runtime/extensions/ask-user.ts +21 -1
  59. package/src/core/runtime/interim-text.ts +7 -4
  60. package/src/core/runtime/pi-agent-runtime.ts +2 -2
  61. package/src/core/runtime/prompt-slot.ts +11 -1
  62. package/src/core/runtime/session-factory.ts +11 -6
  63. package/src/core/runtime/turn-budget.ts +34 -3
  64. package/src/core/runtime/turn-limits.ts +13 -3
  65. package/src/index.ts +7 -1
@@ -37,7 +37,7 @@ export interface RoutingTable {
37
37
  * has none when the agent has no place at all.
38
38
  */
39
39
  of(agent: string, kind: "integration", origin: ChannelKey): ChannelKey;
40
- of(agent: string, kind: "agentMessage", origin?: ChannelKey): ChannelKey;
40
+ of(agent: string, kind: "agentMessage", origin: ChannelKey): ChannelKey;
41
41
  of(
42
42
  agent: string,
43
43
  kind: RouteKind,
@@ -154,7 +154,7 @@ export class Routes implements RoutingTable {
154
154
  * when an `agentMessage` finds no place; undefined when an `opening` finds none.
155
155
  */
156
156
  of(name: string, kind: "integration", origin: ChannelKey): ChannelKey;
157
- of(name: string, kind: "agentMessage", origin?: ChannelKey): ChannelKey;
157
+ of(name: string, kind: "agentMessage", origin: ChannelKey): ChannelKey;
158
158
  of(
159
159
  name: string,
160
160
  kind: RouteKind,
@@ -201,39 +201,57 @@ export class Routes implements RoutingTable {
201
201
  }
202
202
 
203
203
  /**
204
- * The conversation a message to the agent lands in when the sender names none: with `latest`
205
- * the one it last ran a turn in, except that a message from a room never follows it out to
206
- * Discord, since the room's text may be for the owners alone; with none yet, the sender's own
207
- * surface when it hosts rooms, so a message between rooms stays among them; otherwise its
208
- * primary place.
204
+ * The conversation a message to the agent lands in when the sender names none. By default
205
+ * (`"origin-surface"`) its place on the sender's own surface: its Discord channel for a message
206
+ * from Discord, its room for one from a room. `"latest"` is the conversation it last ran a turn
207
+ * in, but only on the sender's surface. A message never crosses surfaces by recency: when the
208
+ * agent has no place on the sender's surface, or no sender is known, it lands in the agent's
209
+ * primary place, and a crossing is logged with both surfaces.
209
210
  */
210
211
  #agentMessage(name: string, from: ChannelKey | undefined): ChannelKey {
211
- const { store, roomSurfaces, latest } = this.#options;
212
- const to = this.rules(name).agentMessage ?? "latest";
213
- if (to !== "latest") {
212
+ const { latest } = this.#options;
213
+ const to = this.rules(name).agentMessage ?? "origin-surface";
214
+ if (to !== "latest" && to !== "origin-surface") {
214
215
  const key =
215
216
  to === "primary" ? this.primary(name) : this.placeOn(name, to);
216
217
  if (key) return key;
217
218
  this.#warn(
218
219
  `message:${name}`,
219
220
  { agent: name, to },
220
- "the agent has no place for messages to it; the conversation it last ran a turn in is used",
221
+ "the agent has no place for messages to it; its place on the sender's surface is used",
221
222
  );
222
223
  }
223
- const via = from === undefined ? undefined : roomSurfaceOf(from);
224
- const last = latest?.(name);
225
- const keys = keysOf(store, name, roomSurfaces());
226
- const outward =
227
- via !== undefined &&
228
- last !== undefined &&
229
- roomSurfaceOf(last) === undefined;
230
- if (last !== undefined && keys.includes(last) && !outward) return last;
231
- if (via === undefined) {
232
- const primary = this.primary(name);
233
- if (!primary) throw new AgentError(`${name} has no channel yet.`);
234
- return primary;
224
+ const surface =
225
+ from === undefined ? undefined : parseChannelKey(from).surface;
226
+ if (to === "latest" && surface !== undefined) {
227
+ const last = latest?.(name);
228
+ const keys = keysOf(
229
+ this.#options.store,
230
+ name,
231
+ this.#options.roomSurfaces(),
232
+ );
233
+ if (
234
+ last !== undefined &&
235
+ keys.includes(last) &&
236
+ parseChannelKey(last).surface === surface
237
+ )
238
+ return last;
235
239
  }
236
- return agentRoomKey(via, name);
240
+ const own = surface === undefined ? undefined : this.placeOn(name, surface);
241
+ if (own) return own;
242
+ const primary = this.primary(name);
243
+ if (!primary) throw new AgentError(`${name} has no channel yet.`);
244
+ if (surface !== undefined)
245
+ this.#warn(
246
+ `message-cross:${name}:${surface}`,
247
+ {
248
+ agent: name,
249
+ from: surface,
250
+ to: parseChannelKey(primary).surface,
251
+ },
252
+ "the agent has no place on the sender's surface; the message lands in its primary place",
253
+ );
254
+ return primary;
237
255
  }
238
256
 
239
257
  /**
@@ -220,9 +220,9 @@ export class TeamEditing {
220
220
  creator: Agent | undefined;
221
221
  /** The surface whose room runs the opening task. */
222
222
  openIn?: string | undefined;
223
- /** Makes the agent a temp knight of this knight: its place is a thread of the parent's channel, and it settles on the parent's model. */
223
+ /** Makes the agent a temp knight of this knight, which settles on the parent's model. */
224
224
  parent?: Agent | undefined;
225
- /** The conversation a temp knight is made from. */
225
+ /** The conversation a temp knight is made from: made on Discord, it lives in a thread of the parent's channel, made elsewhere in its rooms alone. */
226
226
  origin?: string | undefined;
227
227
  },
228
228
  input: {
@@ -279,10 +279,10 @@ export class TeamEditing {
279
279
  const suggested = suggestCharacter(input, taken);
280
280
  const character = this.#resolve(wanted ?? {}, suggested);
281
281
  const avatarHash = await studio.render(character);
282
- // Without Discord channels the agent lives in its rooms, which exist as soon as it does. A
283
- // temp knight lives in a thread of its parent's channel, or in rooms alone without one.
282
+ // Without Discord channels the agent lives in its rooms, which exist as soon as it does.
283
+ const onDiscord = who.origin?.startsWith("discord:") !== false;
284
284
  const channelId = who.parent
285
- ? who.parent.channelId && channels?.createThread
285
+ ? onDiscord && who.parent.channelId && channels?.createThread
286
286
  ? await channels.createThread(who.parent.channelId, input.displayName)
287
287
  : undefined
288
288
  : await channels?.createChannel(
@@ -1,7 +1,8 @@
1
1
  import { helpersOf } from "../config/helpers.ts";
2
2
  import { AgentError } from "../domain/errors.ts";
3
3
  import type { AgentTurnScope } from "../domain/ports.ts";
4
- import { isHelper } from "./agent-rules.ts";
4
+ import { surfaceOf } from "../presence/delivery-router.ts";
5
+ import { answersTo, helperSurface, isHelper } from "./agent-rules.ts";
5
6
  import type { Agent } from "./agent-store.ts";
6
7
  import type { HelpersEnded } from "./helper-ops.ts";
7
8
  import type { TeamContext } from "./team-options.ts";
@@ -11,8 +12,9 @@ const SWEEP_EVERY_MS = 3_600_000;
11
12
 
12
13
  /**
13
14
  * The team's temp knights after they are made: which a knight has, which are finished, and their
14
- * archive, restore, and sweep. Archiving one closes its Discord thread, since a temp knight's
15
- * place is a thread of its parent's channel; an archived one keeps its history.
15
+ * archive, restore, and sweep. A temp knight made on Discord lives in a thread of its parent's
16
+ * channel; one made in the web app gets that thread only once someone reaches for it on Discord.
17
+ * Archiving one closes its thread, when it has one; an archived one keeps its history.
16
18
  */
17
19
  export class TeamHelpers {
18
20
  readonly #ctx: TeamContext;
@@ -20,6 +22,8 @@ export class TeamHelpers {
20
22
  readonly #archiveAgent: (agent: Agent) => Promise<void>;
21
23
  readonly #ended: ((event: HelpersEnded) => void)[] = [];
22
24
  #sweeper: ReturnType<typeof setInterval> | undefined;
25
+ /** The threads being made for web-born temp knights, by name, so two reaches make one. */
26
+ readonly #placing = new Map<string, Promise<void>>();
23
27
 
24
28
  constructor(ctx: TeamContext, archiveAgent: (agent: Agent) => Promise<void>) {
25
29
  this.#ctx = ctx;
@@ -125,9 +129,74 @@ export class TeamHelpers {
125
129
  return restored;
126
130
  }
127
131
 
132
+ /**
133
+ * A `message_agent` call that names "discord", or names nothing from a Discord conversation, gives
134
+ * a web-born temp knight its thread first.
135
+ */
136
+ async reach(
137
+ caller: AgentTurnScope,
138
+ to: string,
139
+ conversation?: string,
140
+ ): Promise<void> {
141
+ const { store } = this.#ctx.options;
142
+ const { turns } = this.#ctx;
143
+ const target = store.agent(to);
144
+ const speaker = turns.speakerOf(caller);
145
+ const from = turns.turnChannel(caller);
146
+ // Unnamed, a message stays on the sender's surface, so one sent from Discord goes there too.
147
+ const wanted = conversation?.trim() || surfaceOf(from);
148
+ if (wanted !== "discord" || !target || !speaker) return;
149
+ if (answersTo(target, caller.name, from, speaker))
150
+ await this.placeOnDiscord(to);
151
+ }
152
+
153
+ /**
154
+ * Gives an active temp knight made in the web app its thread under the parent's channel, the
155
+ * first time someone reaches for it on Discord. One made on Discord, or before the origin was
156
+ * kept, already has its place, and a host without Discord threads makes none; never rejects.
157
+ */
158
+ placeOnDiscord(name: string): Promise<void> {
159
+ const pending = this.#placing.get(name);
160
+ if (pending) return pending;
161
+ const placing = this.#placeOnDiscord(name).finally(() =>
162
+ this.#placing.delete(name),
163
+ );
164
+ this.#placing.set(name, placing);
165
+ return placing;
166
+ }
167
+
168
+ async #placeOnDiscord(name: string): Promise<void> {
169
+ const { store, channels, logger } = this.#ctx.options;
170
+ const agent = store.agent(name);
171
+ const surface = helperSurface(agent);
172
+ if (agent?.status !== "active" || agent.channelId) return;
173
+ if (surface === undefined || surface === "discord") return;
174
+ const parent = store.agent(agent.parent as string);
175
+ if (!parent?.channelId || !channels?.createThread) return;
176
+ try {
177
+ const channelId = await channels.createThread(
178
+ parent.channelId,
179
+ agent.displayName,
180
+ );
181
+ await store.updateAgent(name, { channelId });
182
+ } catch (error) {
183
+ logger.warn(
184
+ { agent: name, err: error },
185
+ "temp knight's thread not made; it stays in its rooms",
186
+ );
187
+ return;
188
+ }
189
+ logger.info(
190
+ { agent: name, parent: parent.name },
191
+ "temp knight's thread made",
192
+ );
193
+ this.#ctx.changed();
194
+ }
195
+
128
196
  /**
129
197
  * Restores one archived temp knight: its thread opens again, or a new one is made when the old
130
- * is gone. It comes back finished, with nothing running.
198
+ * is gone. One made in the web app never gets a new thread: its old one reopens, if it had one
199
+ * that is still there. It comes back finished, with nothing running.
131
200
  */
132
201
  async restoreOne(agent: Agent): Promise<void> {
133
202
  const { store, channels, logger } = this.#ctx.options;
@@ -137,6 +206,8 @@ export class TeamHelpers {
137
206
  `"${agent.name}" belongs to "${agent.parent}", which is archived; restore it first.`,
138
207
  );
139
208
  let { channelId } = agent;
209
+ const surface = helperSurface(agent);
210
+ const bornOnDiscord = surface === undefined || surface === "discord";
140
211
  if (channels?.createThread && parent.channelId) {
141
212
  if (
142
213
  channelId &&
@@ -144,7 +215,7 @@ export class TeamHelpers {
144
215
  (await channels.exists(channelId))
145
216
  )
146
217
  await channels.openThread(channelId);
147
- else
218
+ else if (bornOnDiscord)
148
219
  channelId = await channels.createThread(
149
220
  parent.channelId,
150
221
  agent.displayName,
@@ -87,6 +87,20 @@ export const roomSurfaceOf = (room: ChannelKey): string | undefined => {
87
87
  return surface === DISCORD_SURFACE ? undefined : surface;
88
88
  };
89
89
 
90
+ /**
91
+ * The channel a group seat runs in outside a running turn: the group's room on the surface of the
92
+ * seat's home for a room seat, so it never falls out to the group's Discord channel; otherwise the
93
+ * group's Discord channel, or the seat's home.
94
+ */
95
+ export function groupSeatChannel(
96
+ group: Pick<AgentGroup, "name" | "channelId">,
97
+ scope: Pick<AgentTurnScope, "home">,
98
+ ): ChannelKey {
99
+ const surface = roomSurfaceOf(scope.home);
100
+ if (surface !== undefined) return groupRoomKey(surface, group.name);
101
+ return group.channelId ? discordKey(group.channelId) : scope.home;
102
+ }
103
+
90
104
  /**
91
105
  * An agent's conversation inside a group, apart from its own. In a Discord group channel, or
92
106
  * without a room, it is keyed by the channel; in a room of another surface, by the surface and the
@@ -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,
@@ -30,6 +31,7 @@ import {
30
31
  coordinatorOf,
31
32
  discordIdOf,
32
33
  discordKey,
34
+ groupSeatChannel,
33
35
  scopeOf,
34
36
  } from "./team-keys.ts";
35
37
  import {
@@ -321,8 +323,10 @@ export class TeamTurns {
321
323
  const { store, channels, studio, surfaces } = this.#options;
322
324
  if (discordIdOf(channel) === undefined) {
323
325
  // A room's interim posts are its surface's, under the agent as author.
324
- if (!surfaces?.interim(channel, this.#author(agent))) return undefined;
326
+ const first = surfaces?.interim(channel, this.#author(agent));
327
+ if (!surfaces || !first) return undefined;
325
328
  return {
329
+ ...(first.tools === false ? { tools: false } : {}),
326
330
  post: async (text) => {
327
331
  if (!channelOwner(store, channel, this.#rooms(channel)))
328
332
  throw new AgentError(`${channel} is archived`);
@@ -363,6 +367,7 @@ export class TeamTurns {
363
367
  files?: ReplyFile[];
364
368
  threadId?: string;
365
369
  failed?: true;
370
+ intercom?: IntercomCard;
366
371
  },
367
372
  ): Promise<void> {
368
373
  const { store, channels, studio, surfaces, logger } = this.#options;
@@ -378,7 +383,7 @@ export class TeamTurns {
378
383
  if (discordIdOf(channel) === undefined) {
379
384
  if (!surfaces)
380
385
  throw new AgentError(`no surface serves the room ${channel}`);
381
- const { thinking, chunks, files, threadId } = rest;
386
+ const { thinking, chunks, files, threadId, intercom } = rest;
382
387
  // A thread of the room's surface is a channel of that surface, and speaks there.
383
388
  return surfaces.sendReply(
384
389
  threadId === undefined
@@ -390,6 +395,7 @@ export class TeamTurns {
390
395
  ...(files?.length ? { files } : {}),
391
396
  author: this.#author(as),
392
397
  ...(failed ? { failed } : {}),
398
+ ...(intercom ? { intercom } : {}),
393
399
  },
394
400
  );
395
401
  }
@@ -424,10 +430,7 @@ export class TeamTurns {
424
430
  : undefined;
425
431
  if (!group) return scope.home;
426
432
  // A seat of a room's group runs in that room; its running turn knows which.
427
- return (
428
- this.#posting.get(scope.session) ??
429
- (group.channelId ? discordKey(group.channelId) : scope.home)
430
- );
433
+ return this.#posting.get(scope.session) ?? groupSeatChannel(group, scope);
431
434
  }
432
435
 
433
436
  /**
@@ -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
  };
@@ -31,6 +31,7 @@ import { Scheduler } from "../modules/schedules/scheduler.ts";
31
31
  import { schedulesExtension } from "../modules/schedules/schedules.ts";
32
32
  import type { ErrorReporter } from "../ops/error-reporter.ts";
33
33
  import type { RoundtablePlugin } from "../plugin.ts";
34
+ import { runChannelFor } from "../presence/delivery-router.ts";
34
35
  import { splitReply } from "../presentation/reply-splitter.ts";
35
36
  import {
36
37
  AGENTS,
@@ -130,23 +131,22 @@ export function modulesPlugin(options: ModulesOptions): RoundtablePlugin {
130
131
  services.get(AGENTS).team.roomsOf(agent);
131
132
  // A conversation no chat surface carries keeps its runs in the creator's direct messages, where
132
133
  // a claim must take them; anyone's elsewhere would run in a conversation that is not theirs.
133
- const channelFor = async (
134
+ // The delivery router picks those messages with the conversation as the origin.
135
+ const channelFor = (
134
136
  channel: ChannelKey,
135
137
  principalId: string,
136
138
  refused: (message: string) => Error,
137
- ) => {
138
- if (surfaces.of(channel)) return channel;
139
- const reached = await directChannels.reach(principalId);
140
- if (!reached)
141
- throw refused(
142
- "this conversation has no chat surface to post a run in, and you have no direct channel on this host to post it in instead",
143
- );
144
- if (!conversations.takesBackground(reached.channel))
145
- throw refused(
146
- `this conversation has no chat surface to post a run in, and nothing here takes background turns in your ${reached.provider.name} direct messages, where it would go instead`,
147
- );
148
- return reached.channel;
149
- };
139
+ ) =>
140
+ runChannelFor(
141
+ {
142
+ surfaces,
143
+ directChannels,
144
+ takesBackground: (key) => conversations.takesBackground(key),
145
+ },
146
+ channel,
147
+ principalId,
148
+ refused,
149
+ );
150
150
  const scheduleChannelFor = (channel: ChannelKey, principalId: string) =>
151
151
  channelFor(channel, principalId, (m) => new ScheduleError(m));
152
152
  const delegateChannelFor = (channel: ChannelKey, principalId: string) =>
@@ -198,7 +198,7 @@ export function modulesPlugin(options: ModulesOptions): RoundtablePlugin {
198
198
  // Only whether a direct channel knows them, without the network; the conversation there,
199
199
  // and whether a claim takes its turns, are checked when a tool runs.
200
200
  try {
201
- if (!(await directChannels.known(own))) return;
201
+ if (!(await directChannels.known(home, own))) return;
202
202
  } catch (error) {
203
203
  logger.warn(
204
204
  { channel: home, err: error },
@@ -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
+ }