pi-roundtable 0.7.19 → 0.9.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 +140 -0
- package/README.md +72 -15
- package/README.zh-TW.md +65 -13
- package/docs/migrating-0.8.md +132 -0
- package/docs/migration-0.9.md +247 -0
- package/docs/plugins.md +853 -81
- package/examples/channel-context.test.ts +88 -0
- package/examples/channel-context.ts +62 -0
- package/examples/discord-adapter.test.ts +42 -0
- package/examples/discord-adapter.ts +26 -0
- package/examples/echo-runtime.test.ts +1 -0
- package/examples/echo-runtime.ts +1 -1
- package/examples/fake-surface.test.ts +69 -6
- package/examples/fake-surface.ts +73 -7
- package/examples/headless.test.ts +72 -0
- package/examples/headless.ts +35 -0
- package/examples/http.test.ts +31 -1
- package/examples/http.ts +39 -0
- package/examples/prechecks.test.ts +1 -0
- package/examples/principals.test.ts +60 -0
- package/examples/principals.ts +48 -0
- package/examples/prompt.test.ts +1 -1
- package/examples/study-room.test.ts +29 -1
- package/examples/study-room.ts +16 -19
- package/examples/support-desk.test.ts +3 -1
- package/examples/tools.test.ts +6 -1
- package/package.json +7 -3
- package/src/cli/checks/database.ts +11 -0
- package/src/cli/checks/identity.ts +225 -0
- package/src/cli/checks/model.ts +21 -3
- package/src/cli/cli.ts +130 -5
- package/src/cli/config-edit.ts +83 -13
- package/src/cli/doctor.ts +28 -5
- package/src/cli/init.ts +9 -1
- package/src/cli/line-diff.ts +72 -0
- package/src/cli/principal.ts +412 -0
- package/src/cli/project.ts +33 -2
- package/src/cli/report.ts +12 -3
- package/src/cli/templates.ts +46 -14
- package/src/cli/upgrade-rewrite.ts +102 -0
- package/src/cli/upgrade-source.ts +430 -0
- package/src/cli/upgrade.ts +176 -0
- package/src/core/agents/agent-claim.ts +56 -34
- package/src/core/agents/agent-team-fixture.ts +6 -0
- package/src/core/agents/team-turns.ts +10 -1
- package/src/core/builtin/agent-server.ts +38 -101
- package/src/core/builtin/discord-admin.ts +4 -1
- package/src/core/builtin/discord.ts +71 -13
- package/src/core/builtin/modules.ts +197 -43
- package/src/core/builtin/stores.ts +40 -21
- package/src/core/config/access.ts +229 -0
- package/src/core/config/adapters.ts +120 -0
- package/src/core/config/config.ts +228 -94
- package/src/core/contract/channel-context.ts +53 -0
- package/src/core/contract/channels.ts +81 -9
- package/src/core/contract/providers.ts +3 -2
- package/src/core/contract/runtime.ts +31 -11
- package/src/core/contract/surface.ts +35 -6
- package/src/core/conversations/conversation-registry.ts +56 -0
- package/src/core/conversations/conversation-store.ts +126 -0
- package/src/core/conversations/conversations-plugin.ts +29 -0
- package/src/core/define-roundtable.ts +157 -57
- package/src/core/discord/agent-panel.ts +2 -2
- package/src/core/discord/card-audience.ts +148 -0
- package/src/core/discord/channel-context.ts +348 -0
- package/src/core/discord/connection.ts +15 -3
- package/src/core/discord/direct-channel.ts +54 -0
- package/src/core/discord/discord-adapter.ts +9 -0
- package/src/core/discord/discord-owners.ts +133 -0
- package/src/core/discord/discord-surface.ts +69 -14
- package/src/core/discord/fresh-marker.ts +24 -0
- package/src/core/discord/inbound-message.ts +21 -7
- package/src/core/discord/interaction-module.ts +11 -1
- package/src/core/discord/owner-cards-fixture.ts +80 -0
- package/src/core/discord/owner-cards.ts +58 -86
- package/src/core/discord/owner-command.ts +27 -13
- package/src/core/discord/stop-button.ts +4 -4
- package/src/core/domain/conversation.ts +10 -0
- package/src/core/domain/errors.ts +5 -0
- package/src/core/domain/ports.ts +33 -2
- package/src/core/domain/progress.ts +10 -0
- package/src/core/events.ts +1 -0
- package/src/core/host.ts +32 -10
- package/src/core/http/listeners.ts +174 -21
- package/src/core/http/websocket.ts +316 -0
- package/src/core/i18n/cards.ts +11 -9
- package/src/core/identity/access-policy.ts +227 -0
- package/src/core/identity/actor-facts.ts +65 -0
- package/src/core/identity/caching-principal-store.ts +111 -0
- package/src/core/identity/config-owner.ts +145 -0
- package/src/core/identity/contact.ts +27 -0
- package/src/core/identity/identity-plugin.ts +56 -0
- package/src/core/identity/identity-schema.ts +282 -0
- package/src/core/identity/identity-service.ts +489 -0
- package/src/core/identity/identity-view.ts +77 -0
- package/src/core/identity/plugin-identities.ts +109 -0
- package/src/core/identity/principal-reads.ts +38 -0
- package/src/core/identity/principal-store.ts +478 -0
- package/src/core/identity/principal-tables.ts +53 -0
- package/src/core/identity/seen-throttle.ts +57 -0
- package/src/core/identity/ulid.ts +36 -0
- package/src/core/identity.ts +34 -5
- package/src/core/interactions/prompts.ts +127 -0
- package/src/core/modules/background/background-turns.ts +56 -17
- package/src/core/modules/background/personal-target.ts +50 -0
- package/src/core/modules/delegation/delegate.ts +28 -14
- package/src/core/modules/delegation/delegator.ts +23 -7
- package/src/core/modules/memory/owner-memory.ts +123 -50
- package/src/core/modules/notify/notify.ts +53 -13
- package/src/core/modules/notify/session-notify.ts +110 -0
- package/src/core/modules/schedules/prechecks.ts +8 -2
- package/src/core/modules/schedules/schedule-prechecks.ts +4 -1
- package/src/core/modules/schedules/schedule-store.ts +51 -20
- package/src/core/modules/schedules/schedule-tools.ts +122 -12
- package/src/core/modules/schedules/scheduler.ts +43 -2
- package/src/core/modules/schedules/schedules.ts +43 -14
- package/src/core/ops/error-reporter.ts +24 -12
- package/src/core/plugin.ts +62 -0
- package/src/core/presence/direct-channels.ts +104 -0
- package/src/core/registry/contributions.ts +63 -3
- package/src/core/registry/providers.ts +1 -1
- package/src/core/routing/channel-router.ts +226 -17
- package/src/core/routing/conversation-turns.ts +85 -1
- package/src/core/routing/surface-port.ts +35 -2
- package/src/core/routing/system-turns.ts +29 -0
- package/src/core/runtime/bridge-guard.ts +225 -0
- package/src/core/runtime/compaction-tiers.ts +5 -1
- package/src/core/runtime/conversation-sessions.ts +124 -12
- package/src/core/runtime/extensions/ask-user.ts +1 -1
- package/src/core/runtime/extensions/confirmation-gate.ts +32 -10
- package/src/core/runtime/extensions/private-memory.ts +447 -0
- package/src/core/runtime/extensions/self-compact-guard.ts +11 -0
- package/src/core/runtime/pending-confirmation-store.ts +67 -4
- package/src/core/runtime/pi-agent-runtime.ts +143 -135
- package/src/core/runtime/preflight-tools.ts +47 -0
- package/src/core/runtime/prompt-slot.ts +6 -6
- package/src/core/runtime/reader-history.ts +129 -0
- package/src/core/runtime/runtime-plugin.ts +205 -0
- package/src/core/runtime/runtime-types.ts +40 -12
- package/src/core/runtime/session-conversation.ts +143 -0
- package/src/core/runtime/session-factory.ts +115 -15
- package/src/core/runtime/session-scope.ts +78 -0
- package/src/core/runtime/steerable-run.ts +9 -0
- package/src/core/runtime/tool-call-scope.ts +43 -0
- package/src/core/runtime/turn-answer.ts +30 -1
- package/src/core/runtime/turn-progress.ts +116 -0
- package/src/core/services.ts +42 -2
- package/src/core/sessions.ts +30 -0
- package/src/core/shared/unix-server.ts +35 -5
- package/src/core/speakers.ts +21 -19
- package/src/core/testing/background.ts +31 -0
- package/src/core/testing/fixture-database.ts +55 -0
- package/src/core/testing/map-identity.ts +76 -0
- package/src/core/testing/module-sessions.ts +134 -0
- package/src/core/testing/modules.ts +149 -38
- package/src/core/testing/owner.ts +17 -0
- package/src/core/testing/surface-contract.ts +460 -0
- package/src/core/testing/test-host.ts +100 -19
- package/src/core/testing/tool-set.ts +32 -0
- package/src/core/tool-set.snapshot.json +4 -4
- package/src/core/tool-tiers.ts +57 -3
- package/src/discord/index.ts +10 -0
- package/src/index.ts +65 -8
- package/src/kit/compaction.ts +13 -0
- package/src/kit/index.ts +10 -0
- package/src/kit/support.ts +1 -1
- package/src/testing.ts +96 -21
- package/templates/.env.example +1 -1
- package/templates/adapters/web/.env.example +24 -0
- package/templates/adapters/web/README.md +22 -0
- package/templates/adapters/web/package.json.tmpl +26 -0
- package/templates/adapters/web/persona/assistant.md +2 -0
- package/templates/adapters/web/roundtable.config.ts +59 -0
- package/templates/package.json.tmpl +1 -0
- package/templates/plugins/self-compact.ts +11 -0
- package/templates/roundtable.config.ts +14 -2
- package/src/core/domain/owner-prompts.ts +0 -45
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import type { SurfacePort } from "../contract/surface.ts";
|
|
2
|
+
import type { ChannelKey } from "../domain/conversation.ts";
|
|
3
|
+
import type { Notifier } from "../domain/ports.ts";
|
|
4
|
+
import { splitReply } from "../presentation/reply-splitter.ts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A way to reach a person on their own, such as a chat network's direct messages or a web inbox.
|
|
8
|
+
* A plugin contributes it as `directChannels`; the host reaches a person through the first one,
|
|
9
|
+
* in contribution order, that reaches them.
|
|
10
|
+
*/
|
|
11
|
+
export interface DirectChannelProvider {
|
|
12
|
+
/** Unique across every plugin, such as `"discord"`. */
|
|
13
|
+
name: string;
|
|
14
|
+
/**
|
|
15
|
+
* How the `notify` tool names a message sent through it, completing "Send Ada …": such as
|
|
16
|
+
* `"a direct message on Discord"`.
|
|
17
|
+
*/
|
|
18
|
+
label: string;
|
|
19
|
+
/**
|
|
20
|
+
* The person's own conversation here, where a notice to them is posted and where their
|
|
21
|
+
* schedules and delegated reports from a conversation no chat surface carries run; undefined
|
|
22
|
+
* when they have none here. It may reach the network and throw when that fails.
|
|
23
|
+
*/
|
|
24
|
+
reaches(principalId: string): Promise<ChannelKey | undefined>;
|
|
25
|
+
/**
|
|
26
|
+
* Whether the person has a conversation here, from the host's own records such as their linked
|
|
27
|
+
* identities, without reaching the network. A session reads it when it decides whether to offer
|
|
28
|
+
* a tool that sends or runs there, so a network outage then does not take the tool away; the
|
|
29
|
+
* conversation itself is reached when the tool runs. Without it the host asks `reaches`.
|
|
30
|
+
*/
|
|
31
|
+
knows?(principalId: string): Promise<boolean>;
|
|
32
|
+
/**
|
|
33
|
+
* Sends the person a notice here; without it the host posts the text in the channel `reaches`
|
|
34
|
+
* names, through the surface that serves it.
|
|
35
|
+
*/
|
|
36
|
+
deliver?(principalId: string, text: string): Promise<void>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Where a person was reached: the provider, and their conversation on it. */
|
|
40
|
+
export interface DirectReach {
|
|
41
|
+
provider: DirectChannelProvider;
|
|
42
|
+
channel: ChannelKey;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Every plugin's direct channels, as plugins and the core use them. Each call before the host
|
|
47
|
+
* links its plugins throws NotLinkedError, as for `conversations`.
|
|
48
|
+
*/
|
|
49
|
+
export interface DirectChannels extends Notifier {
|
|
50
|
+
/** Every contributed provider, in contribution order; empty when no plugin contributes one. */
|
|
51
|
+
providers(): readonly DirectChannelProvider[];
|
|
52
|
+
/** The person's own conversation on the first provider that reaches them; undefined when none does. */
|
|
53
|
+
reach(principalId: string): Promise<DirectReach | undefined>;
|
|
54
|
+
/**
|
|
55
|
+
* The first provider that knows the person, as `DirectChannelProvider.knows` tells without the
|
|
56
|
+
* network (or `reaches`, for a provider without it); undefined when none does.
|
|
57
|
+
*/
|
|
58
|
+
known(principalId: string): Promise<DirectChannelProvider | undefined>;
|
|
59
|
+
/**
|
|
60
|
+
* Sends the person `text` through the first provider that reaches them; false when none does.
|
|
61
|
+
* A provider's failure, such as a network error, rejects.
|
|
62
|
+
*/
|
|
63
|
+
notify(principalId: string, text: string): Promise<boolean>;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The direct channels over the providers the host linked, posting through its surfaces where a provider does not deliver itself. */
|
|
67
|
+
export function directChannelsPort(
|
|
68
|
+
providers: () => readonly DirectChannelProvider[],
|
|
69
|
+
surfaces: Pick<SurfacePort, "sendReply">,
|
|
70
|
+
): DirectChannels {
|
|
71
|
+
const reach = async (
|
|
72
|
+
principalId: string,
|
|
73
|
+
): Promise<DirectReach | undefined> => {
|
|
74
|
+
for (const provider of providers()) {
|
|
75
|
+
const channel = await provider.reaches(principalId);
|
|
76
|
+
if (channel !== undefined) return { provider, channel };
|
|
77
|
+
}
|
|
78
|
+
return undefined;
|
|
79
|
+
};
|
|
80
|
+
return {
|
|
81
|
+
providers: () => providers(),
|
|
82
|
+
reach,
|
|
83
|
+
known: async (principalId) => {
|
|
84
|
+
for (const provider of providers()) {
|
|
85
|
+
const knows = provider.knows
|
|
86
|
+
? await provider.knows(principalId)
|
|
87
|
+
: (await provider.reaches(principalId)) !== undefined;
|
|
88
|
+
if (knows) return provider;
|
|
89
|
+
}
|
|
90
|
+
return undefined;
|
|
91
|
+
},
|
|
92
|
+
notify: async (principalId, text) => {
|
|
93
|
+
const reached = await reach(principalId);
|
|
94
|
+
if (!reached) return false;
|
|
95
|
+
if (reached.provider.deliver)
|
|
96
|
+
await reached.provider.deliver(principalId, text);
|
|
97
|
+
else
|
|
98
|
+
await surfaces.sendReply(reached.channel, {
|
|
99
|
+
chunks: splitReply(text),
|
|
100
|
+
});
|
|
101
|
+
return true;
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
}
|
|
@@ -2,7 +2,7 @@ import type { AgentSeed } from "../agents/agent-store.ts";
|
|
|
2
2
|
import type { BackgroundTarget, ChannelClaim } from "../contract/channels.ts";
|
|
3
3
|
import type { ChatSurface } from "../contract/surface.ts";
|
|
4
4
|
import type { ToolContribution } from "../define.ts";
|
|
5
|
-
import { PluginError } from "../errors.ts";
|
|
5
|
+
import { NotLinkedError, PluginError } from "../errors.ts";
|
|
6
6
|
import { type HoldRule, holdChain } from "../holds.ts";
|
|
7
7
|
import type { HttpRoute } from "../http/listeners.ts";
|
|
8
8
|
import {
|
|
@@ -20,6 +20,10 @@ import {
|
|
|
20
20
|
refuseRemovedSurfaceMethods,
|
|
21
21
|
type Service,
|
|
22
22
|
} from "../plugin.ts";
|
|
23
|
+
import {
|
|
24
|
+
type DirectChannelProvider,
|
|
25
|
+
directChannelsPort,
|
|
26
|
+
} from "../presence/direct-channels.ts";
|
|
23
27
|
import {
|
|
24
28
|
compileSessionPlan,
|
|
25
29
|
type SessionTool,
|
|
@@ -44,6 +48,8 @@ export interface Registry {
|
|
|
44
48
|
surfaces: ChatSurface[];
|
|
45
49
|
personas: Persona[];
|
|
46
50
|
backgroundTargets: BackgroundTarget[];
|
|
51
|
+
/** The contributed direct channels, in contribution order. */
|
|
52
|
+
directChannels: DirectChannelProvider[];
|
|
47
53
|
dashboard: string[];
|
|
48
54
|
tools: ToolContribution[];
|
|
49
55
|
seeds: AgentSeed[];
|
|
@@ -64,6 +70,7 @@ export const emptyRegistry = (): Registry => ({
|
|
|
64
70
|
surfaces: [],
|
|
65
71
|
personas: [],
|
|
66
72
|
backgroundTargets: [],
|
|
73
|
+
directChannels: [],
|
|
67
74
|
dashboard: [],
|
|
68
75
|
tools: [],
|
|
69
76
|
seeds: [],
|
|
@@ -171,6 +178,14 @@ function claimPersona(
|
|
|
171
178
|
throw new PluginError(
|
|
172
179
|
`plugin ${plugin}: the persona of kind "${kind}" needs a prompt() that returns its text.`,
|
|
173
180
|
);
|
|
181
|
+
if (
|
|
182
|
+
persona.memory !== undefined &&
|
|
183
|
+
persona.memory !== "speaker" &&
|
|
184
|
+
persona.memory !== "none"
|
|
185
|
+
)
|
|
186
|
+
throw new PluginError(
|
|
187
|
+
`plugin ${plugin}: the persona of kind "${kind}" has memory ${JSON.stringify(persona.memory)}; expected "speaker" or "none".`,
|
|
188
|
+
);
|
|
174
189
|
const other = kinds.get(kind);
|
|
175
190
|
if (other !== undefined)
|
|
176
191
|
throw new PluginError(
|
|
@@ -179,6 +194,31 @@ function claimPersona(
|
|
|
179
194
|
kinds.set(kind, plugin);
|
|
180
195
|
}
|
|
181
196
|
|
|
197
|
+
/**
|
|
198
|
+
* Refuses a direct channel without a name, a label, or `reaches`, or whose name another plugin's
|
|
199
|
+
* already has, so the providers a person is reached through never depend on a typo.
|
|
200
|
+
*/
|
|
201
|
+
function claimDirectChannel(
|
|
202
|
+
names: Names,
|
|
203
|
+
plugin: string,
|
|
204
|
+
provider: DirectChannelProvider,
|
|
205
|
+
): void {
|
|
206
|
+
const { name } = provider;
|
|
207
|
+
if (typeof name !== "string" || name === "")
|
|
208
|
+
throw new PluginError(
|
|
209
|
+
`plugin ${plugin}: a direct channel needs a name, a non-empty string such as "discord", and got ${JSON.stringify(name)}.`,
|
|
210
|
+
);
|
|
211
|
+
if (typeof provider.label !== "string" || provider.label === "")
|
|
212
|
+
throw new PluginError(
|
|
213
|
+
`plugin ${plugin}: the direct channel "${name}" needs a label, such as "a direct message on Discord", which the notify tool names it by.`,
|
|
214
|
+
);
|
|
215
|
+
if (typeof provider.reaches !== "function")
|
|
216
|
+
throw new PluginError(
|
|
217
|
+
`plugin ${plugin}: the direct channel "${name}" needs reaches(principalId), which finds a person's own conversation there.`,
|
|
218
|
+
);
|
|
219
|
+
names.claim(plugin, name);
|
|
220
|
+
}
|
|
221
|
+
|
|
182
222
|
/**
|
|
183
223
|
* Refuses a background target without a name or a label, or whose name another plugin's target
|
|
184
224
|
* already has, so a stored schedule's target never depends on plugin order.
|
|
@@ -242,19 +282,32 @@ function surfaceService(
|
|
|
242
282
|
/**
|
|
243
283
|
* Sets up every plugin in order and collects what each adds. A plugin that adds nothing, that
|
|
244
284
|
* reuses a name, or whose setup throws or returns a part the contract does not have is refused
|
|
245
|
-
* with its name and what to fix.
|
|
285
|
+
* with its name and what to fix. The context's `directChannels` are the contributed ones, read
|
|
286
|
+
* once every plugin is set up.
|
|
246
287
|
*/
|
|
247
288
|
export async function collectContributions(
|
|
248
289
|
plugins: readonly RoundtablePlugin[],
|
|
249
|
-
|
|
290
|
+
base: Omit<PluginContext, "services" | "directChannels">,
|
|
250
291
|
tiers: ToolTierTable,
|
|
251
292
|
services: ServiceRegistry = new ServiceRegistry(plugins),
|
|
252
293
|
): Promise<Registry> {
|
|
253
294
|
const registry = emptyRegistry();
|
|
295
|
+
let collected = false;
|
|
296
|
+
const context: Omit<PluginContext, "services"> = {
|
|
297
|
+
...base,
|
|
298
|
+
directChannels: directChannelsPort(() => {
|
|
299
|
+
if (!collected)
|
|
300
|
+
throw new NotLinkedError(
|
|
301
|
+
"direct channels are linked once every plugin is set up. Use them from a service's start, a handler, or a session tool, not during setup.",
|
|
302
|
+
);
|
|
303
|
+
return registry.directChannels;
|
|
304
|
+
}, base.surfaces),
|
|
305
|
+
};
|
|
254
306
|
const serviceNames = new Names("service");
|
|
255
307
|
const sessionTools = new Names("session tool");
|
|
256
308
|
const holds = new Names("hold rule");
|
|
257
309
|
const surfaces = new Names("surface");
|
|
310
|
+
const directChannelNames = new Names("direct channel");
|
|
258
311
|
const personaKinds = new Map<string, string>();
|
|
259
312
|
const targetNames = new Map<string, string>();
|
|
260
313
|
for (const plugin of plugins) {
|
|
@@ -293,6 +346,7 @@ export async function collectContributions(
|
|
|
293
346
|
surfaces: contributedSurfaces = [],
|
|
294
347
|
personas = [],
|
|
295
348
|
backgroundTargets = [],
|
|
349
|
+
directChannels = [],
|
|
296
350
|
dashboard = [],
|
|
297
351
|
tools = [],
|
|
298
352
|
seeds = [],
|
|
@@ -309,6 +363,7 @@ export async function collectContributions(
|
|
|
309
363
|
contributedSurfaces,
|
|
310
364
|
personas,
|
|
311
365
|
backgroundTargets,
|
|
366
|
+
directChannels,
|
|
312
367
|
dashboard,
|
|
313
368
|
tools,
|
|
314
369
|
seeds,
|
|
@@ -348,6 +403,8 @@ export async function collectContributions(
|
|
|
348
403
|
claimPersona(personaKinds, plugin.name, persona);
|
|
349
404
|
for (const target of backgroundTargets)
|
|
350
405
|
claimTarget(targetNames, plugin.name, target);
|
|
406
|
+
for (const provider of directChannels)
|
|
407
|
+
claimDirectChannel(directChannelNames, plugin.name, provider);
|
|
351
408
|
for (const rule of holdRules) holds.claim(plugin.name, rule.name);
|
|
352
409
|
for (const tool of contribution.sessionTools ?? []) {
|
|
353
410
|
sessionTools.claim(plugin.name, tool.name);
|
|
@@ -380,12 +437,14 @@ export async function collectContributions(
|
|
|
380
437
|
registry.channels.push(...channels);
|
|
381
438
|
registry.personas.push(...personas);
|
|
382
439
|
registry.backgroundTargets.push(...backgroundTargets);
|
|
440
|
+
registry.directChannels.push(...directChannels);
|
|
383
441
|
registry.dashboard.push(...dashboard);
|
|
384
442
|
registry.seeds.push(...seeds);
|
|
385
443
|
registry.prompt.push(...prompt);
|
|
386
444
|
registry.requiredTools.push(...requiredTools);
|
|
387
445
|
}
|
|
388
446
|
services.settle();
|
|
447
|
+
collected = true;
|
|
389
448
|
return registry;
|
|
390
449
|
}
|
|
391
450
|
|
|
@@ -402,6 +461,7 @@ export function linkSessions(registry: Registry): LinkedSessions {
|
|
|
402
461
|
seeds: registry.seeds,
|
|
403
462
|
prompt: registry.prompt,
|
|
404
463
|
persona: (kind) => personas.get(kind)?.prompt(),
|
|
464
|
+
personaMemory: (kind) => personas.get(kind)?.memory ?? "speaker",
|
|
405
465
|
requiredTools: [...new Set(registry.requiredTools)],
|
|
406
466
|
agentTools: registry.tools.flatMap((t) => (t.agent ? [t.name] : [])),
|
|
407
467
|
agentSelection: () => mergeSelections(registry.agentSelections),
|
|
@@ -35,7 +35,7 @@ function defaults(judgeModel: JudgeModel | undefined): Providers {
|
|
|
35
35
|
},
|
|
36
36
|
runtime: () => {
|
|
37
37
|
throw new ProviderError(
|
|
38
|
-
"no runtime provider is configured: the
|
|
38
|
+
"no runtime provider is configured: the runtime plugin builds the Pi runtime when no plugin fills the runtime slot; read the running one from services.get(RUNTIME)",
|
|
39
39
|
);
|
|
40
40
|
},
|
|
41
41
|
};
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
|
+
type Admission,
|
|
3
|
+
type BackgroundRunsAs,
|
|
2
4
|
type BackgroundTarget,
|
|
3
5
|
type BackgroundTurn,
|
|
4
6
|
type ChannelClaim,
|
|
@@ -8,11 +10,21 @@ import {
|
|
|
8
10
|
type ScheduledOutcome,
|
|
9
11
|
STEERED_MARK,
|
|
10
12
|
} from "../contract/channels.ts";
|
|
13
|
+
import { parseChannelKey, type SurfacePort } from "../contract/surface.ts";
|
|
14
|
+
import { IdentityError } from "../domain/errors.ts";
|
|
11
15
|
import { PluginError } from "../errors.ts";
|
|
16
|
+
import type { ActorFacts } from "../identity/actor-facts.ts";
|
|
17
|
+
import type { Contact, ContactAssessor } from "../identity/contact.ts";
|
|
18
|
+
import {
|
|
19
|
+
type IdentityService,
|
|
20
|
+
systemSpeaker,
|
|
21
|
+
} from "../identity/identity-service.ts";
|
|
22
|
+
import { SYSTEM_PRINCIPAL } from "../identity/principal-store.ts";
|
|
12
23
|
import type { Logger } from "../log.ts";
|
|
13
24
|
import type { ChannelKey } from "../sessions.ts";
|
|
14
25
|
import type { ChannelQueue } from "./channel-queue.ts";
|
|
15
26
|
import { ForwardJoin } from "./forward-join.ts";
|
|
27
|
+
import { isSystemTurn } from "./system-turns.ts";
|
|
16
28
|
|
|
17
29
|
export interface ChannelRouterOptions {
|
|
18
30
|
/** Claims in registration order; the router orders them by priority. */
|
|
@@ -21,6 +33,12 @@ export interface ChannelRouterOptions {
|
|
|
21
33
|
/** The contributed background target of a name, read at use time. */
|
|
22
34
|
targets(name: string): BackgroundTarget | undefined;
|
|
23
35
|
logger: Logger;
|
|
36
|
+
/** Who the authors of messages are; without it, no message carries a speaker. */
|
|
37
|
+
contacts?: ContactAssessor;
|
|
38
|
+
/** Whom a background turn runs as; without it, only the host's own turns run. */
|
|
39
|
+
principals?: Pick<IdentityService, "speakerFor">;
|
|
40
|
+
/** The surfaces, so a conversation started over is marked in a channel that shows one; without it, none is. */
|
|
41
|
+
surfaces?: Pick<SurfacePort, "of">;
|
|
24
42
|
/** How long a bare forward waits for the text sent with it; FORWARD_JOIN_MS by default. */
|
|
25
43
|
forwardJoinMs?: number;
|
|
26
44
|
}
|
|
@@ -47,6 +65,10 @@ export class ChannelRouter implements ConversationPort {
|
|
|
47
65
|
readonly #options: ChannelRouterOptions;
|
|
48
66
|
readonly #claims: readonly ChannelClaim[];
|
|
49
67
|
readonly #forwards: ForwardJoin;
|
|
68
|
+
/** Each channel's messages being admitted, so they reach their claim and the queue in the order they came. */
|
|
69
|
+
readonly #admitting = new Map<ChannelKey, Promise<unknown>>();
|
|
70
|
+
/** The surfaces already warned that their messages carry no actor. */
|
|
71
|
+
readonly #warned = new Set<string>();
|
|
50
72
|
|
|
51
73
|
constructor(options: ChannelRouterOptions) {
|
|
52
74
|
this.#options = options;
|
|
@@ -68,34 +90,149 @@ export class ChannelRouter implements ConversationPort {
|
|
|
68
90
|
}
|
|
69
91
|
|
|
70
92
|
async #route(message: InboundMessage): Promise<void> {
|
|
71
|
-
const { queue, logger } = this.#options;
|
|
72
93
|
if (
|
|
73
94
|
!message.text.trim() &&
|
|
74
95
|
message.attachments.length === 0 &&
|
|
75
96
|
!message.forwarded?.text.trim()
|
|
76
97
|
)
|
|
77
98
|
return;
|
|
78
|
-
const
|
|
79
|
-
|
|
99
|
+
const claim = this.#owner(message.channel, message.space);
|
|
100
|
+
if (!claim) return;
|
|
101
|
+
const started = await this.#inOrder(message.channel, async () => {
|
|
102
|
+
const admission = await this.#admit(claim, message);
|
|
103
|
+
return admission && (await this.#start(message, admission));
|
|
104
|
+
});
|
|
105
|
+
await started?.done;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Runs the channel's admissions one at a time, in the order its messages came. */
|
|
109
|
+
#inOrder<T>(channel: ChannelKey, task: () => Promise<T>): Promise<T> {
|
|
110
|
+
const before = this.#admitting.get(channel) ?? Promise.resolve();
|
|
111
|
+
const run = before.then(task);
|
|
112
|
+
const after = run.catch(() => undefined);
|
|
113
|
+
this.#admitting.set(channel, after);
|
|
114
|
+
void after.then(() => {
|
|
115
|
+
if (this.#admitting.get(channel) === after)
|
|
116
|
+
this.#admitting.delete(channel);
|
|
117
|
+
});
|
|
118
|
+
return run;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The claim's admission of the message, which carries the speaker its author resolves to; one
|
|
123
|
+
* who cannot be resolved, such as while the database is down, reaches the claim as no one. The
|
|
124
|
+
* author's contact is recorded, such as their first contact linked, only once the claim takes
|
|
125
|
+
* the message, so a message nobody serves links or makes no one. A linked author the rules
|
|
126
|
+
* refuse is still recorded as seen at no tier as the message is assessed, whether or not a
|
|
127
|
+
* claim then takes it, at most once in a while. When that record fails, or finds them someone
|
|
128
|
+
* else or no one now, such as linked elsewhere meanwhile, the message is dropped: it would run
|
|
129
|
+
* as a principal that may not exist, such as one only assessed at a first contact. The claim
|
|
130
|
+
* hears it through the admission's `dropped` or `unanswered`.
|
|
131
|
+
*/
|
|
132
|
+
async #admit(
|
|
133
|
+
claim: ChannelClaim,
|
|
134
|
+
message: InboundMessage,
|
|
135
|
+
): Promise<Admission | undefined> {
|
|
136
|
+
const { logger, contacts } = this.#options;
|
|
137
|
+
const { speaker: _given, ...facts } = message;
|
|
138
|
+
const actor = this.#actorOf(message);
|
|
139
|
+
let contact: Contact | undefined;
|
|
140
|
+
if (actor && contacts)
|
|
141
|
+
try {
|
|
142
|
+
contact = await contacts.assess(actor, {
|
|
143
|
+
conversation: message.channel,
|
|
144
|
+
});
|
|
145
|
+
} catch (error) {
|
|
146
|
+
logger.error(
|
|
147
|
+
{ channel: message.channel, err: error },
|
|
148
|
+
"could not resolve who wrote a message; it reaches its claim as no one",
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
const admission = claim.admit(
|
|
152
|
+
contact
|
|
153
|
+
? { ...facts, speaker: Object.freeze({ ...contact.speaker }) }
|
|
154
|
+
: facts,
|
|
80
155
|
);
|
|
81
|
-
if (!admission) return;
|
|
156
|
+
if (!admission || !contact) return admission;
|
|
157
|
+
try {
|
|
158
|
+
const taken = await contact.take();
|
|
159
|
+
if (taken?.principalId === contact.speaker.principalId) return admission;
|
|
160
|
+
logger.warn(
|
|
161
|
+
{ channel: message.channel },
|
|
162
|
+
"the author of a message was linked elsewhere, or refused, while it was admitted; it does not run",
|
|
163
|
+
);
|
|
164
|
+
} catch (error) {
|
|
165
|
+
logger.error(
|
|
166
|
+
{ channel: message.channel, err: error },
|
|
167
|
+
"could not record who wrote a message; it does not run",
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
if (admission.kind === "turn") admission.dropped?.();
|
|
171
|
+
else
|
|
172
|
+
admission.unanswered({
|
|
173
|
+
status: "skipped",
|
|
174
|
+
reason: "its author was not who they were when it was admitted",
|
|
175
|
+
});
|
|
176
|
+
return undefined;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Who wrote the message, as its surface reports them; a surface that reports no `actor` has it
|
|
181
|
+
* read from the author fields under its own name, once warned. None for a bot or an
|
|
182
|
+
* integration.
|
|
183
|
+
*/
|
|
184
|
+
#actorOf(message: InboundMessage): ActorFacts | undefined {
|
|
185
|
+
if (message.authorIsBot || message.integration) return undefined;
|
|
186
|
+
if (message.actor) return message.actor;
|
|
187
|
+
const { surface } = parseChannelKey(message.channel);
|
|
188
|
+
if (!this.#warned.has(surface)) {
|
|
189
|
+
this.#warned.add(surface);
|
|
190
|
+
this.#options.logger.warn(
|
|
191
|
+
{ surface },
|
|
192
|
+
`deprecated: the ${surface} surface reports no InboundMessage.actor, so its authors are read from authorId, authorName, and authorRoleIds as ${surface} identities; set actor, as this goes away in 1.0`,
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
return {
|
|
196
|
+
provider: surface,
|
|
197
|
+
subject: message.authorId,
|
|
198
|
+
name: message.authorName,
|
|
199
|
+
surface,
|
|
200
|
+
...(message.authorRoleIds
|
|
201
|
+
? {
|
|
202
|
+
roles: message.authorRoleIds.map(
|
|
203
|
+
(role) => `${surface}:role:${role}`,
|
|
204
|
+
),
|
|
205
|
+
}
|
|
206
|
+
: {}),
|
|
207
|
+
...(message.space ? { space: message.space } : {}),
|
|
208
|
+
legacyId: message.authorId,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Starts what the claim admitted, in the channel's queue, and returns while it runs: a message
|
|
214
|
+
* during a turn is first offered to it, then marked as waiting.
|
|
215
|
+
*/
|
|
216
|
+
async #start(
|
|
217
|
+
message: InboundMessage,
|
|
218
|
+
admission: Admission,
|
|
219
|
+
): Promise<{ done: Promise<void> }> {
|
|
220
|
+
const { queue, logger } = this.#options;
|
|
82
221
|
if (admission.kind === "background") {
|
|
83
|
-
const
|
|
84
|
-
|
|
85
|
-
|
|
222
|
+
const done = this.background(admission.turn).then((outcome) => {
|
|
223
|
+
if (outcome.status !== "ran") admission.unanswered(outcome);
|
|
224
|
+
});
|
|
225
|
+
return { done };
|
|
86
226
|
}
|
|
87
227
|
const { busy } = admission;
|
|
88
228
|
let queued = false;
|
|
89
229
|
// A message during a turn is added to it when the turn takes it, otherwise marked as waiting.
|
|
90
230
|
if (busy && queue.size(message.channel) > 0) {
|
|
91
|
-
if (await busy.steer?.()) {
|
|
92
|
-
await busy.react(STEERED_MARK);
|
|
93
|
-
return;
|
|
94
|
-
}
|
|
231
|
+
if (await busy.steer?.()) return { done: busy.react(STEERED_MARK) };
|
|
95
232
|
await busy.react(QUEUED_MARK);
|
|
96
233
|
queued = true;
|
|
97
234
|
}
|
|
98
|
-
|
|
235
|
+
const done = queue
|
|
99
236
|
.run(message.channel, () => {
|
|
100
237
|
if (queued) void busy?.unreact(QUEUED_MARK);
|
|
101
238
|
return admission.run();
|
|
@@ -107,6 +244,7 @@ export class ChannelRouter implements ConversationPort {
|
|
|
107
244
|
admission.failure,
|
|
108
245
|
),
|
|
109
246
|
);
|
|
247
|
+
return { done };
|
|
110
248
|
}
|
|
111
249
|
|
|
112
250
|
target(name: string): BackgroundTarget | undefined {
|
|
@@ -114,8 +252,9 @@ export class ChannelRouter implements ConversationPort {
|
|
|
114
252
|
}
|
|
115
253
|
|
|
116
254
|
/**
|
|
117
|
-
* A turn nobody wrote, run by the channel's claim inside its queue
|
|
118
|
-
* target no plugin contributes is skipped, never
|
|
255
|
+
* A turn nobody wrote, run by the channel's claim inside its queue, as the speaker its author
|
|
256
|
+
* is checked to be; never rejects. A turn whose target no plugin contributes is skipped, never
|
|
257
|
+
* handed to another target's claim, and so is one whose author may not run it now.
|
|
119
258
|
*/
|
|
120
259
|
async background(turn: BackgroundTurn): Promise<ScheduledOutcome> {
|
|
121
260
|
if (!this.#options.targets(turn.target))
|
|
@@ -131,21 +270,78 @@ export class ChannelRouter implements ConversationPort {
|
|
|
131
270
|
status: "skipped",
|
|
132
271
|
reason: "no conversation takes background turns here",
|
|
133
272
|
};
|
|
134
|
-
|
|
273
|
+
// Checked as the turn starts, in its place in the queue, so a change meanwhile counts.
|
|
274
|
+
const runs = await this.runsAs(turn);
|
|
275
|
+
if ("skipped" in runs)
|
|
276
|
+
return { status: "skipped", reason: runs.skipped };
|
|
277
|
+
return claim.background({ ...turn, speaker: runs.speaker });
|
|
135
278
|
});
|
|
136
279
|
} catch (error) {
|
|
137
280
|
return { status: "failed", error: String(error) };
|
|
138
281
|
}
|
|
139
282
|
}
|
|
140
283
|
|
|
284
|
+
/**
|
|
285
|
+
* Who a background turn runs as: the system principal for the host's own turn, at its tier;
|
|
286
|
+
* otherwise its author's principal, which must exist, be enabled, and hold a tier now, at the
|
|
287
|
+
* lower of the turn's tier and theirs. Why not, when it may not run; throws when the identity
|
|
288
|
+
* service cannot tell.
|
|
289
|
+
*/
|
|
290
|
+
async runsAs(turn: BackgroundTurn): Promise<BackgroundRunsAs> {
|
|
291
|
+
const { author, tier } = turn;
|
|
292
|
+
if (!author?.principalId || !tier)
|
|
293
|
+
return {
|
|
294
|
+
skipped:
|
|
295
|
+
"a background turn names its author's principal (author.principalId) and the tier it runs at (tier); this one does not",
|
|
296
|
+
};
|
|
297
|
+
if (author.principalId === SYSTEM_PRINCIPAL)
|
|
298
|
+
return isSystemTurn(turn)
|
|
299
|
+
? { speaker: { ...systemSpeaker(tier), name: author.name } }
|
|
300
|
+
: {
|
|
301
|
+
skipped: `only the host itself starts a turn as the system principal "${SYSTEM_PRINCIPAL}"`,
|
|
302
|
+
};
|
|
303
|
+
const { principals } = this.#options;
|
|
304
|
+
if (!principals)
|
|
305
|
+
return {
|
|
306
|
+
skipped: "the host has no identity service to check whom it runs as",
|
|
307
|
+
};
|
|
308
|
+
try {
|
|
309
|
+
const checked = await principals.speakerFor(author.principalId, tier);
|
|
310
|
+
return {
|
|
311
|
+
speaker: {
|
|
312
|
+
id: author.id,
|
|
313
|
+
name: author.name,
|
|
314
|
+
tier: checked.tier,
|
|
315
|
+
principalId: checked.principalId,
|
|
316
|
+
},
|
|
317
|
+
};
|
|
318
|
+
} catch (error) {
|
|
319
|
+
if (!(error instanceof IdentityError)) throw error;
|
|
320
|
+
return { skipped: error.message };
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
141
324
|
startFresh(channel: ChannelKey): Promise<string> {
|
|
142
|
-
return this.#options.queue.run(channel, () => {
|
|
325
|
+
return this.#options.queue.run(channel, async () => {
|
|
143
326
|
const claim = this.#owner(channel);
|
|
144
327
|
if (!claim) throw new Error(`no conversation owns ${channel}`);
|
|
145
|
-
|
|
328
|
+
const kind = await claim.startFresh(channel);
|
|
329
|
+
await this.#markFresh(channel);
|
|
330
|
+
return kind;
|
|
146
331
|
});
|
|
147
332
|
}
|
|
148
333
|
|
|
334
|
+
/** The restart already counts: a surface that cannot mark it is logged, not failed. */
|
|
335
|
+
async #markFresh(channel: ChannelKey): Promise<void> {
|
|
336
|
+
try {
|
|
337
|
+
await this.#options.surfaces?.of(channel)?.markFresh?.(channel);
|
|
338
|
+
} catch (error) {
|
|
339
|
+
this.#options.logger.warn(
|
|
340
|
+
`could not mark the restart of ${channel}: ${error instanceof Error ? error.message : String(error)}`,
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
149
345
|
async deleteConversation(channel: ChannelKey): Promise<"deleted" | "busy"> {
|
|
150
346
|
const { queue } = this.#options;
|
|
151
347
|
const claim = this.#owner(channel);
|
|
@@ -168,4 +364,17 @@ export class ChannelRouter implements ConversationPort {
|
|
|
168
364
|
postsInPlace(channel: ChannelKey): boolean {
|
|
169
365
|
return this.#owner(channel)?.postsInPlace === true;
|
|
170
366
|
}
|
|
367
|
+
|
|
368
|
+
owns(channel: ChannelKey): boolean {
|
|
369
|
+
return this.#owner(channel) !== undefined;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
takesBackground(channel: ChannelKey): boolean {
|
|
373
|
+
return this.#owner(channel)?.background !== undefined;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
takesSystemReports(channel: ChannelKey): boolean {
|
|
377
|
+
const owner = this.#owner(channel);
|
|
378
|
+
return owner !== undefined && owner.takesSystemReports !== false;
|
|
379
|
+
}
|
|
171
380
|
}
|