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.
- package/CHANGELOG.md +48 -0
- package/README.md +23 -0
- package/README.zh-TW.md +31 -8
- package/docs/plugins.md +176 -7
- package/package.json +1 -1
- package/src/core/agents/agent-book.ts +34 -1
- package/src/core/agents/agent-claim.ts +25 -5
- package/src/core/agents/agent-dashboard.ts +23 -2
- package/src/core/agents/agent-messages.ts +28 -10
- package/src/core/agents/agent-ports.ts +10 -0
- package/src/core/agents/agent-prompt.ts +17 -1
- package/src/core/agents/agent-rules.ts +11 -1
- package/src/core/agents/agent-schema.ts +22 -0
- package/src/core/agents/agent-statements.ts +16 -2
- package/src/core/agents/agent-store.ts +30 -20
- package/src/core/agents/agent-team-fake-runtime.ts +48 -0
- package/src/core/agents/agent-team-fixture.ts +32 -44
- package/src/core/agents/agent-team.ts +69 -74
- package/src/core/agents/agent-tool-schema.ts +84 -0
- package/src/core/agents/agent-tools.ts +50 -85
- package/src/core/agents/group-turns.ts +132 -41
- package/src/core/agents/helper-create.ts +180 -0
- package/src/core/agents/helper-ops.ts +33 -0
- package/src/core/agents/helper-test-kit.ts +72 -0
- package/src/core/agents/helper-tools.ts +104 -0
- package/src/core/agents/helper-work.ts +101 -0
- package/src/core/agents/message-target.ts +15 -23
- package/src/core/agents/routes.ts +347 -0
- package/src/core/agents/team-approval.ts +32 -0
- package/src/core/agents/team-editing.ts +97 -32
- package/src/core/agents/team-helpers.ts +216 -0
- package/src/core/agents/team-lifecycle.ts +26 -1
- package/src/core/agents/team-options.ts +8 -0
- package/src/core/agents/team-prompt.ts +75 -0
- package/src/core/agents/team-selection.ts +31 -13
- package/src/core/agents/team-status.ts +6 -0
- package/src/core/agents/team-text.ts +20 -4
- package/src/core/agents/team-turn-types.ts +12 -1
- package/src/core/agents/team-turns.ts +25 -31
- package/src/core/builtin/agent-server.ts +37 -2
- package/src/core/builtin/modules.ts +4 -4
- package/src/core/config/adapters.ts +12 -0
- package/src/core/config/agent-team.ts +2 -0
- package/src/core/config/config-schema.ts +4 -0
- package/src/core/config/config.ts +48 -2
- package/src/core/config/helpers.ts +60 -0
- package/src/core/config/ops.ts +8 -0
- package/src/core/config/routing.ts +296 -0
- package/src/core/contract/channels.ts +73 -2
- package/src/core/contract/surface.ts +31 -1
- package/src/core/define-roundtable.ts +8 -1
- package/src/core/discord/agent-discord.ts +67 -6
- package/src/core/discord/discord-surface.ts +24 -24
- package/src/core/discord/message-changes.ts +66 -0
- package/src/core/discord/poster/poster-checks.ts +62 -8
- package/src/core/discord/poster/poster.ts +41 -0
- package/src/core/discord/poster/rest-poster-read.ts +130 -0
- package/src/core/discord/poster/rest-poster.ts +38 -120
- package/src/core/domain/round.ts +31 -0
- package/src/core/host-registration.ts +15 -0
- package/src/core/host.ts +6 -0
- package/src/core/i18n/conversation.ts +3 -0
- package/src/core/i18n/dashboard.ts +4 -0
- package/src/core/ops/error-reporter.ts +4 -0
- package/src/core/ops/ops-alerts.ts +102 -6
- package/src/core/plugin.ts +17 -0
- package/src/core/registry/contributions.ts +1 -26
- package/src/core/registry/surface-service.ts +41 -0
- package/src/core/routing/channel-router.ts +54 -3
- package/src/core/routing/claim-events.ts +42 -0
- package/src/core/routing/message-observers.ts +86 -0
- package/src/core/routing/peer-bots.ts +149 -0
- package/src/core/routing/surface-port.ts +4 -0
- package/src/core/services.ts +7 -6
- package/src/core/testing/fake-poster-guild.ts +1 -1
- package/src/core/testing/fake-poster-reads.ts +96 -0
- package/src/core/testing/fake-poster.ts +62 -94
- package/src/core/tool-set.snapshot.json +8 -0
- package/src/discord/index.ts +2 -0
- package/src/index.ts +18 -1
- package/src/kit/channels.ts +1 -0
- package/src/kit/index.ts +5 -0
- package/src/kit/ops-alerts.ts +4 -0
- 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
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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?:
|
|
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
|
-
|
|
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;
|
package/src/core/plugin.ts
CHANGED
|
@@ -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(
|
|
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)
|
|
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
|
+
}
|