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