pi-roundtable 0.9.12 → 0.10.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 +34 -0
- package/docs/migration-0.9.md +3 -1
- package/docs/plugins.md +146 -21
- package/examples/fake-surface.test.ts +137 -0
- package/examples/fake-surface.ts +71 -14
- package/package.json +1 -1
- package/src/cli/checks/identity.ts +73 -5
- package/src/core/agents/agent-claim.ts +12 -0
- package/src/core/agents/team-turns.ts +11 -4
- package/src/core/builtin/agent-server.ts +6 -0
- package/src/core/config/access.ts +23 -3
- package/src/core/config/adapters.ts +11 -0
- package/src/core/config/schema.ts +14 -0
- package/src/core/define-roundtable.ts +1 -0
- package/src/core/define.ts +44 -6
- package/src/core/discord/card-audience.ts +104 -7
- package/src/core/discord/discord-owners.ts +19 -13
- package/src/core/discord/owner-card-types.ts +73 -0
- package/src/core/discord/owner-cards.ts +56 -77
- package/src/core/domain/conversation.ts +7 -0
- package/src/core/domain/ports.ts +4 -1
- package/src/core/holds.ts +60 -0
- package/src/core/i18n/cards.ts +3 -0
- package/src/core/identity/access-policy.ts +65 -2
- package/src/core/identity/caching-principal-store.ts +2 -2
- package/src/core/identity/identity-links.ts +96 -0
- package/src/core/identity/identity-plugin.ts +10 -3
- package/src/core/identity/identity-schema.ts +3 -1
- package/src/core/identity/identity-service.ts +5 -3
- package/src/core/identity/principal-store.ts +59 -142
- package/src/core/identity/principal-tables.ts +10 -2
- package/src/core/identity/principal-types.ts +146 -0
- package/src/core/interactions/prompts.ts +47 -3
- package/src/core/modules/schedules/precheck-tools.ts +115 -20
- package/src/core/routing/conversation-turns.ts +4 -1
- package/src/core/runtime/conversation-sessions.ts +8 -0
- package/src/core/runtime/extensions/confirmation-gate-extension.ts +50 -0
- package/src/core/runtime/extensions/confirmation-gate.ts +252 -204
- package/src/core/runtime/extensions/gate-refusals.ts +133 -0
- package/src/core/runtime/extensions/held-calls.ts +91 -0
- package/src/core/runtime/extensions/hold-lookups.ts +108 -0
- package/src/core/runtime/pi-agent-runtime.ts +7 -3
- package/src/core/runtime/prompt-slot.ts +10 -2
- package/src/core/runtime/session-factory.ts +1 -0
- package/src/core/services.ts +11 -0
- package/src/core/sessions.ts +7 -0
- package/src/core/testing/surface-contract-support.ts +194 -0
- package/src/core/testing/surface-contract.ts +54 -120
- package/src/core/tool-turn.ts +5 -1
- package/src/index.ts +7 -0
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { messages } from "../i18n/index.ts";
|
|
2
|
-
import type { PromptScope } from "../interactions/prompts.ts";
|
|
2
|
+
import type { ApprovedBy, PromptScope } from "../interactions/prompts.ts";
|
|
3
3
|
import { type Tier, tierAtLeast } from "../speakers.ts";
|
|
4
4
|
import type {
|
|
5
5
|
DiscordOwners,
|
|
@@ -17,6 +17,11 @@ export interface Audience {
|
|
|
17
17
|
note: string;
|
|
18
18
|
/** What someone else who presses it is told. */
|
|
19
19
|
refusal: string;
|
|
20
|
+
/**
|
|
21
|
+
* Who answered, as the approval reports them, for a user `allows` admitted; undefined when it
|
|
22
|
+
* cannot be read. Only an approval's audience has it.
|
|
23
|
+
*/
|
|
24
|
+
approver?(user: DiscordUser): Promise<ApprovedBy | undefined>;
|
|
20
25
|
}
|
|
21
26
|
|
|
22
27
|
/** The primary owner, who answers every card the owners may, and who else holds a tier. */
|
|
@@ -31,6 +36,13 @@ export type CardAudienceOptions = DiscordOwnersOptions;
|
|
|
31
36
|
* escalates to the owners, every owner with a Discord identity answers it too, and a card the
|
|
32
37
|
* speaker cannot answer, or whose principal has no Discord identity, is theirs alone; where it
|
|
33
38
|
* escalates to no one, that card goes to no one. Without a scope, a card is the owners'.
|
|
39
|
+
*
|
|
40
|
+
* An approval that names `approvers`, where it escalates, is theirs instead of the speaker's:
|
|
41
|
+
* each named principal answers on any of their Discord identities that is still theirs when they
|
|
42
|
+
* press, at the tier it needs then, and the card mentions all of those identities. The speaker
|
|
43
|
+
* does not answer it unless named. The owners still do, and are the whole audience when none of
|
|
44
|
+
* the named has a Discord identity. Where the scope escalates to no one, the person is the only
|
|
45
|
+
* one who answers, and only if named or an owner: any other person's card is not posted.
|
|
34
46
|
*/
|
|
35
47
|
export class CardAudiences {
|
|
36
48
|
readonly #options: CardAudienceOptions;
|
|
@@ -42,14 +54,28 @@ export class CardAudiences {
|
|
|
42
54
|
}
|
|
43
55
|
|
|
44
56
|
/** Who may approve a call held at `minTier`; undefined when no one may. */
|
|
45
|
-
approval(
|
|
57
|
+
async approval(
|
|
46
58
|
scope: PromptScope | undefined,
|
|
47
59
|
minTier: Tier,
|
|
60
|
+
named?: readonly string[],
|
|
48
61
|
): Promise<Audience | undefined> {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
62
|
+
let approvers = named;
|
|
63
|
+
// A private conversation's person is the only one who answers, so a call naming others is theirs only if named or an owner.
|
|
64
|
+
if (approvers && scope?.escalate === "none") {
|
|
65
|
+
if (!(await this.#mayAnswerPrivately(scope, approvers))) return undefined;
|
|
66
|
+
approvers = undefined;
|
|
67
|
+
}
|
|
68
|
+
return this.#audience(
|
|
69
|
+
scope,
|
|
70
|
+
minTier,
|
|
71
|
+
{
|
|
72
|
+
note: messages().cardApproversNote,
|
|
73
|
+
refusal: approvers
|
|
74
|
+
? messages().cardNamedApproversRefusal
|
|
75
|
+
: messages().cardApproversRefusal,
|
|
76
|
+
},
|
|
77
|
+
approvers,
|
|
78
|
+
);
|
|
53
79
|
}
|
|
54
80
|
|
|
55
81
|
/** Who may answer a question; undefined when no one may. */
|
|
@@ -64,10 +90,33 @@ export class CardAudiences {
|
|
|
64
90
|
scope: PromptScope | undefined,
|
|
65
91
|
minTier: Tier | undefined,
|
|
66
92
|
words: { note(...userIds: string[]): string; refusal: string },
|
|
93
|
+
approvers?: readonly string[],
|
|
67
94
|
): Promise<Audience | undefined> {
|
|
68
95
|
const { ownerId } = this.#options;
|
|
69
96
|
const escalates = scope?.escalate !== "none";
|
|
70
|
-
|
|
97
|
+
if (approvers && scope) {
|
|
98
|
+
const named = await this.#approverIdentities(approvers);
|
|
99
|
+
if (named.size > 0)
|
|
100
|
+
return {
|
|
101
|
+
allows: async (user) => {
|
|
102
|
+
for (const [principalId, ids] of named)
|
|
103
|
+
if (
|
|
104
|
+
ids.includes(user.id) &&
|
|
105
|
+
// Read the links again: one moved or unlinked since the card was posted is no longer theirs.
|
|
106
|
+
(await this.#stillOwn(principalId, user.id)) &&
|
|
107
|
+
(await this.#owners.holds(user, minTier, principalId))
|
|
108
|
+
)
|
|
109
|
+
return true;
|
|
110
|
+
return this.#owners.isOwner(user);
|
|
111
|
+
},
|
|
112
|
+
mentions: [...new Set([...named.values()].flat())],
|
|
113
|
+
note: words.note(...new Set([...named.values()].flat())),
|
|
114
|
+
refusal: words.refusal,
|
|
115
|
+
approver: (user) => this.#approver(user),
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
const own =
|
|
119
|
+
scope && !approvers && (await this.#ownIdentities(scope, minTier));
|
|
71
120
|
if (scope && own) {
|
|
72
121
|
const primary = own.includes(ownerId);
|
|
73
122
|
const mentions = own.includes(scope.speakerId) ? [scope.speakerId] : own;
|
|
@@ -87,6 +136,7 @@ export class CardAudiences {
|
|
|
87
136
|
mentions,
|
|
88
137
|
note: primary ? "" : words.note(...mentions),
|
|
89
138
|
refusal: primary ? messages().cardOwnerOnly : words.refusal,
|
|
139
|
+
approver: (user) => this.#approver(user),
|
|
90
140
|
};
|
|
91
141
|
}
|
|
92
142
|
if (!escalates) return undefined;
|
|
@@ -95,9 +145,56 @@ export class CardAudiences {
|
|
|
95
145
|
mentions: await this.#owners.ids(),
|
|
96
146
|
note: "",
|
|
97
147
|
refusal: messages().cardOwnerOnly,
|
|
148
|
+
approver: (user) => this.#approver(user),
|
|
98
149
|
};
|
|
99
150
|
}
|
|
100
151
|
|
|
152
|
+
/**
|
|
153
|
+
* The Discord identities of each named principal that has any, by principal id; a principal
|
|
154
|
+
* whose identities cannot be read, or who has none, is left out.
|
|
155
|
+
*/
|
|
156
|
+
async #approverIdentities(
|
|
157
|
+
principals: readonly string[],
|
|
158
|
+
): Promise<Map<string, string[]>> {
|
|
159
|
+
const named = new Map<string, string[]>();
|
|
160
|
+
for (const principalId of new Set(principals)) {
|
|
161
|
+
const ids = await this.#discordIdentities(principalId);
|
|
162
|
+
if (ids && ids.length > 0) named.set(principalId, ids);
|
|
163
|
+
}
|
|
164
|
+
return named;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Whether a private conversation's person may answer a call that names `approvers`: when named, or an owner. */
|
|
168
|
+
async #mayAnswerPrivately(
|
|
169
|
+
scope: PromptScope,
|
|
170
|
+
approvers: readonly string[],
|
|
171
|
+
): Promise<boolean> {
|
|
172
|
+
return (
|
|
173
|
+
approvers.includes(scope.principalId) ||
|
|
174
|
+
scope.speakerId === this.#options.ownerId ||
|
|
175
|
+
(await this.#owners.isOwnerPrincipal(scope.principalId))
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Who the user is as an approver: their principal, the name their identity goes by, and whether they are an owner. */
|
|
180
|
+
async #approver(user: DiscordUser): Promise<ApprovedBy | undefined> {
|
|
181
|
+
const owner = (await this.#owners.isOwner(user))
|
|
182
|
+
? { owner: true as const }
|
|
183
|
+
: {};
|
|
184
|
+
const who = await this.#owners.speakerOf(user);
|
|
185
|
+
if (who)
|
|
186
|
+
return {
|
|
187
|
+
principalId: who.principalId,
|
|
188
|
+
speakerId: user.id,
|
|
189
|
+
name: user.name,
|
|
190
|
+
...owner,
|
|
191
|
+
};
|
|
192
|
+
// Without an identity service the primary owner is the only one who answers.
|
|
193
|
+
return this.#options.identity
|
|
194
|
+
? undefined
|
|
195
|
+
: { principalId: user.id, speakerId: user.id, name: user.name, ...owner };
|
|
196
|
+
}
|
|
197
|
+
|
|
101
198
|
/** Whether the Discord user is still one of the principal's identities; false when that cannot be read. */
|
|
102
199
|
async #stillOwn(principalId: string, userId: string): Promise<boolean> {
|
|
103
200
|
return (
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { IdentityService } from "../identity/identity-service.ts";
|
|
2
2
|
import type { Logger } from "../log.ts";
|
|
3
|
-
import { type Tier, tierAtLeast } from "../speakers.ts";
|
|
3
|
+
import { type Speaker, type Tier, tierAtLeast } from "../speakers.ts";
|
|
4
4
|
|
|
5
5
|
/** A Discord user who pressed or ran something; their roles are unknown where the interaction carries no member. */
|
|
6
6
|
export interface DiscordUser {
|
|
@@ -102,19 +102,11 @@ export class DiscordOwners {
|
|
|
102
102
|
}
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
-
/**
|
|
106
|
-
|
|
107
|
-
* without `minTier` anyone does. Call it only for someone already known, so it admits no one.
|
|
108
|
-
*/
|
|
109
|
-
async holds(
|
|
110
|
-
user: DiscordUser,
|
|
111
|
-
minTier: Tier | undefined,
|
|
112
|
-
principalId?: string,
|
|
113
|
-
): Promise<boolean> {
|
|
105
|
+
/** The speaker the user is as they act; undefined without an identity service or when none serves them. Call it only for someone already admitted. */
|
|
106
|
+
async speakerOf(user: DiscordUser): Promise<Speaker | undefined> {
|
|
114
107
|
const { identity } = this.#options;
|
|
115
|
-
if (!
|
|
116
|
-
|
|
117
|
-
const who = await identity.resolve({
|
|
108
|
+
if (!identity) return undefined;
|
|
109
|
+
return identity.resolve({
|
|
118
110
|
provider: "discord",
|
|
119
111
|
subject: user.id,
|
|
120
112
|
name: user.name,
|
|
@@ -124,6 +116,20 @@ export class DiscordOwners {
|
|
|
124
116
|
: {}),
|
|
125
117
|
legacyId: user.id,
|
|
126
118
|
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Whether the user holds `minTier` as they act, as the principal named when there is one;
|
|
123
|
+
* without `minTier` anyone does. Call it only for someone already known, so it admits no one.
|
|
124
|
+
*/
|
|
125
|
+
async holds(
|
|
126
|
+
user: DiscordUser,
|
|
127
|
+
minTier: Tier | undefined,
|
|
128
|
+
principalId?: string,
|
|
129
|
+
): Promise<boolean> {
|
|
130
|
+
if (!minTier) return true;
|
|
131
|
+
if (!this.#options.identity) return false;
|
|
132
|
+
const who = await this.speakerOf(user);
|
|
127
133
|
return (
|
|
128
134
|
who !== undefined &&
|
|
129
135
|
(principalId === undefined || who.principalId === principalId) &&
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { TextDisplayBuilder } from "discord.js";
|
|
2
|
+
import type { ApprovedBy, OwnerQuestion } from "../interactions/prompts.ts";
|
|
3
|
+
import type { Audience } from "./card-audience.ts";
|
|
4
|
+
import type { DiscordUser } from "./discord-owners.ts";
|
|
5
|
+
import type { Rows } from "./owner-card-rows.ts";
|
|
6
|
+
import type { ownerPanel } from "./owner-panel.ts";
|
|
7
|
+
|
|
8
|
+
/** A card's message: the panel, led in a thread by a mention of those it is for. */
|
|
9
|
+
export type CardPayload = Omit<
|
|
10
|
+
ReturnType<typeof ownerPanel>,
|
|
11
|
+
"components" | "allowedMentions"
|
|
12
|
+
> & {
|
|
13
|
+
components: (
|
|
14
|
+
| ReturnType<typeof ownerPanel>["components"][number]
|
|
15
|
+
| TextDisplayBuilder
|
|
16
|
+
)[];
|
|
17
|
+
allowedMentions: { parse?: never[]; users?: string[] };
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/** The posted card, which is edited to show how it ended. */
|
|
21
|
+
export interface CardMessage {
|
|
22
|
+
edit(payload: CardPayload): Promise<unknown>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Where a channel's cards are posted. */
|
|
26
|
+
export interface CardChannel {
|
|
27
|
+
send(payload: CardPayload): Promise<CardMessage>;
|
|
28
|
+
/** A thread, where those a card is for are mentioned so they hear of it; a channel mentions no one. */
|
|
29
|
+
thread?: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** A turn a card answered after its turn stopped waiting starts, as a message in its channel. */
|
|
33
|
+
export interface LateTurn {
|
|
34
|
+
channelId: string;
|
|
35
|
+
/** The card's message, which the turn's message stands for. */
|
|
36
|
+
messageId: string;
|
|
37
|
+
/** Who answered. */
|
|
38
|
+
user: DiscordUser;
|
|
39
|
+
/** The server the channel belongs to; absent in a direct message. */
|
|
40
|
+
guildId?: string;
|
|
41
|
+
isDirect: boolean;
|
|
42
|
+
text: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Where and by whom a card was answered. */
|
|
46
|
+
export interface Answerer {
|
|
47
|
+
user: DiscordUser;
|
|
48
|
+
/** Who approved, for an approval card they approved. */
|
|
49
|
+
approver?: ApprovedBy;
|
|
50
|
+
messageId?: string;
|
|
51
|
+
guildId?: string;
|
|
52
|
+
isDirect: boolean;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** One open card: how it looks, and how it ends. */
|
|
56
|
+
export interface OpenCard {
|
|
57
|
+
title: string;
|
|
58
|
+
sections: string[];
|
|
59
|
+
/** The card's controls, disabled once it has ended. */
|
|
60
|
+
rows(disabled: boolean): Rows;
|
|
61
|
+
/**
|
|
62
|
+
* Ends the card with its answer; the answering interaction shows the outcome. A card whose
|
|
63
|
+
* turn stopped waiting starts a turn with the answer instead.
|
|
64
|
+
*/
|
|
65
|
+
end(value: unknown, by: Answerer): void;
|
|
66
|
+
question?: OwnerQuestion;
|
|
67
|
+
/** Options chosen together with "Other…", kept while its form is open. */
|
|
68
|
+
picked?: string[];
|
|
69
|
+
/** Posted in a thread, so it leads with the mention of who may answer. */
|
|
70
|
+
mention?: boolean;
|
|
71
|
+
/** Who may answer, and what to tell someone else who presses it. */
|
|
72
|
+
audience: Audience;
|
|
73
|
+
}
|
|
@@ -24,11 +24,7 @@ import {
|
|
|
24
24
|
type CardAudienceOptions,
|
|
25
25
|
CardAudiences,
|
|
26
26
|
} from "./card-audience.ts";
|
|
27
|
-
import {
|
|
28
|
-
DiscordOwners,
|
|
29
|
-
type DiscordUser,
|
|
30
|
-
discordUser,
|
|
31
|
-
} from "./discord-owners.ts";
|
|
27
|
+
import { DiscordOwners, discordUser } from "./discord-owners.ts";
|
|
32
28
|
import type { InteractionModule } from "./interaction-module.ts";
|
|
33
29
|
import {
|
|
34
30
|
answeredLine,
|
|
@@ -39,36 +35,25 @@ import {
|
|
|
39
35
|
type Rows,
|
|
40
36
|
TEXT_FIELD,
|
|
41
37
|
} from "./owner-card-rows.ts";
|
|
38
|
+
import type {
|
|
39
|
+
Answerer,
|
|
40
|
+
CardChannel,
|
|
41
|
+
CardMessage,
|
|
42
|
+
CardPayload,
|
|
43
|
+
LateTurn,
|
|
44
|
+
OpenCard,
|
|
45
|
+
} from "./owner-card-types.ts";
|
|
42
46
|
import { ownerPanel, type PanelContent } from "./owner-panel.ts";
|
|
43
47
|
|
|
48
|
+
export type {
|
|
49
|
+
CardChannel,
|
|
50
|
+
CardPayload,
|
|
51
|
+
LateTurn,
|
|
52
|
+
} from "./owner-card-types.ts";
|
|
44
53
|
export { CARD_PREFIX };
|
|
45
54
|
/** How long a turn that can be resumed waits on its card before it goes on without the answer. */
|
|
46
55
|
export const CARD_GRACE_MS = 2 * 60_000;
|
|
47
56
|
|
|
48
|
-
/** A card's message: the panel, led in a thread by a mention of those it is for. */
|
|
49
|
-
export type CardPayload = Omit<
|
|
50
|
-
ReturnType<typeof ownerPanel>,
|
|
51
|
-
"components" | "allowedMentions"
|
|
52
|
-
> & {
|
|
53
|
-
components: (
|
|
54
|
-
| ReturnType<typeof ownerPanel>["components"][number]
|
|
55
|
-
| TextDisplayBuilder
|
|
56
|
-
)[];
|
|
57
|
-
allowedMentions: { parse?: never[]; users?: string[] };
|
|
58
|
-
};
|
|
59
|
-
|
|
60
|
-
/** The posted card, which is edited to show how it ended. */
|
|
61
|
-
export interface CardMessage {
|
|
62
|
-
edit(payload: CardPayload): Promise<unknown>;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/** Where a channel's cards are posted. */
|
|
66
|
-
export interface CardChannel {
|
|
67
|
-
send(payload: CardPayload): Promise<CardMessage>;
|
|
68
|
-
/** A thread, where those a card is for are mentioned so they hear of it; a channel mentions no one. */
|
|
69
|
-
thread?: boolean;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
57
|
export interface OwnerCardsOptions {
|
|
73
58
|
/** The primary owner's Discord user id. */
|
|
74
59
|
ownerId: string;
|
|
@@ -92,47 +77,6 @@ export interface OwnerCardsOptions {
|
|
|
92
77
|
resume?(turn: LateTurn): void;
|
|
93
78
|
}
|
|
94
79
|
|
|
95
|
-
/** A turn a card answered after its turn stopped waiting starts, as a message in its channel. */
|
|
96
|
-
export interface LateTurn {
|
|
97
|
-
channelId: string;
|
|
98
|
-
/** The card's message, which the turn's message stands for. */
|
|
99
|
-
messageId: string;
|
|
100
|
-
/** Who answered. */
|
|
101
|
-
user: DiscordUser;
|
|
102
|
-
/** The server the channel belongs to; absent in a direct message. */
|
|
103
|
-
guildId?: string;
|
|
104
|
-
isDirect: boolean;
|
|
105
|
-
text: string;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
/** Where and by whom a card was answered. */
|
|
109
|
-
interface Answerer {
|
|
110
|
-
user: DiscordUser;
|
|
111
|
-
messageId?: string;
|
|
112
|
-
guildId?: string;
|
|
113
|
-
isDirect: boolean;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
/** One open card: how it looks, and how it ends. */
|
|
117
|
-
interface OpenCard {
|
|
118
|
-
title: string;
|
|
119
|
-
sections: string[];
|
|
120
|
-
/** The card's controls, disabled once it has ended. */
|
|
121
|
-
rows(disabled: boolean): Rows;
|
|
122
|
-
/**
|
|
123
|
-
* Ends the card with its answer; the answering interaction shows the outcome. A card whose
|
|
124
|
-
* turn stopped waiting starts a turn with the answer instead.
|
|
125
|
-
*/
|
|
126
|
-
end(value: unknown, by: Answerer): void;
|
|
127
|
-
question?: OwnerQuestion;
|
|
128
|
-
/** Options chosen together with "Other…", kept while its form is open. */
|
|
129
|
-
picked?: string[];
|
|
130
|
-
/** Posted in a thread, so it leads with the mention of who may answer. */
|
|
131
|
-
mention?: boolean;
|
|
132
|
-
/** Who may answer, and what to tell someone else who presses it. */
|
|
133
|
-
audience: Audience;
|
|
134
|
-
}
|
|
135
|
-
|
|
136
80
|
/**
|
|
137
81
|
* The cards in the assistant's channels: approvals of held actions and ask_user questions,
|
|
138
82
|
* answered with buttons, a menu, or a form, by those the turn's prompt scope names (see
|
|
@@ -164,8 +108,20 @@ export class OwnerCards implements InteractionModule {
|
|
|
164
108
|
prompts(channel: ChannelKey, scope?: PromptScope): Prompts | undefined {
|
|
165
109
|
const channelId = parseChannelKey(channel).id;
|
|
166
110
|
return {
|
|
167
|
-
confirm: async (
|
|
168
|
-
|
|
111
|
+
confirm: async (
|
|
112
|
+
title,
|
|
113
|
+
message,
|
|
114
|
+
signal,
|
|
115
|
+
minTier = "owner",
|
|
116
|
+
wait,
|
|
117
|
+
_details,
|
|
118
|
+
options,
|
|
119
|
+
) => {
|
|
120
|
+
const audience = await this.#audiences.approval(
|
|
121
|
+
scope,
|
|
122
|
+
minTier,
|
|
123
|
+
options?.approvers,
|
|
124
|
+
);
|
|
169
125
|
// A private conversation's call above its person's tier is no one's to approve.
|
|
170
126
|
if (!audience) return "unavailable";
|
|
171
127
|
return this.#post<Approval>(
|
|
@@ -183,11 +139,19 @@ export class OwnerCards implements InteractionModule {
|
|
|
183
139
|
wait.late({
|
|
184
140
|
kind: "approval",
|
|
185
141
|
approved: value === "approved",
|
|
186
|
-
by,
|
|
142
|
+
by: by.user.name,
|
|
143
|
+
...(by.approver ? { approvedBy: by.approver } : {}),
|
|
187
144
|
}),
|
|
188
145
|
},
|
|
189
146
|
}
|
|
190
147
|
: {}),
|
|
148
|
+
...(options?.approvedBy
|
|
149
|
+
? {
|
|
150
|
+
approved: (by: Answerer) => {
|
|
151
|
+
if (by.approver) options.approvedBy?.(by.approver);
|
|
152
|
+
},
|
|
153
|
+
}
|
|
154
|
+
: {}),
|
|
191
155
|
title,
|
|
192
156
|
sections: [message],
|
|
193
157
|
rows: (id, disabled) => [
|
|
@@ -228,7 +192,7 @@ export class OwnerCards implements InteractionModule {
|
|
|
228
192
|
wait.late({
|
|
229
193
|
kind: "question",
|
|
230
194
|
answer: value as OwnerAnswer,
|
|
231
|
-
by,
|
|
195
|
+
by: by.user.name,
|
|
232
196
|
}),
|
|
233
197
|
},
|
|
234
198
|
}
|
|
@@ -262,8 +226,10 @@ export class OwnerCards implements InteractionModule {
|
|
|
262
226
|
footer: string;
|
|
263
227
|
wait?: {
|
|
264
228
|
pending: T;
|
|
265
|
-
late(value: unknown, by:
|
|
229
|
+
late(value: unknown, by: Answerer): ReturnType<PromptWait["late"]>;
|
|
266
230
|
};
|
|
231
|
+
/** Hears who approved the card, before it resolves approved in its turn. */
|
|
232
|
+
approved?(by: Answerer): void;
|
|
267
233
|
},
|
|
268
234
|
): Promise<T> {
|
|
269
235
|
const { logger, graceMs = CARD_GRACE_MS, resume } = this.#options;
|
|
@@ -323,10 +289,11 @@ export class OwnerCards implements InteractionModule {
|
|
|
323
289
|
audience: card.audience,
|
|
324
290
|
end: (value, by) => {
|
|
325
291
|
const answeredLate = late;
|
|
292
|
+
if (value === "approved" && !answeredLate) card.approved?.(by);
|
|
326
293
|
settle(value as T, undefined);
|
|
327
294
|
if (!answeredLate || !wait || !resume) return;
|
|
328
295
|
try {
|
|
329
|
-
const text = wait.late(value, by
|
|
296
|
+
const text = wait.late(value, by);
|
|
330
297
|
if (text === undefined) return;
|
|
331
298
|
resume({
|
|
332
299
|
channelId,
|
|
@@ -429,7 +396,19 @@ export class OwnerCards implements InteractionModule {
|
|
|
429
396
|
);
|
|
430
397
|
if (interaction.isButton() && (action === "yes" || action === "no")) {
|
|
431
398
|
const approved = action === "yes";
|
|
432
|
-
|
|
399
|
+
const approver = approved
|
|
400
|
+
? await card.audience.approver?.(user).catch((error: unknown) => {
|
|
401
|
+
this.#options.logger.warn(
|
|
402
|
+
{ err: error },
|
|
403
|
+
"could not tell who approved; the call runs without an approver",
|
|
404
|
+
);
|
|
405
|
+
return undefined;
|
|
406
|
+
})
|
|
407
|
+
: undefined;
|
|
408
|
+
card.end(
|
|
409
|
+
approved ? "approved" : "declined",
|
|
410
|
+
approver ? { ...by, approver } : by,
|
|
411
|
+
);
|
|
433
412
|
await interaction.update(
|
|
434
413
|
closed(approved ? messages().cardApproved : messages().cardDeclined),
|
|
435
414
|
);
|
|
@@ -19,6 +19,13 @@ export interface HeldCall {
|
|
|
19
19
|
action: string;
|
|
20
20
|
/** The lowest tier that may approve it, when higher than its tool's own; see `HoldRule.approvalTier`. */
|
|
21
21
|
minTier?: Tier;
|
|
22
|
+
/**
|
|
23
|
+
* The principal ids that may approve it instead of the speaker whose turn held it, as the tool's
|
|
24
|
+
* `approvers` named them; the owners may still. Absent when the tool names none, and an empty
|
|
25
|
+
* list leaves the owners alone. They are kept with the call, so a message that confirms it after
|
|
26
|
+
* a restart honors them; a private conversation ignores them.
|
|
27
|
+
*/
|
|
28
|
+
approverIds?: string[];
|
|
22
29
|
}
|
|
23
30
|
|
|
24
31
|
export interface PendingConfirmation {
|
package/src/core/domain/ports.ts
CHANGED
|
@@ -21,7 +21,10 @@ export interface TurnRequest {
|
|
|
21
21
|
text: string;
|
|
22
22
|
/** Files and images the message carries; none when absent. */
|
|
23
23
|
attachments?: TurnAttachments;
|
|
24
|
-
/**
|
|
24
|
+
/**
|
|
25
|
+
* The speaker confirmed the channel's pending actions, so they may run in this turn, except
|
|
26
|
+
* those that named approvers the speaker is neither among nor an owner of, which stay pending.
|
|
27
|
+
*/
|
|
25
28
|
confirmed?: boolean;
|
|
26
29
|
/** Set for a turn of an agent-server agent. */
|
|
27
30
|
agent?: AgentTurnScope;
|
package/src/core/holds.ts
CHANGED
|
@@ -57,6 +57,27 @@ export interface HoldRule {
|
|
|
57
57
|
input: Record<string, unknown>,
|
|
58
58
|
context: HoldContext,
|
|
59
59
|
): Tier | undefined;
|
|
60
|
+
/**
|
|
61
|
+
* The principal ids that may approve a call of this rule's tool instead of the speaker whose turn
|
|
62
|
+
* makes it, looked up as that turn; undefined when the rule names none and the speaker approves
|
|
63
|
+
* it, as for any held call. The confirmation gate asks it, with the same wait and `turn.signal`
|
|
64
|
+
* as `describeInTurn`, for each call it holds; a rejection or a timeout leaves the owners alone
|
|
65
|
+
* to approve it. Where several rules name approvers for one call, only principals every rule
|
|
66
|
+
* names may approve. It must change nothing.
|
|
67
|
+
*/
|
|
68
|
+
approversInTurn?(
|
|
69
|
+
tool: string,
|
|
70
|
+
input: Record<string, unknown>,
|
|
71
|
+
context: HoldContext,
|
|
72
|
+
turn: ToolTurn,
|
|
73
|
+
): readonly string[] | undefined | Promise<readonly string[] | undefined>;
|
|
74
|
+
/**
|
|
75
|
+
* Whether `approversInTurn` may name approvers for some call of `tool`, for a rule whose answer
|
|
76
|
+
* depends on the input. Asked when the input is not known yet, as for a precheck script's call
|
|
77
|
+
* whose arguments are computed when it runs; a rule with `approversInTurn` and without this is
|
|
78
|
+
* taken to name approvers for every tool.
|
|
79
|
+
*/
|
|
80
|
+
mayName?(tool: string): boolean;
|
|
60
81
|
}
|
|
61
82
|
|
|
62
83
|
/** The description of a call that must be approved first, or undefined to let it run. */
|
|
@@ -84,6 +105,19 @@ export type HoldCheck = ((
|
|
|
84
105
|
input: Record<string, unknown>,
|
|
85
106
|
context: HoldContext,
|
|
86
107
|
): Tier | undefined;
|
|
108
|
+
/**
|
|
109
|
+
* Who may approve the call instead of its speaker, from the rules that name approvers; see
|
|
110
|
+
* `HoldRule.approversInTurn`. Absent when no rule has the lookup; it answers at once when every
|
|
111
|
+
* rule asked did, and otherwise as a promise that rejects when a rule's does.
|
|
112
|
+
*/
|
|
113
|
+
approversInTurn?(
|
|
114
|
+
tool: string,
|
|
115
|
+
input: Record<string, unknown>,
|
|
116
|
+
context: HoldContext,
|
|
117
|
+
turn: ToolTurn,
|
|
118
|
+
): readonly string[] | undefined | Promise<readonly string[] | undefined>;
|
|
119
|
+
/** The name of a rule that may name approvers for some call of `tool` whatever its input; see `HoldRule.mayName`. */
|
|
120
|
+
mayName?(tool: string): string | undefined;
|
|
87
121
|
};
|
|
88
122
|
|
|
89
123
|
/** The higher of two tiers; undefined only when both are. */
|
|
@@ -140,9 +174,35 @@ export function holdChain(rules: readonly HoldRule[]): HoldCheck {
|
|
|
140
174
|
higherTier(tier, rule.approvalTier?.(tool, input, context)),
|
|
141
175
|
undefined,
|
|
142
176
|
);
|
|
177
|
+
if (rules.some((rule) => rule.approversInTurn)) {
|
|
178
|
+
check.mayName = (tool) =>
|
|
179
|
+
rules.find(
|
|
180
|
+
(rule) => rule.approversInTurn && (rule.mayName?.(tool) ?? true),
|
|
181
|
+
)?.name;
|
|
182
|
+
check.approversInTurn = (tool, input, context, turn) => {
|
|
183
|
+
const named = rules.flatMap((rule) => {
|
|
184
|
+
const approvers = rule.approversInTurn?.(tool, input, context, turn);
|
|
185
|
+
return approvers === undefined ? [] : [approvers];
|
|
186
|
+
});
|
|
187
|
+
if (named.length === 0) return undefined;
|
|
188
|
+
if (named.some(isPromise))
|
|
189
|
+
return Promise.all(named).then(commonApprovers);
|
|
190
|
+
return commonApprovers(named as (readonly string[] | undefined)[]);
|
|
191
|
+
};
|
|
192
|
+
}
|
|
143
193
|
return check;
|
|
144
194
|
}
|
|
145
195
|
|
|
196
|
+
/** The principals every list names, in the first list's order; undefined when there are no lists. */
|
|
197
|
+
function commonApprovers(
|
|
198
|
+
lists: readonly (readonly string[] | undefined)[],
|
|
199
|
+
): readonly string[] | undefined {
|
|
200
|
+
const named = lists.filter((list): list is readonly string[] => !!list);
|
|
201
|
+
const [first, ...rest] = named;
|
|
202
|
+
if (!first) return undefined;
|
|
203
|
+
return first.filter((id) => rest.every((list) => list.includes(id)));
|
|
204
|
+
}
|
|
205
|
+
|
|
146
206
|
/** Whether a hold's answer comes later. */
|
|
147
207
|
export function isPromise<T>(value: T | Promise<T>): value is Promise<T> {
|
|
148
208
|
return (
|
package/src/core/i18n/cards.ts
CHANGED
|
@@ -14,6 +14,8 @@ export function cardsEn(ctx: CatalogContext) {
|
|
|
14
14
|
`-# Who can approve: ${userIds.map((id) => `<@${id}>`).join(" ")}`,
|
|
15
15
|
cardApproversRefusal:
|
|
16
16
|
"This approval is for the speaker whose turn asked for it.",
|
|
17
|
+
cardNamedApproversRefusal:
|
|
18
|
+
"This approval is for the people it names, and the owners.",
|
|
17
19
|
cardAskerNote: (...userIds: readonly string[]) =>
|
|
18
20
|
`-# Who can answer: ${userIds.map((id) => `<@${id}>`).join(" ")}`,
|
|
19
21
|
cardAskerRefusal: "This question is for another speaker.",
|
|
@@ -44,6 +46,7 @@ export function cardsZhTW(ctx: CatalogContext): ReturnType<typeof cardsEn> {
|
|
|
44
46
|
cardApproversNote: (...userIds: readonly string[]) =>
|
|
45
47
|
`-# 可以核准的人:${userIds.map((id) => `<@${id}>`).join(" ")}`,
|
|
46
48
|
cardApproversRefusal: "這張核准卡是給提出這一輪的發話者的。",
|
|
49
|
+
cardNamedApproversRefusal: "這張核准卡是給卡片上點名的人和擁有者的。",
|
|
47
50
|
cardAskerNote: (...userIds: readonly string[]) =>
|
|
48
51
|
`-# 可以回答的人:${userIds.map((id) => `<@${id}>`).join(" ")}`,
|
|
49
52
|
cardAskerRefusal: "這張問題是問另一位發話者的。",
|