pi-roundtable 0.8.0 → 0.9.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 (150) hide show
  1. package/CHANGELOG.md +102 -0
  2. package/README.md +35 -11
  3. package/README.zh-TW.md +27 -10
  4. package/docs/migration-0.9.md +247 -0
  5. package/docs/plugins.md +606 -78
  6. package/examples/channel-context.test.ts +88 -0
  7. package/examples/channel-context.ts +62 -0
  8. package/examples/echo-runtime.test.ts +1 -0
  9. package/examples/fake-surface.test.ts +69 -6
  10. package/examples/fake-surface.ts +73 -7
  11. package/examples/headless.ts +5 -1
  12. package/examples/prechecks.test.ts +1 -0
  13. package/examples/principals.test.ts +60 -0
  14. package/examples/principals.ts +48 -0
  15. package/examples/prompt.test.ts +1 -1
  16. package/examples/study-room.test.ts +29 -1
  17. package/examples/study-room.ts +16 -19
  18. package/examples/support-desk.test.ts +3 -1
  19. package/examples/tools.test.ts +6 -1
  20. package/package.json +5 -2
  21. package/src/cli/checks/database.ts +11 -0
  22. package/src/cli/checks/identity.ts +225 -0
  23. package/src/cli/checks/model.ts +21 -3
  24. package/src/cli/cli.ts +115 -1
  25. package/src/cli/config-edit.ts +83 -13
  26. package/src/cli/doctor.ts +7 -0
  27. package/src/cli/line-diff.ts +72 -0
  28. package/src/cli/principal.ts +412 -0
  29. package/src/cli/project.ts +5 -0
  30. package/src/cli/report.ts +12 -3
  31. package/src/cli/upgrade-rewrite.ts +102 -0
  32. package/src/cli/upgrade-source.ts +430 -0
  33. package/src/cli/upgrade.ts +176 -0
  34. package/src/core/agents/agent-claim.ts +56 -34
  35. package/src/core/agents/team-turns.ts +8 -4
  36. package/src/core/builtin/agent-server.ts +9 -7
  37. package/src/core/builtin/discord-admin.ts +4 -1
  38. package/src/core/builtin/discord.ts +71 -13
  39. package/src/core/builtin/modules.ts +140 -85
  40. package/src/core/builtin/stores.ts +40 -21
  41. package/src/core/config/access.ts +229 -0
  42. package/src/core/config/adapters.ts +64 -1
  43. package/src/core/config/config.ts +78 -41
  44. package/src/core/contract/channel-context.ts +53 -0
  45. package/src/core/contract/channels.ts +77 -9
  46. package/src/core/contract/runtime.ts +23 -7
  47. package/src/core/contract/surface.ts +22 -6
  48. package/src/core/conversations/conversation-registry.ts +14 -0
  49. package/src/core/conversations/conversation-store.ts +13 -0
  50. package/src/core/define-roundtable.ts +41 -11
  51. package/src/core/discord/agent-panel.ts +2 -2
  52. package/src/core/discord/card-audience.ts +148 -0
  53. package/src/core/discord/channel-context.ts +348 -0
  54. package/src/core/discord/connection.ts +15 -3
  55. package/src/core/discord/direct-channel.ts +54 -0
  56. package/src/core/discord/discord-owners.ts +133 -0
  57. package/src/core/discord/discord-surface.ts +69 -14
  58. package/src/core/discord/fresh-marker.ts +24 -0
  59. package/src/core/discord/inbound-message.ts +21 -7
  60. package/src/core/discord/interaction-module.ts +11 -1
  61. package/src/core/discord/owner-cards-fixture.ts +80 -0
  62. package/src/core/discord/owner-cards.ts +58 -97
  63. package/src/core/discord/owner-command.ts +27 -13
  64. package/src/core/discord/stop-button.ts +4 -4
  65. package/src/core/domain/conversation.ts +7 -2
  66. package/src/core/domain/errors.ts +5 -0
  67. package/src/core/domain/ports.ts +27 -2
  68. package/src/core/host.ts +27 -8
  69. package/src/core/i18n/cards.ts +8 -4
  70. package/src/core/identity/access-policy.ts +227 -0
  71. package/src/core/identity/actor-facts.ts +65 -0
  72. package/src/core/identity/caching-principal-store.ts +111 -0
  73. package/src/core/identity/config-owner.ts +145 -0
  74. package/src/core/identity/contact.ts +27 -0
  75. package/src/core/identity/identity-plugin.ts +56 -0
  76. package/src/core/identity/identity-schema.ts +282 -0
  77. package/src/core/identity/identity-service.ts +489 -0
  78. package/src/core/identity/identity-view.ts +77 -0
  79. package/src/core/identity/plugin-identities.ts +109 -0
  80. package/src/core/identity/principal-reads.ts +38 -0
  81. package/src/core/identity/principal-store.ts +478 -0
  82. package/src/core/identity/principal-tables.ts +53 -0
  83. package/src/core/identity/seen-throttle.ts +57 -0
  84. package/src/core/identity/ulid.ts +36 -0
  85. package/src/core/identity.ts +34 -5
  86. package/src/core/interactions/prompts.ts +127 -0
  87. package/src/core/modules/background/background-turns.ts +56 -17
  88. package/src/core/modules/background/personal-target.ts +50 -0
  89. package/src/core/modules/delegation/delegate.ts +28 -14
  90. package/src/core/modules/delegation/delegator.ts +23 -7
  91. package/src/core/modules/memory/owner-memory.ts +123 -50
  92. package/src/core/modules/notify/notify.ts +53 -13
  93. package/src/core/modules/notify/session-notify.ts +110 -0
  94. package/src/core/modules/schedules/prechecks.ts +8 -2
  95. package/src/core/modules/schedules/schedule-prechecks.ts +4 -1
  96. package/src/core/modules/schedules/schedule-store.ts +51 -20
  97. package/src/core/modules/schedules/schedule-tools.ts +122 -12
  98. package/src/core/modules/schedules/scheduler.ts +43 -2
  99. package/src/core/modules/schedules/schedules.ts +43 -14
  100. package/src/core/plugin.ts +48 -0
  101. package/src/core/presence/direct-channels.ts +104 -0
  102. package/src/core/registry/contributions.ts +63 -3
  103. package/src/core/routing/channel-router.ts +218 -17
  104. package/src/core/routing/conversation-turns.ts +38 -8
  105. package/src/core/routing/surface-port.ts +32 -2
  106. package/src/core/routing/system-turns.ts +29 -0
  107. package/src/core/runtime/bridge-guard.ts +225 -0
  108. package/src/core/runtime/compaction-tiers.ts +5 -1
  109. package/src/core/runtime/conversation-sessions.ts +124 -12
  110. package/src/core/runtime/extensions/ask-user.ts +1 -1
  111. package/src/core/runtime/extensions/confirmation-gate.ts +28 -14
  112. package/src/core/runtime/extensions/private-memory.ts +447 -0
  113. package/src/core/runtime/pending-confirmation-store.ts +30 -5
  114. package/src/core/runtime/pi-agent-runtime.ts +135 -134
  115. package/src/core/runtime/preflight-tools.ts +47 -0
  116. package/src/core/runtime/prompt-slot.ts +4 -4
  117. package/src/core/runtime/reader-history.ts +129 -0
  118. package/src/core/runtime/runtime-plugin.ts +50 -5
  119. package/src/core/runtime/runtime-types.ts +40 -12
  120. package/src/core/runtime/session-conversation.ts +143 -0
  121. package/src/core/runtime/session-factory.ts +115 -15
  122. package/src/core/runtime/session-scope.ts +78 -0
  123. package/src/core/runtime/steerable-run.ts +9 -0
  124. package/src/core/runtime/tool-call-scope.ts +43 -0
  125. package/src/core/runtime/turn-answer.ts +30 -1
  126. package/src/core/services.ts +27 -1
  127. package/src/core/sessions.ts +30 -0
  128. package/src/core/speakers.ts +21 -19
  129. package/src/core/testing/background.ts +31 -0
  130. package/src/core/testing/fixture-database.ts +55 -0
  131. package/src/core/testing/map-identity.ts +76 -0
  132. package/src/core/testing/module-sessions.ts +134 -0
  133. package/src/core/testing/modules.ts +108 -21
  134. package/src/core/testing/owner.ts +17 -0
  135. package/src/core/testing/surface-contract.ts +118 -41
  136. package/src/core/testing/test-host.ts +79 -16
  137. package/src/core/testing/tool-set.ts +32 -0
  138. package/src/core/tool-set.snapshot.json +4 -4
  139. package/src/core/tool-tiers.ts +57 -3
  140. package/src/discord/index.ts +8 -0
  141. package/src/index.ts +47 -8
  142. package/src/kit/compaction.ts +13 -0
  143. package/src/kit/index.ts +10 -0
  144. package/src/kit/support.ts +1 -1
  145. package/src/testing.ts +55 -16
  146. package/templates/.env.example +1 -1
  147. package/templates/adapters/web/README.md +7 -0
  148. package/templates/adapters/web/roundtable.config.ts +14 -10
  149. package/templates/roundtable.config.ts +12 -1
  150. package/src/core/domain/owner-prompts.ts +0 -45
@@ -14,20 +14,21 @@ import {
14
14
  } from "discord.js";
15
15
  import { parseChannelKey } from "../contract/surface.ts";
16
16
  import type { ChannelKey } from "../domain/conversation.ts";
17
+ import { messages } from "../i18n/index.ts";
17
18
  import type {
18
19
  Approval,
19
20
  OwnerAnswer,
20
- OwnerPrompts,
21
21
  OwnerQuestion,
22
- } from "../domain/owner-prompts.ts";
23
- import { messages } from "../i18n/index.ts";
22
+ PromptScope,
23
+ Prompts,
24
+ } from "../interactions/prompts.ts";
24
25
  import type { Logger } from "../log.ts";
25
26
  import {
26
- type Speaker,
27
- type SpeakerPolicy,
28
- type Tier,
29
- tierAtLeast,
30
- } from "../speakers.ts";
27
+ type Audience,
28
+ type CardAudienceOptions,
29
+ CardAudiences,
30
+ } from "./card-audience.ts";
31
+ import { DiscordOwners, discordUser } from "./discord-owners.ts";
31
32
  import type { InteractionModule } from "./interaction-module.ts";
32
33
  import { ownerPanel, type PanelContent, plain } from "./owner-panel.ts";
33
34
 
@@ -37,7 +38,7 @@ export const CARD_TIMEOUT_MS = 30 * 60_000;
37
38
  const OTHER = "other";
38
39
  const TEXT_FIELD = "text";
39
40
 
40
- /** A card's message: the panel, led by a mention of the owner in a thread. */
41
+ /** A card's message: the panel, led in a thread by a mention of those it is for. */
41
42
  export type CardPayload = Omit<
42
43
  ReturnType<typeof ownerPanel>,
43
44
  "components" | "allowedMentions"
@@ -57,14 +58,18 @@ export interface CardMessage {
57
58
  /** Where a channel's cards are posted. */
58
59
  export interface CardChannel {
59
60
  send(payload: CardPayload): Promise<CardMessage>;
60
- /** A thread, where the owner is mentioned so they hear of the card; a channel never mentions them. */
61
+ /** A thread, where those a card is for are mentioned so they hear of it; a channel mentions no one. */
61
62
  thread?: boolean;
62
63
  }
63
64
 
64
65
  export interface OwnerCardsOptions {
66
+ /** The primary owner's Discord user id. */
65
67
  ownerId: string;
66
- /** Decides who holds a tier, for cards that lower tiers may answer; without it only the owner answers. */
67
- speakers?: SpeakerPolicy;
68
+ /**
69
+ * Who holds a tier now, and which owners have a Discord identity, for cards that others than
70
+ * the primary owner may answer; without it only the primary owner answers.
71
+ */
72
+ identity?: CardAudienceOptions["identity"];
68
73
  /** The Discord channel by id; throws when it cannot take messages. */
69
74
  channel(channelId: string): Promise<CardChannel>;
70
75
  logger: Logger;
@@ -91,42 +96,41 @@ interface OpenCard {
91
96
  audience: Audience;
92
97
  }
93
98
 
94
- /** The people a card is for. */
95
- interface Audience {
96
- allows(user: { id: string; roleIds: readonly string[] }): boolean;
97
- /** The user the card mentions in a thread. */
98
- mentionId: string;
99
- /** Shown on the card. */
100
- note: string;
101
- refusal: string;
102
- }
103
-
104
99
  /**
105
- * The owner's cards in the assistant's channels: approvals of held actions and ask_user questions,
106
- * answered with buttons, a menu, or a form. The owner may answer, and so may the speaker whose
107
- * turn asks, when the card allows (a question always; an approval at its tier); an unanswered card
108
- * expires after 30 minutes and a stopped turn cancels it. Open cards live in memory, so a
109
- * restart abandons them; pressing one then says it no longer works.
100
+ * The cards in the assistant's channels: approvals of held actions and ask_user questions,
101
+ * answered with buttons, a menu, or a form, by those the turn's prompt scope names (see
102
+ * `CardAudiences`): the speaker whose turn asks, when the card allows (a question always; an
103
+ * approval at its tier), and, in a shared conversation, the owners. An unanswered card expires
104
+ * after 30 minutes and a stopped turn cancels it. Open cards live in memory, so a restart
105
+ * abandons them; pressing one then says it no longer works.
110
106
  */
111
107
  export class OwnerCards implements InteractionModule {
112
108
  readonly #options: OwnerCardsOptions;
109
+ readonly #audiences: CardAudiences;
113
110
  readonly #open = new Map<string, OpenCard>();
114
111
 
115
112
  constructor(options: OwnerCardsOptions) {
116
113
  this.#options = options;
114
+ this.#audiences = new CardAudiences(options, new DiscordOwners(options));
117
115
  }
118
116
 
119
117
  commands() {
120
118
  return [];
121
119
  }
122
120
 
123
- /** The channel's cards; the chat surface port sends only Discord keys here. */
124
- prompts(channel: ChannelKey, speaker?: Speaker): OwnerPrompts | undefined {
121
+ /**
122
+ * The channel's cards for the scope's turn; without a scope they are the owners'. The chat
123
+ * surface port sends only Discord keys here.
124
+ */
125
+ prompts(channel: ChannelKey, scope?: PromptScope): Prompts | undefined {
125
126
  const channelId = parseChannelKey(channel).id;
126
127
  return {
127
- confirm: (title, message, signal, minTier = "owner") =>
128
- this.#post<Approval>(channelId, signal, "expired", "cancelled", {
129
- audience: this.#approvers(minTier, speaker),
128
+ confirm: async (title, message, signal, minTier = "owner") => {
129
+ const audience = await this.#audiences.approval(scope, minTier);
130
+ // A private conversation's call above its person's tier is no one's to approve.
131
+ if (!audience) return "expired";
132
+ return this.#post<Approval>(channelId, signal, "expired", "cancelled", {
133
+ audience,
130
134
  title,
131
135
  sections: [message],
132
136
  rows: (id, disabled) => [
@@ -146,22 +150,26 @@ export class OwnerCards implements InteractionModule {
146
150
  ),
147
151
  ],
148
152
  footer: messages().cardApprovalFooter,
149
- }),
150
- ask: (title, question, signal) =>
151
- this.#post<OwnerAnswer | undefined>(
153
+ });
154
+ },
155
+ ask: async (title, question, signal) => {
156
+ const audience = await this.#audiences.question(scope);
157
+ if (!audience) return undefined;
158
+ return this.#post<OwnerAnswer | undefined>(
152
159
  channelId,
153
160
  signal,
154
161
  undefined,
155
162
  undefined,
156
163
  {
157
- audience: this.#asker(speaker),
164
+ audience,
158
165
  title,
159
166
  sections: [question.question],
160
167
  question,
161
168
  rows: (id, disabled) => askRows(id, question, disabled),
162
169
  footer: messages().cardQuestionFooter,
163
170
  },
164
- ),
171
+ );
172
+ },
165
173
  };
166
174
  }
167
175
 
@@ -214,7 +222,7 @@ export class OwnerCards implements InteractionModule {
214
222
  footer,
215
223
  },
216
224
  open.mention === true,
217
- card.audience.mentionId,
225
+ card.audience.mentions,
218
226
  );
219
227
  const abort = () => settle(cancelled, messages().cardStopped);
220
228
  const timer = setTimeout(
@@ -250,67 +258,22 @@ export class OwnerCards implements InteractionModule {
250
258
  }
251
259
 
252
260
  /** The card's panel; in a thread it leads with the mention of who may answer. */
253
- #panel(content: PanelContent, mention: boolean, userId: string): CardPayload {
261
+ #panel(
262
+ content: PanelContent,
263
+ mention: boolean,
264
+ userIds: readonly string[],
265
+ ): CardPayload {
254
266
  const panel = ownerPanel(content);
255
267
  if (!mention) return panel;
256
268
  return {
257
269
  ...panel,
258
270
  components: [
259
- new TextDisplayBuilder().setContent(`<@${userId}>`),
271
+ new TextDisplayBuilder().setContent(
272
+ userIds.map((id) => `<@${id}>`).join(" "),
273
+ ),
260
274
  ...panel.components,
261
275
  ],
262
- allowedMentions: { users: [userId] },
263
- };
264
- }
265
-
266
- /**
267
- * An approval is for the speaker whose turn held the call, when their tier holds it, and for
268
- * the owner; the owner's alone by default. Nobody else in the channel may approve it.
269
- */
270
- #approvers(minTier: Tier, speaker: Speaker | undefined): Audience {
271
- const { ownerId, speakers } = this.#options;
272
- if (
273
- minTier === "owner" ||
274
- !speakers ||
275
- !speaker ||
276
- speaker.id === ownerId ||
277
- !tierAtLeast(speaker.tier, minTier)
278
- )
279
- return {
280
- allows: (user) => user.id === ownerId,
281
- mentionId: ownerId,
282
- note: "",
283
- refusal: messages().cardOwnerOnly,
284
- };
285
- return {
286
- allows: (user) => {
287
- if (user.id === ownerId) return true;
288
- if (user.id !== speaker.id) return false;
289
- // Their tier when they press, in case it was lowered during the turn.
290
- const who = speakers.resolve({ ...user, name: "" });
291
- return who !== undefined && tierAtLeast(who.tier, minTier);
292
- },
293
- mentionId: speaker.id,
294
- note: messages().cardApproversNote(speaker.id),
295
- refusal: messages().cardApproversRefusal,
296
- };
297
- }
298
-
299
- /** A question is for the speaker whose turn asks it, and for the owner. */
300
- #asker(speaker: Speaker | undefined): Audience {
301
- const { ownerId } = this.#options;
302
- if (!speaker || speaker.id === ownerId)
303
- return {
304
- allows: (user) => user.id === ownerId,
305
- mentionId: ownerId,
306
- note: "",
307
- refusal: messages().cardOwnerOnly,
308
- };
309
- return {
310
- allows: (user) => user.id === speaker.id || user.id === ownerId,
311
- mentionId: speaker.id,
312
- note: messages().cardAskerNote(speaker.id),
313
- refusal: messages().cardAskerRefusal,
276
+ allowedMentions: { users: [...userIds] },
314
277
  };
315
278
  }
316
279
 
@@ -335,10 +298,8 @@ export class OwnerCards implements InteractionModule {
335
298
  });
336
299
  return true;
337
300
  }
338
- const { member } = interaction;
339
- const roleIds =
340
- member && "cache" in member.roles ? [...member.roles.cache.keys()] : [];
341
- if (!card.audience.allows({ id: interaction.user.id, roleIds })) {
301
+ const user = discordUser(interaction);
302
+ if (!(await card.audience.allows(user))) {
342
303
  await interaction.reply({
343
304
  content: card.audience.refusal,
344
305
  flags: MessageFlags.Ephemeral,
@@ -354,7 +315,7 @@ export class OwnerCards implements InteractionModule {
354
315
  footer: outcome,
355
316
  },
356
317
  card.mention === true,
357
- card.audience.mentionId,
318
+ card.audience.mentions,
358
319
  );
359
320
  if (interaction.isButton() && (action === "yes" || action === "no")) {
360
321
  const approved = action === "yes";
@@ -7,7 +7,13 @@ import {
7
7
  SlashCommandSubcommandGroupBuilder,
8
8
  } from "discord.js";
9
9
  import { messages } from "../i18n/index.ts";
10
+ import type { IdentityService } from "../identity/identity-service.ts";
10
11
  import type { Logger } from "../log.ts";
12
+ import {
13
+ type DiscordActor,
14
+ DiscordOwners,
15
+ discordUser,
16
+ } from "./discord-owners.ts";
11
17
  import type {
12
18
  CommandGuard,
13
19
  CommandRoot,
@@ -33,31 +39,36 @@ export const groupOption = (
33
39
  ): RootOption => build(new SlashCommandSubcommandGroupBuilder()).toJSON();
34
40
 
35
41
  /**
36
- * Only the owner may use the root command, checked by user ID; every answer is visible only to them,
37
- * and a failure becomes a panel instead of a hanging interaction.
42
+ * Only an owner may use the root command: the primary owner by user ID, and, with the identity
43
+ * service, every other owner principal's Discord identity, checked again each time. Every answer
44
+ * is visible only to them, and a failure becomes a panel instead of a hanging interaction.
38
45
  */
39
- // pi-lens-ignore: large-class — two methods: the owner check and the failure panel
46
+ // pi-lens-ignore: large-class — three methods: the owner checks and the failure panel
40
47
  export class OwnerGuard implements CommandGuard {
41
- readonly #ownerId: string;
48
+ readonly #owners: DiscordOwners;
42
49
  readonly #logger: Logger;
43
50
  /** The name of the root command the guard serves, without the slash. */
44
51
  readonly root: string;
45
52
  readonly refusalHint: string | undefined;
46
53
 
47
54
  constructor(
48
- ownerId: string,
55
+ owners: DiscordOwners,
49
56
  logger: Logger,
50
57
  root: string,
51
58
  refusalHint?: string,
52
59
  ) {
53
- this.#ownerId = ownerId;
60
+ this.#owners = owners;
54
61
  this.#logger = logger;
55
62
  this.root = root;
56
63
  this.refusalHint = refusalHint;
57
64
  }
58
65
 
66
+ allows(actor: DiscordActor): Promise<boolean> {
67
+ return this.#owners.isOwner(discordUser(actor));
68
+ }
69
+
59
70
  isOwner(actor: { user: { id: string } }): boolean {
60
- return actor.user.id === this.#ownerId;
71
+ return actor.user.id === this.#owners.primary;
61
72
  }
62
73
 
63
74
  /** Runs a handler; failures become a panel instead of a hanging interaction. */
@@ -82,8 +93,10 @@ export class OwnerGuard implements CommandGuard {
82
93
 
83
94
  /** What `commandGuard` needs. */
84
95
  export interface CommandGuardOptions {
85
- /** The one user who may use the owner's commands. */
96
+ /** The primary owner's Discord user id, who may always use the owner's commands. */
86
97
  ownerId: string;
98
+ /** Which other owners there are and whether they still are; without it only `ownerId` may. */
99
+ identity?: Pick<IdentityService, "resolve" | "owners" | "identities">;
87
100
  /** The name of the root command the guard serves, without the slash. */
88
101
  root: string;
89
102
  /** Where a failing command is logged. */
@@ -94,9 +107,10 @@ export interface CommandGuardOptions {
94
107
 
95
108
  /** A guard for the owner's commands, such as the one a test hands a module; the Discord plugin makes its own. */
96
109
  export function commandGuard(options: CommandGuardOptions): CommandGuard {
110
+ const { ownerId, identity, logger } = options;
97
111
  return new OwnerGuard(
98
- options.ownerId,
99
- options.logger,
112
+ new DiscordOwners({ ownerId, ...(identity ? { identity } : {}), logger }),
113
+ logger,
100
114
  options.root,
101
115
  options.refusalHint,
102
116
  );
@@ -121,7 +135,7 @@ const refusal = (hint: string | undefined) =>
121
135
  sections: [`${messages().ownerRefusalBody}${hint ?? ""}`],
122
136
  });
123
137
 
124
- /** Answers a feature's root subcommands and components, for the owner only. */
138
+ /** Answers a feature's root subcommands and components, for the owners only. */
125
139
  export function ownerCommandModule(
126
140
  guard: CommandGuard,
127
141
  handlers: OwnerCommandHandlers,
@@ -139,14 +153,14 @@ export function ownerCommandModule(
139
153
  async handle(interaction) {
140
154
  if (interaction.isAutocomplete()) {
141
155
  if (!owned(interaction)) return false;
142
- if (!guard.isOwner(interaction)) await interaction.respond([]);
156
+ if (!(await guard.allows(interaction))) await interaction.respond([]);
143
157
  else await handlers.autocomplete?.(interaction);
144
158
  return true;
145
159
  }
146
160
  if (await handlers.component?.(interaction)) return true;
147
161
  if (!interaction.isChatInputCommand() || !owned(interaction))
148
162
  return false;
149
- if (!guard.isOwner(interaction)) {
163
+ if (!(await guard.allows(interaction))) {
150
164
  await interaction.reply(refusal(guard.refusalHint));
151
165
  return true;
152
166
  }
@@ -12,7 +12,7 @@ import type { CommandGuard, InteractionModule } from "./interaction-module.ts";
12
12
 
13
13
  export const STOP_BUTTON_ID = "roundtable:stop";
14
14
 
15
- /** The message a long turn shows: a stop button only the owner may press. */
15
+ /** The message a long turn shows: a stop button only an owner may press. */
16
16
  export function stopPanel() {
17
17
  return {
18
18
  content: messages().stopNote,
@@ -29,13 +29,13 @@ export function stopPanel() {
29
29
  }
30
30
 
31
31
  export interface StopButtonOptions {
32
- guard: Pick<CommandGuard, "isOwner">;
32
+ guard: Pick<CommandGuard, "allows">;
33
33
  /** Stops the running turn of the channel the button was pressed in, through its claim. */
34
34
  conversations: Pick<ConversationPort, "stop">;
35
35
  }
36
36
 
37
37
  /**
38
- * Answers a press of the stop button: only the owner stops the channel's turn, and the answer,
38
+ * Answers a press of the stop button: only an owner stops the channel's turn, and the answer,
39
39
  * visible only to whoever pressed, says whether one was running.
40
40
  */
41
41
  export function stopButtonModule(
@@ -46,7 +46,7 @@ export function stopButtonModule(
46
46
  handle: async (interaction: Interaction) => {
47
47
  if (!interaction.isButton() || interaction.customId !== STOP_BUTTON_ID)
48
48
  return false;
49
- const owner = options.guard.isOwner(interaction);
49
+ const owner = await options.guard.allows(interaction);
50
50
  const stopped =
51
51
  owner &&
52
52
  options.conversations.stop(
@@ -27,10 +27,15 @@ export interface PendingConfirmation {
27
27
  heldAt: Date;
28
28
  calls: HeldCall[];
29
29
  /**
30
- * The speaker whose turn held the calls. Besides the owner, only they may approve them, at a
31
- * tier that holds them; without one, the owner alone may.
30
+ * The speaker whose turn held the calls, by their id on the surface. Besides the owners, only
31
+ * they may approve them, at a tier that holds them; without one, the owners alone may.
32
32
  */
33
33
  speakerId?: string;
34
+ /**
35
+ * The principal of that speaker, who approves them on any of their identities; absent for
36
+ * calls held before 0.9, which are matched by `speakerId`.
37
+ */
38
+ principalId?: string;
34
39
  }
35
40
 
36
41
  /** A file produced for a reply; data is raw bytes, not a path or base64. */
@@ -29,3 +29,8 @@ export class DelegationError extends Error {
29
29
  export class AgentError extends Error {
30
30
  override name = "AgentError";
31
31
  }
32
+
33
+ /** A principal, identity link, or role change that cannot be made; its message names what to fix. */
34
+ export class IdentityError extends Error {
35
+ override name = "IdentityError";
36
+ }
@@ -7,6 +7,11 @@ import type { TurnProgress } from "./progress.ts";
7
7
 
8
8
  export type { AgentTurnScope };
9
9
 
10
+ /** Who a turn's conversation belongs to. */
11
+ export type TurnConversation =
12
+ | { visibility: "private"; principalId: string }
13
+ | { visibility: "shared" };
14
+
10
15
  export interface TurnRequest {
11
16
  /** Where the turn's message arrived and its attachments are saved. */
12
17
  channel: ChannelKey;
@@ -19,8 +24,18 @@ export interface TurnRequest {
19
24
  confirmed?: boolean;
20
25
  /** Set for a turn of an agent-server agent. */
21
26
  agent?: AgentTurnScope;
22
- /** The person the turn is for: who wrote the message, or who set up the work. */
23
- speaker?: Speaker;
27
+ /**
28
+ * The person the turn is for: who wrote the message, or who set up the work; the turn runs at
29
+ * their tier. Required: a turn without one is refused, never run as the owner's. A plugin that
30
+ * starts a turn on someone's behalf gets theirs from `IDENTITY.speakerFor`.
31
+ */
32
+ speaker: Speaker;
33
+ /**
34
+ * Who the conversation belongs to, as the host records it: `private` to one principal, or
35
+ * `shared` by whoever its claim admits, which it is taken to be when absent. A private
36
+ * conversation's prompts are its person's alone; a shared one's escalate to the owners.
37
+ */
38
+ conversation?: TurnConversation;
24
39
  /**
25
40
  * The conversation's kind, fixed when its session is made: the string a claim's `startFresh`
26
41
  * returns, which picks the persona of a non-agent conversation. "owner" when absent.
@@ -54,6 +69,16 @@ export type {
54
69
  YesNoQuestion,
55
70
  } from "../contract/providers.ts";
56
71
 
72
+ /** Sends a person a notice through their own channel; resolves false when they have none. */
73
+ export interface Notifier {
74
+ notify(principalId: string, text: string): Promise<boolean>;
75
+ }
76
+
77
+ /**
78
+ * Sends the primary owner a direct message.
79
+ * @deprecated Since 0.9 there may be more than one owner: reach a person with a `Notifier`, such as
80
+ * `context.directChannels`. Goes away in 1.0.
81
+ */
57
82
  export interface OwnerNotifier {
58
83
  notifyOwner(text: string): Promise<void>;
59
84
  }
package/src/core/host.ts CHANGED
@@ -7,6 +7,7 @@ import { MigrationError, NotLinkedError, PluginError } from "./errors.ts";
7
7
  import { EventBus } from "./events.ts";
8
8
  import { HttpListeners, type ListenerConfig } from "./http/listeners.ts";
9
9
  import { type Locale, setLocale } from "./i18n/index.ts";
10
+ import { contactsOf } from "./identity/identity-view.ts";
10
11
  import type { JudgeModel } from "./judging/model-judge.ts";
11
12
  import type { Logger } from "./log.ts";
12
13
  import type {
@@ -32,7 +33,7 @@ import {
32
33
  conversationTurns,
33
34
  } from "./routing/conversation-turns.ts";
34
35
  import { surfacePort } from "./routing/surface-port.ts";
35
- import { CONVERSATIONS, RUNTIME } from "./services.ts";
36
+ import { CONVERSATIONS, IDENTITY, RUNTIME } from "./services.ts";
36
37
  import { setTimeZone } from "./time.ts";
37
38
  import { type ToolTierTable, toolTiers } from "./tool-tiers.ts";
38
39
 
@@ -154,6 +155,7 @@ export class Roundtable {
154
155
  #listeners: HttpListeners | undefined;
155
156
  #pool: SQL | undefined;
156
157
  readonly #queue = new ChannelQueue();
158
+ readonly #warned = new Set<string>();
157
159
  readonly #tiers: ToolTierTable;
158
160
  readonly #events: EventBus;
159
161
  #services: ServiceRegistry | undefined;
@@ -191,6 +193,7 @@ export class Roundtable {
191
193
  return {
192
194
  handle: (message) => router().handle(message),
193
195
  background: (turn) => router().background(turn),
196
+ runsAs: (turn) => router().runsAs(turn),
194
197
  target: (name) => router().target(name),
195
198
  startFresh: (channel) => router().startFresh(channel),
196
199
  deleteConversation: (channel) => router().deleteConversation(channel),
@@ -198,18 +201,29 @@ export class Roundtable {
198
201
  postsInPlace: (channel) => router().postsInPlace(channel),
199
202
  owns: (channel) => router().owns(channel),
200
203
  takesBackground: (channel) => router().takesBackground(channel),
204
+ takesSystemReports: (channel) => router().takesSystemReports(channel),
201
205
  };
202
206
  }
203
207
 
204
208
  /** The contributed surfaces by channel prefix; every call before linking throws NotLinkedError. */
205
209
  #surfaces(): SurfacePort {
206
- return surfacePort(() => {
207
- if (!this.#sessions)
208
- throw new NotLinkedError(
209
- "chat surfaces are linked once every plugin is set up. Use surfaces from a service's start or from a handler, not during setup.",
210
- );
211
- return this.#registry.surfaces;
212
- });
210
+ return surfacePort(
211
+ () => {
212
+ if (!this.#sessions)
213
+ throw new NotLinkedError(
214
+ "chat surfaces are linked once every plugin is set up. Use surfaces from a service's start or from a handler, not during setup.",
215
+ );
216
+ return this.#registry.surfaces;
217
+ },
218
+ { deprecated: (message) => this.#deprecated(message) },
219
+ );
220
+ }
221
+
222
+ /** Logs a deprecation once per host. */
223
+ #deprecated(message: string): void {
224
+ if (this.#warned.has(message)) return;
225
+ this.#warned.add(message);
226
+ this.#options.logger.warn(message);
213
227
  }
214
228
 
215
229
  /** Turns over the runtime plugin's runtime and the surfaces; every call before linking is refused with NotLinkedError. */
@@ -328,11 +342,16 @@ export class Roundtable {
328
342
  const { routes, channels } = this.#registry;
329
343
  this.#sessions = linkSessions(this.#registry);
330
344
  this.#events.link(this.#registry.handlers);
345
+ const identity = this.#services.find(IDENTITY);
331
346
  this.#router = new ChannelRouter({
332
347
  claims: channels,
348
+ ...(identity
349
+ ? { contacts: contactsOf(identity), principals: identity }
350
+ : {}),
333
351
  targets: (name) =>
334
352
  this.#registry.backgroundTargets.find((t) => t.name === name),
335
353
  queue: this.#queue,
354
+ surfaces: this.#surfaces(),
336
355
  logger,
337
356
  ...(this.#options.conversations?.forwardJoinMs === undefined
338
357
  ? {}
@@ -11,10 +11,12 @@ export function cardsEn(ctx: CatalogContext) {
11
11
  cardStopped: "⏹️ This round was stopped.",
12
12
  cardExpired: "⌛ Timed out with no answer.",
13
13
  cardOwnerOnly: `Only ${ctx.assistant}'s owner can answer this card.`,
14
- cardApproversNote: (userId: string) => `-# Who can approve: <@${userId}>`,
14
+ cardApproversNote: (...userIds: readonly string[]) =>
15
+ `-# Who can approve: ${userIds.map((id) => `<@${id}>`).join(" ")}`,
15
16
  cardApproversRefusal:
16
17
  "This approval is for the speaker whose turn asked for it.",
17
- cardAskerNote: (userId: string) => `-# Who can answer: <@${userId}>`,
18
+ cardAskerNote: (...userIds: readonly string[]) =>
19
+ `-# Who can answer: ${userIds.map((id) => `<@${id}>`).join(" ")}`,
18
20
  cardAskerRefusal: "This question is for another speaker.",
19
21
  cardInactive: `This card is no longer active (it timed out, or ${ctx.assistant} restarted); reply in text if you need to.`,
20
22
  cardApproved: "✅ Approved to run.",
@@ -41,9 +43,11 @@ export function cardsZhTW(ctx: CatalogContext): ReturnType<typeof cardsEn> {
41
43
  cardStopped: "⏹️ 這一輪已停止。",
42
44
  cardExpired: "⌛ 已逾時,沒有回答。",
43
45
  cardOwnerOnly: `只有 ${ctx.assistant} 的擁有者能回答這張卡片。`,
44
- cardApproversNote: (userId: string) => `-# 可以核准的人:<@${userId}>`,
46
+ cardApproversNote: (...userIds: readonly string[]) =>
47
+ `-# 可以核准的人:${userIds.map((id) => `<@${id}>`).join(" ")}`,
45
48
  cardApproversRefusal: "這張核准卡是給提出這一輪的發話者的。",
46
- cardAskerNote: (userId: string) => `-# 可以回答的人:<@${userId}>`,
49
+ cardAskerNote: (...userIds: readonly string[]) =>
50
+ `-# 可以回答的人:${userIds.map((id) => `<@${id}>`).join(" ")}`,
47
51
  cardAskerRefusal: "這張問題是問另一位發話者的。",
48
52
  cardInactive: `這張卡片已經失效(逾時,或 ${ctx.assistant} 重新啟動過);需要的話直接打字回覆。`,
49
53
  cardApproved: "✅ 已核准執行。",