pi-roundtable 0.8.0 → 0.9.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 +115 -0
- package/README.md +35 -11
- package/README.zh-TW.md +27 -10
- package/docs/migration-0.9.md +247 -0
- package/docs/plugins.md +618 -86
- package/examples/channel-context.test.ts +88 -0
- package/examples/channel-context.ts +62 -0
- package/examples/echo-runtime.test.ts +1 -0
- package/examples/fake-surface.test.ts +69 -6
- package/examples/fake-surface.ts +73 -7
- package/examples/headless.ts +5 -1
- 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 +5 -2
- 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 +115 -1
- package/src/cli/config-edit.ts +83 -13
- package/src/cli/doctor.ts +7 -0
- package/src/cli/line-diff.ts +72 -0
- package/src/cli/principal.ts +412 -0
- package/src/cli/project.ts +5 -0
- package/src/cli/report.ts +12 -3
- 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/team-turns.ts +8 -4
- package/src/core/builtin/agent-server.ts +9 -7
- package/src/core/builtin/discord-admin.ts +4 -1
- package/src/core/builtin/discord.ts +71 -13
- package/src/core/builtin/modules.ts +150 -86
- package/src/core/builtin/stores.ts +40 -21
- package/src/core/config/access.ts +229 -0
- package/src/core/config/adapters.ts +64 -1
- package/src/core/config/config.ts +87 -41
- package/src/core/contract/channel-context.ts +53 -0
- package/src/core/contract/channels.ts +82 -9
- package/src/core/contract/runtime.ts +23 -7
- package/src/core/contract/surface.ts +22 -6
- package/src/core/conversations/conversation-registry.ts +14 -0
- package/src/core/conversations/conversation-store.ts +13 -0
- package/src/core/define-roundtable.ts +42 -11
- 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-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 -97
- package/src/core/discord/owner-command.ts +27 -13
- package/src/core/discord/stop-button.ts +4 -4
- package/src/core/domain/conversation.ts +7 -2
- package/src/core/domain/errors.ts +5 -0
- package/src/core/domain/ports.ts +27 -2
- package/src/core/drain.ts +56 -5
- package/src/core/errors.ts +13 -0
- package/src/core/host-background.ts +56 -0
- package/src/core/host.ts +52 -68
- package/src/core/i18n/cards.ts +8 -4
- package/src/core/i18n/conversation.ts +2 -0
- 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 +56 -4
- package/src/core/modules/schedules/schedules.ts +43 -14
- package/src/core/plugin.ts +48 -0
- package/src/core/presence/direct-channels.ts +104 -0
- package/src/core/registry/contributions.ts +63 -3
- package/src/core/routing/channel-queue.ts +18 -1
- package/src/core/routing/channel-router.ts +274 -21
- package/src/core/routing/conversation-turns.ts +45 -10
- package/src/core/routing/surface-port.ts +32 -2
- package/src/core/routing/system-turns.ts +29 -0
- package/src/core/runtime/abort-guard.ts +58 -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 +139 -12
- package/src/core/runtime/extensions/ask-user.ts +1 -1
- package/src/core/runtime/extensions/confirmation-gate.ts +28 -14
- package/src/core/runtime/extensions/private-memory.ts +447 -0
- package/src/core/runtime/pending-confirmation-store.ts +30 -5
- package/src/core/runtime/pi-agent-runtime.ts +174 -190
- package/src/core/runtime/preflight-tools.ts +47 -0
- package/src/core/runtime/prompt-slot.ts +4 -4
- package/src/core/runtime/reader-history.ts +129 -0
- package/src/core/runtime/runtime-plugin.ts +51 -5
- package/src/core/runtime/runtime-types.ts +48 -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 +21 -3
- package/src/core/runtime/thinking-level.ts +47 -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-wrapup.ts +63 -0
- package/src/core/services.ts +27 -1
- package/src/core/sessions.ts +30 -0
- 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 +108 -21
- package/src/core/testing/owner.ts +17 -0
- package/src/core/testing/surface-contract.ts +118 -41
- package/src/core/testing/test-host.ts +79 -16
- 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 +8 -0
- package/src/index.ts +48 -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 +55 -16
- package/templates/.env.example +1 -1
- package/templates/adapters/web/README.md +7 -0
- package/templates/adapters/web/roundtable.config.ts +14 -10
- package/templates/roundtable.config.ts +12 -1
- package/src/core/domain/owner-prompts.ts +0 -45
|
@@ -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),
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { HostStoppingError } from "../errors.ts";
|
|
1
2
|
import type { ChannelKey } from "../sessions.ts";
|
|
2
3
|
|
|
3
4
|
/** Runs tasks one at a time per channel, in arrival order; different channels run independently. */
|
|
@@ -5,6 +6,7 @@ export class ChannelQueue {
|
|
|
5
6
|
readonly #tails = new Map<ChannelKey, Promise<unknown>>();
|
|
6
7
|
readonly #sizes = new Map<ChannelKey, number>();
|
|
7
8
|
readonly #listeners: ((channel: ChannelKey) => void)[] = [];
|
|
9
|
+
#closed = false;
|
|
8
10
|
|
|
9
11
|
/** Called whenever a channel's count of running and waiting tasks changes. */
|
|
10
12
|
onChange(listener: (channel: ChannelKey) => void): void {
|
|
@@ -15,11 +17,26 @@ export class ChannelQueue {
|
|
|
15
17
|
for (const listener of this.#listeners) listener(channel);
|
|
16
18
|
}
|
|
17
19
|
|
|
20
|
+
/** Whether the host is shutting down, so no task starts any more. */
|
|
21
|
+
get closed(): boolean {
|
|
22
|
+
return this.#closed;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Starts the shutdown: from now on a task that has not started, whether queued before or after
|
|
27
|
+
* this call, is refused with HostStoppingError. A running task finishes.
|
|
28
|
+
*/
|
|
29
|
+
close(): void {
|
|
30
|
+
this.#closed = true;
|
|
31
|
+
}
|
|
32
|
+
|
|
18
33
|
run<T>(channel: ChannelKey, task: () => Promise<T>): Promise<T> {
|
|
34
|
+
const start = () =>
|
|
35
|
+
this.#closed ? Promise.reject(new HostStoppingError()) : task();
|
|
19
36
|
this.#sizes.set(channel, this.size(channel) + 1);
|
|
20
37
|
this.#changed(channel);
|
|
21
38
|
const previous = this.#tails.get(channel) ?? Promise.resolve();
|
|
22
|
-
const result = previous.then(
|
|
39
|
+
const result = previous.then(start, start);
|
|
23
40
|
const tail = result.catch(() => undefined);
|
|
24
41
|
this.#tails.set(channel, tail);
|
|
25
42
|
void tail.then(() => {
|
|
@@ -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,22 @@ import {
|
|
|
8
10
|
type ScheduledOutcome,
|
|
9
11
|
STEERED_MARK,
|
|
10
12
|
} from "../contract/channels.ts";
|
|
11
|
-
import {
|
|
13
|
+
import { parseChannelKey, type SurfacePort } from "../contract/surface.ts";
|
|
14
|
+
import { IdentityError } from "../domain/errors.ts";
|
|
15
|
+
import { HostStoppingError, PluginError } from "../errors.ts";
|
|
16
|
+
import { messages } from "../i18n/index.ts";
|
|
17
|
+
import type { ActorFacts } from "../identity/actor-facts.ts";
|
|
18
|
+
import type { Contact, ContactAssessor } from "../identity/contact.ts";
|
|
19
|
+
import {
|
|
20
|
+
type IdentityService,
|
|
21
|
+
systemSpeaker,
|
|
22
|
+
} from "../identity/identity-service.ts";
|
|
23
|
+
import { SYSTEM_PRINCIPAL } from "../identity/principal-store.ts";
|
|
12
24
|
import type { Logger } from "../log.ts";
|
|
13
25
|
import type { ChannelKey } from "../sessions.ts";
|
|
14
26
|
import type { ChannelQueue } from "./channel-queue.ts";
|
|
15
27
|
import { ForwardJoin } from "./forward-join.ts";
|
|
28
|
+
import { isSystemTurn } from "./system-turns.ts";
|
|
16
29
|
|
|
17
30
|
export interface ChannelRouterOptions {
|
|
18
31
|
/** Claims in registration order; the router orders them by priority. */
|
|
@@ -21,6 +34,15 @@ export interface ChannelRouterOptions {
|
|
|
21
34
|
/** The contributed background target of a name, read at use time. */
|
|
22
35
|
targets(name: string): BackgroundTarget | undefined;
|
|
23
36
|
logger: Logger;
|
|
37
|
+
/** Who the authors of messages are; without it, no message carries a speaker. */
|
|
38
|
+
contacts?: ContactAssessor;
|
|
39
|
+
/** Whom a background turn runs as; without it, only the host's own turns run. */
|
|
40
|
+
principals?: Pick<IdentityService, "speakerFor">;
|
|
41
|
+
/**
|
|
42
|
+
* The surfaces, so a conversation started over is marked in a channel that shows one, and a
|
|
43
|
+
* message that arrives while the host shuts down is told so; without it, neither is.
|
|
44
|
+
*/
|
|
45
|
+
surfaces?: Pick<SurfacePort, "of"> & Partial<Pick<SurfacePort, "sendReply">>;
|
|
24
46
|
/** How long a bare forward waits for the text sent with it; FORWARD_JOIN_MS by default. */
|
|
25
47
|
forwardJoinMs?: number;
|
|
26
48
|
}
|
|
@@ -38,6 +60,12 @@ export function orderClaims(
|
|
|
38
60
|
return claims.toSorted((a, b) => b.priority - a.priority);
|
|
39
61
|
}
|
|
40
62
|
|
|
63
|
+
/** The outcome of a background turn the host did not start because it is shutting down. */
|
|
64
|
+
const stopping = (): ScheduledOutcome => ({
|
|
65
|
+
status: "skipped",
|
|
66
|
+
reason: new HostStoppingError().message,
|
|
67
|
+
});
|
|
68
|
+
|
|
41
69
|
/**
|
|
42
70
|
* Routes every conversation operation to the claim that owns its channel, each inside the
|
|
43
71
|
* channel's queue: messages after joining a bare forward to its text, background turns,
|
|
@@ -47,6 +75,12 @@ export class ChannelRouter implements ConversationPort {
|
|
|
47
75
|
readonly #options: ChannelRouterOptions;
|
|
48
76
|
readonly #claims: readonly ChannelClaim[];
|
|
49
77
|
readonly #forwards: ForwardJoin;
|
|
78
|
+
/** Each channel's messages being admitted, so they reach their claim and the queue in the order they came. */
|
|
79
|
+
readonly #admitting = new Map<ChannelKey, Promise<unknown>>();
|
|
80
|
+
/** The surfaces already warned that their messages carry no actor. */
|
|
81
|
+
readonly #warned = new Set<string>();
|
|
82
|
+
/** The channels already told that the host is restarting, so a burst of messages gets one notice. */
|
|
83
|
+
readonly #told = new Set<ChannelKey>();
|
|
50
84
|
|
|
51
85
|
constructor(options: ChannelRouterOptions) {
|
|
52
86
|
this.#options = options;
|
|
@@ -68,45 +102,199 @@ export class ChannelRouter implements ConversationPort {
|
|
|
68
102
|
}
|
|
69
103
|
|
|
70
104
|
async #route(message: InboundMessage): Promise<void> {
|
|
71
|
-
const { queue, logger } = this.#options;
|
|
72
105
|
if (
|
|
73
106
|
!message.text.trim() &&
|
|
74
107
|
message.attachments.length === 0 &&
|
|
75
108
|
!message.forwarded?.text.trim()
|
|
76
109
|
)
|
|
77
110
|
return;
|
|
78
|
-
const
|
|
79
|
-
|
|
111
|
+
const claim = this.#owner(message.channel, message.space);
|
|
112
|
+
if (!claim) return;
|
|
113
|
+
const started = await this.#inOrder(message.channel, async () => {
|
|
114
|
+
const admission = await this.#admit(claim, message);
|
|
115
|
+
if (!admission) return undefined;
|
|
116
|
+
// Once the host shuts down only the turns already running finish: this one starts nothing.
|
|
117
|
+
if (this.#options.queue.closed) {
|
|
118
|
+
await this.#refuse(message, admission);
|
|
119
|
+
return undefined;
|
|
120
|
+
}
|
|
121
|
+
return this.#start(message, admission);
|
|
122
|
+
});
|
|
123
|
+
await started?.done;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Runs the channel's admissions one at a time, in the order its messages came. */
|
|
127
|
+
#inOrder<T>(channel: ChannelKey, task: () => Promise<T>): Promise<T> {
|
|
128
|
+
const before = this.#admitting.get(channel) ?? Promise.resolve();
|
|
129
|
+
const run = before.then(task);
|
|
130
|
+
const after = run.catch(() => undefined);
|
|
131
|
+
this.#admitting.set(channel, after);
|
|
132
|
+
void after.then(() => {
|
|
133
|
+
if (this.#admitting.get(channel) === after)
|
|
134
|
+
this.#admitting.delete(channel);
|
|
135
|
+
});
|
|
136
|
+
return run;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* The claim's admission of the message, which carries the speaker its author resolves to; one
|
|
141
|
+
* who cannot be resolved, such as while the database is down, reaches the claim as no one. The
|
|
142
|
+
* author's contact is recorded, such as their first contact linked, only once the claim takes
|
|
143
|
+
* the message, so a message nobody serves links or makes no one. A linked author the rules
|
|
144
|
+
* refuse is still recorded as seen at no tier as the message is assessed, whether or not a
|
|
145
|
+
* claim then takes it, at most once in a while. When that record fails, or finds them someone
|
|
146
|
+
* else or no one now, such as linked elsewhere meanwhile, the message is dropped: it would run
|
|
147
|
+
* as a principal that may not exist, such as one only assessed at a first contact. The claim
|
|
148
|
+
* hears it through the admission's `dropped` or `unanswered`.
|
|
149
|
+
*/
|
|
150
|
+
async #admit(
|
|
151
|
+
claim: ChannelClaim,
|
|
152
|
+
message: InboundMessage,
|
|
153
|
+
): Promise<Admission | undefined> {
|
|
154
|
+
const { logger, contacts } = this.#options;
|
|
155
|
+
const { speaker: _given, ...facts } = message;
|
|
156
|
+
const actor = this.#actorOf(message);
|
|
157
|
+
let contact: Contact | undefined;
|
|
158
|
+
if (actor && contacts)
|
|
159
|
+
try {
|
|
160
|
+
contact = await contacts.assess(actor, {
|
|
161
|
+
conversation: message.channel,
|
|
162
|
+
});
|
|
163
|
+
} catch (error) {
|
|
164
|
+
logger.error(
|
|
165
|
+
{ channel: message.channel, err: error },
|
|
166
|
+
"could not resolve who wrote a message; it reaches its claim as no one",
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
const admission = claim.admit(
|
|
170
|
+
contact
|
|
171
|
+
? { ...facts, speaker: Object.freeze({ ...contact.speaker }) }
|
|
172
|
+
: facts,
|
|
80
173
|
);
|
|
81
|
-
if (!admission) return;
|
|
82
|
-
|
|
83
|
-
const
|
|
84
|
-
if (
|
|
174
|
+
if (!admission || !contact) return admission;
|
|
175
|
+
try {
|
|
176
|
+
const taken = await contact.take();
|
|
177
|
+
if (taken?.principalId === contact.speaker.principalId) return admission;
|
|
178
|
+
logger.warn(
|
|
179
|
+
{ channel: message.channel },
|
|
180
|
+
"the author of a message was linked elsewhere, or refused, while it was admitted; it does not run",
|
|
181
|
+
);
|
|
182
|
+
} catch (error) {
|
|
183
|
+
logger.error(
|
|
184
|
+
{ channel: message.channel, err: error },
|
|
185
|
+
"could not record who wrote a message; it does not run",
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
if (admission.kind === "turn") admission.dropped?.();
|
|
189
|
+
else
|
|
190
|
+
admission.unanswered({
|
|
191
|
+
status: "skipped",
|
|
192
|
+
reason: "its author was not who they were when it was admitted",
|
|
193
|
+
});
|
|
194
|
+
return undefined;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Who wrote the message, as its surface reports them; a surface that reports no `actor` has it
|
|
199
|
+
* read from the author fields under its own name, once warned. None for a bot or an
|
|
200
|
+
* integration.
|
|
201
|
+
*/
|
|
202
|
+
#actorOf(message: InboundMessage): ActorFacts | undefined {
|
|
203
|
+
if (message.authorIsBot || message.integration) return undefined;
|
|
204
|
+
if (message.actor) return message.actor;
|
|
205
|
+
const { surface } = parseChannelKey(message.channel);
|
|
206
|
+
if (!this.#warned.has(surface)) {
|
|
207
|
+
this.#warned.add(surface);
|
|
208
|
+
this.#options.logger.warn(
|
|
209
|
+
{ surface },
|
|
210
|
+
`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`,
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
return {
|
|
214
|
+
provider: surface,
|
|
215
|
+
subject: message.authorId,
|
|
216
|
+
name: message.authorName,
|
|
217
|
+
surface,
|
|
218
|
+
...(message.authorRoleIds
|
|
219
|
+
? {
|
|
220
|
+
roles: message.authorRoleIds.map(
|
|
221
|
+
(role) => `${surface}:role:${role}`,
|
|
222
|
+
),
|
|
223
|
+
}
|
|
224
|
+
: {}),
|
|
225
|
+
...(message.space ? { space: message.space } : {}),
|
|
226
|
+
legacyId: message.authorId,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Drops a message the host no longer starts a turn for because it is shutting down, and tells
|
|
232
|
+
* its channel once that it restarts; no one is told for a bot's or an integration's message, such
|
|
233
|
+
* as a report, since a notice there would only start the next one.
|
|
234
|
+
*/
|
|
235
|
+
async #refuse(message: InboundMessage, admission: Admission): Promise<void> {
|
|
236
|
+
const { logger, surfaces } = this.#options;
|
|
237
|
+
const { channel } = message;
|
|
238
|
+
if (admission.kind === "turn") admission.dropped?.();
|
|
239
|
+
else
|
|
240
|
+
admission.unanswered({
|
|
241
|
+
status: "skipped",
|
|
242
|
+
reason: new HostStoppingError().message,
|
|
243
|
+
});
|
|
244
|
+
logger.info({ channel }, "message not run: the host is shutting down");
|
|
245
|
+
if (message.authorIsBot || message.integration || this.#told.has(channel))
|
|
85
246
|
return;
|
|
247
|
+
this.#told.add(channel);
|
|
248
|
+
try {
|
|
249
|
+
await surfaces?.sendReply?.(channel, {
|
|
250
|
+
chunks: [messages().restartingNotice],
|
|
251
|
+
});
|
|
252
|
+
} catch (error) {
|
|
253
|
+
logger.warn({ channel, err: error }, "restart notice not posted");
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Starts what the claim admitted, in the channel's queue, and returns while it runs: a message
|
|
259
|
+
* during a turn is first offered to it, then marked as waiting.
|
|
260
|
+
*/
|
|
261
|
+
async #start(
|
|
262
|
+
message: InboundMessage,
|
|
263
|
+
admission: Admission,
|
|
264
|
+
): Promise<{ done: Promise<void> }> {
|
|
265
|
+
const { queue, logger } = this.#options;
|
|
266
|
+
if (admission.kind === "background") {
|
|
267
|
+
const done = this.background(admission.turn).then((outcome) => {
|
|
268
|
+
if (outcome.status !== "ran") admission.unanswered(outcome);
|
|
269
|
+
});
|
|
270
|
+
return { done };
|
|
86
271
|
}
|
|
87
272
|
const { busy } = admission;
|
|
88
273
|
let queued = false;
|
|
89
274
|
// A message during a turn is added to it when the turn takes it, otherwise marked as waiting.
|
|
90
275
|
if (busy && queue.size(message.channel) > 0) {
|
|
91
|
-
if (await busy.steer?.()) {
|
|
92
|
-
await busy.react(STEERED_MARK);
|
|
93
|
-
return;
|
|
94
|
-
}
|
|
276
|
+
if (await busy.steer?.()) return { done: busy.react(STEERED_MARK) };
|
|
95
277
|
await busy.react(QUEUED_MARK);
|
|
96
278
|
queued = true;
|
|
97
279
|
}
|
|
98
|
-
|
|
280
|
+
const done = queue
|
|
99
281
|
.run(message.channel, () => {
|
|
100
282
|
if (queued) void busy?.unreact(QUEUED_MARK);
|
|
101
283
|
return admission.run();
|
|
102
284
|
})
|
|
103
285
|
// pi-lens-ignore: no-unknown-parameters
|
|
104
|
-
.catch((error: unknown) =>
|
|
286
|
+
.catch(async (error: unknown) => {
|
|
287
|
+
// The host began shutting down while this message waited behind a running turn.
|
|
288
|
+
if (error instanceof HostStoppingError) {
|
|
289
|
+
if (queued) void busy?.unreact(QUEUED_MARK);
|
|
290
|
+
return this.#refuse(message, admission);
|
|
291
|
+
}
|
|
105
292
|
logger.error(
|
|
106
293
|
{ channel: message.channel, err: error },
|
|
107
294
|
admission.failure,
|
|
108
|
-
)
|
|
109
|
-
);
|
|
295
|
+
);
|
|
296
|
+
});
|
|
297
|
+
return { done };
|
|
110
298
|
}
|
|
111
299
|
|
|
112
300
|
target(name: string): BackgroundTarget | undefined {
|
|
@@ -114,10 +302,12 @@ export class ChannelRouter implements ConversationPort {
|
|
|
114
302
|
}
|
|
115
303
|
|
|
116
304
|
/**
|
|
117
|
-
* A turn nobody wrote, run by the channel's claim inside its queue
|
|
118
|
-
* target no plugin contributes is skipped, never
|
|
305
|
+
* A turn nobody wrote, run by the channel's claim inside its queue, as the speaker its author
|
|
306
|
+
* is checked to be; never rejects. A turn whose target no plugin contributes is skipped, never
|
|
307
|
+
* handed to another target's claim, and so is one whose author may not run it now.
|
|
119
308
|
*/
|
|
120
309
|
async background(turn: BackgroundTurn): Promise<ScheduledOutcome> {
|
|
310
|
+
if (this.#options.queue.closed) return stopping();
|
|
121
311
|
if (!this.#options.targets(turn.target))
|
|
122
312
|
return {
|
|
123
313
|
status: "skipped",
|
|
@@ -131,21 +321,79 @@ export class ChannelRouter implements ConversationPort {
|
|
|
131
321
|
status: "skipped",
|
|
132
322
|
reason: "no conversation takes background turns here",
|
|
133
323
|
};
|
|
134
|
-
|
|
324
|
+
// Checked as the turn starts, in its place in the queue, so a change meanwhile counts.
|
|
325
|
+
const runs = await this.runsAs(turn);
|
|
326
|
+
if ("skipped" in runs)
|
|
327
|
+
return { status: "skipped", reason: runs.skipped };
|
|
328
|
+
return claim.background({ ...turn, speaker: runs.speaker });
|
|
135
329
|
});
|
|
136
330
|
} catch (error) {
|
|
331
|
+
if (error instanceof HostStoppingError) return stopping();
|
|
137
332
|
return { status: "failed", error: String(error) };
|
|
138
333
|
}
|
|
139
334
|
}
|
|
140
335
|
|
|
336
|
+
/**
|
|
337
|
+
* Who a background turn runs as: the system principal for the host's own turn, at its tier;
|
|
338
|
+
* otherwise its author's principal, which must exist, be enabled, and hold a tier now, at the
|
|
339
|
+
* lower of the turn's tier and theirs. Why not, when it may not run; throws when the identity
|
|
340
|
+
* service cannot tell.
|
|
341
|
+
*/
|
|
342
|
+
async runsAs(turn: BackgroundTurn): Promise<BackgroundRunsAs> {
|
|
343
|
+
const { author, tier } = turn;
|
|
344
|
+
if (!author?.principalId || !tier)
|
|
345
|
+
return {
|
|
346
|
+
skipped:
|
|
347
|
+
"a background turn names its author's principal (author.principalId) and the tier it runs at (tier); this one does not",
|
|
348
|
+
};
|
|
349
|
+
if (author.principalId === SYSTEM_PRINCIPAL)
|
|
350
|
+
return isSystemTurn(turn)
|
|
351
|
+
? { speaker: { ...systemSpeaker(tier), name: author.name } }
|
|
352
|
+
: {
|
|
353
|
+
skipped: `only the host itself starts a turn as the system principal "${SYSTEM_PRINCIPAL}"`,
|
|
354
|
+
};
|
|
355
|
+
const { principals } = this.#options;
|
|
356
|
+
if (!principals)
|
|
357
|
+
return {
|
|
358
|
+
skipped: "the host has no identity service to check whom it runs as",
|
|
359
|
+
};
|
|
360
|
+
try {
|
|
361
|
+
const checked = await principals.speakerFor(author.principalId, tier);
|
|
362
|
+
return {
|
|
363
|
+
speaker: {
|
|
364
|
+
id: author.id,
|
|
365
|
+
name: author.name,
|
|
366
|
+
tier: checked.tier,
|
|
367
|
+
principalId: checked.principalId,
|
|
368
|
+
},
|
|
369
|
+
};
|
|
370
|
+
} catch (error) {
|
|
371
|
+
if (!(error instanceof IdentityError)) throw error;
|
|
372
|
+
return { skipped: error.message };
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
141
376
|
startFresh(channel: ChannelKey): Promise<string> {
|
|
142
|
-
return this.#options.queue.run(channel, () => {
|
|
377
|
+
return this.#options.queue.run(channel, async () => {
|
|
143
378
|
const claim = this.#owner(channel);
|
|
144
379
|
if (!claim) throw new Error(`no conversation owns ${channel}`);
|
|
145
|
-
|
|
380
|
+
const kind = await claim.startFresh(channel);
|
|
381
|
+
await this.#markFresh(channel);
|
|
382
|
+
return kind;
|
|
146
383
|
});
|
|
147
384
|
}
|
|
148
385
|
|
|
386
|
+
/** The restart already counts: a surface that cannot mark it is logged, not failed. */
|
|
387
|
+
async #markFresh(channel: ChannelKey): Promise<void> {
|
|
388
|
+
try {
|
|
389
|
+
await this.#options.surfaces?.of(channel)?.markFresh?.(channel);
|
|
390
|
+
} catch (error) {
|
|
391
|
+
this.#options.logger.warn(
|
|
392
|
+
`could not mark the restart of ${channel}: ${error instanceof Error ? error.message : String(error)}`,
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
149
397
|
async deleteConversation(channel: ChannelKey): Promise<"deleted" | "busy"> {
|
|
150
398
|
const { queue } = this.#options;
|
|
151
399
|
const claim = this.#owner(channel);
|
|
@@ -176,4 +424,9 @@ export class ChannelRouter implements ConversationPort {
|
|
|
176
424
|
takesBackground(channel: ChannelKey): boolean {
|
|
177
425
|
return this.#owner(channel)?.background !== undefined;
|
|
178
426
|
}
|
|
427
|
+
|
|
428
|
+
takesSystemReports(channel: ChannelKey): boolean {
|
|
429
|
+
const owner = this.#owner(channel);
|
|
430
|
+
return owner !== undefined && owner.takesSystemReports !== false;
|
|
431
|
+
}
|
|
179
432
|
}
|