pi-roundtable 0.4.0 → 0.5.1
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 +18 -0
- package/README.md +39 -11
- package/README.zh-TW.md +36 -15
- package/docs/plugins.md +259 -168
- package/examples/migrations.test.ts +1 -1
- package/examples/migrations.ts +2 -2
- package/package.json +8 -4
- package/src/cli/cli.ts +2 -0
- package/src/cli/main.ts +1 -0
- package/src/cli/project.ts +2 -0
- package/src/cli/runtime.ts +2 -2
- package/src/cli/templates.ts +10 -9
- package/src/core/agents/agent-prompt.ts +1 -2
- package/src/core/agents/agent-store.ts +1 -2
- package/src/core/agents/agent-team-fixture.ts +5 -3
- package/src/core/agents/fallback-avatar.ts +1 -0
- package/src/core/agents/group-messages.ts +1 -1
- package/src/core/agents/group-round.ts +10 -9
- package/src/core/agents/group-turns.ts +13 -8
- package/src/core/agents/team-layout.ts +13 -14
- package/src/core/agents/team-status.ts +8 -8
- package/src/core/agents/team-turns.ts +8 -9
- package/src/core/builtin/skills.ts +6 -1
- package/src/core/config/config.ts +4 -1
- package/src/core/define-roundtable.ts +19 -19
- package/src/core/define.ts +10 -10
- package/src/core/discord/channel-executor.ts +20 -18
- package/src/core/discord/channel-operations.ts +7 -3
- package/src/core/discord/inbound-message.ts +13 -11
- package/src/core/discord/owner-cards.ts +3 -3
- package/src/core/discord/owner-discord-threads.ts +16 -12
- package/src/core/discord/owner-discord.ts +5 -4
- package/src/core/host.ts +1 -0
- package/src/core/log.ts +1 -0
- package/src/core/modules/schedules/scheduler.ts +3 -0
- package/src/core/modules/skills/skill-link.ts +13 -8
- package/src/core/modules/skills/skill-listing.ts +2 -4
- package/src/core/modules/skills/skill-registry.ts +3 -2
- package/src/core/modules/skills/skill-store.ts +3 -2
- package/src/core/registry/contributions.ts +1 -1
- package/src/core/routing/conversation-turns.ts +2 -2
- package/src/core/routing/settle-turn.ts +8 -0
- package/src/core/runtime/compaction-tiers.ts +9 -10
- package/src/core/runtime/turn-answer.ts +10 -12
- package/src/core/shared/session-messages.ts +4 -4
- package/src/core/speakers.ts +4 -1
- package/src/core/tool-set.snapshot.json +5 -0
- package/templates/official/codex-images/plugin.ts +65 -57
- package/templates/official/dice/plugin.ts +70 -41
|
@@ -64,18 +64,22 @@ export class OwnerThreads {
|
|
|
64
64
|
permissions.has(PermissionFlagsBits.ManageThreads))
|
|
65
65
|
);
|
|
66
66
|
};
|
|
67
|
-
return threads
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
67
|
+
return threads.flatMap((t) =>
|
|
68
|
+
sees(owner, t) && sees(bot, t)
|
|
69
|
+
? [
|
|
70
|
+
{
|
|
71
|
+
id: t.id,
|
|
72
|
+
name: t.name,
|
|
73
|
+
parentId: t.parentId,
|
|
74
|
+
private: t.type === ChannelType.PrivateThread,
|
|
75
|
+
archived: t.archived,
|
|
76
|
+
locked: t.locked,
|
|
77
|
+
messageCount: t.messageCount,
|
|
78
|
+
createdAt: t.createdAt?.toISOString() ?? null,
|
|
79
|
+
},
|
|
80
|
+
]
|
|
81
|
+
: [],
|
|
82
|
+
);
|
|
79
83
|
}
|
|
80
84
|
|
|
81
85
|
async create(args: Record<string, unknown>) {
|
|
@@ -153,9 +153,9 @@ export class DiscordOwnerOps implements OwnerOperations {
|
|
|
153
153
|
displayName: m.displayName,
|
|
154
154
|
nickname: m.nickname,
|
|
155
155
|
bot: m.user.bot,
|
|
156
|
-
roles: m.roles.cache
|
|
157
|
-
.
|
|
158
|
-
|
|
156
|
+
roles: [...m.roles.cache.values()].flatMap((r) =>
|
|
157
|
+
r.id === guild.id ? [] : [r.name],
|
|
158
|
+
),
|
|
159
159
|
}));
|
|
160
160
|
}
|
|
161
161
|
case "discord_create_role": {
|
|
@@ -233,8 +233,9 @@ export class DiscordOwnerOps implements OwnerOperations {
|
|
|
233
233
|
await guild.members.unban(userId, reason);
|
|
234
234
|
return { userId, banned: false };
|
|
235
235
|
}
|
|
236
|
+
default:
|
|
237
|
+
throw refuse("INVALID_TOOL", tool);
|
|
236
238
|
}
|
|
237
|
-
throw refuse("INVALID_TOOL", tool);
|
|
238
239
|
}
|
|
239
240
|
|
|
240
241
|
/** Servers the owner and the assistant share, with what each of them may do there. */
|
package/src/core/host.ts
CHANGED
|
@@ -109,6 +109,7 @@ async function startInBackground(
|
|
|
109
109
|
): Promise<void> {
|
|
110
110
|
const { services, servicePlugins, handlers } = registry;
|
|
111
111
|
await Promise.all(
|
|
112
|
+
// pi-lens-ignore: array-callback-return — an async callback returns its promise on every path; map only starts the tasks for Promise.all, and the early return skips a service with no background start
|
|
112
113
|
services.map(async (service) => {
|
|
113
114
|
if (!service.startInBackground) return;
|
|
114
115
|
const plugin = servicePlugins.get(service) ?? "unknown";
|
package/src/core/log.ts
CHANGED
|
@@ -2,6 +2,7 @@ import pino from "pino";
|
|
|
2
2
|
|
|
3
3
|
/** One log method: fields and a message, or a message alone. */
|
|
4
4
|
export interface LogFn {
|
|
5
|
+
// pi-lens-ignore: no-bare-object-param — LogFn is public API and pino's own call signature takes any object; a narrower type would refuse loggers that pass an interface-typed value
|
|
5
6
|
(fields: object, message?: string): void;
|
|
6
7
|
(message: string): void;
|
|
7
8
|
}
|
|
@@ -31,6 +31,8 @@ function statusText(outcome: ScheduledOutcome): string {
|
|
|
31
31
|
return `failed: ${outcome.error}`.slice(0, 300);
|
|
32
32
|
case "skipped":
|
|
33
33
|
return `skipped: ${outcome.reason}`;
|
|
34
|
+
default:
|
|
35
|
+
return outcome satisfies never;
|
|
34
36
|
}
|
|
35
37
|
}
|
|
36
38
|
|
|
@@ -99,6 +101,7 @@ export class Scheduler {
|
|
|
99
101
|
|
|
100
102
|
/** Resolves once every started run has finished. */
|
|
101
103
|
async idle(): Promise<void> {
|
|
104
|
+
// pi-lens-ignore: no-single-promise-in-promise-methods — the spread holds every run in flight, none to many; it is not one promise
|
|
102
105
|
await Promise.all([...this.#running]);
|
|
103
106
|
}
|
|
104
107
|
|
|
@@ -4,6 +4,18 @@ import { AgentError } from "../../domain/errors.ts";
|
|
|
4
4
|
import { checkRepoName } from "./repo-name.ts";
|
|
5
5
|
import { checkSkillName, readSkillFile } from "./skill-rules.ts";
|
|
6
6
|
|
|
7
|
+
/** The child folders of `dir` that hold a SKILL.md, as paths from the repository root. */
|
|
8
|
+
function skillFoldersUnder(dir: string, relative: string): string[] {
|
|
9
|
+
const prefix = relative ? `${relative}/` : "";
|
|
10
|
+
const folders = readdirSync(dir, { withFileTypes: true }).flatMap((entry) =>
|
|
11
|
+
entry.isDirectory() && existsSync(join(dir, entry.name, "SKILL.md"))
|
|
12
|
+
? [`${prefix}${entry.name}`]
|
|
13
|
+
: [],
|
|
14
|
+
);
|
|
15
|
+
// pi-lens-ignore: no-sort-without-comparator — folder paths sort by code unit, the same order on every machine
|
|
16
|
+
return folders.sort();
|
|
17
|
+
}
|
|
18
|
+
|
|
7
19
|
/**
|
|
8
20
|
* The skills a link would register: the one at a repository path, or every child folder with a
|
|
9
21
|
* SKILL.md (behavior 15). Throws one error listing every problem, so nothing is half linked.
|
|
@@ -28,14 +40,7 @@ export function planLinkedSkills(
|
|
|
28
40
|
throw new AgentError(`${repo} has no folder ${relative || "."}.`);
|
|
29
41
|
const folders = existsSync(join(dir, "SKILL.md"))
|
|
30
42
|
? [relative]
|
|
31
|
-
:
|
|
32
|
-
.filter(
|
|
33
|
-
(entry) =>
|
|
34
|
-
entry.isDirectory() &&
|
|
35
|
-
existsSync(join(dir, entry.name, "SKILL.md")),
|
|
36
|
-
)
|
|
37
|
-
.map((entry) => (relative ? `${relative}/${entry.name}` : entry.name))
|
|
38
|
-
.sort();
|
|
43
|
+
: skillFoldersUnder(dir, relative);
|
|
39
44
|
if (folders.length === 0)
|
|
40
45
|
throw new AgentError(
|
|
41
46
|
`${repo}:${relative || "."} has no SKILL.md, nor any folder with one.`,
|
|
@@ -42,8 +42,7 @@ export function skillCatalog(
|
|
|
42
42
|
: { description: one.description, file: one.file }),
|
|
43
43
|
groups: store
|
|
44
44
|
.groups()
|
|
45
|
-
.
|
|
46
|
-
.map((g) => g.name),
|
|
45
|
+
.flatMap((g) => (g.skills.includes(name) ? [g.name] : [])),
|
|
47
46
|
carriers: store.carriers(name),
|
|
48
47
|
};
|
|
49
48
|
});
|
|
@@ -93,8 +92,7 @@ export function skillListText(
|
|
|
93
92
|
continue;
|
|
94
93
|
const groups = store
|
|
95
94
|
.groups()
|
|
96
|
-
.
|
|
97
|
-
.map((g) => g.name);
|
|
95
|
+
.flatMap((g) => (g.skills.includes(name) ? [g.name] : []));
|
|
98
96
|
const carriers = store.carriers(name);
|
|
99
97
|
const missing = "reason" in one ? ` — missing: ${one.reason}` : "";
|
|
100
98
|
rows.push(
|
|
@@ -80,8 +80,9 @@ export class StoredSkillRegistry implements SkillRegistry {
|
|
|
80
80
|
linkedFrom(repo: string): string[] {
|
|
81
81
|
return this.#options.store
|
|
82
82
|
.skills()
|
|
83
|
-
.
|
|
84
|
-
|
|
83
|
+
.flatMap((entry) =>
|
|
84
|
+
entry.kind === "linked" && entry.repo === repo ? [entry.name] : [],
|
|
85
|
+
);
|
|
85
86
|
}
|
|
86
87
|
|
|
87
88
|
/** The registered skills an agent carries, as the dashboard lists them. */
|
|
@@ -177,9 +177,9 @@ export class SkillStore {
|
|
|
177
177
|
|
|
178
178
|
/** The agents carrying a skill. */
|
|
179
179
|
carriers(skill: string): string[] {
|
|
180
|
+
// pi-lens-ignore: no-sort-without-comparator — agent names sort by code unit, the same order on every machine
|
|
180
181
|
return [...this.#carried]
|
|
181
|
-
.
|
|
182
|
-
.map(([agent]) => agent)
|
|
182
|
+
.flatMap(([agent, skills]) => (skills.includes(skill) ? [agent] : []))
|
|
183
183
|
.sort();
|
|
184
184
|
}
|
|
185
185
|
|
|
@@ -260,6 +260,7 @@ export class SkillStore {
|
|
|
260
260
|
|
|
261
261
|
/** Replaces the registered skills an agent carries. */
|
|
262
262
|
async setCarried(agent: string, skills: readonly string[]): Promise<void> {
|
|
263
|
+
// pi-lens-ignore: no-sort-without-comparator — skill names sort by code unit, the same order on every machine
|
|
263
264
|
const sorted = [...new Set(skills)].sort();
|
|
264
265
|
await this.#sql.begin(async (tx) => {
|
|
265
266
|
await tx`DELETE FROM agent_skills WHERE guild_id = ${this.#guild} AND agent = ${agent}`;
|
|
@@ -403,7 +403,7 @@ export function linkSessions(registry: Registry): LinkedSessions {
|
|
|
403
403
|
prompt: registry.prompt,
|
|
404
404
|
persona: (kind) => personas.get(kind)?.prompt(),
|
|
405
405
|
requiredTools: [...new Set(registry.requiredTools)],
|
|
406
|
-
agentTools: registry.tools.
|
|
406
|
+
agentTools: registry.tools.flatMap((t) => (t.agent ? [t.name] : [])),
|
|
407
407
|
agentSelection: () => mergeSelections(registry.agentSelections),
|
|
408
408
|
};
|
|
409
409
|
}
|
|
@@ -9,7 +9,7 @@ import { splitReply } from "../presentation/reply-splitter.ts";
|
|
|
9
9
|
import { thinkingLine } from "../presentation/thinking-line.ts";
|
|
10
10
|
import type { ChannelKey, ToolSelection, TurnSelection } from "../sessions.ts";
|
|
11
11
|
import type { Speaker } from "../speakers.ts";
|
|
12
|
-
import { settleTurn } from "./settle-turn.ts";
|
|
12
|
+
import { endOf, settleTurn } from "./settle-turn.ts";
|
|
13
13
|
|
|
14
14
|
/** One turn of a conversation, as a claim asks for it. */
|
|
15
15
|
export interface ConversationTurnInput {
|
|
@@ -106,7 +106,7 @@ export function conversationTurns(
|
|
|
106
106
|
}
|
|
107
107
|
events.turnEnded({
|
|
108
108
|
...turn,
|
|
109
|
-
result: result
|
|
109
|
+
result: endOf(result),
|
|
110
110
|
});
|
|
111
111
|
try {
|
|
112
112
|
if (!result.ok && !result.stopped)
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { ScheduledOutcome } from "../contract/channels.ts";
|
|
2
|
+
import type { TurnResult } from "../domain/conversation.ts";
|
|
2
3
|
import { AgentRunError } from "../domain/errors.ts";
|
|
4
|
+
import type { TurnEndEvent } from "../plugin.ts";
|
|
3
5
|
|
|
4
6
|
/** Runs a turn; a thrown error becomes its failed result, labelled `<what> crashed`. */
|
|
5
7
|
export async function settleTurn<R>(
|
|
@@ -16,6 +18,12 @@ export async function settleTurn<R>(
|
|
|
16
18
|
}
|
|
17
19
|
}
|
|
18
20
|
|
|
21
|
+
/** How a turn ended, as the `turnEnded` event reports it. */
|
|
22
|
+
export function endOf(result: TurnResult): TurnEndEvent["result"] {
|
|
23
|
+
if (result.ok) return "ok";
|
|
24
|
+
return result.stopped ? "stopped" : "failed";
|
|
25
|
+
}
|
|
26
|
+
|
|
19
27
|
/** The result of a turn nobody wrote, as the scheduler and reports see it. */
|
|
20
28
|
export function outcome(
|
|
21
29
|
result: { ok: true } | { ok: false; error: { message: string } },
|
|
@@ -182,19 +182,18 @@ export class CompactionTiers {
|
|
|
182
182
|
event: string,
|
|
183
183
|
handler: Handler,
|
|
184
184
|
) => () => void;
|
|
185
|
-
const on = ((event: string, handler: Handler) =>
|
|
186
|
-
register(
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
? (compaction, ctx)
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
: handler,
|
|
194
|
-
)) as ExtensionAPI["on"];
|
|
185
|
+
const on = ((event: string, handler: Handler) => {
|
|
186
|
+
if (event !== "session_before_compact") return register(event, handler);
|
|
187
|
+
return register(event, (compaction, ctx) =>
|
|
188
|
+
gate(compaction as SessionBeforeCompactEvent)
|
|
189
|
+
? handler(compaction, ctx)
|
|
190
|
+
: undefined,
|
|
191
|
+
);
|
|
192
|
+
}) as ExtensionAPI["on"];
|
|
195
193
|
return compactor(
|
|
196
194
|
new Proxy(pi, {
|
|
197
195
|
get: (target, prop, receiver) =>
|
|
196
|
+
// pi-lens-ignore: no-reflect-get — a Proxy get trap: the receiver keeps a getter's `this` on the proxy, which target[prop] would not
|
|
198
197
|
prop === "on" ? on : Reflect.get(target, prop, receiver),
|
|
199
198
|
}),
|
|
200
199
|
);
|
|
@@ -34,15 +34,13 @@ export function thinkingOf(messages: readonly Message[]): string {
|
|
|
34
34
|
? (message.content as (MessagePart | undefined)[])
|
|
35
35
|
: [],
|
|
36
36
|
)
|
|
37
|
-
.
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
part
|
|
42
|
-
|
|
43
|
-
part.redacted !== true,
|
|
37
|
+
.flatMap((part) =>
|
|
38
|
+
part?.type === "thinking" &&
|
|
39
|
+
typeof part.thinking === "string" &&
|
|
40
|
+
part.redacted !== true
|
|
41
|
+
? [part.thinking.trim()]
|
|
42
|
+
: [],
|
|
44
43
|
)
|
|
45
|
-
.map((part) => part.thinking.trim())
|
|
46
44
|
.filter(Boolean)
|
|
47
45
|
.join("\n\n");
|
|
48
46
|
}
|
|
@@ -60,11 +58,11 @@ export function turnAnswer(
|
|
|
60
58
|
const text = (
|
|
61
59
|
steered
|
|
62
60
|
? messages
|
|
63
|
-
.
|
|
64
|
-
(message)
|
|
65
|
-
|
|
61
|
+
.flatMap((message) =>
|
|
62
|
+
isAssistant(message) && message.stopReason === "stop"
|
|
63
|
+
? [textOf(message.content).trim()]
|
|
64
|
+
: [],
|
|
66
65
|
)
|
|
67
|
-
.map((message) => textOf(message.content).trim())
|
|
68
66
|
.filter(Boolean)
|
|
69
67
|
.join("\n\n")
|
|
70
68
|
: textOf(last.message.content)
|
|
@@ -11,11 +11,11 @@ export function textOf(content: unknown): string {
|
|
|
11
11
|
if (typeof content === "string") return content;
|
|
12
12
|
if (!Array.isArray(content)) return "";
|
|
13
13
|
return content
|
|
14
|
-
.
|
|
15
|
-
|
|
16
|
-
|
|
14
|
+
.flatMap((part) =>
|
|
15
|
+
part?.type === "text" && typeof part.text === "string"
|
|
16
|
+
? [part.text as string]
|
|
17
|
+
: [],
|
|
17
18
|
)
|
|
18
|
-
.map((part) => part.text)
|
|
19
19
|
.join("\n");
|
|
20
20
|
}
|
|
21
21
|
|
package/src/core/speakers.ts
CHANGED
|
@@ -75,7 +75,10 @@ export interface SpeakerPolicy {
|
|
|
75
75
|
resolve(author: SpeakerFacts): Speaker | undefined;
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
-
/**
|
|
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(".")
|
|
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
|
-
/**
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
129
|
+
image ??= imageOf(event.item);
|
|
109
130
|
break;
|
|
110
131
|
case "response.completed": {
|
|
111
132
|
const output = object(event.response).output;
|
|
112
|
-
if (
|
|
113
|
-
for (const item of output)
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
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(
|