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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-roundtable",
3
- "version": "0.22.1",
3
+ "version": "0.22.3",
4
4
  "description": "A plugin-driven Pi agent server for one owner, reached through Discord or the web",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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
- const added: string[] = [];
101
- for (const seed of seeds) {
102
- const rows: unknown[] = await this.#sql`
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 name`;
108
- if (rows.length > 0) added.push(seed.name);
109
- }
110
- await this.#reload();
111
- return added;
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
- checkAgentName(agent.name);
182
- checkDisplayName(agent.displayName);
183
- checkPrompt(agent.prompt);
184
- // An agent of a host without an image provider has no avatar prompt.
185
- if (agent.avatarPrompt !== "") checkAvatarPrompt(agent.avatarPrompt);
186
- this.#checkFree(agent.name);
187
- const rows: AgentRow[] = await this.#sql`
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
- return this.#keepAgent(rows[0]);
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
- const current = this.activeAgent(name);
215
- if (change.displayName !== undefined) checkDisplayName(change.displayName);
216
- if (change.prompt !== undefined) checkPrompt(change.prompt);
217
- if (change.avatarPrompt !== undefined)
218
- checkAvatarPrompt(change.avatarPrompt);
219
- if (change.model) checkModelRef(change.model);
220
- if (change.thinking) checkThinking(change.thinking);
221
- const model =
222
- change.model === undefined ? (current.model ?? null) : change.model;
223
- const thinking =
224
- change.thinking === undefined
225
- ? (current.thinking ?? null)
226
- : change.thinking;
227
- const rows: AgentRow[] = await this.#sql`
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
- return this.#keepAgent(rows[0]);
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
- const rows: AgentRow[] = await this.#sql`
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
- this.#keepAgent(rows[0]);
248
- const left: AgentGroup[] = [];
249
- for (const group of this.groups()) {
250
- if (group.status !== "active" || !group.members.includes(name)) continue;
251
- const members = group.members.filter((m) => m !== name);
252
- const host = group.host === name ? (members[0] ?? name) : group.host;
253
- left.push(
254
- await this.#writeGroup(group.name, {
255
- members,
256
- host,
257
- status: members.length < MIN_GROUP_MEMBERS ? "archived" : "active",
258
- }),
259
- );
260
- }
261
- return left;
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.checkNewGroup(group.name, group.members, group.host);
300
- checkDisplayName(group.displayName);
301
- const rows: GroupRow[] = await this.#sql`
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
- return this.#keepGroup(rows[0]);
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
- const current = this.#groups.get(name);
314
- if (current?.status !== "active")
315
- throw new AgentError(`There is no active group "${name}".`);
316
- if (change.displayName !== undefined) checkDisplayName(change.displayName);
317
- const members = change.members ?? current.members;
318
- const host =
319
- change.host ??
320
- (members.includes(current.host) ? current.host : (members[0] ?? ""));
321
- this.#checkMembers(members, host);
322
- return this.#writeGroup(name, {
323
- displayName: change.displayName?.trim() ?? current.displayName,
324
- members,
325
- host,
326
- status: "active",
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
- rows: (id, disabled) => askRows(id, question, disabled),
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<OwnerAnswer | "pending" | undefined>(
72
- channelId,
73
- signal,
74
- undefined,
75
- undefined,
76
- {
77
- audience,
78
- ...(wait
79
- ? {
80
- wait: {
81
- pending: "pending",
82
- late: (value, by) =>
83
- wait.late({
84
- kind: "questionnaire",
85
- answer: { answers: [value as OwnerAnswer] },
86
- by: by.user.name,
87
- }),
88
- },
89
- }
90
- : {}),
91
- title,
92
- sections: [only.question],
93
- question: only,
94
- previews: questionnaire,
95
- rows: (id, disabled) => [
96
- ...askRows(id, only, disabled),
97
- ...(only.options.some((o) => o.preview !== undefined)
98
- ? [previewsRow(id, disabled)]
99
- : []),
100
- ],
101
- footer: messages().cardQuestionFooter,
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: it shows the options' previews and answers nothing. */
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;
@@ -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, and `pending`, only
252
- * with a `wait`, when the turn stopped waiting and the question stays open.
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
- const taken = entries.filter((entry) => this.#skills.has(entry.name));
193
- if (taken.length > 0)
194
- throw new AgentError(
195
- `Already registered: ${taken.map((entry) => entry.name).join(", ")}. Nothing was linked.`,
196
- );
197
- await this.#sql.begin(async (tx) => {
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
- await tx`
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
- if (this.#skills.has(skill.name))
213
- throw new AgentError(`A skill named "${skill.name}" already exists.`);
214
- await this.#sql`
215
- INSERT INTO skills (name, kind, description, body)
216
- VALUES (${skill.name}, 'written', ${skill.description}, ${skill.body})`;
217
- this.#skills.set(skill.name, { kind: "written", ...skill });
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
- await this.#sql`
226
- UPDATE skills SET description = ${skill.description}, body = ${skill.body},
227
- updated_at = now()
228
- WHERE name = ${skill.name} AND kind = 'written'`;
229
- this.#skills.set(skill.name, { kind: "written", ...skill });
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
- const agents = this.carriers(name);
235
- const groups = this.groups().filter((group) => group.skills.includes(name));
236
- await this.#sql.begin(async (tx) => {
237
- await tx`DELETE FROM skills WHERE name = ${name}`;
238
- await tx`DELETE FROM agent_skills WHERE guild_id = ${this.#guild} AND skill = ${name}`;
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
- await tx`
241
- UPDATE skill_groups
242
- SET skills = ${group.skills.filter((s) => s !== name).join(",")}
243
- WHERE name = ${group.name}`;
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
- await this.#sql`
251
- INSERT INTO skill_groups (name, description, skills)
252
- VALUES (${group.name}, ${group.description}, ${group.skills.join(",")})
253
- ON CONFLICT (name) DO UPDATE
254
- SET description = EXCLUDED.description, skills = EXCLUDED.skills`;
255
- this.#groups.set(group.name, { ...group, skills: [...group.skills] });
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
- await this.#sql`DELETE FROM skill_groups WHERE name = ${name}`;
260
- this.#groups.delete(name);
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
- await this.#sql.begin(async (tx) => {
268
- await tx`DELETE FROM agent_skills WHERE guild_id = ${this.#guild} AND agent = ${agent}`;
269
- for (const skill of sorted)
270
- await tx`INSERT INTO agent_skills (guild_id, agent, skill) VALUES (${this.#guild}, ${agent}, ${skill})`;
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 === "pending" ? "pending" : "unanswered",
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" || !answer) return answerText(answer, owner);
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)) ===