@frockbot/plugin-flock 0.0.0 → 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.
Files changed (92) hide show
  1. package/README.md +8 -1
  2. package/assets/amber-glasses.webp +0 -0
  3. package/assets/background-bright-orange.webp +0 -0
  4. package/assets/background-canary-yellow.webp +0 -0
  5. package/assets/background-electric-blue.webp +0 -0
  6. package/assets/background-hot-pink.webp +0 -0
  7. package/assets/background-lime-green.webp +0 -0
  8. package/assets/background-vivid-purple.webp +0 -0
  9. package/assets/blue-sailor-cap.webp +0 -0
  10. package/assets/bronze-aviator-goggles.webp +0 -0
  11. package/assets/burgundy-beret.webp +0 -0
  12. package/assets/canary-party-hat.webp +0 -0
  13. package/assets/canary-rain-cap.webp +0 -0
  14. package/assets/canonical.webp +0 -0
  15. package/assets/chef-toque.webp +0 -0
  16. package/assets/cobalt-round-glasses.webp +0 -0
  17. package/assets/coral-bucket-hat.webp +0 -0
  18. package/assets/emerald-necktie.webp +0 -0
  19. package/assets/forest-ranger-fedora.webp +0 -0
  20. package/assets/gold-royal-crown.webp +0 -0
  21. package/assets/gold-sun-medallion.webp +0 -0
  22. package/assets/graduation-cap.webp +0 -0
  23. package/assets/green-square-glasses.webp +0 -0
  24. package/assets/layers.css +159 -0
  25. package/assets/leather-bell.webp +0 -0
  26. package/assets/lilac-flower-crown.webp +0 -0
  27. package/assets/manifest.json +769 -0
  28. package/assets/matte-black-top-hat.webp +0 -0
  29. package/assets/navy-wizard-hat.webp +0 -0
  30. package/assets/orange-hard-hat.webp +0 -0
  31. package/assets/plum-glasses.webp +0 -0
  32. package/assets/purple-scarf.webp +0 -0
  33. package/assets/purple-witch-hat.webp +0 -0
  34. package/assets/rainbow-propeller-cap.webp +0 -0
  35. package/assets/rainbow-visor.webp +0 -0
  36. package/assets/ranger-fedora-feather.webp +0 -0
  37. package/assets/red-polka-bowtie.webp +0 -0
  38. package/assets/retro-3d-glasses.webp +0 -0
  39. package/assets/rose-heart-sunglasses.webp +0 -0
  40. package/assets/royal-crown-rubies.webp +0 -0
  41. package/assets/sailor-cap-anchor.webp +0 -0
  42. package/assets/silver-space-helmet.webp +0 -0
  43. package/assets/straw-boater.webp +0 -0
  44. package/assets/teal-bowtie.webp +0 -0
  45. package/assets/teal-glasses.webp +0 -0
  46. package/assets/terracotta-beanie.webp +0 -0
  47. package/assets/tortoiseshell-readers.webp +0 -0
  48. package/assets/turquoise-bandana.webp +0 -0
  49. package/assets/violet-masquerade-mask.webp +0 -0
  50. package/assets/white-snow-goggles.webp +0 -0
  51. package/assets/white-tennis-visor.webp +0 -0
  52. package/assets/witch-hat-moon.webp +0 -0
  53. package/assets/yellow-star-glasses.webp +0 -0
  54. package/frockbot.json +38 -0
  55. package/package.json +47 -6
  56. package/src/agent.test.ts +401 -0
  57. package/src/agent.ts +571 -0
  58. package/src/assets.test.ts +31 -0
  59. package/src/backend.test.ts +205 -0
  60. package/src/backend.ts +283 -0
  61. package/src/bot.test.ts +190 -0
  62. package/src/bot.ts +255 -0
  63. package/src/client/BotAvatar.vue +20 -0
  64. package/src/client/FlockAvatar.vue +33 -0
  65. package/src/client/FlockAvatarEditor.vue +44 -0
  66. package/src/client/FlockCreateButton.vue +18 -0
  67. package/src/client/FlockIdentity.vue +31 -0
  68. package/src/client/FlockOverlay.vue +209 -0
  69. package/src/client/FlockSidebar.vue +254 -0
  70. package/src/client/SheepAvatar.vue +28 -0
  71. package/src/client/dialog-focus.test.ts +15 -0
  72. package/src/client/dialog-focus.ts +13 -0
  73. package/src/client/index.test.ts +502 -0
  74. package/src/client/index.ts +619 -0
  75. package/src/client/mobile-dialog-layout.test.ts +50 -0
  76. package/src/client/pending-create.test.ts +71 -0
  77. package/src/client/pending-create.ts +93 -0
  78. package/src/client/sidebar.test.ts +77 -0
  79. package/src/client/sidebar.ts +77 -0
  80. package/src/client/state.ts +53 -0
  81. package/src/client/styles.css +611 -0
  82. package/src/env.d.ts +9 -0
  83. package/src/index.ts +4 -0
  84. package/src/manifest.ts +3 -0
  85. package/src/package.test.ts +12 -0
  86. package/src/shared.test.ts +197 -0
  87. package/src/shared.ts +735 -0
  88. package/src/user.test.ts +455 -0
  89. package/src/user.ts +465 -0
  90. package/tsconfig.json +15 -0
  91. package/validate_readability.py +64 -0
  92. package/vite.config.ts +30 -0
package/src/agent.ts ADDED
@@ -0,0 +1,571 @@
1
+ // The Flock runtime Contribution: a Bot's own self-management tools.
2
+ //
3
+ // GrokBot exposes two of these and no more (§2.12): `UpdateAgent`, where only
4
+ // the fields the call carries change, and `CreateAgent`, which makes a new
5
+ // agent in the same user's flock. **There is no delete tool** — deletion is a
6
+ // user-only action — and this Package matches that: `bot_update` cannot
7
+ // archive, restore, or remove anything, and no third tool exists to do it.
8
+ //
9
+ // AUTHORITY. "Self-modification never widens authority." Both tools run
10
+ // through paths the Bot's User already owns:
11
+ //
12
+ // - `bot_update` issues the same `bot/set-profile` command the settings UI
13
+ // issues, against *this* Bot's own Durable Object, with `namedBy: "bot"` so
14
+ // the `bot/renamed` announcement records who did it. The seam refuses a
15
+ // writer that names any Bot but this one.
16
+ // - `bot_create` issues the User's own `bot/create`, and nothing else. The
17
+ // new Bot is registered with no Assignments and no model of its own: it
18
+ // follows the User's default model exactly as a Bot the User creates in the
19
+ // sidebar does. A Bot therefore cannot hand a Bot it makes — or itself —
20
+ // any authority the User's create path does not already give.
21
+ //
22
+ // A `model` argument is deliberately absent from `bot_create`. Giving the new
23
+ // Bot the caller's model would mean writing a `bot/select-model` and a
24
+ // Capability Assignment onto another Bot, which is exactly the authority
25
+ // widening the constitution forbids; a User grants that, in the UI.
26
+ //
27
+ // REPLAY. Both tools survive Durable Object eviction without duplicating an
28
+ // effect, and both declare `idempotent: true` so the registry may recover them
29
+ // by re-running:
30
+ //
31
+ // - `bot_create` derives the new Bot's id from the tool-call occurrence, so a
32
+ // replay asks for the *same* Bot id. The tool checks the directory for it
33
+ // before commanding anything: on a replay it finds the Bot it already made
34
+ // and reports that, rather than registering a second one. The directory's
35
+ // uniqueness rule is the fence; the durable `commandId` receipt is only the
36
+ // first line of it.
37
+ // - `bot_update` computes the durable result the patch would produce and
38
+ // commands nothing when it already holds, so a replay is a read. That also
39
+ // keeps a replayed rename from appending a second announcement.
40
+ import {
41
+ applyBotProfilePatchV1,
42
+ ConfigurationConflictError,
43
+ type BotProfile,
44
+ type BotProfilePatchV1,
45
+ type BotSelfWriterV1,
46
+ type BotSettingsViewV1,
47
+ type ConfigurationCommandV1,
48
+ type OperationReceiptV1,
49
+ } from "@frockbot/configuration-core";
50
+ import type {
51
+ ToolDefinition,
52
+ ToolExecutionContext,
53
+ ToolExecutionResult,
54
+ } from "@frockbot/kernel-contracts";
55
+ import type { Plugin } from "cordis";
56
+ import {
57
+ FlockConflictError,
58
+ randomSheepRecipeV1,
59
+ type BotDirectoryViewV1,
60
+ type CreateBotCommandV1,
61
+ type FlockReceiptV1,
62
+ } from "./shared.js";
63
+ export type {
64
+ BotDirectoryViewV1,
65
+ CreateBotCommandV1,
66
+ FlockReceiptV1,
67
+ } from "./shared.js";
68
+ export type { BotSelfWriterV1 } from "@frockbot/configuration-core";
69
+
70
+ /** The User and Bot one admitted Turn's self-management runs as. */
71
+ export interface FlockSelfOwnerV1 {
72
+ userId: string;
73
+ botId: string;
74
+ }
75
+
76
+ /**
77
+ * The host seam this Contribution receives, supplied by the Bot Durable Object
78
+ * for one admitted Turn. It is absent outside a Turn, and the tools are then
79
+ * not registered at all: a Bot changes itself only inside a Turn whose Session
80
+ * and Turn its provenance can name.
81
+ *
82
+ * Every method is a command the User's own surfaces already issue. This
83
+ * Package holds no authority: the Bot Durable Object owns the profile, the
84
+ * User Durable Object owns the Flock directory, and neither is reachable from
85
+ * here except through these four calls.
86
+ */
87
+ export interface FlockSelfRuntimeHostV1 {
88
+ owner: FlockSelfOwnerV1;
89
+ /** The provenance every write this Turn records. */
90
+ writer: BotSelfWriterV1;
91
+ /** This Bot's durable settings, including the revision a command expects. */
92
+ readSelf(): Promise<BotSettingsViewV1>;
93
+ /** Applies one Bot-scoped configuration command to this Bot. */
94
+ commandSelf(
95
+ command: Extract<ConfigurationCommandV1, { botId: string }>,
96
+ ): Promise<OperationReceiptV1>;
97
+ /** The User's Flock directory. */
98
+ listBots(): Promise<BotDirectoryViewV1>;
99
+ /** The User's own `bot/create` path, and no wider. */
100
+ createBot(command: CreateBotCommandV1): Promise<FlockReceiptV1>;
101
+ }
102
+
103
+ /** How many times a command is re-issued after losing an optimistic race. */
104
+ const REVISION_RETRIES = 3;
105
+
106
+ /** The patch field each `bot_update` argument writes, in report order. */
107
+ const PROFILE_FIELD_NAMES = [
108
+ ["name", "name"],
109
+ ["description", "description"],
110
+ ["title", "title"],
111
+ ["hiddenFromSidebar", "hidden_from_sidebar"],
112
+ ] as const satisfies ReadonlyArray<readonly [keyof BotProfilePatchV1, string]>;
113
+
114
+ const BOT_UPDATE_SCHEMA = {
115
+ type: "object",
116
+ properties: {
117
+ name: {
118
+ type: "string",
119
+ description:
120
+ "Your new name. Renaming yourself is announced in the conversation and recorded as your own change.",
121
+ },
122
+ description: {
123
+ type: "string",
124
+ description:
125
+ "Your persona and standing instructions. The empty string clears it.",
126
+ },
127
+ title: {
128
+ type: "string",
129
+ description:
130
+ "A short role line shown under your name. The empty string clears it.",
131
+ },
132
+ hidden_from_sidebar: {
133
+ type: "boolean",
134
+ description:
135
+ "Hide yourself from the default sidebar list. You stay reachable and nothing is archived or deleted.",
136
+ },
137
+ notify_on_updates: {
138
+ type: "boolean",
139
+ description: "Whether your User is notified when you have news.",
140
+ },
141
+ },
142
+ additionalProperties: false,
143
+ } as const;
144
+
145
+ const BOT_CREATE_SCHEMA = {
146
+ type: "object",
147
+ properties: {
148
+ name: {
149
+ type: "string",
150
+ description: "The new Bot's name.",
151
+ },
152
+ description: {
153
+ type: "string",
154
+ description:
155
+ "The new Bot's persona and standing instructions, which become its profile.",
156
+ },
157
+ },
158
+ required: ["name"],
159
+ additionalProperties: false,
160
+ } as const;
161
+
162
+ interface BotUpdateInputV1 {
163
+ profile: BotProfilePatchV1;
164
+ notifyOnUpdates?: boolean;
165
+ }
166
+
167
+ interface BotCreateInputV1 {
168
+ name: string;
169
+ description?: string;
170
+ }
171
+
172
+ function fields(
173
+ input: unknown,
174
+ allowed: readonly string[],
175
+ ): Record<string, unknown> {
176
+ if (!input || typeof input !== "object" || Array.isArray(input)) {
177
+ throw new Error("input must be an object");
178
+ }
179
+ const value = input as Record<string, unknown>;
180
+ if (!Object.keys(value).every((key) => allowed.includes(key))) {
181
+ throw new Error("input has unknown fields");
182
+ }
183
+ return value;
184
+ }
185
+
186
+ /**
187
+ * A patch field carrying text. A non-empty string sets it; the empty string
188
+ * clears it, which is the only way a partial update can say "remove this".
189
+ */
190
+ function patchText(value: unknown, label: string, maximum: number): string {
191
+ if (typeof value !== "string") throw new Error(`${label} must be a string`);
192
+ const normalized = value.trim();
193
+ if (normalized.length > maximum) throw new Error(`${label} is too long`);
194
+ return normalized;
195
+ }
196
+
197
+ function boolean(value: unknown, label: string): boolean {
198
+ if (typeof value !== "boolean") throw new Error(`${label} must be a boolean`);
199
+ return value;
200
+ }
201
+
202
+ export function decodeBotUpdateInputV1(input: unknown): BotUpdateInputV1 {
203
+ const value = fields(input, [
204
+ "name",
205
+ "description",
206
+ "title",
207
+ "hidden_from_sidebar",
208
+ "notify_on_updates",
209
+ ]);
210
+ if (Object.keys(value).length === 0) {
211
+ throw new Error("bot_update needs at least one field to change");
212
+ }
213
+ const profile: BotProfilePatchV1 = {};
214
+ if (value.name !== undefined) {
215
+ // The name is the one field a partial update may not blank.
216
+ const name = patchText(value.name, "name", 100);
217
+ if (!name) throw new Error("name must not be empty");
218
+ profile.name = name;
219
+ }
220
+ if (value.description !== undefined) {
221
+ profile.description = patchText(value.description, "description", 10_000);
222
+ }
223
+ if (value.title !== undefined) {
224
+ profile.title = patchText(value.title, "title", 120);
225
+ }
226
+ if (value.hidden_from_sidebar !== undefined) {
227
+ profile.hiddenFromSidebar = boolean(
228
+ value.hidden_from_sidebar,
229
+ "hidden_from_sidebar",
230
+ );
231
+ }
232
+ return {
233
+ profile,
234
+ ...(value.notify_on_updates === undefined
235
+ ? {}
236
+ : {
237
+ notifyOnUpdates: boolean(
238
+ value.notify_on_updates,
239
+ "notify_on_updates",
240
+ ),
241
+ }),
242
+ };
243
+ }
244
+
245
+ export function decodeBotCreateInputV1(input: unknown): BotCreateInputV1 {
246
+ const value = fields(input, ["name", "description"]);
247
+ const name = patchText(value.name, "name", 100);
248
+ if (!name) throw new Error("name must not be empty");
249
+ return {
250
+ name,
251
+ ...(value.description === undefined
252
+ ? {}
253
+ : {
254
+ description: (() => {
255
+ const description = patchText(
256
+ value.description,
257
+ "description",
258
+ 10_000,
259
+ );
260
+ if (!description) {
261
+ throw new Error("description must not be empty");
262
+ }
263
+ return description;
264
+ })(),
265
+ }),
266
+ };
267
+ }
268
+
269
+ function refusal(reason: string): ToolExecutionResult {
270
+ return { content: reason, isError: true };
271
+ }
272
+
273
+ /** Two profiles are the same durable record when their fields all match. */
274
+ function sameProfile(left: BotProfile, right: BotProfile): boolean {
275
+ return (
276
+ JSON.stringify(canonicalProfile(left)) ===
277
+ JSON.stringify(canonicalProfile(right))
278
+ );
279
+ }
280
+
281
+ function canonicalProfile(profile: BotProfile): unknown[] {
282
+ return [
283
+ profile.name,
284
+ profile.description ?? null,
285
+ profile.title ?? null,
286
+ profile.namedBy ?? null,
287
+ profile.hiddenFromSidebar === true,
288
+ ];
289
+ }
290
+
291
+ async function sha256HexV1(text: string): Promise<string> {
292
+ const digest = await crypto.subtle.digest(
293
+ "SHA-256",
294
+ new TextEncoder().encode(text),
295
+ );
296
+ return [...new Uint8Array(digest)]
297
+ .map((byte) => byte.toString(16).padStart(2, "0"))
298
+ .join("");
299
+ }
300
+
301
+ /**
302
+ * The Bot id one `bot_create` occurrence asks for.
303
+ *
304
+ * Derived from the User, the calling Bot and the durable tool-call occurrence,
305
+ * so the same call always asks for the same id and a replay after eviction
306
+ * collides with the Bot it already made instead of registering another. The
307
+ * readable half is the requested name, exactly as the sidebar's create does.
308
+ */
309
+ export async function createdBotIdV1(
310
+ owner: FlockSelfOwnerV1,
311
+ effectId: string,
312
+ name: string,
313
+ ): Promise<string> {
314
+ const base =
315
+ name
316
+ .toLowerCase()
317
+ .normalize("NFKD")
318
+ .replace(/[^a-z0-9]+/g, "-")
319
+ .replace(/^-|-$/g, "")
320
+ .slice(0, 80) || "bot";
321
+ const digest = await sha256HexV1(`${owner.userId}${owner.botId}${effectId}`);
322
+ return `${base}-${digest.slice(0, 12)}`;
323
+ }
324
+
325
+ /** A `commandId` derived from the occurrence, so a retry reuses one receipt. */
326
+ function occurrenceCommandIdV1(prefix: string, effectId: string): string {
327
+ return `${prefix}-${effectId.replace(/[^a-zA-Z0-9._-]/g, "-")}`;
328
+ }
329
+
330
+ export function createBotUpdateTool(
331
+ host: FlockSelfRuntimeHostV1,
332
+ ): ToolDefinition {
333
+ return {
334
+ name: "bot_update",
335
+ description:
336
+ "Change your own name, description, title, sidebar visibility, or update notifications. Only the fields you pass change; everything else stays exactly as it is. Renaming yourself is announced in the conversation. This cannot archive or delete you — only your User can do that.",
337
+ inputSchema: BOT_UPDATE_SCHEMA as unknown as Record<string, unknown>,
338
+ // Re-running converges on the same durable record and commands nothing
339
+ // once it already holds, so recovery may replay it.
340
+ idempotent: true,
341
+ validate: (input) => {
342
+ try {
343
+ decodeBotUpdateInputV1(input);
344
+ return true;
345
+ } catch {
346
+ return false;
347
+ }
348
+ },
349
+ execute: async (input: unknown) => {
350
+ let decoded: BotUpdateInputV1;
351
+ try {
352
+ decoded = decodeBotUpdateInputV1(input);
353
+ } catch (error) {
354
+ return refusal(
355
+ `bot_update was refused: ${error instanceof Error ? error.message : String(error)}`,
356
+ );
357
+ }
358
+ const changed: string[] = [];
359
+ try {
360
+ let settings = await host.readSelf();
361
+ const target = applyBotProfilePatchV1(
362
+ settings.profile,
363
+ decoded.profile,
364
+ "bot",
365
+ );
366
+ if (!sameProfile(settings.profile, target)) {
367
+ const renamed = target.name !== settings.profile.name;
368
+ settings = await applySelfProfileV1(host, settings, decoded.profile);
369
+ for (const [patchKey, toolKey] of PROFILE_FIELD_NAMES) {
370
+ if (decoded.profile[patchKey] === undefined) continue;
371
+ if (patchKey === "name" && !renamed) continue;
372
+ changed.push(toolKey);
373
+ }
374
+ }
375
+ if (
376
+ decoded.notifyOnUpdates !== undefined &&
377
+ decoded.notifyOnUpdates !== settings.notifications.enabled
378
+ ) {
379
+ await applyWithRevisionV1(host, settings, (revision) => ({
380
+ schemaVersion: 1,
381
+ type: "bot/update-notifications",
382
+ commandId: crypto.randomUUID(),
383
+ expectedRevision: revision,
384
+ botId: host.owner.botId,
385
+ notifications: { enabled: decoded.notifyOnUpdates! },
386
+ }));
387
+ changed.push("notify_on_updates");
388
+ }
389
+ } catch (error) {
390
+ return refusal(
391
+ `bot_update failed: ${error instanceof Error ? error.message : String(error)}`,
392
+ );
393
+ }
394
+ if (changed.length === 0) {
395
+ return {
396
+ content:
397
+ "Nothing changed: your profile already holds every value you asked for.",
398
+ isError: false,
399
+ };
400
+ }
401
+ return {
402
+ content: `Updated ${changed.join(", ")}. Everything else is unchanged.`,
403
+ isError: false,
404
+ };
405
+ },
406
+ };
407
+ }
408
+
409
+ /**
410
+ * Issues one Bot-scoped command, re-reading the revision when an unrelated
411
+ * write wins the race. The receipt is the authority for the outcome: a
412
+ * rejection is a refusal, not a thrown error.
413
+ */
414
+ async function applyWithRevisionV1(
415
+ host: FlockSelfRuntimeHostV1,
416
+ settings: BotSettingsViewV1,
417
+ build: (
418
+ revision: number,
419
+ ) => Extract<ConfigurationCommandV1, { botId: string }>,
420
+ ): Promise<BotSettingsViewV1> {
421
+ let current = settings;
422
+ for (let attempt = 0; ; attempt += 1) {
423
+ try {
424
+ const receipt = await host.commandSelf(build(current.revision));
425
+ if (receipt.status === "rejected") {
426
+ throw new Error(receipt.failure ?? "the command was rejected");
427
+ }
428
+ return host.readSelf();
429
+ } catch (error) {
430
+ const conflict =
431
+ error instanceof ConfigurationConflictError ||
432
+ (typeof error === "object" &&
433
+ error !== null &&
434
+ "name" in error &&
435
+ error.name === "ConfigurationConflictError");
436
+ if (!conflict || attempt >= REVISION_RETRIES) throw error;
437
+ current = await host.readSelf();
438
+ }
439
+ }
440
+ }
441
+
442
+ /** The profile half of `bot_update`, with its provenance attached. */
443
+ async function applySelfProfileV1(
444
+ host: FlockSelfRuntimeHostV1,
445
+ settings: BotSettingsViewV1,
446
+ profile: BotProfilePatchV1,
447
+ ): Promise<BotSettingsViewV1> {
448
+ return applyWithRevisionV1(host, settings, (revision) => ({
449
+ schemaVersion: 1,
450
+ type: "bot/set-profile",
451
+ commandId: crypto.randomUUID(),
452
+ expectedRevision: revision,
453
+ botId: host.owner.botId,
454
+ // The provenance of a self-rename: `namedBy` says a Bot did it, and the
455
+ // writer says which Bot, in which Session and Turn.
456
+ namedBy: "bot",
457
+ writer: host.writer,
458
+ profile: structuredClone(profile),
459
+ }));
460
+ }
461
+
462
+ export function createBotCreateTool(
463
+ host: FlockSelfRuntimeHostV1,
464
+ random?: () => number,
465
+ ): ToolDefinition {
466
+ return {
467
+ name: "bot_create",
468
+ description:
469
+ "Create a new Bot in your User's flock, with a name and an optional description that becomes its profile. It starts with no capabilities of its own beyond what your User's own create gives a new Bot, and follows your User's default model. There is no matching delete: only your User can remove a Bot.",
470
+ inputSchema: BOT_CREATE_SCHEMA as unknown as Record<string, unknown>,
471
+ // The requested Bot id is derived from the occurrence, so a replay asks
472
+ // for a Bot that already exists and reports it instead of making another.
473
+ idempotent: true,
474
+ validate: (input) => {
475
+ try {
476
+ decodeBotCreateInputV1(input);
477
+ return true;
478
+ } catch {
479
+ return false;
480
+ }
481
+ },
482
+ execute: async (input: unknown, context: ToolExecutionContext) => {
483
+ let decoded: BotCreateInputV1;
484
+ try {
485
+ decoded = decodeBotCreateInputV1(input);
486
+ } catch (error) {
487
+ return refusal(
488
+ `bot_create was refused: ${error instanceof Error ? error.message : String(error)}`,
489
+ );
490
+ }
491
+ const botId = await createdBotIdV1(
492
+ host.owner,
493
+ context.effectId,
494
+ decoded.name,
495
+ );
496
+ const sheep = randomSheepRecipeV1(random);
497
+ try {
498
+ for (let attempt = 0; ; attempt += 1) {
499
+ const directory = await host.listBots();
500
+ // The occurrence-derived id is the fence. A replay of this exact
501
+ // call finds the Bot it already registered and stops here.
502
+ if (directory.bots.some((bot) => bot.botId === botId)) {
503
+ return {
504
+ content: `Bot "${decoded.name}" already exists as ${botId}; nothing was created a second time.`,
505
+ isError: false,
506
+ };
507
+ }
508
+ try {
509
+ const receipt = await host.createBot({
510
+ schemaVersion: 1,
511
+ type: "bot/create",
512
+ commandId: occurrenceCommandIdV1("bot-create", context.effectId),
513
+ expectedRevision: directory.revision,
514
+ botId,
515
+ name: decoded.name,
516
+ ...(decoded.description === undefined
517
+ ? {}
518
+ : { description: decoded.description }),
519
+ createdBy: host.writer,
520
+ sheep,
521
+ });
522
+ if (receipt.status === "rejected") {
523
+ return refusal(
524
+ `bot_create was rejected: ${receipt.failure ?? "the Flock refused it"}`,
525
+ );
526
+ }
527
+ return {
528
+ content: `Created Bot "${decoded.name}" as ${botId}. It follows your User's default model and holds no capabilities of its own.`,
529
+ isError: false,
530
+ };
531
+ } catch (error) {
532
+ const conflict =
533
+ error instanceof FlockConflictError ||
534
+ (typeof error === "object" &&
535
+ error !== null &&
536
+ "name" in error &&
537
+ error.name === "FlockConflictError");
538
+ if (!conflict || attempt >= REVISION_RETRIES) throw error;
539
+ }
540
+ }
541
+ } catch (error) {
542
+ return refusal(
543
+ `bot_create failed: ${error instanceof Error ? error.message : String(error)}`,
544
+ );
545
+ }
546
+ },
547
+ };
548
+ }
549
+
550
+ /**
551
+ * The runtime Contribution. Registers the two self-management tools; both are
552
+ * work tools, offered on every turn type, because an automation or a subagent
553
+ * Turn is as entitled to correct its own title as a chat Turn is.
554
+ */
555
+ export function createFlockRuntimePlugin(
556
+ host: FlockSelfRuntimeHostV1,
557
+ ): Plugin.Function {
558
+ const plugin: Plugin.Function = (ctx) => {
559
+ const disposers = [
560
+ ctx.tools.register(createBotUpdateTool(host)),
561
+ ctx.tools.register(createBotCreateTool(host)),
562
+ ];
563
+ return () => {
564
+ for (const dispose of disposers.toReversed()) dispose();
565
+ };
566
+ };
567
+ plugin.inject = ["tools"];
568
+ return plugin;
569
+ }
570
+
571
+ export default createFlockRuntimePlugin;
@@ -0,0 +1,31 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { CryptoHasher } from "bun";
3
+ import manifest from "../assets/manifest.json" with { type: "json" };
4
+
5
+ describe("production sheep assets", () => {
6
+ test("contains the approved bounded collection with exact hashes", async () => {
7
+ expect(manifest.assets).toHaveLength(50);
8
+ expect(
9
+ manifest.assets.filter((item) => item.kind === "background"),
10
+ ).toHaveLength(6);
11
+ expect(
12
+ manifest.assets.filter((item) => item.kind === "upper"),
13
+ ).toHaveLength(23);
14
+ expect(
15
+ manifest.assets.filter((item) => item.kind === "middle"),
16
+ ).toHaveLength(13);
17
+ expect(
18
+ manifest.assets.filter((item) => item.kind === "lower"),
19
+ ).toHaveLength(7);
20
+ for (const asset of manifest.assets) {
21
+ expect([asset.width, asset.height]).toEqual([256, 256]);
22
+ const bytes = await Bun.file(
23
+ new URL(`../assets/${asset.file}`, import.meta.url),
24
+ ).arrayBuffer();
25
+ expect(new TextDecoder().decode(bytes.slice(0, 4))).toBe("RIFF");
26
+ expect(new CryptoHasher("sha256").update(bytes).digest("hex")).toBe(
27
+ asset.sha256,
28
+ );
29
+ }
30
+ });
31
+ });