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 +6 -0
- package/package.json +1 -1
- package/src/core/agents/agent-store.ts +90 -66
- package/src/core/modules/skills/skill-store.ts +100 -47
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
|
@@ -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(
|
|
@@ -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
|
+
}
|