pi-roundtable 0.1.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 +28 -0
- package/LICENSE +21 -0
- package/README.md +140 -0
- package/docs/plugins.md +924 -0
- package/examples/channels.test.ts +13 -0
- package/examples/channels.ts +27 -0
- package/examples/dashboard.test.ts +11 -0
- package/examples/dashboard.ts +9 -0
- package/examples/events.test.ts +26 -0
- package/examples/events.ts +25 -0
- package/examples/guide.test.ts +52 -0
- package/examples/holds.test.ts +16 -0
- package/examples/holds.ts +30 -0
- package/examples/http.test.ts +12 -0
- package/examples/http.ts +17 -0
- package/examples/interactions.test.ts +25 -0
- package/examples/interactions.ts +28 -0
- package/examples/migrations.test.ts +31 -0
- package/examples/migrations.ts +42 -0
- package/examples/packages.test.ts +9 -0
- package/examples/packages.ts +9 -0
- package/examples/preflight.test.ts +11 -0
- package/examples/preflight.ts +19 -0
- package/examples/prompt.test.ts +25 -0
- package/examples/prompt.ts +20 -0
- package/examples/providers.test.ts +10 -0
- package/examples/providers.ts +17 -0
- package/examples/seeds.test.ts +12 -0
- package/examples/seeds.ts +18 -0
- package/examples/selection.test.ts +13 -0
- package/examples/selection.ts +14 -0
- package/examples/services.test.ts +20 -0
- package/examples/services.ts +30 -0
- package/examples/session-tools.test.ts +24 -0
- package/examples/session-tools.ts +34 -0
- package/examples/tools.test.ts +18 -0
- package/examples/tools.ts +28 -0
- package/package.json +55 -0
- package/src/cli/add-plugin.test.ts +107 -0
- package/src/cli/add-plugin.ts +75 -0
- package/src/cli/checks/basic.test.ts +276 -0
- package/src/cli/checks/bun.ts +26 -0
- package/src/cli/checks/configuration.ts +28 -0
- package/src/cli/checks/database.test.ts +117 -0
- package/src/cli/checks/database.ts +100 -0
- package/src/cli/checks/discord.test.ts +200 -0
- package/src/cli/checks/discord.ts +218 -0
- package/src/cli/checks/environment.ts +27 -0
- package/src/cli/checks/model.ts +28 -0
- package/src/cli/checks/public-url.ts +40 -0
- package/src/cli/cli.test.ts +122 -0
- package/src/cli/cli.ts +166 -0
- package/src/cli/config-edit.test.ts +130 -0
- package/src/cli/config-edit.ts +199 -0
- package/src/cli/discord-api.ts +96 -0
- package/src/cli/doctor.test.ts +184 -0
- package/src/cli/doctor.ts +92 -0
- package/src/cli/http.ts +23 -0
- package/src/cli/init.test.ts +127 -0
- package/src/cli/init.ts +82 -0
- package/src/cli/main.ts +13 -0
- package/src/cli/project.ts +123 -0
- package/src/cli/report.ts +68 -0
- package/src/cli/roundtable.mjs +22 -0
- package/src/cli/runtime.ts +53 -0
- package/src/cli/size.test.ts +9 -0
- package/src/cli/start.ts +33 -0
- package/src/cli/templates.test.ts +129 -0
- package/src/cli/templates.ts +119 -0
- package/src/cli/testing/fixtures.ts +106 -0
- package/src/core/agents/agent-claim.test.ts +142 -0
- package/src/core/agents/agent-claim.ts +170 -0
- package/src/core/agents/agent-dashboard.test.ts +120 -0
- package/src/core/agents/agent-dashboard.ts +185 -0
- package/src/core/agents/agent-guild.test.ts +124 -0
- package/src/core/agents/agent-messages.ts +189 -0
- package/src/core/agents/agent-ports.ts +103 -0
- package/src/core/agents/agent-prompt.ts +163 -0
- package/src/core/agents/agent-rules.ts +106 -0
- package/src/core/agents/agent-schema.ts +115 -0
- package/src/core/agents/agent-settings.ts +49 -0
- package/src/core/agents/agent-store.test.ts +205 -0
- package/src/core/agents/agent-store.ts +384 -0
- package/src/core/agents/agent-team.ts +445 -0
- package/src/core/agents/agent-tools.ts +386 -0
- package/src/core/agents/avatar-studio.test.ts +88 -0
- package/src/core/agents/avatar-studio.ts +147 -0
- package/src/core/agents/group-messages.ts +99 -0
- package/src/core/agents/group-round.test.ts +143 -0
- package/src/core/agents/group-round.ts +117 -0
- package/src/core/agents/group-turns.ts +132 -0
- package/src/core/agents/owner-identity.test.ts +172 -0
- package/src/core/agents/team-editing.ts +205 -0
- package/src/core/agents/team-keys.ts +40 -0
- package/src/core/agents/team-layout.ts +135 -0
- package/src/core/agents/team-lifecycle.ts +188 -0
- package/src/core/agents/team-options.ts +52 -0
- package/src/core/agents/team-status.ts +80 -0
- package/src/core/agents/team-text.ts +60 -0
- package/src/core/agents/team-turn-types.ts +107 -0
- package/src/core/agents/team-turns.test.ts +278 -0
- package/src/core/agents/team-turns.ts +323 -0
- package/src/core/assets/neutral.png +0 -0
- package/src/core/assets/prompts/shared-guest.md +11 -0
- package/src/core/assets/prompts/shared.md +11 -0
- package/src/core/assets/skills/writing-skills/SKILL.md +21 -0
- package/src/core/attachments/attachment-dir.ts +15 -0
- package/src/core/attachments/attachment-fetcher.ts +83 -0
- package/src/core/attachments/attachments.test.ts +85 -0
- package/src/core/attachments/image-prep.ts +50 -0
- package/src/core/attachments/prompt-block.ts +37 -0
- package/src/core/attachments/turn-attachments.ts +79 -0
- package/src/core/boundary.test.ts +45 -0
- package/src/core/builtin/agent-server.ts +275 -0
- package/src/core/builtin/discord.ts +95 -0
- package/src/core/builtin/modules.test.ts +127 -0
- package/src/core/builtin/modules.ts +176 -0
- package/src/core/builtin/seeds.ts +10 -0
- package/src/core/builtin/stores.ts +42 -0
- package/src/core/config/config.test.ts +135 -0
- package/src/core/config/config.ts +285 -0
- package/src/core/config/schema.ts +181 -0
- package/src/core/contract/channels.ts +152 -0
- package/src/core/contract/discord.ts +33 -0
- package/src/core/contract/providers.ts +67 -0
- package/src/core/db/guild-scope.ts +53 -0
- package/src/core/db/migrations.test.ts +237 -0
- package/src/core/db/migrations.ts +37 -0
- package/src/core/define-roundtable.test.ts +134 -0
- package/src/core/define-roundtable.ts +195 -0
- package/src/core/define.test.ts +144 -0
- package/src/core/define.ts +154 -0
- package/src/core/discord/agent-commands.test.ts +58 -0
- package/src/core/discord/agent-commands.ts +385 -0
- package/src/core/discord/agent-discord.test.ts +70 -0
- package/src/core/discord/agent-discord.ts +367 -0
- package/src/core/discord/channel-executor.ts +404 -0
- package/src/core/discord/channel-operations.ts +329 -0
- package/src/core/discord/discord-surface.ts +431 -0
- package/src/core/discord/dispatch-thread-host.test.ts +114 -0
- package/src/core/discord/dispatch-thread-host.ts +72 -0
- package/src/core/discord/dispatch-threads.test.ts +94 -0
- package/src/core/discord/dispatch-threads.ts +180 -0
- package/src/core/discord/interaction-module.ts +1 -0
- package/src/core/discord/owner-cards.test.ts +435 -0
- package/src/core/discord/owner-cards.ts +463 -0
- package/src/core/discord/owner-command.ts +126 -0
- package/src/core/discord/owner-discord-access.ts +183 -0
- package/src/core/discord/owner-discord-threads.ts +160 -0
- package/src/core/discord/owner-discord.test.ts +325 -0
- package/src/core/discord/owner-discord.ts +282 -0
- package/src/core/discord/owner-panel.ts +103 -0
- package/src/core/discord/schedule-commands.ts +149 -0
- package/src/core/discord/stop-button.ts +20 -0
- package/src/core/domain/attachment.ts +39 -0
- package/src/core/domain/conversation.ts +42 -0
- package/src/core/domain/errors.ts +31 -0
- package/src/core/domain/expression.ts +21 -0
- package/src/core/domain/owner-prompts.ts +45 -0
- package/src/core/domain/ports.ts +91 -0
- package/src/core/domain/profile.ts +32 -0
- package/src/core/drain.test.ts +46 -0
- package/src/core/drain.ts +33 -0
- package/src/core/errors.ts +29 -0
- package/src/core/events.test.ts +81 -0
- package/src/core/events.ts +55 -0
- package/src/core/holds.test.ts +54 -0
- package/src/core/holds.ts +46 -0
- package/src/core/host.test.ts +536 -0
- package/src/core/host.ts +297 -0
- package/src/core/http/listeners.test.ts +164 -0
- package/src/core/http/listeners.ts +151 -0
- package/src/core/i18n/agent-panel.ts +96 -0
- package/src/core/i18n/cards.ts +64 -0
- package/src/core/i18n/channels.ts +28 -0
- package/src/core/i18n/conversation.ts +49 -0
- package/src/core/i18n/dashboard.ts +72 -0
- package/src/core/i18n/discord.ts +23 -0
- package/src/core/i18n/en.ts +27 -0
- package/src/core/i18n/i18n.test.ts +139 -0
- package/src/core/i18n/index.ts +58 -0
- package/src/core/i18n/owner.ts +35 -0
- package/src/core/i18n/schedules.ts +92 -0
- package/src/core/i18n/time-zones.ts +27 -0
- package/src/core/i18n/types.ts +7 -0
- package/src/core/i18n/zh-tw.ts +26 -0
- package/src/core/identity.test.ts +18 -0
- package/src/core/identity.ts +31 -0
- package/src/core/judging/confirmation-judge.ts +68 -0
- package/src/core/judging/effort-judge.test.ts +112 -0
- package/src/core/judging/effort-judge.ts +158 -0
- package/src/core/judging/model-judge.test.ts +126 -0
- package/src/core/judging/model-judge.ts +193 -0
- package/src/core/log.ts +45 -0
- package/src/core/models.ts +52 -0
- package/src/core/modules/background/background-turns.ts +78 -0
- package/src/core/modules/delegation/delegate.ts +54 -0
- package/src/core/modules/delegation/delegator.test.ts +170 -0
- package/src/core/modules/delegation/delegator.ts +191 -0
- package/src/core/modules/delegation/sol-worker.ts +78 -0
- package/src/core/modules/discord-admin/discord-admin.ts +288 -0
- package/src/core/modules/host-shell/shell-policy.ts +386 -0
- package/src/core/modules/memory/owner-memory-store.test.ts +244 -0
- package/src/core/modules/memory/owner-memory-store.ts +228 -0
- package/src/core/modules/memory/owner-memory.ts +153 -0
- package/src/core/modules/notify/notify.ts +26 -0
- package/src/core/modules/schedules/recurrence.ts +206 -0
- package/src/core/modules/schedules/schedule-store.ts +213 -0
- package/src/core/modules/schedules/schedule-tools.ts +196 -0
- package/src/core/modules/schedules/schedule.test.ts +358 -0
- package/src/core/modules/schedules/scheduler.ts +121 -0
- package/src/core/modules/schedules/schedules.ts +87 -0
- package/src/core/modules/skills/repo-name.ts +11 -0
- package/src/core/modules/skills/skill-kind.test.ts +55 -0
- package/src/core/modules/skills/skill-link.ts +69 -0
- package/src/core/modules/skills/skill-listing.ts +128 -0
- package/src/core/modules/skills/skill-registry.test.ts +376 -0
- package/src/core/modules/skills/skill-registry.ts +323 -0
- package/src/core/modules/skills/skill-rules.ts +120 -0
- package/src/core/modules/skills/skill-store.ts +285 -0
- package/src/core/modules/skills/skill-tools.ts +207 -0
- package/src/core/ops/error-reporter.test.ts +283 -0
- package/src/core/ops/error-reporter.ts +271 -0
- package/src/core/plugin.ts +200 -0
- package/src/core/presentation/card-cadence.ts +41 -0
- package/src/core/presentation/headline.ts +34 -0
- package/src/core/presentation/presentation.test.ts +166 -0
- package/src/core/presentation/quiet-links.ts +36 -0
- package/src/core/presentation/reply-splitter.ts +110 -0
- package/src/core/presentation/thinking-line.ts +24 -0
- package/src/core/public-entry.test.ts +59 -0
- package/src/core/registry/contributions.test.ts +215 -0
- package/src/core/registry/contributions.ts +249 -0
- package/src/core/registry/interactions.test.ts +87 -0
- package/src/core/registry/interactions.ts +55 -0
- package/src/core/registry/providers.test.ts +103 -0
- package/src/core/registry/providers.ts +55 -0
- package/src/core/routing/channel-queue.test.ts +31 -0
- package/src/core/routing/channel-queue.ts +44 -0
- package/src/core/routing/channel-router.test.ts +326 -0
- package/src/core/routing/channel-router.ts +157 -0
- package/src/core/routing/conversation-kind.ts +14 -0
- package/src/core/routing/forward-join.ts +65 -0
- package/src/core/routing/message-text.ts +16 -0
- package/src/core/routing/settle-turn.test.ts +21 -0
- package/src/core/routing/settle-turn.ts +26 -0
- package/src/core/runtime/compaction-tiers.test.ts +227 -0
- package/src/core/runtime/compaction-tiers.ts +203 -0
- package/src/core/runtime/conversation-sessions.ts +162 -0
- package/src/core/runtime/extensions/agent-prompt.ts +16 -0
- package/src/core/runtime/extensions/ask-user.test.ts +114 -0
- package/src/core/runtime/extensions/ask-user.ts +98 -0
- package/src/core/runtime/extensions/confirmation-gate.ts +247 -0
- package/src/core/runtime/extensions/self-compact-guard.test.ts +50 -0
- package/src/core/runtime/extensions/self-compact-guard.ts +23 -0
- package/src/core/runtime/mcp.ts +23 -0
- package/src/core/runtime/pending-confirmation-store.test.ts +52 -0
- package/src/core/runtime/pending-confirmation-store.ts +75 -0
- package/src/core/runtime/pi-agent-runtime.ts +477 -0
- package/src/core/runtime/prompt-slot.ts +81 -0
- package/src/core/runtime/runtime-types.ts +150 -0
- package/src/core/runtime/session-archive.test.ts +20 -0
- package/src/core/runtime/session-archive.ts +17 -0
- package/src/core/runtime/session-factory.ts +291 -0
- package/src/core/runtime/steerable-run.test.ts +264 -0
- package/src/core/runtime/steerable-run.ts +109 -0
- package/src/core/runtime/text-tools.test.ts +110 -0
- package/src/core/runtime/text-tools.ts +66 -0
- package/src/core/runtime/turn-answer.test.ts +72 -0
- package/src/core/runtime/turn-answer.ts +79 -0
- package/src/core/runtime/worker-task.test.ts +82 -0
- package/src/core/runtime/worker-task.ts +65 -0
- package/src/core/services.test.ts +28 -0
- package/src/core/services.ts +107 -0
- package/src/core/sessions.test.ts +85 -0
- package/src/core/sessions.ts +149 -0
- package/src/core/shared/attachment-reader.ts +91 -0
- package/src/core/shared/delegate-tool.ts +15 -0
- package/src/core/shared/mcp-adapter.ts +58 -0
- package/src/core/shared/package-dir.ts +9 -0
- package/src/core/shared/profile-tools.ts +16 -0
- package/src/core/shared/read-attachment-tool.ts +43 -0
- package/src/core/shared/schedule-tools.ts +118 -0
- package/src/core/shared/session-messages.ts +50 -0
- package/src/core/shared/tool-result.ts +9 -0
- package/src/core/shared/unix-server.ts +18 -0
- package/src/core/size.test.ts +9 -0
- package/src/core/speakers.test.ts +78 -0
- package/src/core/speakers.ts +143 -0
- package/src/core/testing/database.ts +52 -0
- package/src/core/testing/file-size.ts +34 -0
- package/src/core/testing/locale.ts +8 -0
- package/src/core/testing/modules.ts +97 -0
- package/src/core/testing/owner.ts +15 -0
- package/src/core/testing/thread-host.ts +60 -0
- package/src/core/time.test.ts +166 -0
- package/src/core/time.ts +67 -0
- package/src/core/tool-tiers.test.ts +117 -0
- package/src/core/tool-tiers.ts +127 -0
- package/src/entries.test.ts +112 -0
- package/src/index.ts +28 -0
- package/src/testing.test.ts +156 -0
- package/src/testing.ts +182 -0
- package/templates/.env.example +22 -0
- package/templates/README.md +11 -0
- package/templates/_gitignore +3 -0
- package/templates/agents.ts +12 -0
- package/templates/biome.json.tmpl +7 -0
- package/templates/docker-compose.yml +16 -0
- package/templates/package.json.tmpl +24 -0
- package/templates/persona/shared.md +2 -0
- package/templates/plugin/plugin.test.ts.tmpl +13 -0
- package/templates/plugin/plugin.ts +18 -0
- package/templates/roundtable.config.ts +24 -0
- package/templates/tsconfig.json.tmpl +16 -0
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
import type { ExtensionFactory } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Type } from "typebox";
|
|
3
|
+
import { AgentError } from "../domain/errors.ts";
|
|
4
|
+
import type { AgentTurnScope } from "../domain/ports.ts";
|
|
5
|
+
import { assistantName, messages } from "../i18n/index.ts";
|
|
6
|
+
import { type OwnerIdentity, ownerWords } from "../identity.ts";
|
|
7
|
+
import { THINKING_LEVELS } from "../models.ts";
|
|
8
|
+
import {
|
|
9
|
+
optionalString,
|
|
10
|
+
requiredString,
|
|
11
|
+
stringList,
|
|
12
|
+
type TextToolDef,
|
|
13
|
+
textToolsExtension,
|
|
14
|
+
} from "../runtime/text-tools.ts";
|
|
15
|
+
import { timeZone } from "../time.ts";
|
|
16
|
+
import {
|
|
17
|
+
MAX_AVATAR_PROMPT_CHARS,
|
|
18
|
+
MAX_GROUP_MEMBERS,
|
|
19
|
+
MAX_PROMPT_CHARS,
|
|
20
|
+
MIN_GROUP_MEMBERS,
|
|
21
|
+
} from "./agent-store.ts";
|
|
22
|
+
|
|
23
|
+
/** The tool that hands a request to another agent; a group seat has none. */
|
|
24
|
+
export const MESSAGE_AGENT_TOOL = "message_agent";
|
|
25
|
+
|
|
26
|
+
export const AGENT_TOOLS = [
|
|
27
|
+
"agent_list",
|
|
28
|
+
"agent_get",
|
|
29
|
+
"agent_update",
|
|
30
|
+
"agent_create",
|
|
31
|
+
"agent_avatar",
|
|
32
|
+
MESSAGE_AGENT_TOOL,
|
|
33
|
+
"group_create",
|
|
34
|
+
"group_update",
|
|
35
|
+
"archive",
|
|
36
|
+
"channel_arrange",
|
|
37
|
+
"channel_read",
|
|
38
|
+
] as const;
|
|
39
|
+
|
|
40
|
+
export type AvatarMode = "redraw" | "new_prompt" | "edit";
|
|
41
|
+
|
|
42
|
+
/** What the agent tools do; the agent team implements it. Each returns text for the model. */
|
|
43
|
+
export interface AgentOps {
|
|
44
|
+
list(): Promise<string>;
|
|
45
|
+
get(name: string): string;
|
|
46
|
+
update(
|
|
47
|
+
name: string,
|
|
48
|
+
change: {
|
|
49
|
+
displayName?: string;
|
|
50
|
+
prompt?: string;
|
|
51
|
+
model?: string;
|
|
52
|
+
thinking?: string;
|
|
53
|
+
},
|
|
54
|
+
): Promise<string>;
|
|
55
|
+
create(
|
|
56
|
+
caller: AgentTurnScope,
|
|
57
|
+
input: {
|
|
58
|
+
name: string;
|
|
59
|
+
displayName: string;
|
|
60
|
+
prompt: string;
|
|
61
|
+
avatarPrompt: string;
|
|
62
|
+
task: string;
|
|
63
|
+
category?: string;
|
|
64
|
+
skills?: string[];
|
|
65
|
+
},
|
|
66
|
+
): Promise<string>;
|
|
67
|
+
avatar(name: string, mode: AvatarMode, text?: string): Promise<string>;
|
|
68
|
+
/** Queues the message and returns at once. */
|
|
69
|
+
message(caller: AgentTurnScope, to: string, text: string): string;
|
|
70
|
+
createGroup(input: {
|
|
71
|
+
name: string;
|
|
72
|
+
displayName: string;
|
|
73
|
+
members: string[];
|
|
74
|
+
host?: string;
|
|
75
|
+
category?: string;
|
|
76
|
+
}): Promise<string>;
|
|
77
|
+
updateGroup(
|
|
78
|
+
name: string,
|
|
79
|
+
change: { displayName?: string; members?: string[]; host?: string },
|
|
80
|
+
): Promise<string>;
|
|
81
|
+
archive(caller: AgentTurnScope, name: string): Promise<string>;
|
|
82
|
+
arrange(entries: { category: string; names: string[] }[]): Promise<string>;
|
|
83
|
+
read(
|
|
84
|
+
channel: string,
|
|
85
|
+
around: string | undefined,
|
|
86
|
+
limit: number | undefined,
|
|
87
|
+
): Promise<string>;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const NAME = Type.String({
|
|
91
|
+
description:
|
|
92
|
+
"An agent's name (lowercase, from agent_list), not its display name.",
|
|
93
|
+
});
|
|
94
|
+
const DISPLAY = Type.String({
|
|
95
|
+
minLength: 1,
|
|
96
|
+
maxLength: 32,
|
|
97
|
+
description: "The name shown on its Discord messages; any language.",
|
|
98
|
+
});
|
|
99
|
+
const PROMPT = Type.String({
|
|
100
|
+
minLength: 1,
|
|
101
|
+
maxLength: MAX_PROMPT_CHARS,
|
|
102
|
+
description:
|
|
103
|
+
"The agent's role prompt, in English: its job, sources, boundaries, and how it reports. Short and stated positively.",
|
|
104
|
+
});
|
|
105
|
+
const avatarPrompt = () =>
|
|
106
|
+
Type.String({
|
|
107
|
+
minLength: 1,
|
|
108
|
+
maxLength: MAX_AVATAR_PROMPT_CHARS,
|
|
109
|
+
description: `The avatar is ${assistantName()}'s face drawn as an anime character; describe the role's exaggerated pose and expression, one telling prop, the outfit colours, and the background colour. Pick outfit and background colours unlike the other agents' (their avatar prompts are in agent_get).`,
|
|
110
|
+
});
|
|
111
|
+
const category = (kind: "Agents" | "Groups") =>
|
|
112
|
+
Type.String({
|
|
113
|
+
description: `The category its channel opens under: ${kind} (the default) or ${kind}-<suffix>, such as ${kind}-work; made when missing. Keep to the categories agent_list shows unless a new one fits better.`,
|
|
114
|
+
});
|
|
115
|
+
const MEMBERS = Type.Array(Type.String(), {
|
|
116
|
+
minItems: MIN_GROUP_MEMBERS,
|
|
117
|
+
maxItems: MAX_GROUP_MEMBERS,
|
|
118
|
+
description: "Agent names in speaking-tie order.",
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
type AgentToolDef = TextToolDef<(typeof AGENT_TOOLS)[number]>;
|
|
122
|
+
|
|
123
|
+
function toolDefs(
|
|
124
|
+
ops: AgentOps,
|
|
125
|
+
scope: AgentTurnScope,
|
|
126
|
+
owner: OwnerIdentity,
|
|
127
|
+
): AgentToolDef[] {
|
|
128
|
+
const o = ownerWords(owner);
|
|
129
|
+
const defs: AgentToolDef[] = [
|
|
130
|
+
{
|
|
131
|
+
name: "agent_list",
|
|
132
|
+
label: "List agents",
|
|
133
|
+
description:
|
|
134
|
+
"List every agent and group: name, display name, channel, and status; then the Agents and Groups categories in server order with their channels.",
|
|
135
|
+
parameters: Type.Object({}),
|
|
136
|
+
run: () => ops.list(),
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
name: "agent_get",
|
|
140
|
+
label: "Read agent",
|
|
141
|
+
description:
|
|
142
|
+
"Read an agent's display name, status, channel, model, thinking level, skills, prompt, and avatar prompt.",
|
|
143
|
+
parameters: Type.Object({ name: NAME }),
|
|
144
|
+
run: (i) => ops.get(requiredString(i, "name")),
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
name: "agent_update",
|
|
148
|
+
label: "Update agent",
|
|
149
|
+
description:
|
|
150
|
+
"Change an agent's display name, prompt, model, or thinking level, yours included. The prompt replaces the old one whole. Changes apply from that agent's next turn; its conversation stays. Read it with agent_get first.",
|
|
151
|
+
parameters: Type.Object({
|
|
152
|
+
name: NAME,
|
|
153
|
+
display_name: Type.Optional(DISPLAY),
|
|
154
|
+
prompt: Type.Optional(PROMPT),
|
|
155
|
+
model: Type.Optional(
|
|
156
|
+
Type.String({
|
|
157
|
+
description: `The model to run on, as <provider>/<model> such as openai-codex/gpt-6-sol; "default" for ${assistantName()}'s. An unusable one is refused with the usable list.`,
|
|
158
|
+
}),
|
|
159
|
+
),
|
|
160
|
+
thinking: Type.Optional(
|
|
161
|
+
Type.Union(
|
|
162
|
+
[...THINKING_LEVELS, "default"].map((level) => Type.Literal(level)),
|
|
163
|
+
{
|
|
164
|
+
description: `"default" for ${assistantName()}'s, which is auto: the judge picks each turn's level from its message. Set a fixed level only when the agent has a reason to.`,
|
|
165
|
+
},
|
|
166
|
+
),
|
|
167
|
+
),
|
|
168
|
+
}),
|
|
169
|
+
run: (i) => {
|
|
170
|
+
const displayName = optionalString(i, "display_name");
|
|
171
|
+
const prompt = optionalString(i, "prompt");
|
|
172
|
+
const model = optionalString(i, "model");
|
|
173
|
+
const thinking = optionalString(i, "thinking");
|
|
174
|
+
return ops.update(requiredString(i, "name"), {
|
|
175
|
+
...(displayName !== undefined ? { displayName } : {}),
|
|
176
|
+
...(prompt !== undefined ? { prompt } : {}),
|
|
177
|
+
...(model !== undefined ? { model } : {}),
|
|
178
|
+
...(thinking !== undefined ? { thinking } : {}),
|
|
179
|
+
});
|
|
180
|
+
},
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
name: "agent_create",
|
|
184
|
+
label: "Create agent",
|
|
185
|
+
description:
|
|
186
|
+
"Create a new agent with its own channel and a drawn avatar; it starts at once on the opening task in its channel. Its name is permanent.",
|
|
187
|
+
parameters: Type.Object({
|
|
188
|
+
name: Type.String({
|
|
189
|
+
pattern: "^[a-z0-9-]{1,32}$",
|
|
190
|
+
description:
|
|
191
|
+
"Permanent name: lowercase letters, digits, and dashes; also its channel name.",
|
|
192
|
+
}),
|
|
193
|
+
display_name: DISPLAY,
|
|
194
|
+
prompt: PROMPT,
|
|
195
|
+
avatar_prompt: avatarPrompt(),
|
|
196
|
+
task: Type.String({
|
|
197
|
+
minLength: 1,
|
|
198
|
+
maxLength: 4000,
|
|
199
|
+
description: "Its opening task, self-contained.",
|
|
200
|
+
}),
|
|
201
|
+
category: Type.Optional(category("Agents")),
|
|
202
|
+
skills: Type.Optional(
|
|
203
|
+
Type.Array(Type.String(), {
|
|
204
|
+
description:
|
|
205
|
+
"Skills it carries, from skill_list; pick those its role needs. Change them later with agent_skills.",
|
|
206
|
+
}),
|
|
207
|
+
),
|
|
208
|
+
}),
|
|
209
|
+
run: (i) => {
|
|
210
|
+
const category = optionalString(i, "category");
|
|
211
|
+
const skills = stringList(i, "skills");
|
|
212
|
+
return ops.create(scope, {
|
|
213
|
+
name: requiredString(i, "name"),
|
|
214
|
+
displayName: requiredString(i, "display_name"),
|
|
215
|
+
prompt: requiredString(i, "prompt"),
|
|
216
|
+
avatarPrompt: requiredString(i, "avatar_prompt"),
|
|
217
|
+
task: requiredString(i, "task"),
|
|
218
|
+
...(category ? { category } : {}),
|
|
219
|
+
...(skills && skills.length > 0 ? { skills } : {}),
|
|
220
|
+
});
|
|
221
|
+
},
|
|
222
|
+
},
|
|
223
|
+
{
|
|
224
|
+
name: "agent_avatar",
|
|
225
|
+
label: "Redraw avatar",
|
|
226
|
+
description:
|
|
227
|
+
"Redraw an agent's avatar: redraw from its stored avatar prompt; new_prompt with a new avatar prompt, which replaces the stored one; or edit the current picture by an instruction.",
|
|
228
|
+
parameters: Type.Object({
|
|
229
|
+
name: NAME,
|
|
230
|
+
mode: Type.Union([
|
|
231
|
+
Type.Literal("redraw"),
|
|
232
|
+
Type.Literal("new_prompt"),
|
|
233
|
+
Type.Literal("edit"),
|
|
234
|
+
]),
|
|
235
|
+
text: Type.Optional(
|
|
236
|
+
Type.String({
|
|
237
|
+
maxLength: MAX_AVATAR_PROMPT_CHARS,
|
|
238
|
+
description:
|
|
239
|
+
"The new avatar prompt for new_prompt, or the instruction for edit.",
|
|
240
|
+
}),
|
|
241
|
+
),
|
|
242
|
+
}),
|
|
243
|
+
run: (i) =>
|
|
244
|
+
ops.avatar(
|
|
245
|
+
requiredString(i, "name"),
|
|
246
|
+
requiredString(i, "mode") as AvatarMode,
|
|
247
|
+
optionalString(i, "text"),
|
|
248
|
+
),
|
|
249
|
+
},
|
|
250
|
+
{
|
|
251
|
+
name: "group_create",
|
|
252
|
+
label: "Create group",
|
|
253
|
+
description: `Create a group chat channel with 2 to 6 agents. The judge picks which members answer each of ${o.name}'s messages there, one after another. The host answers when nobody else fits.`,
|
|
254
|
+
parameters: Type.Object({
|
|
255
|
+
name: Type.String({ pattern: "^[a-z0-9-]{1,32}$" }),
|
|
256
|
+
display_name: DISPLAY,
|
|
257
|
+
members: MEMBERS,
|
|
258
|
+
host: Type.Optional(NAME),
|
|
259
|
+
category: Type.Optional(category("Groups")),
|
|
260
|
+
}),
|
|
261
|
+
run: (i) => {
|
|
262
|
+
const host = optionalString(i, "host");
|
|
263
|
+
const category = optionalString(i, "category");
|
|
264
|
+
return ops.createGroup({
|
|
265
|
+
name: requiredString(i, "name"),
|
|
266
|
+
displayName: requiredString(i, "display_name"),
|
|
267
|
+
members: stringList(i, "members") ?? [],
|
|
268
|
+
...(host ? { host } : {}),
|
|
269
|
+
...(category ? { category } : {}),
|
|
270
|
+
});
|
|
271
|
+
},
|
|
272
|
+
},
|
|
273
|
+
{
|
|
274
|
+
name: "group_update",
|
|
275
|
+
label: "Update group",
|
|
276
|
+
description: "Change a group's display name, members, or host.",
|
|
277
|
+
parameters: Type.Object({
|
|
278
|
+
name: Type.String(),
|
|
279
|
+
display_name: Type.Optional(DISPLAY),
|
|
280
|
+
members: Type.Optional(MEMBERS),
|
|
281
|
+
host: Type.Optional(NAME),
|
|
282
|
+
}),
|
|
283
|
+
run: (i) => {
|
|
284
|
+
const displayName = optionalString(i, "display_name");
|
|
285
|
+
const members = stringList(i, "members");
|
|
286
|
+
const host = optionalString(i, "host");
|
|
287
|
+
return ops.updateGroup(requiredString(i, "name"), {
|
|
288
|
+
...(displayName !== undefined ? { displayName } : {}),
|
|
289
|
+
...(members ? { members } : {}),
|
|
290
|
+
...(host ? { host } : {}),
|
|
291
|
+
});
|
|
292
|
+
},
|
|
293
|
+
},
|
|
294
|
+
{
|
|
295
|
+
name: "archive",
|
|
296
|
+
label: "Archive",
|
|
297
|
+
description: `Archive an agent or a group, only when ${o.name} asks for it. Same as deleting its channel, except the channel is kept with its history under the Archive category, where nobody answers. An agent's schedules are cancelled and it leaves its groups; a group's members are untouched. There is no unarchive tool. The coordinator and yourself cannot be archived.`,
|
|
298
|
+
parameters: Type.Object({
|
|
299
|
+
name: Type.String({
|
|
300
|
+
description: "The agent's or group's name, from agent_list.",
|
|
301
|
+
}),
|
|
302
|
+
}),
|
|
303
|
+
run: (i) => ops.archive(scope, requiredString(i, "name")),
|
|
304
|
+
},
|
|
305
|
+
{
|
|
306
|
+
name: "channel_arrange",
|
|
307
|
+
label: "Arrange channels",
|
|
308
|
+
description: `Sort agent and group channels into categories and order them, when ${o.name} asks. Agents go under Agents or Agents-<suffix>, groups under Groups or Groups-<suffix>; missing categories are made. The listed categories come first in the server in list order, then every other category as it was; in each listed category the listed channels come first in order, then its other channels as they were. Agents and Groups categories left empty are deleted. Any wrong entry refuses the whole call and nothing moves. Read the current layout with agent_list first.`,
|
|
309
|
+
parameters: Type.Object({
|
|
310
|
+
categories: Type.Array(
|
|
311
|
+
Type.Object({
|
|
312
|
+
category: Type.String({
|
|
313
|
+
description:
|
|
314
|
+
"Agents, Groups, or either with -<suffix>, the suffix 1 to 32 characters in any language.",
|
|
315
|
+
}),
|
|
316
|
+
channels: Type.Array(Type.String(), {
|
|
317
|
+
minItems: 1,
|
|
318
|
+
description:
|
|
319
|
+
"Agent or group names (from agent_list), in the order they should appear.",
|
|
320
|
+
}),
|
|
321
|
+
}),
|
|
322
|
+
{ minItems: 1, description: "Categories in server order." },
|
|
323
|
+
),
|
|
324
|
+
}),
|
|
325
|
+
run: (i) => {
|
|
326
|
+
const entries = Array.isArray(i.categories) ? i.categories : [];
|
|
327
|
+
return ops.arrange(
|
|
328
|
+
entries.map((entry: Record<string, unknown>) => ({
|
|
329
|
+
category: requiredString(entry, "category"),
|
|
330
|
+
names: stringList(entry, "channels") ?? [],
|
|
331
|
+
})),
|
|
332
|
+
);
|
|
333
|
+
},
|
|
334
|
+
},
|
|
335
|
+
{
|
|
336
|
+
name: "channel_read",
|
|
337
|
+
label: "Read channel",
|
|
338
|
+
description: `Read messages of a text channel or thread of the agent server, oldest first, each with its id, ${messages().zoneTime(timeZone())}, author, text, and attachment names. Use it for the surroundings of a message ${o.name} forwarded to you: pass the source channel and the message id from the forwarded link. Read-only.`,
|
|
339
|
+
parameters: Type.Object({
|
|
340
|
+
channel: Type.String({
|
|
341
|
+
description: "The channel as <#id> or its id.",
|
|
342
|
+
}),
|
|
343
|
+
around: Type.Optional(
|
|
344
|
+
Type.String({
|
|
345
|
+
pattern: "^\\d+$",
|
|
346
|
+
description:
|
|
347
|
+
"A message id: read the messages around it instead of the latest.",
|
|
348
|
+
}),
|
|
349
|
+
),
|
|
350
|
+
limit: Type.Optional(
|
|
351
|
+
Type.Integer({ minimum: 1, maximum: 50, description: "Default 20." }),
|
|
352
|
+
),
|
|
353
|
+
}),
|
|
354
|
+
run: (i) =>
|
|
355
|
+
ops.read(
|
|
356
|
+
requiredString(i, "channel"),
|
|
357
|
+
optionalString(i, "around"),
|
|
358
|
+
typeof i.limit === "number" ? i.limit : undefined,
|
|
359
|
+
),
|
|
360
|
+
},
|
|
361
|
+
];
|
|
362
|
+
// In a group, members hand off by mentioning each other where everyone can see it.
|
|
363
|
+
if (!scope.group)
|
|
364
|
+
defs.push({
|
|
365
|
+
name: MESSAGE_AGENT_TOOL,
|
|
366
|
+
label: "Message agent",
|
|
367
|
+
description:
|
|
368
|
+
"Send a self-contained request to another agent. It returns at once; the agent works in its own channel and its answer comes back to you as a new turn. Its channel shows the exchange, and so does a thread in your channel.",
|
|
369
|
+
parameters: Type.Object({
|
|
370
|
+
to: NAME,
|
|
371
|
+
text: Type.String({ minLength: 1, maxLength: 4000 }),
|
|
372
|
+
}),
|
|
373
|
+
run: (i) =>
|
|
374
|
+
ops.message(scope, requiredString(i, "to"), requiredString(i, "text")),
|
|
375
|
+
});
|
|
376
|
+
return defs;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** Registers the agent tools for one agent session. */
|
|
380
|
+
export function agentToolsExtension(
|
|
381
|
+
ops: AgentOps,
|
|
382
|
+
scope: AgentTurnScope,
|
|
383
|
+
owner: OwnerIdentity,
|
|
384
|
+
): ExtensionFactory {
|
|
385
|
+
return textToolsExtension(toolDefs(ops, scope, owner), AgentError);
|
|
386
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { createCanvas, loadImage } from "canvas";
|
|
6
|
+
import { AvatarStudio, type ImageDrawer } from "./avatar-studio.ts";
|
|
7
|
+
|
|
8
|
+
const REFERENCE = join(import.meta.dir, "..", "assets", "neutral.png");
|
|
9
|
+
|
|
10
|
+
function picture(width: number, height: number, color: string): Uint8Array {
|
|
11
|
+
const canvas = createCanvas(width, height);
|
|
12
|
+
const ctx = canvas.getContext("2d");
|
|
13
|
+
ctx.fillStyle = color;
|
|
14
|
+
ctx.fillRect(0, 0, width, height);
|
|
15
|
+
return canvas.toBuffer("image/png");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function studio(draw: ImageDrawer) {
|
|
19
|
+
const s = new AvatarStudio({
|
|
20
|
+
dir: mkdtempSync(join(tmpdir(), "roundtable-avatars-")),
|
|
21
|
+
publicUrl: "https://example.com",
|
|
22
|
+
referencePath: REFERENCE,
|
|
23
|
+
draw,
|
|
24
|
+
});
|
|
25
|
+
await s.init();
|
|
26
|
+
return s;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe("AvatarStudio", () => {
|
|
30
|
+
test("an agent without a picture gets the assistant's neutral avatar", async () => {
|
|
31
|
+
const s = await studio(async () => picture(10, 10, "red"));
|
|
32
|
+
const url = s.url(undefined);
|
|
33
|
+
expect(url).toMatch(/^https:\/\/example\.com\/avatars\/[0-9a-f]{64}\.png$/);
|
|
34
|
+
const served = s.serve(new URL(url).pathname);
|
|
35
|
+
expect(served?.status).toBe(200);
|
|
36
|
+
expect(served?.headers.get("Content-Type")).toBe("image/png");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("draws with the reference and stores a square of at most 512 px", async () => {
|
|
40
|
+
const prompts: string[] = [];
|
|
41
|
+
const s = await studio(async (prompt, refs) => {
|
|
42
|
+
prompts.push(prompt);
|
|
43
|
+
expect(refs).toHaveLength(1);
|
|
44
|
+
return picture(1024, 768, "blue");
|
|
45
|
+
});
|
|
46
|
+
const hash = await s.draw("a friendly engineer");
|
|
47
|
+
expect(prompts[0]).toContain("a friendly engineer");
|
|
48
|
+
expect(prompts[0]).toContain("anime");
|
|
49
|
+
const served = s.serve(`/avatars/${hash}.png`);
|
|
50
|
+
if (!served) throw new Error("not served");
|
|
51
|
+
const image = await loadImage(Buffer.from(await served.arrayBuffer()));
|
|
52
|
+
expect([image.width, image.height]).toEqual([512, 512]);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("an edit starts from the current picture and yields a new hash", async () => {
|
|
56
|
+
let references = 0;
|
|
57
|
+
const s = await studio(async (_prompt, refs) => {
|
|
58
|
+
references += refs.length;
|
|
59
|
+
return picture(64, 64, references > 1 ? "green" : "blue");
|
|
60
|
+
});
|
|
61
|
+
const first = await s.draw("x");
|
|
62
|
+
const second = await s.edit(first, "add glasses");
|
|
63
|
+
expect(second).not.toBe(first);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("serves nothing but avatar pictures", async () => {
|
|
67
|
+
const s = await studio(async () => picture(4, 4, "red"));
|
|
68
|
+
expect(s.serve("/mcp/personal")).toBeUndefined();
|
|
69
|
+
expect(s.serve("/avatars/../secrets.env")).toBeUndefined();
|
|
70
|
+
expect(s.serve(`/avatars/${"0".repeat(64)}.png`)?.status).toBe(404);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("its public route answers GET for pictures and 404 for any other avatar path", async () => {
|
|
74
|
+
const s = await studio(async () => picture(4, 4, "red"));
|
|
75
|
+
const hash = await s.draw("x");
|
|
76
|
+
const route = s.route("public");
|
|
77
|
+
expect(route).toMatchObject({
|
|
78
|
+
listener: "public",
|
|
79
|
+
path: { prefix: "/avatars/" },
|
|
80
|
+
methods: ["GET", "HEAD"],
|
|
81
|
+
});
|
|
82
|
+
const get = (path: string) =>
|
|
83
|
+
route.handle(new Request(`https://example.com${path}`));
|
|
84
|
+
expect((await get(`/avatars/${hash}.png`)).status).toBe(200);
|
|
85
|
+
const other = await get("/avatars/../secrets.env");
|
|
86
|
+
expect([other.status, await other.text()]).toEqual([404, "Not found"]);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { createCanvas, loadImage } from "canvas";
|
|
5
|
+
import type { ImageDrawer } from "../contract/providers.ts";
|
|
6
|
+
import type { HttpRoute } from "../http/listeners.ts";
|
|
7
|
+
|
|
8
|
+
const AVATAR_SIDE = 512;
|
|
9
|
+
const REFERENCE_SIDE = 512;
|
|
10
|
+
const HASH_PATH = /^\/avatars\/([0-9a-f]{64})\.png$/;
|
|
11
|
+
|
|
12
|
+
export type { ImageDrawer };
|
|
13
|
+
|
|
14
|
+
export interface AvatarStudioOptions {
|
|
15
|
+
/** Where pictures are kept, one `<sha256>.png` each. */
|
|
16
|
+
dir: string;
|
|
17
|
+
/** Origin the pictures are served from, such as https://assistant.example.com. */
|
|
18
|
+
publicUrl: string;
|
|
19
|
+
/** The assistant's neutral avatar: the style reference and the picture of an agent without one. */
|
|
20
|
+
referencePath: string;
|
|
21
|
+
draw: ImageDrawer;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** A centered square crop scaled to at most `side` pixels, as PNG. */
|
|
25
|
+
async function squarePng(bytes: Uint8Array, side: number): Promise<Buffer> {
|
|
26
|
+
const image = await loadImage(Buffer.from(bytes));
|
|
27
|
+
const crop = Math.min(image.width, image.height);
|
|
28
|
+
const size = Math.min(side, crop);
|
|
29
|
+
const canvas = createCanvas(size, size);
|
|
30
|
+
canvas
|
|
31
|
+
.getContext("2d")
|
|
32
|
+
.drawImage(
|
|
33
|
+
image,
|
|
34
|
+
(image.width - crop) / 2,
|
|
35
|
+
(image.height - crop) / 2,
|
|
36
|
+
crop,
|
|
37
|
+
crop,
|
|
38
|
+
0,
|
|
39
|
+
0,
|
|
40
|
+
size,
|
|
41
|
+
size,
|
|
42
|
+
);
|
|
43
|
+
return canvas.toBuffer("image/png");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Agent avatars: Codex draws them in the assistant's style, and they are kept and served by content
|
|
48
|
+
* hash, so a new picture has a new URL and Discord shows it at once.
|
|
49
|
+
*/
|
|
50
|
+
export class AvatarStudio {
|
|
51
|
+
readonly #options: AvatarStudioOptions;
|
|
52
|
+
#defaultHash: string | undefined;
|
|
53
|
+
|
|
54
|
+
constructor(options: AvatarStudioOptions) {
|
|
55
|
+
this.#options = options;
|
|
56
|
+
mkdirSync(options.dir, { recursive: true });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Stores the assistant's neutral avatar, the picture of an agent that has none yet. */
|
|
60
|
+
async init(): Promise<void> {
|
|
61
|
+
this.#defaultHash = await this.#store(
|
|
62
|
+
readFileSync(this.#options.referencePath),
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The public URL of a picture, or of the default when there is none. */
|
|
67
|
+
url(hash: string | undefined): string {
|
|
68
|
+
const chosen = hash ?? this.#defaultHash;
|
|
69
|
+
if (!chosen) throw new Error("the avatar studio was not initialized");
|
|
70
|
+
return `${this.#options.publicUrl}/avatars/${chosen}.png`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Draws a new picture from an avatar prompt: the assistant's face as an anime character in the
|
|
75
|
+
* role's pose; returns its hash. Outfit and background colours come from the avatar prompt,
|
|
76
|
+
* so agents stay easy to tell apart.
|
|
77
|
+
*/
|
|
78
|
+
async draw(avatarPrompt: string): Promise<string> {
|
|
79
|
+
const reference = await squarePng(
|
|
80
|
+
readFileSync(this.#options.referencePath),
|
|
81
|
+
REFERENCE_SIDE,
|
|
82
|
+
);
|
|
83
|
+
const bytes = await this.#options.draw(
|
|
84
|
+
`Draw a square Discord avatar of the person in the reference image, redrawn as an anime character: keep their face, short dark hair, and features recognizable as the same person, but in vivid anime cel-shading with a big, exaggerated expression and a dramatic pose that shows the role. Dress them and paint the background exactly in the colours the description names; ignore the reference's green jacket and teal background. Head-and-shoulders framing that still reads at 40 px. No text, no border. The role: ${avatarPrompt}`,
|
|
85
|
+
[{ data: reference.toString("base64"), mimeType: "image/png" }],
|
|
86
|
+
);
|
|
87
|
+
return this.#store(bytes);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Edits a stored picture by an instruction; returns the new picture's hash. */
|
|
91
|
+
async edit(hash: string | undefined, instruction: string): Promise<string> {
|
|
92
|
+
const current = readFileSync(
|
|
93
|
+
this.#path(hash ?? this.#defaultHashOrThrow()),
|
|
94
|
+
);
|
|
95
|
+
const bytes = await this.#options.draw(
|
|
96
|
+
`Edit this Discord avatar: ${instruction}. Keep everything else, the style, and the square head-and-shoulders framing the same. No text, no border.`,
|
|
97
|
+
[{ data: current.toString("base64"), mimeType: "image/png" }],
|
|
98
|
+
);
|
|
99
|
+
return this.#store(bytes);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Avatar pictures, public on the listener that cloudflared forwards the public hostname to. */
|
|
103
|
+
route(listener: string): HttpRoute {
|
|
104
|
+
return {
|
|
105
|
+
name: "avatars",
|
|
106
|
+
listener,
|
|
107
|
+
path: { prefix: "/avatars/" },
|
|
108
|
+
methods: ["GET", "HEAD"],
|
|
109
|
+
handle: (request) =>
|
|
110
|
+
// pi-lens-ignore: unchecked-throwing-call -- the server builds request.url, always an absolute URL
|
|
111
|
+
this.serve(new URL(request.url).pathname) ??
|
|
112
|
+
new Response("Not found", { status: 404 }),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Serves `/avatars/<hash>.png`; undefined for any other path. */
|
|
117
|
+
serve(pathname: string): Response | undefined {
|
|
118
|
+
const match = HASH_PATH.exec(pathname);
|
|
119
|
+
if (!match?.[1]) return undefined;
|
|
120
|
+
const path = this.#path(match[1]);
|
|
121
|
+
if (!existsSync(path)) return new Response("Not found", { status: 404 });
|
|
122
|
+
return new Response(readFileSync(path), {
|
|
123
|
+
headers: {
|
|
124
|
+
"Content-Type": "image/png",
|
|
125
|
+
"Cache-Control": "public, max-age=31536000, immutable",
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
#defaultHashOrThrow(): string {
|
|
131
|
+
if (!this.#defaultHash)
|
|
132
|
+
throw new Error("the avatar studio was not initialized");
|
|
133
|
+
return this.#defaultHash;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
#path(hash: string): string {
|
|
137
|
+
return join(this.#options.dir, `${hash}.png`);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async #store(bytes: Uint8Array): Promise<string> {
|
|
141
|
+
const png = await squarePng(bytes, AVATAR_SIDE);
|
|
142
|
+
const hash = createHash("sha256").update(png).digest("hex");
|
|
143
|
+
const path = this.#path(hash);
|
|
144
|
+
if (!existsSync(path)) writeFileSync(path, png);
|
|
145
|
+
return hash;
|
|
146
|
+
}
|
|
147
|
+
}
|