pi-roundtable 0.26.3 → 0.27.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/README.md +23 -0
  3. package/README.zh-TW.md +31 -8
  4. package/docs/plugins.md +176 -7
  5. package/package.json +1 -1
  6. package/src/core/agents/agent-book.ts +34 -1
  7. package/src/core/agents/agent-claim.ts +25 -5
  8. package/src/core/agents/agent-dashboard.ts +23 -2
  9. package/src/core/agents/agent-messages.ts +28 -10
  10. package/src/core/agents/agent-ports.ts +10 -0
  11. package/src/core/agents/agent-prompt.ts +17 -1
  12. package/src/core/agents/agent-rules.ts +11 -1
  13. package/src/core/agents/agent-schema.ts +22 -0
  14. package/src/core/agents/agent-statements.ts +16 -2
  15. package/src/core/agents/agent-store.ts +30 -20
  16. package/src/core/agents/agent-team-fake-runtime.ts +48 -0
  17. package/src/core/agents/agent-team-fixture.ts +32 -44
  18. package/src/core/agents/agent-team.ts +69 -74
  19. package/src/core/agents/agent-tool-schema.ts +84 -0
  20. package/src/core/agents/agent-tools.ts +50 -85
  21. package/src/core/agents/group-turns.ts +132 -41
  22. package/src/core/agents/helper-create.ts +180 -0
  23. package/src/core/agents/helper-ops.ts +33 -0
  24. package/src/core/agents/helper-test-kit.ts +72 -0
  25. package/src/core/agents/helper-tools.ts +104 -0
  26. package/src/core/agents/helper-work.ts +101 -0
  27. package/src/core/agents/message-target.ts +15 -23
  28. package/src/core/agents/routes.ts +347 -0
  29. package/src/core/agents/team-approval.ts +32 -0
  30. package/src/core/agents/team-editing.ts +97 -32
  31. package/src/core/agents/team-helpers.ts +216 -0
  32. package/src/core/agents/team-lifecycle.ts +26 -1
  33. package/src/core/agents/team-options.ts +8 -0
  34. package/src/core/agents/team-prompt.ts +75 -0
  35. package/src/core/agents/team-selection.ts +31 -13
  36. package/src/core/agents/team-status.ts +6 -0
  37. package/src/core/agents/team-text.ts +20 -4
  38. package/src/core/agents/team-turn-types.ts +12 -1
  39. package/src/core/agents/team-turns.ts +25 -31
  40. package/src/core/builtin/agent-server.ts +37 -2
  41. package/src/core/builtin/modules.ts +4 -4
  42. package/src/core/config/adapters.ts +12 -0
  43. package/src/core/config/agent-team.ts +2 -0
  44. package/src/core/config/config-schema.ts +4 -0
  45. package/src/core/config/config.ts +48 -2
  46. package/src/core/config/helpers.ts +60 -0
  47. package/src/core/config/ops.ts +8 -0
  48. package/src/core/config/routing.ts +296 -0
  49. package/src/core/contract/channels.ts +73 -2
  50. package/src/core/contract/surface.ts +31 -1
  51. package/src/core/define-roundtable.ts +8 -1
  52. package/src/core/discord/agent-discord.ts +67 -6
  53. package/src/core/discord/discord-surface.ts +24 -24
  54. package/src/core/discord/message-changes.ts +66 -0
  55. package/src/core/discord/poster/poster-checks.ts +62 -8
  56. package/src/core/discord/poster/poster.ts +41 -0
  57. package/src/core/discord/poster/rest-poster-read.ts +130 -0
  58. package/src/core/discord/poster/rest-poster.ts +38 -120
  59. package/src/core/domain/round.ts +31 -0
  60. package/src/core/host-registration.ts +15 -0
  61. package/src/core/host.ts +6 -0
  62. package/src/core/i18n/conversation.ts +3 -0
  63. package/src/core/i18n/dashboard.ts +4 -0
  64. package/src/core/ops/error-reporter.ts +4 -0
  65. package/src/core/ops/ops-alerts.ts +102 -6
  66. package/src/core/plugin.ts +17 -0
  67. package/src/core/registry/contributions.ts +1 -26
  68. package/src/core/registry/surface-service.ts +41 -0
  69. package/src/core/routing/channel-router.ts +54 -3
  70. package/src/core/routing/claim-events.ts +42 -0
  71. package/src/core/routing/message-observers.ts +86 -0
  72. package/src/core/routing/peer-bots.ts +149 -0
  73. package/src/core/routing/surface-port.ts +4 -0
  74. package/src/core/services.ts +7 -6
  75. package/src/core/testing/fake-poster-guild.ts +1 -1
  76. package/src/core/testing/fake-poster-reads.ts +96 -0
  77. package/src/core/testing/fake-poster.ts +62 -94
  78. package/src/core/tool-set.snapshot.json +8 -0
  79. package/src/discord/index.ts +2 -0
  80. package/src/index.ts +18 -1
  81. package/src/kit/channels.ts +1 -0
  82. package/src/kit/index.ts +5 -0
  83. package/src/kit/ops-alerts.ts +4 -0
  84. package/src/testing.ts +18 -1
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Where a group's round stands, for a surface that shows it: who speaks now, who is next and who
3
+ * has spoken. A round is the members' answers to one owner message, one after another; this is what
4
+ * the core tells a surface at each step, and a surface that shows no rounds never hears it.
5
+ */
6
+ export type RoundEnd =
7
+ /** Every member in the order has spoken. */
8
+ | "done"
9
+ /** The round reached the most replies a round holds, with members still waiting. */
10
+ | "limit"
11
+ /** The owner pressed Stop: the running turn ended and the members waiting did not speak. */
12
+ | "stopped";
13
+
14
+ export interface RoundProgress {
15
+ /** The group message id of the line that opened the round; one round has one id. */
16
+ id: number;
17
+ state: "running" | "ended";
18
+ /** The member speaking now, by name. */
19
+ current?: string;
20
+ /** The members still to speak, in order, a handoff appended. */
21
+ next: string[];
22
+ /** The members who have replied so far, in order. */
23
+ spoken: string[];
24
+ /** The replies made so far, and the most a round holds. */
25
+ replies: number;
26
+ limit: number;
27
+ /** Why a round that has ended ended. */
28
+ ended?: RoundEnd;
29
+ /** The owner's messages waiting in the channel for the next round. */
30
+ queued?: number;
31
+ }
@@ -39,6 +39,8 @@ import {
39
39
  type ConversationTurns,
40
40
  conversationTurns,
41
41
  } from "./routing/conversation-turns.ts";
42
+ import { MessageObservers } from "./routing/message-observers.ts";
43
+ import { PeerBots } from "./routing/peer-bots.ts";
42
44
  import { surfacePort } from "./routing/surface-port.ts";
43
45
  import { refuseHostAccess } from "./runtime/chat-only.ts";
44
46
  import { CONVERSATIONS, IDENTITY, RUNTIME } from "./services.ts";
@@ -85,6 +87,8 @@ export interface Linked {
85
87
  */
86
88
  export class Linkage {
87
89
  readonly queue = new ChannelQueue();
90
+ /** The plugins' passive message observers, shared by the router and `context.observe`. */
91
+ readonly observers: MessageObservers;
88
92
  readonly events: EventBus;
89
93
  readonly tiers: ToolTierTable;
90
94
  registry: Registry = emptyRegistry();
@@ -113,6 +117,9 @@ export class Linkage {
113
117
  this.#plugins = plugins;
114
118
  this.tiers = options.toolTiers ?? toolTiers();
115
119
  this.events = new EventBus(options.logger);
120
+ this.observers = new MessageObservers(options.logger, () =>
121
+ this.#surfaces().roomSurfaces(),
122
+ );
116
123
  }
117
124
 
118
125
  /** Applies the host's environment to the process and returns what plugins read of it. */
@@ -206,6 +213,7 @@ export class Linkage {
206
213
  );
207
214
  return this.registry.dashboard;
208
215
  },
216
+ observe: (filter, observer) => this.observers.add(filter, observer),
209
217
  apiKey: async (provider) => this.#options.apiKey?.(provider),
210
218
  listener: (id) => listenerAddress(listeners, id),
211
219
  },
@@ -226,6 +234,10 @@ export class Linkage {
226
234
  queue: this.queue,
227
235
  surfaces: this.#surfaces(),
228
236
  logger,
237
+ observers: this.observers,
238
+ ...(this.#options.peerBots
239
+ ? { peerBots: new PeerBots(this.#options.peerBots) }
240
+ : {}),
229
241
  ...(this.#options.conversations?.forwardJoinMs === undefined
230
242
  ? {}
231
243
  : { forwardJoinMs: this.#options.conversations.forwardJoinMs }),
@@ -259,6 +271,9 @@ export class Linkage {
259
271
  };
260
272
  return {
261
273
  handle: (message) => router().handle(message),
274
+ messageDeleted: (channel, id, space) =>
275
+ router().messageDeleted(channel, id, space),
276
+ messageEdited: (message) => router().messageEdited(message),
262
277
  background: (turn) => router().background(turn),
263
278
  runsAs: (turn) => router().runsAs(turn),
264
279
  target: (name) => router().target(name),
package/src/core/host.ts CHANGED
@@ -12,6 +12,7 @@ import type { Locale } from "./i18n/index.ts";
12
12
  import type { JudgeModel } from "./judging/model-judge.ts";
13
13
  import type { Logger } from "./log.ts";
14
14
  import type { AgentTools, RoundtablePlugin, Service } from "./plugin.ts";
15
+ import type { PeerBotsConfig } from "./routing/peer-bots.ts";
15
16
  import { RUNTIME } from "./services.ts";
16
17
  import type { ChannelKey } from "./sessions.ts";
17
18
  import type { ToolTierTable } from "./tool-tiers.ts";
@@ -50,6 +51,11 @@ export interface RoundtableOptions {
50
51
  conversations?: {
51
52
  forwardJoinMs?: number;
52
53
  };
54
+ /**
55
+ * The other bots on Discord whose mentions and replies are answered like a guest's (what
56
+ * `discord.peerBots` sets); without it no bot is. Stops the start for an id that is not a Discord id.
57
+ */
58
+ peerBots?: PeerBotsConfig;
53
59
  /** The model the default judge asks, when no plugin provides a judge. */
54
60
  judgeModel?: JudgeModel;
55
61
  /**
@@ -16,6 +16,8 @@ export function conversationEn(ctx: CatalogContext) {
16
16
  restartingNotice: `-# ${ctx.assistant} is restarting and takes no new messages right now; please send this again in a few minutes.`,
17
17
  roundLimitNotice:
18
18
  "-# This round already has 8 replies, so it stops here; call us again if you need more.",
19
+ roundStoppedNotice:
20
+ "-# The round was stopped, so the members still waiting did not speak.",
19
21
  restartNotice: `-# ${ctx.assistant} restarted before this job finished, so nothing more will be reported here.`,
20
22
  threadStarted: (name: string, thread: string | undefined) =>
21
23
  thread ? `🧵 Started: ${name} → ${thread}` : `🧵 Started: ${name}`,
@@ -48,6 +50,7 @@ export function conversationZhTW(
48
50
  restartingNotice: `-# ${ctx.assistant} 正在重新啟動,暫時不接新訊息;請過幾分鐘再傳一次。`,
49
51
  roundLimitNotice:
50
52
  "-# 這一輪已經有 8 則回覆,先停在這裡;需要的話請再叫我們。",
53
+ roundStoppedNotice: "-# 本輪已停止,還沒輪到的成員不再發言。",
51
54
  restartNotice: `-# ${ctx.assistant} 在這項工作結束前重新啟動了,這裡不會再有回報。`,
52
55
  threadStarted: (name: string, thread: string | undefined) =>
53
56
  thread ? `🧵 開始:${name} → ${thread}` : `🧵 開始:${name}`,
@@ -20,6 +20,8 @@ export function dashboardEn(ctx: CatalogContext) {
20
20
  dashNotActive: "No activity since startup",
21
21
  dashAgentFooter: (schedules: number, last: string) =>
22
22
  `-# Schedules ${schedules} · ${last}`,
23
+ dashHelpers: (count: number, working: number) =>
24
+ `-# ${count} temp knight${count === 1 ? "" : "s"}, ${working} working`,
23
25
  dashGroupBusy: (busy: number) => `🟠 ${busy} in progress`,
24
26
  dashGroupMembers: (members: readonly string[], host: string) =>
25
27
  `Members: ${members.join(", ")} | Host: ${host}`,
@@ -56,6 +58,8 @@ export function dashboardZhTW(
56
58
  dashNotActive: "啟動後尚未活動",
57
59
  dashAgentFooter: (schedules: number, last: string) =>
58
60
  `-# 排程 ${schedules} · ${last}`,
61
+ dashHelpers: (count: number, working: number) =>
62
+ `-# 臨時騎士 ${count} 位,${working} 位工作中`,
59
63
  dashGroupBusy: (busy: number) => `🟠 進行中(${busy} 則)`,
60
64
  dashGroupMembers: (members: readonly string[], host: string) =>
61
65
  `成員:${members.join("、")}|主持:${host}`,
@@ -46,6 +46,8 @@ export interface ErrorReporterOptions {
46
46
  destination: ErrorReportDestination;
47
47
  /** The name the reports give the process, such as "Roundtable". */
48
48
  app: string;
49
+ /** The configuration key the destination was written under, as startup errors name it; default `ops.conversation`. */
50
+ configKey?: string;
49
51
  ignored?: readonly RegExp[];
50
52
  /** Replaceable in tests. */
51
53
  now?: () => number;
@@ -62,6 +64,7 @@ export interface ErrorReporterOptions {
62
64
  */
63
65
  export class ErrorReporter {
64
66
  readonly destination: ErrorReportDestination;
67
+ readonly configKey: string;
65
68
  readonly #app: string;
66
69
  readonly #ignored: readonly RegExp[];
67
70
  readonly #now: () => number;
@@ -80,6 +83,7 @@ export class ErrorReporter {
80
83
 
81
84
  constructor(options: ErrorReporterOptions) {
82
85
  this.destination = options.destination;
86
+ this.configKey = options.configKey ?? "ops.conversation";
83
87
  this.#app = options.app;
84
88
  this.#ignored = options.ignored ?? IGNORED_ERRORS;
85
89
  this.#now = options.now ?? Date.now;
@@ -4,14 +4,51 @@ import { AlertThrottle } from "./alert-throttle.ts";
4
4
  const DEFAULT_WINDOW_MS = 60 * 60_000;
5
5
  const DEFAULT_KIND_CAP = 8;
6
6
 
7
+ /** How serious a raised alert says it is; only the sink reads it, to choose a picture or a route. */
8
+ export type OpsSeverity = "info" | "warning" | "critical";
9
+
10
+ /** What the sink is told about an alert besides its text. */
11
+ export interface OpsAlert {
12
+ /** The key's word before the first colon. */
13
+ kind: string;
14
+ /** The key the alert was raised under; `OpsAlerts.resolve` takes it. */
15
+ key: string;
16
+ /** The short code given to `raise`, when there was one. */
17
+ code?: string;
18
+ /** Set when the alert is news, not a fault. */
19
+ info?: true;
20
+ /** The severity given to `raise`, when there was one. */
21
+ severity?: OpsSeverity;
22
+ }
23
+
24
+ /** What the sink is told when an alert's fault is over. */
25
+ export interface OpsResolution {
26
+ kind: string;
27
+ key: string;
28
+ }
29
+
30
+ /**
31
+ * Posts one alert's text where the people who run the host read it. Resolves true when it was
32
+ * posted and false when the sink chose not to (it logs why itself); a throw is a failed post. A sink
33
+ * written to take only the text still fits: the second argument is simply not read.
34
+ */
35
+ export interface OpsSink {
36
+ (text: string, alert: OpsAlert): Promise<boolean>;
37
+ /**
38
+ * Called when `OpsAlerts.resolve` ends a fault whose alert was posted, so the sink can mark that
39
+ * alert as recovered, such as editing its message. A throw is logged by its name and never
40
+ * reaches the caller of `resolve`.
41
+ */
42
+ resolved?(event: OpsResolution): Promise<void> | void;
43
+ }
44
+
7
45
  export interface OpsAlertsOptions {
8
46
  /**
9
- * Posts one alert's text where the people who run the host read it. Resolves true when it was
10
- * posted and false when the sink chose not to (it logs why itself); a throw is a failed post.
11
- * Either way the key and the slot are given back, so the next raise tries again. Absent: alerts are
12
- * only recorded and logged, the key still held so the log has one line per window.
47
+ * Where alerts are posted. Either way a post ends, the key and the slot are given back when it
48
+ * did not succeed, so the next raise tries again. Absent: alerts are only recorded and logged,
49
+ * the key still held so the log has one line per window.
13
50
  */
14
- sink?: (text: string) => Promise<boolean>;
51
+ sink?: OpsSink;
15
52
  logger: Logger;
16
53
  /** What `faults()` calls each kind; a kind not listed is called by itself. */
17
54
  kinds?: Readonly<Record<string, string>>;
@@ -27,6 +64,8 @@ export interface OpsRaiseOptions {
27
64
  code?: string;
28
65
  /** News, not a fault: posted at most once per key, not counted against the kind's cap, not listed. */
29
66
  info?: boolean;
67
+ /** Passed to the sink with the text; nothing in `OpsAlerts` reads it. */
68
+ severity?: OpsSeverity;
30
69
  }
31
70
 
32
71
  /** The last fault of one kind since the start, and how often it came. */
@@ -39,8 +78,17 @@ export interface OpsAlertKind {
39
78
  raised: number;
40
79
  /** Of those, held back because the key or the kind had been raised inside the window. */
41
80
  suppressed: number;
81
+ /**
82
+ * When `resolve` ended the kind's last open alert, and no fault of the kind has been raised
83
+ * since; absent while the kind has an alert posted and not resolved, or none was ever
84
+ * resolved.
85
+ */
86
+ clearedAt?: number;
42
87
  }
43
88
 
89
+ /** The most posted alerts `resolve` remembers; the oldest is forgotten past it. */
90
+ const POSTED_CAP = 1_000;
91
+
44
92
  /** The kind of an alert is the word before the first colon of its key. */
45
93
  function kindOf(key: string): string {
46
94
  const colon = key.indexOf(":");
@@ -58,6 +106,8 @@ export class OpsAlerts {
58
106
  readonly #options: OpsAlertsOptions;
59
107
  readonly #throttle: AlertThrottle;
60
108
  readonly #kinds = new Map<string, OpsAlertKind>();
109
+ /** The faults posted and not resolved, oldest first, by key. */
110
+ readonly #posted = new Map<string, string>();
61
111
 
62
112
  constructor(options: OpsAlertsOptions) {
63
113
  this.#options = options;
@@ -78,6 +128,33 @@ export class OpsAlerts {
78
128
  return [...this.#kinds.values()].sort((a, b) => b.at - a.at);
79
129
  }
80
130
 
131
+ /**
132
+ * Says the fault of a key is over: gives the key back, so a new fault under it posts again, calls
133
+ * the sink's `resolved` when the key's alert was posted, and marks the kind in `faults()` as
134
+ * cleared once none of its alerts is open. Resolving a key that was not posted, or that is
135
+ * already resolved, does nothing, and so does resolving one whose post has not finished.
136
+ * Never throws; whether it ended an open alert.
137
+ */
138
+ async resolve(key: string): Promise<boolean> {
139
+ const { sink, logger } = this.#options;
140
+ const kind = this.#posted.get(key);
141
+ if (kind === undefined) return false;
142
+ this.#posted.delete(key);
143
+ this.#throttle.release(key);
144
+ const entry = this.#kinds.get(kind);
145
+ if (entry && ![...this.#posted.values()].includes(kind))
146
+ entry.clearedAt = (this.#options.now ?? Date.now)();
147
+ try {
148
+ await sink?.resolved?.({ kind, key });
149
+ } catch (error) {
150
+ logger.warn(
151
+ { error: error instanceof Error ? error.name : "unknown" },
152
+ "an ops alert's recovery could not be posted",
153
+ );
154
+ }
155
+ return true;
156
+ }
157
+
81
158
  /** Posts the alert unless its key or its kind is held inside the window; whether it was posted. */
82
159
  async raise(
83
160
  key: string,
@@ -103,7 +180,17 @@ export class OpsAlerts {
103
180
  if (!info) this.#throttle.slot(kind);
104
181
  const undo = () => this.#throttle.release(key, info ? undefined : kind);
105
182
  try {
106
- if (await sink(text)) return true;
183
+ const sent = await sink(text, {
184
+ kind,
185
+ key,
186
+ ...(options.code ? { code: options.code } : {}),
187
+ ...(info ? { info: true as const } : {}),
188
+ ...(options.severity ? { severity: options.severity } : {}),
189
+ });
190
+ if (sent) {
191
+ if (!info) this.#open(key, kind);
192
+ return true;
193
+ }
107
194
  undo();
108
195
  return false;
109
196
  } catch (error) {
@@ -117,6 +204,15 @@ export class OpsAlerts {
117
204
  }
118
205
  }
119
206
 
207
+ #open(key: string, kind: string): void {
208
+ this.#posted.delete(key);
209
+ this.#posted.set(key, kind);
210
+ for (const old of this.#posted.keys()) {
211
+ if (this.#posted.size <= POSTED_CAP) break;
212
+ this.#posted.delete(old);
213
+ }
214
+ }
215
+
120
216
  #record(kind: string, code: string | undefined, suppressed: boolean): void {
121
217
  const before = this.#kinds.get(kind);
122
218
  const { kinds, now } = this.#options;
@@ -6,6 +6,8 @@ import type {
6
6
  BackgroundTarget,
7
7
  ChannelClaim,
8
8
  ConversationPort,
9
+ MessageObserver,
10
+ ObserveFilter,
9
11
  QueuePort,
10
12
  } from "./contract/channels.ts";
11
13
  import type { Providers, ResolvedProviders } from "./contract/providers.ts";
@@ -335,6 +337,21 @@ export interface PluginContext {
335
337
  events: EventSink;
336
338
  /** The claimed channels' conversations; calls during setup throw NotLinkedError. */
337
339
  conversations: ConversationPort;
340
+ /**
341
+ * Hears every inbound message the filter matches, whichever claim owns its channel, without
342
+ * admitting, blocking or delaying it: the observer runs after routing has begun and cannot
343
+ * change the message the claims get. It hears what the router considers: people's messages,
344
+ * those of a bot the host lists as a peer, and an integration's (such as a webhook's) with
345
+ * `integration` set; never an unlisted bot's, never the assistant's own, and never a message
346
+ * in a channel no claim owns. It does not hear direct conversations unless the filter says
347
+ * `includeDirect`, and not the rooms of a surface that hosts agent rooms unless the filter
348
+ * names that surface. The message is a copy without a `speaker`, as no one is resolved for an
349
+ * observer. Observers see untrusted text from other people: keep it out of logs, hold it no
350
+ * longer than needed, and treat it as private to the people who wrote it. A throw or rejection
351
+ * is logged and never reaches routing or another observer. Returns a function that stops it.
352
+ * May be called during setup.
353
+ */
354
+ observe(filter: ObserveFilter, observer: MessageObserver): () => void;
338
355
  /**
339
356
  * Files a plugin takes from a person outside a turn, such as an upload, kept for a conversation
340
357
  * until a turn uses them; a call throws PluginError when the host has no `dataDir`.
@@ -34,6 +34,7 @@ import {
34
34
  import { TIERS } from "../speakers.ts";
35
35
  import type { ToolTierTable } from "../tool-tiers.ts";
36
36
  import { ServiceRegistry } from "./services.ts";
37
+ import { surfaceService } from "./surface-service.ts";
37
38
 
38
39
  /** Everything the plugins contributed, in contribution order. */
39
40
  export interface Registry {
@@ -273,32 +274,6 @@ function checkSurfacePrefix(plugin: string, prefix: unknown): void {
273
274
  );
274
275
  }
275
276
 
276
- /**
277
- * The surface as a service: it starts with the router's `handle` as its delivery, which drops
278
- * and logs a message that belongs to another prefix, and stops with the surface.
279
- */
280
- function surfaceService(
281
- surface: ChatSurface,
282
- context: Pick<PluginContext, "logger" | "conversations">,
283
- ): Service {
284
- const { logger, conversations } = context;
285
- return {
286
- name: `surface:${surface.surface}`,
287
- start: () =>
288
- surface.start((message) => {
289
- if (!message.channel.startsWith(`${surface.surface}:`)) {
290
- logger.error(
291
- { surface: surface.surface, channel: message.channel },
292
- "a surface delivered a message of another prefix; dropped",
293
- );
294
- return;
295
- }
296
- void conversations.handle(message);
297
- }),
298
- ...(surface.stop ? { stop: () => surface.stop?.() } : {}),
299
- };
300
- }
301
-
302
277
  /**
303
278
  * Sets up every plugin in order and collects what each adds. A plugin that adds nothing, that
304
279
  * reuses a name, or whose setup throws or returns a part the contract does not have is refused
@@ -0,0 +1,41 @@
1
+ import type { ChatSurface } from "../contract/surface.ts";
2
+ import type { PluginContext, Service } from "../plugin.ts";
3
+
4
+ /**
5
+ * The surface as a service: it starts with the router's `handle` as its delivery, which drops
6
+ * and logs a message that belongs to another prefix, and stops with the surface. It also passes the surface the router's
7
+ * delete and edit events for its own channels.
8
+ */
9
+ export function surfaceService(
10
+ surface: ChatSurface,
11
+ context: Pick<PluginContext, "logger" | "conversations">,
12
+ ): Service {
13
+ const { logger, conversations } = context;
14
+ return {
15
+ name: `surface:${surface.surface}`,
16
+ start: () =>
17
+ surface.start(
18
+ (message) => {
19
+ if (!message.channel.startsWith(`${surface.surface}:`)) {
20
+ logger.error(
21
+ { surface: surface.surface, channel: message.channel },
22
+ "a surface delivered a message of another prefix; dropped",
23
+ );
24
+ return;
25
+ }
26
+ void conversations.handle(message);
27
+ },
28
+ {
29
+ deleted: (channel, messageId, space) => {
30
+ if (!channel.startsWith(`${surface.surface}:`)) return;
31
+ void conversations.messageDeleted?.(channel, messageId, space);
32
+ },
33
+ edited: (message) => {
34
+ if (!message.channel.startsWith(`${surface.surface}:`)) return;
35
+ void conversations.messageEdited?.(message);
36
+ },
37
+ },
38
+ ),
39
+ ...(surface.stop ? { stop: () => surface.stop?.() } : {}),
40
+ };
41
+ }
@@ -24,7 +24,10 @@ import { SYSTEM_PRINCIPAL } from "../identity/principal-store.ts";
24
24
  import type { Logger } from "../log.ts";
25
25
  import type { ChannelKey } from "../sessions.ts";
26
26
  import type { ChannelQueue } from "./channel-queue.ts";
27
+ import { tellDeleted, tellEdited } from "./claim-events.ts";
27
28
  import { ForwardJoin } from "./forward-join.ts";
29
+ import type { MessageObservers } from "./message-observers.ts";
30
+ import { type PeerBots, peerFacts } from "./peer-bots.ts";
28
31
  import { isSystemTurn } from "./system-turns.ts";
29
32
 
30
33
  export interface ChannelRouterOptions {
@@ -45,6 +48,10 @@ export interface ChannelRouterOptions {
45
48
  surfaces?: Pick<SurfacePort, "of"> & Partial<Pick<SurfacePort, "sendReply">>;
46
49
  /** How long a bare forward waits for the text sent with it; FORWARD_JOIN_MS by default. */
47
50
  forwardJoinMs?: number;
51
+ /** The other bots whose messages are answered like a guest's, with the rounds they get; without it, no bot's. */
52
+ peerBots?: PeerBots;
53
+ /** The plugins' passive listeners; each message that reaches routing in an owned channel is handed to them. */
54
+ observers?: MessageObservers;
48
55
  }
49
56
 
50
57
  /** Claims by descending priority, keeping registration order among equals; refuses a repeated name. */
@@ -101,7 +108,16 @@ export class ChannelRouter implements ConversationPort {
101
108
  return this.#forwards.handle(message, (m) => this.#route(m));
102
109
  }
103
110
 
104
- async #route(message: InboundMessage): Promise<void> {
111
+ async #route(given: InboundMessage): Promise<void> {
112
+ // Only the router says a message is a peer bot's or who its speaker is.
113
+ const { peerBot: _peer, speaker: _speaker, ...plain } = given;
114
+ const peers = this.#options.peerBots;
115
+ if (peers && !plain.authorIsBot && !plain.integration)
116
+ peers.personPosted(plain.channel);
117
+ const listed = peers?.lists(plain) === true;
118
+ const message: InboundMessage = listed
119
+ ? { ...peerFacts(plain), peerBot: true }
120
+ : plain;
105
121
  if (
106
122
  !message.text.trim() &&
107
123
  message.attachments.length === 0 &&
@@ -110,9 +126,20 @@ export class ChannelRouter implements ConversationPort {
110
126
  return;
111
127
  const claim = this.#owner(message.channel, message.space);
112
128
  if (!claim) return;
129
+ // A bot nobody lists is not heard, as it is not answered; a listed peer is, rounds or not.
130
+ if (!message.authorIsBot || message.peerBot || message.integration)
131
+ this.#options.observers?.notify(message);
113
132
  const started = await this.#inOrder(message.channel, async () => {
133
+ if (listed && peers?.exhausted(message.channel)) {
134
+ this.#options.logger.info(
135
+ { channel: message.channel, bot: message.authorId },
136
+ "peer bot message ignored: its rounds in this channel are used up until a person posts",
137
+ );
138
+ return undefined;
139
+ }
114
140
  const admission = await this.#admit(claim, message);
115
141
  if (!admission) return undefined;
142
+ if (listed) peers?.answered(message.channel);
116
143
  // Once the host shuts down only the turns already running finish: this one starts nothing.
117
144
  if (this.#options.queue.closed) {
118
145
  await this.#refuse(message, admission);
@@ -197,10 +224,11 @@ export class ChannelRouter implements ConversationPort {
197
224
  /**
198
225
  * Who wrote the message, as its surface reports them; a surface that reports no `actor` has it
199
226
  * read from the author fields under its own name, once warned. None for a bot or an
200
- * integration.
227
+ * integration, unless the host lists the bot as a peer.
201
228
  */
202
229
  #actorOf(message: InboundMessage): ActorFacts | undefined {
203
- if (message.authorIsBot || message.integration) return undefined;
230
+ if ((message.authorIsBot && !message.peerBot) || message.integration)
231
+ return undefined;
204
232
  if (message.actor) return message.actor;
205
233
  const { surface } = parseChannelKey(message.channel);
206
234
  if (!this.#warned.has(surface)) {
@@ -297,6 +325,29 @@ export class ChannelRouter implements ConversationPort {
297
325
  return { done };
298
326
  }
299
327
 
328
+ /** Tells the claim that owns the channel a message was deleted; never rejects, never waits for the channel's queue. */
329
+ messageDeleted(
330
+ channel: ChannelKey,
331
+ messageId: string,
332
+ space?: string,
333
+ ): Promise<void> {
334
+ return tellDeleted(
335
+ this.#owner(channel, space),
336
+ channel,
337
+ messageId,
338
+ this.#options.logger,
339
+ );
340
+ }
341
+
342
+ /** Tells the claim that owns the channel a message was edited; never rejects, never waits for the channel's queue. */
343
+ messageEdited(message: InboundMessage): Promise<void> {
344
+ return tellEdited(
345
+ this.#owner(message.channel, message.space),
346
+ message,
347
+ this.#options.logger,
348
+ );
349
+ }
350
+
300
351
  target(name: string): BackgroundTarget | undefined {
301
352
  return this.#options.targets(name);
302
353
  }
@@ -0,0 +1,42 @@
1
+ import type { ChannelClaim, InboundMessage } from "../contract/channels.ts";
2
+ import type { Logger } from "../log.ts";
3
+ import type { ChannelKey } from "../sessions.ts";
4
+
5
+ /** Tells the claim a message in its channel was deleted; a claim without the hook, or none, is skipped. Never rejects. */
6
+ export async function tellDeleted(
7
+ claim: ChannelClaim | undefined,
8
+ channel: ChannelKey,
9
+ messageId: string,
10
+ logger: Logger,
11
+ ): Promise<void> {
12
+ if (!claim?.onMessageDeleted) return;
13
+ try {
14
+ await claim.onMessageDeleted(channel, messageId);
15
+ } catch (error) {
16
+ logger.warn(
17
+ { channel, claim: claim.name, err: error },
18
+ "a claim failed to handle a deleted message",
19
+ );
20
+ }
21
+ }
22
+
23
+ /**
24
+ * Tells the claim a message in its channel was edited. Nobody is resolved or admitted for an edit,
25
+ * so what only the router sets (`speaker`, `peerBot`) is dropped. Never rejects.
26
+ */
27
+ export async function tellEdited(
28
+ claim: ChannelClaim | undefined,
29
+ given: InboundMessage,
30
+ logger: Logger,
31
+ ): Promise<void> {
32
+ if (!claim?.onMessageEdited) return;
33
+ const { peerBot: _peer, speaker: _speaker, ...message } = given;
34
+ try {
35
+ await claim.onMessageEdited(message);
36
+ } catch (error) {
37
+ logger.warn(
38
+ { channel: message.channel, claim: claim.name, err: error },
39
+ "a claim failed to handle an edited message",
40
+ );
41
+ }
42
+ }
@@ -0,0 +1,86 @@
1
+ import type {
2
+ InboundMessage,
3
+ MessageObserver,
4
+ ObserveFilter,
5
+ } from "../contract/channels.ts";
6
+ import { parseChannelKey } from "../contract/surface.ts";
7
+ import type { Logger } from "../log.ts";
8
+
9
+ interface Registered {
10
+ filter: Readonly<ObserveFilter>;
11
+ observer: MessageObserver;
12
+ }
13
+
14
+ /**
15
+ * Whether the filter lets the message through. A direct message passes only when the filter asks
16
+ * for it, and a message of a surface that hosts private rooms only when the filter names that
17
+ * surface.
18
+ */
19
+ function matches(
20
+ filter: Readonly<ObserveFilter>,
21
+ message: InboundMessage,
22
+ rooms: ReadonlySet<string>,
23
+ ) {
24
+ if (message.isDirect && filter.includeDirect !== true) return false;
25
+ const { surface } = parseChannelKey(message.channel);
26
+ if (filter.surface !== undefined && surface !== filter.surface) return false;
27
+ if (filter.surface === undefined && rooms.has(surface)) return false;
28
+ if (filter.space !== undefined && message.space !== filter.space)
29
+ return false;
30
+ if (filter.channels && !filter.channels.includes(message.channel))
31
+ return false;
32
+ return true;
33
+ }
34
+
35
+ /**
36
+ * The plugins' passive listeners on inbound messages. `notify` hands each matching observer its
37
+ * own frozen copy on a later tick and returns at once, so an observer cannot hold up, change or
38
+ * fail the routing of the message it hears; its errors are logged by name, never with the text.
39
+ */
40
+ export class MessageObservers {
41
+ readonly #logger: Logger;
42
+ readonly #observers = new Set<Registered>();
43
+ readonly #rooms: () => readonly string[];
44
+
45
+ /**
46
+ * `rooms` names the surfaces that host private rooms (`agentRooms`); a filter that does not
47
+ * name the surface does not hear their messages. It is read for each message, since surfaces
48
+ * are linked after the observers are made.
49
+ */
50
+ constructor(logger: Logger, rooms: () => readonly string[] = () => []) {
51
+ this.#logger = logger;
52
+ this.#rooms = rooms;
53
+ }
54
+
55
+ /** Registers an observer; the returned function removes it. */
56
+ add(filter: ObserveFilter, observer: MessageObserver): () => void {
57
+ const entry = { filter: { ...filter }, observer };
58
+ this.#observers.add(entry);
59
+ return () => {
60
+ this.#observers.delete(entry);
61
+ };
62
+ }
63
+
64
+ /** Hands the message to every observer whose filter matches; never throws. */
65
+ notify(message: InboundMessage): void {
66
+ for (const { filter, observer } of this.#observers) {
67
+ // Anything that throws here, such as a value that cannot be cloned, is the observer's loss.
68
+ try {
69
+ if (!matches(filter, message, new Set(this.#rooms()))) continue;
70
+ const copy = Object.freeze(structuredClone(message));
71
+ void Promise.resolve()
72
+ .then(() => observer(copy))
73
+ .catch((error: unknown) => this.#failed(error));
74
+ } catch (error) {
75
+ this.#failed(error);
76
+ }
77
+ }
78
+ }
79
+
80
+ #failed(error: unknown): void {
81
+ this.#logger.warn(
82
+ { error: error instanceof Error ? error.name : "unknown" },
83
+ "a message observer failed",
84
+ );
85
+ }
86
+ }