pi-roundtable 0.28.0 → 0.29.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.
@@ -1,7 +1,8 @@
1
1
  import { helpersOf } from "../config/helpers.ts";
2
2
  import { AgentError } from "../domain/errors.ts";
3
3
  import type { AgentTurnScope } from "../domain/ports.ts";
4
- import { isHelper } from "./agent-rules.ts";
4
+ import { surfaceOf } from "../presence/delivery-router.ts";
5
+ import { answersTo, helperSurface, isHelper } from "./agent-rules.ts";
5
6
  import type { Agent } from "./agent-store.ts";
6
7
  import type { HelpersEnded } from "./helper-ops.ts";
7
8
  import type { TeamContext } from "./team-options.ts";
@@ -11,8 +12,9 @@ const SWEEP_EVERY_MS = 3_600_000;
11
12
 
12
13
  /**
13
14
  * The team's temp knights after they are made: which a knight has, which are finished, and their
14
- * archive, restore, and sweep. Archiving one closes its Discord thread, since a temp knight's
15
- * place is a thread of its parent's channel; an archived one keeps its history.
15
+ * archive, restore, and sweep. A temp knight made on Discord lives in a thread of its parent's
16
+ * channel; one made in the web app gets that thread only once someone reaches for it on Discord.
17
+ * Archiving one closes its thread, when it has one; an archived one keeps its history.
16
18
  */
17
19
  export class TeamHelpers {
18
20
  readonly #ctx: TeamContext;
@@ -20,6 +22,8 @@ export class TeamHelpers {
20
22
  readonly #archiveAgent: (agent: Agent) => Promise<void>;
21
23
  readonly #ended: ((event: HelpersEnded) => void)[] = [];
22
24
  #sweeper: ReturnType<typeof setInterval> | undefined;
25
+ /** The threads being made for web-born temp knights, by name, so two reaches make one. */
26
+ readonly #placing = new Map<string, Promise<void>>();
23
27
 
24
28
  constructor(ctx: TeamContext, archiveAgent: (agent: Agent) => Promise<void>) {
25
29
  this.#ctx = ctx;
@@ -125,9 +129,74 @@ export class TeamHelpers {
125
129
  return restored;
126
130
  }
127
131
 
132
+ /**
133
+ * A `message_agent` call that names "discord", or names nothing from a Discord conversation, gives
134
+ * a web-born temp knight its thread first.
135
+ */
136
+ async reach(
137
+ caller: AgentTurnScope,
138
+ to: string,
139
+ conversation?: string,
140
+ ): Promise<void> {
141
+ const { store } = this.#ctx.options;
142
+ const { turns } = this.#ctx;
143
+ const target = store.agent(to);
144
+ const speaker = turns.speakerOf(caller);
145
+ const from = turns.turnChannel(caller);
146
+ // Unnamed, a message stays on the sender's surface, so one sent from Discord goes there too.
147
+ const wanted = conversation?.trim() || surfaceOf(from);
148
+ if (wanted !== "discord" || !target || !speaker) return;
149
+ if (answersTo(target, caller.name, from, speaker))
150
+ await this.placeOnDiscord(to);
151
+ }
152
+
153
+ /**
154
+ * Gives an active temp knight made in the web app its thread under the parent's channel, the
155
+ * first time someone reaches for it on Discord. One made on Discord, or before the origin was
156
+ * kept, already has its place, and a host without Discord threads makes none; never rejects.
157
+ */
158
+ placeOnDiscord(name: string): Promise<void> {
159
+ const pending = this.#placing.get(name);
160
+ if (pending) return pending;
161
+ const placing = this.#placeOnDiscord(name).finally(() =>
162
+ this.#placing.delete(name),
163
+ );
164
+ this.#placing.set(name, placing);
165
+ return placing;
166
+ }
167
+
168
+ async #placeOnDiscord(name: string): Promise<void> {
169
+ const { store, channels, logger } = this.#ctx.options;
170
+ const agent = store.agent(name);
171
+ const surface = helperSurface(agent);
172
+ if (agent?.status !== "active" || agent.channelId) return;
173
+ if (surface === undefined || surface === "discord") return;
174
+ const parent = store.agent(agent.parent as string);
175
+ if (!parent?.channelId || !channels?.createThread) return;
176
+ try {
177
+ const channelId = await channels.createThread(
178
+ parent.channelId,
179
+ agent.displayName,
180
+ );
181
+ await store.updateAgent(name, { channelId });
182
+ } catch (error) {
183
+ logger.warn(
184
+ { agent: name, err: error },
185
+ "temp knight's thread not made; it stays in its rooms",
186
+ );
187
+ return;
188
+ }
189
+ logger.info(
190
+ { agent: name, parent: parent.name },
191
+ "temp knight's thread made",
192
+ );
193
+ this.#ctx.changed();
194
+ }
195
+
128
196
  /**
129
197
  * Restores one archived temp knight: its thread opens again, or a new one is made when the old
130
- * is gone. It comes back finished, with nothing running.
198
+ * is gone. One made in the web app never gets a new thread: its old one reopens, if it had one
199
+ * that is still there. It comes back finished, with nothing running.
131
200
  */
132
201
  async restoreOne(agent: Agent): Promise<void> {
133
202
  const { store, channels, logger } = this.#ctx.options;
@@ -137,6 +206,8 @@ export class TeamHelpers {
137
206
  `"${agent.name}" belongs to "${agent.parent}", which is archived; restore it first.`,
138
207
  );
139
208
  let { channelId } = agent;
209
+ const surface = helperSurface(agent);
210
+ const bornOnDiscord = surface === undefined || surface === "discord";
140
211
  if (channels?.createThread && parent.channelId) {
141
212
  if (
142
213
  channelId &&
@@ -144,7 +215,7 @@ export class TeamHelpers {
144
215
  (await channels.exists(channelId))
145
216
  )
146
217
  await channels.openThread(channelId);
147
- else
218
+ else if (bornOnDiscord)
148
219
  channelId = await channels.createThread(
149
220
  parent.channelId,
150
221
  agent.displayName,
@@ -87,6 +87,20 @@ export const roomSurfaceOf = (room: ChannelKey): string | undefined => {
87
87
  return surface === DISCORD_SURFACE ? undefined : surface;
88
88
  };
89
89
 
90
+ /**
91
+ * The channel a group seat runs in outside a running turn: the group's room on the surface of the
92
+ * seat's home for a room seat, so it never falls out to the group's Discord channel; otherwise the
93
+ * group's Discord channel, or the seat's home.
94
+ */
95
+ export function groupSeatChannel(
96
+ group: Pick<AgentGroup, "name" | "channelId">,
97
+ scope: Pick<AgentTurnScope, "home">,
98
+ ): ChannelKey {
99
+ const surface = roomSurfaceOf(scope.home);
100
+ if (surface !== undefined) return groupRoomKey(surface, group.name);
101
+ return group.channelId ? discordKey(group.channelId) : scope.home;
102
+ }
103
+
90
104
  /**
91
105
  * An agent's conversation inside a group, apart from its own. In a Discord group channel, or
92
106
  * without a room, it is keyed by the channel; in a room of another surface, by the surface and the
@@ -31,6 +31,7 @@ import {
31
31
  coordinatorOf,
32
32
  discordIdOf,
33
33
  discordKey,
34
+ groupSeatChannel,
34
35
  scopeOf,
35
36
  } from "./team-keys.ts";
36
37
  import {
@@ -429,10 +430,7 @@ export class TeamTurns {
429
430
  : undefined;
430
431
  if (!group) return scope.home;
431
432
  // A seat of a room's group runs in that room; its running turn knows which.
432
- return (
433
- this.#posting.get(scope.session) ??
434
- (group.channelId ? discordKey(group.channelId) : scope.home)
435
- );
433
+ return this.#posting.get(scope.session) ?? groupSeatChannel(group, scope);
436
434
  }
437
435
 
438
436
  /**
@@ -31,6 +31,7 @@ import { Scheduler } from "../modules/schedules/scheduler.ts";
31
31
  import { schedulesExtension } from "../modules/schedules/schedules.ts";
32
32
  import type { ErrorReporter } from "../ops/error-reporter.ts";
33
33
  import type { RoundtablePlugin } from "../plugin.ts";
34
+ import { runChannelFor } from "../presence/delivery-router.ts";
34
35
  import { splitReply } from "../presentation/reply-splitter.ts";
35
36
  import {
36
37
  AGENTS,
@@ -130,23 +131,22 @@ export function modulesPlugin(options: ModulesOptions): RoundtablePlugin {
130
131
  services.get(AGENTS).team.roomsOf(agent);
131
132
  // A conversation no chat surface carries keeps its runs in the creator's direct messages, where
132
133
  // a claim must take them; anyone's elsewhere would run in a conversation that is not theirs.
133
- const channelFor = async (
134
+ // The delivery router picks those messages with the conversation as the origin.
135
+ const channelFor = (
134
136
  channel: ChannelKey,
135
137
  principalId: string,
136
138
  refused: (message: string) => Error,
137
- ) => {
138
- if (surfaces.of(channel)) return channel;
139
- const reached = await directChannels.reach(principalId);
140
- if (!reached)
141
- throw refused(
142
- "this conversation has no chat surface to post a run in, and you have no direct channel on this host to post it in instead",
143
- );
144
- if (!conversations.takesBackground(reached.channel))
145
- throw refused(
146
- `this conversation has no chat surface to post a run in, and nothing here takes background turns in your ${reached.provider.name} direct messages, where it would go instead`,
147
- );
148
- return reached.channel;
149
- };
139
+ ) =>
140
+ runChannelFor(
141
+ {
142
+ surfaces,
143
+ directChannels,
144
+ takesBackground: (key) => conversations.takesBackground(key),
145
+ },
146
+ channel,
147
+ principalId,
148
+ refused,
149
+ );
150
150
  const scheduleChannelFor = (channel: ChannelKey, principalId: string) =>
151
151
  channelFor(channel, principalId, (m) => new ScheduleError(m));
152
152
  const delegateChannelFor = (channel: ChannelKey, principalId: string) =>
@@ -198,7 +198,7 @@ export function modulesPlugin(options: ModulesOptions): RoundtablePlugin {
198
198
  // Only whether a direct channel knows them, without the network; the conversation there,
199
199
  // and whether a claim takes its turns, are checked when a tool runs.
200
200
  try {
201
- if (!(await directChannels.known(own))) return;
201
+ if (!(await directChannels.known(home, own))) return;
202
202
  } catch (error) {
203
203
  logger.warn(
204
204
  { channel: home, err: error },
@@ -31,11 +31,12 @@ export interface AgentRoutes {
31
31
  */
32
32
  integration?: "origin" | "primary" | (string & {});
33
33
  /**
34
- * The conversation a `message_agent` call reaches when it names none. `"latest"` (default) is
35
- * the one the agent last ran a turn in, never carrying a message from a room out to Discord;
34
+ * The conversation a `message_agent` call reaches when it names none. `"origin-surface"`
35
+ * (default) is the agent's place on the sender's surface, else its primary place; `"latest"` the
36
+ * one the agent last ran a turn in on the sender's surface, else as `"origin-surface"`;
36
37
  * `"primary"` the agent's primary place; a surface name the agent's place there.
37
38
  */
38
- agentMessage?: "latest" | "primary" | (string & {});
39
+ agentMessage?: "origin-surface" | "latest" | "primary" | (string & {});
39
40
  /**
40
41
  * Where an agent's opening task (`agent_create task`) runs. `"origin-surface"` (default) is the
41
42
  * agent's place on the surface the creation came from, else its primary place; `"primary"`
@@ -86,7 +87,7 @@ const DECLARED_PLACES: readonly string[] = ["ops"];
86
87
  export const ROUTE_WORDS = {
87
88
  primary: ["auto"],
88
89
  integration: ["origin", "primary"],
89
- agentMessage: ["latest", "primary"],
90
+ agentMessage: ["origin-surface", "latest", "primary"],
90
91
  opening: ["origin-surface", "primary"],
91
92
  } as const;
92
93
 
@@ -109,6 +109,31 @@ export interface InboundMessage {
109
109
  export const STEERED_MARK = "↪️";
110
110
  export const QUEUED_MARK = "⏳";
111
111
 
112
+ /** A message waiting in a channel's queue for its own turn (`ConversationPort.queued`). */
113
+ export interface QueuedMessage {
114
+ /** The surface's id of the message, as `InboundMessage.messageId`. */
115
+ messageId: string;
116
+ /** The principal of the person who wrote it; absent when the router resolved none. */
117
+ principalId?: string;
118
+ /** What its turn will use: the text as last edited. */
119
+ text: string;
120
+ /** Epoch milliseconds when it was queued. */
121
+ queuedAt: number;
122
+ }
123
+
124
+ /** Who asks to change a queued message; only its author may. */
125
+ export interface QueuedBy {
126
+ principalId: string;
127
+ }
128
+
129
+ /**
130
+ * What cancelling or editing a queued message came to. `unknown`: none is queued under that id,
131
+ * such as one whose turn has started, been cancelled, or been steered into the running turn.
132
+ * `forbidden`: it is queued, but not by that principal. `refused` (edit only): the claim would no
133
+ * longer take the message with the new text.
134
+ */
135
+ export type QueuedChange = "done" | "unknown" | "forbidden" | "refused";
136
+
112
137
  /** How a turn nobody wrote in the channel ended. */
113
138
  export type ScheduledOutcome =
114
139
  | { status: "ran" }
@@ -205,8 +230,8 @@ export type Admission =
205
230
  run(): Promise<void>;
206
231
  /**
207
232
  * Called instead of `run` when the router drops the message the claim admitted, because
208
- * its author, once recorded, was not who they were when admitted; frees what the claim
209
- * holds for it.
233
+ * its author, once recorded, was not who they were when admitted, or because it was
234
+ * cancelled or replaced by an edit while it waited; frees what the claim holds for it.
210
235
  */
211
236
  dropped?(): void;
212
237
  /** The log line when the turn throws. */
@@ -327,6 +352,34 @@ export interface ConversationPort {
327
352
  ): Promise<void>;
328
353
  /** Tells the claim that owns the channel a message was edited (`ChannelClaim.onMessageEdited`); never rejects. */
329
354
  messageEdited?(message: InboundMessage): Promise<void>;
355
+ /**
356
+ * The messages waiting in the channel for their own turn, oldest first: a message sent during
357
+ * a turn that the running turn did not take (`QUEUED_MARK`). One that started, was steered
358
+ * into the running turn, or was cancelled is not listed. Optional so a port written before 0.28
359
+ * still type-checks; the host's has it.
360
+ */
361
+ queued?(channel: ChannelKey): readonly QueuedMessage[];
362
+ /**
363
+ * Cancels a queued message so its turn never starts, for its author only. `unknown` once the
364
+ * turn has started, so a caller can tell the person it is too late. The surface is told only
365
+ * through the message's `QUEUED_MARK` being removed.
366
+ */
367
+ cancelQueued?(
368
+ channel: ChannelKey,
369
+ messageId: string,
370
+ by: QueuedBy,
371
+ ): QueuedChange;
372
+ /**
373
+ * Replaces the text a queued message's turn will use, for its author only: the claim that
374
+ * admitted the message admits it again with the new text, and the turn runs on that. The
375
+ * surface's own copy of the message is the caller's to change.
376
+ */
377
+ editQueued?(
378
+ channel: ChannelKey,
379
+ messageId: string,
380
+ by: QueuedBy,
381
+ text: string,
382
+ ): QueuedChange;
330
383
  }
331
384
 
332
385
  /**
@@ -36,6 +36,7 @@ export function discordDirectChannel(
36
36
  };
37
37
  return {
38
38
  name: "discord",
39
+ surface: "discord",
39
40
  label: "a direct message on Discord",
40
41
  knows: async (principalId) => (await userOf(principalId)) !== undefined,
41
42
  reaches: async (principalId) => {
@@ -274,6 +274,10 @@ export class Linkage {
274
274
  messageDeleted: (channel, id, space) =>
275
275
  router().messageDeleted(channel, id, space),
276
276
  messageEdited: (message) => router().messageEdited(message),
277
+ queued: (channel) => router().queued(channel),
278
+ cancelQueued: (channel, id, by) => router().cancelQueued(channel, id, by),
279
+ editQueued: (channel, id, by, text) =>
280
+ router().editQueued(channel, id, by, text),
277
281
  background: (turn) => router().background(turn),
278
282
  runsAs: (turn) => router().runsAs(turn),
279
283
  target: (name) => router().target(name),
@@ -19,7 +19,8 @@ export interface SessionNotify {
19
19
 
20
20
  /**
21
21
  * Registers notify for one session: it sends a notice to the person the conversation serves,
22
- * through the first direct channel that reaches them, and refuses when none does. Its 0.8 name,
22
+ * through the direct channel on the conversation's own surface (another only when that one cannot
23
+ * reach them), and refuses when none does. Its 0.8 name,
23
24
  * `notify_owner`, still names it in a selection and in `toolTiers` until 1.0 (`RENAMED_TOOLS`).
24
25
  */
25
26
  export function notifyExtension(
@@ -37,7 +38,7 @@ export function notifyExtension(
37
38
  return {
38
39
  name: "notify",
39
40
  label: "Notify",
40
- description: `Send ${o.name} ${notify.channels}. Use only when ${o.he} asks to be notified or reminded by DM; your normal reply already reaches ${o.him}.`,
41
+ description: `Send ${o.name} ${notify.channels}. It goes out on this conversation's own surface. Use only when ${o.he} asks to be notified or reminded; your normal reply already reaches ${o.him}.`,
41
42
  parameters: Type.Object({
42
43
  text: Type.String({ description: "The message to send." }),
43
44
  }),
@@ -1,7 +1,9 @@
1
1
  import type { ExtensionFactory } from "@earendil-works/pi-coding-agent";
2
+ import type { ChannelKey } from "../../domain/conversation.ts";
2
3
  import { SYSTEM_PRINCIPAL } from "../../identity/principal-store.ts";
3
4
  import type { OwnerIdentity } from "../../identity.ts";
4
5
  import type { Logger } from "../../log.ts";
6
+ import { providerSurface, surfaceOf } from "../../presence/delivery-router.ts";
5
7
  import type {
6
8
  DirectChannelProvider,
7
9
  DirectChannels,
@@ -26,10 +28,21 @@ function labels(providers: readonly DirectChannelProvider[]): string {
26
28
  return providers.map((provider) => provider.label).join(" or ");
27
29
  }
28
30
 
31
+ /** The providers on the conversation's own surface, or every one when none is on it. */
32
+ function ownSurface(
33
+ providers: readonly DirectChannelProvider[],
34
+ home: ChannelKey,
35
+ ): readonly DirectChannelProvider[] {
36
+ const surface = surfaceOf(home);
37
+ const own = providers.filter((p) => providerSurface(p) === surface);
38
+ return own.length > 0 ? own : providers;
39
+ }
40
+
29
41
  /**
30
42
  * The notify tool of each session: none on a host without direct channels, and none in a private
31
43
  * conversation whose person no direct channel reaches. A notice goes to the conversation's
32
- * person in a private one, and to the turn's speaker in a shared one.
44
+ * person in a private one, and to the turn's speaker in a shared one, on the conversation's own
45
+ * surface: the delivery router tries another only when that one cannot reach them.
33
46
  */
34
47
  export function sessionNotify(
35
48
  deps: SessionNotifyDeps,
@@ -70,15 +83,16 @@ export function sessionNotify(
70
83
  const providers = directChannels.providers();
71
84
  if (providers.length === 0) return null;
72
85
  const { conversation } = session;
86
+ const origin = session.homeChannel;
73
87
  const own =
74
88
  conversation.visibility === "private"
75
89
  ? conversation.principalId
76
90
  : undefined;
77
91
  return async (pi) => {
78
- let reaching = providers;
92
+ let reaching = ownSurface(providers, origin);
79
93
  if (own !== undefined) {
80
94
  try {
81
- const known = await directChannels.known(own);
95
+ const known = await directChannels.known(origin, own);
82
96
  if (!known) return;
83
97
  reaching = [known];
84
98
  } catch (error) {
@@ -98,7 +112,10 @@ export function sessionNotify(
98
112
  : session.addressee;
99
113
  await notifyExtension(
100
114
  {
101
- notifier: directChannels,
115
+ notifier: {
116
+ notify: (principalId, text) =>
117
+ directChannels.notify(origin, principalId, text),
118
+ },
102
119
  recipient: async () => recipientOf(session, own),
103
120
  channels: labels(reaching),
104
121
  },
@@ -4,6 +4,7 @@ import type {
4
4
  SkillSet,
5
5
  SkillVersion,
6
6
  } from "./skill-rules.ts";
7
+ import type { SkillGroup } from "./skill-store.ts";
7
8
 
8
9
  /** The skills agents carry: built in, linked from repositories, or written by an agent. */
9
10
  export interface SkillRegistry {
@@ -45,6 +46,16 @@ export interface SkillRegistry {
45
46
  delete(name: string): Promise<string>;
46
47
  /** The saved versions of an agent-written skill, newest first; none for any other skill. */
47
48
  documentHistory(name: string): Promise<SkillVersion[]>;
49
+ /** Every skill group, by name: a named list of skills to filter by or apply to an agent once; an agent carries skills, not groups. */
50
+ groups(): SkillGroup[];
51
+ /** Creates or replaces a group; returns what it did. Throws AgentError for a refused name, an empty description or an unknown skill. */
52
+ setGroup(
53
+ name: string,
54
+ description: string,
55
+ skills: readonly string[],
56
+ ): Promise<string>;
57
+ /** Deletes a group and leaves its skills and their carriers as they are; returns what it did. Throws AgentError for an unknown group. */
58
+ deleteGroup(name: string): Promise<string>;
48
59
  /** Adds and removes an agent's registered skills together, or changes nothing; returns what it carries after. */
49
60
  attach(
50
61
  agent: string,
@@ -28,7 +28,7 @@ import {
28
28
  type SkillVersion,
29
29
  skillFileContent,
30
30
  } from "./skill-rules.ts";
31
- import type { SkillEntry } from "./skill-store.ts";
31
+ import type { SkillEntry, SkillGroup } from "./skill-store.ts";
32
32
 
33
33
  export {
34
34
  BUILTIN_SKILLS,
@@ -343,6 +343,8 @@ export class StoredSkillRegistry implements SkillRegistry {
343
343
  return this.#removed(name, detached);
344
344
  }
345
345
 
346
+ readonly groups = (): SkillGroup[] => this.#options.store.groups();
347
+
346
348
  async setGroup(
347
349
  name: string,
348
350
  description: string,
@@ -209,10 +209,11 @@ export class SkillStore {
209
209
  return this.#skills.get(name);
210
210
  }
211
211
 
212
+ /** Copies, by name, so a caller's change reaches no stored group. */
212
213
  groups(): SkillGroup[] {
213
- return [...this.#groups.values()].sort((a, b) =>
214
- a.name.localeCompare(b.name),
215
- );
214
+ return [...this.#groups.values()]
215
+ .map((group) => ({ ...group, skills: [...group.skills] }))
216
+ .sort((a, b) => a.name.localeCompare(b.name));
216
217
  }
217
218
 
218
219
  group(name: string): SkillGroup | undefined {
@@ -0,0 +1,165 @@
1
+ import type { SurfacePort } from "../contract/surface.ts";
2
+ import { parseChannelKey } from "../contract/surface.ts";
3
+ import type { ChannelKey } from "../domain/conversation.ts";
4
+ import type { Logger } from "../log.ts";
5
+ import { splitReply } from "../presentation/reply-splitter.ts";
6
+ import type {
7
+ DirectChannelProvider,
8
+ DirectChannels,
9
+ DirectReach,
10
+ } from "./direct-channels.ts";
11
+
12
+ /**
13
+ * The conversation a piece of work came from: a turn's channel, a schedule's, a job's. Its surface
14
+ * is the key's prefix, and everything the work sends goes there, or to the same person on that
15
+ * surface, before any other.
16
+ */
17
+ export type Origin = ChannelKey;
18
+
19
+ /** The surface an origin is on: the prefix of its key, such as `discord` or `app`. */
20
+ export function surfaceOf(origin: Origin): string {
21
+ return parseChannelKey(origin).surface;
22
+ }
23
+
24
+ /** The surface whose conversations a provider reaches: its `surface`, else its name. */
25
+ export function providerSurface(provider: DirectChannelProvider): string {
26
+ return provider.surface ?? provider.name;
27
+ }
28
+
29
+ /**
30
+ * The one place that decides where a notice or run for a person goes, from the origin of the work.
31
+ * A provider on the origin's surface is tried first; another surface is tried only when none there
32
+ * reaches the person, and that fallback is logged with both surfaces. Mirroring is the provider's
33
+ * own: after a delivery, each other provider that declares `mirror` echoes the notice to the same
34
+ * person. Nothing here picks the "first" provider or the "most recent" conversation.
35
+ */
36
+ export interface DeliveryRouter {
37
+ /** The providers in the order an origin tries them: its own surface's, then the rest. */
38
+ order(origin: Origin): readonly DirectChannelProvider[];
39
+ /** The person's own conversation for work from `origin`; undefined when no provider reaches them. */
40
+ reach(origin: Origin, principalId: string): Promise<DirectReach | undefined>;
41
+ /** The provider that knows the person for `origin`, without the network where it can tell. */
42
+ known(
43
+ origin: Origin,
44
+ principalId: string,
45
+ ): Promise<DirectChannelProvider | undefined>;
46
+ /** Sends the person `text` for work from `origin`; false when no provider reaches them. */
47
+ notify(origin: Origin, principalId: string, text: string): Promise<boolean>;
48
+ }
49
+
50
+ export function deliveryRouter(
51
+ providers: () => readonly DirectChannelProvider[],
52
+ surfaces: Pick<SurfacePort, "sendReply">,
53
+ logger?: Pick<Logger, "warn">,
54
+ ): DeliveryRouter {
55
+ const order = (origin: Origin): readonly DirectChannelProvider[] => {
56
+ const surface = surfaceOf(origin);
57
+ const all = providers();
58
+ return [
59
+ ...all.filter((p) => providerSurface(p) === surface),
60
+ ...all.filter((p) => providerSurface(p) !== surface),
61
+ ];
62
+ };
63
+ const fellBack = (
64
+ origin: Origin,
65
+ provider: DirectChannelProvider,
66
+ principalId: string,
67
+ ): void => {
68
+ const from = surfaceOf(origin);
69
+ const to = providerSurface(provider);
70
+ if (from !== to)
71
+ logger?.warn(
72
+ { origin, from, to, principalId, provider: provider.name },
73
+ "the origin's surface cannot reach the person; delivered on another surface",
74
+ );
75
+ };
76
+ const reach = async (
77
+ origin: Origin,
78
+ principalId: string,
79
+ ): Promise<DirectReach | undefined> => {
80
+ for (const provider of order(origin)) {
81
+ const channel = await provider.reaches(principalId);
82
+ if (channel === undefined) continue;
83
+ fellBack(origin, provider, principalId);
84
+ return { provider, channel };
85
+ }
86
+ return undefined;
87
+ };
88
+ const mirrored = async (
89
+ delivered: DirectChannelProvider,
90
+ principalId: string,
91
+ text: string,
92
+ ): Promise<void> => {
93
+ await Promise.all(
94
+ providers()
95
+ .filter((provider) => provider !== delivered && provider.mirror)
96
+ .map(async (provider) => {
97
+ try {
98
+ await provider.mirror?.(principalId, text);
99
+ } catch (error) {
100
+ logger?.warn(
101
+ { err: error, provider: provider.name },
102
+ "a direct channel did not mirror a notice",
103
+ );
104
+ }
105
+ }),
106
+ );
107
+ };
108
+ return {
109
+ order,
110
+ reach,
111
+ known: async (origin, principalId) => {
112
+ for (const provider of order(origin)) {
113
+ const knows = provider.knows
114
+ ? await provider.knows(principalId)
115
+ : (await provider.reaches(principalId)) !== undefined;
116
+ if (knows) return provider;
117
+ }
118
+ return undefined;
119
+ },
120
+ notify: async (origin, principalId, text) => {
121
+ const reached = await reach(origin, principalId);
122
+ if (!reached) return false;
123
+ if (reached.provider.deliver)
124
+ await reached.provider.deliver(principalId, text);
125
+ else
126
+ await surfaces.sendReply(reached.channel, {
127
+ chunks: splitReply(text),
128
+ });
129
+ await mirrored(reached.provider, principalId, text);
130
+ return true;
131
+ },
132
+ };
133
+ }
134
+
135
+ /** What `runChannelFor` reads: the surfaces, the direct channels, and which claims take background turns. */
136
+ export interface RunChannelDeps {
137
+ surfaces: Pick<SurfacePort, "of">;
138
+ directChannels: Pick<DirectChannels, "reach">;
139
+ takesBackground(channel: ChannelKey): boolean;
140
+ }
141
+
142
+ /**
143
+ * The conversation a schedule's runs or a delegated job's report post in, for work created in
144
+ * `origin`: the origin itself when a chat surface carries it; otherwise the creator's own
145
+ * conversation the router reaches from that origin, which a claim must take background turns in.
146
+ * Throws `refused` with why there is none.
147
+ */
148
+ export async function runChannelFor(
149
+ deps: RunChannelDeps,
150
+ origin: Origin,
151
+ principalId: string,
152
+ refused: (message: string) => Error,
153
+ ): Promise<ChannelKey> {
154
+ if (deps.surfaces.of(origin)) return origin;
155
+ const reached = await deps.directChannels.reach(origin, principalId);
156
+ if (!reached)
157
+ throw refused(
158
+ "this conversation has no chat surface to post a run in, and you have no direct channel on this host to post it in instead",
159
+ );
160
+ if (!deps.takesBackground(reached.channel))
161
+ throw refused(
162
+ `this conversation has no chat surface to post a run in, and nothing here takes background turns in your ${reached.provider.name} direct messages, where it would go instead`,
163
+ );
164
+ return reached.channel;
165
+ }