pi-roundtable 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md 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.5.0] - 2026-10-01
9
+
10
+ ### Changed
11
+
12
+ - `@earendil-works/pi-ai` and `@earendil-works/pi-coding-agent` are no longer pinned to one version: they take `>=0.99.2 <1`, so a host picks up a newer model table with `bun update @earendil-works/pi-ai @earendil-works/pi-coding-agent`, or its own direct dependency, without waiting for a pi-roundtable release. Pinned to 0.87.1 before, the host's model list stopped at the models that version knew. 0.99.2 is the lowest version the tests run against, and the lockfile keeps CI on it, so a host that updates further runs on versions this package was not tested with.
13
+ - `pi-mcp-adapter` is 4.0.0 (was 2.37.0). Version 2.37.0 declared support for pi-ai up to 0.87 only; 4.0.0 declares 0.99. It stays pinned. Checked by connecting a session built the way the core builds one to a local MCP server that requires a bearer token: the tool registered and a call returned its result.
14
+ - A judge or agent model that the newer table lists but the host has no login for fails with the provider's "Provider is not configured" error, where an unlisted model fails with "is not available".
15
+
16
+ ### Fixed
17
+
18
+ - Agents had no `skill_list` tool, although the `agent_create` description, the built-in `writing-skills` skill and the skill errors all tell them to call it. The `skills` addon now mounts it for agent sessions, at the member tier like its entry in the tool tiers. A host that mounted `skillListExtension` itself for agents (the kit still exports it) now offers the tool twice and should drop its own copy.
19
+
8
20
  ## [0.4.0] - 2026-10-01
9
21
 
10
22
  ### Added
package/README.zh-TW.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # pi-roundtable
2
2
 
3
- [English](README.md) | 繁體中文
3
+ [English](./README.md) | 繁體中文
4
4
 
5
5
  一個建立在 [Pi](https://github.com/earendil-works/pi) 上的 Discord 智慧體(agent)伺服器。
6
6
  你會在一個 Discord 伺服器裡得到一組 AI 智慧體:每個智慧體擁有一個頻道和一段對話,彼此共用工具與記憶,你用 TypeScript 寫外掛(plugin)來擴充這個 bot。
package/docs/plugins.md CHANGED
@@ -289,7 +289,7 @@ They are on by default, so a bot that says nothing about them has all three.
289
289
  | Addon | Plugin | Switch in `roundtable.config.ts` | What it adds | When it is off |
290
290
  |---|---|---|---|---|
291
291
  | Memory | `memory` (provides `MEMORY`) | `memory: false` | The memory table, the `memory_add`, `memory_search` and `memory_remove` tools, and the memory block of every system prompt | No memory tools and no block; the table is left as it is |
292
- | Skills | `skills` (provides `SKILLS`) | `skills: false` | The skill tables, the skill tools of agent sessions (`skill_link`, `skill_create`, `agent_skills`, and the rest), and the skills every agent carries, `writing-skills` included | No skill tools and no skills in any session; `agent_get` has no skills line; `agent_create` leaves out its `skills` parameter and refuses a call that passes some with `Skills are off on this host`; the tables are left as they are |
292
+ | Skills | `skills` (provides `SKILLS`) | `skills: false` | The skill tables, the skill tools of agent sessions (`skill_list`, `skill_link`, `skill_create`, `agent_skills`, and the rest), and the skills every agent carries, `writing-skills` included | No skill tools and no skills in any session; `agent_get` has no skills line; `agent_create` leaves out its `skills` parameter and refuses a call that passes some with `Skills are off on this host`; the tables are left as they are |
293
293
  | Discord administration | `discord-admin` | `discord: { admin: false }` | The `discord_*` tools that read and manage the server, for the owner | No `discord_*` tools; the channel executor that remote MCP uses is the connection's, so it stays |
294
294
 
295
295
  A switched-off addon's tables and rows are never touched: turning it on again finds them as they were.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-roundtable",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "A plugin-driven Pi agent server for Discord",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -45,11 +45,11 @@
45
45
  },
46
46
  "dependencies": {
47
47
  "@babel/parser": "7.29.9",
48
- "@earendil-works/pi-ai": "0.87.1",
49
- "@earendil-works/pi-coding-agent": "0.87.1",
48
+ "@earendil-works/pi-ai": ">=0.99.2 <1",
49
+ "@earendil-works/pi-coding-agent": ">=0.99.2 <1",
50
50
  "canvas": "3.2.3",
51
51
  "discord.js": "14.27.0",
52
- "pi-mcp-adapter": "2.37.0",
52
+ "pi-mcp-adapter": "4.0.0",
53
53
  "pino": "10.3.1",
54
54
  "typebox": "1.3.34",
55
55
  "unpdf": "1.8.1"
@@ -4,6 +4,7 @@ import { SkillStore } from "../modules/skills/skill-store.ts";
4
4
  import {
5
5
  SKILL_LIST_TOOL,
6
6
  SKILL_TOOLS,
7
+ skillListExtension,
7
8
  skillToolsExtension,
8
9
  } from "../modules/skills/skill-tools.ts";
9
10
  import type { RoundtablePlugin } from "../plugin.ts";
@@ -57,6 +58,7 @@ export function skillsPlugin(
57
58
  services.provide(SKILLS, built);
58
59
  return {
59
60
  sessionTools: [
61
+ agentOnly("skill-list", () => skillListExtension(built)),
60
62
  agentOnly("skill-tools", () =>
61
63
  skillToolsExtension(built, (name) => {
62
64
  // The agent server is set up after this plugin and read when a session is built.
@@ -64,7 +66,10 @@ export function skillsPlugin(
64
66
  }),
65
67
  ),
66
68
  ],
67
- agentSelection: () => ({ tools: SKILL_TOOLS, groups: [] }),
69
+ agentSelection: () => ({
70
+ tools: [SKILL_LIST_TOOL, ...SKILL_TOOLS],
71
+ groups: [],
72
+ }),
68
73
  toolTiers: SKILL_TIERS,
69
74
  };
70
75
  },
@@ -27,7 +27,10 @@ import {
27
27
  /** How prompts refer back to the owner. */
28
28
  export type Pronouns = "he" | "she" | "they";
29
29
 
30
- /** Who holds a tier besides the owner: user ids, role ids, and for members everyone. */
30
+ /**
31
+ * Who holds a tier besides the owner: user ids, role ids, or everyone. `everyone` works under any
32
+ * tier it is written in, so under `admins` it makes every author an admin.
33
+ */
31
34
  export interface TierConfig {
32
35
  users?: readonly string[];
33
36
  roles?: readonly string[];
@@ -75,7 +75,10 @@ export interface SpeakerPolicy {
75
75
  resolve(author: SpeakerFacts): Speaker | undefined;
76
76
  }
77
77
 
78
- /** Who holds one tier: user ids, role ids, and (for members) everyone. */
78
+ /**
79
+ * Who holds one tier: user ids, role ids, or everyone. `everyone` works under any tier it is
80
+ * written in, so under `admins` it makes every author an admin.
81
+ */
79
82
  export interface TierMembers {
80
83
  users?: readonly string[];
81
84
  roles?: readonly string[];
@@ -102,6 +102,7 @@
102
102
  "skill_group_delete",
103
103
  "skill_group_set",
104
104
  "skill_link",
105
+ "skill_list",
105
106
  "skill_unlink",
106
107
  "skill_update"
107
108
  ],
@@ -125,6 +126,7 @@
125
126
  "skill_group_delete",
126
127
  "skill_group_set",
127
128
  "skill_link",
129
+ "skill_list",
128
130
  "skill_unlink",
129
131
  "skill_update",
130
132
  "write"
@@ -187,6 +189,7 @@
187
189
  "skill_group_delete",
188
190
  "skill_group_set",
189
191
  "skill_link",
192
+ "skill_list",
190
193
  "skill_unlink",
191
194
  "skill_update"
192
195
  ],
@@ -209,6 +212,7 @@
209
212
  "skill_group_delete",
210
213
  "skill_group_set",
211
214
  "skill_link",
215
+ "skill_list",
212
216
  "skill_unlink",
213
217
  "skill_update",
214
218
  "write"
@@ -280,6 +284,7 @@
280
284
  "skill_group_delete": "admin",
281
285
  "skill_group_set": "admin",
282
286
  "skill_link": "admin",
287
+ "skill_list": "member",
283
288
  "skill_unlink": "admin",
284
289
  "skill_update": "admin",
285
290
  "web_search": "member",
@@ -35,7 +35,7 @@ export interface CodexImagesOptions {
35
35
 
36
36
  /** The ChatGPT account the login token belongs to, from the token's claims. */
37
37
  function chatGptAccountId(token: string): string {
38
- const payload = token.split(".")[1];
38
+ const [, payload] = token.split(".");
39
39
  if (!payload) throw new Error("the Codex token is not a JWT");
40
40
  let claims: Record<string, unknown>;
41
41
  try {
@@ -58,43 +58,64 @@ function object(value: unknown): Record<string, unknown> {
58
58
  : {};
59
59
  }
60
60
 
61
- /** Reads the server-sent events until the response completes, and returns its one image. */
61
+ /** The one image an `image_generation_call` item carries; anything else is not an image. */
62
+ function imageOf(value: unknown): Uint8Array | undefined {
63
+ const item = object(value);
64
+ if (item.type !== "image_generation_call") return undefined;
65
+ if (
66
+ item.status !== "completed" ||
67
+ typeof item.result !== "string" ||
68
+ !item.result
69
+ ) {
70
+ const { result: _result, ...detail } = item;
71
+ throw new ImageNotGeneratedError(
72
+ `Codex image generation did not complete (status ${String(item.status)}): ${JSON.stringify(detail).slice(0, 300)}`,
73
+ );
74
+ }
75
+ if (item.result.length > MAX_IMAGE_BASE64)
76
+ throw new Error("the image exceeds 32 MiB");
77
+ return new Uint8Array(Buffer.from(item.result, "base64"));
78
+ }
79
+
80
+ /** The frames of a server-sent event stream: the text between blank lines. */
81
+ async function* frames(
82
+ body: ReadableStream<Uint8Array>,
83
+ ): AsyncGenerator<string> {
84
+ const decoder = new TextDecoder();
85
+ let buffer = "";
86
+ for await (const chunk of body) {
87
+ buffer += decoder.decode(chunk, { stream: true });
88
+ let match = /\r?\n\r?\n/.exec(buffer);
89
+ while (match) {
90
+ yield buffer.slice(0, match.index);
91
+ buffer = buffer.slice(match.index + match[0].length);
92
+ match = /\r?\n\r?\n/.exec(buffer);
93
+ }
94
+ }
95
+ if (buffer.trim()) yield buffer;
96
+ }
97
+
98
+ /** The event a frame carries, or undefined for a frame without data. */
99
+ function eventOf(frame: string): Record<string, unknown> | undefined {
100
+ const data = frame
101
+ .split(/\r?\n/)
102
+ .flatMap((line) => (line.startsWith("data:") ? [line.slice(5).trim()] : []))
103
+ .join("\n");
104
+ if (!data || data === "[DONE]") return undefined;
105
+ try {
106
+ return object(JSON.parse(data));
107
+ } catch {
108
+ throw new Error(`Codex sent a malformed event: ${data.slice(0, 200)}`);
109
+ }
110
+ }
111
+
112
+ /** Reads the events until the response completes, and returns its one image. */
62
113
  async function parseImageStream(response: Response): Promise<Uint8Array> {
63
114
  if (!response.body) throw new Error("Codex returned no body");
64
115
  let image: Uint8Array | undefined;
65
- let completed = false;
66
- const take = (value: unknown) => {
67
- const item = object(value);
68
- if (item.type !== "image_generation_call" || image) return;
69
- if (
70
- item.status !== "completed" ||
71
- typeof item.result !== "string" ||
72
- !item.result
73
- ) {
74
- const { result: _result, ...detail } = item;
75
- throw new ImageNotGeneratedError(
76
- `Codex image generation did not complete (status ${String(item.status)}): ${JSON.stringify(detail).slice(0, 300)}`,
77
- );
78
- }
79
- if (item.result.length > MAX_IMAGE_BASE64)
80
- throw new Error("the image exceeds 32 MiB");
81
- image = new Uint8Array(Buffer.from(item.result, "base64"));
82
- };
83
- const handle = (frame: string) => {
84
- const data = frame
85
- .split(/\r?\n/)
86
- .filter((line) => line.startsWith("data:"))
87
- .map((line) => line.slice(5).trim())
88
- .join("\n");
89
- if (!data || data === "[DONE]") return;
90
- let parsed: unknown;
91
- try {
92
- parsed = JSON.parse(data);
93
- } catch {
94
- throw new Error(`Codex sent a malformed event: ${data.slice(0, 200)}`);
95
- }
96
- const event = object(parsed);
97
- switch (event.type) {
116
+ for await (const frame of frames(response.body)) {
117
+ const event = eventOf(frame);
118
+ switch (event?.type) {
98
119
  case "error":
99
120
  case "response.failed": {
100
121
  const error = object(object(event.response).error ?? event.error);
@@ -105,34 +126,21 @@ async function parseImageStream(response: Response): Promise<Uint8Array> {
105
126
  case "response.incomplete":
106
127
  throw new Error("the Codex response was incomplete");
107
128
  case "response.output_item.done":
108
- take(event.item);
129
+ image ??= imageOf(event.item);
109
130
  break;
110
131
  case "response.completed": {
111
132
  const output = object(event.response).output;
112
- if (!image && Array.isArray(output))
113
- for (const item of output) take(item);
114
- completed = true;
115
- break;
133
+ if (Array.isArray(output))
134
+ for (const item of output) image ??= imageOf(item);
135
+ if (!image)
136
+ throw new ImageNotGeneratedError("Codex answered without an image");
137
+ return image;
116
138
  }
139
+ default:
140
+ break;
117
141
  }
118
- };
119
- const decoder = new TextDecoder();
120
- let buffer = "";
121
- for await (const chunk of response.body) {
122
- buffer += decoder.decode(chunk, { stream: true });
123
- let match = /\r?\n\r?\n/.exec(buffer);
124
- while (match && !completed) {
125
- handle(buffer.slice(0, match.index));
126
- buffer = buffer.slice(match.index + match[0].length);
127
- match = /\r?\n\r?\n/.exec(buffer);
128
- }
129
- if (completed) break;
130
142
  }
131
- if (!completed && buffer.trim()) handle(buffer);
132
- if (!completed) throw new Error("the Codex stream ended before completion");
133
- if (!image)
134
- throw new ImageNotGeneratedError("Codex answered without an image");
135
- return image;
143
+ throw new Error("the Codex stream ended before completion");
136
144
  }
137
145
 
138
146
  async function generateImage(
@@ -41,6 +41,8 @@ function removed(
41
41
  return ascending.slice(0, n);
42
42
  case "dh":
43
43
  return ascending.slice(rolls.length - n);
44
+ default:
45
+ return rule satisfies never;
44
46
  }
45
47
  }
46
48
 
@@ -51,49 +53,53 @@ interface Rolled {
51
53
  dice: number;
52
54
  }
53
55
 
54
- function rollTerm(term: string, random: Random): Rolled {
55
- if (/^\d+$/.test(term)) {
56
- if (term.length > 7 || Number(term) > MAX_MODIFIER)
57
- return refuse(`The number ${term} is over ${MAX_MODIFIER}.`);
58
- return { shown: String(Number(term)), value: Number(term), dice: 0 };
59
- }
60
- const match = TERM.exec(term);
61
- if (!match) return refuse(`"${term}" is not a dice term.`);
62
- const [, countText, fate, sidesText, rule, nText] = match;
63
- const count = countText ? Number(countText) : fate ? FATE_DEFAULT : 1;
64
- if (count < 1) return refuse(`"${term}" rolls no dice.`);
65
- if (count > MAX_DICE)
66
- return refuse(`"${term}" rolls more than ${MAX_DICE} dice.`);
67
- if (fate) {
68
- const faces = Array.from(
69
- { length: count },
70
- () => Math.floor(random() * 3) - 1,
71
- );
72
- const symbols = faces.map((face) =>
73
- face > 0 ? "+" : face < 0 ? "-" : "0",
74
- );
75
- return {
76
- shown: `[${symbols.join(", ")}]`,
77
- value: faces.reduce((sum, face) => sum + face, 0),
78
- dice: count,
79
- };
80
- }
81
- const sides = Number(sidesText);
56
+ /** A plain number term. */
57
+ function rollNumber(term: string): Rolled {
58
+ if (term.length > 7 || Number(term) > MAX_MODIFIER)
59
+ return refuse(`The number ${term} is over ${MAX_MODIFIER}.`);
60
+ return { shown: String(Number(term)), value: Number(term), dice: 0 };
61
+ }
62
+
63
+ /** How a fate die shows: + for 1, - for -1, and 0. */
64
+ function fateSymbol(face: number): string {
65
+ if (face > 0) return "+";
66
+ if (face < 0) return "-";
67
+ return "0";
68
+ }
69
+
70
+ /** `count` fate dice, each one of -1, 0, or +1. */
71
+ function rollFate(count: number, random: Random): Rolled {
72
+ const faces = Array.from(
73
+ { length: count },
74
+ () => Math.floor(random() * 3) - 1,
75
+ );
76
+ const symbols = faces.map(fateSymbol);
77
+ return {
78
+ shown: `[${symbols.join(", ")}]`,
79
+ value: faces.reduce((sum, face) => sum + face, 0),
80
+ dice: count,
81
+ };
82
+ }
83
+
84
+ /** `count` dice of `sides` sides, with an optional keep or drop rule that removes some of them. */
85
+ function rollPool(
86
+ term: string,
87
+ count: number,
88
+ sides: number,
89
+ rule: { kind: KeepDrop; n: number } | undefined,
90
+ random: Random,
91
+ ): Rolled {
82
92
  if (sides < 2 || sides > MAX_SIDES)
83
93
  return refuse(`"${term}" needs between 2 and ${MAX_SIDES} sides.`);
84
94
  const rolls = Array.from(
85
95
  { length: count },
86
96
  () => Math.floor(random() * sides) + 1,
87
97
  );
88
- const dropped = new Set<number>();
89
- if (rule) {
90
- const n = Number(nText);
91
- if (n < 1 || n >= count)
92
- return refuse(
93
- `"${term}" must keep or drop at least 1 and fewer than ${count} dice.`,
94
- );
95
- for (const index of removed(rolls, rule as KeepDrop, n)) dropped.add(index);
96
- }
98
+ if (rule && (rule.n < 1 || rule.n >= count))
99
+ return refuse(
100
+ `"${term}" must keep or drop at least 1 and fewer than ${count} dice.`,
101
+ );
102
+ const dropped = new Set(rule ? removed(rolls, rule.kind, rule.n) : []);
97
103
  const shown = rolls.map((value, index) =>
98
104
  dropped.has(index) ? `(${value})` : String(value),
99
105
  );
@@ -107,6 +113,31 @@ function rollTerm(term: string, random: Random): Rolled {
107
113
  };
108
114
  }
109
115
 
116
+ /** How many dice a term rolls: the count it writes, else 4 for fate dice and 1 for the rest. */
117
+ function diceCount(countText: string | undefined, fate: string | undefined) {
118
+ if (countText) return Number(countText);
119
+ return fate ? FATE_DEFAULT : 1;
120
+ }
121
+
122
+ function rollTerm(term: string, random: Random): Rolled {
123
+ if (/^\d+$/.test(term)) return rollNumber(term);
124
+ const match = TERM.exec(term);
125
+ if (!match) return refuse(`"${term}" is not a dice term.`);
126
+ const [, countText, fate, sidesText, rule, nText] = match;
127
+ const count = diceCount(countText, fate);
128
+ if (count < 1) return refuse(`"${term}" rolls no dice.`);
129
+ if (count > MAX_DICE)
130
+ return refuse(`"${term}" rolls more than ${MAX_DICE} dice.`);
131
+ if (fate) return rollFate(count, random);
132
+ return rollPool(
133
+ term,
134
+ count,
135
+ Number(sidesText),
136
+ rule ? { kind: rule as KeepDrop, n: Number(nText) } : undefined,
137
+ random,
138
+ );
139
+ }
140
+
110
141
  /**
111
142
  * Rolls an expression of dice terms and numbers joined by `+` and `-`, and writes the result as
112
143
  * text such as `2d6+3: [3, 5] + 3 = 11`. A term is `NdS`, an optional keep or drop rule
@@ -133,10 +164,8 @@ export function roll(expression: string, random: Random = Math.random): string {
133
164
  if (dice > MAX_DICE)
134
165
  return refuse(`The expression rolls more than ${MAX_DICE} dice in all.`);
135
166
  total += sign === "-" ? -rolled.value : rolled.value;
136
- line +=
137
- index === 0
138
- ? `${sign === "-" ? "-" : ""}${rolled.shown}`
139
- : ` ${sign} ${rolled.shown}`;
167
+ const lead = sign === "-" ? "-" : "";
168
+ line += index === 0 ? `${lead}${rolled.shown}` : ` ${sign} ${rolled.shown}`;
140
169
  }
141
170
  return `${compact}: ${line} = ${total}`;
142
171
  }