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,323 @@
|
|
|
1
|
+
import type { TurnAttachments } from "../domain/attachment.ts";
|
|
2
|
+
import type {
|
|
3
|
+
ChannelKey,
|
|
4
|
+
PendingConfirmation,
|
|
5
|
+
TurnResult,
|
|
6
|
+
} from "../domain/conversation.ts";
|
|
7
|
+
import { AgentError } from "../domain/errors.ts";
|
|
8
|
+
import type { AgentTurnScope } from "../domain/ports.ts";
|
|
9
|
+
import { messages } from "../i18n/index.ts";
|
|
10
|
+
import { splitReply } from "../presentation/reply-splitter.ts";
|
|
11
|
+
import { thinkingLine } from "../presentation/thinking-line.ts";
|
|
12
|
+
import { settleTurn } from "../routing/settle-turn.ts";
|
|
13
|
+
import type { Speaker } from "../speakers.ts";
|
|
14
|
+
import { toolTiers } from "../tool-tiers.ts";
|
|
15
|
+
import { AgentMessages } from "./agent-messages.ts";
|
|
16
|
+
import type { Agent } from "./agent-store.ts";
|
|
17
|
+
import { GroupTurns } from "./group-turns.ts";
|
|
18
|
+
import {
|
|
19
|
+
channelIdOf,
|
|
20
|
+
channelKey,
|
|
21
|
+
channelOwner,
|
|
22
|
+
homeScope,
|
|
23
|
+
} from "./team-keys.ts";
|
|
24
|
+
import {
|
|
25
|
+
type Chain,
|
|
26
|
+
MAX_CHAIN_MESSAGES,
|
|
27
|
+
type TeamTurnsOptions,
|
|
28
|
+
type TurnExtra,
|
|
29
|
+
type TurnHost,
|
|
30
|
+
} from "./team-turn-types.ts";
|
|
31
|
+
|
|
32
|
+
export { MAX_CHAIN_MESSAGES, type TeamTurnsOptions };
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The agent team's turns: owner messages, background turns, group rounds, and messages between
|
|
36
|
+
* agents, each posted under the answering agent's name.
|
|
37
|
+
*/
|
|
38
|
+
export class TeamTurns {
|
|
39
|
+
readonly #options: TeamTurnsOptions;
|
|
40
|
+
/** The chain of each running turn, by session key, read by message_agent. */
|
|
41
|
+
readonly #chains = new Map<ChannelKey, Chain>();
|
|
42
|
+
/** The channel each working agent's turn posts in. */
|
|
43
|
+
readonly #working = new Map<string, ChannelKey>();
|
|
44
|
+
/** When each agent or group (by `group:<name>`) last started or ended a turn. */
|
|
45
|
+
readonly #lastActive = new Map<string, Date>();
|
|
46
|
+
|
|
47
|
+
readonly #messages: AgentMessages;
|
|
48
|
+
readonly #groups: GroupTurns;
|
|
49
|
+
|
|
50
|
+
constructor(options: TeamTurnsOptions) {
|
|
51
|
+
this.#options = options;
|
|
52
|
+
const host: TurnHost = {
|
|
53
|
+
options,
|
|
54
|
+
chains: this.#chains,
|
|
55
|
+
turn: (agent, scope, postTo, text, chain, extra) =>
|
|
56
|
+
this.#turn(agent, scope, postTo, text, chain, extra),
|
|
57
|
+
post: (channel, as, body) => this.#post(channel, as, body),
|
|
58
|
+
current: (agent) => this.#current(agent),
|
|
59
|
+
mayApprove: (speaker, pending) => this.#mayApprove(speaker, pending),
|
|
60
|
+
turnChannel: (scope) => this.turnChannel(scope),
|
|
61
|
+
};
|
|
62
|
+
this.#messages = new AgentMessages(host);
|
|
63
|
+
this.#groups = new GroupTurns(host);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The channel of the agent's running turn, if it has one. */
|
|
67
|
+
workingIn(agent: string): ChannelKey | undefined {
|
|
68
|
+
return this.#working.get(agent);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Whoever started the run the agent's session is in; undefined outside a turn. */
|
|
72
|
+
speakerOf(scope: AgentTurnScope): Speaker | undefined {
|
|
73
|
+
return this.#chains.get(scope.session)?.speaker;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
lastActive(agent: string): Date | undefined {
|
|
77
|
+
return this.#lastActive.get(agent);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
lastGroupActive(group: string): Date | undefined {
|
|
81
|
+
return this.#lastActive.get(`group:${group}`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The owner's message in an agent's channel; call inside the channel's queue. `text`
|
|
86
|
+
* carries the reference, `replyText` is what he typed.
|
|
87
|
+
*/
|
|
88
|
+
async answerOwner(
|
|
89
|
+
channel: ChannelKey,
|
|
90
|
+
speaker: Speaker,
|
|
91
|
+
text: string,
|
|
92
|
+
replyText: string,
|
|
93
|
+
attachments: TurnAttachments,
|
|
94
|
+
): Promise<TurnResult> {
|
|
95
|
+
const agent = this.#agentOf(channel);
|
|
96
|
+
const scope = homeScope(agent);
|
|
97
|
+
const pending = await this.#options.runtime().heldActions(scope.session);
|
|
98
|
+
const confirmed =
|
|
99
|
+
pending !== undefined &&
|
|
100
|
+
this.#mayApprove(speaker, pending) &&
|
|
101
|
+
(await this.#options.confirmations.approves(pending, replyText));
|
|
102
|
+
return this.#turn(
|
|
103
|
+
agent,
|
|
104
|
+
scope,
|
|
105
|
+
channel,
|
|
106
|
+
text,
|
|
107
|
+
{ messages: 0, speaker },
|
|
108
|
+
{
|
|
109
|
+
attachments,
|
|
110
|
+
confirmed,
|
|
111
|
+
steerable: true,
|
|
112
|
+
interactive: true,
|
|
113
|
+
},
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* A turn in an agent's channel that no owner message started; call inside its queue. A report
|
|
119
|
+
* turn is interactive: it may ask the owner on cards there.
|
|
120
|
+
*/
|
|
121
|
+
answerBackground(
|
|
122
|
+
channel: ChannelKey,
|
|
123
|
+
speaker: Speaker,
|
|
124
|
+
text: string,
|
|
125
|
+
interactive = false,
|
|
126
|
+
): Promise<TurnResult> {
|
|
127
|
+
const agent = this.#agentOf(channel);
|
|
128
|
+
return this.#turn(
|
|
129
|
+
agent,
|
|
130
|
+
homeScope(agent),
|
|
131
|
+
channel,
|
|
132
|
+
text,
|
|
133
|
+
{ messages: 0, speaker },
|
|
134
|
+
interactive ? { interactive: true } : {},
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Whether the speaker's tier holds every tool of the held actions. */
|
|
139
|
+
#mayApprove(speaker: Speaker, pending: PendingConfirmation): boolean {
|
|
140
|
+
const tiers = this.#options.toolTiers ?? toolTiers();
|
|
141
|
+
return pending.calls.every((call) => tiers.allows(speaker.tier, call.tool));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
#agentOf(channel: ChannelKey): Agent {
|
|
145
|
+
const agent = this.#options.store.agentByChannel(channelIdOf(channel));
|
|
146
|
+
if (!agent) throw new AgentError(`no agent owns ${channel}`);
|
|
147
|
+
return agent;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Runs one agent turn and posts its reply, or a failure notice, in `postTo` under the
|
|
152
|
+
* agent's name. Never rejects.
|
|
153
|
+
*/
|
|
154
|
+
async #turn(
|
|
155
|
+
agent: Agent,
|
|
156
|
+
scope: AgentTurnScope,
|
|
157
|
+
postTo: ChannelKey,
|
|
158
|
+
text: string,
|
|
159
|
+
chain: Chain,
|
|
160
|
+
extra: TurnExtra = {},
|
|
161
|
+
): Promise<TurnResult> {
|
|
162
|
+
const { logger, startTyping, showStop } = this.#options;
|
|
163
|
+
const stopTyping = startTyping(postTo);
|
|
164
|
+
// A group round has several sessions; only an agent's own channel maps to one.
|
|
165
|
+
const hideStop = scope.group ? undefined : showStop(postTo);
|
|
166
|
+
this.#chains.set(scope.session, chain);
|
|
167
|
+
this.#working.set(agent.name, postTo);
|
|
168
|
+
this.#active(agent.name, scope.group);
|
|
169
|
+
const turn = {
|
|
170
|
+
agent: agent.name,
|
|
171
|
+
channel: postTo,
|
|
172
|
+
speaker: chain.speaker,
|
|
173
|
+
...(scope.group ? { group: scope.group } : {}),
|
|
174
|
+
};
|
|
175
|
+
this.#options.events?.turnStarted(turn);
|
|
176
|
+
let result: TurnResult;
|
|
177
|
+
try {
|
|
178
|
+
result = await settleTurn(
|
|
179
|
+
() =>
|
|
180
|
+
this.#options.runtime().runTurn({
|
|
181
|
+
channel: postTo,
|
|
182
|
+
selection: this.#options.selection(scope),
|
|
183
|
+
text,
|
|
184
|
+
...(extra.attachments ? { attachments: extra.attachments } : {}),
|
|
185
|
+
...(extra.confirmed ? { confirmed: true } : {}),
|
|
186
|
+
...(extra.steerable ? { steerable: true } : {}),
|
|
187
|
+
...(extra.interactive ? { interactive: true } : {}),
|
|
188
|
+
agent: scope,
|
|
189
|
+
speaker: chain.speaker,
|
|
190
|
+
}),
|
|
191
|
+
"agent turn",
|
|
192
|
+
);
|
|
193
|
+
} finally {
|
|
194
|
+
hideStop?.();
|
|
195
|
+
this.#chains.delete(scope.session);
|
|
196
|
+
this.#working.delete(agent.name);
|
|
197
|
+
this.#active(agent.name, scope.group);
|
|
198
|
+
}
|
|
199
|
+
this.#options.events?.turnEnded({
|
|
200
|
+
...turn,
|
|
201
|
+
result: result.ok ? "ok" : result.stopped ? "stopped" : "failed",
|
|
202
|
+
});
|
|
203
|
+
try {
|
|
204
|
+
if (!result.ok && !result.stopped) {
|
|
205
|
+
logger.error(
|
|
206
|
+
{ agent: agent.name, channel: postTo, err: result.error },
|
|
207
|
+
"agent turn failed",
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
const thinking =
|
|
211
|
+
result.ok && result.thinking
|
|
212
|
+
? thinkingLine(result.thinking)
|
|
213
|
+
: undefined;
|
|
214
|
+
await this.#post(postTo, this.#current(agent), {
|
|
215
|
+
...(thinking ? { thinking } : {}),
|
|
216
|
+
chunks: result.ok
|
|
217
|
+
? splitReply(result.text)
|
|
218
|
+
: [
|
|
219
|
+
result.stopped
|
|
220
|
+
? messages().stoppedNotice
|
|
221
|
+
: messages().failureNotice,
|
|
222
|
+
],
|
|
223
|
+
});
|
|
224
|
+
} catch (error) {
|
|
225
|
+
logger.error({ agent: agent.name, err: error }, "agent reply not posted");
|
|
226
|
+
} finally {
|
|
227
|
+
stopTyping();
|
|
228
|
+
}
|
|
229
|
+
return result;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
#active(agent: string, group: string | undefined): void {
|
|
233
|
+
const now = new Date();
|
|
234
|
+
this.#lastActive.set(agent, now);
|
|
235
|
+
if (group) this.#lastActive.set(`group:${group}`, now);
|
|
236
|
+
this.#options.changed();
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** The agent's latest row, so a display name or avatar changed during the turn shows. */
|
|
240
|
+
#current(agent: Agent): Agent {
|
|
241
|
+
return this.#options.store.agent(agent.name) ?? agent;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Posts under the agent's name. A channel no agent or group owns any more, archived while a
|
|
246
|
+
* turn ran, gets nothing, so its webhook is not made again.
|
|
247
|
+
*/
|
|
248
|
+
async #post(
|
|
249
|
+
channel: ChannelKey,
|
|
250
|
+
as: Agent,
|
|
251
|
+
body: { thinking?: string; chunks: string[]; threadId?: string },
|
|
252
|
+
): Promise<void> {
|
|
253
|
+
const { store, channels, studio, logger } = this.#options;
|
|
254
|
+
if (!channelOwner(store, channel)) {
|
|
255
|
+
logger.info(
|
|
256
|
+
{ channel, agent: as.name },
|
|
257
|
+
"post to an archived channel dropped",
|
|
258
|
+
);
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
return channels.post(channelIdOf(channel), {
|
|
262
|
+
name: as.displayName,
|
|
263
|
+
avatarUrl: studio.url(as.avatarHash),
|
|
264
|
+
...body,
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Posts text under the caller's name in the channel of its turn: the group's in a group
|
|
270
|
+
* round, otherwise its own, as a change report is (repos-and-skills spec behavior 10).
|
|
271
|
+
*/
|
|
272
|
+
async postAs(caller: AgentTurnScope, text: string): Promise<void> {
|
|
273
|
+
const agent = this.#options.store.activeAgent(caller.name);
|
|
274
|
+
await this.#post(this.turnChannel(caller), this.#current(agent), {
|
|
275
|
+
chunks: splitReply(text),
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** The channel the scope's turns run in: the group's in a group round, otherwise its own. */
|
|
280
|
+
turnChannel(scope: AgentTurnScope): ChannelKey {
|
|
281
|
+
const group = scope.group
|
|
282
|
+
? this.#options.store.group(scope.group)
|
|
283
|
+
: undefined;
|
|
284
|
+
return group ? channelKey(group.channelId) : scope.home;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** The assistant's own notice, such as a new release, posted in the coordinator's channel under its name. */
|
|
288
|
+
async announce(text: string): Promise<void> {
|
|
289
|
+
const { store, entryChannelId } = this.#options;
|
|
290
|
+
const coordinator = store.agentByChannel(entryChannelId);
|
|
291
|
+
if (!coordinator)
|
|
292
|
+
throw new AgentError("no active agent in the entry channel");
|
|
293
|
+
await this.#post(channelKey(entryChannelId), coordinator, {
|
|
294
|
+
chunks: splitReply(text),
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
message(caller: AgentTurnScope, to: string, text: string): string {
|
|
299
|
+
return this.#messages.message(caller, to, text);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* One owner message in a group channel: scored, then answered by the chosen members one at
|
|
304
|
+
* a time, each seeing every group message since its last turn. Call inside the queue.
|
|
305
|
+
*/
|
|
306
|
+
answerGroup(
|
|
307
|
+
channel: ChannelKey,
|
|
308
|
+
speaker: Speaker,
|
|
309
|
+
text: string,
|
|
310
|
+
replyText: string,
|
|
311
|
+
attachments: TurnAttachments,
|
|
312
|
+
repliedToName: string | undefined,
|
|
313
|
+
): Promise<void> {
|
|
314
|
+
return this.#groups.answerGroup(
|
|
315
|
+
channel,
|
|
316
|
+
speaker,
|
|
317
|
+
text,
|
|
318
|
+
replyText,
|
|
319
|
+
attachments,
|
|
320
|
+
repliedToName,
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
Binary file
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
You are an agent in a Discord server. The person you are talking with is a guest of that server, named in the last section of this prompt. You are not speaking for the server's owner, and the owner's private facts stay theirs.
|
|
2
|
+
|
|
3
|
+
Reply in the language they write in. Be warm, direct, and brief.
|
|
4
|
+
|
|
5
|
+
Write in Discord's Markdown: bold, italics, headings, bullet and numbered lists, block quotes, inline code, fenced code blocks, and `-#` small text all render. Discord shows Markdown tables as raw pipes, so list figures as bullets instead.
|
|
6
|
+
|
|
7
|
+
Your context holds only their own core facts and upcoming events. When a request touches their projects, people, tools, or past, search memory first. When they ask you to remember or forget something, use the memory tools; what you store is theirs alone.
|
|
8
|
+
|
|
9
|
+
When you need their decision to continue, ask with ask_user instead of ending your turn with a question; they answer on buttons in the channel and your turn goes on.
|
|
10
|
+
|
|
11
|
+
Cite the source links of web information you use.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
You are an agent in a Discord server, working for the server's owner. You talk with them in channels and threads.
|
|
2
|
+
|
|
3
|
+
Reply in the language they write in. Be warm, direct, and brief.
|
|
4
|
+
|
|
5
|
+
Write in Discord's Markdown: bold, italics, headings, bullet and numbered lists, block quotes, inline code, fenced code blocks, and `-#` small text all render. Discord shows Markdown tables as raw pipes, so list figures as bullets instead.
|
|
6
|
+
|
|
7
|
+
Your context holds only their core facts and upcoming events. When a request touches their projects, people, tools, or past, search memory first. When they ask you to remember or forget something, use the memory tools.
|
|
8
|
+
|
|
9
|
+
When you need their decision to continue, ask with ask_user instead of ending your turn with a question; they answer on buttons in the channel and your turn goes on.
|
|
10
|
+
|
|
11
|
+
Cite the source links of web information you use.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: writing-skills
|
|
3
|
+
description: Use when creating, editing, or reviewing a skill, or when a lesson from your work should become one.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Writing Skills
|
|
7
|
+
|
|
8
|
+
A skill makes the next run predictable: same process, straight to the work. Write the smallest skill that does that.
|
|
9
|
+
|
|
10
|
+
## Steps
|
|
11
|
+
|
|
12
|
+
1. **Check the registry.** Run `skill_list` with a word from the task. Extend the skill that owns the procedure instead of adding a second one. A linked skill is read-only here: change it in its repository.
|
|
13
|
+
- **Completion criterion**: you can name the skill you will create or edit, and why no other one owns this.
|
|
14
|
+
2. **Write the description.** State only when to use the skill: `Use when <trigger>`, one trigger per distinct way it is used, joined with "or". Leave out what the skill does and how.
|
|
15
|
+
- **Completion criterion**: every phrase in it is a situation that should load the skill.
|
|
16
|
+
3. **Write the body.** Ordered steps, each an action with a checkable **completion criterion**; then the reference the steps need (rules, tables, templates). State the behavior you want; keep a prohibition only as a hard guardrail, paired with what to do instead. Name the tools the agent actually has.
|
|
17
|
+
- **Completion criterion**: an agent that reads only this body can run the procedure without asking what a step means.
|
|
18
|
+
4. **Cut.** Read each sentence alone and delete it when the model would behave the same without it. One fact lives in one place: point to another skill instead of copying it. A skill an agent writes is a single file of at most 12,000 characters.
|
|
19
|
+
- **Completion criterion**: every remaining sentence changes behavior.
|
|
20
|
+
5. **Save and attach.** `skill_create` or `skill_update` with the name, description, and body; the tool writes the frontmatter. Attach it with `agent_skills` to the agents that do the work, yourself included when you do.
|
|
21
|
+
- **Completion criterion**: the tool confirmed the save, and `skill_list` shows the intended carriers.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import type { ChannelKey } from "../domain/conversation.ts";
|
|
3
|
+
|
|
4
|
+
/** A channel key as a single path segment. */
|
|
5
|
+
export function channelSegment(channel: ChannelKey): string {
|
|
6
|
+
return channel.replace(/[^A-Za-z0-9_-]/g, "_");
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Where an owner channel's attachments are saved; the runtime's read_attachment reads the same place. */
|
|
10
|
+
export function ownerAttachmentDir(
|
|
11
|
+
dataDir: string,
|
|
12
|
+
channel: ChannelKey,
|
|
13
|
+
): string {
|
|
14
|
+
return join(dataDir, "attachments", channelSegment(channel));
|
|
15
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { mkdirSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import type {
|
|
4
|
+
AttachmentFailure,
|
|
5
|
+
AttachmentRef,
|
|
6
|
+
StoredAttachment,
|
|
7
|
+
} from "../domain/attachment.ts";
|
|
8
|
+
|
|
9
|
+
export const MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024;
|
|
10
|
+
|
|
11
|
+
export interface FetchRequest {
|
|
12
|
+
refs: readonly AttachmentRef[];
|
|
13
|
+
/** Directory of the channel's attachment store; created when missing. */
|
|
14
|
+
dir: string;
|
|
15
|
+
/** Prefix that keeps files of different messages apart, such as the message ID. */
|
|
16
|
+
prefix: string;
|
|
17
|
+
fromReference: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface FetchResult {
|
|
21
|
+
files: StoredAttachment[];
|
|
22
|
+
failures: AttachmentFailure[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Keeps a file name readable while removing path separators and control characters. */
|
|
26
|
+
export function safeFileName(name: string): string {
|
|
27
|
+
const cleaned = name
|
|
28
|
+
.normalize("NFC")
|
|
29
|
+
.replace(/[/\\\p{Cc}]/gu, "_")
|
|
30
|
+
.replace(/^\.+/, "_")
|
|
31
|
+
.slice(-120);
|
|
32
|
+
return cleaned || "file";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Downloads attachments one by one; a failed file is reported without stopping the others. */
|
|
36
|
+
export async function fetchAttachments(
|
|
37
|
+
request: FetchRequest,
|
|
38
|
+
fetchImpl: typeof fetch = fetch,
|
|
39
|
+
): Promise<FetchResult> {
|
|
40
|
+
const files: StoredAttachment[] = [];
|
|
41
|
+
const failures: AttachmentFailure[] = [];
|
|
42
|
+
const { refs, dir, prefix, fromReference } = request;
|
|
43
|
+
if (refs.length === 0) return { files, failures };
|
|
44
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
45
|
+
|
|
46
|
+
for (const [index, ref] of refs.entries()) {
|
|
47
|
+
const fail = (reason: string) =>
|
|
48
|
+
failures.push({ name: ref.name, reason, fromReference });
|
|
49
|
+
if (ref.size > MAX_ATTACHMENT_BYTES) {
|
|
50
|
+
fail(`larger than 25 MB (${ref.size} bytes)`);
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
try {
|
|
54
|
+
const response = await fetchImpl(ref.url);
|
|
55
|
+
if (!response.ok) {
|
|
56
|
+
fail(`download failed with HTTP ${response.status}`);
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
const data = new Uint8Array(await response.arrayBuffer());
|
|
60
|
+
if (data.byteLength > MAX_ATTACHMENT_BYTES) {
|
|
61
|
+
fail(`larger than 25 MB (${data.byteLength} bytes)`);
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
const file = `${prefix}-${index}-${safeFileName(ref.name)}`;
|
|
65
|
+
const path = join(dir, file);
|
|
66
|
+
await Bun.write(path, data);
|
|
67
|
+
files.push({
|
|
68
|
+
name: ref.name,
|
|
69
|
+
file,
|
|
70
|
+
path,
|
|
71
|
+
contentType:
|
|
72
|
+
ref.contentType ||
|
|
73
|
+
response.headers.get("content-type") ||
|
|
74
|
+
"application/octet-stream",
|
|
75
|
+
size: data.byteLength,
|
|
76
|
+
fromReference,
|
|
77
|
+
});
|
|
78
|
+
} catch (error) {
|
|
79
|
+
fail(`download failed: ${String(error)}`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return { files, failures };
|
|
83
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
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 { NO_ATTACHMENTS } from "../domain/attachment.ts";
|
|
7
|
+
import { readAttachment } from "../shared/attachment-reader.ts";
|
|
8
|
+
import { safeFileName } from "./attachment-fetcher.ts";
|
|
9
|
+
import { prepareImage } from "./image-prep.ts";
|
|
10
|
+
import { withAttachmentsBlock } from "./prompt-block.ts";
|
|
11
|
+
|
|
12
|
+
const dir = mkdtempSync(join(tmpdir(), "roundtable-read-"));
|
|
13
|
+
|
|
14
|
+
describe("safeFileName", () => {
|
|
15
|
+
test("keeps names readable and strips path parts", () => {
|
|
16
|
+
expect(safeFileName("отчёт v2.pdf")).toBe("отчёт v2.pdf");
|
|
17
|
+
expect(safeFileName("../../etc/passwd")).toBe("__.._etc_passwd");
|
|
18
|
+
expect(safeFileName("")).toBe("file");
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe("prepareImage", () => {
|
|
23
|
+
test("an image over the side limit is downscaled to 2,000 px JPEG", async () => {
|
|
24
|
+
const canvas = createCanvas(9000, 100);
|
|
25
|
+
const path = join(dir, "wide.png");
|
|
26
|
+
await Bun.write(path, canvas.toBuffer("image/png"));
|
|
27
|
+
const image = await prepareImage({ path, contentType: "image/png" });
|
|
28
|
+
expect(image.mimeType).toBe("image/jpeg");
|
|
29
|
+
const decoded = await loadImage(Buffer.from(image.data, "base64"));
|
|
30
|
+
expect(decoded.width).toBe(2000);
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe("readAttachment", () => {
|
|
35
|
+
test("reads text in pieces and refuses paths and binaries", async () => {
|
|
36
|
+
await Bun.write(join(dir, "a.txt"), "x".repeat(70_000));
|
|
37
|
+
const first = await readAttachment(dir, "a.txt");
|
|
38
|
+
expect(first.text).toHaveLength(60_000);
|
|
39
|
+
expect(first.total).toBe(70_000);
|
|
40
|
+
expect((await readAttachment(dir, "a.txt", 60_000)).text).toHaveLength(
|
|
41
|
+
10_000,
|
|
42
|
+
);
|
|
43
|
+
await expect(readAttachment(dir, "../a.txt")).rejects.toThrow(
|
|
44
|
+
"not an attachment name",
|
|
45
|
+
);
|
|
46
|
+
await Bun.write(join(dir, "b.bin"), new Uint8Array([0, 1, 2, 255]));
|
|
47
|
+
await expect(readAttachment(dir, "b.bin")).rejects.toThrow(
|
|
48
|
+
"not a text or PDF file",
|
|
49
|
+
);
|
|
50
|
+
await expect(readAttachment(dir, "none.txt")).rejects.toThrow(
|
|
51
|
+
"no attachment named",
|
|
52
|
+
);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe("withAttachmentsBlock", () => {
|
|
57
|
+
test("adds nothing without attachments", () => {
|
|
58
|
+
expect(withAttachmentsBlock("hi", NO_ATTACHMENTS)).toBe("hi");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("lists files and failures", () => {
|
|
62
|
+
const text = withAttachmentsBlock("hi", {
|
|
63
|
+
files: [
|
|
64
|
+
{
|
|
65
|
+
name: "a.pdf",
|
|
66
|
+
file: "m-0-a.pdf",
|
|
67
|
+
path: "/x",
|
|
68
|
+
contentType: "application/pdf",
|
|
69
|
+
size: 2048,
|
|
70
|
+
fromReference: false,
|
|
71
|
+
},
|
|
72
|
+
],
|
|
73
|
+
images: [],
|
|
74
|
+
failures: [
|
|
75
|
+
{ name: "b.mov", reason: "larger than 25 MB", fromReference: true },
|
|
76
|
+
],
|
|
77
|
+
});
|
|
78
|
+
expect(text).toContain(
|
|
79
|
+
'- m-0-a.pdf (original name "a.pdf", application/pdf, 2 KB)',
|
|
80
|
+
);
|
|
81
|
+
expect(text).toContain(
|
|
82
|
+
'"b.mov" from the replied-to message could not be received: larger than 25 MB',
|
|
83
|
+
);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { createCanvas, loadImage } from "canvas";
|
|
2
|
+
import type { ModelImage, StoredAttachment } from "../domain/attachment.ts";
|
|
3
|
+
|
|
4
|
+
/** Images the model accepts as images; other files stay files. */
|
|
5
|
+
const IMAGE_TYPES = new Set([
|
|
6
|
+
"image/png",
|
|
7
|
+
"image/jpeg",
|
|
8
|
+
"image/gif",
|
|
9
|
+
"image/webp",
|
|
10
|
+
]);
|
|
11
|
+
export const MAX_IMAGES_PER_TURN = 4;
|
|
12
|
+
/** Base64 grows by a third, so 3.75 MB stays under Claude's 5 MB per image. */
|
|
13
|
+
const MAX_RAW_BYTES = 3.75 * 1024 * 1024;
|
|
14
|
+
const MAX_SIDE = 8000;
|
|
15
|
+
const DOWNSCALED_SIDE = 2000;
|
|
16
|
+
|
|
17
|
+
export function isModelImage(
|
|
18
|
+
file: Pick<StoredAttachment, "contentType">,
|
|
19
|
+
): boolean {
|
|
20
|
+
return IMAGE_TYPES.has(
|
|
21
|
+
file.contentType.split(";")[0]?.trim().toLowerCase() ?? "",
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Returns the image as the model accepts it. An image over the size or side limit is
|
|
27
|
+
* re-encoded as JPEG with its long side at 2,000 px instead of being dropped.
|
|
28
|
+
*/
|
|
29
|
+
export async function prepareImage(
|
|
30
|
+
file: Pick<StoredAttachment, "path" | "contentType">,
|
|
31
|
+
): Promise<ModelImage> {
|
|
32
|
+
const bytes = Buffer.from(await Bun.file(file.path).arrayBuffer());
|
|
33
|
+
const mimeType = file.contentType.split(";")[0]?.trim().toLowerCase() ?? "";
|
|
34
|
+
if (bytes.byteLength <= MAX_RAW_BYTES) {
|
|
35
|
+
const image = await loadImage(bytes);
|
|
36
|
+
if (Math.max(image.width, image.height) <= MAX_SIDE) {
|
|
37
|
+
return { data: bytes.toString("base64"), mimeType };
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
const image = await loadImage(bytes);
|
|
41
|
+
const scale = DOWNSCALED_SIDE / Math.max(image.width, image.height);
|
|
42
|
+
const width = Math.max(1, Math.round(image.width * Math.min(1, scale)));
|
|
43
|
+
const height = Math.max(1, Math.round(image.height * Math.min(1, scale)));
|
|
44
|
+
const canvas = createCanvas(width, height);
|
|
45
|
+
canvas.getContext("2d").drawImage(image, 0, 0, width, height);
|
|
46
|
+
return {
|
|
47
|
+
data: canvas.toBuffer("image/jpeg", { quality: 0.85 }).toString("base64"),
|
|
48
|
+
mimeType: "image/jpeg",
|
|
49
|
+
};
|
|
50
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { TurnAttachments } from "../domain/attachment.ts";
|
|
2
|
+
|
|
3
|
+
function formatSize(bytes: number): string {
|
|
4
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
5
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
|
|
6
|
+
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Appends an `## Attachments` block naming every file the turn carries, so the model knows
|
|
11
|
+
* what it can read or hand to tools. Files are named by their store file name, which is what
|
|
12
|
+
* `read_attachment` and file-taking tools accept.
|
|
13
|
+
*/
|
|
14
|
+
export function withAttachmentsBlock(
|
|
15
|
+
text: string,
|
|
16
|
+
attachments: TurnAttachments,
|
|
17
|
+
): string {
|
|
18
|
+
const lines: string[] = [];
|
|
19
|
+
for (const file of attachments.files) {
|
|
20
|
+
const origin = file.fromReference ? ", from the replied-to message" : "";
|
|
21
|
+
lines.push(
|
|
22
|
+
`- ${file.file} (original name "${file.name}", ${file.contentType}, ${formatSize(file.size)}${origin})`,
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
for (const failure of attachments.failures) {
|
|
26
|
+
const origin = failure.fromReference ? " from the replied-to message" : "";
|
|
27
|
+
lines.push(
|
|
28
|
+
`- "${failure.name}"${origin} could not be received: ${failure.reason}`,
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
if (lines.length === 0) return text;
|
|
32
|
+
const note =
|
|
33
|
+
attachments.images.length > 0
|
|
34
|
+
? "Images among them are shown to you directly. Read other files with read_attachment."
|
|
35
|
+
: "Read them with read_attachment.";
|
|
36
|
+
return `${text}\n\n## Attachments\n${note}\n${lines.join("\n")}`;
|
|
37
|
+
}
|