pi-roundtable 0.8.0 → 0.9.1

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 (158) hide show
  1. package/CHANGELOG.md +115 -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 +618 -86
  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 +150 -86
  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 +87 -41
  44. package/src/core/contract/channel-context.ts +53 -0
  45. package/src/core/contract/channels.ts +82 -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 +42 -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/drain.ts +56 -5
  69. package/src/core/errors.ts +13 -0
  70. package/src/core/host-background.ts +56 -0
  71. package/src/core/host.ts +52 -68
  72. package/src/core/i18n/cards.ts +8 -4
  73. package/src/core/i18n/conversation.ts +2 -0
  74. package/src/core/identity/access-policy.ts +227 -0
  75. package/src/core/identity/actor-facts.ts +65 -0
  76. package/src/core/identity/caching-principal-store.ts +111 -0
  77. package/src/core/identity/config-owner.ts +145 -0
  78. package/src/core/identity/contact.ts +27 -0
  79. package/src/core/identity/identity-plugin.ts +56 -0
  80. package/src/core/identity/identity-schema.ts +282 -0
  81. package/src/core/identity/identity-service.ts +489 -0
  82. package/src/core/identity/identity-view.ts +77 -0
  83. package/src/core/identity/plugin-identities.ts +109 -0
  84. package/src/core/identity/principal-reads.ts +38 -0
  85. package/src/core/identity/principal-store.ts +478 -0
  86. package/src/core/identity/principal-tables.ts +53 -0
  87. package/src/core/identity/seen-throttle.ts +57 -0
  88. package/src/core/identity/ulid.ts +36 -0
  89. package/src/core/identity.ts +34 -5
  90. package/src/core/interactions/prompts.ts +127 -0
  91. package/src/core/modules/background/background-turns.ts +56 -17
  92. package/src/core/modules/background/personal-target.ts +50 -0
  93. package/src/core/modules/delegation/delegate.ts +28 -14
  94. package/src/core/modules/delegation/delegator.ts +23 -7
  95. package/src/core/modules/memory/owner-memory.ts +123 -50
  96. package/src/core/modules/notify/notify.ts +53 -13
  97. package/src/core/modules/notify/session-notify.ts +110 -0
  98. package/src/core/modules/schedules/prechecks.ts +8 -2
  99. package/src/core/modules/schedules/schedule-prechecks.ts +4 -1
  100. package/src/core/modules/schedules/schedule-store.ts +51 -20
  101. package/src/core/modules/schedules/schedule-tools.ts +122 -12
  102. package/src/core/modules/schedules/scheduler.ts +56 -4
  103. package/src/core/modules/schedules/schedules.ts +43 -14
  104. package/src/core/plugin.ts +48 -0
  105. package/src/core/presence/direct-channels.ts +104 -0
  106. package/src/core/registry/contributions.ts +63 -3
  107. package/src/core/routing/channel-queue.ts +18 -1
  108. package/src/core/routing/channel-router.ts +274 -21
  109. package/src/core/routing/conversation-turns.ts +45 -10
  110. package/src/core/routing/surface-port.ts +32 -2
  111. package/src/core/routing/system-turns.ts +29 -0
  112. package/src/core/runtime/abort-guard.ts +58 -0
  113. package/src/core/runtime/bridge-guard.ts +225 -0
  114. package/src/core/runtime/compaction-tiers.ts +5 -1
  115. package/src/core/runtime/conversation-sessions.ts +139 -12
  116. package/src/core/runtime/extensions/ask-user.ts +1 -1
  117. package/src/core/runtime/extensions/confirmation-gate.ts +28 -14
  118. package/src/core/runtime/extensions/private-memory.ts +447 -0
  119. package/src/core/runtime/pending-confirmation-store.ts +30 -5
  120. package/src/core/runtime/pi-agent-runtime.ts +174 -190
  121. package/src/core/runtime/preflight-tools.ts +47 -0
  122. package/src/core/runtime/prompt-slot.ts +4 -4
  123. package/src/core/runtime/reader-history.ts +129 -0
  124. package/src/core/runtime/runtime-plugin.ts +51 -5
  125. package/src/core/runtime/runtime-types.ts +48 -12
  126. package/src/core/runtime/session-conversation.ts +143 -0
  127. package/src/core/runtime/session-factory.ts +115 -15
  128. package/src/core/runtime/session-scope.ts +78 -0
  129. package/src/core/runtime/steerable-run.ts +21 -3
  130. package/src/core/runtime/thinking-level.ts +47 -0
  131. package/src/core/runtime/tool-call-scope.ts +43 -0
  132. package/src/core/runtime/turn-answer.ts +30 -1
  133. package/src/core/runtime/turn-wrapup.ts +63 -0
  134. package/src/core/services.ts +27 -1
  135. package/src/core/sessions.ts +30 -0
  136. package/src/core/speakers.ts +21 -19
  137. package/src/core/testing/background.ts +31 -0
  138. package/src/core/testing/fixture-database.ts +55 -0
  139. package/src/core/testing/map-identity.ts +76 -0
  140. package/src/core/testing/module-sessions.ts +134 -0
  141. package/src/core/testing/modules.ts +108 -21
  142. package/src/core/testing/owner.ts +17 -0
  143. package/src/core/testing/surface-contract.ts +118 -41
  144. package/src/core/testing/test-host.ts +79 -16
  145. package/src/core/testing/tool-set.ts +32 -0
  146. package/src/core/tool-set.snapshot.json +4 -4
  147. package/src/core/tool-tiers.ts +57 -3
  148. package/src/discord/index.ts +8 -0
  149. package/src/index.ts +48 -8
  150. package/src/kit/compaction.ts +13 -0
  151. package/src/kit/index.ts +10 -0
  152. package/src/kit/support.ts +1 -1
  153. package/src/testing.ts +55 -16
  154. package/templates/.env.example +1 -1
  155. package/templates/adapters/web/README.md +7 -0
  156. package/templates/adapters/web/roundtable.config.ts +14 -10
  157. package/templates/roundtable.config.ts +12 -1
  158. package/src/core/domain/owner-prompts.ts +0 -45
@@ -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),
@@ -1,3 +1,4 @@
1
+ import { HostStoppingError } from "../errors.ts";
1
2
  import type { ChannelKey } from "../sessions.ts";
2
3
 
3
4
  /** Runs tasks one at a time per channel, in arrival order; different channels run independently. */
@@ -5,6 +6,7 @@ export class ChannelQueue {
5
6
  readonly #tails = new Map<ChannelKey, Promise<unknown>>();
6
7
  readonly #sizes = new Map<ChannelKey, number>();
7
8
  readonly #listeners: ((channel: ChannelKey) => void)[] = [];
9
+ #closed = false;
8
10
 
9
11
  /** Called whenever a channel's count of running and waiting tasks changes. */
10
12
  onChange(listener: (channel: ChannelKey) => void): void {
@@ -15,11 +17,26 @@ export class ChannelQueue {
15
17
  for (const listener of this.#listeners) listener(channel);
16
18
  }
17
19
 
20
+ /** Whether the host is shutting down, so no task starts any more. */
21
+ get closed(): boolean {
22
+ return this.#closed;
23
+ }
24
+
25
+ /**
26
+ * Starts the shutdown: from now on a task that has not started, whether queued before or after
27
+ * this call, is refused with HostStoppingError. A running task finishes.
28
+ */
29
+ close(): void {
30
+ this.#closed = true;
31
+ }
32
+
18
33
  run<T>(channel: ChannelKey, task: () => Promise<T>): Promise<T> {
34
+ const start = () =>
35
+ this.#closed ? Promise.reject(new HostStoppingError()) : task();
19
36
  this.#sizes.set(channel, this.size(channel) + 1);
20
37
  this.#changed(channel);
21
38
  const previous = this.#tails.get(channel) ?? Promise.resolve();
22
- const result = previous.then(task, task);
39
+ const result = previous.then(start, start);
23
40
  const tail = result.catch(() => undefined);
24
41
  this.#tails.set(channel, tail);
25
42
  void tail.then(() => {
@@ -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,22 @@ import {
8
10
  type ScheduledOutcome,
9
11
  STEERED_MARK,
10
12
  } from "../contract/channels.ts";
11
- import { PluginError } from "../errors.ts";
13
+ import { parseChannelKey, type SurfacePort } from "../contract/surface.ts";
14
+ import { IdentityError } from "../domain/errors.ts";
15
+ import { HostStoppingError, PluginError } from "../errors.ts";
16
+ import { messages } from "../i18n/index.ts";
17
+ import type { ActorFacts } from "../identity/actor-facts.ts";
18
+ import type { Contact, ContactAssessor } from "../identity/contact.ts";
19
+ import {
20
+ type IdentityService,
21
+ systemSpeaker,
22
+ } from "../identity/identity-service.ts";
23
+ import { SYSTEM_PRINCIPAL } from "../identity/principal-store.ts";
12
24
  import type { Logger } from "../log.ts";
13
25
  import type { ChannelKey } from "../sessions.ts";
14
26
  import type { ChannelQueue } from "./channel-queue.ts";
15
27
  import { ForwardJoin } from "./forward-join.ts";
28
+ import { isSystemTurn } from "./system-turns.ts";
16
29
 
17
30
  export interface ChannelRouterOptions {
18
31
  /** Claims in registration order; the router orders them by priority. */
@@ -21,6 +34,15 @@ export interface ChannelRouterOptions {
21
34
  /** The contributed background target of a name, read at use time. */
22
35
  targets(name: string): BackgroundTarget | undefined;
23
36
  logger: Logger;
37
+ /** Who the authors of messages are; without it, no message carries a speaker. */
38
+ contacts?: ContactAssessor;
39
+ /** Whom a background turn runs as; without it, only the host's own turns run. */
40
+ principals?: Pick<IdentityService, "speakerFor">;
41
+ /**
42
+ * The surfaces, so a conversation started over is marked in a channel that shows one, and a
43
+ * message that arrives while the host shuts down is told so; without it, neither is.
44
+ */
45
+ surfaces?: Pick<SurfacePort, "of"> & Partial<Pick<SurfacePort, "sendReply">>;
24
46
  /** How long a bare forward waits for the text sent with it; FORWARD_JOIN_MS by default. */
25
47
  forwardJoinMs?: number;
26
48
  }
@@ -38,6 +60,12 @@ export function orderClaims(
38
60
  return claims.toSorted((a, b) => b.priority - a.priority);
39
61
  }
40
62
 
63
+ /** The outcome of a background turn the host did not start because it is shutting down. */
64
+ const stopping = (): ScheduledOutcome => ({
65
+ status: "skipped",
66
+ reason: new HostStoppingError().message,
67
+ });
68
+
41
69
  /**
42
70
  * Routes every conversation operation to the claim that owns its channel, each inside the
43
71
  * channel's queue: messages after joining a bare forward to its text, background turns,
@@ -47,6 +75,12 @@ export class ChannelRouter implements ConversationPort {
47
75
  readonly #options: ChannelRouterOptions;
48
76
  readonly #claims: readonly ChannelClaim[];
49
77
  readonly #forwards: ForwardJoin;
78
+ /** Each channel's messages being admitted, so they reach their claim and the queue in the order they came. */
79
+ readonly #admitting = new Map<ChannelKey, Promise<unknown>>();
80
+ /** The surfaces already warned that their messages carry no actor. */
81
+ readonly #warned = new Set<string>();
82
+ /** The channels already told that the host is restarting, so a burst of messages gets one notice. */
83
+ readonly #told = new Set<ChannelKey>();
50
84
 
51
85
  constructor(options: ChannelRouterOptions) {
52
86
  this.#options = options;
@@ -68,45 +102,199 @@ export class ChannelRouter implements ConversationPort {
68
102
  }
69
103
 
70
104
  async #route(message: InboundMessage): Promise<void> {
71
- const { queue, logger } = this.#options;
72
105
  if (
73
106
  !message.text.trim() &&
74
107
  message.attachments.length === 0 &&
75
108
  !message.forwarded?.text.trim()
76
109
  )
77
110
  return;
78
- const admission = this.#owner(message.channel, message.space)?.admit(
79
- message,
111
+ const claim = this.#owner(message.channel, message.space);
112
+ if (!claim) return;
113
+ const started = await this.#inOrder(message.channel, async () => {
114
+ const admission = await this.#admit(claim, message);
115
+ if (!admission) return undefined;
116
+ // Once the host shuts down only the turns already running finish: this one starts nothing.
117
+ if (this.#options.queue.closed) {
118
+ await this.#refuse(message, admission);
119
+ return undefined;
120
+ }
121
+ return this.#start(message, admission);
122
+ });
123
+ await started?.done;
124
+ }
125
+
126
+ /** Runs the channel's admissions one at a time, in the order its messages came. */
127
+ #inOrder<T>(channel: ChannelKey, task: () => Promise<T>): Promise<T> {
128
+ const before = this.#admitting.get(channel) ?? Promise.resolve();
129
+ const run = before.then(task);
130
+ const after = run.catch(() => undefined);
131
+ this.#admitting.set(channel, after);
132
+ void after.then(() => {
133
+ if (this.#admitting.get(channel) === after)
134
+ this.#admitting.delete(channel);
135
+ });
136
+ return run;
137
+ }
138
+
139
+ /**
140
+ * The claim's admission of the message, which carries the speaker its author resolves to; one
141
+ * who cannot be resolved, such as while the database is down, reaches the claim as no one. The
142
+ * author's contact is recorded, such as their first contact linked, only once the claim takes
143
+ * the message, so a message nobody serves links or makes no one. A linked author the rules
144
+ * refuse is still recorded as seen at no tier as the message is assessed, whether or not a
145
+ * claim then takes it, at most once in a while. When that record fails, or finds them someone
146
+ * else or no one now, such as linked elsewhere meanwhile, the message is dropped: it would run
147
+ * as a principal that may not exist, such as one only assessed at a first contact. The claim
148
+ * hears it through the admission's `dropped` or `unanswered`.
149
+ */
150
+ async #admit(
151
+ claim: ChannelClaim,
152
+ message: InboundMessage,
153
+ ): Promise<Admission | undefined> {
154
+ const { logger, contacts } = this.#options;
155
+ const { speaker: _given, ...facts } = message;
156
+ const actor = this.#actorOf(message);
157
+ let contact: Contact | undefined;
158
+ if (actor && contacts)
159
+ try {
160
+ contact = await contacts.assess(actor, {
161
+ conversation: message.channel,
162
+ });
163
+ } catch (error) {
164
+ logger.error(
165
+ { channel: message.channel, err: error },
166
+ "could not resolve who wrote a message; it reaches its claim as no one",
167
+ );
168
+ }
169
+ const admission = claim.admit(
170
+ contact
171
+ ? { ...facts, speaker: Object.freeze({ ...contact.speaker }) }
172
+ : facts,
80
173
  );
81
- if (!admission) return;
82
- if (admission.kind === "background") {
83
- const outcome = await this.background(admission.turn);
84
- if (outcome.status !== "ran") admission.unanswered(outcome);
174
+ if (!admission || !contact) return admission;
175
+ try {
176
+ const taken = await contact.take();
177
+ if (taken?.principalId === contact.speaker.principalId) return admission;
178
+ logger.warn(
179
+ { channel: message.channel },
180
+ "the author of a message was linked elsewhere, or refused, while it was admitted; it does not run",
181
+ );
182
+ } catch (error) {
183
+ logger.error(
184
+ { channel: message.channel, err: error },
185
+ "could not record who wrote a message; it does not run",
186
+ );
187
+ }
188
+ if (admission.kind === "turn") admission.dropped?.();
189
+ else
190
+ admission.unanswered({
191
+ status: "skipped",
192
+ reason: "its author was not who they were when it was admitted",
193
+ });
194
+ return undefined;
195
+ }
196
+
197
+ /**
198
+ * Who wrote the message, as its surface reports them; a surface that reports no `actor` has it
199
+ * read from the author fields under its own name, once warned. None for a bot or an
200
+ * integration.
201
+ */
202
+ #actorOf(message: InboundMessage): ActorFacts | undefined {
203
+ if (message.authorIsBot || message.integration) return undefined;
204
+ if (message.actor) return message.actor;
205
+ const { surface } = parseChannelKey(message.channel);
206
+ if (!this.#warned.has(surface)) {
207
+ this.#warned.add(surface);
208
+ this.#options.logger.warn(
209
+ { surface },
210
+ `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`,
211
+ );
212
+ }
213
+ return {
214
+ provider: surface,
215
+ subject: message.authorId,
216
+ name: message.authorName,
217
+ surface,
218
+ ...(message.authorRoleIds
219
+ ? {
220
+ roles: message.authorRoleIds.map(
221
+ (role) => `${surface}:role:${role}`,
222
+ ),
223
+ }
224
+ : {}),
225
+ ...(message.space ? { space: message.space } : {}),
226
+ legacyId: message.authorId,
227
+ };
228
+ }
229
+
230
+ /**
231
+ * Drops a message the host no longer starts a turn for because it is shutting down, and tells
232
+ * its channel once that it restarts; no one is told for a bot's or an integration's message, such
233
+ * as a report, since a notice there would only start the next one.
234
+ */
235
+ async #refuse(message: InboundMessage, admission: Admission): Promise<void> {
236
+ const { logger, surfaces } = this.#options;
237
+ const { channel } = message;
238
+ if (admission.kind === "turn") admission.dropped?.();
239
+ else
240
+ admission.unanswered({
241
+ status: "skipped",
242
+ reason: new HostStoppingError().message,
243
+ });
244
+ logger.info({ channel }, "message not run: the host is shutting down");
245
+ if (message.authorIsBot || message.integration || this.#told.has(channel))
85
246
  return;
247
+ this.#told.add(channel);
248
+ try {
249
+ await surfaces?.sendReply?.(channel, {
250
+ chunks: [messages().restartingNotice],
251
+ });
252
+ } catch (error) {
253
+ logger.warn({ channel, err: error }, "restart notice not posted");
254
+ }
255
+ }
256
+
257
+ /**
258
+ * Starts what the claim admitted, in the channel's queue, and returns while it runs: a message
259
+ * during a turn is first offered to it, then marked as waiting.
260
+ */
261
+ async #start(
262
+ message: InboundMessage,
263
+ admission: Admission,
264
+ ): Promise<{ done: Promise<void> }> {
265
+ const { queue, logger } = this.#options;
266
+ if (admission.kind === "background") {
267
+ const done = this.background(admission.turn).then((outcome) => {
268
+ if (outcome.status !== "ran") admission.unanswered(outcome);
269
+ });
270
+ return { done };
86
271
  }
87
272
  const { busy } = admission;
88
273
  let queued = false;
89
274
  // A message during a turn is added to it when the turn takes it, otherwise marked as waiting.
90
275
  if (busy && queue.size(message.channel) > 0) {
91
- if (await busy.steer?.()) {
92
- await busy.react(STEERED_MARK);
93
- return;
94
- }
276
+ if (await busy.steer?.()) return { done: busy.react(STEERED_MARK) };
95
277
  await busy.react(QUEUED_MARK);
96
278
  queued = true;
97
279
  }
98
- await queue
280
+ const done = queue
99
281
  .run(message.channel, () => {
100
282
  if (queued) void busy?.unreact(QUEUED_MARK);
101
283
  return admission.run();
102
284
  })
103
285
  // pi-lens-ignore: no-unknown-parameters
104
- .catch((error: unknown) =>
286
+ .catch(async (error: unknown) => {
287
+ // The host began shutting down while this message waited behind a running turn.
288
+ if (error instanceof HostStoppingError) {
289
+ if (queued) void busy?.unreact(QUEUED_MARK);
290
+ return this.#refuse(message, admission);
291
+ }
105
292
  logger.error(
106
293
  { channel: message.channel, err: error },
107
294
  admission.failure,
108
- ),
109
- );
295
+ );
296
+ });
297
+ return { done };
110
298
  }
111
299
 
112
300
  target(name: string): BackgroundTarget | undefined {
@@ -114,10 +302,12 @@ export class ChannelRouter implements ConversationPort {
114
302
  }
115
303
 
116
304
  /**
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.
305
+ * A turn nobody wrote, run by the channel's claim inside its queue, as the speaker its author
306
+ * is checked to be; never rejects. A turn whose target no plugin contributes is skipped, never
307
+ * handed to another target's claim, and so is one whose author may not run it now.
119
308
  */
120
309
  async background(turn: BackgroundTurn): Promise<ScheduledOutcome> {
310
+ if (this.#options.queue.closed) return stopping();
121
311
  if (!this.#options.targets(turn.target))
122
312
  return {
123
313
  status: "skipped",
@@ -131,21 +321,79 @@ export class ChannelRouter implements ConversationPort {
131
321
  status: "skipped",
132
322
  reason: "no conversation takes background turns here",
133
323
  };
134
- return claim.background(turn);
324
+ // Checked as the turn starts, in its place in the queue, so a change meanwhile counts.
325
+ const runs = await this.runsAs(turn);
326
+ if ("skipped" in runs)
327
+ return { status: "skipped", reason: runs.skipped };
328
+ return claim.background({ ...turn, speaker: runs.speaker });
135
329
  });
136
330
  } catch (error) {
331
+ if (error instanceof HostStoppingError) return stopping();
137
332
  return { status: "failed", error: String(error) };
138
333
  }
139
334
  }
140
335
 
336
+ /**
337
+ * Who a background turn runs as: the system principal for the host's own turn, at its tier;
338
+ * otherwise its author's principal, which must exist, be enabled, and hold a tier now, at the
339
+ * lower of the turn's tier and theirs. Why not, when it may not run; throws when the identity
340
+ * service cannot tell.
341
+ */
342
+ async runsAs(turn: BackgroundTurn): Promise<BackgroundRunsAs> {
343
+ const { author, tier } = turn;
344
+ if (!author?.principalId || !tier)
345
+ return {
346
+ skipped:
347
+ "a background turn names its author's principal (author.principalId) and the tier it runs at (tier); this one does not",
348
+ };
349
+ if (author.principalId === SYSTEM_PRINCIPAL)
350
+ return isSystemTurn(turn)
351
+ ? { speaker: { ...systemSpeaker(tier), name: author.name } }
352
+ : {
353
+ skipped: `only the host itself starts a turn as the system principal "${SYSTEM_PRINCIPAL}"`,
354
+ };
355
+ const { principals } = this.#options;
356
+ if (!principals)
357
+ return {
358
+ skipped: "the host has no identity service to check whom it runs as",
359
+ };
360
+ try {
361
+ const checked = await principals.speakerFor(author.principalId, tier);
362
+ return {
363
+ speaker: {
364
+ id: author.id,
365
+ name: author.name,
366
+ tier: checked.tier,
367
+ principalId: checked.principalId,
368
+ },
369
+ };
370
+ } catch (error) {
371
+ if (!(error instanceof IdentityError)) throw error;
372
+ return { skipped: error.message };
373
+ }
374
+ }
375
+
141
376
  startFresh(channel: ChannelKey): Promise<string> {
142
- return this.#options.queue.run(channel, () => {
377
+ return this.#options.queue.run(channel, async () => {
143
378
  const claim = this.#owner(channel);
144
379
  if (!claim) throw new Error(`no conversation owns ${channel}`);
145
- return claim.startFresh(channel);
380
+ const kind = await claim.startFresh(channel);
381
+ await this.#markFresh(channel);
382
+ return kind;
146
383
  });
147
384
  }
148
385
 
386
+ /** The restart already counts: a surface that cannot mark it is logged, not failed. */
387
+ async #markFresh(channel: ChannelKey): Promise<void> {
388
+ try {
389
+ await this.#options.surfaces?.of(channel)?.markFresh?.(channel);
390
+ } catch (error) {
391
+ this.#options.logger.warn(
392
+ `could not mark the restart of ${channel}: ${error instanceof Error ? error.message : String(error)}`,
393
+ );
394
+ }
395
+ }
396
+
149
397
  async deleteConversation(channel: ChannelKey): Promise<"deleted" | "busy"> {
150
398
  const { queue } = this.#options;
151
399
  const claim = this.#owner(channel);
@@ -176,4 +424,9 @@ export class ChannelRouter implements ConversationPort {
176
424
  takesBackground(channel: ChannelKey): boolean {
177
425
  return this.#owner(channel)?.background !== undefined;
178
426
  }
427
+
428
+ takesSystemReports(channel: ChannelKey): boolean {
429
+ const owner = this.#owner(channel);
430
+ return owner !== undefined && owner.takesSystemReports !== false;
431
+ }
179
432
  }