pi-roundtable 0.22.1 → 0.22.3
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 +12 -0
- package/docs/plugins.md +1 -0
- package/package.json +1 -1
- package/src/core/agents/agent-store.ts +90 -66
- package/src/core/discord/owner-card-asks.ts +42 -38
- package/src/core/discord/owner-card-questionnaire.ts +2 -1
- package/src/core/discord/owner-card-rows.ts +19 -1
- package/src/core/discord/owner-card-types.ts +8 -0
- package/src/core/discord/owner-cards.ts +18 -0
- package/src/core/i18n/cards.ts +2 -0
- package/src/core/interactions/prompts.ts +6 -4
- package/src/core/modules/skills/skill-store.ts +100 -47
- package/src/core/runtime/ask-questions.ts +3 -3
- package/src/core/runtime/extensions/ask-user-text.ts +7 -4
- package/src/core/testing/surface-contract-questionnaire.ts +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,18 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [0.22.3] - 2026-10-23
|
|
9
|
+
|
|
10
|
+
### Changed
|
|
11
|
+
|
|
12
|
+
- Question cards can be cancelled (`pi-roundtable`). Every Discord `ask_user` card, single or several questions, has a **Cancel** button beside Submit, for those who may answer it. Pressed while the turn still waits, `Prompts.ask` and `Prompts.questionnaire` resolve the new `"declined"`, and the `ask_user` result tells the model the owner cancelled the question without answering, not to ask it again, and to continue without the answer or end the turn if it cannot. Pressed after the grace period, it only closes the card and starts no turn. The card shows "❌ Cancelled, not answered." with its controls disabled. A surface that implements `Prompts` need not return `"declined"`; code that reads `ask` or `questionnaire` results should handle it. See [a prompt that outlives its turn](docs/plugins.md#a-prompt-that-outlives-its-turn-promptwait).
|
|
13
|
+
|
|
14
|
+
## [0.22.2] - 2026-10-21
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
|
|
18
|
+
- A skill linked while another was removed no longer vanishes from the registry (`pi-roundtable`). `skill_unlink` reloaded the whole skill cache from the database after its delete, so a `skill_link` running at the same time, as two tool calls of one model message do, could commit its row after the reload had read the table and then be dropped from memory: the skill was missing from `skill_list`, unknown to `agent_skills` and `skill_group_set`, and could be neither unlinked nor linked again until a restart. The skill store now updates its memory from what each write committed and runs its writes one at a time, and a link that meets an existing row says "Already registered" instead of PostgreSQL's duplicate-key error. The agent store's seeding no longer reloads its cache either, and its agent and group writes also run one at a time, so an agent archived while its group is updated cannot come back as a member.
|
|
19
|
+
|
|
8
20
|
## [0.22.1] - 2026-10-21
|
|
9
21
|
|
|
10
22
|
### Changed
|
package/docs/plugins.md
CHANGED
|
@@ -2730,6 +2730,7 @@ A coding worker's cards give no `wait`, since nothing could resume a worker: it
|
|
|
2730
2730
|
|
|
2731
2731
|
On Discord the grace period is `discord.cardGraceSeconds` (default 120), and the cards carry no countdown.
|
|
2732
2732
|
A card answered late is edited to show the answer with its controls disabled, as one answered in time is; a second press finds it answered.
|
|
2733
|
+
Every question card, single or several questions, has a **Cancel** button beside its answer controls, for the same audience as the answers. Pressed while the turn still waits, `ask` and `questionnaire` resolve `"declined"`, and `ask_user` tells the model the person cancelled the question without answering, not to ask it again, and to go on without the answer or end its turn if it cannot. Pressed after the turn stopped waiting, it only closes the card: no turn starts and nothing stays pending. Either way the card shows "❌ Cancelled, not answered." with its controls disabled, and a second press finds it ended.
|
|
2733
2734
|
Open cards live in memory: after a restart, pressing one replies, to the presser alone, that it is no longer valid and to ask again.
|
|
2734
2735
|
|
|
2735
2736
|
#### Several questions on one card: `questionnaire` and `AskSupport`
|
package/package.json
CHANGED
|
@@ -58,6 +58,8 @@ export class PgAgentStore implements AgentDirectory {
|
|
|
58
58
|
readonly #sql: SQL;
|
|
59
59
|
readonly #agents = new Map<string, Agent>();
|
|
60
60
|
readonly #groups = new Map<string, AgentGroup>();
|
|
61
|
+
/** The tail of the agent and group writes, which run one at a time so none reads a map another is about to change. */
|
|
62
|
+
#queue: Promise<unknown> = Promise.resolve();
|
|
61
63
|
|
|
62
64
|
readonly #guild: string;
|
|
63
65
|
readonly #messages: GroupMessages;
|
|
@@ -97,18 +99,27 @@ export class PgAgentStore implements AgentDirectory {
|
|
|
97
99
|
|
|
98
100
|
/** Inserts seed agents that do not exist yet; existing rows, edited or not, are left alone. */
|
|
99
101
|
async seed(seeds: readonly AgentSeed[]): Promise<string[]> {
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
const
|
|
102
|
+
return this.#serial(async () => {
|
|
103
|
+
const added: string[] = [];
|
|
104
|
+
for (const seed of seeds) {
|
|
105
|
+
const rows: AgentRow[] = await this.#sql`
|
|
103
106
|
INSERT INTO agents (guild_id, name, display_name, prompt, avatar_prompt, channel_id)
|
|
104
107
|
VALUES (${this.#guild}, ${seed.name}, ${seed.displayName}, ${seed.prompt}, ${seed.avatarPrompt},
|
|
105
108
|
${seed.channelId ?? null})
|
|
106
109
|
ON CONFLICT DO NOTHING
|
|
107
|
-
RETURNING
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
110
|
+
RETURNING *`;
|
|
111
|
+
// Only the inserted rows join the map: reloading it whole would drop a write that
|
|
112
|
+
// committed after its SELECT.
|
|
113
|
+
if (rows[0]) added.push(this.#keepAgent(rows[0]).name);
|
|
114
|
+
}
|
|
115
|
+
return added;
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
#serial<T>(write: () => Promise<T>): Promise<T> {
|
|
120
|
+
const run = this.#queue.then(write);
|
|
121
|
+
this.#queue = run.catch(() => undefined);
|
|
122
|
+
return run;
|
|
112
123
|
}
|
|
113
124
|
|
|
114
125
|
agents(): Agent[] {
|
|
@@ -178,18 +189,20 @@ export class PgAgentStore implements AgentDirectory {
|
|
|
178
189
|
avatarPrompt: string;
|
|
179
190
|
channelId: string;
|
|
180
191
|
}): Promise<Agent> {
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
192
|
+
return this.#serial(async () => {
|
|
193
|
+
checkAgentName(agent.name);
|
|
194
|
+
checkDisplayName(agent.displayName);
|
|
195
|
+
checkPrompt(agent.prompt);
|
|
196
|
+
// An agent of a host without an image provider has no avatar prompt.
|
|
197
|
+
if (agent.avatarPrompt !== "") checkAvatarPrompt(agent.avatarPrompt);
|
|
198
|
+
this.#checkFree(agent.name);
|
|
199
|
+
const rows: AgentRow[] = await this.#sql`
|
|
188
200
|
INSERT INTO agents (guild_id, name, display_name, prompt, avatar_prompt, channel_id)
|
|
189
201
|
VALUES (${this.#guild}, ${agent.name}, ${agent.displayName.trim()}, ${agent.prompt}, ${agent.avatarPrompt},
|
|
190
202
|
${agent.channelId})
|
|
191
203
|
RETURNING *`;
|
|
192
|
-
|
|
204
|
+
return this.#keepAgent(rows[0]);
|
|
205
|
+
});
|
|
193
206
|
}
|
|
194
207
|
|
|
195
208
|
/** A name that createAgent would accept, checked before its channel is made. */
|
|
@@ -211,20 +224,22 @@ export class PgAgentStore implements AgentDirectory {
|
|
|
211
224
|
thinking?: ThinkingLevel | null;
|
|
212
225
|
},
|
|
213
226
|
): Promise<Agent> {
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
227
|
+
return this.#serial(async () => {
|
|
228
|
+
const current = this.activeAgent(name);
|
|
229
|
+
if (change.displayName !== undefined)
|
|
230
|
+
checkDisplayName(change.displayName);
|
|
231
|
+
if (change.prompt !== undefined) checkPrompt(change.prompt);
|
|
232
|
+
if (change.avatarPrompt !== undefined)
|
|
233
|
+
checkAvatarPrompt(change.avatarPrompt);
|
|
234
|
+
if (change.model) checkModelRef(change.model);
|
|
235
|
+
if (change.thinking) checkThinking(change.thinking);
|
|
236
|
+
const model =
|
|
237
|
+
change.model === undefined ? (current.model ?? null) : change.model;
|
|
238
|
+
const thinking =
|
|
239
|
+
change.thinking === undefined
|
|
240
|
+
? (current.thinking ?? null)
|
|
241
|
+
: change.thinking;
|
|
242
|
+
const rows: AgentRow[] = await this.#sql`
|
|
228
243
|
UPDATE agents SET
|
|
229
244
|
display_name = ${change.displayName?.trim() ?? current.displayName},
|
|
230
245
|
prompt = ${change.prompt ?? current.prompt},
|
|
@@ -236,29 +251,33 @@ export class PgAgentStore implements AgentDirectory {
|
|
|
236
251
|
updated_at = now()
|
|
237
252
|
WHERE guild_id = ${this.#guild} AND name = ${name}
|
|
238
253
|
RETURNING *`;
|
|
239
|
-
|
|
254
|
+
return this.#keepAgent(rows[0]);
|
|
255
|
+
});
|
|
240
256
|
}
|
|
241
257
|
|
|
242
258
|
/** Archives an agent and takes it out of every group; says which groups it left. */
|
|
243
259
|
async archiveAgent(name: string): Promise<AgentGroup[]> {
|
|
244
|
-
|
|
260
|
+
return this.#serial(async () => {
|
|
261
|
+
const rows: AgentRow[] = await this.#sql`
|
|
245
262
|
UPDATE agents SET status = 'archived', updated_at = now()
|
|
246
263
|
WHERE guild_id = ${this.#guild} AND name = ${name} RETURNING *`;
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
264
|
+
this.#keepAgent(rows[0]);
|
|
265
|
+
const left: AgentGroup[] = [];
|
|
266
|
+
for (const group of this.groups()) {
|
|
267
|
+
if (group.status !== "active" || !group.members.includes(name))
|
|
268
|
+
continue;
|
|
269
|
+
const members = group.members.filter((m) => m !== name);
|
|
270
|
+
const host = group.host === name ? (members[0] ?? name) : group.host;
|
|
271
|
+
left.push(
|
|
272
|
+
await this.#writeGroup(group.name, {
|
|
273
|
+
members,
|
|
274
|
+
host,
|
|
275
|
+
status: members.length < MIN_GROUP_MEMBERS ? "archived" : "active",
|
|
276
|
+
}),
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
return left;
|
|
280
|
+
});
|
|
262
281
|
}
|
|
263
282
|
|
|
264
283
|
#keepAgent(row: AgentRow | undefined): Agent {
|
|
@@ -296,39 +315,44 @@ export class PgAgentStore implements AgentDirectory {
|
|
|
296
315
|
members: string[];
|
|
297
316
|
host: string;
|
|
298
317
|
}): Promise<AgentGroup> {
|
|
299
|
-
this
|
|
300
|
-
|
|
301
|
-
|
|
318
|
+
return this.#serial(async () => {
|
|
319
|
+
this.checkNewGroup(group.name, group.members, group.host);
|
|
320
|
+
checkDisplayName(group.displayName);
|
|
321
|
+
const rows: GroupRow[] = await this.#sql`
|
|
302
322
|
INSERT INTO agent_groups (guild_id, name, display_name, channel_id, members, host)
|
|
303
323
|
VALUES (${this.#guild}, ${group.name}, ${group.displayName.trim()}, ${group.channelId},
|
|
304
324
|
${group.members.join(",")}, ${group.host})
|
|
305
325
|
RETURNING *`;
|
|
306
|
-
|
|
326
|
+
return this.#keepGroup(rows[0]);
|
|
327
|
+
});
|
|
307
328
|
}
|
|
308
329
|
|
|
309
330
|
async updateGroup(
|
|
310
331
|
name: string,
|
|
311
332
|
change: { displayName?: string; members?: string[]; host?: string },
|
|
312
333
|
): Promise<AgentGroup> {
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
change.
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
334
|
+
return this.#serial(async () => {
|
|
335
|
+
const current = this.#groups.get(name);
|
|
336
|
+
if (current?.status !== "active")
|
|
337
|
+
throw new AgentError(`There is no active group "${name}".`);
|
|
338
|
+
if (change.displayName !== undefined)
|
|
339
|
+
checkDisplayName(change.displayName);
|
|
340
|
+
const members = change.members ?? current.members;
|
|
341
|
+
const host =
|
|
342
|
+
change.host ??
|
|
343
|
+
(members.includes(current.host) ? current.host : (members[0] ?? ""));
|
|
344
|
+
this.#checkMembers(members, host);
|
|
345
|
+
return this.#writeGroup(name, {
|
|
346
|
+
displayName: change.displayName?.trim() ?? current.displayName,
|
|
347
|
+
members,
|
|
348
|
+
host,
|
|
349
|
+
status: "active",
|
|
350
|
+
});
|
|
327
351
|
});
|
|
328
352
|
}
|
|
329
353
|
|
|
330
354
|
async archiveGroup(name: string): Promise<AgentGroup> {
|
|
331
|
-
return this.#writeGroup(name, { status: "archived" });
|
|
355
|
+
return this.#serial(() => this.#writeGroup(name, { status: "archived" }));
|
|
332
356
|
}
|
|
333
357
|
|
|
334
358
|
async #writeGroup(
|
|
@@ -11,7 +11,7 @@ import type {
|
|
|
11
11
|
import type { CardAudiences } from "./card-audience.ts";
|
|
12
12
|
import type { QuestionnaireForm } from "./owner-card-questionnaire.ts";
|
|
13
13
|
import { newDraft, questionnaireCard } from "./owner-card-questionnaire.ts";
|
|
14
|
-
import { askRows, previewsRow } from "./owner-card-rows.ts";
|
|
14
|
+
import { askRows, cancelRow, previewsRow } from "./owner-card-rows.ts";
|
|
15
15
|
import type { PostCard } from "./owner-card-types.ts";
|
|
16
16
|
|
|
17
17
|
/** What the Discord card shows of a questionnaire: four questions, previews, and a note. */
|
|
@@ -34,7 +34,7 @@ export function askPrompts(args: {
|
|
|
34
34
|
ask: async (title, question, signal, wait) => {
|
|
35
35
|
const audience = await audiences.question(scope);
|
|
36
36
|
if (!audience) return undefined;
|
|
37
|
-
return post<OwnerAnswer | "pending" | undefined>(
|
|
37
|
+
return post<OwnerAnswer | "pending" | "declined" | undefined>(
|
|
38
38
|
channelId,
|
|
39
39
|
signal,
|
|
40
40
|
undefined,
|
|
@@ -57,7 +57,11 @@ export function askPrompts(args: {
|
|
|
57
57
|
title,
|
|
58
58
|
sections: [question.question],
|
|
59
59
|
question,
|
|
60
|
-
|
|
60
|
+
declined: "declined",
|
|
61
|
+
rows: (id, disabled) => [
|
|
62
|
+
...askRows(id, question, disabled),
|
|
63
|
+
cancelRow(id, disabled),
|
|
64
|
+
],
|
|
61
65
|
footer: messages().cardQuestionFooter,
|
|
62
66
|
},
|
|
63
67
|
);
|
|
@@ -68,40 +72,39 @@ export function askPrompts(args: {
|
|
|
68
72
|
const [only] = questionnaire.questions;
|
|
69
73
|
if (only && questionnaire.questions.length === 1) {
|
|
70
74
|
// One question is the 0.19 card, answered by one click, with a Previews button.
|
|
71
|
-
const answer = await post<
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
return answer === "pending" || answer === undefined
|
|
75
|
+
const answer = await post<
|
|
76
|
+
OwnerAnswer | "pending" | "declined" | undefined
|
|
77
|
+
>(channelId, signal, undefined, undefined, {
|
|
78
|
+
audience,
|
|
79
|
+
...(wait
|
|
80
|
+
? {
|
|
81
|
+
wait: {
|
|
82
|
+
pending: "pending",
|
|
83
|
+
late: (value, by) =>
|
|
84
|
+
wait.late({
|
|
85
|
+
kind: "questionnaire",
|
|
86
|
+
answer: { answers: [value as OwnerAnswer] },
|
|
87
|
+
by: by.user.name,
|
|
88
|
+
}),
|
|
89
|
+
},
|
|
90
|
+
}
|
|
91
|
+
: {}),
|
|
92
|
+
title,
|
|
93
|
+
sections: [only.question],
|
|
94
|
+
question: only,
|
|
95
|
+
previews: questionnaire,
|
|
96
|
+
declined: "declined",
|
|
97
|
+
rows: (id, disabled) => [
|
|
98
|
+
...askRows(id, only, disabled),
|
|
99
|
+
only.options.some((o) => o.preview !== undefined)
|
|
100
|
+
? previewsRow(id, disabled)
|
|
101
|
+
: cancelRow(id, disabled),
|
|
102
|
+
],
|
|
103
|
+
footer: messages().cardQuestionFooter,
|
|
104
|
+
});
|
|
105
|
+
return answer === "pending" ||
|
|
106
|
+
answer === "declined" ||
|
|
107
|
+
answer === undefined
|
|
105
108
|
? answer
|
|
106
109
|
: { answers: [answer] };
|
|
107
110
|
}
|
|
@@ -109,7 +112,7 @@ export function askPrompts(args: {
|
|
|
109
112
|
questionnaire,
|
|
110
113
|
draft: newDraft(questionnaire.questions.length),
|
|
111
114
|
};
|
|
112
|
-
return post<QuestionnaireAnswer | "pending" | undefined>(
|
|
115
|
+
return post<QuestionnaireAnswer | "pending" | "declined" | undefined>(
|
|
113
116
|
channelId,
|
|
114
117
|
signal,
|
|
115
118
|
undefined,
|
|
@@ -132,6 +135,7 @@ export function askPrompts(args: {
|
|
|
132
135
|
title,
|
|
133
136
|
sections: [],
|
|
134
137
|
form,
|
|
138
|
+
declined: "declined",
|
|
135
139
|
build: (id, footer, disabled) =>
|
|
136
140
|
questionnaireCard({
|
|
137
141
|
id,
|
|
@@ -19,7 +19,7 @@ import {
|
|
|
19
19
|
type Questionnaire,
|
|
20
20
|
type QuestionnaireAnswer,
|
|
21
21
|
} from "../interactions/questionnaire.ts";
|
|
22
|
-
import { CARD_PREFIX } from "./owner-card-rows.ts";
|
|
22
|
+
import { CARD_PREFIX, cancelButton } from "./owner-card-rows.ts";
|
|
23
23
|
import type { CardPayload } from "./owner-card-types.ts";
|
|
24
24
|
import { ephemeralPanel, ownerPanels, plain } from "./owner-panel.ts";
|
|
25
25
|
import { clip } from "./utf16.ts";
|
|
@@ -127,6 +127,7 @@ function buttons(id: string, form: QuestionnaireForm, disabled: boolean) {
|
|
|
127
127
|
.setDisabled(disabled);
|
|
128
128
|
row.addComponents(
|
|
129
129
|
button("submit", messages().cardSubmitLabel, ButtonStyle.Primary),
|
|
130
|
+
cancelButton(id, disabled),
|
|
130
131
|
button("notes", messages().cardWriteLabel, ButtonStyle.Secondary),
|
|
131
132
|
);
|
|
132
133
|
if (questions.some((q) => q.options.some((o) => o.preview !== undefined)))
|
|
@@ -85,7 +85,7 @@ export function askRows(
|
|
|
85
85
|
];
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
-
/** The Previews button of a single-question card
|
|
88
|
+
/** The Previews button of a single-question card, which answers nothing, beside its Cancel button. */
|
|
89
89
|
export function previewsRow(id: string, disabled: boolean) {
|
|
90
90
|
return new ActionRowBuilder<ButtonBuilder>().addComponents(
|
|
91
91
|
new ButtonBuilder()
|
|
@@ -93,6 +93,24 @@ export function previewsRow(id: string, disabled: boolean) {
|
|
|
93
93
|
.setLabel(messages().cardPreviewsLabel)
|
|
94
94
|
.setStyle(ButtonStyle.Secondary)
|
|
95
95
|
.setDisabled(disabled),
|
|
96
|
+
cancelButton(id, disabled),
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** A question card's Cancel button: the question is declined, not answered. */
|
|
101
|
+
export function cancelButton(id: string, disabled: boolean): ButtonBuilder {
|
|
102
|
+
return new ButtonBuilder()
|
|
103
|
+
.setCustomId(`${CARD_PREFIX}${id}:cancel`)
|
|
104
|
+
.setLabel(messages().cardCancelLabel)
|
|
105
|
+
.setEmoji("❌")
|
|
106
|
+
.setStyle(ButtonStyle.Secondary)
|
|
107
|
+
.setDisabled(disabled);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** A single-question card's row with only its Cancel button. */
|
|
111
|
+
export function cancelRow(id: string, disabled: boolean) {
|
|
112
|
+
return new ActionRowBuilder<ButtonBuilder>().addComponents(
|
|
113
|
+
cancelButton(id, disabled),
|
|
96
114
|
);
|
|
97
115
|
}
|
|
98
116
|
|
|
@@ -72,6 +72,12 @@ export interface OpenCard {
|
|
|
72
72
|
* already, answered by another press or withdrawn by its plugin, and this answer changed nothing.
|
|
73
73
|
*/
|
|
74
74
|
end(value: unknown, by: Answerer): boolean;
|
|
75
|
+
/**
|
|
76
|
+
* Ends a question card declined, its Cancel button: a turn still waiting gets `declined`, and
|
|
77
|
+
* a card whose turn stopped waiting only closes, starting no turn. False when the card had
|
|
78
|
+
* ended already or is not a question.
|
|
79
|
+
*/
|
|
80
|
+
decline(): boolean;
|
|
75
81
|
question?: OwnerQuestion;
|
|
76
82
|
/** A questionnaire card's questions and its shared draft. */
|
|
77
83
|
form?: QuestionnaireForm;
|
|
@@ -99,6 +105,8 @@ export interface CardSpec<T> {
|
|
|
99
105
|
question?: OwnerQuestion;
|
|
100
106
|
form?: QuestionnaireForm;
|
|
101
107
|
previews?: Questionnaire;
|
|
108
|
+
/** What a question card resolves with when it is cancelled; without it the card has no Cancel. */
|
|
109
|
+
declined?: T;
|
|
102
110
|
/** Draws the card itself, as a questionnaire does; without it the card is the title, sections and rows. */
|
|
103
111
|
build?(id: string, footer: string, disabled: boolean): CardPayload;
|
|
104
112
|
rows?(id: string, disabled: boolean): Rows;
|
|
@@ -295,6 +295,12 @@ export class OwnerCards implements InteractionModule {
|
|
|
295
295
|
}
|
|
296
296
|
return true;
|
|
297
297
|
},
|
|
298
|
+
decline: () => {
|
|
299
|
+
if (!this.#open.has(id) || !("declined" in card)) return false;
|
|
300
|
+
// After the grace period settling only closes the card: no turn starts.
|
|
301
|
+
settle(card.declined as T, undefined);
|
|
302
|
+
return true;
|
|
303
|
+
},
|
|
298
304
|
...(card.question ? { question: card.question } : {}),
|
|
299
305
|
...(card.form ? { form: card.form } : {}),
|
|
300
306
|
...(card.previews ? { previews: card.previews } : {}),
|
|
@@ -428,6 +434,18 @@ export class OwnerCards implements InteractionModule {
|
|
|
428
434
|
);
|
|
429
435
|
return true;
|
|
430
436
|
}
|
|
437
|
+
if (interaction.isButton() && action === "cancel") {
|
|
438
|
+
// Another press, a stop or a withdrawal ended it while this one was being checked.
|
|
439
|
+
if (!card.decline()) {
|
|
440
|
+
await interaction.reply({
|
|
441
|
+
content: messages().cardInactive,
|
|
442
|
+
flags: MessageFlags.Ephemeral,
|
|
443
|
+
});
|
|
444
|
+
return true;
|
|
445
|
+
}
|
|
446
|
+
await interaction.update(card.view(messages().cardQuestionCancelled));
|
|
447
|
+
return true;
|
|
448
|
+
}
|
|
431
449
|
if (card.form) await answerForm(card, id, action, interaction);
|
|
432
450
|
else await answerSingle(card, id, action, interaction, by);
|
|
433
451
|
return true;
|
package/src/core/i18n/cards.ts
CHANGED
|
@@ -34,6 +34,7 @@ export function cardsEn(ctx: CatalogContext) {
|
|
|
34
34
|
cardInactive: `This card is no longer valid (${ctx.assistant} restarted since it was posted, or it was already answered or withdrawn); ask again, or reply in text.`,
|
|
35
35
|
cardApproved: "✅ Approved to run.",
|
|
36
36
|
cardDeclined: "❌ Cancelled.",
|
|
37
|
+
cardQuestionCancelled: "❌ Cancelled, not answered.",
|
|
37
38
|
cardAnswerLabel: "Answer…",
|
|
38
39
|
cardOtherLabel: "Other…",
|
|
39
40
|
cardOtherDescription: "Write your own answer",
|
|
@@ -81,6 +82,7 @@ export function cardsZhTW(ctx: CatalogContext): ReturnType<typeof cardsEn> {
|
|
|
81
82
|
cardInactive: `這張卡片已經失效(${ctx.assistant} 在它發出後重新啟動過,或它已經回答過或被撤回);請再問一次,或直接打字回覆。`,
|
|
82
83
|
cardApproved: "✅ 已核准執行。",
|
|
83
84
|
cardDeclined: "❌ 已取消。",
|
|
85
|
+
cardQuestionCancelled: "❌ 已取消,沒有回答。",
|
|
84
86
|
cardAnswerLabel: "回答…",
|
|
85
87
|
cardOtherLabel: "其他…",
|
|
86
88
|
cardOtherDescription: "自己寫答案",
|
|
@@ -248,15 +248,17 @@ export interface Prompts {
|
|
|
248
248
|
options?: ApprovalOptions,
|
|
249
249
|
): Promise<Approval>;
|
|
250
250
|
/**
|
|
251
|
-
* Asks a question; undefined when no one answered it or it was cancelled,
|
|
252
|
-
*
|
|
251
|
+
* Asks a question; undefined when no one answered it or it was cancelled, `declined` when the
|
|
252
|
+
* one asked cancelled the question on its card instead of answering, and `pending`, only with
|
|
253
|
+
* a `wait`, when the turn stopped waiting and the question stays open. A question cancelled
|
|
254
|
+
* after the turn stopped waiting only closes: it starts no turn.
|
|
253
255
|
*/
|
|
254
256
|
ask(
|
|
255
257
|
title: string,
|
|
256
258
|
question: OwnerQuestion,
|
|
257
259
|
signal?: AbortSignal,
|
|
258
260
|
wait?: PromptWait,
|
|
259
|
-
): Promise<OwnerAnswer | "pending" | undefined>;
|
|
261
|
+
): Promise<OwnerAnswer | "pending" | "declined" | undefined>;
|
|
260
262
|
/**
|
|
261
263
|
* What `questionnaire` can show; absent means the 0.19 contract (one question, no previews,
|
|
262
264
|
* no note), and `ask` is the only way to ask.
|
|
@@ -272,7 +274,7 @@ export interface Prompts {
|
|
|
272
274
|
questionnaire: Questionnaire,
|
|
273
275
|
signal?: AbortSignal,
|
|
274
276
|
wait?: PromptWait,
|
|
275
|
-
): Promise<QuestionnaireAnswer | "pending" | undefined>;
|
|
277
|
+
): Promise<QuestionnaireAnswer | "pending" | "declined" | undefined>;
|
|
276
278
|
}
|
|
277
279
|
|
|
278
280
|
/** @deprecated Since 0.9 the prompts are `Prompts`, answered by those their `PromptScope` names; this name goes away in 1.0. */
|
|
@@ -70,6 +70,11 @@ export class SkillStore {
|
|
|
70
70
|
readonly #skills = new Map<string, SkillEntry>();
|
|
71
71
|
readonly #groups = new Map<string, SkillGroup>();
|
|
72
72
|
readonly #carried = new Map<string, string[]>();
|
|
73
|
+
/**
|
|
74
|
+
* The tail of the mutations, which run one at a time: each checks the maps, writes, then
|
|
75
|
+
* updates the maps from what it committed, and none may interleave with another.
|
|
76
|
+
*/
|
|
77
|
+
#queue: Promise<unknown> = Promise.resolve();
|
|
73
78
|
|
|
74
79
|
readonly #guild: string;
|
|
75
80
|
|
|
@@ -152,6 +157,12 @@ export class SkillStore {
|
|
|
152
157
|
]);
|
|
153
158
|
}
|
|
154
159
|
|
|
160
|
+
#serial<T>(write: () => Promise<T>): Promise<T> {
|
|
161
|
+
const run = this.#queue.then(write);
|
|
162
|
+
this.#queue = run.catch(() => undefined);
|
|
163
|
+
return run;
|
|
164
|
+
}
|
|
165
|
+
|
|
155
166
|
skills(): SkillEntry[] {
|
|
156
167
|
return [...this.#skills.values()].sort((a, b) =>
|
|
157
168
|
a.name.localeCompare(b.name),
|
|
@@ -189,19 +200,29 @@ export class SkillStore {
|
|
|
189
200
|
async addLinked(
|
|
190
201
|
entries: readonly { name: string; repo: string; path: string }[],
|
|
191
202
|
): Promise<void> {
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
203
|
+
return this.#serial(async () => {
|
|
204
|
+
const taken = entries.filter((entry) => this.#skills.has(entry.name));
|
|
205
|
+
const refuse = (names: string[]) =>
|
|
206
|
+
new AgentError(
|
|
207
|
+
`Already registered: ${names.join(", ")}. Nothing was linked.`,
|
|
208
|
+
);
|
|
209
|
+
if (taken.length > 0) throw refuse(taken.map((entry) => entry.name));
|
|
210
|
+
try {
|
|
211
|
+
await this.#sql.begin(async (tx) => {
|
|
212
|
+
for (const entry of entries)
|
|
213
|
+
await tx`
|
|
214
|
+
INSERT INTO skills (name, kind, repo, path)
|
|
215
|
+
VALUES (${entry.name}, 'linked', ${entry.repo}, ${entry.path})`;
|
|
216
|
+
});
|
|
217
|
+
} catch (error) {
|
|
218
|
+
// A row the maps do not hold, such as a duplicate within `entries`.
|
|
219
|
+
if (isUniqueViolation(error))
|
|
220
|
+
throw refuse(entries.map((entry) => entry.name));
|
|
221
|
+
throw error;
|
|
222
|
+
}
|
|
198
223
|
for (const entry of entries)
|
|
199
|
-
|
|
200
|
-
INSERT INTO skills (name, kind, repo, path)
|
|
201
|
-
VALUES (${entry.name}, 'linked', ${entry.repo}, ${entry.path})`;
|
|
224
|
+
this.#skills.set(entry.name, { kind: "linked", ...entry });
|
|
202
225
|
});
|
|
203
|
-
for (const entry of entries)
|
|
204
|
-
this.#skills.set(entry.name, { kind: "linked", ...entry });
|
|
205
226
|
}
|
|
206
227
|
|
|
207
228
|
async addWritten(skill: {
|
|
@@ -209,12 +230,14 @@ export class SkillStore {
|
|
|
209
230
|
description: string;
|
|
210
231
|
body: string;
|
|
211
232
|
}): Promise<void> {
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
233
|
+
return this.#serial(async () => {
|
|
234
|
+
if (this.#skills.has(skill.name))
|
|
235
|
+
throw new AgentError(`A skill named "${skill.name}" already exists.`);
|
|
236
|
+
await this.#sql`
|
|
237
|
+
INSERT INTO skills (name, kind, description, body)
|
|
238
|
+
VALUES (${skill.name}, 'written', ${skill.description}, ${skill.body})`;
|
|
239
|
+
this.#skills.set(skill.name, { kind: "written", ...skill });
|
|
240
|
+
});
|
|
218
241
|
}
|
|
219
242
|
|
|
220
243
|
async updateWritten(skill: {
|
|
@@ -222,54 +245,84 @@ export class SkillStore {
|
|
|
222
245
|
description: string;
|
|
223
246
|
body: string;
|
|
224
247
|
}): Promise<void> {
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
248
|
+
return this.#serial(async () => {
|
|
249
|
+
await this.#sql`
|
|
250
|
+
UPDATE skills SET description = ${skill.description}, body = ${skill.body},
|
|
251
|
+
updated_at = now()
|
|
252
|
+
WHERE name = ${skill.name} AND kind = 'written'`;
|
|
253
|
+
this.#skills.set(skill.name, { kind: "written", ...skill });
|
|
254
|
+
});
|
|
230
255
|
}
|
|
231
256
|
|
|
232
257
|
/** Removes a skill and detaches it from every agent and group. */
|
|
233
258
|
async remove(name: string): Promise<Detached> {
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
259
|
+
return this.#serial(async () => {
|
|
260
|
+
const agents = this.carriers(name);
|
|
261
|
+
const groups = this.groups().filter((group) =>
|
|
262
|
+
group.skills.includes(name),
|
|
263
|
+
);
|
|
264
|
+
await this.#sql.begin(async (tx) => {
|
|
265
|
+
await tx`DELETE FROM skills WHERE name = ${name}`;
|
|
266
|
+
await tx`DELETE FROM agent_skills WHERE guild_id = ${this.#guild} AND skill = ${name}`;
|
|
267
|
+
for (const group of groups)
|
|
268
|
+
await tx`
|
|
269
|
+
UPDATE skill_groups
|
|
270
|
+
SET skills = ${group.skills.filter((s) => s !== name).join(",")}
|
|
271
|
+
WHERE name = ${group.name}`;
|
|
272
|
+
});
|
|
273
|
+
// The maps follow the transaction; reloading them all here would drop a write that
|
|
274
|
+
// committed after its SELECT.
|
|
275
|
+
this.#skills.delete(name);
|
|
276
|
+
for (const agent of agents) {
|
|
277
|
+
const left = this.carriedBy(agent).filter((s) => s !== name);
|
|
278
|
+
if (left.length > 0) this.#carried.set(agent, left);
|
|
279
|
+
else this.#carried.delete(agent);
|
|
280
|
+
}
|
|
239
281
|
for (const group of groups)
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
282
|
+
this.#groups.set(group.name, {
|
|
283
|
+
...group,
|
|
284
|
+
skills: group.skills.filter((s) => s !== name),
|
|
285
|
+
});
|
|
286
|
+
return { agents, groups: groups.map((group) => group.name) };
|
|
244
287
|
});
|
|
245
|
-
await this.#reload();
|
|
246
|
-
return { agents, groups: groups.map((group) => group.name) };
|
|
247
288
|
}
|
|
248
289
|
|
|
249
290
|
async setGroup(group: SkillGroup): Promise<void> {
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
291
|
+
return this.#serial(async () => {
|
|
292
|
+
await this.#sql`
|
|
293
|
+
INSERT INTO skill_groups (name, description, skills)
|
|
294
|
+
VALUES (${group.name}, ${group.description}, ${group.skills.join(",")})
|
|
295
|
+
ON CONFLICT (name) DO UPDATE
|
|
296
|
+
SET description = EXCLUDED.description, skills = EXCLUDED.skills`;
|
|
297
|
+
this.#groups.set(group.name, { ...group, skills: [...group.skills] });
|
|
298
|
+
});
|
|
256
299
|
}
|
|
257
300
|
|
|
258
301
|
async removeGroup(name: string): Promise<void> {
|
|
259
|
-
|
|
260
|
-
|
|
302
|
+
return this.#serial(async () => {
|
|
303
|
+
await this.#sql`DELETE FROM skill_groups WHERE name = ${name}`;
|
|
304
|
+
this.#groups.delete(name);
|
|
305
|
+
});
|
|
261
306
|
}
|
|
262
307
|
|
|
263
308
|
/** Replaces the registered skills an agent carries. */
|
|
264
309
|
async setCarried(agent: string, skills: readonly string[]): Promise<void> {
|
|
265
310
|
// pi-lens-ignore: no-sort-without-comparator — skill names sort by code unit, the same order on every machine
|
|
266
311
|
const sorted = [...new Set(skills)].sort();
|
|
267
|
-
|
|
268
|
-
await
|
|
269
|
-
|
|
270
|
-
|
|
312
|
+
return this.#serial(async () => {
|
|
313
|
+
await this.#sql.begin(async (tx) => {
|
|
314
|
+
await tx`DELETE FROM agent_skills WHERE guild_id = ${this.#guild} AND agent = ${agent}`;
|
|
315
|
+
for (const skill of sorted)
|
|
316
|
+
await tx`INSERT INTO agent_skills (guild_id, agent, skill) VALUES (${this.#guild}, ${agent}, ${skill})`;
|
|
317
|
+
});
|
|
318
|
+
if (sorted.length > 0) this.#carried.set(agent, sorted);
|
|
319
|
+
else this.#carried.delete(agent);
|
|
271
320
|
});
|
|
272
|
-
if (sorted.length > 0) this.#carried.set(agent, sorted);
|
|
273
|
-
else this.#carried.delete(agent);
|
|
274
321
|
}
|
|
275
322
|
}
|
|
323
|
+
|
|
324
|
+
/** PostgreSQL's unique_violation, as Bun's driver reports it. */
|
|
325
|
+
function isUniqueViolation(error: unknown): boolean {
|
|
326
|
+
const fields = error as { errno?: unknown; code?: unknown } | null;
|
|
327
|
+
return fields?.errno === "23505" || fields?.code === "23505";
|
|
328
|
+
}
|
|
@@ -61,7 +61,7 @@ export async function askQuestions(
|
|
|
61
61
|
);
|
|
62
62
|
if (n === 1)
|
|
63
63
|
return answerText(
|
|
64
|
-
answer === "pending" || answer === undefined
|
|
64
|
+
answer === "pending" || answer === "declined" || answer === undefined
|
|
65
65
|
? answer
|
|
66
66
|
: (answer.answers[0] ?? { choices: [] }),
|
|
67
67
|
owner,
|
|
@@ -79,11 +79,11 @@ export async function askQuestions(
|
|
|
79
79
|
: undefined,
|
|
80
80
|
});
|
|
81
81
|
if (n === 1) return answerText(answer, owner);
|
|
82
|
-
if (answer === "pending" || answer === undefined)
|
|
82
|
+
if (answer === "pending" || answer === "declined" || answer === undefined)
|
|
83
83
|
return sequentialText(
|
|
84
84
|
questionnaire,
|
|
85
85
|
answers,
|
|
86
|
-
answer
|
|
86
|
+
answer ?? "unanswered",
|
|
87
87
|
owner,
|
|
88
88
|
answerText(answer, owner),
|
|
89
89
|
);
|
|
@@ -11,10 +11,12 @@ const NAME_CHARS = 120;
|
|
|
11
11
|
|
|
12
12
|
/** The tool's result: what the owner chose and wrote. */
|
|
13
13
|
export function answerText(
|
|
14
|
-
answer: OwnerAnswer | "pending" | undefined,
|
|
14
|
+
answer: OwnerAnswer | "pending" | "declined" | undefined,
|
|
15
15
|
owner: OwnerIdentity,
|
|
16
16
|
): string {
|
|
17
17
|
const o = ownerWords(owner);
|
|
18
|
+
if (answer === "declined")
|
|
19
|
+
return `${o.name} cancelled the question on its card without answering. Do not ask the same question again. Continue without the answer, or, if you cannot go on without it, end your turn saying so.`;
|
|
18
20
|
if (answer === "pending")
|
|
19
21
|
return `${o.name} has not answered yet. The card stays open, so do not ask again and do not wait for it. End your turn now: say briefly that you are waiting for ${o.his} answer on the card. When ${o.he} answers, a new turn brings you the answer and you continue from there.`;
|
|
20
22
|
if (!answer)
|
|
@@ -72,11 +74,12 @@ function listOf(
|
|
|
72
74
|
|
|
73
75
|
/** The tool's result for a questionnaire: each question's answer, and the note. */
|
|
74
76
|
export function questionnaireText(
|
|
75
|
-
answer: QuestionnaireAnswer | "pending" | undefined,
|
|
77
|
+
answer: QuestionnaireAnswer | "pending" | "declined" | undefined,
|
|
76
78
|
questionnaire: Questionnaire,
|
|
77
79
|
owner: OwnerIdentity,
|
|
78
80
|
): string {
|
|
79
|
-
if (answer === "pending" ||
|
|
81
|
+
if (answer === "pending" || answer === "declined" || !answer)
|
|
82
|
+
return answerText(answer, owner);
|
|
80
83
|
return [
|
|
81
84
|
`${owner.name} answered your questions on the card:`,
|
|
82
85
|
...listOf(questionnaire, answer),
|
|
@@ -102,7 +105,7 @@ export function lateQuestionnaireText(
|
|
|
102
105
|
export function sequentialText(
|
|
103
106
|
asked: Questionnaire,
|
|
104
107
|
answers: readonly OwnerAnswer[],
|
|
105
|
-
stopped: "pending" | "unanswered" | undefined,
|
|
108
|
+
stopped: "pending" | "declined" | "unanswered" | undefined,
|
|
106
109
|
owner: OwnerIdentity,
|
|
107
110
|
stoppedText: string,
|
|
108
111
|
): string {
|
|
@@ -93,7 +93,7 @@ export const QUESTIONNAIRE_CHECKS: readonly QuestionnaireCheck[] = [
|
|
|
93
93
|
const given = answerOf(set, found.asks);
|
|
94
94
|
await answer(id, given, set);
|
|
95
95
|
const got = await result;
|
|
96
|
-
if (got === "pending" || got === undefined)
|
|
96
|
+
if (got === "pending" || got === "declined" || got === undefined)
|
|
97
97
|
throw new ContractBroken(`the person's answers came back ${got}`);
|
|
98
98
|
expectThat(
|
|
99
99
|
JSON.stringify(got.answers.map((a) => a ?? null)) ===
|