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
@@ -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,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);
@@ -176,4 +372,9 @@ export class ChannelRouter implements ConversationPort {
176
372
  takesBackground(channel: ChannelKey): boolean {
177
373
  return this.#owner(channel)?.background !== undefined;
178
374
  }
375
+
376
+ takesSystemReports(channel: ChannelKey): boolean {
377
+ const owner = this.#owner(channel);
378
+ return owner !== undefined && owner.takesSystemReports !== false;
379
+ }
179
380
  }
@@ -6,9 +6,11 @@ import type {
6
6
  } from "../conversations/conversation-registry.ts";
7
7
  import type { TurnAttachments } from "../domain/attachment.ts";
8
8
  import type { TurnResult } from "../domain/conversation.ts";
9
+ import type { TurnConversation } from "../domain/ports.ts";
9
10
  import type { TurnProgress } from "../domain/progress.ts";
10
11
  import { PluginError } from "../errors.ts";
11
12
  import { messages } from "../i18n/index.ts";
13
+ import { SYSTEM_PRINCIPAL } from "../identity/principal-store.ts";
12
14
  import type { Logger } from "../log.ts";
13
15
  import type { EventSink } from "../plugin.ts";
14
16
  import { splitReply } from "../presentation/reply-splitter.ts";
@@ -60,8 +62,10 @@ export interface ConversationTurns {
60
62
  * `turnEnded` with the turn's `kind`, settles a runtime that throws into a failed result, and
61
63
  * posts the reply, or a failure or stopped notice, through the surface unless `reply` is given.
62
64
  * It rejects during setup (NotLinkedError), when the host has no runtime to run the turn on,
63
- * and when the conversation cannot be recorded in the host's registry (a database error, or a
64
- * `private` conversation without a speaker). Each of these rejects before the turn starts:
65
+ * when the conversation cannot be recorded in the host's registry (a database error, or a
66
+ * `private` conversation without a speaker), and when the conversation is recorded private to a
67
+ * principal other than the speaker's, unless the speaker is the host's own (`SYSTEM_PRINCIPAL`).
68
+ * Each of these rejects before the turn starts:
65
69
  * nothing is shown, no event is emitted, and `reply` is not called, so a claim that answers
66
70
  * its person tells them itself.
67
71
  */
@@ -85,25 +89,50 @@ export interface ConversationTurnsOptions {
85
89
  /** The selection of a turn that gives none: the plugins' `agentSelection`. */
86
90
  const DEFAULT_SELECTION = "turns";
87
91
 
88
- /** Records the conversation of a turn: private to the speaker when asked, else shared. */
92
+ /**
93
+ * Records the conversation of a turn, private to the speaker when asked, else shared, and returns
94
+ * who it belongs to as the record keeps it; without a registry, as the turn asks, if it does.
95
+ */
89
96
  async function record(
90
97
  registry: Pick<ConversationRegistry, "register"> | undefined,
91
98
  input: ConversationTurnInput,
92
- ): Promise<void> {
93
- if (!registry) return;
99
+ ): Promise<TurnConversation | undefined> {
94
100
  const visibility = input.conversation?.visibility ?? "shared";
95
101
  if (visibility === "private" && !input.speaker)
96
102
  throw new PluginError(
97
103
  `a private conversation needs the speaker it belongs to; ${input.channel} was run without one.`,
98
104
  );
105
+ if (!registry)
106
+ return input.conversation && conversationOf(visibility, input.speaker);
99
107
  const title = input.conversation?.title;
100
- await registry.register({
108
+ const kept = await registry.register({
101
109
  key: input.channel,
102
110
  kind: input.kind,
103
111
  visibility,
104
- ...(visibility === "private" ? { principalId: input.speaker.id } : {}),
112
+ ...(visibility === "private"
113
+ ? { principalId: input.speaker.principalId }
114
+ : {}),
105
115
  ...(title === undefined ? {} : { title }),
106
116
  });
117
+ if (kept.visibility !== "private" || kept.principalId === undefined)
118
+ return { visibility: "shared" };
119
+ // Only its person, or the host itself, speaks in a private conversation: anyone else's turn would
120
+ // read and act on what is theirs.
121
+ const { principalId } = input.speaker;
122
+ if (principalId !== kept.principalId && principalId !== SYSTEM_PRINCIPAL)
123
+ throw new PluginError(
124
+ `${input.channel} is private to ${kept.principalId}, so ${principalId} may not speak in it; run their turn in a conversation of their own.`,
125
+ );
126
+ return { visibility: "private", principalId: kept.principalId };
127
+ }
128
+
129
+ function conversationOf(
130
+ visibility: ConversationVisibility,
131
+ speaker: Speaker,
132
+ ): TurnConversation {
133
+ return visibility === "private"
134
+ ? { visibility, principalId: speaker.principalId }
135
+ : { visibility };
107
136
  }
108
137
 
109
138
  export function conversationTurns(
@@ -117,7 +146,7 @@ export function conversationTurns(
117
146
  // A host without a runtime is a setup mistake, so it is refused before anything is shown.
118
147
  const runtime = options.runtime();
119
148
  // A conversation that cannot be recorded runs no turn: who it belongs to would be lost.
120
- await record(options.registry?.(), input);
149
+ const conversation = await record(options.registry?.(), input);
121
150
  const stopTyping = surfaces.startTyping(channel);
122
151
  const hideStop = surfaces.showStop(channel);
123
152
  const turn = { kind, channel, speaker };
@@ -150,6 +179,7 @@ export function conversationTurns(
150
179
  },
151
180
  text: input.text,
152
181
  speaker,
182
+ ...(conversation ? { conversation } : {}),
153
183
  ...(input.attachments
154
184
  ? { attachments: input.attachments }
155
185
  : {}),
@@ -1,13 +1,27 @@
1
1
  import type { ChatSurface, SurfacePort } from "../contract/surface.ts";
2
2
  import { parseChannelKey } from "../contract/surface.ts";
3
3
  import { PluginError } from "../errors.ts";
4
+ import {
5
+ isPromptScope,
6
+ type PromptScope,
7
+ promptScope,
8
+ } from "../interactions/prompts.ts";
4
9
  import type { ChannelKey } from "../sessions.ts";
10
+ import type { Speaker } from "../speakers.ts";
11
+
12
+ export interface SurfacePortOptions {
13
+ /** Told when a caller uses a form that goes away, such as `prompts(channel, speaker)`. */
14
+ deprecated?(message: string): void;
15
+ }
5
16
 
6
17
  /**
7
18
  * The port over the contributed surfaces, each chosen by the prefix of a channel's key. `linked`
8
19
  * gives the surfaces once every plugin is set up and throws NotLinkedError before.
9
20
  */
10
- export function surfacePort(linked: () => readonly ChatSurface[]): SurfacePort {
21
+ export function surfacePort(
22
+ linked: () => readonly ChatSurface[],
23
+ options: SurfacePortOptions = {},
24
+ ): SurfacePort {
11
25
  const of = (channel: ChannelKey): ChatSurface | undefined => {
12
26
  const at = channel.indexOf(":");
13
27
  if (at <= 0) return undefined;
@@ -40,10 +54,26 @@ export function surfacePort(linked: () => readonly ChatSurface[]): SurfacePort {
40
54
  of(channel)?.react?.(channel, messageId, emoji),
41
55
  unreact: async (channel, messageId, emoji) =>
42
56
  of(channel)?.unreact?.(channel, messageId, emoji),
43
- prompts: (channel, speaker) => of(channel)?.prompts?.(channel, speaker),
57
+ prompts: (channel, scope) =>
58
+ of(channel)?.prompts?.(channel, scopeOf(scope, options)),
44
59
  interim: (channel) => of(channel)?.interim?.(channel),
45
60
  progress: async (channel, event) => {
46
61
  await of(channel)?.progress?.(channel, event);
47
62
  },
48
63
  };
49
64
  }
65
+
66
+ /**
67
+ * The scope a surface's prompts get: as given, or, from the 0.8 form's speaker, theirs in a
68
+ * shared conversation, as 0.8 had them, with a warning.
69
+ */
70
+ function scopeOf(
71
+ given: PromptScope | Speaker | undefined,
72
+ options: SurfacePortOptions,
73
+ ): PromptScope | undefined {
74
+ if (!given || isPromptScope(given)) return given;
75
+ options.deprecated?.(
76
+ "deprecated: prompts(channel, speaker) takes a PromptScope since 0.9, made with promptScope(speaker, visibility); a speaker is read as theirs in a shared conversation, and this form goes away in 1.0",
77
+ );
78
+ return promptScope(given);
79
+ }
@@ -0,0 +1,29 @@
1
+ import type { BackgroundTurn } from "../contract/channels.ts";
2
+ import { SYSTEM_PRINCIPAL } from "../identity/principal-store.ts";
3
+
4
+ /** The turns the host itself made, by identity: a copy, or one a plugin writes, is not one of them. */
5
+ const made = new WeakSet<BackgroundTurn>();
6
+
7
+ /**
8
+ * A background turn the host itself starts as the system principal, such as a logged error's
9
+ * report or a Discord webhook's post, at the tier its starter gives; the router runs it as the
10
+ * system principal's speaker. Core-internal: a plugin cannot make one, and a turn it writes naming
11
+ * the system principal is skipped.
12
+ */
13
+ export function systemTurn(
14
+ turn: Omit<BackgroundTurn, "author" | "speaker"> & {
15
+ author: { id: string; name: string };
16
+ },
17
+ ): BackgroundTurn {
18
+ const own: BackgroundTurn = {
19
+ ...turn,
20
+ author: { ...turn.author, principalId: SYSTEM_PRINCIPAL },
21
+ };
22
+ made.add(own);
23
+ return own;
24
+ }
25
+
26
+ /** Whether the host made this very turn with `systemTurn`. */
27
+ export function isSystemTurn(turn: BackgroundTurn): boolean {
28
+ return made.has(turn);
29
+ }