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
@@ -1,4 +1,8 @@
1
1
  import { attachmentsOf } from "../attachments/turn-attachments.ts";
2
+ import {
3
+ type ChannelContext,
4
+ withChannelContext,
5
+ } from "../contract/channel-context.ts";
2
6
  import type {
3
7
  Admission,
4
8
  BackgroundTarget,
@@ -8,12 +12,13 @@ import type {
8
12
  import type { AgentRuntime } from "../contract/runtime.ts";
9
13
  import type { SurfacePort } from "../contract/surface.ts";
10
14
  import type { ChannelKey } from "../domain/conversation.ts";
11
- import { messages } from "../i18n/index.ts";
12
15
  import type { OwnerIdentity } from "../identity.ts";
13
16
  import type { Logger } from "../log.ts";
17
+ import { PERSONAL_TARGET } from "../modules/background/personal-target.ts";
14
18
  import { withReference } from "../routing/message-text.ts";
15
19
  import { outcome } from "../routing/settle-turn.ts";
16
- import { addressee, attributed, type SpeakerPolicy } from "../speakers.ts";
20
+ import { systemTurn } from "../routing/system-turns.ts";
21
+ import { addressee, attributed } from "../speakers.ts";
17
22
  import type { DiscordAgentTeam } from "./agent-team.ts";
18
23
  import { discordIdOf } from "./team-keys.ts";
19
24
 
@@ -27,23 +32,15 @@ import { discordIdOf } from "./team-keys.ts";
27
32
  export const AGENT_SERVER_PRIORITY = 100;
28
33
 
29
34
  /**
30
- * The host's own background target: the conversations of the owner and of the agent server. The
31
- * agent server contributes it, so schedules and delegated tasks made in those conversations carry
32
- * its name and limits. Its label is read when a list is shown, never when this module loads,
33
- * because the host applies its catalog at startup.
35
+ * The host's own background target, which the agent server answers.
36
+ * @deprecated Use `PERSONAL_TARGET`, the same target, which the core contributes on every host;
37
+ * this name goes away in 1.0.
34
38
  */
35
- export const OWNER_TARGET: BackgroundTarget = {
36
- name: "owner",
37
- label: () => messages().scheduleModeOwner,
38
- schedules: { perChannel: 20, promptChars: 8_000, aheadDays: 366 },
39
- delegation: { maxRunning: 3 },
40
- };
39
+ export const OWNER_TARGET: BackgroundTarget = PERSONAL_TARGET;
41
40
 
42
41
  export interface AgentClaimOptions {
43
42
  /** The owner, as forwarded messages name them. */
44
43
  owner: OwnerIdentity;
45
- /** Who may talk to the agents, and at which tier. */
46
- speakers: SpeakerPolicy;
47
44
  team: Pick<
48
45
  DiscordAgentTeam,
49
46
  | "guildId"
@@ -60,6 +57,13 @@ export interface AgentClaimOptions {
60
57
  logger: Logger;
61
58
  /** Replaceable in tests. */
62
59
  fetchImpl?: typeof fetch;
60
+ /**
61
+ * What was said in an agent's channel since its last post, `DISCORD.channelContext`; without it
62
+ * the turns carry none. A group's round carries what was said since its last turn already.
63
+ */
64
+ channelContext?: (
65
+ message: InboundMessage,
66
+ ) => Promise<ChannelContext | undefined>;
63
67
  }
64
68
 
65
69
  /**
@@ -68,24 +72,39 @@ export interface AgentClaimOptions {
68
72
  * failure notice, is a labelled report turn for the agent.
69
73
  */
70
74
  export function agentClaim(options: AgentClaimOptions): ChannelClaim {
71
- const { speakers, team, runtime, surface, attachmentDir, logger, fetchImpl } =
72
- options;
75
+ const { team, runtime, surface, attachmentDir, logger, fetchImpl } = options;
73
76
  const attachments = (message: InboundMessage) =>
74
77
  attachmentsOf(message, attachmentDir(message.channel), logger, fetchImpl);
78
+ /** The channel context of a message in an agent's channel; none, logged, when it cannot be read. */
79
+ const around = async (
80
+ message: InboundMessage,
81
+ ): Promise<ChannelContext | undefined> => {
82
+ try {
83
+ return await options.channelContext?.(message);
84
+ } catch (error) {
85
+ logger.warn(
86
+ { channel: message.channel, err: error },
87
+ "channel context not read; the turn runs without it",
88
+ );
89
+ return undefined;
90
+ }
91
+ };
75
92
 
76
93
  const webhookReport = (message: InboundMessage): Admission | undefined => {
77
94
  const text = message.text.trim();
78
95
  if (!text) return undefined;
79
96
  return {
80
97
  kind: "background",
81
- turn: {
98
+ // The host's own turn, at the owner tier as in 0.8; what the webhook posted is untrusted input.
99
+ turn: systemTurn({
82
100
  channel: message.channel,
83
- target: OWNER_TARGET.name,
101
+ target: PERSONAL_TARGET.name,
84
102
  author: { id: message.authorId, name: message.authorName },
103
+ tier: "owner",
85
104
  turnId: `webhook-${message.messageId}`,
86
105
  text: `Webhook "${message.authorName}" posted in your channel:\n${text}`,
87
106
  report: true,
88
- },
107
+ }),
89
108
  unanswered: (result) =>
90
109
  logger.warn(
91
110
  {
@@ -112,13 +131,9 @@ export function agentClaim(options: AgentClaimOptions): ChannelClaim {
112
131
  if (!owned) return undefined;
113
132
  if (owned === "agent" && message.integration && !message.integration.own)
114
133
  return webhookReport(message);
115
- if (message.authorIsBot) return undefined;
116
- const speaker = speakers.resolve({
117
- id: message.authorId,
118
- name: message.authorName,
119
- ...(message.authorRoleIds ? { roleIds: message.authorRoleIds } : {}),
120
- });
121
- if (!speaker) return undefined;
134
+ // Who may talk to the agents, and at which tier, the router resolved through the identity service.
135
+ const { speaker } = message;
136
+ if (message.authorIsBot || !speaker) return undefined;
122
137
  const { channel, messageId } = message;
123
138
  const forwarder = addressee(speaker, options.owner);
124
139
  return {
@@ -147,7 +162,10 @@ export function agentClaim(options: AgentClaimOptions): ChannelClaim {
147
162
  await team.answerOwner(
148
163
  channel,
149
164
  speaker,
150
- attributed(speaker, text),
165
+ attributed(
166
+ speaker,
167
+ withChannelContext(text, await around(message)),
168
+ ),
151
169
  message.text,
152
170
  saved,
153
171
  );
@@ -166,22 +184,26 @@ export function agentClaim(options: AgentClaimOptions): ChannelClaim {
166
184
  },
167
185
  background: async (turn) => {
168
186
  // Fail closed: a turn for any target but the owner's never reaches the owner's tools.
169
- if (turn.target !== OWNER_TARGET.name)
187
+ if (turn.target !== PERSONAL_TARGET.name)
170
188
  return {
171
189
  status: "skipped",
172
- reason: `the agent server answers only "${OWNER_TARGET.name}" background turns`,
190
+ reason: `the agent server answers only "${PERSONAL_TARGET.name}" background turns`,
173
191
  };
174
192
  const owned = team.owns(turn.channel);
175
193
  if (owned === "group")
176
194
  return { status: "skipped", reason: "groups have no schedules" };
195
+ // Who the turn runs as, checked by the router: its author's principal, at a tier they hold.
196
+ const { speaker } = turn;
197
+ if (!speaker)
198
+ return {
199
+ status: "skipped",
200
+ reason:
201
+ "a background turn reaches the agent server through the router, which says whom it runs as",
202
+ };
177
203
  return outcome(
178
204
  await team.answerBackground(
179
205
  turn.channel,
180
- {
181
- id: turn.author.id,
182
- name: turn.author.name,
183
- tier: turn.tier ?? "owner",
184
- },
206
+ speaker,
185
207
  turn.text,
186
208
  turn.report === true,
187
209
  ),
@@ -144,12 +144,16 @@ export class TeamTurns {
144
144
  }
145
145
 
146
146
  /**
147
- * Whether the speaker may approve the held actions: the owner, or the speaker whose turn held
148
- * them, when their tier now holds every tool. Nobody else in the channel may.
147
+ * Whether the speaker may approve the held actions by a message: an owner, as the agents'
148
+ * channels are shared, or the principal whose turn held them (by their id, for actions held
149
+ * before 0.9), when their tier now holds every tool. Nobody else in the channel may.
149
150
  */
150
151
  #mayApprove(speaker: Speaker, pending: PendingConfirmation): boolean {
151
- if (speaker.tier !== "owner" && speaker.id !== pending.speakerId)
152
- return false;
152
+ const theirs =
153
+ pending.principalId !== undefined
154
+ ? speaker.principalId === pending.principalId
155
+ : speaker.id === pending.speakerId;
156
+ if (speaker.tier !== "owner" && !theirs) return false;
153
157
  const tiers = this.#options.toolTiers ?? toolTiers();
154
158
  return pending.calls.every(
155
159
  (call) =>
@@ -2,7 +2,7 @@ import { chmodSync, lstatSync, mkdirSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import type { ModelRuntime } from "@earendil-works/pi-coding-agent";
4
4
  import type { SQL } from "bun";
5
- import { agentClaim, OWNER_TARGET } from "../agents/agent-claim.ts";
5
+ import { agentClaim } from "../agents/agent-claim.ts";
6
6
  import { AgentDashboard } from "../agents/agent-dashboard.ts";
7
7
  import { PgAgentStore } from "../agents/agent-store.ts";
8
8
  import { DiscordAgentTeam } from "../agents/agent-team.ts";
@@ -30,7 +30,7 @@ import {
30
30
  SKILLS,
31
31
  } from "../services.ts";
32
32
  import type { SessionTool } from "../sessions.ts";
33
- import type { SpeakerPolicy, Tier } from "../speakers.ts";
33
+ import type { Tier } from "../speakers.ts";
34
34
  import { DISCORD } from "./discord.ts";
35
35
  import { agentOnly } from "./session-tool.ts";
36
36
 
@@ -41,8 +41,6 @@ export interface AgentServerOptions {
41
41
  owner: OwnerIdentity & { id: string };
42
42
  /** The assistant's display name, as the confirmation judge names it. */
43
43
  assistant: string;
44
- /** Who may talk to the agents, and at which tier. */
45
- speakers: SpeakerPolicy;
46
44
  /** Shared with the rest of the host, so logins refresh in one place; lists the models an agent may run. */
47
45
  modelRuntime: ModelRuntime;
48
46
  dataDir: string;
@@ -61,6 +59,11 @@ export interface AgentServerOptions {
61
59
  scratchDir?: string;
62
60
  /** The host account the agents' shell and file tools run as. */
63
61
  shellUser: string;
62
+ /**
63
+ * Whose turns in the agents' channels load memory: `"everyone"` (the default) or `"owners"`;
64
+ * see `AgentSessions.memory`.
65
+ */
66
+ memory?: "everyone" | "owners";
64
67
  /** The prompt every agent starts with, and the one for speakers other than the owner. */
65
68
  prompts: { shared: string; guest?: string };
66
69
  /** The listener the avatar pictures are served on. */
@@ -225,6 +228,7 @@ export function agentServerPlugin(
225
228
  modelOf: (name) => built.modelOf(name),
226
229
  skills: (name) => built.skillsOf(name),
227
230
  turnChannel: (scope) => built.turnChannel(scope),
231
+ ...(options.memory ? { memory: options.memory } : {}),
228
232
  };
229
233
  options.agents?.bind(agentSessions);
230
234
  context.services.provide(AGENTS, {
@@ -276,17 +280,15 @@ export function agentServerPlugin(
276
280
  },
277
281
  },
278
282
  ],
279
- // The schedules and delegated tasks of the owner's and the agents' conversations are its own.
280
- backgroundTargets: [OWNER_TARGET],
281
283
  channels: [
282
284
  agentClaim({
283
285
  owner: options.owner,
284
- speakers: options.speakers,
285
286
  team: built,
286
287
  runtime: running,
287
288
  surface: context.surfaces,
288
289
  attachmentDir,
289
290
  logger,
291
+ channelContext: (message) => discord.channelContext(message),
290
292
  }),
291
293
  ],
292
294
  http: [studio.route(options.avatarListener)],
@@ -8,7 +8,10 @@ import { DISCORD } from "./discord.ts";
8
8
  import { fixed } from "./session-tool.ts";
9
9
 
10
10
  export interface DiscordAdminOptions {
11
- /** Whose Discord permissions the tools check, and who they serve. */
11
+ /**
12
+ * Whose Discord permissions the tools check, and who they serve: the primary owner, by their
13
+ * Discord user id, whichever owner's turn calls them.
14
+ */
12
15
  owner: OwnerIdentity & { id: string };
13
16
  }
14
17
 
@@ -1,7 +1,14 @@
1
1
  import { join } from "node:path";
2
+ import type {
3
+ ChannelContext,
4
+ ChannelContextOptions,
5
+ } from "../contract/channel-context.ts";
2
6
  import { type ServiceKey, serviceKey } from "../contract/services.ts";
7
+ import { ChannelContextReader } from "../discord/channel-context.ts";
3
8
  import { CommandCollection } from "../discord/command-collection.ts";
4
9
  import type { DiscordConnection } from "../discord/connection.ts";
10
+ import { discordDirectChannel } from "../discord/direct-channel.ts";
11
+ import { DiscordOwners } from "../discord/discord-owners.ts";
5
12
  import { DiscordSurface } from "../discord/discord-surface.ts";
6
13
  import { DispatchThreads } from "../discord/dispatch-threads.ts";
7
14
  import type {
@@ -9,20 +16,33 @@ import type {
9
16
  CommandRegistrar,
10
17
  } from "../discord/interaction-module.ts";
11
18
  import { OwnerCards } from "../discord/owner-cards.ts";
12
- import { OwnerGuard, ownerRootCommand } from "../discord/owner-command.ts";
19
+ import { commandGuard, ownerRootCommand } from "../discord/owner-command.ts";
13
20
  import { stopButtonModule } from "../discord/stop-button.ts";
21
+ import type { InboundMessage } from "../domain/conversation.ts";
14
22
  import type { RoundtablePlugin } from "../plugin.ts";
15
- import type { SpeakerPolicy } from "../speakers.ts";
23
+ import { IDENTITY } from "../services.ts";
16
24
 
17
25
  /** The Discord connection and what stands on it, as plugins use it. Provided by the Discord plugin. */
18
26
  export interface DiscordServices {
19
27
  connection: DiscordConnection;
20
28
  /** Adds a plugin's slash commands and components; during setup only. */
21
29
  commands: CommandRegistrar;
22
- /** Who may use the owner's commands. */
30
+ /** Who may use the owner's commands: every owner, by their Discord identity. */
23
31
  guard: CommandGuard;
24
32
  /** The threads that carry background reports. */
25
33
  threads: DispatchThreads;
34
+ /**
35
+ * What was said in a server channel since the assistant last posted there, before `message`:
36
+ * for a claim that answers it, to append to its turn's text with `withChannelContext`. Call it
37
+ * once per answered message, inside the turn's `run`: the messages it returns, and `message`,
38
+ * are remembered per channel and not returned again. Undefined in a direct message, on another
39
+ * surface, when the host's `discord.channelContext` is `false`, when nothing new was said, and
40
+ * when Discord cannot be read (logged). Never rejects. `options` override the host's for this call.
41
+ */
42
+ channelContext(
43
+ message: InboundMessage,
44
+ options?: ChannelContextOptions,
45
+ ): Promise<ChannelContext | undefined>;
26
46
  }
27
47
 
28
48
  /** The Discord connection, provided by the Discord plugin. */
@@ -31,16 +51,22 @@ export const DISCORD: ServiceKey<DiscordServices> =
31
51
 
32
52
  export interface DiscordOptions {
33
53
  token: string;
54
+ /**
55
+ * The primary owner's Discord user id: an owner always, and the one the admin tools act as.
56
+ * Every other owner the identity service knows uses the owner's commands and cards too.
57
+ */
34
58
  ownerId: string;
35
- /** How the owner is named in audit-log reasons and refusals. */
59
+ /** How the primary owner is named in audit-log reasons and refusals. */
36
60
  ownerName: string;
37
- /** Who may answer a card besides the owner. */
38
- speakers: SpeakerPolicy;
39
61
  dataDir: string;
40
62
  /** The name of the root slash command, without the slash. */
41
63
  rootCommand: string;
42
64
  /** Text appended as it is to the refusal a non-owner gets, such as a pointer to the commands anyone may use. */
43
65
  refusalHint?: string;
66
+ /** The host's channel context settings; `false` turns it off. Default: on, with the defaults. */
67
+ channelContext?: ChannelContextOptions | false;
68
+ /** The divider posted in a server channel when its conversation starts over; `false` posts none. Default: a short English divider. */
69
+ freshMarker?: string | false;
44
70
  }
45
71
 
46
72
  /**
@@ -62,10 +88,12 @@ export function discordPlugin(options: DiscordOptions): RoundtablePlugin {
62
88
  );
63
89
  },
64
90
  setup: async ({ conversations, services, logger }) => {
91
+ const identity = services.find(IDENTITY);
65
92
  // The surface exists by the time a turn posts a card.
66
93
  const cards: OwnerCards = new OwnerCards({
67
94
  ownerId: options.ownerId,
68
- speakers: options.speakers,
95
+ // Who may answer a card besides the primary owner, by the identity service.
96
+ ...(identity ? { identity } : {}),
69
97
  channel: (channelId) => connected.cardChannel(channelId),
70
98
  logger,
71
99
  });
@@ -73,16 +101,23 @@ export function discordPlugin(options: DiscordOptions): RoundtablePlugin {
73
101
  token: options.token,
74
102
  ownerId: options.ownerId,
75
103
  ownerName: options.ownerName,
76
- prompts: (channel, speaker) => cards.prompts(channel, speaker),
104
+ prompts: (channel, scope) => cards.prompts(channel, scope),
77
105
  logger,
106
+ ...(options.freshMarker === undefined
107
+ ? {}
108
+ : { freshMarker: options.freshMarker }),
78
109
  });
79
110
  surface = connected;
80
- const guard = new OwnerGuard(
81
- options.ownerId,
111
+ // Every owner may use the owner's commands, by the identity service; the primary owner always.
112
+ const guard = commandGuard({
113
+ ownerId: options.ownerId,
114
+ ...(identity ? { identity } : {}),
115
+ root: options.rootCommand,
82
116
  logger,
83
- options.rootCommand,
84
- options.refusalHint,
85
- );
117
+ ...(options.refusalHint === undefined
118
+ ? {}
119
+ : { refusalHint: options.refusalHint }),
120
+ });
86
121
  // A channel whose claim keeps reports in place, such as an open channel, opens no thread.
87
122
  const threads = new DispatchThreads({
88
123
  host: {
@@ -101,15 +136,38 @@ export function discordPlugin(options: DiscordOptions): RoundtablePlugin {
101
136
  collection.registrar.add({
102
137
  module: stopButtonModule({ guard, conversations }),
103
138
  });
139
+ // Marks the owners in channel context: the primary owner, and every other owner's Discord identities.
140
+ const owners = new DiscordOwners({
141
+ ownerId: options.ownerId,
142
+ ...(identity ? { identity } : {}),
143
+ logger,
144
+ });
145
+ const context = new ChannelContextReader({
146
+ read: (channelId, before, limit) =>
147
+ connected.messagesBefore(channelId, before, limit),
148
+ owners: () => owners.ids(),
149
+ settings: options.channelContext ?? {},
150
+ logger,
151
+ });
104
152
  services.provide(DISCORD, {
105
153
  connection: connected,
106
154
  commands: collection.registrar,
107
155
  guard,
108
156
  threads,
157
+ channelContext: (message, given) => context.of(message, given),
109
158
  });
110
159
  return {
111
160
  // The host starts the surface first; it registers the composed commands as it connects.
112
161
  surfaces: [connected],
162
+ // A person's Discord direct messages, through a Discord identity of theirs.
163
+ directChannels: [
164
+ discordDirectChannel({
165
+ directChannel: (userId) => connected.directChannel(userId),
166
+ sendDirect: (userId, text) => connected.sendDirect(userId, text),
167
+ ...(identity ? { identity } : {}),
168
+ ownerId: options.ownerId,
169
+ }),
170
+ ],
113
171
  services: [
114
172
  // The sweep reads threads through the connection, so it follows the surface's start.
115
173
  { name: "threads", start: () => void threads.sweep() },