pi-roundtable 0.27.1 → 0.29.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 (65) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +2 -0
  3. package/README.zh-TW.md +2 -0
  4. package/docs/plugins.md +54 -22
  5. package/package.json +1 -1
  6. package/src/characters/compose.ts +11 -0
  7. package/src/characters/parts-face.ts +23 -2
  8. package/src/core/agents/agent-messages.ts +37 -92
  9. package/src/core/agents/agent-ports.ts +3 -0
  10. package/src/core/agents/agent-prompt.ts +2 -16
  11. package/src/core/agents/agent-rules.ts +27 -0
  12. package/src/core/agents/agent-team.ts +5 -0
  13. package/src/core/agents/agent-tools.ts +18 -10
  14. package/src/core/agents/helper-create.ts +3 -24
  15. package/src/core/agents/helper-modes.ts +64 -0
  16. package/src/core/agents/message-target.ts +20 -18
  17. package/src/core/agents/routes.ts +42 -24
  18. package/src/core/agents/team-editing.ts +5 -5
  19. package/src/core/agents/team-helpers.ts +76 -5
  20. package/src/core/agents/team-keys.ts +14 -0
  21. package/src/core/agents/team-turn-types.ts +7 -13
  22. package/src/core/agents/team-turns.ts +9 -6
  23. package/src/core/builtin/agent-server.ts +3 -4
  24. package/src/core/builtin/discord.ts +35 -8
  25. package/src/core/builtin/modules.ts +15 -15
  26. package/src/core/config/adapters.ts +2 -1
  27. package/src/core/config/limits.ts +35 -2
  28. package/src/core/config/routing.ts +5 -4
  29. package/src/core/discord/agent-discord.ts +12 -1
  30. package/src/core/discord/card-copies.ts +7 -2
  31. package/src/core/discord/direct-channel.ts +1 -0
  32. package/src/core/discord/discord-surface.ts +13 -30
  33. package/src/core/discord/intercom-cards.ts +121 -0
  34. package/src/core/discord/owner-card-asks.ts +9 -0
  35. package/src/core/discord/owner-card-types.ts +2 -0
  36. package/src/core/discord/owner-cards.ts +2 -1
  37. package/src/core/discord/stop-button.ts +35 -0
  38. package/src/core/domain/conversation.ts +18 -0
  39. package/src/core/domain/interim.ts +7 -0
  40. package/src/core/domain/turn-timeout.ts +5 -0
  41. package/src/core/i18n/conversation.ts +16 -4
  42. package/src/core/i18n/turn-timeout.ts +10 -2
  43. package/src/core/interactions/prompts.ts +5 -0
  44. package/src/core/modes/autonomy.ts +22 -1
  45. package/src/core/modes/conversation-modes.ts +135 -23
  46. package/src/core/modes/mode-inheritance.ts +78 -0
  47. package/src/core/modes/modes-plugin.ts +1 -1
  48. package/src/core/modules/notify/notify.ts +3 -2
  49. package/src/core/modules/notify/session-notify.ts +21 -4
  50. package/src/core/modules/skills/skill-contract.ts +13 -0
  51. package/src/core/modules/skills/skill-registry.ts +3 -1
  52. package/src/core/modules/skills/skill-store.ts +4 -3
  53. package/src/core/presence/delivery-router.ts +165 -0
  54. package/src/core/presence/direct-channels.ts +39 -68
  55. package/src/core/presentation/intercom.ts +44 -0
  56. package/src/core/runtime/ask-questions.ts +21 -4
  57. package/src/core/runtime/extensions/ask-user-text.ts +36 -3
  58. package/src/core/runtime/extensions/ask-user.ts +21 -1
  59. package/src/core/runtime/interim-text.ts +7 -4
  60. package/src/core/runtime/pi-agent-runtime.ts +2 -2
  61. package/src/core/runtime/prompt-slot.ts +11 -1
  62. package/src/core/runtime/session-factory.ts +11 -6
  63. package/src/core/runtime/turn-budget.ts +34 -3
  64. package/src/core/runtime/turn-limits.ts +13 -3
  65. package/src/index.ts +7 -1
@@ -5,6 +5,12 @@ import { SYSTEM_PRINCIPAL } from "../identity/principal-store.ts";
5
5
  import type { Logger } from "../log.ts";
6
6
  import type { ChannelKey } from "../sessions.ts";
7
7
  import type { Speaker } from "../speakers.ts";
8
+ import {
9
+ followersOf,
10
+ type ModeOrigins,
11
+ resolveMode,
12
+ type StoredMode,
13
+ } from "./mode-inheritance.ts";
8
14
 
9
15
  /** What a conversation is set to run unattended: both off until an owner switches them on. */
10
16
  export interface ConversationMode {
@@ -15,6 +21,12 @@ export interface ConversationMode {
15
21
  /** The principal who last changed it; absent for a conversation never changed. */
16
22
  changedBy?: string;
17
23
  changedAt?: Date;
24
+ /**
25
+ * Present when the modes follow another conversation's, live: a temp knight's follow the
26
+ * conversation it was made from. `name` is that conversation's, as the agent server tells it;
27
+ * `gone` says it no longer exists, so both modes are off. `set` refuses such a conversation.
28
+ */
29
+ inheritedFrom?: { channel: ChannelKey; name?: string; gone?: true };
18
30
  }
19
31
 
20
32
  /** Which modes a change sets; a mode left out stays as it is. */
@@ -53,7 +65,33 @@ export interface ConversationModes {
53
65
  change: ModeChange,
54
66
  by: Speaker,
55
67
  ): Promise<ConversationMode>;
56
- /** Called after each change. Returns the function that stops it. A throwing listener is logged. */
68
+ /**
69
+ * Makes each of `channels` follow `origin`'s modes live, in place of its own: `peek` returns the
70
+ * origin's, with `inheritedFrom`, and `set` is refused for it. Stored, so it survives a restart.
71
+ * Throws `PluginError` unless `by` is an owner other than the system principal, as `set` does.
72
+ */
73
+ follow(
74
+ channels: readonly ChannelKey[],
75
+ origin: ChannelKey,
76
+ by: Speaker,
77
+ ): Promise<void>;
78
+ /**
79
+ * Drops a conversation's modes and what it follows, as for a conversation that is gone or made
80
+ * anew. It only turns modes off, so it needs no speaker.
81
+ */
82
+ forget(channel: ChannelKey): Promise<void>;
83
+ /**
84
+ * How a following conversation's origin is named and whether it still exists; the agent server
85
+ * gives it. `originOf` is undefined when `channel` no longer follows `origin`, so its own modes
86
+ * count again; `alive: false` when the origin is gone, so both modes read off.
87
+ */
88
+ trackOrigins(origins: {
89
+ originOf(
90
+ channel: ChannelKey,
91
+ origin: ChannelKey,
92
+ ): { name: string; alive: boolean } | undefined;
93
+ }): void;
94
+ /** Called after each change, also for each conversation that follows the changed one. Returns the function that stops it. A throwing listener is logged. */
57
95
  onChange(
58
96
  listener: (channel: ChannelKey, mode: ConversationMode) => void,
59
97
  ): () => void;
@@ -68,27 +106,35 @@ interface Row {
68
106
  long_running: boolean;
69
107
  changed_by: string | null;
70
108
  changed_at: Date;
109
+ follows: string | null;
71
110
  }
72
111
 
73
- const OFF: ConversationMode = Object.freeze({
74
- yolo: false,
75
- longRunning: false,
76
- });
77
-
78
- function modeOf(row: Row): ConversationMode {
79
- return Object.freeze({
80
- yolo: row.yolo,
81
- longRunning: row.long_running,
82
- ...(row.changed_by === null ? {} : { changedBy: row.changed_by }),
83
- changedAt: row.changed_at,
84
- });
112
+ function storedOf(row: Row): StoredMode {
113
+ return {
114
+ own: Object.freeze({
115
+ yolo: row.yolo,
116
+ longRunning: row.long_running,
117
+ ...(row.changed_by === null ? {} : { changedBy: row.changed_by }),
118
+ changedAt: row.changed_at,
119
+ }),
120
+ ...(row.follows === null ? {} : { follows: row.follows as ChannelKey }),
121
+ };
122
+ }
123
+
124
+ /** Refuses a change of modes by anyone but an owner, and by the host's own turns. */
125
+ function checkOwner(by: Speaker): void {
126
+ if (by.tier !== "owner" || by.principalId === SYSTEM_PRINCIPAL)
127
+ throw new PluginError(
128
+ "only an owner sets a conversation's modes, and not the host's own turns",
129
+ );
85
130
  }
86
131
 
87
132
  /** The conversation modes in the `conversation_modes` table, cached in memory after one load. */
88
133
  export class PgConversationModes implements ConversationModes {
89
134
  readonly #sql: SQL;
90
135
  readonly #logger: Logger;
91
- readonly #modes = new Map<ChannelKey, ConversationMode>();
136
+ readonly #modes = new Map<ChannelKey, StoredMode>();
137
+ #origins: ModeOrigins | undefined;
92
138
  readonly #changed = new Set<
93
139
  (channel: ChannelKey, mode: ConversationMode) => void
94
140
  >();
@@ -99,7 +145,7 @@ export class PgConversationModes implements ConversationModes {
99
145
  this.#logger = logger;
100
146
  }
101
147
 
102
- /** The modes' table; the host runs this before the modes attach. */
148
+ /** The modes' table; the host runs this, then `followsMigration`, before the modes attach. */
103
149
  static readonly migration: Migration = {
104
150
  name: "conversation-modes",
105
151
  up: async (sql) => {
@@ -114,6 +160,20 @@ export class PgConversationModes implements ConversationModes {
114
160
  },
115
161
  };
116
162
 
163
+ /** The conversation whose modes a row follows, such as a temp knight's origin. */
164
+ static readonly followsMigration: Migration = {
165
+ name: "conversation-modes-follows",
166
+ up: async (sql) => {
167
+ await sql`ALTER TABLE conversation_modes ADD COLUMN IF NOT EXISTS follows text`;
168
+ },
169
+ };
170
+
171
+ /** Every migration of the modes' table, in order. */
172
+ static readonly migrations: readonly Migration[] = [
173
+ PgConversationModes.migration,
174
+ PgConversationModes.followsMigration,
175
+ ];
176
+
117
177
  /**
118
178
  * The modes over the host's migrated pool, with none loaded: `Roundtable.check()` opens no
119
179
  * connection, so the rows are read by `load`, from the plugin's service start.
@@ -128,11 +188,15 @@ export class PgConversationModes implements ConversationModes {
128
188
  // A mode `set` wrote while the rows were read is newer than its row here.
129
189
  for (const row of rows)
130
190
  if (!this.#modes.has(row.channel as ChannelKey))
131
- this.#modes.set(row.channel as ChannelKey, modeOf(row));
191
+ this.#modes.set(row.channel as ChannelKey, storedOf(row));
132
192
  }
133
193
 
134
194
  peek(channel: ChannelKey): ConversationMode {
135
- return this.#modes.get(channel) ?? OFF;
195
+ return resolveMode(channel, (key) => this.#modes.get(key), this.#origins);
196
+ }
197
+
198
+ trackOrigins(origins: ModeOrigins): void {
199
+ this.#origins = origins;
136
200
  }
137
201
 
138
202
  async set(
@@ -140,9 +204,11 @@ export class PgConversationModes implements ConversationModes {
140
204
  change: ModeChange,
141
205
  by: Speaker,
142
206
  ): Promise<ConversationMode> {
143
- if (by.tier !== "owner" || by.principalId === SYSTEM_PRINCIPAL)
207
+ checkOwner(by);
208
+ const inherited = this.peek(channel).inheritedFrom;
209
+ if (inherited)
144
210
  throw new PluginError(
145
- "only an owner sets a conversation's modes, and not the host's own turns",
211
+ `this conversation follows the modes of ${inherited.name ?? inherited.channel}; change them there`,
146
212
  );
147
213
  const yolo = change.yolo ?? null;
148
214
  const longRunning = change.longRunning ?? null;
@@ -155,13 +221,14 @@ export class PgConversationModes implements ConversationModes {
155
221
  yolo = COALESCE(${yolo}::boolean, conversation_modes.yolo),
156
222
  long_running = COALESCE(${longRunning}::boolean, conversation_modes.long_running),
157
223
  changed_by = ${by.principalId},
158
- changed_at = now()
224
+ changed_at = now(),
225
+ follows = NULL
159
226
  RETURNING *`;
160
227
  const [row] = rows;
161
228
  if (!row)
162
229
  throw new Error(`conversation modes of ${channel} were not stored`);
163
- const mode = modeOf(row);
164
- this.#modes.set(channel, mode);
230
+ this.#modes.set(channel, storedOf(row));
231
+ const mode = this.peek(channel);
165
232
  this.#logger.info(
166
233
  {
167
234
  channel,
@@ -171,10 +238,44 @@ export class PgConversationModes implements ConversationModes {
171
238
  },
172
239
  "conversation modes changed",
173
240
  );
174
- this.#tell(this.#changed, "change", (listener) => listener(channel, mode));
241
+ this.#announce(channel);
175
242
  return mode;
176
243
  }
177
244
 
245
+ async follow(
246
+ channels: readonly ChannelKey[],
247
+ origin: ChannelKey,
248
+ by: Speaker,
249
+ ): Promise<void> {
250
+ checkOwner(by);
251
+ for (const channel of channels) {
252
+ // Its own modes go off: they count again only once it no longer follows.
253
+ const rows: Row[] = await this.#sql`
254
+ INSERT INTO conversation_modes (channel, yolo, long_running, changed_by, changed_at, follows)
255
+ VALUES (${channel}, false, false, ${by.principalId}, now(), ${origin})
256
+ ON CONFLICT (channel) DO UPDATE SET
257
+ yolo = false, long_running = false, changed_by = ${by.principalId},
258
+ changed_at = now(), follows = ${origin}
259
+ RETURNING *`;
260
+ const [row] = rows;
261
+ if (!row)
262
+ throw new Error(`conversation modes of ${channel} were not stored`);
263
+ this.#modes.set(channel, storedOf(row));
264
+ this.#logger.info(
265
+ { channel, origin, by: by.principalId },
266
+ "conversation modes follow another conversation",
267
+ );
268
+ this.#announce(channel);
269
+ }
270
+ }
271
+
272
+ async forget(channel: ChannelKey): Promise<void> {
273
+ await this.#sql`DELETE FROM conversation_modes WHERE channel = ${channel}`;
274
+ if (!this.#modes.delete(channel)) return;
275
+ this.#logger.info({ channel }, "conversation modes forgotten");
276
+ this.#announce(channel);
277
+ }
278
+
178
279
  onChange(
179
280
  listener: (channel: ChannelKey, mode: ConversationMode) => void,
180
281
  ): () => void {
@@ -200,6 +301,17 @@ export class PgConversationModes implements ConversationModes {
200
301
  return () => void this.#approved.delete(listener);
201
302
  }
202
303
 
304
+ /** Tells the listeners of `channel`'s modes, and of each conversation that follows it. */
305
+ #announce(channel: ChannelKey): void {
306
+ for (const key of [
307
+ channel,
308
+ ...followersOf(channel, this.#modes, this.#origins),
309
+ ]) {
310
+ const mode = this.peek(key);
311
+ this.#tell(this.#changed, "change", (listener) => listener(key, mode));
312
+ }
313
+ }
314
+
203
315
  /** Runs each listener; one that throws is logged and the rest still hear it. */
204
316
  #tell<L>(listeners: Set<L>, what: string, call: (listener: L) => void): void {
205
317
  for (const listener of [...listeners]) {
@@ -0,0 +1,78 @@
1
+ import type { ChannelKey } from "../sessions.ts";
2
+ import type { ConversationMode } from "./conversation-modes.ts";
3
+
4
+ /**
5
+ * What the agent server says of a conversation that follows another's modes, given to
6
+ * `ConversationModes.trackOrigins`.
7
+ */
8
+ export interface ModeOrigins {
9
+ /**
10
+ * Whether `channel` still follows `origin`, as a temp knight's conversation follows the one it
11
+ * was made from, and the origin's name to show: undefined when it no longer does (its agent is
12
+ * gone, or the name is another agent's now), so its own modes count again; `alive: false` when
13
+ * the origin conversation is gone, so both modes read off.
14
+ */
15
+ originOf(
16
+ channel: ChannelKey,
17
+ origin: ChannelKey,
18
+ ): { name: string; alive: boolean } | undefined;
19
+ }
20
+
21
+ /** A conversation's stored modes, and the conversation it follows when it follows one. */
22
+ export interface StoredMode {
23
+ own: ConversationMode;
24
+ follows?: ChannelKey;
25
+ }
26
+
27
+ const OFF: ConversationMode = Object.freeze({
28
+ yolo: false,
29
+ longRunning: false,
30
+ });
31
+
32
+ /**
33
+ * A conversation's modes as they apply now: its own, or, for one that follows another, the
34
+ * origin's own modes with `inheritedFrom`, both off when the origin is gone. One level only: an
35
+ * origin's modes are its own, whatever it follows. Without `origins` an origin counts as alive.
36
+ */
37
+ export function resolveMode(
38
+ channel: ChannelKey,
39
+ stored: (channel: ChannelKey) => StoredMode | undefined,
40
+ origins: ModeOrigins | undefined,
41
+ ): ConversationMode {
42
+ const mine = stored(channel);
43
+ if (!mine) return OFF;
44
+ const follows = mine.follows;
45
+ if (follows === undefined) return mine.own;
46
+ const origin = origins
47
+ ? origins.originOf(channel, follows)
48
+ : { name: undefined, alive: true };
49
+ if (!origin) return mine.own;
50
+ const from = origin.alive ? (stored(follows)?.own ?? OFF) : OFF;
51
+ return Object.freeze({
52
+ yolo: from.yolo,
53
+ longRunning: from.longRunning,
54
+ ...(from.changedBy === undefined ? {} : { changedBy: from.changedBy }),
55
+ ...(from.changedAt === undefined ? {} : { changedAt: from.changedAt }),
56
+ inheritedFrom: {
57
+ channel: follows,
58
+ ...(origin.name === undefined ? {} : { name: origin.name }),
59
+ ...(origin.alive ? {} : { gone: true as const }),
60
+ },
61
+ });
62
+ }
63
+
64
+ /** The conversations that follow `origin` now, from every stored one. */
65
+ export function followersOf(
66
+ origin: ChannelKey,
67
+ all: Iterable<[ChannelKey, StoredMode]>,
68
+ origins: ModeOrigins | undefined,
69
+ ): ChannelKey[] {
70
+ const found: ChannelKey[] = [];
71
+ for (const [channel, mode] of all)
72
+ if (
73
+ mode.follows === origin &&
74
+ (!origins || origins.originOf(channel, origin) !== undefined)
75
+ )
76
+ found.push(channel);
77
+ return found;
78
+ }
@@ -13,7 +13,7 @@ export const MODES_PLUGIN = "modes";
13
13
  export function modesPlugin(): RoundtablePlugin {
14
14
  return {
15
15
  name: MODES_PLUGIN,
16
- migrations: [PgConversationModes.migration],
16
+ migrations: [...PgConversationModes.migrations],
17
17
  provides: [MODES],
18
18
  setup: ({ database, logger, services }) => {
19
19
  const modes = PgConversationModes.attach(database(), logger);
@@ -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 {
@@ -41,8 +42,20 @@ export interface SkillRegistry {
41
42
  ): Promise<SkillDocument>;
42
43
  /** Makes a skill from a whole SKILL.md, as `skill_create` does; it is attached to no one. Throws AgentError for a taken or refused name or text. */
43
44
  createDocument(text: string, by: string): Promise<SkillDocument>;
45
+ /** Deletes an agent-written skill and detaches it from every agent and group, as `skill_delete` does; returns what it was detached from. Throws AgentError for any other skill. */
46
+ delete(name: string): Promise<string>;
44
47
  /** The saved versions of an agent-written skill, newest first; none for any other skill. */
45
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>;
46
59
  /** Adds and removes an agent's registered skills together, or changes nothing; returns what it carries after. */
47
60
  attach(
48
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
+ }