pi-roundtable 0.26.0 → 0.26.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 (61) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/docs/plugins.md +39 -7
  3. package/package.json +1 -1
  4. package/src/core/agents/agent-claim.ts +1 -0
  5. package/src/core/agents/agent-conversations.ts +94 -0
  6. package/src/core/agents/agent-messages.ts +247 -56
  7. package/src/core/agents/agent-outbox.ts +91 -0
  8. package/src/core/agents/agent-prompt.ts +40 -1
  9. package/src/core/agents/agent-schema.ts +6 -0
  10. package/src/core/agents/agent-store.ts +22 -3
  11. package/src/core/agents/agent-team-fixture.ts +81 -88
  12. package/src/core/agents/agent-team-seeds.ts +29 -0
  13. package/src/core/agents/agent-team.ts +17 -14
  14. package/src/core/agents/agent-tools.ts +34 -5
  15. package/src/core/agents/group-turns.ts +1 -1
  16. package/src/core/agents/message-target.ts +79 -0
  17. package/src/core/agents/team-editing.ts +30 -2
  18. package/src/core/agents/team-keys.ts +7 -0
  19. package/src/core/agents/team-options.ts +3 -0
  20. package/src/core/agents/team-text.ts +4 -0
  21. package/src/core/agents/team-turn-types.ts +28 -0
  22. package/src/core/agents/team-turns.ts +36 -25
  23. package/src/core/assets/prompts/shared-guest.md +1 -3
  24. package/src/core/assets/prompts/shared.md +1 -3
  25. package/src/core/builtin/agent-server.ts +5 -0
  26. package/src/core/builtin/modules.ts +6 -0
  27. package/src/core/config/config.ts +10 -2
  28. package/src/core/config/conversations.ts +21 -2
  29. package/src/core/contract/channels.ts +5 -0
  30. package/src/core/contract/surface.ts +5 -0
  31. package/src/core/conversations/conversation-retention.ts +22 -6
  32. package/src/core/conversations/conversations-plugin.ts +7 -1
  33. package/src/core/define-roundtable.ts +5 -1
  34. package/src/core/discord/channel-context.ts +3 -1
  35. package/src/core/discord/embed-text.ts +45 -0
  36. package/src/core/discord/inbound-message.ts +7 -2
  37. package/src/core/domain/ports.ts +17 -0
  38. package/src/core/domain/turn-timeout.ts +2 -0
  39. package/src/core/holds.ts +9 -0
  40. package/src/core/i18n/turn-timeout.ts +8 -2
  41. package/src/core/modules/background/background-turns.ts +3 -0
  42. package/src/core/modules/schedules/precheck-catalog.ts +49 -0
  43. package/src/core/modules/schedules/schedule-store.ts +27 -3
  44. package/src/core/modules/schedules/schedule-tools.ts +43 -86
  45. package/src/core/modules/schedules/scheduled-turn-text.ts +31 -0
  46. package/src/core/modules/schedules/schedules.ts +26 -2
  47. package/src/core/modules/schedules/time-limit.ts +27 -0
  48. package/src/core/presentation/reply-body.ts +8 -0
  49. package/src/core/routing/conversation-turns.ts +21 -2
  50. package/src/core/runtime/extensions/ask-user.ts +1 -1
  51. package/src/core/runtime/extensions/confirmation-gate.ts +36 -36
  52. package/src/core/runtime/extensions/gate-refusals.ts +32 -0
  53. package/src/core/runtime/extensions/hold-lookups.ts +31 -2
  54. package/src/core/runtime/extensions/late-answer.ts +5 -2
  55. package/src/core/runtime/extensions/late-releases.ts +76 -0
  56. package/src/core/runtime/pi-agent-runtime.ts +21 -19
  57. package/src/core/runtime/turn-answer.ts +5 -1
  58. package/src/core/runtime/turn-budget.ts +1 -0
  59. package/src/core/runtime/turn-limits.ts +12 -2
  60. package/src/core/runtime/turn-tools.ts +26 -0
  61. package/src/core/shared/schedule-tools.ts +25 -1
@@ -7,6 +7,7 @@ import type {
7
7
  TurnResult,
8
8
  } from "../domain/conversation.ts";
9
9
  import type { AgentTurnScope } from "../domain/ports.ts";
10
+ import type { IdentityService } from "../identity/identity-port.ts";
10
11
  import type { OwnerIdentity } from "../identity.ts";
11
12
  import type { Logger } from "../log.ts";
12
13
  import type { EventSink } from "../plugin.ts";
@@ -85,6 +86,12 @@ export interface TeamTurnsOptions {
85
86
  changed(): void;
86
87
  /** Where turns and team changes are reported for the plugins' handlers. */
87
88
  events?: EventSink;
89
+ /**
90
+ * The identity service, read when a kept agent message is replayed: its speaker runs again only
91
+ * as a principal that still exists and is enabled, at no more than its tier now. Without one, the
92
+ * stored speaker is kept.
93
+ */
94
+ principals?: () => Pick<IdentityService, "speakerFor"> | undefined;
88
95
  /** A thread for each message between agents; without it both go in the sender's channel. */
89
96
  threads?: Pick<DispatchThreads, "open">;
90
97
  }
@@ -94,8 +101,29 @@ export interface TurnExtra {
94
101
  attachments?: TurnAttachments;
95
102
  confirmed?: boolean;
96
103
  steerable?: boolean;
104
+ /** A person's message started it. */
105
+ byMessage?: boolean;
97
106
  /** The owner started it by writing, or it delivers a report, so it may ask them on cards. */
98
107
  interactive?: boolean;
108
+ /** No person waits on the answer, so an empty one is no failure. */
109
+ unattended?: boolean;
110
+ /** How long the turn may work, in ms, in place of the default; a schedule's own limit. */
111
+ timeLimitMs?: number;
112
+ }
113
+
114
+ /** The fields of `extra` that a turn request carries, left out where unset. */
115
+ export function turnFlags(extra: TurnExtra) {
116
+ return {
117
+ ...(extra.attachments ? { attachments: extra.attachments } : {}),
118
+ ...(extra.confirmed ? { confirmed: true } : {}),
119
+ ...(extra.steerable ? { steerable: true } : {}),
120
+ ...(extra.byMessage ? { byMessage: true } : {}),
121
+ ...(extra.interactive ? { interactive: true } : {}),
122
+ ...(extra.unattended ? { unattended: true } : {}),
123
+ ...(extra.timeLimitMs === undefined
124
+ ? {}
125
+ : { timeLimitMs: extra.timeLimitMs }),
126
+ };
99
127
  }
100
128
 
101
129
  /** What the team's message and group collaborators need of the turns that own their state. */
@@ -11,7 +11,7 @@ import { AgentError } from "../domain/errors.ts";
11
11
  import type { InterimPosts } from "../domain/interim.ts";
12
12
  import type { AgentTurnScope } from "../domain/ports.ts";
13
13
  import type { TurnProgress } from "../domain/progress.ts";
14
- import { replyBody } from "../presentation/reply-body.ts";
14
+ import { isQuiet, replyBody } from "../presentation/reply-body.ts";
15
15
  import { splitReply } from "../presentation/reply-splitter.ts";
16
16
  import { withReplyFiles } from "../reply-files.ts";
17
17
  import { endOf, settleTurn } from "../routing/settle-turn.ts";
@@ -28,9 +28,9 @@ import {
28
28
  coordinatorOf,
29
29
  discordIdOf,
30
30
  discordKey,
31
- homeScope,
32
31
  keysOf,
33
32
  roomScope,
33
+ scopeOf,
34
34
  } from "./team-keys.ts";
35
35
  import {
36
36
  type Chain,
@@ -38,6 +38,7 @@ import {
38
38
  type TeamTurnsOptions,
39
39
  type TurnExtra,
40
40
  type TurnHost,
41
+ turnFlags,
41
42
  } from "./team-turn-types.ts";
42
43
 
43
44
  export { MAX_CHAIN_MESSAGES, type TeamTurnsOptions };
@@ -95,8 +96,8 @@ export class TeamTurns {
95
96
  }
96
97
 
97
98
  /**
98
- * The owner's message in an agent's channel; call inside the channel's queue. `text`
99
- * carries the reference, `replyText` is what they typed.
99
+ * The owner's message in an agent's channel; call inside its queue. `text` carries the
100
+ * reference, `replyText` is what they typed.
100
101
  */
101
102
  async answerOwner(
102
103
  channel: ChannelKey,
@@ -106,7 +107,7 @@ export class TeamTurns {
106
107
  attachments: TurnAttachments,
107
108
  ): Promise<TurnResult> {
108
109
  const agent = this.#agentOf(channel);
109
- const scope = this.#scopeOf(agent, channel);
110
+ const scope = scopeOf(agent, channel);
110
111
  const pending = await this.#options.runtime().heldActions(scope.session);
111
112
  const confirmed =
112
113
  pending !== undefined &&
@@ -122,29 +123,31 @@ export class TeamTurns {
122
123
  attachments,
123
124
  confirmed,
124
125
  steerable: true,
126
+ byMessage: true,
125
127
  interactive: true,
126
128
  },
127
129
  );
128
130
  }
129
131
 
130
- /**
131
- * A turn in an agent's channel that no owner message started; call inside its queue. A report
132
- * turn is interactive: it may ask the owner on cards there.
133
- */
132
+ /** A turn no owner message started; call inside its queue. A report turn is interactive. */
134
133
  answerBackground(
135
134
  channel: ChannelKey,
136
135
  speaker: Speaker,
137
136
  text: string,
138
137
  interactive = false,
138
+ timeLimitMs?: number,
139
139
  ): Promise<TurnResult> {
140
140
  const agent = this.#agentOf(channel);
141
141
  return this.#turn(
142
142
  agent,
143
- this.#scopeOf(agent, channel),
143
+ scopeOf(agent, channel),
144
144
  channel,
145
145
  text,
146
146
  { messages: 0, speaker },
147
- interactive ? { interactive: true } : {},
147
+ {
148
+ ...(interactive ? { interactive: true } : { unattended: true }),
149
+ ...(timeLimitMs === undefined ? {} : { timeLimitMs }),
150
+ },
148
151
  );
149
152
  }
150
153
 
@@ -190,13 +193,6 @@ export class TeamTurns {
190
193
  return agent;
191
194
  }
192
195
 
193
- /** The agent's own conversation in the channel: its Discord channel's, or its room's. */
194
- #scopeOf(agent: Agent, channel: ChannelKey): AgentTurnScope {
195
- return discordIdOf(channel) === undefined
196
- ? roomScope(agent, channel)
197
- : homeScope(agent);
198
- }
199
-
200
196
  /**
201
197
  * Runs one agent turn and posts its reply, or a failure notice, in `postTo` under the
202
198
  * agent's name. Never rejects.
@@ -216,6 +212,9 @@ export class TeamTurns {
216
212
  this.#chains.set(scope.session, chain);
217
213
  this.#posting.set(scope.session, postTo);
218
214
  this.#working.set(agent.name, postTo);
215
+ // A message to the agent lands where it last ran a turn; a group round is none.
216
+ if (!scope.group)
217
+ this.#options.store.conversations.keep(agent.name, postTo, logger);
219
218
  this.#active(agent.name, scope.group);
220
219
  const turn = {
221
220
  agent: agent.name,
@@ -236,10 +235,7 @@ export class TeamTurns {
236
235
  channel: postTo,
237
236
  selection: this.#options.selection(scope),
238
237
  text,
239
- ...(extra.attachments ? { attachments: extra.attachments } : {}),
240
- ...(extra.confirmed ? { confirmed: true } : {}),
241
- ...(extra.steerable ? { steerable: true } : {}),
242
- ...(extra.interactive ? { interactive: true } : {}),
238
+ ...turnFlags(extra),
243
239
  agent: scope,
244
240
  speaker: chain.speaker,
245
241
  ...(interim ? { interim } : {}),
@@ -266,7 +262,12 @@ export class TeamTurns {
266
262
  "agent turn failed",
267
263
  );
268
264
  }
269
- await this.#post(postTo, this.#current(agent), replyBody(result));
265
+ if (isQuiet(result))
266
+ logger.info(
267
+ { agent: agent.name, channel: postTo },
268
+ "unattended turn had nothing to say; nothing posted",
269
+ );
270
+ else await this.#post(postTo, this.#current(agent), replyBody(result));
270
271
  } catch (error) {
271
272
  logger.error({ agent: agent.name, err: error }, "agent reply not posted");
272
273
  } finally {
@@ -459,8 +460,18 @@ export class TeamTurns {
459
460
  await this.#post(home, coordinator, { chunks: splitReply(text) });
460
461
  }
461
462
 
462
- message(caller: AgentTurnScope, to: string, text: string): string {
463
- return this.#messages.message(caller, to, text);
463
+ message(...args: Parameters<AgentMessages["message"]>): string {
464
+ return this.#messages.message(...args);
465
+ }
466
+
467
+ /** Delivers the agent messages the last run kept while it stopped; never rejects. */
468
+ replayMessages(): Promise<void> {
469
+ return this.#messages.replay();
470
+ }
471
+
472
+ /** What the team still writes that a shutdown must wait for. */
473
+ busy(): string[] {
474
+ return this.#messages.busy();
464
475
  }
465
476
 
466
477
  /**
@@ -1,9 +1,7 @@
1
- You are an agent in a Discord server. The person you are talking with is a guest of that server, named in the last section of this prompt. You are not speaking for the server's owner, and the owner's private facts stay theirs.
1
+ You are an agent in an agent server. The person you are talking with is a guest of that server, named in the last section of this prompt. You are not speaking for the server's owner, and the owner's private facts stay theirs.
2
2
 
3
3
  Reply in the language they write in. Be warm, direct, and brief.
4
4
 
5
- Write in Discord's Markdown: bold, italics, headings, bullet and numbered lists, block quotes, inline code, fenced code blocks, and `-#` small text all render. Discord shows Markdown tables as raw pipes, so list figures as bullets instead.
6
-
7
5
  Your context holds only their own core facts and upcoming events. When a request touches their projects, people, tools, or past, search memory first. When they ask you to remember or forget something, use the memory tools; what you store is theirs alone.
8
6
 
9
7
  When you need their decision to continue, ask with ask_user instead of ending your turn with a question; they answer on buttons in the channel and your turn goes on.
@@ -1,9 +1,7 @@
1
- You are an agent in a Discord server, working for the server's owner. You talk with them in channels and threads.
1
+ You are an agent working for the owner of this agent server. You talk with them in channels, rooms and threads.
2
2
 
3
3
  Reply in the language they write in. Be warm, direct, and brief.
4
4
 
5
- Write in Discord's Markdown: bold, italics, headings, bullet and numbered lists, block quotes, inline code, fenced code blocks, and `-#` small text all render. Discord shows Markdown tables as raw pipes, so list figures as bullets instead.
6
-
7
5
  Your context holds only their core facts and upcoming events. When a request touches their projects, people, tools, or past, search memory first. When they ask you to remember or forget something, use the memory tools.
8
6
 
9
7
  When you need their decision to continue, ask with ask_user instead of ending your turn with a question; they answer on buttons in the channel and your turn goes on.
@@ -26,6 +26,8 @@ import type { AgentSessionsSlot } from "../runtime/runtime-plugin.ts";
26
26
  import {
27
27
  AGENTS,
28
28
  BACKGROUND_TURNS,
29
+ IDENTITY,
30
+ MODES,
29
31
  PROMPTS,
30
32
  RUNTIME,
31
33
  SCHEDULES,
@@ -316,6 +318,8 @@ export function agentServerPlugin(
316
318
  ? { guestPrompt: guestPrompt ?? options.prompts.guest }
317
319
  : {}),
318
320
  ...(skills ? { skills } : {}),
321
+ modes: () => context.services.find(MODES),
322
+ principals: () => context.services.find(IDENTITY),
319
323
  ...(threads ? { threads } : {}),
320
324
  logger,
321
325
  });
@@ -387,6 +391,7 @@ export function agentServerPlugin(
387
391
  {
388
392
  name: AGENT_TEAM_SERVICE,
389
393
  stop: () => built.stop(),
394
+ busy: () => built.busy(),
390
395
  // The dashboard shows the team, so it starts once the team has; the error
391
396
  // reporter connects whether or not the team did, since it reports the failure.
392
397
  startInBackground: async () => {
@@ -125,6 +125,9 @@ export function modulesPlugin(options: ModulesOptions): RoundtablePlugin {
125
125
  // Another agent's channel, for schedule_list; asked when a tool runs, after the agent server set up.
126
126
  const agentChannelOf = (agent: string, near: ChannelKey) =>
127
127
  services.get(AGENTS).team.channelOf(agent, near);
128
+ // All of an agent's conversations, for its own schedules; asked when a tool runs.
129
+ const ownConversations = (agent: string) =>
130
+ services.get(AGENTS).team.roomsOf(agent);
128
131
  // A conversation no chat surface carries keeps its runs in the creator's direct messages, where
129
132
  // a claim must take them; anyone's elsewhere would run in a conversation that is not theirs.
130
133
  const channelFor = async (
@@ -303,6 +306,9 @@ export function modulesPlugin(options: ModulesOptions): RoundtablePlugin {
303
306
  session.homeChannel,
304
307
  session.agent ? agentChannelOf : undefined,
305
308
  session.speaker,
309
+ session.agent
310
+ ? () => ownConversations(session.agent?.name ?? "")
311
+ : undefined,
306
312
  ),
307
313
  ),
308
314
  ),
@@ -271,7 +271,10 @@ export interface ResolvedConfig extends ResolvedLimits {
271
271
  /** Absent `retention`: used files live as long as their conversation. */
272
272
  attachments: AttachmentsConfig;
273
273
  /** Days to keep each conversation kind after its last activity; a kind not listed is kept. */
274
- conversations: { keepDays: Readonly<Record<string, number>> };
274
+ conversations: {
275
+ keepDays: Readonly<Record<string, number>>;
276
+ keep?: ConversationsConfig["keep"];
277
+ };
275
278
  agentDir: string;
276
279
  model: ModelRef;
277
280
  thinking: ThinkingLevel;
@@ -373,7 +376,12 @@ export function resolveConfig(input: unknown): ResolvedConfig {
373
376
  attachments: config.attachments?.retention
374
377
  ? { retention: config.attachments.retention }
375
378
  : {},
376
- conversations: { keepDays: { ...config.conversations?.keepDays } },
379
+ conversations: {
380
+ keepDays: { ...config.conversations?.keepDays },
381
+ ...(config.conversations?.keep
382
+ ? { keep: config.conversations.keep }
383
+ : {}),
384
+ },
377
385
  agentDir: config.agentDir ?? `${config.dataDir}/pi`,
378
386
  model,
379
387
  thinking,
@@ -1,4 +1,5 @@
1
- import { integer, optional, record, shape } from "./schema.ts";
1
+ import type { ConversationRecord } from "../conversations/conversation-registry.ts";
2
+ import { guarded, integer, optional, record, shape } from "./schema.ts";
2
3
 
3
4
  /** The configuration's `conversations`: what the host does with the conversations it has recorded. */
4
5
  export interface ConversationsConfig {
@@ -10,6 +11,15 @@ export interface ConversationsConfig {
10
11
  * which is what a host without this key does.
11
12
  */
12
13
  keepDays?: Readonly<Record<string, number>>;
14
+ /**
15
+ * Asked for each conversation the sweep would delete, after its kind's `keepDays` ran out:
16
+ * answer `true` to keep it for now, such as a support conversation whose ticket is still open.
17
+ * It is asked again at the next sweep, so a conversation kept today is deleted once the host
18
+ * stops wanting it. A function that throws or rejects keeps the conversation for this sweep and
19
+ * is logged. Without it
20
+ * every conversation past its days is deleted.
21
+ */
22
+ keep?: (conversation: ConversationRecord) => boolean | Promise<boolean>;
13
23
  }
14
24
 
15
25
  /** The longest keep the configuration accepts, in days: a hundred years. */
@@ -17,5 +27,14 @@ const MAX_DAYS = 36_500;
17
27
 
18
28
  /** The `conversations` key's shape. */
19
29
  export const conversationsShape = optional(
20
- shape({ keepDays: optional(record(integer(1, MAX_DAYS))) }),
30
+ shape({
31
+ keepDays: optional(record(integer(1, MAX_DAYS))),
32
+ keep: optional(
33
+ guarded(
34
+ "a function from a conversation record to true to keep it",
35
+ (value): value is NonNullable<ConversationsConfig["keep"]> =>
36
+ typeof value === "function",
37
+ ),
38
+ ),
39
+ }),
21
40
  );
@@ -153,6 +153,11 @@ export interface BackgroundTurn {
153
153
  text: string;
154
154
  /** It delivers a report the owner is waiting for, so it may ask them on cards. */
155
155
  report?: boolean;
156
+ /**
157
+ * How long the turn may work, in ms, in place of the runtime's default time limit: a
158
+ * schedule's own. The runtime holds it under the long-running cap. Absent = the default.
159
+ */
160
+ timeLimitMs?: number;
156
161
  /**
157
162
  * Who the turn runs as, set by the router alone once it checked the author: their principal
158
163
  * exists and is not disabled, and the tier is capped at theirs. A claim runs the turn as this
@@ -79,6 +79,11 @@ export interface SurfaceThreads {
79
79
  export interface ChatSurface {
80
80
  /** The key prefix of this surface's channels, such as "discord"; unique per host. */
81
81
  readonly surface: string;
82
+ /**
83
+ * What people call this surface, such as "Discord" or "the web app"; the agent prompt names it
84
+ * as the place a conversation is in. Absent = the key prefix.
85
+ */
86
+ readonly label?: string;
82
87
  /** Explicit opt-in to delivering OutboundReply.files; absent or false refuses turn attachments. */
83
88
  readonly supportsFiles?: boolean;
84
89
  /**
@@ -1,7 +1,10 @@
1
1
  import type { ConversationPort } from "../contract/channels.ts";
2
2
  import type { Logger } from "../log.ts";
3
3
  import type { Service } from "../plugin.ts";
4
- import type { ConversationRegistry } from "./conversation-registry.ts";
4
+ import type {
5
+ ConversationRecord,
6
+ ConversationRegistry,
7
+ } from "./conversation-registry.ts";
5
8
 
6
9
  const DAY_MS = 24 * 60 * 60_000;
7
10
 
@@ -12,6 +15,8 @@ export interface ConversationRetentionOptions {
12
15
  conversations: Pick<ConversationPort, "deleteConversation">;
13
16
  /** Days to keep each kind after its last activity; a kind not listed is kept. */
14
17
  keepDays: Readonly<Record<string, number>>;
18
+ /** True keeps a conversation past its days for this sweep; a throw keeps it too and is logged. */
19
+ keep?: (record: ConversationRecord) => boolean | Promise<boolean>;
15
20
  now: () => Date;
16
21
  logger: Logger;
17
22
  }
@@ -21,8 +26,10 @@ export interface ConversationSweep {
21
26
  deleted: number;
22
27
  /** A turn was running or waiting: tried again at the next sweep. */
23
28
  busy: number;
24
- /** Its claim could not delete it: logged, and tried again at the next sweep. */
29
+ /** Its claim could not delete it, or `keep` threw: logged, and tried again at the next sweep. */
25
30
  failed: number;
31
+ /** `keep` wanted it: asked again at the next sweep. */
32
+ kept: number;
26
33
  }
27
34
 
28
35
  /**
@@ -33,8 +40,13 @@ export interface ConversationSweep {
33
40
  export async function sweepConversations(
34
41
  options: ConversationRetentionOptions,
35
42
  ): Promise<ConversationSweep> {
36
- const { registry, conversations, keepDays, now, logger } = options;
37
- const result: ConversationSweep = { deleted: 0, busy: 0, failed: 0 };
43
+ const { registry, conversations, keepDays, keep, now, logger } = options;
44
+ const result: ConversationSweep = {
45
+ deleted: 0,
46
+ busy: 0,
47
+ failed: 0,
48
+ kept: 0,
49
+ };
38
50
  const at = now().getTime();
39
51
  for (const record of await registry.list()) {
40
52
  const days = keepDays[record.kind];
@@ -44,6 +56,10 @@ export async function sweepConversations(
44
56
  )
45
57
  continue;
46
58
  try {
59
+ if (keep && (await keep(record))) {
60
+ result.kept += 1;
61
+ continue;
62
+ }
47
63
  const outcome = await conversations.deleteConversation(record.key);
48
64
  if (outcome === "busy") {
49
65
  result.busy += 1;
@@ -55,7 +71,7 @@ export async function sweepConversations(
55
71
  result.failed += 1;
56
72
  logger.warn(
57
73
  { channel: record.key, kind: record.kind, err: error },
58
- "conversation past its keepDays not deleted",
74
+ "conversation past its keepDays not deleted (keep or its claim failed)",
59
75
  );
60
76
  }
61
77
  }
@@ -77,7 +93,7 @@ export function conversationRetentionService(
77
93
  const sweep = async (): Promise<void> => {
78
94
  try {
79
95
  const result = await sweepConversations(options);
80
- if (result.deleted + result.busy + result.failed > 0)
96
+ if (result.deleted + result.busy + result.failed + result.kept > 0)
81
97
  options.logger.info(result, "conversations past their keepDays swept");
82
98
  } catch (error) {
83
99
  options.logger.warn(
@@ -2,7 +2,10 @@ import type { SQL } from "bun";
2
2
  import type { RoundtablePlugin } from "../plugin.ts";
3
3
  import { CONVERSATIONS } from "../services.ts";
4
4
  import type { ConversationRegistry } from "./conversation-registry.ts";
5
- import { conversationRetentionService } from "./conversation-retention.ts";
5
+ import {
6
+ type ConversationRetentionOptions,
7
+ conversationRetentionService,
8
+ } from "./conversation-retention.ts";
6
9
  import { PgConversationRegistry } from "./conversation-store.ts";
7
10
 
8
11
  /** The name of the conversation registry's plugin, as the migration ledger names it. */
@@ -19,6 +22,8 @@ export function conversationsPlugin(
19
22
  PgConversationRegistry.attach(sql),
20
23
  /** Days to keep each conversation kind after its last activity (`conversations.keepDays`); none = keep them all. */
21
24
  keepDays: Readonly<Record<string, number>> = {},
25
+ /** Asked before a conversation past its days is deleted (`conversations.keep`); true keeps it. */
26
+ keep?: ConversationRetentionOptions["keep"],
22
27
  ): RoundtablePlugin {
23
28
  return {
24
29
  name: CONVERSATIONS_PLUGIN,
@@ -35,6 +40,7 @@ export function conversationsPlugin(
35
40
  registry,
36
41
  conversations,
37
42
  keepDays,
43
+ ...(keep ? { keep } : {}),
38
44
  now: () => env.now(),
39
45
  logger,
40
46
  }),
@@ -372,7 +372,11 @@ export async function defineRoundtable(
372
372
  }),
373
373
  ...(assembly?.admin ?? []),
374
374
  ...(agentTeam?.skills ?? []),
375
- conversationsPlugin(undefined, config.conversations.keepDays),
375
+ conversationsPlugin(
376
+ undefined,
377
+ config.conversations.keepDays,
378
+ config.conversations.keep,
379
+ ),
376
380
  modesPlugin(),
377
381
  runtimePlugin({
378
382
  owner,
@@ -14,6 +14,7 @@ import { parseChannelKey } from "../contract/surface.ts";
14
14
  import { MAX_TURN_IMAGES } from "../domain/attachment.ts";
15
15
  import type { Logger } from "../log.ts";
16
16
  import { contextImages, type SkippedImage } from "./context-images.ts";
17
+ import { embedText } from "./embed-text.ts";
17
18
  import type { BotIdentity } from "./inbound-message.ts";
18
19
 
19
20
  /** The most bytes one image may be: the core's limit for any attachment. */
@@ -122,7 +123,8 @@ export function contextSourceOf(
122
123
  if (!user) return mention;
123
124
  return `@${members?.get(id)?.displayName ?? user.globalName ?? user.username}`;
124
125
  });
125
- const parts = text.trim() ? [text.trim()] : [];
126
+ const embeds = embedText(message.embeds);
127
+ const parts = [text.trim(), embeds].filter(Boolean);
126
128
  for (const sticker of message.stickers.values())
127
129
  parts.push(`[sticker: ${sticker.name}]`);
128
130
  for (const file of message.attachments.values())
@@ -0,0 +1,45 @@
1
+ import type { Embed } from "discord.js";
2
+
3
+ /** The most characters the embeds of one message add to its text; the rest is cut and marked. */
4
+ export const EMBED_TEXT_CHARS = 4_000;
5
+
6
+ /** What of an embed is read: its words and link, never its pictures, colors, or timestamp. */
7
+ type EmbedFacts = Pick<
8
+ Embed,
9
+ "title" | "description" | "url" | "fields" | "footer" | "author"
10
+ >;
11
+
12
+ const CUT = "…";
13
+
14
+ function embedLines(embed: EmbedFacts): string[] {
15
+ const lines: string[] = [];
16
+ const head = [embed.author?.name, embed.title].filter(Boolean).join(" — ");
17
+ if (head) lines.push(head);
18
+ if (embed.description) lines.push(embed.description);
19
+ for (const field of embed.fields) lines.push(`${field.name}: ${field.value}`);
20
+ if (embed.footer?.text) lines.push(embed.footer.text);
21
+ if (embed.url) lines.push(embed.url);
22
+ return lines;
23
+ }
24
+
25
+ /**
26
+ * The words of a message's embeds as plain text, one block per embed (title, description, each
27
+ * field as `name: value`, footer, link), so a post that is only an embed, such as an alert a
28
+ * webhook posts, reads as text. Discord renders a Slack-compatible webhook's attachments as
29
+ * embeds, so those are covered too. Text only, nothing is wrapped in markup; at most
30
+ * `EMBED_TEXT_CHARS` characters; empty for a message without embeds.
31
+ */
32
+ export function embedText(embeds: readonly EmbedFacts[]): string {
33
+ const text = embeds
34
+ .map((embed) => embedLines(embed).join("\n").replace(/\r\n?/g, "\n").trim())
35
+ .filter(Boolean)
36
+ .join("\n\n");
37
+ return text.length > EMBED_TEXT_CHARS
38
+ ? `${text.slice(0, EMBED_TEXT_CHARS - CUT.length)}${CUT}`
39
+ : text;
40
+ }
41
+
42
+ /** A message's own text and its embeds' text, apart by a blank line; either may be empty. */
43
+ export function withEmbeds(content: string, embeds: readonly EmbedFacts[]) {
44
+ return [content.trim(), embedText(embeds)].filter(Boolean).join("\n\n");
45
+ }
@@ -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 { withEmbeds } from "./embed-text.ts";
5
6
  import type { LateTurn } from "./owner-cards.ts";
6
7
 
7
8
  /** Who the bot is on Discord, as far as the client knows it. */
@@ -25,7 +26,9 @@ function attachmentRefs(
25
26
 
26
27
  function referenceOf(referenced: Message, mention: RegExp) {
27
28
  return {
28
- text: referenced.content.replace(mention, "").trim(),
29
+ text: withEmbeds(referenced.content, referenced.embeds)
30
+ .replace(mention, "")
31
+ .trim(),
29
32
  attachments: attachmentRefs(referenced),
30
33
  ...(referenced.webhookId
31
34
  ? { webhookName: referenced.author.username }
@@ -97,7 +100,9 @@ export async function toInbound(
97
100
  ...(message.guildId ? { space: message.guildId } : {}),
98
101
  mentionsBot: message.mentions.users.has(botId),
99
102
  repliesToBot: referenced?.author.id === botId,
100
- text: message.content.replace(mention, "").trim(),
103
+ text: withEmbeds(message.content, message.embeds)
104
+ .replace(mention, "")
105
+ .trim(),
101
106
  attachments: [
102
107
  ...attachmentRefs(message),
103
108
  ...(forward ? attachmentRefs(forward) : []),
@@ -60,12 +60,29 @@ export interface TurnRequest {
60
60
  kind?: string;
61
61
  /** The owner's own chat turn, which their next messages may steer. */
62
62
  steerable?: boolean;
63
+ /**
64
+ * A person's message started the turn, so the speaker is present: only such a turn spends a late
65
+ * approval's release. Set by message ingest alone, never by a schedule, agent message, report or error turn.
66
+ */
67
+ byMessage?: boolean;
63
68
  /**
64
69
  * The owner started the turn by writing in its channel, or it delivers a dispatch's report,
65
70
  * so it may ask them there on cards: approvals of held actions and ask_user questions. A
66
71
  * schedule's turn is not.
67
72
  */
68
73
  interactive?: boolean;
74
+ /**
75
+ * No person waits on the turn's answer: a schedule's turn, an agent's message, an agent's
76
+ * opening task. A turn like that may end with nothing to say, which is an empty answer
77
+ * instead of a failure; the reply is then not posted. Absent = someone waits, and an empty
78
+ * final answer fails.
79
+ */
80
+ unattended?: boolean;
81
+ /**
82
+ * How long the turn may work, in ms, in place of the runtime's default time limit; a schedule's
83
+ * own limit. The runtime never lets it pass the long-running cap. Absent = the default.
84
+ */
85
+ timeLimitMs?: number;
69
86
  /**
70
87
  * Where the turn posts the text it writes before its final answer, as it goes; the caller
71
88
  * hands the channel its reply goes to. Absent = only the final reply is posted.
@@ -24,6 +24,8 @@ export interface TurnTimeout {
24
24
  /** How long the timeout card waits for an answer. */
25
25
  graceMs: number;
26
26
  end: TurnTimeoutEnd;
27
+ /** No timeout card could be shown, as in a schedule's turn, so nobody was asked whether to go on. */
28
+ unasked?: true;
27
29
  }
28
30
 
29
31
  /** A turn aborted at a time limit; `timeout` says why, and the message says it in English. */
package/src/core/holds.ts CHANGED
@@ -47,6 +47,14 @@ export interface HoldContext {
47
47
  *
48
48
  * `classes` are the hold classes of the call, for a rule that describes it in a turn (see
49
49
  * `HoldRule.holdClasses`); a rule that leaves them out leaves its call unclassed.
50
+ *
51
+ * `onDeclined` is called once when the call's approval card is declined, whether the turn was
52
+ * still waiting on it or the owner declined after the turn stopped waiting, so a plugin can close
53
+ * what it opened for the call, such as its own notice that asks for the approval. It is not
54
+ * called when the call is withdrawn through `supersede` (the plugin did that itself), approved,
55
+ * left unanswered or expired, or dropped by a message that does not confirm it: only a declined
56
+ * card calls it, in the process that holds the card (a restart loses it with the card). It
57
+ * must not throw; whatever it throws or rejects with is ignored, and the call is refused either way.
50
58
  */
51
59
  export type HoldDescription =
52
60
  | string
@@ -56,6 +64,7 @@ export type HoldDescription =
56
64
  supersede?: AbortSignal;
57
65
  approval?: "speaker";
58
66
  classes?: readonly string[];
67
+ onDeclined?: () => void | Promise<void>;
59
68
  };
60
69
 
61
70
  /**