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