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
|
@@ -7,7 +7,13 @@ import {
|
|
|
7
7
|
SlashCommandSubcommandGroupBuilder,
|
|
8
8
|
} from "discord.js";
|
|
9
9
|
import { messages } from "../i18n/index.ts";
|
|
10
|
+
import type { IdentityService } from "../identity/identity-service.ts";
|
|
10
11
|
import type { Logger } from "../log.ts";
|
|
12
|
+
import {
|
|
13
|
+
type DiscordActor,
|
|
14
|
+
DiscordOwners,
|
|
15
|
+
discordUser,
|
|
16
|
+
} from "./discord-owners.ts";
|
|
11
17
|
import type {
|
|
12
18
|
CommandGuard,
|
|
13
19
|
CommandRoot,
|
|
@@ -33,31 +39,36 @@ export const groupOption = (
|
|
|
33
39
|
): RootOption => build(new SlashCommandSubcommandGroupBuilder()).toJSON();
|
|
34
40
|
|
|
35
41
|
/**
|
|
36
|
-
* Only
|
|
37
|
-
*
|
|
42
|
+
* Only an owner may use the root command: the primary owner by user ID, and, with the identity
|
|
43
|
+
* service, every other owner principal's Discord identity, checked again each time. Every answer
|
|
44
|
+
* is visible only to them, and a failure becomes a panel instead of a hanging interaction.
|
|
38
45
|
*/
|
|
39
|
-
// pi-lens-ignore: large-class —
|
|
46
|
+
// pi-lens-ignore: large-class — three methods: the owner checks and the failure panel
|
|
40
47
|
export class OwnerGuard implements CommandGuard {
|
|
41
|
-
readonly #
|
|
48
|
+
readonly #owners: DiscordOwners;
|
|
42
49
|
readonly #logger: Logger;
|
|
43
50
|
/** The name of the root command the guard serves, without the slash. */
|
|
44
51
|
readonly root: string;
|
|
45
52
|
readonly refusalHint: string | undefined;
|
|
46
53
|
|
|
47
54
|
constructor(
|
|
48
|
-
|
|
55
|
+
owners: DiscordOwners,
|
|
49
56
|
logger: Logger,
|
|
50
57
|
root: string,
|
|
51
58
|
refusalHint?: string,
|
|
52
59
|
) {
|
|
53
|
-
this.#
|
|
60
|
+
this.#owners = owners;
|
|
54
61
|
this.#logger = logger;
|
|
55
62
|
this.root = root;
|
|
56
63
|
this.refusalHint = refusalHint;
|
|
57
64
|
}
|
|
58
65
|
|
|
66
|
+
allows(actor: DiscordActor): Promise<boolean> {
|
|
67
|
+
return this.#owners.isOwner(discordUser(actor));
|
|
68
|
+
}
|
|
69
|
+
|
|
59
70
|
isOwner(actor: { user: { id: string } }): boolean {
|
|
60
|
-
return actor.user.id === this.#
|
|
71
|
+
return actor.user.id === this.#owners.primary;
|
|
61
72
|
}
|
|
62
73
|
|
|
63
74
|
/** Runs a handler; failures become a panel instead of a hanging interaction. */
|
|
@@ -82,8 +93,10 @@ export class OwnerGuard implements CommandGuard {
|
|
|
82
93
|
|
|
83
94
|
/** What `commandGuard` needs. */
|
|
84
95
|
export interface CommandGuardOptions {
|
|
85
|
-
/** The
|
|
96
|
+
/** The primary owner's Discord user id, who may always use the owner's commands. */
|
|
86
97
|
ownerId: string;
|
|
98
|
+
/** Which other owners there are and whether they still are; without it only `ownerId` may. */
|
|
99
|
+
identity?: Pick<IdentityService, "resolve" | "owners" | "identities">;
|
|
87
100
|
/** The name of the root command the guard serves, without the slash. */
|
|
88
101
|
root: string;
|
|
89
102
|
/** Where a failing command is logged. */
|
|
@@ -94,9 +107,10 @@ export interface CommandGuardOptions {
|
|
|
94
107
|
|
|
95
108
|
/** A guard for the owner's commands, such as the one a test hands a module; the Discord plugin makes its own. */
|
|
96
109
|
export function commandGuard(options: CommandGuardOptions): CommandGuard {
|
|
110
|
+
const { ownerId, identity, logger } = options;
|
|
97
111
|
return new OwnerGuard(
|
|
98
|
-
|
|
99
|
-
|
|
112
|
+
new DiscordOwners({ ownerId, ...(identity ? { identity } : {}), logger }),
|
|
113
|
+
logger,
|
|
100
114
|
options.root,
|
|
101
115
|
options.refusalHint,
|
|
102
116
|
);
|
|
@@ -121,7 +135,7 @@ const refusal = (hint: string | undefined) =>
|
|
|
121
135
|
sections: [`${messages().ownerRefusalBody}${hint ?? ""}`],
|
|
122
136
|
});
|
|
123
137
|
|
|
124
|
-
/** Answers a feature's root subcommands and components, for the
|
|
138
|
+
/** Answers a feature's root subcommands and components, for the owners only. */
|
|
125
139
|
export function ownerCommandModule(
|
|
126
140
|
guard: CommandGuard,
|
|
127
141
|
handlers: OwnerCommandHandlers,
|
|
@@ -139,14 +153,14 @@ export function ownerCommandModule(
|
|
|
139
153
|
async handle(interaction) {
|
|
140
154
|
if (interaction.isAutocomplete()) {
|
|
141
155
|
if (!owned(interaction)) return false;
|
|
142
|
-
if (!guard.
|
|
156
|
+
if (!(await guard.allows(interaction))) await interaction.respond([]);
|
|
143
157
|
else await handlers.autocomplete?.(interaction);
|
|
144
158
|
return true;
|
|
145
159
|
}
|
|
146
160
|
if (await handlers.component?.(interaction)) return true;
|
|
147
161
|
if (!interaction.isChatInputCommand() || !owned(interaction))
|
|
148
162
|
return false;
|
|
149
|
-
if (!guard.
|
|
163
|
+
if (!(await guard.allows(interaction))) {
|
|
150
164
|
await interaction.reply(refusal(guard.refusalHint));
|
|
151
165
|
return true;
|
|
152
166
|
}
|
|
@@ -12,7 +12,7 @@ import type { CommandGuard, InteractionModule } from "./interaction-module.ts";
|
|
|
12
12
|
|
|
13
13
|
export const STOP_BUTTON_ID = "roundtable:stop";
|
|
14
14
|
|
|
15
|
-
/** The message a long turn shows: a stop button only
|
|
15
|
+
/** The message a long turn shows: a stop button only an owner may press. */
|
|
16
16
|
export function stopPanel() {
|
|
17
17
|
return {
|
|
18
18
|
content: messages().stopNote,
|
|
@@ -29,13 +29,13 @@ export function stopPanel() {
|
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
export interface StopButtonOptions {
|
|
32
|
-
guard: Pick<CommandGuard, "
|
|
32
|
+
guard: Pick<CommandGuard, "allows">;
|
|
33
33
|
/** Stops the running turn of the channel the button was pressed in, through its claim. */
|
|
34
34
|
conversations: Pick<ConversationPort, "stop">;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
/**
|
|
38
|
-
* Answers a press of the stop button: only
|
|
38
|
+
* Answers a press of the stop button: only an owner stops the channel's turn, and the answer,
|
|
39
39
|
* visible only to whoever pressed, says whether one was running.
|
|
40
40
|
*/
|
|
41
41
|
export function stopButtonModule(
|
|
@@ -46,7 +46,7 @@ export function stopButtonModule(
|
|
|
46
46
|
handle: async (interaction: Interaction) => {
|
|
47
47
|
if (!interaction.isButton() || interaction.customId !== STOP_BUTTON_ID)
|
|
48
48
|
return false;
|
|
49
|
-
const owner = options.guard.
|
|
49
|
+
const owner = await options.guard.allows(interaction);
|
|
50
50
|
const stopped =
|
|
51
51
|
owner &&
|
|
52
52
|
options.conversations.stop(
|
|
@@ -27,10 +27,15 @@ export interface PendingConfirmation {
|
|
|
27
27
|
heldAt: Date;
|
|
28
28
|
calls: HeldCall[];
|
|
29
29
|
/**
|
|
30
|
-
* The speaker whose turn held the calls
|
|
31
|
-
* tier that holds them; without one, the
|
|
30
|
+
* The speaker whose turn held the calls, by their id on the surface. Besides the owners, only
|
|
31
|
+
* they may approve them, at a tier that holds them; without one, the owners alone may.
|
|
32
32
|
*/
|
|
33
33
|
speakerId?: string;
|
|
34
|
+
/**
|
|
35
|
+
* The principal of that speaker, who approves them on any of their identities; absent for
|
|
36
|
+
* calls held before 0.9, which are matched by `speakerId`.
|
|
37
|
+
*/
|
|
38
|
+
principalId?: string;
|
|
34
39
|
}
|
|
35
40
|
|
|
36
41
|
/** A file produced for a reply; data is raw bytes, not a path or base64. */
|
|
@@ -29,3 +29,8 @@ export class DelegationError extends Error {
|
|
|
29
29
|
export class AgentError extends Error {
|
|
30
30
|
override name = "AgentError";
|
|
31
31
|
}
|
|
32
|
+
|
|
33
|
+
/** A principal, identity link, or role change that cannot be made; its message names what to fix. */
|
|
34
|
+
export class IdentityError extends Error {
|
|
35
|
+
override name = "IdentityError";
|
|
36
|
+
}
|
package/src/core/domain/ports.ts
CHANGED
|
@@ -7,6 +7,11 @@ import type { TurnProgress } from "./progress.ts";
|
|
|
7
7
|
|
|
8
8
|
export type { AgentTurnScope };
|
|
9
9
|
|
|
10
|
+
/** Who a turn's conversation belongs to. */
|
|
11
|
+
export type TurnConversation =
|
|
12
|
+
| { visibility: "private"; principalId: string }
|
|
13
|
+
| { visibility: "shared" };
|
|
14
|
+
|
|
10
15
|
export interface TurnRequest {
|
|
11
16
|
/** Where the turn's message arrived and its attachments are saved. */
|
|
12
17
|
channel: ChannelKey;
|
|
@@ -19,8 +24,18 @@ export interface TurnRequest {
|
|
|
19
24
|
confirmed?: boolean;
|
|
20
25
|
/** Set for a turn of an agent-server agent. */
|
|
21
26
|
agent?: AgentTurnScope;
|
|
22
|
-
/**
|
|
23
|
-
|
|
27
|
+
/**
|
|
28
|
+
* The person the turn is for: who wrote the message, or who set up the work; the turn runs at
|
|
29
|
+
* their tier. Required: a turn without one is refused, never run as the owner's. A plugin that
|
|
30
|
+
* starts a turn on someone's behalf gets theirs from `IDENTITY.speakerFor`.
|
|
31
|
+
*/
|
|
32
|
+
speaker: Speaker;
|
|
33
|
+
/**
|
|
34
|
+
* Who the conversation belongs to, as the host records it: `private` to one principal, or
|
|
35
|
+
* `shared` by whoever its claim admits, which it is taken to be when absent. A private
|
|
36
|
+
* conversation's prompts are its person's alone; a shared one's escalate to the owners.
|
|
37
|
+
*/
|
|
38
|
+
conversation?: TurnConversation;
|
|
24
39
|
/**
|
|
25
40
|
* The conversation's kind, fixed when its session is made: the string a claim's `startFresh`
|
|
26
41
|
* returns, which picks the persona of a non-agent conversation. "owner" when absent.
|
|
@@ -54,6 +69,16 @@ export type {
|
|
|
54
69
|
YesNoQuestion,
|
|
55
70
|
} from "../contract/providers.ts";
|
|
56
71
|
|
|
72
|
+
/** Sends a person a notice through their own channel; resolves false when they have none. */
|
|
73
|
+
export interface Notifier {
|
|
74
|
+
notify(principalId: string, text: string): Promise<boolean>;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Sends the primary owner a direct message.
|
|
79
|
+
* @deprecated Since 0.9 there may be more than one owner: reach a person with a `Notifier`, such as
|
|
80
|
+
* `context.directChannels`. Goes away in 1.0.
|
|
81
|
+
*/
|
|
57
82
|
export interface OwnerNotifier {
|
|
58
83
|
notifyOwner(text: string): Promise<void>;
|
|
59
84
|
}
|
package/src/core/drain.ts
CHANGED
|
@@ -1,19 +1,32 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
import type { Logger } from "./log.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* How long a shutdown waits for running work before aborting it: long enough for a usual turn to
|
|
5
|
+
* answer, short enough that a hung one never holds a restart for an hour.
|
|
6
|
+
*/
|
|
7
|
+
export const DRAIN_LIMIT_MS = 3 * 60_000;
|
|
8
|
+
|
|
9
|
+
/** How long work that was told to stop gets to end before the shutdown goes on without it. */
|
|
10
|
+
const ABORT_GRACE_MS = 10_000;
|
|
3
11
|
|
|
4
12
|
export interface DrainOptions {
|
|
5
13
|
/** What still runs or waits, one entry per piece of work; empty when idle. */
|
|
6
14
|
busy: () => string[];
|
|
15
|
+
/** The most the drain waits for running work; DRAIN_LIMIT_MS by default. */
|
|
7
16
|
limitMs?: number;
|
|
17
|
+
/** How often it looks again; one second by default. */
|
|
8
18
|
intervalMs?: number;
|
|
19
|
+
/** The most work told to stop at the limit gets to end; ABORT_GRACE_MS by default. */
|
|
20
|
+
abortGraceMs?: number;
|
|
9
21
|
sleep?: (ms: number) => Promise<void>;
|
|
10
22
|
now?: () => number;
|
|
11
23
|
}
|
|
12
24
|
|
|
13
25
|
/**
|
|
14
|
-
* Waits until nothing runs or waits, checking every interval, for at most the limit.
|
|
15
|
-
*
|
|
16
|
-
*
|
|
26
|
+
* Waits until nothing runs or waits, checking every interval, for at most the limit. Returns what
|
|
27
|
+
* was still busy when the limit ran out, or an empty list once idle. It only waits: the host stops
|
|
28
|
+
* work from starting before it calls this, and a caller that does not must expect work that
|
|
29
|
+
* arrives meanwhile to be waited for too.
|
|
17
30
|
*/
|
|
18
31
|
export async function waitUntilIdle(options: DrainOptions): Promise<string[]> {
|
|
19
32
|
const {
|
|
@@ -31,3 +44,41 @@ export async function waitUntilIdle(options: DrainOptions): Promise<string[]> {
|
|
|
31
44
|
await sleep(intervalMs);
|
|
32
45
|
}
|
|
33
46
|
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Waits for running work up to the limit; if some is left, tells it to stop (`abort` says whether
|
|
50
|
+
* it told any) and gives it the abort grace to end. Returns what the limit ran out on, even when stopping it worked, so the
|
|
51
|
+
* caller can hand it over as cut short.
|
|
52
|
+
*/
|
|
53
|
+
export async function drainWork(
|
|
54
|
+
options: DrainOptions & { abort: (left: string[]) => boolean },
|
|
55
|
+
): Promise<string[]> {
|
|
56
|
+
const { abort, abortGraceMs = ABORT_GRACE_MS, ...waiting } = options;
|
|
57
|
+
const left = await waitUntilIdle(waiting);
|
|
58
|
+
if (left.length === 0) return left;
|
|
59
|
+
// Work nothing could be told to stop would only be waited for in vain.
|
|
60
|
+
if (abort(left)) await waitUntilIdle({ ...waiting, limitMs: abortGraceMs });
|
|
61
|
+
return left;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Tells each channel's running turn to stop, as the owner's stop does; a failure is logged and the
|
|
66
|
+
* rest are still told. Whether any turn was running to be told.
|
|
67
|
+
*/
|
|
68
|
+
export function stopTurns(
|
|
69
|
+
channels: readonly string[],
|
|
70
|
+
stop: (channel: string) => boolean,
|
|
71
|
+
logger: Logger,
|
|
72
|
+
): boolean {
|
|
73
|
+
let stopped = false;
|
|
74
|
+
for (const channel of new Set(channels)) {
|
|
75
|
+
try {
|
|
76
|
+
const told = stop(channel);
|
|
77
|
+
logger.warn({ channel, told }, "stopping the turn");
|
|
78
|
+
stopped ||= told;
|
|
79
|
+
} catch (error) {
|
|
80
|
+
logger.error({ channel, err: error }, "the turn could not be stopped");
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return stopped;
|
|
84
|
+
}
|
package/src/core/errors.ts
CHANGED
|
@@ -27,3 +27,16 @@ export class JudgeError extends Error {
|
|
|
27
27
|
export class ProviderError extends Error {
|
|
28
28
|
override name = "ProviderError";
|
|
29
29
|
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Work refused because the host is shutting down: once the drain starts, no turn, queue task, or
|
|
33
|
+
* background run starts any more, and what was waiting behind a running one is dropped.
|
|
34
|
+
*/
|
|
35
|
+
export class HostStoppingError extends Error {
|
|
36
|
+
override name = "HostStoppingError";
|
|
37
|
+
constructor() {
|
|
38
|
+
super(
|
|
39
|
+
"the host is shutting down and starts no new work; try again once it is back",
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { Logger } from "./log.ts";
|
|
2
|
+
import type { ServiceStartedEvent } from "./plugin.ts";
|
|
3
|
+
import type { Registry } from "./registry/contributions.ts";
|
|
4
|
+
|
|
5
|
+
type Attempt = { ok: true } | { ok: false; error: unknown };
|
|
6
|
+
|
|
7
|
+
/** Runs one step whose failure the caller reports and moves past. */
|
|
8
|
+
export async function attempt(
|
|
9
|
+
step: () => Promise<void> | void,
|
|
10
|
+
): Promise<Attempt> {
|
|
11
|
+
try {
|
|
12
|
+
await step();
|
|
13
|
+
return { ok: true };
|
|
14
|
+
} catch (error) {
|
|
15
|
+
return { ok: false, error };
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Runs every service's background start at once and tells every plugin, as each ends, how it
|
|
21
|
+
* went; the rest of the process runs either way, and a failing start or handler never stops the
|
|
22
|
+
* others.
|
|
23
|
+
*/
|
|
24
|
+
export async function startInBackground(
|
|
25
|
+
registry: Registry,
|
|
26
|
+
logger: Logger,
|
|
27
|
+
): Promise<void> {
|
|
28
|
+
const { services, servicePlugins, handlers } = registry;
|
|
29
|
+
await Promise.all(
|
|
30
|
+
// pi-lens-ignore: array-callback-return — an async callback returns its promise on every path; map only starts the tasks for Promise.all, and the early return skips a service with no background start
|
|
31
|
+
services.map(async (service) => {
|
|
32
|
+
if (!service.startInBackground) return;
|
|
33
|
+
const plugin = servicePlugins.get(service) ?? "unknown";
|
|
34
|
+
const started = await attempt(() => service.startInBackground?.());
|
|
35
|
+
if (started.ok) logger.info({ plugin, service: service.name }, "ready");
|
|
36
|
+
else
|
|
37
|
+
logger.error(
|
|
38
|
+
{ plugin, service: service.name, err: started.error },
|
|
39
|
+
"service did not start in the background",
|
|
40
|
+
);
|
|
41
|
+
const event: ServiceStartedEvent = {
|
|
42
|
+
plugin,
|
|
43
|
+
service: service.name,
|
|
44
|
+
outcome: started.ok ? "ready" : "failed",
|
|
45
|
+
};
|
|
46
|
+
for (const { plugin: heard, events } of handlers) {
|
|
47
|
+
const handled = await attempt(() => events.serviceStarted?.(event));
|
|
48
|
+
if (!handled.ok)
|
|
49
|
+
logger.error(
|
|
50
|
+
{ plugin: heard, err: handled.error },
|
|
51
|
+
"service started handler failed",
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
}),
|
|
55
|
+
);
|
|
56
|
+
}
|
package/src/core/host.ts
CHANGED
|
@@ -2,11 +2,13 @@ import type { SQL } from "bun";
|
|
|
2
2
|
import type { ConversationPort } from "./contract/channels.ts";
|
|
3
3
|
import type { SurfacePort } from "./contract/surface.ts";
|
|
4
4
|
import { openPool, runMigrations } from "./db/migrations.ts";
|
|
5
|
-
import { type DrainOptions,
|
|
5
|
+
import { type DrainOptions, drainWork, stopTurns } from "./drain.ts";
|
|
6
6
|
import { MigrationError, NotLinkedError, PluginError } from "./errors.ts";
|
|
7
7
|
import { EventBus } from "./events.ts";
|
|
8
|
+
import { attempt, startInBackground } from "./host-background.ts";
|
|
8
9
|
import { HttpListeners, type ListenerConfig } from "./http/listeners.ts";
|
|
9
10
|
import { type Locale, setLocale } from "./i18n/index.ts";
|
|
11
|
+
import { contactsOf } from "./identity/identity-view.ts";
|
|
10
12
|
import type { JudgeModel } from "./judging/model-judge.ts";
|
|
11
13
|
import type { Logger } from "./log.ts";
|
|
12
14
|
import type {
|
|
@@ -14,7 +16,6 @@ import type {
|
|
|
14
16
|
LinkedSessions,
|
|
15
17
|
RoundtablePlugin,
|
|
16
18
|
Service,
|
|
17
|
-
ServiceStartedEvent,
|
|
18
19
|
} from "./plugin.ts";
|
|
19
20
|
import { refuseRemovedFields } from "./plugin.ts";
|
|
20
21
|
import {
|
|
@@ -32,7 +33,8 @@ import {
|
|
|
32
33
|
conversationTurns,
|
|
33
34
|
} from "./routing/conversation-turns.ts";
|
|
34
35
|
import { surfacePort } from "./routing/surface-port.ts";
|
|
35
|
-
import { CONVERSATIONS, RUNTIME } from "./services.ts";
|
|
36
|
+
import { CONVERSATIONS, IDENTITY, RUNTIME } from "./services.ts";
|
|
37
|
+
import type { ChannelKey } from "./sessions.ts";
|
|
36
38
|
import { setTimeZone } from "./time.ts";
|
|
37
39
|
import { type ToolTierTable, toolTiers } from "./tool-tiers.ts";
|
|
38
40
|
|
|
@@ -77,7 +79,11 @@ export interface RoundtableOptions {
|
|
|
77
79
|
database?: { url: string };
|
|
78
80
|
/** Receives the work a shutdown drain gave up on, before any service stops. */
|
|
79
81
|
aborted?: (left: string[]) => Promise<void>;
|
|
80
|
-
/**
|
|
82
|
+
/**
|
|
83
|
+
* The shutdown drain: it waits up to `limitMs` (3 minutes by default) for the turns already
|
|
84
|
+
* running, then stops them, gives them `abortGraceMs` (10 seconds) to end, and goes on without
|
|
85
|
+
* them. Nothing new starts once it begins. Tests shorten the limits and the clock.
|
|
86
|
+
*/
|
|
81
87
|
drain?: Omit<DrainOptions, "busy">;
|
|
82
88
|
/** What `listen()` calls with the shutdown's exit code; the process's own exit by default. */
|
|
83
89
|
exit?: (code: number) => void;
|
|
@@ -86,63 +92,13 @@ export interface RoundtableOptions {
|
|
|
86
92
|
/** The host running in this process; the text catalog, time zone and environment are process-wide. */
|
|
87
93
|
let running: Roundtable | undefined;
|
|
88
94
|
|
|
89
|
-
type Attempt = { ok: true } | { ok: false; error: unknown };
|
|
90
|
-
|
|
91
|
-
/** Runs one step whose failure the caller reports and moves past. */
|
|
92
|
-
async function attempt(step: () => Promise<void> | void): Promise<Attempt> {
|
|
93
|
-
try {
|
|
94
|
-
await step();
|
|
95
|
-
return { ok: true };
|
|
96
|
-
} catch (error) {
|
|
97
|
-
return { ok: false, error };
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
/**
|
|
102
|
-
* Runs every service's background start at once and tells every plugin, as each ends, how it
|
|
103
|
-
* went; the rest of the process runs either way, and a failing start or handler never stops the
|
|
104
|
-
* others.
|
|
105
|
-
*/
|
|
106
|
-
async function startInBackground(
|
|
107
|
-
registry: Registry,
|
|
108
|
-
logger: Logger,
|
|
109
|
-
): Promise<void> {
|
|
110
|
-
const { services, servicePlugins, handlers } = registry;
|
|
111
|
-
await Promise.all(
|
|
112
|
-
// pi-lens-ignore: array-callback-return — an async callback returns its promise on every path; map only starts the tasks for Promise.all, and the early return skips a service with no background start
|
|
113
|
-
services.map(async (service) => {
|
|
114
|
-
if (!service.startInBackground) return;
|
|
115
|
-
const plugin = servicePlugins.get(service) ?? "unknown";
|
|
116
|
-
const started = await attempt(() => service.startInBackground?.());
|
|
117
|
-
if (started.ok) logger.info({ plugin, service: service.name }, "ready");
|
|
118
|
-
else
|
|
119
|
-
logger.error(
|
|
120
|
-
{ plugin, service: service.name, err: started.error },
|
|
121
|
-
"service did not start in the background",
|
|
122
|
-
);
|
|
123
|
-
const event: ServiceStartedEvent = {
|
|
124
|
-
plugin,
|
|
125
|
-
service: service.name,
|
|
126
|
-
outcome: started.ok ? "ready" : "failed",
|
|
127
|
-
};
|
|
128
|
-
for (const { plugin: heard, events } of handlers) {
|
|
129
|
-
const handled = await attempt(() => events.serviceStarted?.(event));
|
|
130
|
-
if (!handled.ok)
|
|
131
|
-
logger.error(
|
|
132
|
-
{ plugin: heard, err: handled.error },
|
|
133
|
-
"service started handler failed",
|
|
134
|
-
);
|
|
135
|
-
}
|
|
136
|
-
}),
|
|
137
|
-
);
|
|
138
|
-
}
|
|
139
|
-
|
|
140
95
|
/**
|
|
141
96
|
* The process around the plugins: it sets them all up, links what they add (session parts,
|
|
142
97
|
* HTTP routes) and runs the preflight, then starts their services in order and the
|
|
143
98
|
* HTTP listeners last, then runs the services' background starts. Nothing reaches Discord or a listener unless
|
|
144
|
-
* every setup, link, and the preflight succeeded. On shutdown it
|
|
145
|
-
*
|
|
99
|
+
* every setup, link, and the preflight succeeded. On shutdown it starts no new work, waits for
|
|
100
|
+
* what runs for at most the drain limit, then closes the listeners and stops the services in
|
|
101
|
+
* reverse.
|
|
146
102
|
*/
|
|
147
103
|
// pi-lens-ignore: large-class
|
|
148
104
|
export class Roundtable {
|
|
@@ -154,6 +110,7 @@ export class Roundtable {
|
|
|
154
110
|
#listeners: HttpListeners | undefined;
|
|
155
111
|
#pool: SQL | undefined;
|
|
156
112
|
readonly #queue = new ChannelQueue();
|
|
113
|
+
readonly #warned = new Set<string>();
|
|
157
114
|
readonly #tiers: ToolTierTable;
|
|
158
115
|
readonly #events: EventBus;
|
|
159
116
|
#services: ServiceRegistry | undefined;
|
|
@@ -191,6 +148,7 @@ export class Roundtable {
|
|
|
191
148
|
return {
|
|
192
149
|
handle: (message) => router().handle(message),
|
|
193
150
|
background: (turn) => router().background(turn),
|
|
151
|
+
runsAs: (turn) => router().runsAs(turn),
|
|
194
152
|
target: (name) => router().target(name),
|
|
195
153
|
startFresh: (channel) => router().startFresh(channel),
|
|
196
154
|
deleteConversation: (channel) => router().deleteConversation(channel),
|
|
@@ -198,18 +156,29 @@ export class Roundtable {
|
|
|
198
156
|
postsInPlace: (channel) => router().postsInPlace(channel),
|
|
199
157
|
owns: (channel) => router().owns(channel),
|
|
200
158
|
takesBackground: (channel) => router().takesBackground(channel),
|
|
159
|
+
takesSystemReports: (channel) => router().takesSystemReports(channel),
|
|
201
160
|
};
|
|
202
161
|
}
|
|
203
162
|
|
|
204
163
|
/** The contributed surfaces by channel prefix; every call before linking throws NotLinkedError. */
|
|
205
164
|
#surfaces(): SurfacePort {
|
|
206
|
-
return surfacePort(
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
165
|
+
return surfacePort(
|
|
166
|
+
() => {
|
|
167
|
+
if (!this.#sessions)
|
|
168
|
+
throw new NotLinkedError(
|
|
169
|
+
"chat surfaces are linked once every plugin is set up. Use surfaces from a service's start or from a handler, not during setup.",
|
|
170
|
+
);
|
|
171
|
+
return this.#registry.surfaces;
|
|
172
|
+
},
|
|
173
|
+
{ deprecated: (message) => this.#deprecated(message) },
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Logs a deprecation once per host. */
|
|
178
|
+
#deprecated(message: string): void {
|
|
179
|
+
if (this.#warned.has(message)) return;
|
|
180
|
+
this.#warned.add(message);
|
|
181
|
+
this.#options.logger.warn(message);
|
|
213
182
|
}
|
|
214
183
|
|
|
215
184
|
/** Turns over the runtime plugin's runtime and the surfaces; every call before linking is refused with NotLinkedError. */
|
|
@@ -229,6 +198,7 @@ export class Roundtable {
|
|
|
229
198
|
registry: () => this.#services?.find(CONVERSATIONS),
|
|
230
199
|
surfaces: this.#surfaces(),
|
|
231
200
|
events: this.#events.sink,
|
|
201
|
+
stopping: () => this.#queue.closed,
|
|
232
202
|
selection: () => {
|
|
233
203
|
if (!this.#sessions)
|
|
234
204
|
throw new NotLinkedError("session parts are not linked yet.");
|
|
@@ -328,11 +298,16 @@ export class Roundtable {
|
|
|
328
298
|
const { routes, channels } = this.#registry;
|
|
329
299
|
this.#sessions = linkSessions(this.#registry);
|
|
330
300
|
this.#events.link(this.#registry.handlers);
|
|
301
|
+
const identity = this.#services.find(IDENTITY);
|
|
331
302
|
this.#router = new ChannelRouter({
|
|
332
303
|
claims: channels,
|
|
304
|
+
...(identity
|
|
305
|
+
? { contacts: contactsOf(identity), principals: identity }
|
|
306
|
+
: {}),
|
|
333
307
|
targets: (name) =>
|
|
334
308
|
this.#registry.backgroundTargets.find((t) => t.name === name),
|
|
335
309
|
queue: this.#queue,
|
|
310
|
+
surfaces: this.#surfaces(),
|
|
336
311
|
logger,
|
|
337
312
|
...(this.#options.conversations?.forwardJoinMs === undefined
|
|
338
313
|
? {}
|
|
@@ -404,7 +379,8 @@ export class Roundtable {
|
|
|
404
379
|
}
|
|
405
380
|
|
|
406
381
|
/**
|
|
407
|
-
* Stops the host once no work runs or waits
|
|
382
|
+
* Stops the host once no work runs or waits, or the drain limit has passed, and returns the
|
|
383
|
+
* exit code: 0, or 1 when the
|
|
408
384
|
* boot had failed or a listener, service or the pool did not stop. Every call shares the one
|
|
409
385
|
* shutdown.
|
|
410
386
|
*/
|
|
@@ -418,19 +394,27 @@ export class Roundtable {
|
|
|
418
394
|
// A signal during the boot waits for it to settle; a failed boot has already stopped.
|
|
419
395
|
await this.#booting?.catch(() => undefined);
|
|
420
396
|
if (running !== this) return this.#bootFailed ? 1 : 0;
|
|
421
|
-
//
|
|
397
|
+
// Turns already running finish, but nothing new starts from here on, whoever asks.
|
|
422
398
|
logger.info({ signal }, "shutting down once idle");
|
|
423
|
-
|
|
399
|
+
this.#queue.close();
|
|
400
|
+
const left = await drainWork({
|
|
424
401
|
...drain,
|
|
425
402
|
busy: () => [
|
|
426
403
|
...this.#queue.busy(),
|
|
427
404
|
...this.#started.flatMap((service) => service.busy?.() ?? []),
|
|
428
405
|
],
|
|
406
|
+
// A turn the limit finds still running is stopped, as the owner's stop would.
|
|
407
|
+
abort: () =>
|
|
408
|
+
stopTurns(
|
|
409
|
+
this.#queue.busy(),
|
|
410
|
+
(channel) => this.#router?.stop(channel as ChannelKey) ?? false,
|
|
411
|
+
logger,
|
|
412
|
+
),
|
|
429
413
|
});
|
|
430
414
|
if (left.length > 0) {
|
|
431
415
|
logger.warn(
|
|
432
416
|
{ aborted: left },
|
|
433
|
-
"still busy after the drain limit;
|
|
417
|
+
"still busy after the drain limit; aborted",
|
|
434
418
|
);
|
|
435
419
|
const recorded = await attempt(() => aborted?.(left));
|
|
436
420
|
if (!recorded.ok)
|
package/src/core/i18n/cards.ts
CHANGED
|
@@ -11,10 +11,12 @@ export function cardsEn(ctx: CatalogContext) {
|
|
|
11
11
|
cardStopped: "⏹️ This round was stopped.",
|
|
12
12
|
cardExpired: "⌛ Timed out with no answer.",
|
|
13
13
|
cardOwnerOnly: `Only ${ctx.assistant}'s owner can answer this card.`,
|
|
14
|
-
cardApproversNote: (
|
|
14
|
+
cardApproversNote: (...userIds: readonly string[]) =>
|
|
15
|
+
`-# Who can approve: ${userIds.map((id) => `<@${id}>`).join(" ")}`,
|
|
15
16
|
cardApproversRefusal:
|
|
16
17
|
"This approval is for the speaker whose turn asked for it.",
|
|
17
|
-
cardAskerNote: (
|
|
18
|
+
cardAskerNote: (...userIds: readonly string[]) =>
|
|
19
|
+
`-# Who can answer: ${userIds.map((id) => `<@${id}>`).join(" ")}`,
|
|
18
20
|
cardAskerRefusal: "This question is for another speaker.",
|
|
19
21
|
cardInactive: `This card is no longer active (it timed out, or ${ctx.assistant} restarted); reply in text if you need to.`,
|
|
20
22
|
cardApproved: "✅ Approved to run.",
|
|
@@ -41,9 +43,11 @@ export function cardsZhTW(ctx: CatalogContext): ReturnType<typeof cardsEn> {
|
|
|
41
43
|
cardStopped: "⏹️ 這一輪已停止。",
|
|
42
44
|
cardExpired: "⌛ 已逾時,沒有回答。",
|
|
43
45
|
cardOwnerOnly: `只有 ${ctx.assistant} 的擁有者能回答這張卡片。`,
|
|
44
|
-
cardApproversNote: (
|
|
46
|
+
cardApproversNote: (...userIds: readonly string[]) =>
|
|
47
|
+
`-# 可以核准的人:${userIds.map((id) => `<@${id}>`).join(" ")}`,
|
|
45
48
|
cardApproversRefusal: "這張核准卡是給提出這一輪的發話者的。",
|
|
46
|
-
cardAskerNote: (
|
|
49
|
+
cardAskerNote: (...userIds: readonly string[]) =>
|
|
50
|
+
`-# 可以回答的人:${userIds.map((id) => `<@${id}>`).join(" ")}`,
|
|
47
51
|
cardAskerRefusal: "這張問題是問另一位發話者的。",
|
|
48
52
|
cardInactive: `這張卡片已經失效(逾時,或 ${ctx.assistant} 重新啟動過);需要的話直接打字回覆。`,
|
|
49
53
|
cardApproved: "✅ 已核准執行。",
|
|
@@ -6,6 +6,7 @@ export function conversationEn(ctx: CatalogContext) {
|
|
|
6
6
|
failureNotice:
|
|
7
7
|
"Sorry, this reply did not go through. Please try again later.",
|
|
8
8
|
stoppedNotice: "-# Stopped.",
|
|
9
|
+
restartingNotice: `-# ${ctx.assistant} is restarting and takes no new messages right now; please send this again in a few minutes.`,
|
|
9
10
|
roundLimitNotice:
|
|
10
11
|
"-# This round already has 8 replies, so it stops here; call us again if you need more.",
|
|
11
12
|
restartNotice: `-# ${ctx.assistant} restarted before this job finished, so nothing more will be reported here.`,
|
|
@@ -30,6 +31,7 @@ export function conversationZhTW(
|
|
|
30
31
|
return {
|
|
31
32
|
failureNotice: "抱歉,這次沒能完成回覆,請稍後再試一次。",
|
|
32
33
|
stoppedNotice: "-# 已停止。",
|
|
34
|
+
restartingNotice: `-# ${ctx.assistant} 正在重新啟動,暫時不接新訊息;請過幾分鐘再傳一次。`,
|
|
33
35
|
roundLimitNotice:
|
|
34
36
|
"-# 這一輪已經有 8 則回覆,先停在這裡;需要的話請再叫我們。",
|
|
35
37
|
restartNotice: `-# ${ctx.assistant} 在這項工作結束前重新啟動了,這裡不會再有回報。`,
|