pi-roundtable 0.17.3 → 0.19.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.
@@ -0,0 +1,153 @@
1
+ // A Judge over Jev's hosted service, built on pi-jev-compaction's client.
2
+
3
+ import { JevClient, type JevQuestions } from "pi-jev-compaction";
4
+ import type {
5
+ ChoiceAnswer,
6
+ ChoiceQuestion,
7
+ Judge,
8
+ ScoreQuestion,
9
+ YesNoQuestion,
10
+ } from "../core/contract/providers.ts";
11
+ import { JudgeError } from "../core/errors.ts";
12
+
13
+ /** The service's name for the yes-or-no question and for its answer's probability. */
14
+ const WIRE_YES_NO = "noul";
15
+ type JevQuestion = JevQuestions[string];
16
+ const DEFAULT_TIMEOUT_MS = 20_000;
17
+
18
+ export interface JevJudgeOptions {
19
+ /** The service's API key. It is required: an empty one is refused, not looked up in the environment. */
20
+ apiKey: string;
21
+ /** The service's model; default pi-jev-compaction's. */
22
+ model?: string;
23
+ /** The service's endpoint; default pi-jev-compaction's. */
24
+ baseUrl?: string;
25
+ /** How long one question may take, the read of the answer included; default 20,000. */
26
+ timeoutMs?: number;
27
+ /** The request function; default the global `fetch`. */
28
+ fetch?: typeof fetch;
29
+ }
30
+
31
+ // pi-lens-ignore: no-unknown-parameters — a type guard over parsed JSON
32
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
33
+ typeof value === "object" && value !== null && !Array.isArray(value);
34
+
35
+ // pi-lens-ignore: no-unknown-parameters — a type guard over parsed JSON
36
+ const isProbability = (value: unknown): value is number =>
37
+ typeof value === "number" && value >= 0 && value <= 1;
38
+
39
+ /**
40
+ * A `Judge` that asks Jev. Each question is one request with its own time limit of `timeoutMs`,
41
+ * which also covers reading the answer. Every failure throws a `JudgeError`: a missing answer,
42
+ * a probability outside 0 to 1, a choice that is not one of the listed options, a failed request,
43
+ * and a limit that ran out. An empty `apiKey` is refused when the judge is made.
44
+ */
45
+ export function jevJudge(options: JevJudgeOptions): Judge {
46
+ const apiKey = options.apiKey.trim();
47
+ if (!apiKey)
48
+ throw new JudgeError(
49
+ "jevJudge needs an apiKey; an empty one would fall back to the environment's key",
50
+ );
51
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
52
+
53
+ async function ask(
54
+ state: Record<string, unknown>,
55
+ questions: JevQuestions,
56
+ ): Promise<Record<string, unknown>> {
57
+ const signal = AbortSignal.timeout(timeoutMs);
58
+ const client = new JevClient({
59
+ apiKey,
60
+ model: options.model,
61
+ baseUrl: options.baseUrl,
62
+ fetch: options.fetch,
63
+ signal,
64
+ });
65
+ try {
66
+ return (await client.ask(state, questions)).answers;
67
+ } catch (error) {
68
+ if (error instanceof JudgeError) throw error;
69
+ if (signal.aborted)
70
+ throw new JudgeError(`Jev did not answer within ${timeoutMs} ms`, {
71
+ cause: error,
72
+ });
73
+ const message = error instanceof Error ? error.message : String(error);
74
+ throw new JudgeError(
75
+ /^Jev/.test(message) ? message : `Jev request failed: ${message}`,
76
+ { cause: error },
77
+ );
78
+ }
79
+ }
80
+
81
+ const malformed = (name: string, detail = "") =>
82
+ new JudgeError(`Jev's answer for ${name} is malformed${detail}`);
83
+
84
+ return {
85
+ async askYesNo(
86
+ state: Record<string, unknown>,
87
+ questions: Record<string, YesNoQuestion>,
88
+ ): Promise<Record<string, number>> {
89
+ const names = Object.keys(questions);
90
+ if (names.length === 0) return {};
91
+ const answers = await ask(
92
+ state,
93
+ Object.fromEntries(
94
+ names.map((name) => {
95
+ const { type: _type, ...question } = questions[
96
+ name
97
+ ] as YesNoQuestion;
98
+ return [name, { ...question, type: WIRE_YES_NO } as JevQuestion];
99
+ }),
100
+ ),
101
+ );
102
+ // pi-lens-ignore: no-known-value-widening — filled per question name below
103
+ const result: Record<string, number> = {};
104
+ for (const name of names) {
105
+ const answer = answers[name];
106
+ const probability = isRecord(answer) ? answer[WIRE_YES_NO] : undefined;
107
+ if (!isProbability(probability)) throw malformed(name);
108
+ result[name] = probability;
109
+ }
110
+ return result;
111
+ },
112
+
113
+ async askChoice(
114
+ state: Record<string, unknown>,
115
+ name: string,
116
+ question: ChoiceQuestion,
117
+ ): Promise<ChoiceAnswer> {
118
+ const answer = (await ask(state, { [name]: question }))[name];
119
+ if (
120
+ !isRecord(answer) ||
121
+ typeof answer.choice !== "string" ||
122
+ !isProbability(answer.confidence)
123
+ )
124
+ throw malformed(name);
125
+ if (!Object.hasOwn(question.criteria, answer.choice))
126
+ throw new JudgeError(
127
+ `Jev chose ${answer.choice}, which is not an option of ${name}`,
128
+ );
129
+ return { choice: answer.choice, confidence: answer.confidence };
130
+ },
131
+
132
+ async askScore(
133
+ state: Record<string, unknown>,
134
+ name: string,
135
+ question: ScoreQuestion,
136
+ ): Promise<number[]> {
137
+ if (question.criteria.length === 0) return [];
138
+ const answer = (
139
+ await ask(state, {
140
+ [name]: { ...question, criteria: [...question.criteria] },
141
+ })
142
+ )[name];
143
+ const probabilities = isRecord(answer) ? answer.probabilities : undefined;
144
+ if (!isRecord(probabilities)) throw malformed(name);
145
+ return question.criteria.map((_, index) => {
146
+ const value = probabilities[String(index)];
147
+ if (!isProbability(value))
148
+ throw malformed(name, `: no probability for criterion ${index}`);
149
+ return value;
150
+ });
151
+ },
152
+ };
153
+ }
@@ -0,0 +1,9 @@
1
+ // Plugin helpers, versioned like the main entry; see the plugin guide.
2
+ // Short alerts for the people who run a host, held to a window so a fault cannot flood them.
3
+
4
+ export type {
5
+ OpsAlertKind,
6
+ OpsAlertsOptions,
7
+ OpsRaiseOptions,
8
+ } from "../core/ops/ops-alerts.ts";
9
+ export { OpsAlerts } from "../core/ops/ops-alerts.ts";
package/src/testing.ts CHANGED
@@ -29,6 +29,7 @@ import {
29
29
  commandGuard,
30
30
  ownerRootCommand,
31
31
  } from "./core/discord/owner-command.ts";
32
+ import type { DiscordPoster } from "./core/discord/poster/poster.ts";
32
33
  import type { ClientPart, ClientPort } from "./core/domain/client.ts";
33
34
  import type {
34
35
  PendingConfirmation,
@@ -76,6 +77,7 @@ import {
76
77
  } from "./core/services.ts";
77
78
  import type { ChannelKey, SessionContext } from "./core/sessions.ts";
78
79
  import { type Speaker, THE_SPEAKER } from "./core/speakers.ts";
80
+ import { fakeDiscordPoster } from "./core/testing/fake-poster.ts";
79
81
  import { mapIdentity } from "./core/testing/map-identity.ts";
80
82
  import {
81
83
  callRegisteredTool,
@@ -95,6 +97,12 @@ export {
95
97
  testDatabaseUrl,
96
98
  } from "./core/testing/database.ts";
97
99
  export { eagerText, useEagerCatalog } from "./core/testing/eager-catalog.ts";
100
+ export type {
101
+ FakeDiscordPoster,
102
+ FakePosterChannel,
103
+ FakePosterMessage,
104
+ } from "./core/testing/fake-poster.ts";
105
+ export { fakeDiscordPoster } from "./core/testing/fake-poster.ts";
98
106
  export type { TestLocale } from "./core/testing/locale.ts";
99
107
  export { useTestLocale } from "./core/testing/locale.ts";
100
108
  export { OWNER_SPEAKER } from "./core/testing/owner.ts";
@@ -156,6 +164,8 @@ export interface FakeDiscord {
156
164
  readonly service: ServicePair;
157
165
  /** What `DISCORD.commands` is to the plugin: the real registrar, which refuses what the Discord plugin refuses. */
158
166
  readonly commands: CommandRegistrar;
167
+ /** What `DISCORD.poster` is: the one the test gave, else a `fakeDiscordPoster()`. */
168
+ readonly poster: DiscordPoster;
159
169
  /** What `DISCORD.guard` is: the owner, `owner` unless the test gave another id, serving the root command. */
160
170
  readonly guard: CommandGuard;
161
171
  /** Every contribution the plugin added, in the order it added them. */
@@ -169,13 +179,14 @@ export interface FakeDiscord {
169
179
  * plugin's `commands.add` calls are recorded, and `compose()` shows the tree Discord would get.
170
180
  * `rootCommand` is the root command's name, `roundtable` by default. `channelContext` is what
171
181
  * `DISCORD.channelContext` answers, by default no context, as on a host with it off. Only
172
- * `commands`, `guard`, and `channelContext` are given; reading another member throws.
182
+ * `commands`, `guard`, `poster`, and `channelContext` are given; reading another member throws.
173
183
  */
174
184
  export function fakeDiscord(
175
185
  options: {
176
186
  ownerId?: string;
177
187
  rootCommand?: string;
178
188
  channelContext?: DiscordServices["channelContext"];
189
+ poster?: DiscordPoster;
179
190
  } = {},
180
191
  ): FakeDiscord {
181
192
  const collection = new CommandCollection();
@@ -185,12 +196,15 @@ export function fakeDiscord(
185
196
  root: rootCommand,
186
197
  logger: silentLogger(),
187
198
  });
199
+ const poster = options.poster ?? fakeDiscordPoster();
188
200
  return {
189
201
  service: servicePair(DISCORD, {
190
202
  commands: collection.registrar,
191
203
  guard,
204
+ poster,
192
205
  channelContext: options.channelContext ?? (async () => undefined),
193
206
  }),
207
+ poster,
194
208
  commands: collection.registrar,
195
209
  guard,
196
210
  added: () => collection.added(),
@@ -16,11 +16,11 @@
16
16
  "pi-roundtable": "__VERSION__",
17
17
  "pi-roundtable-webchat": "__VERSION__",
18
18
  "pi-self-compact": "0.2.0",
19
- "typebox": "1.3.34"
19
+ "typebox": "1.3.37"
20
20
  },
21
21
  "devDependencies": {
22
22
  "@biomejs/biome": "2.5.15",
23
- "@types/bun": "1.4.2",
23
+ "@types/bun": "1.4.3",
24
24
  "typescript": "7.0.2"
25
25
  }
26
26
  }
@@ -107,6 +107,37 @@ test("codex-images reports an HTTP error with its status, and never the token",
107
107
  await harness.stop();
108
108
  });
109
109
 
110
+ test("codex-images aborts a drawing with the turn that asked for it", async () => {
111
+ const signals: AbortSignal[] = [];
112
+ const plugin = createCodexImages({
113
+ fetch: (_url, init) => {
114
+ const signal = init.signal as AbortSignal;
115
+ signals.push(signal);
116
+ return new Promise((_resolve, reject) => {
117
+ signal.addEventListener("abort", () => reject(signal.reason));
118
+ });
119
+ },
120
+ });
121
+ const harness = await testPlugin(plugin, {
122
+ apiKeys: { "openai-codex": token("acct-1") },
123
+ });
124
+ const turn = new AbortController();
125
+ const reason = new Error("the turn ended");
126
+ const drawing = plugin.providers?.images?.("a fox", [], {
127
+ signal: turn.signal,
128
+ });
129
+ const outcome = drawing?.then(
130
+ () => undefined,
131
+ (error: unknown) => error,
132
+ );
133
+ await new Promise((done) => setTimeout(done, 20));
134
+ expect(signals[0]?.aborted).toBe(false);
135
+ turn.abort(reason);
136
+ expect(await outcome).toBe(reason);
137
+ expect(signals[0]?.aborted).toBe(true);
138
+ await harness.stop();
139
+ });
140
+
110
141
  test("codex-images refuses to start without an openai-codex login", async () => {
111
142
  const failure = await testPlugin(codexImages).then(
112
143
  () => undefined,
@@ -3,238 +3,14 @@
3
3
  // as an API, so it can stop working without notice, and OpenAI's terms for the subscription
4
4
  // apply to it. Use it only where you accept that risk.
5
5
 
6
- import { definePlugin, PluginError, type ReferenceImage } from "pi-roundtable";
6
+ import { createCodexImages } from "pi-roundtable/kit";
7
7
 
8
- const CODEX_RESPONSES_URL = "https://chatgpt.com/backend-api/codex/responses";
9
- /** The Pi provider whose login pays for the images. */
10
- const CODEX_PROVIDER = "openai-codex";
11
- /** Routes the Codex request; the backend picks the image model itself. */
12
- const CODEX_MODEL = "gpt-6-sol";
13
- const JWT_CLAIM_PATH = "https://api.openai.com/auth";
14
- const REQUEST_TIMEOUT_MS = 5 * 60_000;
15
- const MAX_IMAGE_BYTES = 32 * 1024 * 1024;
16
- const MAX_IMAGE_BASE64 = Math.ceil(MAX_IMAGE_BYTES / 3) * 4;
17
-
18
- /**
19
- * Codex ran the image tool but returned no image, usually because it refused the prompt.
20
- * Asking again with the same prompt fails again.
21
- */
22
- export class ImageNotGeneratedError extends Error {
23
- override name = "ImageNotGeneratedError";
24
- }
25
-
26
- /** The part of `fetch` the plugin uses, so a test can stand in for the network. */
27
- export type CodexFetch = (url: string, init: RequestInit) => Promise<Response>;
28
-
29
- export interface CodexImagesOptions {
30
- /** The request function; default the global `fetch`. */
31
- fetch?: CodexFetch;
32
- /** The model that routes the request; default `gpt-6-sol`. */
33
- model?: string;
34
- /**
35
- * What setup does when the openai-codex login is missing or unusable: "throw" (the default)
36
- * stops the host; "warn" logs a warning, and a drawing then fails with the same PluginError.
37
- */
38
- onMissingLogin?: "throw" | "warn";
39
- }
40
-
41
- /** The ChatGPT account the login token belongs to, from the token's claims. */
42
- function chatGptAccountId(token: string): string {
43
- const [, payload] = token.split(".");
44
- if (!payload) throw new Error("the Codex token is not a JWT");
45
- let claims: Record<string, unknown>;
46
- try {
47
- claims = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
48
- } catch {
49
- throw new Error("the Codex token's claims are not JSON");
50
- }
51
- const auth = claims[JWT_CLAIM_PATH] as
52
- | { chatgpt_account_id?: unknown }
53
- | undefined;
54
- const id = auth?.chatgpt_account_id;
55
- if (typeof id !== "string" || !id)
56
- throw new Error("the Codex token has no ChatGPT account");
57
- return id;
58
- }
59
-
60
- function object(value: unknown): Record<string, unknown> {
61
- return value !== null && typeof value === "object" && !Array.isArray(value)
62
- ? (value as Record<string, unknown>)
63
- : {};
64
- }
65
-
66
- /** The one image an `image_generation_call` item carries; anything else is not an image. */
67
- function imageOf(value: unknown): Uint8Array | undefined {
68
- const item = object(value);
69
- if (item.type !== "image_generation_call") return undefined;
70
- if (
71
- item.status !== "completed" ||
72
- typeof item.result !== "string" ||
73
- !item.result
74
- ) {
75
- const { result: _result, ...detail } = item;
76
- throw new ImageNotGeneratedError(
77
- `Codex image generation did not complete (status ${String(item.status)}): ${JSON.stringify(detail).slice(0, 300)}`,
78
- );
79
- }
80
- if (item.result.length > MAX_IMAGE_BASE64)
81
- throw new Error("the image exceeds 32 MiB");
82
- return new Uint8Array(Buffer.from(item.result, "base64"));
83
- }
84
-
85
- /** The frames of a server-sent event stream: the text between blank lines. */
86
- async function* frames(
87
- body: ReadableStream<Uint8Array>,
88
- ): AsyncGenerator<string> {
89
- const decoder = new TextDecoder();
90
- let buffer = "";
91
- for await (const chunk of body) {
92
- buffer += decoder.decode(chunk, { stream: true });
93
- let match = /\r?\n\r?\n/.exec(buffer);
94
- while (match) {
95
- yield buffer.slice(0, match.index);
96
- buffer = buffer.slice(match.index + match[0].length);
97
- match = /\r?\n\r?\n/.exec(buffer);
98
- }
99
- }
100
- if (buffer.trim()) yield buffer;
101
- }
102
-
103
- /** The event a frame carries, or undefined for a frame without data. */
104
- function eventOf(frame: string): Record<string, unknown> | undefined {
105
- const data = frame
106
- .split(/\r?\n/)
107
- .flatMap((line) => (line.startsWith("data:") ? [line.slice(5).trim()] : []))
108
- .join("\n");
109
- if (!data || data === "[DONE]") return undefined;
110
- try {
111
- return object(JSON.parse(data));
112
- } catch {
113
- throw new Error(`Codex sent a malformed event: ${data.slice(0, 200)}`);
114
- }
115
- }
116
-
117
- /** Reads the events until the response completes, and returns its one image. */
118
- async function parseImageStream(response: Response): Promise<Uint8Array> {
119
- if (!response.body) throw new Error("Codex returned no body");
120
- let image: Uint8Array | undefined;
121
- for await (const frame of frames(response.body)) {
122
- const event = eventOf(frame);
123
- switch (event?.type) {
124
- case "error":
125
- case "response.failed": {
126
- const error = object(object(event.response).error ?? event.error);
127
- throw new Error(
128
- `Codex failed: ${String(error.message ?? error.code ?? "unknown error")}`,
129
- );
130
- }
131
- case "response.incomplete":
132
- throw new Error("the Codex response was incomplete");
133
- case "response.output_item.done":
134
- image ??= imageOf(event.item);
135
- break;
136
- case "response.completed": {
137
- const output = object(event.response).output;
138
- if (Array.isArray(output))
139
- for (const item of output) image ??= imageOf(item);
140
- if (!image)
141
- throw new ImageNotGeneratedError("Codex answered without an image");
142
- return image;
143
- }
144
- default:
145
- break;
146
- }
147
- }
148
- throw new Error("the Codex stream ended before completion");
149
- }
150
-
151
- async function generateImage(
152
- prompt: string,
153
- references: readonly ReferenceImage[],
154
- token: string,
155
- model: string,
156
- fetchImpl: CodexFetch,
157
- ): Promise<Uint8Array> {
158
- const response = await fetchImpl(CODEX_RESPONSES_URL, {
159
- method: "POST",
160
- redirect: "error",
161
- signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
162
- headers: {
163
- authorization: `Bearer ${token}`,
164
- "chatgpt-account-id": chatGptAccountId(token),
165
- originator: "pi",
166
- "openai-beta": "responses=experimental",
167
- accept: "text/event-stream",
168
- "content-type": "application/json",
169
- },
170
- body: JSON.stringify({
171
- model,
172
- store: false,
173
- stream: true,
174
- instructions:
175
- "You are generating bitmap image assets. For this request, call the image_generation tool exactly once. Do not answer with only text unless image generation is unavailable.",
176
- input: [
177
- {
178
- role: "user",
179
- content: [
180
- { type: "input_text", text: prompt },
181
- ...references.map((image) => ({
182
- type: "input_image",
183
- image_url: `data:${image.mimeType};base64,${image.data}`,
184
- })),
185
- ],
186
- },
187
- ],
188
- tools: [{ type: "image_generation", output_format: "png" }],
189
- tool_choice: "auto",
190
- parallel_tool_calls: false,
191
- text: { verbosity: "low" },
192
- }),
193
- });
194
- if (!response.ok) {
195
- const detail = (await response.text()).slice(0, 300);
196
- throw new Error(`Codex answered ${response.status}: ${detail}`);
197
- }
198
- return parseImageStream(response);
199
- }
200
-
201
- const NO_LOGIN = `the host has no login for ${CODEX_PROVIDER}, the provider that pays for the images. Log in to the ${CODEX_PROVIDER} provider with Pi so the agent directory's auth.json holds it, or remove this plugin.`;
202
-
203
- /** The plugin, with its request function and model replaceable for a test. */
204
- export function createCodexImages(options: CodexImagesOptions = {}) {
205
- const {
206
- fetch: fetchImpl = fetch,
207
- model = CODEX_MODEL,
208
- onMissingLogin = "throw",
209
- } = options;
210
- let apiKey: ((provider: string) => Promise<string | undefined>) | undefined;
211
- // The login refreshes its token, so each image reads it again instead of keeping the first.
212
- const token = async (): Promise<string> => {
213
- const value = await apiKey?.(CODEX_PROVIDER);
214
- if (!value) throw new PluginError(`plugin codex-images: ${NO_LOGIN}`);
215
- return value;
216
- };
217
- return definePlugin({
218
- name: "codex-images",
219
- providers: {
220
- // The images slot draws an agent's avatar from a prompt and reference pictures.
221
- images: async (prompt, references) =>
222
- generateImage(prompt, references, await token(), model, fetchImpl),
223
- },
224
- async setup(context) {
225
- apiKey = context.apiKey;
226
- try {
227
- await token();
228
- } catch (error) {
229
- if (onMissingLogin !== "warn" || !(error instanceof PluginError))
230
- throw error;
231
- context.logger.warn(
232
- `${NO_LOGIN} Drawing images fails until the login exists.`,
233
- );
234
- }
235
- return {};
236
- },
237
- });
238
- }
8
+ export {
9
+ type CodexFetch,
10
+ type CodexImagesOptions,
11
+ createCodexImages,
12
+ ImageNotGeneratedError,
13
+ } from "pi-roundtable/kit";
239
14
 
15
+ /** The plugin the project lists. The client and the plugin are the kit's; this file is yours to change. */
240
16
  export const codexImages = createCodexImages();
@@ -15,11 +15,11 @@
15
15
  "dependencies": {
16
16
  "pi-roundtable": "__VERSION__",
17
17
  "pi-self-compact": "0.2.0",
18
- "typebox": "1.3.34"
18
+ "typebox": "1.3.37"
19
19
  },
20
20
  "devDependencies": {
21
21
  "@biomejs/biome": "2.5.15",
22
- "@types/bun": "1.4.2",
22
+ "@types/bun": "1.4.3",
23
23
  "typescript": "7.0.2"
24
24
  }
25
25
  }