pi-roundtable 0.7.19 → 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 (177) hide show
  1. package/CHANGELOG.md +140 -0
  2. package/README.md +72 -15
  3. package/README.zh-TW.md +65 -13
  4. package/docs/migrating-0.8.md +132 -0
  5. package/docs/migration-0.9.md +247 -0
  6. package/docs/plugins.md +853 -81
  7. package/examples/channel-context.test.ts +88 -0
  8. package/examples/channel-context.ts +62 -0
  9. package/examples/discord-adapter.test.ts +42 -0
  10. package/examples/discord-adapter.ts +26 -0
  11. package/examples/echo-runtime.test.ts +1 -0
  12. package/examples/echo-runtime.ts +1 -1
  13. package/examples/fake-surface.test.ts +69 -6
  14. package/examples/fake-surface.ts +73 -7
  15. package/examples/headless.test.ts +72 -0
  16. package/examples/headless.ts +35 -0
  17. package/examples/http.test.ts +31 -1
  18. package/examples/http.ts +39 -0
  19. package/examples/prechecks.test.ts +1 -0
  20. package/examples/principals.test.ts +60 -0
  21. package/examples/principals.ts +48 -0
  22. package/examples/prompt.test.ts +1 -1
  23. package/examples/study-room.test.ts +29 -1
  24. package/examples/study-room.ts +16 -19
  25. package/examples/support-desk.test.ts +3 -1
  26. package/examples/tools.test.ts +6 -1
  27. package/package.json +7 -3
  28. package/src/cli/checks/database.ts +11 -0
  29. package/src/cli/checks/identity.ts +225 -0
  30. package/src/cli/checks/model.ts +21 -3
  31. package/src/cli/cli.ts +130 -5
  32. package/src/cli/config-edit.ts +83 -13
  33. package/src/cli/doctor.ts +28 -5
  34. package/src/cli/init.ts +9 -1
  35. package/src/cli/line-diff.ts +72 -0
  36. package/src/cli/principal.ts +412 -0
  37. package/src/cli/project.ts +33 -2
  38. package/src/cli/report.ts +12 -3
  39. package/src/cli/templates.ts +46 -14
  40. package/src/cli/upgrade-rewrite.ts +102 -0
  41. package/src/cli/upgrade-source.ts +430 -0
  42. package/src/cli/upgrade.ts +176 -0
  43. package/src/core/agents/agent-claim.ts +56 -34
  44. package/src/core/agents/agent-team-fixture.ts +6 -0
  45. package/src/core/agents/team-turns.ts +10 -1
  46. package/src/core/builtin/agent-server.ts +38 -101
  47. package/src/core/builtin/discord-admin.ts +4 -1
  48. package/src/core/builtin/discord.ts +71 -13
  49. package/src/core/builtin/modules.ts +197 -43
  50. package/src/core/builtin/stores.ts +40 -21
  51. package/src/core/config/access.ts +229 -0
  52. package/src/core/config/adapters.ts +120 -0
  53. package/src/core/config/config.ts +228 -94
  54. package/src/core/contract/channel-context.ts +53 -0
  55. package/src/core/contract/channels.ts +81 -9
  56. package/src/core/contract/providers.ts +3 -2
  57. package/src/core/contract/runtime.ts +31 -11
  58. package/src/core/contract/surface.ts +35 -6
  59. package/src/core/conversations/conversation-registry.ts +56 -0
  60. package/src/core/conversations/conversation-store.ts +126 -0
  61. package/src/core/conversations/conversations-plugin.ts +29 -0
  62. package/src/core/define-roundtable.ts +157 -57
  63. package/src/core/discord/agent-panel.ts +2 -2
  64. package/src/core/discord/card-audience.ts +148 -0
  65. package/src/core/discord/channel-context.ts +348 -0
  66. package/src/core/discord/connection.ts +15 -3
  67. package/src/core/discord/direct-channel.ts +54 -0
  68. package/src/core/discord/discord-adapter.ts +9 -0
  69. package/src/core/discord/discord-owners.ts +133 -0
  70. package/src/core/discord/discord-surface.ts +69 -14
  71. package/src/core/discord/fresh-marker.ts +24 -0
  72. package/src/core/discord/inbound-message.ts +21 -7
  73. package/src/core/discord/interaction-module.ts +11 -1
  74. package/src/core/discord/owner-cards-fixture.ts +80 -0
  75. package/src/core/discord/owner-cards.ts +58 -86
  76. package/src/core/discord/owner-command.ts +27 -13
  77. package/src/core/discord/stop-button.ts +4 -4
  78. package/src/core/domain/conversation.ts +10 -0
  79. package/src/core/domain/errors.ts +5 -0
  80. package/src/core/domain/ports.ts +33 -2
  81. package/src/core/domain/progress.ts +10 -0
  82. package/src/core/events.ts +1 -0
  83. package/src/core/host.ts +32 -10
  84. package/src/core/http/listeners.ts +174 -21
  85. package/src/core/http/websocket.ts +316 -0
  86. package/src/core/i18n/cards.ts +11 -9
  87. package/src/core/identity/access-policy.ts +227 -0
  88. package/src/core/identity/actor-facts.ts +65 -0
  89. package/src/core/identity/caching-principal-store.ts +111 -0
  90. package/src/core/identity/config-owner.ts +145 -0
  91. package/src/core/identity/contact.ts +27 -0
  92. package/src/core/identity/identity-plugin.ts +56 -0
  93. package/src/core/identity/identity-schema.ts +282 -0
  94. package/src/core/identity/identity-service.ts +489 -0
  95. package/src/core/identity/identity-view.ts +77 -0
  96. package/src/core/identity/plugin-identities.ts +109 -0
  97. package/src/core/identity/principal-reads.ts +38 -0
  98. package/src/core/identity/principal-store.ts +478 -0
  99. package/src/core/identity/principal-tables.ts +53 -0
  100. package/src/core/identity/seen-throttle.ts +57 -0
  101. package/src/core/identity/ulid.ts +36 -0
  102. package/src/core/identity.ts +34 -5
  103. package/src/core/interactions/prompts.ts +127 -0
  104. package/src/core/modules/background/background-turns.ts +56 -17
  105. package/src/core/modules/background/personal-target.ts +50 -0
  106. package/src/core/modules/delegation/delegate.ts +28 -14
  107. package/src/core/modules/delegation/delegator.ts +23 -7
  108. package/src/core/modules/memory/owner-memory.ts +123 -50
  109. package/src/core/modules/notify/notify.ts +53 -13
  110. package/src/core/modules/notify/session-notify.ts +110 -0
  111. package/src/core/modules/schedules/prechecks.ts +8 -2
  112. package/src/core/modules/schedules/schedule-prechecks.ts +4 -1
  113. package/src/core/modules/schedules/schedule-store.ts +51 -20
  114. package/src/core/modules/schedules/schedule-tools.ts +122 -12
  115. package/src/core/modules/schedules/scheduler.ts +43 -2
  116. package/src/core/modules/schedules/schedules.ts +43 -14
  117. package/src/core/ops/error-reporter.ts +24 -12
  118. package/src/core/plugin.ts +62 -0
  119. package/src/core/presence/direct-channels.ts +104 -0
  120. package/src/core/registry/contributions.ts +63 -3
  121. package/src/core/registry/providers.ts +1 -1
  122. package/src/core/routing/channel-router.ts +226 -17
  123. package/src/core/routing/conversation-turns.ts +85 -1
  124. package/src/core/routing/surface-port.ts +35 -2
  125. package/src/core/routing/system-turns.ts +29 -0
  126. package/src/core/runtime/bridge-guard.ts +225 -0
  127. package/src/core/runtime/compaction-tiers.ts +5 -1
  128. package/src/core/runtime/conversation-sessions.ts +124 -12
  129. package/src/core/runtime/extensions/ask-user.ts +1 -1
  130. package/src/core/runtime/extensions/confirmation-gate.ts +32 -10
  131. package/src/core/runtime/extensions/private-memory.ts +447 -0
  132. package/src/core/runtime/extensions/self-compact-guard.ts +11 -0
  133. package/src/core/runtime/pending-confirmation-store.ts +67 -4
  134. package/src/core/runtime/pi-agent-runtime.ts +143 -135
  135. package/src/core/runtime/preflight-tools.ts +47 -0
  136. package/src/core/runtime/prompt-slot.ts +6 -6
  137. package/src/core/runtime/reader-history.ts +129 -0
  138. package/src/core/runtime/runtime-plugin.ts +205 -0
  139. package/src/core/runtime/runtime-types.ts +40 -12
  140. package/src/core/runtime/session-conversation.ts +143 -0
  141. package/src/core/runtime/session-factory.ts +115 -15
  142. package/src/core/runtime/session-scope.ts +78 -0
  143. package/src/core/runtime/steerable-run.ts +9 -0
  144. package/src/core/runtime/tool-call-scope.ts +43 -0
  145. package/src/core/runtime/turn-answer.ts +30 -1
  146. package/src/core/runtime/turn-progress.ts +116 -0
  147. package/src/core/services.ts +42 -2
  148. package/src/core/sessions.ts +30 -0
  149. package/src/core/shared/unix-server.ts +35 -5
  150. package/src/core/speakers.ts +21 -19
  151. package/src/core/testing/background.ts +31 -0
  152. package/src/core/testing/fixture-database.ts +55 -0
  153. package/src/core/testing/map-identity.ts +76 -0
  154. package/src/core/testing/module-sessions.ts +134 -0
  155. package/src/core/testing/modules.ts +149 -38
  156. package/src/core/testing/owner.ts +17 -0
  157. package/src/core/testing/surface-contract.ts +460 -0
  158. package/src/core/testing/test-host.ts +100 -19
  159. package/src/core/testing/tool-set.ts +32 -0
  160. package/src/core/tool-set.snapshot.json +4 -4
  161. package/src/core/tool-tiers.ts +57 -3
  162. package/src/discord/index.ts +10 -0
  163. package/src/index.ts +65 -8
  164. package/src/kit/compaction.ts +13 -0
  165. package/src/kit/index.ts +10 -0
  166. package/src/kit/support.ts +1 -1
  167. package/src/testing.ts +96 -21
  168. package/templates/.env.example +1 -1
  169. package/templates/adapters/web/.env.example +24 -0
  170. package/templates/adapters/web/README.md +22 -0
  171. package/templates/adapters/web/package.json.tmpl +26 -0
  172. package/templates/adapters/web/persona/assistant.md +2 -0
  173. package/templates/adapters/web/roundtable.config.ts +59 -0
  174. package/templates/package.json.tmpl +1 -0
  175. package/templates/plugins/self-compact.ts +11 -0
  176. package/templates/roundtable.config.ts +14 -2
  177. package/src/core/domain/owner-prompts.ts +0 -45
@@ -0,0 +1,104 @@
1
+ import type { SurfacePort } from "../contract/surface.ts";
2
+ import type { ChannelKey } from "../domain/conversation.ts";
3
+ import type { Notifier } from "../domain/ports.ts";
4
+ import { splitReply } from "../presentation/reply-splitter.ts";
5
+
6
+ /**
7
+ * A way to reach a person on their own, such as a chat network's direct messages or a web inbox.
8
+ * A plugin contributes it as `directChannels`; the host reaches a person through the first one,
9
+ * in contribution order, that reaches them.
10
+ */
11
+ export interface DirectChannelProvider {
12
+ /** Unique across every plugin, such as `"discord"`. */
13
+ name: string;
14
+ /**
15
+ * How the `notify` tool names a message sent through it, completing "Send Ada …": such as
16
+ * `"a direct message on Discord"`.
17
+ */
18
+ label: string;
19
+ /**
20
+ * The person's own conversation here, where a notice to them is posted and where their
21
+ * schedules and delegated reports from a conversation no chat surface carries run; undefined
22
+ * when they have none here. It may reach the network and throw when that fails.
23
+ */
24
+ reaches(principalId: string): Promise<ChannelKey | undefined>;
25
+ /**
26
+ * Whether the person has a conversation here, from the host's own records such as their linked
27
+ * identities, without reaching the network. A session reads it when it decides whether to offer
28
+ * a tool that sends or runs there, so a network outage then does not take the tool away; the
29
+ * conversation itself is reached when the tool runs. Without it the host asks `reaches`.
30
+ */
31
+ knows?(principalId: string): Promise<boolean>;
32
+ /**
33
+ * Sends the person a notice here; without it the host posts the text in the channel `reaches`
34
+ * names, through the surface that serves it.
35
+ */
36
+ deliver?(principalId: string, text: string): Promise<void>;
37
+ }
38
+
39
+ /** Where a person was reached: the provider, and their conversation on it. */
40
+ export interface DirectReach {
41
+ provider: DirectChannelProvider;
42
+ channel: ChannelKey;
43
+ }
44
+
45
+ /**
46
+ * Every plugin's direct channels, as plugins and the core use them. Each call before the host
47
+ * links its plugins throws NotLinkedError, as for `conversations`.
48
+ */
49
+ export interface DirectChannels extends Notifier {
50
+ /** Every contributed provider, in contribution order; empty when no plugin contributes one. */
51
+ providers(): readonly DirectChannelProvider[];
52
+ /** The person's own conversation on the first provider that reaches them; undefined when none does. */
53
+ reach(principalId: string): Promise<DirectReach | undefined>;
54
+ /**
55
+ * The first provider that knows the person, as `DirectChannelProvider.knows` tells without the
56
+ * network (or `reaches`, for a provider without it); undefined when none does.
57
+ */
58
+ known(principalId: string): Promise<DirectChannelProvider | undefined>;
59
+ /**
60
+ * Sends the person `text` through the first provider that reaches them; false when none does.
61
+ * A provider's failure, such as a network error, rejects.
62
+ */
63
+ notify(principalId: string, text: string): Promise<boolean>;
64
+ }
65
+
66
+ /** The direct channels over the providers the host linked, posting through its surfaces where a provider does not deliver itself. */
67
+ export function directChannelsPort(
68
+ providers: () => readonly DirectChannelProvider[],
69
+ surfaces: Pick<SurfacePort, "sendReply">,
70
+ ): DirectChannels {
71
+ const reach = async (
72
+ principalId: string,
73
+ ): Promise<DirectReach | undefined> => {
74
+ for (const provider of providers()) {
75
+ const channel = await provider.reaches(principalId);
76
+ if (channel !== undefined) return { provider, channel };
77
+ }
78
+ return undefined;
79
+ };
80
+ return {
81
+ providers: () => providers(),
82
+ reach,
83
+ known: async (principalId) => {
84
+ for (const provider of providers()) {
85
+ const knows = provider.knows
86
+ ? await provider.knows(principalId)
87
+ : (await provider.reaches(principalId)) !== undefined;
88
+ if (knows) return provider;
89
+ }
90
+ return undefined;
91
+ },
92
+ notify: async (principalId, text) => {
93
+ const reached = await reach(principalId);
94
+ if (!reached) return false;
95
+ if (reached.provider.deliver)
96
+ await reached.provider.deliver(principalId, text);
97
+ else
98
+ await surfaces.sendReply(reached.channel, {
99
+ chunks: splitReply(text),
100
+ });
101
+ return true;
102
+ },
103
+ };
104
+ }
@@ -2,7 +2,7 @@ import type { AgentSeed } from "../agents/agent-store.ts";
2
2
  import type { BackgroundTarget, ChannelClaim } from "../contract/channels.ts";
3
3
  import type { ChatSurface } from "../contract/surface.ts";
4
4
  import type { ToolContribution } from "../define.ts";
5
- import { PluginError } from "../errors.ts";
5
+ import { NotLinkedError, PluginError } from "../errors.ts";
6
6
  import { type HoldRule, holdChain } from "../holds.ts";
7
7
  import type { HttpRoute } from "../http/listeners.ts";
8
8
  import {
@@ -20,6 +20,10 @@ import {
20
20
  refuseRemovedSurfaceMethods,
21
21
  type Service,
22
22
  } from "../plugin.ts";
23
+ import {
24
+ type DirectChannelProvider,
25
+ directChannelsPort,
26
+ } from "../presence/direct-channels.ts";
23
27
  import {
24
28
  compileSessionPlan,
25
29
  type SessionTool,
@@ -44,6 +48,8 @@ export interface Registry {
44
48
  surfaces: ChatSurface[];
45
49
  personas: Persona[];
46
50
  backgroundTargets: BackgroundTarget[];
51
+ /** The contributed direct channels, in contribution order. */
52
+ directChannels: DirectChannelProvider[];
47
53
  dashboard: string[];
48
54
  tools: ToolContribution[];
49
55
  seeds: AgentSeed[];
@@ -64,6 +70,7 @@ export const emptyRegistry = (): Registry => ({
64
70
  surfaces: [],
65
71
  personas: [],
66
72
  backgroundTargets: [],
73
+ directChannels: [],
67
74
  dashboard: [],
68
75
  tools: [],
69
76
  seeds: [],
@@ -171,6 +178,14 @@ function claimPersona(
171
178
  throw new PluginError(
172
179
  `plugin ${plugin}: the persona of kind "${kind}" needs a prompt() that returns its text.`,
173
180
  );
181
+ if (
182
+ persona.memory !== undefined &&
183
+ persona.memory !== "speaker" &&
184
+ persona.memory !== "none"
185
+ )
186
+ throw new PluginError(
187
+ `plugin ${plugin}: the persona of kind "${kind}" has memory ${JSON.stringify(persona.memory)}; expected "speaker" or "none".`,
188
+ );
174
189
  const other = kinds.get(kind);
175
190
  if (other !== undefined)
176
191
  throw new PluginError(
@@ -179,6 +194,31 @@ function claimPersona(
179
194
  kinds.set(kind, plugin);
180
195
  }
181
196
 
197
+ /**
198
+ * Refuses a direct channel without a name, a label, or `reaches`, or whose name another plugin's
199
+ * already has, so the providers a person is reached through never depend on a typo.
200
+ */
201
+ function claimDirectChannel(
202
+ names: Names,
203
+ plugin: string,
204
+ provider: DirectChannelProvider,
205
+ ): void {
206
+ const { name } = provider;
207
+ if (typeof name !== "string" || name === "")
208
+ throw new PluginError(
209
+ `plugin ${plugin}: a direct channel needs a name, a non-empty string such as "discord", and got ${JSON.stringify(name)}.`,
210
+ );
211
+ if (typeof provider.label !== "string" || provider.label === "")
212
+ throw new PluginError(
213
+ `plugin ${plugin}: the direct channel "${name}" needs a label, such as "a direct message on Discord", which the notify tool names it by.`,
214
+ );
215
+ if (typeof provider.reaches !== "function")
216
+ throw new PluginError(
217
+ `plugin ${plugin}: the direct channel "${name}" needs reaches(principalId), which finds a person's own conversation there.`,
218
+ );
219
+ names.claim(plugin, name);
220
+ }
221
+
182
222
  /**
183
223
  * Refuses a background target without a name or a label, or whose name another plugin's target
184
224
  * already has, so a stored schedule's target never depends on plugin order.
@@ -242,19 +282,32 @@ function surfaceService(
242
282
  /**
243
283
  * Sets up every plugin in order and collects what each adds. A plugin that adds nothing, that
244
284
  * reuses a name, or whose setup throws or returns a part the contract does not have is refused
245
- * with its name and what to fix.
285
+ * with its name and what to fix. The context's `directChannels` are the contributed ones, read
286
+ * once every plugin is set up.
246
287
  */
247
288
  export async function collectContributions(
248
289
  plugins: readonly RoundtablePlugin[],
249
- context: Omit<PluginContext, "services">,
290
+ base: Omit<PluginContext, "services" | "directChannels">,
250
291
  tiers: ToolTierTable,
251
292
  services: ServiceRegistry = new ServiceRegistry(plugins),
252
293
  ): Promise<Registry> {
253
294
  const registry = emptyRegistry();
295
+ let collected = false;
296
+ const context: Omit<PluginContext, "services"> = {
297
+ ...base,
298
+ directChannels: directChannelsPort(() => {
299
+ if (!collected)
300
+ throw new NotLinkedError(
301
+ "direct channels are linked once every plugin is set up. Use them from a service's start, a handler, or a session tool, not during setup.",
302
+ );
303
+ return registry.directChannels;
304
+ }, base.surfaces),
305
+ };
254
306
  const serviceNames = new Names("service");
255
307
  const sessionTools = new Names("session tool");
256
308
  const holds = new Names("hold rule");
257
309
  const surfaces = new Names("surface");
310
+ const directChannelNames = new Names("direct channel");
258
311
  const personaKinds = new Map<string, string>();
259
312
  const targetNames = new Map<string, string>();
260
313
  for (const plugin of plugins) {
@@ -293,6 +346,7 @@ export async function collectContributions(
293
346
  surfaces: contributedSurfaces = [],
294
347
  personas = [],
295
348
  backgroundTargets = [],
349
+ directChannels = [],
296
350
  dashboard = [],
297
351
  tools = [],
298
352
  seeds = [],
@@ -309,6 +363,7 @@ export async function collectContributions(
309
363
  contributedSurfaces,
310
364
  personas,
311
365
  backgroundTargets,
366
+ directChannels,
312
367
  dashboard,
313
368
  tools,
314
369
  seeds,
@@ -348,6 +403,8 @@ export async function collectContributions(
348
403
  claimPersona(personaKinds, plugin.name, persona);
349
404
  for (const target of backgroundTargets)
350
405
  claimTarget(targetNames, plugin.name, target);
406
+ for (const provider of directChannels)
407
+ claimDirectChannel(directChannelNames, plugin.name, provider);
351
408
  for (const rule of holdRules) holds.claim(plugin.name, rule.name);
352
409
  for (const tool of contribution.sessionTools ?? []) {
353
410
  sessionTools.claim(plugin.name, tool.name);
@@ -380,12 +437,14 @@ export async function collectContributions(
380
437
  registry.channels.push(...channels);
381
438
  registry.personas.push(...personas);
382
439
  registry.backgroundTargets.push(...backgroundTargets);
440
+ registry.directChannels.push(...directChannels);
383
441
  registry.dashboard.push(...dashboard);
384
442
  registry.seeds.push(...seeds);
385
443
  registry.prompt.push(...prompt);
386
444
  registry.requiredTools.push(...requiredTools);
387
445
  }
388
446
  services.settle();
447
+ collected = true;
389
448
  return registry;
390
449
  }
391
450
 
@@ -402,6 +461,7 @@ export function linkSessions(registry: Registry): LinkedSessions {
402
461
  seeds: registry.seeds,
403
462
  prompt: registry.prompt,
404
463
  persona: (kind) => personas.get(kind)?.prompt(),
464
+ personaMemory: (kind) => personas.get(kind)?.memory ?? "speaker",
405
465
  requiredTools: [...new Set(registry.requiredTools)],
406
466
  agentTools: registry.tools.flatMap((t) => (t.agent ? [t.name] : [])),
407
467
  agentSelection: () => mergeSelections(registry.agentSelections),
@@ -35,7 +35,7 @@ function defaults(judgeModel: JudgeModel | undefined): Providers {
35
35
  },
36
36
  runtime: () => {
37
37
  throw new ProviderError(
38
- "no runtime provider is configured: the agent server builds the Pi runtime when no plugin fills the runtime slot",
38
+ "no runtime provider is configured: the runtime plugin builds the Pi runtime when no plugin fills the runtime slot; read the running one from services.get(RUNTIME)",
39
39
  );
40
40
  },
41
41
  };
@@ -1,4 +1,6 @@
1
1
  import {
2
+ type Admission,
3
+ type BackgroundRunsAs,
2
4
  type BackgroundTarget,
3
5
  type BackgroundTurn,
4
6
  type ChannelClaim,
@@ -8,11 +10,21 @@ import {
8
10
  type ScheduledOutcome,
9
11
  STEERED_MARK,
10
12
  } from "../contract/channels.ts";
13
+ import { parseChannelKey, type SurfacePort } from "../contract/surface.ts";
14
+ import { IdentityError } from "../domain/errors.ts";
11
15
  import { PluginError } from "../errors.ts";
16
+ import type { ActorFacts } from "../identity/actor-facts.ts";
17
+ import type { Contact, ContactAssessor } from "../identity/contact.ts";
18
+ import {
19
+ type IdentityService,
20
+ systemSpeaker,
21
+ } from "../identity/identity-service.ts";
22
+ import { SYSTEM_PRINCIPAL } from "../identity/principal-store.ts";
12
23
  import type { Logger } from "../log.ts";
13
24
  import type { ChannelKey } from "../sessions.ts";
14
25
  import type { ChannelQueue } from "./channel-queue.ts";
15
26
  import { ForwardJoin } from "./forward-join.ts";
27
+ import { isSystemTurn } from "./system-turns.ts";
16
28
 
17
29
  export interface ChannelRouterOptions {
18
30
  /** Claims in registration order; the router orders them by priority. */
@@ -21,6 +33,12 @@ export interface ChannelRouterOptions {
21
33
  /** The contributed background target of a name, read at use time. */
22
34
  targets(name: string): BackgroundTarget | undefined;
23
35
  logger: Logger;
36
+ /** Who the authors of messages are; without it, no message carries a speaker. */
37
+ contacts?: ContactAssessor;
38
+ /** Whom a background turn runs as; without it, only the host's own turns run. */
39
+ principals?: Pick<IdentityService, "speakerFor">;
40
+ /** The surfaces, so a conversation started over is marked in a channel that shows one; without it, none is. */
41
+ surfaces?: Pick<SurfacePort, "of">;
24
42
  /** How long a bare forward waits for the text sent with it; FORWARD_JOIN_MS by default. */
25
43
  forwardJoinMs?: number;
26
44
  }
@@ -47,6 +65,10 @@ export class ChannelRouter implements ConversationPort {
47
65
  readonly #options: ChannelRouterOptions;
48
66
  readonly #claims: readonly ChannelClaim[];
49
67
  readonly #forwards: ForwardJoin;
68
+ /** Each channel's messages being admitted, so they reach their claim and the queue in the order they came. */
69
+ readonly #admitting = new Map<ChannelKey, Promise<unknown>>();
70
+ /** The surfaces already warned that their messages carry no actor. */
71
+ readonly #warned = new Set<string>();
50
72
 
51
73
  constructor(options: ChannelRouterOptions) {
52
74
  this.#options = options;
@@ -68,34 +90,149 @@ export class ChannelRouter implements ConversationPort {
68
90
  }
69
91
 
70
92
  async #route(message: InboundMessage): Promise<void> {
71
- const { queue, logger } = this.#options;
72
93
  if (
73
94
  !message.text.trim() &&
74
95
  message.attachments.length === 0 &&
75
96
  !message.forwarded?.text.trim()
76
97
  )
77
98
  return;
78
- const admission = this.#owner(message.channel, message.space)?.admit(
79
- message,
99
+ const claim = this.#owner(message.channel, message.space);
100
+ if (!claim) return;
101
+ const started = await this.#inOrder(message.channel, async () => {
102
+ const admission = await this.#admit(claim, message);
103
+ return admission && (await this.#start(message, admission));
104
+ });
105
+ await started?.done;
106
+ }
107
+
108
+ /** Runs the channel's admissions one at a time, in the order its messages came. */
109
+ #inOrder<T>(channel: ChannelKey, task: () => Promise<T>): Promise<T> {
110
+ const before = this.#admitting.get(channel) ?? Promise.resolve();
111
+ const run = before.then(task);
112
+ const after = run.catch(() => undefined);
113
+ this.#admitting.set(channel, after);
114
+ void after.then(() => {
115
+ if (this.#admitting.get(channel) === after)
116
+ this.#admitting.delete(channel);
117
+ });
118
+ return run;
119
+ }
120
+
121
+ /**
122
+ * The claim's admission of the message, which carries the speaker its author resolves to; one
123
+ * who cannot be resolved, such as while the database is down, reaches the claim as no one. The
124
+ * author's contact is recorded, such as their first contact linked, only once the claim takes
125
+ * the message, so a message nobody serves links or makes no one. A linked author the rules
126
+ * refuse is still recorded as seen at no tier as the message is assessed, whether or not a
127
+ * claim then takes it, at most once in a while. When that record fails, or finds them someone
128
+ * else or no one now, such as linked elsewhere meanwhile, the message is dropped: it would run
129
+ * as a principal that may not exist, such as one only assessed at a first contact. The claim
130
+ * hears it through the admission's `dropped` or `unanswered`.
131
+ */
132
+ async #admit(
133
+ claim: ChannelClaim,
134
+ message: InboundMessage,
135
+ ): Promise<Admission | undefined> {
136
+ const { logger, contacts } = this.#options;
137
+ const { speaker: _given, ...facts } = message;
138
+ const actor = this.#actorOf(message);
139
+ let contact: Contact | undefined;
140
+ if (actor && contacts)
141
+ try {
142
+ contact = await contacts.assess(actor, {
143
+ conversation: message.channel,
144
+ });
145
+ } catch (error) {
146
+ logger.error(
147
+ { channel: message.channel, err: error },
148
+ "could not resolve who wrote a message; it reaches its claim as no one",
149
+ );
150
+ }
151
+ const admission = claim.admit(
152
+ contact
153
+ ? { ...facts, speaker: Object.freeze({ ...contact.speaker }) }
154
+ : facts,
80
155
  );
81
- if (!admission) return;
156
+ if (!admission || !contact) return admission;
157
+ try {
158
+ const taken = await contact.take();
159
+ if (taken?.principalId === contact.speaker.principalId) return admission;
160
+ logger.warn(
161
+ { channel: message.channel },
162
+ "the author of a message was linked elsewhere, or refused, while it was admitted; it does not run",
163
+ );
164
+ } catch (error) {
165
+ logger.error(
166
+ { channel: message.channel, err: error },
167
+ "could not record who wrote a message; it does not run",
168
+ );
169
+ }
170
+ if (admission.kind === "turn") admission.dropped?.();
171
+ else
172
+ admission.unanswered({
173
+ status: "skipped",
174
+ reason: "its author was not who they were when it was admitted",
175
+ });
176
+ return undefined;
177
+ }
178
+
179
+ /**
180
+ * Who wrote the message, as its surface reports them; a surface that reports no `actor` has it
181
+ * read from the author fields under its own name, once warned. None for a bot or an
182
+ * integration.
183
+ */
184
+ #actorOf(message: InboundMessage): ActorFacts | undefined {
185
+ if (message.authorIsBot || message.integration) return undefined;
186
+ if (message.actor) return message.actor;
187
+ const { surface } = parseChannelKey(message.channel);
188
+ if (!this.#warned.has(surface)) {
189
+ this.#warned.add(surface);
190
+ this.#options.logger.warn(
191
+ { surface },
192
+ `deprecated: the ${surface} surface reports no InboundMessage.actor, so its authors are read from authorId, authorName, and authorRoleIds as ${surface} identities; set actor, as this goes away in 1.0`,
193
+ );
194
+ }
195
+ return {
196
+ provider: surface,
197
+ subject: message.authorId,
198
+ name: message.authorName,
199
+ surface,
200
+ ...(message.authorRoleIds
201
+ ? {
202
+ roles: message.authorRoleIds.map(
203
+ (role) => `${surface}:role:${role}`,
204
+ ),
205
+ }
206
+ : {}),
207
+ ...(message.space ? { space: message.space } : {}),
208
+ legacyId: message.authorId,
209
+ };
210
+ }
211
+
212
+ /**
213
+ * Starts what the claim admitted, in the channel's queue, and returns while it runs: a message
214
+ * during a turn is first offered to it, then marked as waiting.
215
+ */
216
+ async #start(
217
+ message: InboundMessage,
218
+ admission: Admission,
219
+ ): Promise<{ done: Promise<void> }> {
220
+ const { queue, logger } = this.#options;
82
221
  if (admission.kind === "background") {
83
- const outcome = await this.background(admission.turn);
84
- if (outcome.status !== "ran") admission.unanswered(outcome);
85
- return;
222
+ const done = this.background(admission.turn).then((outcome) => {
223
+ if (outcome.status !== "ran") admission.unanswered(outcome);
224
+ });
225
+ return { done };
86
226
  }
87
227
  const { busy } = admission;
88
228
  let queued = false;
89
229
  // A message during a turn is added to it when the turn takes it, otherwise marked as waiting.
90
230
  if (busy && queue.size(message.channel) > 0) {
91
- if (await busy.steer?.()) {
92
- await busy.react(STEERED_MARK);
93
- return;
94
- }
231
+ if (await busy.steer?.()) return { done: busy.react(STEERED_MARK) };
95
232
  await busy.react(QUEUED_MARK);
96
233
  queued = true;
97
234
  }
98
- await queue
235
+ const done = queue
99
236
  .run(message.channel, () => {
100
237
  if (queued) void busy?.unreact(QUEUED_MARK);
101
238
  return admission.run();
@@ -107,6 +244,7 @@ export class ChannelRouter implements ConversationPort {
107
244
  admission.failure,
108
245
  ),
109
246
  );
247
+ return { done };
110
248
  }
111
249
 
112
250
  target(name: string): BackgroundTarget | undefined {
@@ -114,8 +252,9 @@ export class ChannelRouter implements ConversationPort {
114
252
  }
115
253
 
116
254
  /**
117
- * A turn nobody wrote, run by the channel's claim inside its queue; never rejects. A turn whose
118
- * target no plugin contributes is skipped, never handed to another target's claim.
255
+ * A turn nobody wrote, run by the channel's claim inside its queue, as the speaker its author
256
+ * is checked to be; never rejects. A turn whose target no plugin contributes is skipped, never
257
+ * handed to another target's claim, and so is one whose author may not run it now.
119
258
  */
120
259
  async background(turn: BackgroundTurn): Promise<ScheduledOutcome> {
121
260
  if (!this.#options.targets(turn.target))
@@ -131,21 +270,78 @@ export class ChannelRouter implements ConversationPort {
131
270
  status: "skipped",
132
271
  reason: "no conversation takes background turns here",
133
272
  };
134
- return claim.background(turn);
273
+ // Checked as the turn starts, in its place in the queue, so a change meanwhile counts.
274
+ const runs = await this.runsAs(turn);
275
+ if ("skipped" in runs)
276
+ return { status: "skipped", reason: runs.skipped };
277
+ return claim.background({ ...turn, speaker: runs.speaker });
135
278
  });
136
279
  } catch (error) {
137
280
  return { status: "failed", error: String(error) };
138
281
  }
139
282
  }
140
283
 
284
+ /**
285
+ * Who a background turn runs as: the system principal for the host's own turn, at its tier;
286
+ * otherwise its author's principal, which must exist, be enabled, and hold a tier now, at the
287
+ * lower of the turn's tier and theirs. Why not, when it may not run; throws when the identity
288
+ * service cannot tell.
289
+ */
290
+ async runsAs(turn: BackgroundTurn): Promise<BackgroundRunsAs> {
291
+ const { author, tier } = turn;
292
+ if (!author?.principalId || !tier)
293
+ return {
294
+ skipped:
295
+ "a background turn names its author's principal (author.principalId) and the tier it runs at (tier); this one does not",
296
+ };
297
+ if (author.principalId === SYSTEM_PRINCIPAL)
298
+ return isSystemTurn(turn)
299
+ ? { speaker: { ...systemSpeaker(tier), name: author.name } }
300
+ : {
301
+ skipped: `only the host itself starts a turn as the system principal "${SYSTEM_PRINCIPAL}"`,
302
+ };
303
+ const { principals } = this.#options;
304
+ if (!principals)
305
+ return {
306
+ skipped: "the host has no identity service to check whom it runs as",
307
+ };
308
+ try {
309
+ const checked = await principals.speakerFor(author.principalId, tier);
310
+ return {
311
+ speaker: {
312
+ id: author.id,
313
+ name: author.name,
314
+ tier: checked.tier,
315
+ principalId: checked.principalId,
316
+ },
317
+ };
318
+ } catch (error) {
319
+ if (!(error instanceof IdentityError)) throw error;
320
+ return { skipped: error.message };
321
+ }
322
+ }
323
+
141
324
  startFresh(channel: ChannelKey): Promise<string> {
142
- return this.#options.queue.run(channel, () => {
325
+ return this.#options.queue.run(channel, async () => {
143
326
  const claim = this.#owner(channel);
144
327
  if (!claim) throw new Error(`no conversation owns ${channel}`);
145
- return claim.startFresh(channel);
328
+ const kind = await claim.startFresh(channel);
329
+ await this.#markFresh(channel);
330
+ return kind;
146
331
  });
147
332
  }
148
333
 
334
+ /** The restart already counts: a surface that cannot mark it is logged, not failed. */
335
+ async #markFresh(channel: ChannelKey): Promise<void> {
336
+ try {
337
+ await this.#options.surfaces?.of(channel)?.markFresh?.(channel);
338
+ } catch (error) {
339
+ this.#options.logger.warn(
340
+ `could not mark the restart of ${channel}: ${error instanceof Error ? error.message : String(error)}`,
341
+ );
342
+ }
343
+ }
344
+
149
345
  async deleteConversation(channel: ChannelKey): Promise<"deleted" | "busy"> {
150
346
  const { queue } = this.#options;
151
347
  const claim = this.#owner(channel);
@@ -168,4 +364,17 @@ export class ChannelRouter implements ConversationPort {
168
364
  postsInPlace(channel: ChannelKey): boolean {
169
365
  return this.#owner(channel)?.postsInPlace === true;
170
366
  }
367
+
368
+ owns(channel: ChannelKey): boolean {
369
+ return this.#owner(channel) !== undefined;
370
+ }
371
+
372
+ takesBackground(channel: ChannelKey): boolean {
373
+ return this.#owner(channel)?.background !== undefined;
374
+ }
375
+
376
+ takesSystemReports(channel: ChannelKey): boolean {
377
+ const owner = this.#owner(channel);
378
+ return owner !== undefined && owner.takesSystemReports !== false;
379
+ }
171
380
  }