pi-roundtable 0.22.1 → 0.22.2

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,12 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.22.2] - 2026-10-21
9
+
10
+ ### Fixed
11
+
12
+ - 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.
13
+
8
14
  ## [0.22.1] - 2026-10-21
9
15
 
10
16
  ### Changed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-roundtable",
3
- "version": "0.22.1",
3
+ "version": "0.22.2",
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(
@@ -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
+ }